Ребейз ветки на текущий gitea/develop (устраняет mergeable:false).
Конфликты разрешены вручную:
- editor-atoms.ts: сохранён type-only импорт Editor (сплит-код с develop),
добавлен import type HocuspocusProvider из #370.
- collaboration/constants.ts: оставлен EMBED_DEBOUNCE_MS (embed-дебаунс develop),
убраны более не используемые HISTORY_* (их единственный потребитель — старая
эвристика computeHistoryJob — удалён в #370), добавлены idle-константы и
PageHistoryKind.
- persistence.extension.ts: 3-way смёржены метрики/#348/#402 develop поверх
idle-конвейера #370; computeHistoryJob остаётся idle-версией без остатков.
Миграция переименована 20260705T120000 -> 20260707T120000 (класс #361):
таймстамп был занят perf-indexes на develop; новый строго позже свежайшей
на develop (20260706T120000-search-lookup-trgm). Содержимое не менялось,
внешних ссылок на имя файла нет (Kysely находит по директории).
Документирование (findings 1-2):
- remove-vs-active гонка в idiome remove()->add() enqueuePageHistory: окно,
где отложенная job уходит в active между remove (проглатывается на active) и
add (BullMQ отбрасывает add с существующим jobId); ограничено и
самовосстанавливается (следующий store перевзводит), кроме худшего случая —
гонящий store был ПОСЛЕДНИМ в сессии: хвостовые правки без trailing-снапшота
до следующей правки. Явно указано, почему нельзя «унифицировать» с соседним
embed-дебаунсом (стабильный jobId, без remove).
- допущение single-process у Map idleBurstStart: в памяти процесса, рестарт
collab теряет метки начала всплеска -> непрерывный всплеск через рестарт может
ждать до 2x cap. Ограничено и безопасно.
Тест (finding 3): интеграционный тест idle-конвейера против реального BullMQ
(короткие интервалы через jest.mock констант): непрерывный всплеск в неск. cap
-> периодические idle-снапшоты не реже cap и не по одному на store;
прерывистый всплеск -> ровно один trailing-снапшот. Жёсткий teardown
(force-close + settle), чтобы фоновый BullMQ не влиял на соседние suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The round-1 F3 fix (wrapping the processor's find+save in a locked tx) itself
introduced two regressions:
F7 [CRITICAL] addPageWatchers ran WITHOUT trx inside the tx holding FOR UPDATE on
pages[pageId]. The watcher insert's FK check takes FOR KEY SHARE on the same row,
but on a DIFFERENT pool connection — a true self-deadlock (our tx connection sits
idle-in-transaction awaiting the JS await, the insert connection blocks on the
lock). Now passes trx (addPageWatchers already accepts it and routes it through
insertMany), so the FK lock is taken on the connection that already holds FOR
UPDATE — no self-conflict.
F8 [WARNING] popContributors is a destructive Redis SPOP; the inner catch only
restores on a throw INSIDE the callback. A COMMIT failure throws OUTSIDE it,
rolling the snapshot back while the pop is gone → a retry writes an unattributed
version. Now tracks the popped set and restores it in an outer catch (idempotent
SADD), leaving BullMQ to retry with attribution intact.
F9 [WARNING] The spec asserted saveHistory args with a loosened objectContaining
that stopped verifying trx, and never pinned withLock/trx on findById or the trx
on addPageWatchers — which is exactly why F7 slipped. Restored the exact
saveHistory(trx) assertion and added findById({withLock,trx}) + addPageWatchers
trx assertions (the latter would have caught F7), plus a commit-failure test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 [BLOCKER] persistence-store.spec used Array.prototype.at(-1) (ES2022) but the
server targets ES2021, so server tsc failed (TS2550) and ts-jest could not
compile the suite — 22 core manual-save/idle/boundary tests silently did not run
in CI. Replaced with [length - 1] index access.
F2 [WARNING] The idle burst-reset used a hardcoded IDLE_MAX_WAIT_USER for both
tiers, but computeHistoryJob's ceiling is source-specific. On a continuously
agent-edited page the burst marker stayed stale for 5..10m, forcing delay=0 on
every store and writing one idle row per store — the exact per-store bloat the
debounce prevents. The reset now uses the same source-specific max-wait.
F3 [WARNING] The processor did an unlocked findPageLastHistory -> saveHistory,
which TOCTOU-races a concurrent manual-save (that runs under a page-row lock),
producing two page_history rows with identical content (one idle, one manual) and
defeating promote-not-dup. The snapshot decision is now wrapped in executeTx with
the same page-row lock, so the second writer observes the first's committed row
and the isDeepStrictEqual gate collapses the duplicate.
F4 [WARNING] The risky client filtered-index -> full-list mapping had no tests.
Extracted it to a pure resolvePrevSnapshotId(fullItems, id) helper (diff/restore
baseline against the true previous snapshot in the FULL list, never the previous
visible version) and unit-tested it; removed the now-vestigial index threading.
F5/F6 [low] Renamed the misleading ceiling test + fixed its comment; added a
CHANGELOG entry for the user-facing versioning feature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-1 'core' of #370: introduces page_history.kind ('manual'|'agent'|'idle'|
'boundary'; legacy null = autosave) and rebuilds the snapshot triggers around a
three-tier intentionality model. Draft durability (pages/ydoc hocuspocus
autosave) is unchanged; only the frequency and labelling of history points change.
- Migration 20260705T120000: page_history.kind nullable varchar(20), no default.
- Manual Save: one stateless 'save-version' path for human AND agent; kind is
derived SERVER-SIDE from the signed context.actor (never the payload), readOnly
connections rejected, the fresh ydoc runs through the existing store path (no
REST race), then broadcasts version.saved.
- Idle-flush: trailing debounce (one BullMQ job per page, remove-then-readd) with
IDLE_INTERVAL_USER=60m / AGENT=15m AND a max-wait ceiling
(IDLE_MAX_WAIT_USER=10m / AGENT=5m) so a continuous editing session can't starve
the autosnapshot (review round-1 WARNING).
- Boundary: generalized from the user→agent special-case to ANY lastUpdatedSource
transition (user↔agent↔git), same isDeepStrictEqual gate — covers git-sync free.
- Removed the agent delay=0 fast path and the old HISTORY_FAST_* constants; the
agent joins the common idle pipeline.
- Promote-not-dup: a manual save on unchanged content promotes the latest
autosave's kind in place (or no-ops if already manual) instead of duplicating a
heavy content row.
- Client: mod+S hotkey + menu button (hidden when readOnly), history-panel kind
badges, dimmed autosaves, a 'versions only' filter (indices map to the full
list so diff/restore still target the true previous snapshot), live refresh on
version.saved.
Internal review: APPROVE-with-suggestions; the round-1 WARNING (idle starvation)
is fixed here via the max-wait ceiling, and the generalized-boundary + ceiling
behaviours are pinned with new tests (115 collab/repo specs green, server tsc 0).
Deferred to later PRs: shares.published_mode (PR-2), the save_page_version MCP
tool + role prompts (PR-3), actor='git' wiring into #359 (PR-4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Правки по 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>
The #443 merge added getTree and getPageContext to the shared registry
(packages/mcp/src/tool-specs.ts) with tier 'core', but the server-side
authoritative core list CORE_TOOL_KEYS was never updated. Two guard tests
in tool-tiers.spec.ts failed on develop (CI job test / mcp-server-parity):
tier agreement SHARED_TOOL_SPECS <-> CORE_TOOL_SET, and the live-toolset
<-> deferred-catalog partition (both tools were live, non-core and absent
from the catalog).
- add 'getTree' and 'getPageContext' to CORE_TOOL_KEYS (kept core, not
demoted: cheap single-call navigation tools; core listPages description
itself points to getTree)
- update the CORE_TOOL_KEYS JSDoc accordingly
- pin the first tool-tiers spec to the new 17-entry list with membership
assertions for both #443 tools
Co-Authored-By: Claude Fable 5 <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>
Ревьюер поймал EXPLAIN'ом: lookup-query фильтровал по
LOWER(f_unaccent(coalesce(col,''))), а GIN-trgm-индексы — на coalesce-free
LOWER(f_unaccent(col)). Postgres берёт функциональный индекс только при точном
совпадении выражения, так что обёртка coalesce отключала индекс → Seq Scan по
pages на КАЖДЫЙ lookup (MCP-клиент всегда шлёт substring:true). Зелёный
int-гейт это не ловил (проверял корректность на крошечном датасете, не
использование индекса).
- search.service.ts: убрал coalesce из двух index-driving LIKE-предикатов
(title + text_content). Семантически эквивалентно: NULL LIKE '%q%' = NULL
(falsy), NULL-страница просто не матчится — как пустая строка не матчит %q%.
SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL).
- Миграция: убран избыточный ре-ассерт idx_pages_title_trgm — его создаёт #348
на том же coalesce-free выражении, теперь title-предикат его использует.
idx_pages_text_content_trgm без изменений (уже coalesce-free), поправлены
вводящие в заблуждение комментарии. down() дропает только text-индекс.
- Новый тест search-lookup-explain.int-spec.ts (3): title→idx_pages_title_trgm,
text→idx_pages_text_content_trgm (оба ассертят отсутствие Seq Scan on pages),
+ негативный контроль (coalesce-обёртка не может использовать индекс) против
тихой ре-регрессии. Дискриминатор enable_seqscan=off.
- CHANGELOG: две записи (opt-in substring lookup mode + смена shape MCP search).
EXPLAIN на реальном PG: обе fixed-ветки → Bitmap Index Scan on trgm; buggy
coalesce → Seq Scan (Disabled:true). Гейт: server jest src/core/search 27/27
(16 исходных int без изменений + 3 EXPLAIN + 8 unit), mcp node --test 708/708,
tsc чисто.
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>
Канонический конвертер (#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>
Чинит два падения прогона Develop (#29104398390):
A. Интеграционные тесты сервера (10 failed) падали с
`TypeError: this.environment.isAiChatFinalStepLockdownEnabled is not a function`.
Мерж #444 добавил вызов этого метода в AiChatService.stream, но два
интеграционных фикстура не обновили env-стаб. Добавлен
`isAiChatFinalStepLockdownEnabled: () => false` в 4 стаба
(ai-chat-attach.int-spec.ts, ai-chat-stream.int-spec.ts).
B. Job e2e-server не компилировал app.e2e-spec.ts:
`TS2307: Cannot find module '@docmost/mcp'`. Рефактор #446 добавил
тип-импорт из @docmost/mcp в docmost-client.loader.ts, но job не
собирал этот пакет (его build/ в gitignore). Добавлен шаг
`Build mcp` в e2e-server (по образцу e2e-mcp / mcp-server-parity).
Только тесты и CI-конфиг; продакшн-логика не менялась.
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>
In-app хендлеры drawio_create/drawio_update деструктурировали args БЕЗ layout и
не передавали его клиенту 5-м аргументом → layout:"elk" (схема его принимает —
общий buildShape) ТИХО терялся, ELK-автолейаут работал только по MCP-хосту.
Корень: ручное зеркало DocmostClientLike (loader) отстало от реального client.ts
— у его drawioCreate/drawioUpdate не было параметра layout (то, что #446 чинит
деривацией типа, но #446 ещё не влит). Добавил layout?:'elk' в обе сигнатуры
зеркала + проброс в обоих хендлерах.
Тест (пропущенный зелёным гейтом пробел — не было теста на in-app passthrough):
in-app drawioCreate/drawioUpdate с layout:'elk' → фейк-клиент получает layout
5-м позиционным аргументом; omit-кейс → undefined. Мутационно: убрать проброс
в drawioCreate → layout-create-тест краснеет.
Гейт: mcp build чисто; tsc -p apps/server без новых ошибок; jest
ai-chat-tools.service (35) + shared-tool-specs.contract + tool-tiers +
comment-signal-inapp → 273 passed.
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>
Финальный линк Фазы 1б. Инвентарь тулов жил в 4 рукописных прозаических копиях
(SERVER_INSTRUCTIONS под regex-тестом; <tool_catalog>; имена в ai-chat.prompt.ts
без гарда; README) — роадмап #416 планировал 4 последовательных ручных правки
этого текста (#411/#412/#413/#415).
- SERVER_INSTRUCTIONS разбит (новый модуль server-instructions.ts): ROUTING_
PROSE (рукописные intent-подсказки «когда что» — осмысленно ручные, перенесены
ДОСЛОВНО со всеми предостережениями: <=250 у create_comment, soft-delete у
delete_page, baseHash у drawio_update, PUBLIC у share_page) + buildToolInventory()
— генерирует <tool_inventory> из реестра (mcpName + purpose из catalogLine,
группировка по TOOL_FAMILY, бакет OTHER ловит незамаппленное → тул нельзя
тихо потерять) + 5 inline MCP-only (INLINE_MCP_INVENTORY). Детерминирован
(семейства FAMILY_ORDER, имена localeCompare). regex-тест server-instructions
удалён; структурные гарантии — в новом tool-inventory.test.mjs (точное
членство множества сильнее старого \b-скрейпа).
- Имена тулов в ai-chat.prompt.ts → через экспорт PROMPT_TOOL_NAMES; новый гард
ai-chat.prompt.tool-names.spec.ts: каждое имя — реальный тул реестра, скан
guidance-нот на camelCase-токены падает на несуществующем (escape-
нейтрализация против ложных nThe-токенов).
- INLINE_TOOL_TIERS уже содержал ровно 8 genuinely-inline тулов (после #445) —
сжатие не потребовалось.
Критерий: добавление/переименование спека меняет инвентарь БЕЗ правки прозы.
Внутреннее ревью: APPROVE — фактическим прогоном подтверждено, что НИ ОДИН тул
из старого SERVER_INSTRUCTIONS не выпал (диф старый-vs-новый пуст; добавился
get_workspace, раньше прятавшийся в EXCEPTIONS); проза дословна; инвентарь
полон/детерминирован/без фантомов; гард краснеет на обеих ветках провала.
613 node + 289 jest зелёные. Стоит на #445 — мержить последним в стопке 1б.
README-каталоги вне обязательного скоупа (docs-скрипт) — в чек-лист #412.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ядро Фазы 1б. Реестр (#294) шарил только метаданные (имя/схема/описание/tier),
но НЕ execute-логику — у каждого shared-тула было ДВА рукописных execute-тела
с копией маппинга аргументов (MCP registerShared в index.ts; in-app sharedTool
в ai-chat-tools.service, зеркалящий MCP-транспорт вручную). Корень
повторяющихся parity-багов (f46d89ea drawio, f8d26420 stashPage, fc9088b7
node-args): добавление одного тула = 7-9 согласованных ручных правок в двух
пакетах.
- SharedToolSpec расширен: канонический execute(client, args) (чистый JS —
свободно пересекает zod-мажорную границу v3/v4) + оверрайды
mcpExecute/inAppExecute/mcpOnly/inAppOnly для ОСОЗНАННЫХ per-layer различий.
client: DocmostClientLike (Pick из #446). Канон возвращает СЫРЬЁ, каждый хост
накладывает свой конверт (MCP jsonContent, in-app как есть); override владеет
результатом хоста целиком.
- Оба хоста → циклы по Object.values(SHARED_TOOL_SPECS): index.ts registerShared
39→0 (цикл), ai-chat-tools.service sharedTool ~40→1 (цикл). Добавление спека
автоматически регистрирует тул в ОБОИХ хостах — сценарий PR #434 невозможен
по построению.
- Осознанные различия через overrides (ни одно не сплющено к одному хосту):
оба mcpExecute+inAppExecute — createPage/movePage/deletePage/
exportPageMarkdown/createComment (guardrails, конверты, проекции, тексты
ошибок); execute+inAppExecute — getPage/renamePage/resolveComment;
execute+mcpExecute — stashPage (resource_link+structuredContent),
checkNewComments (since-guard только на MCP).
- Оставлены inline (по делу): update_comment/delete_comment (MCP-only, in-app
не даёт хард-правку/удаление комментов), search/transformPage (per-transport
дивергенция — hybrid RRF / без deleteComments), table_get (noun-vs-verb
naming clash — уедет после camelCase #412), getCurrentPage/updatePageContent/
listSidebarPages/getComment/getPageHistory (in-app-only, per-request state).
- Guard-тесты (contract-parity, phantom-catalog) сохранены — теперь инварианты,
не «последняя линия».
Внутреннее ревью: APPROVE, построчная BEFORE/AFTER-сверка по каждому shared-тулу
на обоих хостах — ни одного изменённого per-host поведения (порядок/дефолты
аргументов, guard'ы, конверты, проекции сохранены), множества тулов побайтово
совпадают (48 in-app, 45 MCP), кросс-zod-граница чистая (нет z. в execute),
611 mcp + 260 server тестов зелёные. Ядро Фазы 1б, стоит на #446 — мержить после.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Восстановленный отложенный долг #294: @docmost/mcp не отдавал .d.ts, поэтому в
сервере жили ТРИ дрейфующие ручные копии одних и тех же имён/сигнатур
(DocmostClientLike ~230 строк, копия SharedToolSpec, name-only HOST_CONTRACT_
METHODS-тест). In-app execute-тела зовут клиент ПОЗИЦИОННО, так что перестановка
параметра в client.ts доезжала до прода рантайм-ошибкой без сигнала на компиляции.
- declaration:true (+declarationMap) в packages/mcp/tsconfig.json; types-экспорт
в package.json (exports → conditional {types, default} для . и ./http;
require.resolve/dynamic-import резолвят default → build/index.js, рантайм не
тронут). build/index.d.ts эмитится, реэкспортит DocmostClient + SharedToolSpec.
Правок исходников пакета для эмита НЕ потребовалось.
- DocmostClientLike → Pick<DocmostClient, 48 методов> из type-only import
(стёрт на компиляции, ESM/CJS-границу не задевает); ручное зеркало удалено.
- SharedToolSpec → type-only реэкспорт из пакета; ручная копия удалена.
- client-host-contract.test.mjs удалён целиком — имена И сигнатуры теперь
проверяет tsc.
- Позиционная безопасность: never-called __assertClientCallContract(client:
DocmostClientLike) воспроизводит каждый позиционный вызов с типизированными
плейсхолдерами (AI-SDK стирает вход execute-замыканий в any, иначе позиционные
вызовы не проверялись). Перестановка параметров client.ts → ошибка компиляции
сервера ровно тут. Loose as-касты в ai-chat-tools.service не потребовали
правок; as any не добавлялся.
Внутреннее ревью: APPROVE. Runtime resolution через conditional exports не сломан
(разобрано для прод-инсталляции, не только symlink); покрытие
__assertClientCallContract полное (48 call-sites == union == assert, сверено
программно); Pick полон; демонстрация reorder → TS2345 в assert. Единственная
находка (Promise<any> в части возвратов) предсуществующая в client.ts, вне
цели PR. Стоит на #447 (закрытие skew build/vs/src) — мержить после него.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Два структурных слепых пятна: (1) сервер грузит СКОМПИЛИРОВАННЫЙ
packages/mcp/build/index.js, а серверные guard-тесты читают src/tool-specs.ts —
правка src без пересборки оставляет тесты зелёными, но рантайм расходится со
спеками; (2) спеки добавляют в packages/mcp, а parity-тесты живут в jest-сьюте
apps/server — PR, трогающий только пакет, проходит зелёным, сломанная in-app-
проводка всплывает уже на develop (кейс f46d89ea).
- REGISTRY_STAMP: codegen (scripts/gen-registry-stamp.mjs) считает sha256 от
нормализованного (CRLF→LF, один хвостовой \n снят) сырого текста
src/tool-specs.ts, пишет src/registry-stamp.generated.ts (gitignored),
index.ts реэкспортит → попадает в build/. Вшит в build/pretest/watch ДО tsc.
- Loader (dev/test): computeSrcRegistryStamp пересчитывает стамп из src рядом с
build/index.js (dev-vs-prod по existsSync, любая ошибка → null), сверяет с
build-стампом → при рассинхроне бросает «build is stale — rebuild». В prod
(src нет) и на pre-#447 билдах (нет REGISTRY_STAMP) — чистый no-op.
- CI: job mcp-server-parity собирает shared-deps+mcp (регенерит стамп) и гоняет
ОБА сьюта вместе (mcp node:test + server guard-спеки) — именованный гейт, его
нельзя случайно расщепить.
- AGENTS.md: правка спеков требует ребилда @docmost/mcp.
Тесты (20): mcp-сайд (детерминизм, нормализация, desync-гард стамп-vs-билд) +
server-сайд (null при отсутствии src = prod no-op; mismatch → throw точного
сообщения; pre-#447 no-op). Кросс-импл equality-гард: один фиксированный вход →
один хэш на ОБЕИХ сторонах, ловит рассинхрон двух нормализаций. Внутреннее
ревью: APPROVE WITH SUGGESTIONS (обе — покрытие guard'а — закрыты этим тестом).
Мутационно: любой из двух normalize-имплов расходится → equality-тест краснеет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Хэшированные ассеты отдавали 'cache-control: public, max-age=0' вместо
'public, max-age=31536000, immutable' → повторные заходы ревалидировали каждый
ассет (десятки 304 × мобильный RTT), главный выигрыш #346 не реализовывался.
Причина: у @fastify/static опция cacheControl:true по умолчанию пишет свой
Cache-Control (из maxAge, дефолт 0) ПОСЛЕ setHeaders-колбэка, затирая immutable-
заголовок из resolveStaticAssetHeaders. Фикс — cacheControl:false, колбэк
владеет заголовком. preCompressed не конфликтовал, потому баг был только в
заголовках.
Крайние случаи проверены: locales/vad/иконки получают только vary (без
cache-control → браузер ревалидирует по etag — ок); index.html отдаётся
отдельным wildcard-роутом со своим no-cache (не затронут); preCompressed .br
получает путь с /assets/ → маппинг матчит, immutable ставится.
Тест: bare-fastify + inject() — /assets/<hashed>.js содержит immutable+
max-age=31536000, /locales/en.json — нет. Мутационно: cacheControl:true роняет
ассерт immutable. jest static.module → 5/5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Третий класс петли (инцидент 2026-07-10): ран упёрся в 20-шаговый кап, спалив
все шаги на чтение; на 20-м шаге final-step lockdown отнял инструменты
(toolChoice:'none') посреди незаконченной работы → модель выродилась в
текст-повтор («loadTools.» ×20416, 255КБ). Пакет защит по дизайну владельца:
- MAX_AGENT_STEPS 20→50; спеки выводятся из константы (нет захардкоженных 19/20).
- Final-step lockdown под env-тогглом AI_CHAT_FINAL_STEP_LOCKDOWN (дефолт OFF,
по образцу AI_CHAT_DEFERRED_TOOLS): OFF — инструменты доступны на всех шагах +
мягкий финальный нудж; ON — легаси toolChoice:'none'+FINAL_STEP_INSTRUCTION.
Спеки параметризованы по тогглу. .env.example задокументирован.
- Пустой ход (все шаги без текста, шаги исчерпаны) получает синтетический
маркер-текст — виден в UI и реплее.
- Детектор токен-деградации в onChunk (единственная защита от болтовни, БЕЗ
maxOutputTokens — tool-аргументы это выходные токены): чистые правила
(≥25 одинаковых строк ИЛИ периодический хвост), при срабатывании abort через
внутренний AbortController ∪ effectiveSignal (AbortSignal.any), финализация в
onAbort: усечение хвоста, ai_chat_runs.error=Output degeneration detected,
лизы MCP/снапшоты освобождаются (существующий lifecycle).
- Предупреждение о бюджете шагов на MAX-6…MAX-2 с убывающим N.
- loadTools-описание и преамбула каталога явно говорят, что CORE-тулы всегда
активны (список из CORE_TOOL_KEYS динамически).
Внутренний цикл: 2 прохода. Ревью нашло data-loss-риск: правило периодичности
детектора ложно срабатывало на markdown-разделителях/setext-подчёркиваниях/
хвостовых пробелах (монохар-хвост p-периодичен при ЛЮБОМ p → ложный abort с
пометкой error и усечением). Починка: отдельная монохар-проверка (порог 60,
выше любого реального разделителя) + требование ≥2 различных символов в
периодическом блоке при p≥2. Реальный loadTools-цикл (период ~10) ловится.
Мутационно: 59 одинаковых — не флаг, 60 — флаг; loadTools×20416 — флаг.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve/unresolve enqueue — fire-and-forget (void ...catch(warn)): смысл #399
в том, что недоступность очереди НЕ должна ронять HTTP-запрос. Delete-путь уже
покрыт (enqueue awaited перед hard-delete), а reject resolve-пути — нет. Тест:
generalQueue.add реджектит -> resolveComment всё равно resolves (не throws) +
warn залогирован (ошибка проглочена на микротаске после возврата, поэтому
flushMicrotasks перед ассертом). Мутационно: сделать enqueue awaited без catch
-> тест краснеет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve/unresolve/delete comment-mark синхронно дёргали collab-gateway
(handleYjsEvent) прямо в HTTP-запросе — сетевой раунд-трип к collab на
горячем пути. Теперь:
- DB-строка пишется синхронно (источник истины) с общим таймстампом;
- сама mark-операция уходит идемпотентным COMMENT_MARK_UPDATE-джобом на
GENERAL_QUEUE, воркер проигрывает тот же handleYjsEvent;
- resolve/unresolve — fire-and-forget (best-effort), delete — await энкью
ДО необратимого hard-delete (durability split);
- race-guard: устаревшее ПРОТИВОПОЛОЖНОЕ событие (ts <= updatedAt строки и
состояние расходится) пропускается, а не флипает mark в устаревшее;
- DI-цикл обойдён ленивым moduleRef.get(CollaborationGateway, strict:false).
Внутренний цикл: 2 прохода. Правки по внутреннему ревью: `<` → `<=` в
race-guard (безопасная обработка суб-миллисекундной ничьи двух
противоположных тогглов); задокументирован сознательный компромисс —
транзиентный page.updated-broadcast из воркера несёт только {id}, теряя
name/avatarUrl «кто редактировал» (lastUpdatedById выставляется верно,
косметика, самочинится на следующем реальном редактировании).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #434 (drawio stage 1) added drawioGet/drawioCreate/drawioUpdate to the
shared tool-spec registry with in-app metadata (inAppKey, deferred tier,
catalogLine) but wired them only in the standalone MCP server, breaking the
contract-parity and phantom-catalog unit tests on develop CI.
- expose drawioGet/drawioCreate/drawioUpdate in forUser() via sharedTool(),
mirroring the MCP transport's argument mapping (format ?? 'xml' default,
flat schema regrouped into the client's `where` object, positional
baseHash pass-through)
- extend the DocmostClientLike hand-mirror with the three client methods
- append the three names to the HOST_CONTRACT_METHODS drift-guard whitelist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both are direct consequences of the round-1 F1 fix (uncaching
hasRestrictedPagesInWorkspace):
- F5: that EXISTS(SELECT 1 FROM page_access WHERE workspace_id=?) now runs
per-request on every whole-workspace list endpoint (global search + suggest,
favorites, notifications, recent, created-by), and page_access only had a
space_id index → a seq scan in the common zero-restriction case. Added
idx_page_access_workspace_id to the perf migration (up + down) so it's an
index-only existence probe.
- F6: the DomainMiddleware workspace cache invalidation was untested — the
int-spec passed `{}` for cacheManager, so bustWorkspaceCache's `del` threw into
its own try/catch and never ran. Added a Map-backed cache double with a working
del and two tests: updateSetting busts WORKSPACE_SELF_HOSTED; updateSharingSettings
busts WORKSPACE_SELF_HOSTED + WORKSPACE_BY_HOST(hostname). A missed/mismatched
bust key now fails the suite instead of letting a stale security-relevant
workspace row (enforceSso/status) outlive the mutation.
Gate: server tsc 0; workspace-repo-update-setting + page-permission-workspace-filter
int-specs pass on real Postgres (the new index applies via global-setup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1 [medium — the substantive one]: hasRestrictedPagesInWorkspace is now UNCACHED
(a plain EXISTS per call, like its sibling hasRestrictedPagesInSpace). Caching it
(even 5s) reintroduced an access-control leak the space path never had: a
concurrent whole-workspace read in the insert->commit window of the FIRST
restricted page could re-populate `false` under withCache (read-then-set, no
del-during-read guard) and override the insert-time bust, leaking that page to
unauthorized users for up to the TTL. Uncaching removes both the DB/cache
asymmetry and the TOCTOU race; the space path already accepts this per-call cost.
Reverted the now-unnecessary insertPageAccess cache-bust and removed the dead
HAS_RESTRICTED_PAGES_IN_WORKSPACE cache key.
- F2 [test]: page-permission-workspace-filter.int-spec.ts (real PG) — the
short-circuit returns the full input set with zero restrictions AND filters out
the page the user can't reach when a restriction is present (proving the authz
behavior is unchanged), the 0->1 transition flips immediately, and the flag is
per-workspace scoped.
- F3 [doc]: documented the deploy-time write-lock in the migration header — the
non-CONCURRENT GIN trigram builds take a SHARE lock that blocks writes on
pages/users/… for minutes on a large tenant; run in a maintenance window or
build CONCURRENTLY out-of-band for big installs.
- F4 [doc]: corrected the jwt.strategy comment — the reused req.raw.workspace is
the middleware's selectAll superset (not "the exact row this query returns"),
harmless because AuthWorkspace already preferred that object.
Gate: server tsc 0; the new int-spec 3/3 on real Postgres.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One migration + targeted hot-path fixes. API behavior 1:1 (schema change = added
indexes + a byte-identical f_unaccent function-body swap, see below).
- Trigram + composite indexes (20260705T120000-perf-indexes.ts): GIN trigram on
LOWER(f_unaccent(title/name)) for pages/users/groups (the /search/suggest
leading-wildcard LIKE did a seq scan per keystroke — EXPLAIN now confirms
Bitmap Index Scan on idx_pages_title_trgm), + page_history(page_id,id DESC),
comments(page_id,id). DEVIATION (verified byte-identical): PG18 cannot inline
the two-arg f_unaccent body during index creation, so up() swaps it to the
schema-qualified single-arg `SELECT public.unaccent($1)` — same dictionary,
identical output for all inputs, so the tsvector trigger + main @@ search stay
consistent with NO reindex; down() restores the exact two-arg body.
- Auth path: jwt.strategy reuses req.raw.workspace when workspaceId matches (the
middleware already validated it) instead of re-querying; domain.middleware
caches the workspace lookup (withCache 15s, invalidated in all 8 WorkspaceRepo
mutators, with a Date reviver for the JSON-serialized cache). USER + SESSION
caching DEFERRED — the invalidation surface (role change doesn't revoke
sessions; revocation includes background jobs) can't be safely covered, and a
missed hook on a security path is worse than the win.
- AI re-embed coalescing: aiQueue.add gets {jobId: embed-<id>, delay: 30s} so
active editing collapses to one job (worker reads current page state).
- filterAccessiblePageIds: hasRestrictedPagesInWorkspace short-circuit skips the
recursive-ancestor CTE when a workspace has zero restricted pages (wired from
search/favorites/notifications/recent/created-by). EXISTS on the same pageAccess
table the CTE anti-joins → no false-positive / no access leak. Busts the cache
on insertPageAccess so a 0->1 restricted transition takes effect immediately
(review F1).
- Small: syncTransclusion guarded by a family-node probe (both old+new content, so
the removal path is preserved); mention notifications enqueue only when the set
gained a member; redis maintainLock clears a prior interval (leak fix).
Skipped as risky (flagged): global ValidationPipe transform change; a pool-wide
statement_timeout (would kill long CREATE INDEX migrations on the same pool).
NOTE: kept the trash query's `content` select — the trash UI reads page.content
for its preview modal (review F3, would have regressed).
Gate: server tsc 0; jest page-permission/auth/search/persistence 15 suites pass;
migration up+down+idempotency verified on real PG18 with EXPLAIN confirming index
use. No new deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Побочные находки инцидента #400 (правка большой таблицы через MCP подвешивает всё).
1. Гонка connect-vs-unload в @hocuspocus/server 3.4.4 (вероятный источник 25s
connect-таймаутов): createDocument проверяет loadingDocuments/documents, но НЕ
ждёт unloadingDocuments -> новое соединение может захендшейкаться на умирающий
Document -> redis-sync идёт по пути 'doc не загружен', провайдер висит до
таймаута. Апстрим (main) не починен. pnpm-патч (инфра как у yjs-патча): в начале
createDocument await in-flight unload (обёрнут в try/catch — отклонённый unload
не отравляет открытие, поведение как до патча), в ОБОИХ рантаймах (cjs+esm).
Тест hocuspocus-unload-race: реальный createDocument с засеянным in-flight
unload -> не грузит пока unload не осел; при откате патча тест краснеет.
2. Двойная перекодировка в onLoadDocument (persistence.extension.ts): хук строил
НОВЫЙ Y.Doc и возвращал его -> hocuspocus делал applyUpdate(encodeStateAsUpdate)
ВТОРОЙ раз (315КБ на каждую холодную загрузку); в JSON-ветке результат encode
выбрасывался (мёртвый вызов). Теперь стейт применяется прямо в data.document,
возврат undefined (hocuspocus мержит только при возврате Doc); мёртвый encode
убран. Содержимое документа не меняется — только меньше encode/alloc.
3. isDeepStrictEqual по 84КБ JSON на каждом store: замерил — 1.32мс на 90КБ
(immaterial, <50мс порога; доминируют fromYdoc+encodeStateAsUpdate). Изменений
кода НЕТ по правилу задачи (dirty-флаг только при material).
closes#401
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Аудит при подготовке #413 нашёл дрейфующие дубли между packages/mcp и
packages/prosemirror-markdown. Четыре дедупа (поведение тулов не меняется):
1. node-ops: форк ~960 строк сведён в ОДНУ копию в prosemirror-markdown (живая
mcp-версия — строгое надмножество замороженного #293-seed'а пакета; сверено по
git-истории, новая пакет-копия байт-в-байт == прежней mcp-копии). Barrel-экспорт
полной поверхности; mcp/client.ts/page-search.ts/transforms.ts/collaboration.ts
импортируют из пакета; тесты переехали. node-ops тянет stripInlineMarkdown ->
пакет-локальная text-normalize.ts несёт только этот примитив (mcp-версия —
домен #408; заголовок документирует дубликацию + источник истины).
2. footnote-lex/footnote-analyze (vestigial legacy [^id]: диагностика): сведены к
одному fence-aware предупреждению 'reference-style footnotes -> use ^[...]'
(полезно для класса #410); footnote-lex удалён.
3. footnote-authoring -> примитивы (footnoteContentKey/makeFootnoteDefinition/
generateFootnoteId) перенесены в пакетный footnote.ts, одна реализация конвенции.
4. parse-node-arg -> перенесён в prosemirror-markdown (не mcp: сервер CommonJS не
импортирует ESM-only @docmost/mcp, но нативно импортирует пакет), обе копии
удалены, консьюмеры перенаправлены.
canonicalizeFootnotes/ENFORCEMENT RULE #228 и comment-anchor/json-edit/text-normalize
(mcp) не тронуты. API-поверхность node-ops оставлена чистой для #409/#413.
closes#414
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wrapToolsWithCommentSignal всегда ставил свой toModelOutput, молча выбрасывая
собственный toModelOutput инструмента (латентная ловушка — будущий тул со своим
toModelOutput тихо сломался бы). Теперь база = origToModelOutput(info) при наличии,
иначе воспроизведённый дефолт SDK; no-signal путь возвращает базу дословно, signal-
путь = части базы (modelOutputToParts: text/json/content) + элемент сигнала последним.
execute по-прежнему возвращает СЫРОЙ результат -> part.output/цитаты байт-идентичны.
Дефолтный путь (единственный исполняемый сегодня) байт-идентичен и SDK-дефолту, и
до-фиксовому signal-пути (проверено повторным ревью). json-ветку загардил ?? null
для симметрии с fallback. +2 теста: тул со своим text/content toModelOutput —
база честно сохраняется и в no-signal, и в signal (сигнал добавлен последним).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>