Compare commits

..

77 Commits

Author SHA1 Message Date
agent_coder 3d8bc655eb docs(mcp): точный коммент toolAbortSignal — set-and-leave, не restore (#487, ревью nit)
Внутреннее ревью: 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>
2026-07-11 05:34:46 +03:00
agent_coder b8aac9635b feat(ai-chat): ретраи finalizeAssistant + owner-write приоритет + двусторонний reconcile (#487)
Раньше finalizeAssistant ставил finalized=true ДО записи и не ретраил → один
неудачный UPDATE = строка вечно 'streaming'; свип был boot-only; run-свип
безусловный — асимметрия «run succeeded / message streaming навсегда».

- finalizeAssistant: bounded-ретраи; once-гейт закрывается ТОЛЬКО после успешной
  записи; возвращает ok. Правило owner-write: терминальная запись owner'а
  условна на status='streaming' OR metadata.finalizeFailed (repo.finalizeOwner) —
  перетирает reconcile-штамп, но не проставленный терминал. ВСЕ status-only
  штампы reconcile (stampTerminalIfStreaming, sweepStreaming) пишут строго
  onlyIfStreaming И мёржат metadata.finalizeFailed:true (иначе поздний owner-write
  не перетрёт).
- Порядок: попытка message-finalize → ран финализируется ВСЕГДА; при провале
  message onFinish помечает ран 'error' (не 'completed'). Ран не гейтится на
  message.
- Периодический reconcile-джоб (setInterval, env-tunable) клаузами по порядку:
  (a) пере-драйв зомби; (b) message streaming + ран терминален → штамп по статусу
  рана (succeeded-ран + зависшая строка → 'aborted'+finalizeFailed, НЕ
  'completed'-empty); (c) run running + НЕТ entry И НЕТ zombie + staleness →
  aborted (гейт «нет entry» первичный, staleness от last-progress updatedAt,
  X=max(2×per-call cap,15мин)+boot-warn); (d) message streaming + возраст>X + нет
  активной run-строки → aborted (двойной гейт). isInterruptResume исключает
  finalizeFailed-строки. Оппортунистический одно-чатовый reconcile при старте хода
  (best-effort, не фейлит ход). sweepStreaming boot-only → периодический.

Тесты (реальная БД): owner finalizeOwner чистит finalizeFailed; штамп не перетирает
терминал; поздний owner-write перетирает aborted-штамп; клаузы b/c/d (+живой entry
не трогать, двойной гейт d); «убить БД на finish → после восстановления ни строка,
ни ран не застряли». Юниты: finalizeFailed исключает interrupt-resume;
reconcileStaleRuns «нет entry».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 05:15:46 +03:00
agent_coder d72e101216 feat(ai-chat): единый гейт конкурентности + серверный supersede (CAS) (#487)
Раньше в legacy-режиме (дефолт!) гейта конкурентности НЕ было вообще — проверка
409 сидела внутри if (autonomousRuns && chatId): два таба = два параллельных
стрима на один чат (интерливинг истории, падения convertToModelMessages).

Серверная часть (клиентская лестница ретраев — отдельной клиентской итерацией
FSM, см. ниже):
- Run-строка ОБЯЗАТЕЛЬНА в ОБОИХ режимах: legacy тоже проходит beginRun/
  finalizeRun (гейт партиального уникального индекса + runId в start-метаданных).
  Различие режимов сведено к семантике abort: legacy onClose при дисконнекте
  зовёт requestStop(runId) вместо аборта сокет-сигнала (которого streamText уже
  не потребляет). Второй таб в ЛЮБОМ режиме → 409 A_RUN_ALREADY_ACTIVE.
- Supersede CAS POST /stream { supersede: { runId: X } } (AiChatRunService
  .supersede): валидация X.chatId===body.chatId (иначе 400 SUPERSEDE_INVALID);
  нет активного рана → degrade в обычный send; активный ≠ X → 409
  SUPERSEDE_TARGET_MISMATCH + текущий runId; активный = X → requestStop →
  awaitSettled (таймаут W=10c) → «записано/сдался» (сдался → settleZombie
  применяет intended условным UPDATE) → ready; таймаут → 409 SUPERSEDE_TIMEOUT,
  ничего не персистится. W обоснован race-on-abort'ом коммита 1; DB-brownout →
  TIMEOUT штатен, W не увеличивать (env-tunable).
- Задокументированы ограничения: нет квиесценции сайд-эффектов (в промпт нового
  рана добавлена строка SUPERSEDE_NOTE «предыдущий ран прерван, его последние
  операции могли примениться с задержкой»); кража слота между освобождением и
  beginRun (→ MISMATCH с новым runId, бэкстоп — уникальный индекс).

Тесты: два параллельных старта (оба режима) → строго 409 второму; supersede при
живом долгом ТУЛЕ (не UPDATE-задержке) settle'ится быстро; все CAS-ветки; дубль
supersede-POST → degrade; зомби-путь через settleZombie; HTTP-маппинг ветвей.
Кейс «beginRun ok → insert user-msg упал → ран settled, слот свободен» покрыт
lifecycle-спекой против реального safety-net catch.

КЛИЕНТ (deferred, точно flagged): удаление лестницы SUPERSEDE_RETRY_DELAYS_MS/
isRunAlreadyActive/supersedeRetryRef и переход на supersede-в-body требуют
адопции runId из start-метаданных (сейчас клиент runId не трекает вовсе) — это
и есть та «итерация клиентского FSM», которую данный серверный контракт
разблокирует. Не трогаю фрагильный клиентский FSM без возможности E2E-валидации
в браузере, чтобы не внести непроверяемую регрессию.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 05:15:46 +03:00
agent_coder 89fcc60782 feat(ai-chat): зомби-механика finalizeRun + settledPromise + условные терминальные UPDATE (#487)
Раньше give-up-путь finalizeRun ВОССТАНАВЛИВАЛ entry (зомби неотличим от живого
рана), а терминальный runRepo.update был БЕЗУСЛОВНЫМ (последний писатель перетирал
терминальный статус).

- Все терминальные UPDATE ранов теперь УСЛОВНЫЕ: новый repo.finalizeIfActive
  пишет только пока строка pending|running (зеркально onlyIfStreaming у сообщений)
  → двойной settle схлопывается в benign no-op, терминальный статус не перетереть.
- При исчерпании ретраев finalizeRun НЕ восстанавливает entry, а оставляет
  ЗОМБИ-запись { terminalWriteFailed, intended:{status,error} } в отдельной map;
  settleZombie пере-драйвит intended условным UPDATE (зовётся reconcile/supersede/
  boot sweep). Зомби удаляется после успешного UPDATE или обнаружения строки уже
  терминальной.
- Per-run settledPromise в ОТДЕЛЬНОЙ map runId→deferred (переживает active.delete),
  создаётся в beginRun, резолвится ровно один раз исходом (записано/сдался); поздний
  подписчик через ранее взятую ссылку получает резолвленный; peekSettled: live
  deferred → зомби-синтез → undefined (читать строку). Обе map bounded.
- Задокументирована потеря (single-process): рестарт до пере-драйва → boot sweep
  пишет 'aborted' поверх фактического intended — неустранимо в phase 1.

Тесты: юниты give-up-пути (зомби вместо restore + notifier terminalWriteFailed +
settleZombie), двойного settle, позднего подписчика; интеграционные против реальной
Б, что условный finalizeIfActive не перетирает терминал и двойной settle схлопывается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 05:15:46 +03:00
agent_coder 917c406489 fix(ai-chat): in-app тулы — race-on-abort + safe-points + per-call cap (#487)
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>
2026-07-11 05:15:46 +03:00
agent_coder 6bf9358387 fix(#486): ревью — CHANGELOG + AGENTS.md + два теста
Ре-ревью PR #500 (changes-requested, 4 мелких, все doc/test):

F1 [CHANGELOG] CHANGELOG.md [Unreleased]:
- Breaking Changes: metrics-листенер 0.0.0.0→127.0.0.1 — кросс-контейнерный
  скрейп (docmost:9464) молча умрёт без METRICS_BIND=0.0.0.0 + METRICS_TOKEN
  (миграция в .env.example).
- Security: утечка errorText тулов/провайдера анониму (closes #394);
  /metrics под Bearer (METRICS_TOKEN).
- Fixed: ioredis-утечка в /health; ELK вешал event loop; beginRun-призрак →
  честный 503 A_RUN_BEGIN_FAILED; ai drain-hang.

F2 [AGENTS.md] строка про ai-патч: теперь он несёт ДВА фикса (#184 O(n²)
partialOutput И #486 drain-hang), оба тривайра названы.

F3 [test] metrics.server.spec: добавлен кейс токена ТОЙ ЖЕ длины
(Bearer topsecreX) → 401 — пиннит constant-time сравнение (прежние кейсы
коротили на length-guard, до timingSafeEqual не доходили).

F4 [test] output-degeneration.spec: behavior-тест, гоняющий РЕАЛЬНЫЕ
onChunk/onStepFinish из stream() — длинный чистый шаг → граница → свежий
дегенеративный бёрст → ассерт abortSignal.aborted (было хардкодом
resetWatermark=0, ревёрт правки не краснил).

Мутационные пруфы (non-vacuous): F3 — форс compare→true роняет same-length
кейс (401→200); F4 — ревёрт lastDegenerationCheckLen=0 роняет behavior-тест
(aborted true→false). Оба восстановлены, специи зелёные (34/34).
2026-07-11 04:38:16 +03:00
agent_coder ccfbf3343b fix(#486): ревью — run-race контракт под новую политику + timing-safe metrics-токен
Ревью полной ветки #486 нашло два пункта в моих коммитах (3 и 4):

- BLOCKER (коммит 4): ai-chat.service.run-race.spec.ts (#184 F14) пинил СТАРУЮ
  политику «plain begin() failure → swallow + стрим UNTRACKED» (resolves
  toBeUndefined). Коммит 4 её развернул → тест падал (1 failed/6). Кейс
  ИНВЕРТИРОВАН под новую политику: plain begin-failure теперь REJECTS с 503
  A_RUN_BEGIN_FAILED, до первого байта, user-строка не вставлена, streamText не
  вызван — путь остаётся явно запинён, а не удалён.

- NIT (коммит 3): metrics-токен сравнивался наивным !== (единственный слой auth
  эндпоинта) → тайминг-утечка токена. Заменено на crypto.timingSafeEqual с
  length-guard (разная длина → reject), семантика 401/200 без изменений.

Внесено отдельным fixup-коммитом (rebase -i недоступен в окружении; ветка не
запушена). Тесты: run-race 7/7 + metrics.server 7/7 зелёные.
2026-07-11 03:54:50 +03:00
agent_coder b86c747245 chore(mcp): McpService.onModuleDestroy → destroyAllSessions + удаление мёртвого кода (#486)
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>
2026-07-11 03:31:24 +03:00
agent_coder dc96736d44 fix(mcp): пробросить truncated в tree-mode listPages (#486)
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>
2026-07-11 03:28:08 +03:00
agent_coder ef1a2b3960 fix(auth): провенанс для API-key пути (#486)
validateApiKey возвращал результат до резолва провенанса — REST-записи по
is_agent API-ключу не получали маркер 'agent'. Перенести «выше» нельзя: payload
API-ключа не несёт подписанный actor-клейм, а user (с isAgent) неизвестен до
валидации ключа. Резолвим провенанс от возвращённого user: isAgent -> 'agent',
иначе 'user'; aiChatId у API-ключа всегда null (нет ai_chats-строки). Загрузка
EE ApiKeyService вынесена в переопределяемый шов resolveApiKeyService для юнит-
теста без EE-бандла.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:25:56 +03:00
agent_coder 0be44e307e fix(client): flushNext в onFinish гейтится mountedRef (#486)
Финальный onFinish->flushNext() не проверял live-mount флаг. Чистый onFinish
может прийти ПОСЛЕ анмаунта треда (New-chat / переключение чата мид-стрим —
асинхронные attach/resume оседают поздно): flush дергал очередь и re-POST'ил
сообщение из брошенного треда — «призрачные» отправки/чаты-призраки. Остальные
обращения к очереди уже гейтятся mountedRef; закрываем последнюю дыру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:24:19 +03:00
agent_coder 9e99309096 fix(ai-chat): сброс lastDegenerationCheckLen в onStepFinish (#486)
onStepFinish обнулял inProgressText, но НЕ lastDegenerationCheckLen — а это
смещение В аккумулятор. После первого длинного шага протухшая (большая) отметка
делала throttle-условие отрицательным, и детектор токен-лупов молчал весь
следующий шаг, пока текст не перерастал старую отметку. Обнуляем отметку в
onStepFinish. Throttle-предикат вынесен в output-degeneration
(shouldCheckDegeneration + DEGENERATION_CHECK_STEP), чтобы юнит гонял ту же
логику, что и стрим.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:22:14 +03:00
agent_coder 433bd822b6 fix(mcp): сброс collab-токена на WS-auth-failure с ретраем (#486)
Кэш 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>
2026-07-11 03:19:15 +03:00
agent_coder ece84924f3 fix(mcp): REGISTRY_STAMP хэширует всё src/**, а не только tool-specs.ts (#486)
Стамп детектил 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>
2026-07-11 03:16:02 +03:00
agent_coder a14174a726 fix(ai): drain-hang в writeToServerResponse — расширить patches/ai@6.0.134.patch (#486)
Серверная ai@6.0.134 в writeToServerResponse при backpressure (write()===false)
ждала ТОЛЬКО once('drain'). Если клиент отвалился мид-запись, сокет не дренится,
await не резолвится: read-цикл паркуется НАВСЕГДА, finally{response.end()}
недостижим, reader и буферы висят до рестарта. В autonomous ран продолжает лить
вывод после дисконнекта → КАЖДЫЙ дисконнект мид-ран оставляет висящий пайп.
Плюс read() — fire-and-forget с throw → unhandledRejection.

Патч расширен (index.js и index.mjs): Promise.race drain/close/error с гигиеной
once-слушателей (все три снимаются на первом settle — не копятся по одному на
stall); при close/error — reader.cancel() и выход (безопасно для detached-ранов:
независимый дренаж делает consumeStream); rejection read() поглощается с логом.
pnpm-lock patch_hash перегенерён (patch-commit). Выравнивание версии ai —
отдельный коммит (#495), не здесь.

Тест: трипвайр-спека в apps/server (только там резолвится патченная копия) по
образцу ai-sdk-partial-output.patch.spec — дисконнект мид-запись без drain
завершает ответ (end() вызван, reader не висит), read()-throw не даёт
unhandledRejection, оба dist-билда несут маркер PATCH(docmost #486).
2026-07-11 02:54:03 +03:00
agent_coder d822ceeaf7 fix(share): не утекать errorText тулов и провайдера анониму в публичном шэре (closes #394)
SECURITY. В публичном share-чате сырой текст ошибки тула или провайдера утекал
анонимному читателю. Три слоя, все обязательны:

(1) Рендер-гейт: prop showErrors в ToolCallCard (протянут через MessageList/
MessageItem), share-виджет передаёт false — сырой errorText не рисуется. Но
рендер-гейт маскирует только DOM, не байты.

(2) Санитизация на уровне share-тулсета (авторитетно): forShare оборачивает
execute каждого тула catch'ем. Своя ShareToolError (безопасные строки: «page not
available in this share») пробрасывается, ЛЮБАЯ другая ошибка → generic «tool
could not complete», полный текст только в серверный лог. Одно место закрывает
байты (атомарный tool-output-error фрейм v6), рендер и контекст модели; self-
correction сохранена.

(3) Анонимный onError пайпа: ShareToolError → её безопасное сообщение; иначе
describeProviderError (statusCode + тело: внутренний baseUrl/модель) только в лог,
читателю — фиксированная классифицированная строка (rate-limited/unavailable/
provider error).

Тест: интеграционный с РЕАЛЬНЫМ падением тула и провайдера — assert по СЫРЫМ SSE-
БАЙТАМ (не по DOM): секрет/baseUrl/стек отсутствуют, видна безопасная строка,
полный текст провайдера ушёл в серверный лог.
2026-07-11 02:50:19 +03:00
agent_coder 4ac014def2 fix(ai-chat): провал beginRun фейлит ход честным 503 (A_RUN_BEGIN_FAILED) (#486)
Раньше провал beginRun (кроме unique-violation), напр. blip пула БД, логировался
и ход ПРОДОЛЖАЛСЯ без run-строки. В autonomous такой ран никто не абортит: /stop
его не видит, дисконнект не абортит, one-run-гейт пропускает ВТОРОЙ ран — невидимый
неостанавливаемый ран до рестарта.

Теперь провал beginRun (кроме RunAlreadyActiveError → прежний 409) бросает
ServiceUnavailableException с кодом A_RUN_BEGIN_FAILED ДО первого байта и до
вставки user-строки (post-hijack catch контроллера отдаёт честный 503 на raw-
сокет). Без ветвления по режимам — #487 наследует ту же политику. В тело кладём
statusCode: 503 (object-arg исключение его не добавляет), чтобы клиент видел
статус.

Клиентский классификатор: ветка A_RUN_BEGIN_FAILED добавлена СТРОГО ДО generic-
503-матча — иначе показал бы «provider is not configured» вместо «временно,
повторите».

Тесты: unit fail-fast (stream() бросает 503 A_RUN_BEGIN_FAILED, ни байта в сокет,
user-строка не вставлена; RunAlreadyActiveError по-прежнему 409); unit клиентского
классификатора из ПОЛНОГО реального тела ответа с гвардом порядка.
2026-07-11 02:36:43 +03:00
agent_coder 799dcfe7c5 fix(metrics): bind на 127.0.0.1 по умолчанию + METRICS_BIND/METRICS_TOKEN (#486)
/metrics слушал на 0.0.0.0 без какой-либо аутентификации — auth-less
эндпоинт на всех интерфейсах. Теперь дефолтный bind — loopback 127.0.0.1;
env METRICS_BIND переопределяет интерфейс (0.0.0.0 для скрейпера в
отдельном контейнере, docmost:9464); опциональный METRICS_TOKEN включает
Bearer-аутентификацию (запросы без точного токена получают 401). Доки
скрейпа в .env.example обновлены.

Тест: unit на дефолтный bind + env-переопределение + резолв токена;
интеграционный по РЕАЛЬНОМУ сокету — listener забинден на 127.0.0.1,
без токена /metrics отдаётся, с токеном без/с неверным Bearer → 401, с
верным → 200.
2026-07-11 02:32:21 +03:00
agent_coder 14c864f5c6 fix(health): устранить утечку ioredis-клиента в /health-пробе (#486)
pingCheck строил new Redis(...) на КАЖДЫЙ вызов и делал disconnect() только
на success-пути. Пока Redis лежит, каждый тик health-пробы добавлял свежий,
вечно реконнектящийся клиент — неограниченный рост хэндлов/клиентов на всё
время недоступности Redis.

Теперь один долгоживущий probe-клиент, переиспользуемый между тиками:
lazyConnect (конструктор не бросает и не коннектится жадно),
maxRetriesPerRequest: 1 и enableOfflineQueue: false (проба фейлится быстро,
команды не буферизуются), плюс listener на 'error' (иначе unhandled error
роняет процесс). onModuleDestroy закрывает клиент при shutdown.

Тест: интеграционный — N проб при лежащем Redis (реальный refused-порт, не
мок поведения) создают РОВНО ОДИН клиент (на баге было бы N); onModuleDestroy
освобождает клиент, следующая проба лениво строит новый.
2026-07-11 02:29:41 +03:00
agent_coder e292ed5117 fix(mcp): ELK-лейаут в worker_thread — таймаут через terminate() (#486)
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 продолжает
тикать во время раскладки).
2026-07-11 02:25:28 +03:00
vvzvlad 6d7dba970c Merge pull request 'refactor(mcp): структурные инварианты конкурентной записи — UUID-assert, self-resolve seams, no-await-guard (#449)' (#475) from refactor/449-write-invariants into develop
Reviewed-on: #475
2026-07-11 01:32:30 +03:00
vvzvlad 512bcba5f3 Merge pull request 'refactor(mcp): распил client.ts (5206 строк) на доменные модули за тонким фасадом (#450)' (#478) from refactor/450-split-client into refactor/449-write-invariants
Reviewed-on: #478
2026-07-11 01:30:28 +03:00
agent_vscode 5c1ab9c7b5 fix(docker): ship packages/mcp/data into runtime image
drawioFromGraph и каталог фигур читают данные в рантайме по пути
packages/mcp/data/ (относительно build/lib/*.js через import.meta.url).
tsc эмитит только build/, а стадия installer копировала лишь build/ и
package.json — в образе не было ни drawio-presets.json (#425), ни
drawio-shape-index.json.gz (#440), из-за чего drawioFromGraph падал с
ENOENT, а иконки каталога фигур молча не резолвились.

Добавлен COPY packages/mcp/data в стадию installer. .dockerignore /data
заякорен на корень и этот путь не затрагивает.
2026-07-11 01:13:48 +03:00
agent_coder 14d7b21df0 refactor(mcp): распил client.ts (5206 строк) на доменные модули за тонким фасадом (#450)
client.ts был god-object'ом на 5206 строк / ~65 методов / 5 ответственностей —
любая правка рисковала всем write-path. Разнесли на доменные модули;
DocmostClient остаётся ТОНКИМ ФАСАДОМ с прежним внешним контрактом. Чистый
рефакторинг, поведение не меняется. closes #450

- Фасад client.ts (93 строки) композирует 10 миксинов над общим абстрактным
  базовым классом. Паттерн МИКСИНЫ (не context-object): тесты субклассируют
  DocmostClient и переопределяют seam'ы; единая цепочка прототипов сохраняет
  виртуальную диспетчеризацию this.<method> сквозь модули.
- client/context.ts (база): общее состояние (axios, apiUrl, токены, кэши),
  конструктор + оба интерсептора, login/ensureAuthenticated/paginateAll/
  resolvePageId/mutateLiveContentUnlocked + write-seam'ы mutatePage/replacePage.
  private→protected (внутреннее, не в публичном контракте).
- Модули (каждый ≤730): read, pages, nodes-write, media (images/attachments/
  drawio), comments, transforms, tables, stash, doc-validate. errors.ts —
  единый REST error-mapping (довершение #437; тексты сообщений без изменений).
- Внешний контракт СОХРАНЁН (доказано компиляцией с обеих сторон): 53 публичных
  метода через `implements IXMixin` (без ручного зеркала, #446); оба Pick
  (in-app loader + tool-specs) резолвятся; множество async-методов 59==59.
  Нагруженные seam'ы (replaceImage один-лок #425, self-resolve #449, единый
  error-путь) байт-идентичны; ни одного дубля публичного метода.
- zod v3→v4: investigate-only, отложено — SDK 1.29 поддерживает v4, но мажор-
  бамп трогает всю схема-поверхность; отдельным follow-up.

Стоит на #449 (#475). Тесты: mcp node --test 800/800 (== база), tsc чисто в
client/. Ни одной строки кода не потеряно; убраны 52 дублированных JSDoc-блока.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:04:42 +03:00
agent_vscode 363f20ab75 docs(agents): add architectural invariants (non‑negotiable rules)
Introduce a new “ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE” section that
lists ten hard constraints derived from past production incidents. These
rules act as non‑negotiable guidelines for future development and code
review.
2026-07-11 01:04:10 +03:00
agent_vscode 3411bda2d1 fix(mcp): adapt e2e node-ops calls to the #413 XOR input of patchNode/insertNode
#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>
2026-07-11 00:08:00 +03:00
agent_vscode e670f7498a fix(ai-chat): sync CORE_TOOL_KEYS with #443 core tools — restore mcp-server-parity
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>
2026-07-11 00:03:34 +03:00
agent_coder 9a8671c3af refactor(mcp): структурные инварианты конкурентной записи — UUID-assert в page-lock, self-resolve seams, no-await-guard (#449)
Механика конкурентной записи (withPageLock → acquireCollabSession → mutate)
держалась на цепочке конвенций в комментариях. Новый write-метод без знания
правил компилировался и уходил в прод (класс #260/#152/#159). Закрепляем ключевые
инварианты кодом.

- page-lock.ts: экспортированы UUID_RE/isUuid (тот же regex, что resolvePageId,
  UUID v1–8/v7). withPageLock FAIL-FAST кидает при не-UUID ключе ДО любой работы
  (комментарий-инвариант #260/#449) — забытый resolve/slugId больше не даёт тихую
  потерю сериализации под другим ключом. client.ts импортирует isUuid оттуда
  (убран локальный дубль — resolver и assert не разъедутся).
- mutatePage/replacePage seams стали async и сами вызывают resolvePageId — ключ
  лока/кэша канонический даже если вызывающий забыл (для уже-UUID это cached
  no-op; все 7 текущих вызывающих и так резолвят). replaceImage (один внешний
  лок + mutateLiveContentUnlocked) не тронут, deadlock невозможен.
- collab-session.ts: машинно-проверяемые маркеры MUTATE-CRITICAL-WINDOW
  BEGIN/END вокруг синхронного блока fromYdoc→applyDocToFragment (INVARIANT 1).
  Тест no-await-critical-window читает исходник и краснеет на await/yield в окне
  (проверено нейтером). Случайный await больше не тихо клоббит живые правки.
- Документация осознанной позиции: single-instance/sticky-sessions —
  требование деплоя (Dockerfile + README EN/RU + .env.example), т.к. мьютекс и
  stash — per-process. Окно устаревших прав (кэш-сессия пишет под токеном
  момента connect до MCP_COLLAB_SESSION_MAX_AGE_MS=10мин) — задокументированный
  trade-off в .env.example; push-инвалидации нет (осознанно).

Тесты: page-lock fail-fast (slugId/пусто/non-string → throw; канонический UUID
принят), no-away-guard, обновлённые фикстуры на валидные UUID. #449-специфичные
37/37 зелёные; mcp tsc чисто. closes #449.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:51:02 +03:00
vvzvlad bec2156e96 Merge pull request 'recovery: хвост стопки breaking-окна — #413, #415, #443 (3 части), #425 в develop' (#474) from feat/443-get-page-context into develop
Reviewed-on: #474
2026-07-10 23:49:29 +03:00
vvzvlad acc705de19 Merge pull request 'feat(mcp): drawio стадия 3 — семантические тулы fromGraph/fromMermaid/editCells (#425)' (#472) from feat/425-drawio-graph into feat/443-get-page-context
Reviewed-on: #472
2026-07-10 23:45:45 +03:00
vvzvlad a49872444e Merge pull request 'refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (#412)' (#463) from refactor/412-camelcase-tool-names into develop
Reviewed-on: #463
2026-07-10 23:44:42 +03:00
vvzvlad ffc38ea2ca Merge pull request 'fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464)' (#465) from fix/464-diffdocs-cpu-guard into develop
Reviewed-on: #465
2026-07-10 23:44:20 +03:00
agent_coder 2a951df096 feat(mcp): drawio стадия 3 — семантические тулы drawioFromGraph/FromMermaid/EditCells (#425)
Сырой 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>
2026-07-10 23:16:50 +03:00
agent_coder 5dc7a2703f feat(mcp): getPageContext — «где я / что вокруг» по pageId одним вызовом (#443, часть 3/3)
Финальная из трёх частей #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>
2026-07-10 22:19:25 +03:00
agent_coder bfb4c8d8d0 feat(mcp): getTree — иерархия пространства/поддерева одним вызовом (#443, часть 2/3)
Раньше единственный способ получить дерево — 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>
2026-07-10 21:58:32 +03:00
agent_coder f794ac6d6c fix(search): оживить trgm-индекс — убрать coalesce из LIKE-предикатов (#443, ревью)
Ревьюер поймал 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>
2026-07-10 21:57:16 +03:00
agent_coder e4e788f151 feat(search): агентский lookup-режим — substring по точкам/дефисам/цифрам, path, snippet, scope (#443, часть 1/3)
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>
2026-07-10 21:34:53 +03:00
agent_coder 4be4a75fa3 docs(mcp): точные описания потерь getPage/exportPageMarkdown — конвертер канонический (#415)
Описания двух тулов устарели после #345 (канонический конвертер, #293/#351):
«LOSSY…approximated» у getPage и «lossless» у exportPageMarkdown искажали
роутинг агентов и молчаливо скрывали реальную потерю данных. Четвёртый линк
breaking-окна, стоит на #413.

- tool-specs.ts: getPage — вместо «LOSSY…approximated» точный закрытый список
  потерь (canonical for text; теряет лишь id блоков, resolved-якоря
  комментариев и фиксированный набор ACCEPTED-атрибутов без markdown-
  представления: спаны/colwidth/фон ячеек таблиц, indent, callout.icon,
  orderedList.type, link internal/target/rel/class). exportPageMarkdown —
  убрано «lossless»: round-trip перегенерирует id блоков и молча отбрасывает
  тот же набор (в первую очередь merge-спаны ячеек); держать в page-JSON, если
  нужны. Список выведен из источника истины (ATTR_VALUE_FUZZ_ALLOWLIST +
  MARK_ATTR_ALLOWLIST); opaque carried-verbatim токены (attachmentId, mime,
  slugId и пр.) намеренно НЕ в списке потерь — они round-trip'ятся.
- server-instructions.ts: READ-строка ROUTING_PROSE приведена к тому же
  точному списку, без противоречия markdown-default роутингу #413.
- Исправлена вторая латентная неточность: getPage описывался как
  сохраняющий resolved-якоря — но client.ts передаёт dropResolvedCommentAnchors:
  true, resolved-якоря скрыты (getNode, наоборот, их сохраняет — другой тул).
- README пакета (EN + RU-зеркало) приведены в соответствие passage-for-passage:
  убраны безоговорочные «lossless/lossy» для Markdown round-trip; genuinely-
  lossless ссылки на raw-JSON (getPageJson/getNode) оставлены.

Логику не трогает. server-instructions.test.mjs удалён и заменён
tool-inventory.test.mjs (без пинов на старую формулировку). mcp tsc чисто,
node --test 702/702, tool-inventory 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:55:28 +03:00
agent_coder e6171a1810 fix(mcp): сходимость orphan-сноски + дедуп block-id в markdown-splice (#413, ревью)
Правки по внутреннему ревью #413.

1. Orphan-сноска (нарушение «no second canon»). mergeFootnoteDefinitions
   раньше делал ранний return при пустых definitions, пропуская
   canonicalizeFootnotes. Если markdown-фрагмент БЕЗ сносок заменял/удалял
   блок, бывший последним referrer'ом существующей сноски, её определение
   оставалось orphan в хвостовом списке (полный ре-импорт его бы убрал).
   Теперь fast-path (возврат doc по ссылке без clone) только когда
   defs.length===0 И !hasFootnoteArtifacts(doc); иначе клон → append (no-op
   при пустых) → normalizeAndMergeFootnotes → canonicalizeFootnotes, как при
   полном импорте. Новый предикат hasFootnoteArtifacts обходит дерево на
   любой footnotesList/footnoteReference (существующие walk/isObject).
   Идемпотентно: несвязанный plain-патч на странице со сносками не трогает
   их топологию (canonicalizeFootnotes шаг 6 возвращает как есть).

2. Block-id disjoint от страницы. Новый экспорт reassignCollidingBlockIds
   (liveDoc, blocks, skipIndex?) в prosemirror-markdown/node-ops (переиспользует
   collectIds/makeFreshId) + barrel. Зовётся перед сплайсами в client.ts:
   patch — (liveDoc, threaded, 0) (skip 0 = блок, унаследовавший id цели);
   insert — (liveDoc, blocks) без skip. used-аккумулятор ловит и коллизию со
   страницей, и внутрифрагментную. freshBlockId/freshId без изменений.

Тесты (+6, markdown-patch-insert): orphan-repro (0 defs/0 list/0 refs +
docsCanonicallyEqual полному импорту), fast-path (footnote-free patch не трогает
топологию, соседи byte-identical), insert канонизирует при пустых defs со
страничной сноской, уникальность top-level block-id для patch 1→N и insert N.
mcp node --test 702/702, pmd vitest 736, tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:38:06 +03:00
agent_coder 2f23cf4b65 fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464)
Боевой инцидент: diffDocs синхронно зовёт recreateTransform (rfc6902), чей
array-diff — O(n·m) по числу нод и O(w²) по длине прогонов слов, и который
НИКОГДА не бросает. Поэтому существующий catch→coarseDiff не спасает event loop:
большая агентская правка блокирует воркер на секунды→минуты (бенч: 401 нода→1.3с,
801→5.5с, 1601→OOM; отдельная ось — байт-тяжёлый вход: 309КБ→OOM), а это душит
все остальные запросы воркера. Отсюда живой прод-хэнг.

Фикс — pre-flight size-guard ПЕРЕД дорогим вызовом:
- readPositiveIntEnv(name, dflt): парс env каждый вызов; мусор/пусто/≤0/не-finite →
  дефолт, так что guard нельзя случайно отключить.
- exceedsDiffSizeGuard(old, new): срабатывает по max(old,new) на ОБЕИХ осях —
  countNodes и JSON.stringify().length — так что асимметричная пара (крошечный
  new / огромный old и наоборот) тоже триггерит. Дёшево: один обход + один
  stringify на документ.
- Дефолты MCP_DIFF_MAX_NODES=150, MCP_DIFF_MAX_BYTES=12288 (12 КиБ),
  env-переопределяемы. Подобраны бенчмарком под бюджет ~200мс на ЛЮБОЙ форме
  входа на границе cap'а (исходные догадки тикета 3000-5000 нод / 512КБ-1МБ были
  в 20-30× завышены — пропускали бы многоминутные блоки).
- Рефактор: выделены coarseDiffTally (единый источник формы fellBack:true) и
  preciseDiffTally. Новый поток diffDocs: computeIntegrity (без изменений, для
  обоих путей) → если exceedsDiffSizeGuard → coarseDiffTally(fellBack=true) →
  иначе try preciseDiffTally / catch → coarseDiffTally. Обе деградации дают
  идентичную форму результата.

Fail-closed: единственный путь к recreateTransform — ветка else, достижимая
только когда guard НЕ сработал; огромный документ физически до transform не
доходит. computeIntegrity (корректностный сигнал) считается ВСЕГДА. Все три
call-site потребляют DiffResult как информационный preview, не гейтят запись —
coarse-фоллбэк контракт-сохраняющий.

Тесты (11): over/under-порог, обе асимметрии, байт-ось (нод-мало/байт-много),
env-override низким cap'ом, garbage-env→дефолт (guard не отключается), integrity
корректен на tripped-документе; behavioral-proxy что transform пропущен над
cap'ом (guarded ~5мс vs caps-raised ~3.3с, ≥5× + <200мс бюджет). node --test
688/688. tsc --noEmit чисто. Только packages/mcp, внешний симлинк не тронут.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:32:50 +03:00
agent_coder d269bd9efe feat(mcp): markdown — формат по умолчанию для блочных getNode/patchNode/insertNode (#413)
Канонический конвертер (#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>
2026-07-10 20:10:38 +03:00
agent_vscode d287c15db4 fix(ci,ai-chat): восстановить зелёный CI на develop
Чинит два падения прогона 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>
2026-07-10 19:42:16 +03:00
agent_coder 7cb3199d09 refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (унификация с in-app) (#412)
Один логический тул жил под двумя именами: внешний 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>
2026-07-10 19:10:38 +03:00
vvzvlad e19275e96e Merge pull request 'refactor(tools): updatePageContent → updatePageMarkdown; −import_page_markdown с MCP (#411)' (#462) from refactor/411-update-page-markdown into develop
Reviewed-on: #462
2026-07-10 18:36:44 +03:00
agent_coder 3a521ada4d refactor(tools): updatePageContent → updatePageMarkdown; внешний MCP: +updatePageMarkdown, −import_page_markdown (#411)
Поверхности записи «целым телом» были несимметричны: у 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>
2026-07-10 18:33:20 +03:00
vvzvlad 576db3c8f9 Merge pull request 'feat(mcp): drawio стадия 2 — guide, каталог фигур, ELK-лейаут, quality-warnings (#424)' (#440) from feat/424-drawio-rules into develop
Reviewed-on: #440
2026-07-10 18:29:46 +03:00
agent_coder ddb37376a4 refactor(mcp): вписать drawio-тулы в реестровый цикл (Фаза 1б смержена) (#440)
Фаза 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>
2026-07-10 16:34:54 +03:00
agent_coder dc58974b31 fix(ai-chat): пробросить layout:elk в in-app drawioCreate/drawioUpdate — паритет с MCP (ревью #440)
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>
2026-07-10 16:16:19 +03:00
agent_coder c917dcc3c1 fix(mcp): ограничить ELK-лейаут (кап узлов/рёбер + таймаут) — untrusted-граф DoS (ревью #440)
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>
2026-07-10 16:15:06 +03:00
agent_coder eddc3b5c33 feat(ai-chat): пробросить drawio_shapes/drawio_guide in-app — восстановить SHARED_TOOL_SPECS-паритет (#424)
Стадия-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>
2026-07-10 16:15:06 +03:00
agent_coder e454fe189c feat(mcp): drawio стадия 2 — правила качества, каталог фигур, guide, ELK-лейаут, warnings (#424)
Надстройка над стадией-1 (сырой mxGraph XML) — помогает агенту рисовать
корректные диаграммы без бэкенд-рендеринга:

- hard-rules в описаниях drawio_create/drawio_update (геометрия, parent-
  relative координаты, стили);
- drawio_guide (5 секций: skeleton / layout / containers / icons-aws /
  icons-azure, каждая ≤4KB) — по требованию, не раздувает контекст;
- drawio_shapes — реальный jgraph shape-index (10446 фигур, gzip 437KB,
  ленивый node:zlib gunzip) + курируемый оверлей (service-level паттерны
  AWS/Azure, note-подсказки на пустые resIcon, палитра категорий);
  ранжирование aws4>aws3; escapeRe в score (не ReDoS);
- layout:"elk" через elkjs (чистый JS, dependencies:{}) — compound-nesting,
  best-effort (на сбое ELK возвращает нормализованный вход), 73→0 warnings
  на 12-узловом графе;
- 6 типов quality-warnings в линтере (overlap, out-of-bounds, edge-cross,
  и т.п.), геометрия Liang-Barsky; warnings НИКОГДА не блокируют write.

Оба новых инструмента в SHARED_TOOL_SPECS (tier:deferred) + SERVER_
INSTRUCTIONS; drift-guards зелёные. elkjs ^0.11.1 — единственный новый
рантайм-деп; lockfile синхронизирован (--frozen-lockfile --offline EXIT 0).
data/ едет с воркспейсом (.gitignore-негация !packages/mcp/data/).

Внутренний цикл: 1 проход внутреннего ревью (APPROVE WITH SUGGESTIONS);
все 59 профильных тестов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:12:37 +03:00
vvzvlad ea99d4fe63 Merge pull request 'feat(mcp): внятная диагностика ошибок тулов + fail-fast валидация comment-id (#437, #436)' (#441) from feat/437-error-diagnostics into develop
Reviewed-on: #441
2026-07-10 16:03:44 +03:00
vvzvlad 23966ce51c Merge pull request 'fix(ai-chat): «send now» во время detached-run — серверный stop + ограниченный ретрай 409 (#396)' (#456) from fix/396-sendnow-detached-run into develop
Reviewed-on: #456
2026-07-10 16:03:18 +03:00
vvzvlad a53b2f454e Merge pull request 'fix(delivery): immutable-кэш ассетов — отключить дефолтный cacheControl @fastify/static (#452)' (#455) from fix/452-immutable-cache-control into develop
Reviewed-on: #455
2026-07-10 16:03:03 +03:00
vvzvlad d219eb7525 Merge pull request 'fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444)' (#454) from fix/444-agent-loop-guards into develop
Reviewed-on: #454
2026-07-10 16:02:48 +03:00
vvzvlad 93d244478e Merge pull request 'refactor(tools): генерировать инвентарь SERVER_INSTRUCTIONS из реестра + guard имён тулов (#448)' (#460) from refactor/448-generate-inventory into develop
Reviewed-on: #460
2026-07-10 16:02:17 +03:00
vvzvlad 791f709c18 Merge pull request 'refactor(tools): execute-маппинг в SHARED_TOOL_SPECS + автопроводка обоих хостов (#445)' (#459) from refactor/445-execute-mapping into develop
Reviewed-on: #459
2026-07-10 16:02:01 +03:00
vvzvlad f8a27cba91 Merge pull request 'refactor(mcp): вывести DocmostClientLike/SharedToolSpec из реального типа — убить ручные зеркала (#446)' (#458) from refactor/446-derive-client-types into develop
Reviewed-on: #458
2026-07-10 16:01:48 +03:00
vvzvlad 61dc9b50c1 Merge pull request 'fix(ci,mcp): REGISTRY_STAMP в билде + кросс-пакетный CI — закрыть skew build/vs/src (#447)' (#457) from fix/447-registry-stamp-ci into develop
Reviewed-on: #457
2026-07-10 16:01:38 +03:00
vvzvlad cebb1cca87 Merge pull request 'fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409)' (#461) from fix/409-invalid-node-validation into develop
Reviewed-on: #461
2026-07-10 16:01:25 +03:00
vvzvlad 605c0f3dda Merge pull request 'docs(mcp): поправить устаревшую ссылку footnote-authoring.ts в комментариях' (#453) from docs/footnote-authoring-comment-cleanup into develop
Reviewed-on: #453
2026-07-10 15:49:35 +03:00
agent_coder 0f5f048ca2 fix(mcp): pre-validate node JSON против схемы + путь битого узла (#409, остаток Фазы 1)
Структурные редакторы (patchNode/insertNode/updatePageJson/transformPage) кидали
опаковый Yjs-крах на агентском JSON с вложенным узлом без/с неизвестным `type`:
«Failed to encode document to Yjs (fromJSON): Unknown node type: undefined» —
ГЛУБОКО в энкодере, уже ПОСЛЕ открытия collab-сессии, а хинт мислейблил это как
проблему атрибута. Агент ретраил вслепую (~34 краха в истории 06-17…07-07).

- findInvalidNode(doc) в prosemirror-markdown/node-ops.ts: DFS по content,
  возвращает {path, summary} первого узла с отсутствующим/не-строковым `type`
  или типом/маркой вне схемы. Множество имён — из getSchema(docmostExtensions),
  ТОГО ЖЕ, из которого энкод-путь строит docmostSchema → «известный тип»
  обходчика ровно то, что примет PMNode.fromJSON/toYdoc (сверено на 45 узлах +
  12 марках, ни ложных положительных, ни пропуска краш-типа).
- unstorableYjsError: findInvalidNode ПЕРВЫМ (node-shape крах больше не
  мислейблится как атрибут), затем findUnstorableAttr, generic-фраза последней.
- assertValidNodeShape(op, node) ДО getCollabTokenWithReauth/mutatePageContent
  в patchNode/insertNode/updatePageJson: fail-fast — collab-сессия не
  открывается, page-lock не берётся, сообщение детерминировано (mock-тест
  ассертит collabTokenFetched===false на битом пути). tableUpdateCell не тронут
  (строит абзац из plain text через makeCellParagraph, агентский JSON не глотает).
- Описания patch_node/insert_node/update_page_json: каждый узел, включая
  вложенные, несёт строковый `type` из схемы; текст-листы {"type":"text",...}.

sanitizeForYjs (стрип undefined-атрибутов) сохранён — другой класс отказа.
Внутреннее ревью: APPROVE WITH SUGGESTIONS — schema-fidelity/fail-fast/
no-false-positive/precedence подтверждены; замечания необязательны (тест
перечисления схемы, depth-guard безобиден т.к. энкодер падает раньше).
prosemirror-markdown vitest 726/726, mcp node --test 613/613.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:49:51 +03:00
agent_coder 4cb762b039 docs(mcp): обновить AGENTS.md + коммент под генерируемый инвентарь (ревью #460)
Две доковые правки по ревью: (1) AGENTS.md-буллет описывал ДО-#448 мир (ручная
правка SERVER_INSTRUCTIONS, enforced server-instructions.test.mjs, EXCEPTIONS) —
переписан: shared-спеки авто-обновляют генерируемый <tool_inventory>, только
inline-тул требует строки в INLINE_MCP_INVENTORY, enforced tool-inventory.test.mjs,
EXCEPTIONS больше нет; (2) коммент в server-instructions.ts называл гард окольно
('tool-specs.test.mjs's sibling test') → назван tool-inventory.test.mjs напрямую.
Единственная оставшаяся ссылка на удалённый тест устранена. Только доки/комменты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:55:30 +03:00
agent_coder d0f99052cf refactor(tools): генерировать инвентарь SERVER_INSTRUCTIONS из реестра + guard имён тулов в промпте (#448)
Финальный линк Фазы 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>
2026-07-10 09:21:28 +03:00
agent_coder 8c74659d91 refactor(tools): execute-маппинг в SHARED_TOOL_SPECS + автопроводка обоих хостов (#445)
Ядро Фазы 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>
2026-07-10 08:59:40 +03:00
agent_coder fe5b6ecd8c refactor(mcp): вывести DocmostClientLike/SharedToolSpec из реального типа клиента — убить ручные зеркала (#446)
Восстановленный отложенный долг #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>
2026-07-10 08:29:46 +03:00
agent_coder 2e6f1c3de5 fix(ci,mcp): REGISTRY_STAMP в билд-артефакте + кросс-пакетный CI — закрыть skew build/ vs src/ (#447)
Два структурных слепых пятна: (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>
2026-07-10 07:58:37 +03:00
agent_coder e24ddf6b3e test(ai-chat): покрыть safety-путь детектора деградации + границы (ревью #454)
Ревью: дизайн LGTM, но safety-фича недотестирована. Добавлено (только тесты,
прод-код не тронут):
- e2e-реакция детектора: streamText эмитит degenerate-чанки → union abortSignal
  срабатывает с 'Output degeneration detected' (отличимо от Stop) → onAbort
  пишет status:error + OUTPUT_DEGENERATION_ERROR + усечённый content, лиза MCP
  закрыта. Именно ДЕЙСТВИЕ на детект (детектит-но-не-действует = защиты нет);
- граница monochar-порога: hasPeriodicTail('x'×59)=false, ('x'×60)=true
  (мутация >=→> раньше выживала);
- empty-turn маркер (шаги исчерпаны + без текста → STEP_LIMIT_NO_ANSWER_MARKER;
  негативы на нормальный текст-ход и на исчерпание-с-текстом — гардят AND);
- различение degeneration-onAbort vs user-Stop (Stop → status:aborted, без
  error/усечения).

Мутационно: (a) >=→> роняет 60-границу; (b) нейтрализация onAbort-ветки роняет
reaction-тест; (c) нейтрализация маркера роняет empty-turn-тест. +7 тестов,
137 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:54:34 +03:00
agent_coder 3cba551800 fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)
В автономном режиме «Interrupt and send now» во время живого detached-run делал
только локальный stop() (abort SSE), который сервер игнорирует (run живёт по
дизайну #184/#234), поэтому onFinish→flush новый POST упирался в гейт «один
активный run на чат» → 409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал.
handleStop делает правильно (доп. onServerStop), sendNow — нет. Вариант A
(клиентский, горячий путь сервера не тронут):

- sendNow в автономном режиме дополнительно зовёт onServerStop(chatId) (или
  откладывает через stopPendingRef, если chatId ещё не усыновлён — как
  handleStop) и взводит one-shot supersedeRetryRef ДО stop();
- транспорт-fetch на supersede-отправке (и только на ней) ретраит РОВНО 409 с
  body.code===A_RUN_ALREADY_ACTIVE до 4 попыток с бэкоффом 150/300/600ms;
  onServerStop гарантирует осадку run → ретрай сходится. Обычная отправка (не
  взведён флаг) падает на 409 мгновенно. isRunAlreadyActive читает
  response.clone() → тело оригинала возвращается потребителю нетронутым.
  409 всегда до записи user-строки (pre-check/beginRun раньше insert) → повтор
  POST безопасен, дублей нет.

Внутренний цикл: 2 прохода. Ревью нашло CRITICAL: supersedeRetryRef застревал
взведённым, когда sendNow взвёл, но POST не ушёл (promoted head удалён →
flushNext() false; либо wasResumed-return) — следующая обычная отправка молча
ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot
(в ветке !flushNext() и в isStreaming-defuse-эффекте); транспорт read-and-clear
на входе каждой отправки. Мутационно: убрать disarm → strand-тест краснеет
(4 вызова вместо 1). Легаси-режим не тронут (регресс-гард).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:50:04 +03:00
agent_coder 15a9eba562 fix(delivery): immutable-кэш ассетов — отключить дефолтный cacheControl @fastify/static (#452)
Хэшированные ассеты отдавали '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 07:30:44 +03:00
agent_coder 2c03fefa9d fix(ai-chat): защита от петель агента — lockdown под тоггл, детектор деградации, бюджет шагов (#444)
Третий класс петли (инцидент 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>
2026-07-10 07:28:48 +03:00
vvzvlad f8d37d8956 Merge pull request 'fix(mcp): курсорная пагинация — устранить тихую потерю страниц в list_pages/check_new_comments (#442)' (#451) from fix/442-cursor-pagination into develop
Reviewed-on: #451
2026-07-10 07:27:31 +03:00
agent_coder 4809348457 test(mcp): ассертить collab-hint (pageId + transient/retry) в reject-текстах (ревью #441)
Enrichment collab-ошибок из Phase C (#437) дописывает
'(pageId …; transient — retry once; …)' к connect-timeout/connection-closed,
но reject-регекспы матчили только базовый текст → проходили и С hint, и БЕЗ
(vacuous). Ужесточил два ассерта (connection-closed, connect-timeout) до
'<база> (pageId page-1; transient' — теперь рефактор, убравший hint(), их
роняет. Мутационно: hint()->'' → ровно эти 2 теста краснеют (18->16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:14:39 +03:00
agent_coder d3d32d637b fix(mcp): no-response диагностика — не отдавать сырой error.message (утечка host в модель) (внутр. ревью #437)
no-response ветка формата использовала error.code ?? error.message: при
отсутствии code axios-сообщения сетевых ошибок содержат host:port
('connect ECONNREFUSED 127.0.0.1:3000', 'getaddrinfo ENOTFOUND host'), что
нарушает инвариант #437 «host никогда не попадает в видимое модели сообщение».
Теперь только error.code (?? 'network error'); полный нативный текст уходит в
stderr под DEBUG. code проставлен фактически для всех реальных no-response
ошибок. Тест обновлён: сырое host-содержащее сообщение -> нейтральный reason,
плюс ассерт что host в сообщении отсутствует.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder 9a435201b8 feat(mcp): enrich collab connect/persist/closed error texts with pageId + retry hint (#437)
Append `(pageId <id>; transient — retry once; persistent failures mean the
collab server is unreachable/overloaded)` to the connect-timeout, persist-timeout
and connection-closed error texts in CollabSession, so the agent can self-correct
instead of blind-looping. The Yjs-encode error is left untouched (it already names
the offending attribute).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder d6827b9210 feat(mcp): actionable tool errors — central axios diagnostics + fail-fast comment-id guard (#437)
Phase A: add ONE response interceptor on DocmostClient's axios instance,
registered AFTER the re-login interceptor, that reformats a failed request's
error.message IN PLACE (never a custom Error subclass, so the live
axios.isAxiosError / error.response?.status / config._retry checks keep working)
into `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`, or the
no-response variant. serverMessage is built ONLY from the whitelisted
message/error fields or statusText — raw string/HTML bodies, headers and config
never appear; an arraybuffer body is size-capped JSON.parsed; the full body goes
to stderr only under DEBUG (parity with downloadImage). A _docmostFormatted flag
guards against double-processing.

Phase B (#436): assertFullUuid throws an actionable error BEFORE any network
call at all five commentId sites (resolve/update/delete/get_comment and
create_comment's parentCommentId when provided), so a truncated id can no longer
loop as an opaque 400/404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder 90168eb926 fix(mcp): курсорная пагинация вместо офсетной — устранить тихую потерю страниц (#442)
Апстрим 78b1c1a4 перевёл серверные эндпоинты на КУРСОРНУЮ пагинацию, а
ValidationPipe({whitelist:true}) молча вырезает неизвестное поле `page`.
MCP-клиент так и слал офсетный `page` → сервер отдавал ту же первую двадцатку
с hasNextPage:true, цикл выкручивался до MAX_PAGES=50 одинаковых запросов, дети
№21+ не выгружались (с поддеревьями). Дедуп `visited` гасил дубли → «дырявое»
дерево без ошибок. Netmap: 20/299 страниц терялось, 160 запросов вместо 62.

- A: enumerateSpacePages → один POST /pages/tree (весь спейс/поддерево разом);
  fallback на курсорный BFS при 404/405 (stock upstream). Возврат {pages,
  truncated}; truncated честный — true только при реальном упоре fallback-BFS в
  MAX_NODES.
- B: listSidebarPages → курсорный цикл, limit:100, guard на неподвижный курсор
  (!next || next===cursor → break) — если протокол снова разойдётся, не крутит
  дубли молча; warn при упоре в MAX_PAGES.
- C: paginateAll (/spaces, /shares) → та же курсорная миграция + guard.
- D: check_new_comments — /pages/tree поддерева включает корень
  (getPageAndDescendants), убран лишний getPageRaw; в fallback корень
  засевается явно (иначе его комменты терялись — регрессия того же класса).
- listComments: do/while → for с MAX_PAGES + guard неподвижного курсора
  (был безлимитный — тот же сценарий #442 дал бы бесконечный цикл).

Внутренний цикл: 2 прохода. Первый нашёл потерю комментов корня в fallback
поддерева (data-loss) → засев корня; догрёб honest-truncated, warn в
listSidebarPages, guard в listComments. Второй проход — APPROVE, форма возврата
{pages,truncated} распространена на оба вызова без пропусков.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:08:56 +03:00
179 changed files with 26538 additions and 6904 deletions
+59
View File
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
# MCP_TOKEN=
# MCP_SESSION_IDLE_MS=1800000
#
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
# go over the collaboration websocket and are serialized PER PAGE by an
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
#
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
# two replicas can each "hold" the lock for the same page at the same time and
# serialization is silently lost (concurrent full-document writes race on the
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
# traffic to one replica (sticky sessions / consistent hashing on page id). The
# same constraint applies to the RAM-only stash_page blob store above. There is
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
# CONSCIOUS documented constraint, not an oversight (#449).
#
# To reduce connect-storms the write path caches ONE live collab session per
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
#
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
# So if a user's access to a page is REVOKED, MCP writes on an already-open
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
# the session is torn down and the next write re-auths with a fresh token, picking
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
# stale-rights window. There is intentionally no push-based cache invalidation on
# a rights change — this bounded window is the accepted trade-off (#449).
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
#
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
# content + images to an external consumer WITHOUT bloating the model context or
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
@@ -217,6 +251,17 @@ MCP_DOCMOST_PASSWORD=
# active" behavior.
# AI_CHAT_DEFERRED_TOOLS=true
# Final-step lockdown for the in-app agent loop (#444). Default OFF. When ON
# (legacy), the LAST allowed step forces a text-only answer: the model's tools are
# stripped (toolChoice=none) and a synthesis instruction is appended. That
# tool-stripping caused a token-degeneration incident — robbed of its tools on the
# final step mid-work, the model emitted a ~255KB block repeating a single token —
# so the default is now OFF: the last step keeps its tools and gets only a SOFT
# nudge to finish with a text summary, and a token-degeneration detector is the
# universal anti-babble guard. Enable this ONLY for a model that reliably ends its
# turns with a clear text answer.
# AI_CHAT_FINAL_STEP_LOCKDOWN=false
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
@@ -290,6 +335,20 @@ MCP_DOCMOST_PASSWORD=
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
# METRICS_PORT=9464
#
# METRICS_BIND — interface the /metrics listener binds to. DEFAULT 127.0.0.1
# (loopback only), so the unauthenticated endpoint is NOT exposed on all
# interfaces. If the scraper runs in a SEPARATE container and reaches this as
# docmost:9464, set METRICS_BIND=0.0.0.0 — but then also set METRICS_TOKEN
# and/or keep the port on a private network, since /metrics is otherwise open.
# METRICS_BIND=127.0.0.1
#
# METRICS_TOKEN — optional Bearer token guarding /metrics. When set, every
# scrape MUST send `Authorization: Bearer <token>` (others get 401). Configure
# the scraper with the same bearer token (e.g. VictoriaMetrics/vmagent
# `bearer_token`, Prometheus `authorization.credentials`). Leave unset only
# when the endpoint is bound to loopback or an otherwise-trusted network.
# METRICS_TOKEN=
#
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
# endpoint is registered and browsers collect + send web-vitals / editor
+6
View File
@@ -157,6 +157,12 @@ jobs:
- name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
- name: Build mcp
run: pnpm --filter @docmost/mcp build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
+58
View File
@@ -1,5 +1,13 @@
name: Test
# NO `paths:` filter on purpose (issue #447). The tool-spec REGISTRY is split
# across two packages that MUST stay in sync: the specs live in `packages/mcp`
# but the parity/tier guard tests that read them live in the `apps/server` jest
# suite. A PR touching only `packages/mcp/**` must therefore still run the SERVER
# suite (and vice-versa), or an in-app wiring break slips through green and only
# surfaces on develop after merge. The `test` job below runs BOTH suites via
# `pnpm -r test` on every PR; the dedicated `mcp-server-parity` job makes that
# cross-package gate explicit and fast. Do not add a `paths:` filter here.
on:
pull_request:
workflow_call:
@@ -132,3 +140,53 @@ jobs:
# isolated `docmost_test` DB and migrates it to latest.
- name: Run server integration tests
run: pnpm --filter server test:int
# Cross-package tool-spec parity gate (issue #447). The tool-spec registry lives
# in `packages/mcp` but its parity/tier guard tests live in the `apps/server`
# jest suite, so a PR touching ONLY one of the two packages must still run BOTH
# sides — otherwise an in-app wiring break (e.g. PR #434 drawio) passes the mcp
# suite green and only surfaces on develop after merge. The `test` job already
# runs everything via `pnpm -r test`; this job is a fast, explicitly-named guard
# that runs the mcp `node --test` suite AND the server tool-guard jest specs
# together, so the coupling is visible and can never be accidentally split by a
# path filter. No Postgres/Redis needed: these specs mock the DB/loader.
mcp-server-parity:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Shared deps first (build/ dirs are gitignored; see test.yml build order).
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build
# Build the mcp package so build/ carries a FRESH REGISTRY_STAMP (#447): the
# build runs gen-registry-stamp.mjs before tsc, so a build/ vs src/ skew
# cannot slip into the tests that exercise the loader's stale-check.
- name: Build mcp (regenerates REGISTRY_STAMP)
run: pnpm --filter @docmost/mcp build
# mcp side: the standalone MCP server's own tool-spec / instructions guards.
- name: Run mcp tool-spec suite
run: pnpm --filter @docmost/mcp test
# server side: the parity + tier guards that read packages/mcp/src/tool-specs
# and assert the in-app AI-chat wiring matches it.
- name: Run server tool-spec guard specs
run: pnpm --filter server exec jest shared-tool-specs.contract tool-tiers ai-chat-tools.service --runInBand
+10
View File
@@ -2,6 +2,11 @@
.env.dev
.env.prod
data
# Exception: the committed draw.io shape catalog (issue #424) lives in a `data/`
# dir, but the bare `data` ignore above is meant for runtime state, not this
# bundled build asset. Re-include the directory and its contents.
!packages/mcp/data/
!packages/mcp/data/**
# compiled output
/dist
node_modules
@@ -19,6 +24,11 @@ packages/prosemirror-markdown/build/
# markdown convention; the package is private and rebuilt at deploy.
packages/mcp/build/
# mcp REGISTRY_STAMP codegen output (issue #447). Regenerated into src/ by
# scripts/gen-registry-stamp.mjs on every `build`/`pretest` (before tsc), so it
# is a build artifact like build/ — never committed, always fresh.
packages/mcp/src/registry-stamp.generated.ts
# Logs
logs
*.log
+151 -2
View File
@@ -5,6 +5,139 @@ repository. It has two layers: **how to run a task end-to-end** (the
sections below), and **how the codebase is built** (the technical sections
further down, formerly in `CLAUDE.md`).
## ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE
THE TEN RULES BELOW ARE HARD CONSTRAINTS. Each one was paid for with a real
production incident or a multi-PR bug chain in THIS repository (cited inline).
They override convenience, deadlines and "it's just a small feature". A PR that
violates any of them MUST be rejected in review regardless of how good the rest
of it is. If a task genuinely seems to require breaking one — STOP and raise it
with the owner; do not code around it.
### 1. EVERY BUFFER, CACHE, HISTORY AND PAYLOAD HAS AN EXPLICIT SIZE BUDGET
Nothing accumulates unboundedly. A row/item cap is NOT a byte cap. Anything
replayed to a model, buffered in memory, persisted per step, or refetched by a
poll must state its budget in bytes/tokens and enforce it. Rewriting a growing
structure in full on every increment is FORBIDDEN — append or diff instead;
O(n²) write/serialize patterns do not pass review.
(Paid for by: full-row rewrite on every agent step — hundreds of MB of Postgres
writes per 50-step run, with every tool output serialized twice; unbounded
history replay killing long chats on the provider context window; 32 MB replay
buffers per active run.)
### 2. EVERYTHING LONG-RUNNING TERMINATES BY CONSTRUCTION
Every run / row / session / lease / subscriber / queue entry must define AT
DESIGN TIME: its owner; every terminal state; who writes the terminal state on
EVERY path (success, error, abort, disconnect in each phase, process restart);
retries for the terminal write; and a periodic sweeper that does not depend on
a reboot. A best-effort terminal write with no retry and no sweep is FORBIDDEN.
(Paid for by: assistant rows stuck 'streaming' forever; runs stuck 'running'
409-locking their chat until a restart — the #183/#184 follow-up chain.)
### 3. EVERY AWAIT IS CANCELLABLE AND DEADLINED; NEVER BLOCK THE EVENT LOOP
Every async step inside a request or agent turn honors the turn's AbortSignal
AND a wall-clock deadline — including in-app tools, lock queues and pagination
loops, not just external calls. Synchronous CPU work beyond ~50 ms goes to a
worker_thread. Promise.race DOES NOT cancel synchronous work — using it as a
"timeout" for sync computation is forbidden (the timer only fires after the
event loop is free again, i.e. after the damage is done).
(Paid for by: in-app tools ignoring abortSignal and writing pages AFTER Stop;
the synchronous ELK layout freezing every SSE stream in the process; the
step-0 MCP handshake hang — #397.)
### 4. ONE SOURCE OF TRUTH; EVERYTHING ELSE IS A REBUILDABLE CACHE
Postgres is the authoritative state. Every in-memory structure (registries,
caches, client stores) must be reconstructible from the DB and treated as
lossy. The client renders SERVER-DECLARED state — "a run is active" is a server
fact delivered as data, never inferred from side signals (204 vs 2xx, the
flavor of a disconnect). A new feature must name the owner of each piece of
state before implementation starts.
(Paid for by: the strip/restore resume machinery, silently frozen UIs and
ghost sends after unmount — the #381#432#456 chain.)
### 5. STATE MACHINES ARE EXPLICIT — ONE-SHOT FLAGS ARE FORBIDDEN
A complex lifecycle (chat thread, resume/reconnect, run) lives in a named-state
automaton (reducer / enum) where every state has an owner and a rendered
representation — including the failure states. Adding a boolean ref that one
callback arms and another reads-and-clears is FORBIDDEN in the AI-chat client.
New behavior = a new named state + explicit transitions, and the interruption
matrix (disconnect in each phase × restart × stop × supersede) is enumerated at
design time, not discovered one incident at a time.
(Paid for by: 26 one-shot useRef flags in chat-thread.tsx and the drip of
"one more missing transition" across #381#386/#389#432#456.)
### 6. NO NEW MODE FORKS; A FLAG IS FOR ROLLOUT, THEN IT DIES
A behavior flag that forks a code path must ship with a written sunset
condition; stacking a new flag onto the existing matrix without deleting or
scheduling an old one is forbidden. While a temporary fork exists, BOTH sides
must share identical lifecycle handling (abort semantics, error listeners,
concurrency gates) — asymmetric forks are outlawed.
(Paid for by: legacy vs autonomous divergence — the one-active-run gate and
the socket 'error' listener each existing on only ONE side; 2^4 flag
combinations each with different abort semantics.)
### 7. NO HAND-SYNCED MIRRORS — CODEGEN OR A CI PARITY TEST, NOTHING LESS
Two copies of the same knowledge (schema, tool registry, glyph map, probe
body, hash/normalize algorithm, label list) require either generation from a
single source or a CI test that FAILS on drift. A "mirror this change over
there" comment is NOT a guard and does not pass review.
(Paid for by: #293 — three drifting converter copies losing data; #447
REGISTRY_STAMP covering only one of the mirrored files; ~10 still-unguarded
mirrors across the MCP layer.)
### 8. CACHES, HEADERS, BUFFERS AND FSM TRANSITIONS GET AN INTEGRATION TEST OF THE OBSERVABLE PROPERTY
A unit test of a pure helper DOES NOT COUNT for these. Test the real header on
the real HTTP response, the real cache hit under real token sources, the real
transition under a really-killed socket. If the observable property cannot be
tested, the design is wrong — fix the design, not the test.
(Paid for by: #431#439 — a cache keyed on a fresh-per-call JWT, so it NEVER
hit and became prod incident #435 while its unit tests stayed green; and by
the #352#455 immutable-cache header silently overwritten by a framework
default AFTER the unit-tested code ran.)
### 9. CLIENT INPUT IS HOSTILE UNTIL VALIDATED — ALSO BEFORE PERSISTENCE
Anything from the browser (message parts, ids, titles, selections, flags) is
validated/sanitized BEFORE it is persisted into a row that will later be
replayed into a prompt, a converter or another subsystem. A poisoned row must
never be able to permanently brick a chat or a page on every subsequent read.
(Paid for by: unvalidated UIMessage parts persisted verbatim — one bad row
500s the chat on every later turn; #159 client-spoofed page titles; #388
selection re-sanitized server-side for the same reason.)
### 10. FAILURES ARE LOUD AND SPECIFIC; SILENT DEGRADATION IS FORBIDDEN
Extends the error convention below: a fire-and-forget write is allowed ONLY
with a metric or a greppable ERROR log; a degraded mode (dead cached MCP
client, stopped poll, exhausted retries, evicted buffer) must be VISIBLE to
the user or the operator. A feature that can quietly stop working — a frozen
"streaming…" UI, a poll that silently gives up, a cache serving corpses — does
not pass review.
(Paid for by: the degraded poll's silent 10-minute death leaving a forever-
"streaming" answer; dead MCP clients served from cache while every external
tool call failed; #435 being caught in minutes ONLY because metrics — #403
existed.)
## Default skill for feature design
For any feature-design request — the user hands over a raw feature idea, asks
to design or think through a feature, or to draft an issue («спроектируй»,
«продумай фичу», «составь ишью», "design X", "write an issue for X") — invoke
the `orchestrator-feature-designer` skill (Skill tool) BEFORE any other work.
It is the default operating mode for design work in this repository: research
→ design checklist (R1–R10) → forks resolved with the human → adversarial
self-attack → filed PR-sized issues. Do not design features or write issues
ad-hoc while this skill is available. This does not apply to non-design work
(bug fixes, reviews, retrospectives, refactors already specified by an issue).
## Task lifecycle
### 1. Start: sync with develop
@@ -248,6 +381,22 @@ pnpm collab:dev # run the collaboration server process standalone (
> that order). Reach for it whenever you run a consumer package's checks on their
> own rather than through the full `pnpm build`.
> **Editing an MCP tool spec requires a rebuild (issue #447).** The running
> server loads the **compiled** `packages/mcp/build/` of `@docmost/mcp` (via the
> runtime loader in `apps/server/src/core/ai-chat/tools/docmost-client.loader.ts`),
> but the parity/tier guard tests read `packages/mcp/src/tool-specs.ts`. So if you
> edit `tool-specs.ts` (any tool name, description, tier, catalog line, or input
> schema) **without rebuilding**, `build/` and `src/` silently diverge — the tests
> stay green while the server serves the OLD tools. To close that gap, the build
> emits a `REGISTRY_STAMP` (a deterministic hash of the tool-specs content, via
> `scripts/gen-registry-stamp.mjs` before `tsc`); on dev/test startup the loader
> recomputes it from `src/` and **refuses to start with a "@docmost/mcp build is
> stale …" error** on a mismatch (a pure no-op in prod, where only `build/` ships).
> After editing tool specs, rebuild:
> ```bash
> pnpm --filter @docmost/mcp build # or: pnpm --filter @docmost/mcp watch
> ```
**Lint** (per package — there is no root lint script):
```bash
pnpm --filter server lint # eslint --fix on server .ts
@@ -321,8 +470,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
## CI / release
+184
View File
@@ -10,6 +10,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Breaking Changes
- **External MCP tool names are now camelCase (all renamed).** Every tool on the
external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the
external MCP name now matches the in-app tool name exactly (one logical tool,
one name everywhere). For example `get_node``getNode`, `edit_page_text`
`editPageText`, `patch_node``patchNode`. The tools' behaviour, inputs and
outputs are unchanged — only the names change. The single-word `search`
keeps its name.
*Migration (external MCP clients only — the in-app AI agent already used these
names and is unaffected):* update anything that refers to a tool by its
string name — permission allowlists (`mcp__gitmost-*__get_node`
`mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters,
and metrics dashboards that group by the `tool` label — and roll it out in
lockstep with this deploy, because the old snake_case names stop resolving.
Released together with the `import_page_markdown`/`update_page_markdown`
change below so external configs break exactly once.
Full mapping (old → new):
| Old (snake_case) | New (camelCase) |
| --- | --- |
| `check_new_comments` | `checkNewComments` |
| `copy_page_content` | `copyPageContent` |
| `create_comment` | `createComment` |
| `create_page` | `createPage` |
| `delete_comment` | `deleteComment` |
| `delete_node` | `deleteNode` |
| `delete_page` | `deletePage` |
| `diff_page_versions` | `diffPageVersions` |
| `docmost_transform` | `docmostTransform` |
| `drawio_create` | `drawioCreate` |
| `drawio_get` | `drawioGet` |
| `drawio_guide` | `drawioGuide` |
| `drawio_shapes` | `drawioShapes` |
| `drawio_update` | `drawioUpdate` |
| `edit_page_text` | `editPageText` |
| `export_page_markdown` | `exportPageMarkdown` |
| `get_node` | `getNode` |
| `get_outline` | `getOutline` |
| `get_page` | `getPage` |
| `get_page_json` | `getPageJson` |
| `get_workspace` | `getWorkspace` |
| `insert_footnote` | `insertFootnote` |
| `insert_image` | `insertImage` |
| `insert_node` | `insertNode` |
| `list_comments` | `listComments` |
| `list_page_history` | `listPageHistory` |
| `list_pages` | `listPages` |
| `list_shares` | `listShares` |
| `list_spaces` | `listSpaces` |
| `move_page` | `movePage` |
| `patch_node` | `patchNode` |
| `rename_page` | `renamePage` |
| `replace_image` | `replaceImage` |
| `resolve_comment` | `resolveComment` |
| `restore_page_version` | `restorePageVersion` |
| `search` | `search` (unchanged) |
| `search_in_page` | `searchInPage` |
| `share_page` | `sharePage` |
| `stash_page` | `stashPage` |
| `table_delete_row` | `tableDeleteRow` |
| `table_get` | `tableGet` |
| `table_insert_row` | `tableInsertRow` |
| `table_update_cell` | `tableUpdateCell` |
| `unshare_page` | `unsharePage` |
| `update_comment` | `updateComment` |
| `update_page_json` | `updatePageJson` |
| `update_page_markdown` | `updatePageMarkdown` |
(#412)
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
The external `/mcp` surface no longer exposes `importPageMarkdown` (the
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
its place it now exposes **`updatePageMarkdown`** — a plain-Markdown
full-body replace (`{pageId, content, title?}`) that pairs with
`updatePageJson`, re-imports the whole body (block ids regenerate) and
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
*Migration:* MCP clients that called `importPageMarkdown` to overwrite a
page's body from Markdown should call `updatePageMarkdown` instead (pass the
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
comment anchors/diagrams is no longer available on the external MCP surface;
export remains via `exportPageMarkdown`. The in-app AI agent is unaffected —
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The
external names shown here are the post-#412 camelCase names. (#411)
- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The
block-level read/write tools default to Markdown so a block round trip is
`getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns
`{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which
restores the previous `{ …, node }` ProseMirror subtree); comment anchors —
including resolved ones — are preserved in the markdown so a write-back never
orphans a thread, and a node that cannot be a document top-level block
(`tableRow`/`tableCell`/`tableHeader` addressed via `#<index>`) auto-falls back
to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a
`markdown` input alongside `node` (provide exactly one): the markdown fragment
may rewrite/insert several blocks at once and supports `^[...]` footnotes.
*Migration (external MCP clients only):* a client that consumed `getNode`'s
`node` field must now either read `markdown`, or pass `format: "json"` to keep
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
breaking window so external configs break exactly once. (#413)
- **The Prometheus `/metrics` listener now binds to `127.0.0.1` (loopback) by
default instead of `0.0.0.0` (all interfaces).** This closes an unauthenticated
endpoint that was previously reachable on every interface. **DEPLOY MIGRATION —
cross-container scraping breaks silently otherwise:** if your scraper runs in a
SEPARATE container and reaches the app as `docmost:9464` (the exact topology the
old `0.0.0.0` hardcode served), you MUST now set `METRICS_BIND=0.0.0.0` — and,
because that re-exposes the endpoint, also set `METRICS_TOKEN=<secret>` and
configure the scraper with a matching Bearer token. Without `METRICS_BIND`, the
scraper can no longer connect and metrics go dark with no error. See the
`METRICS_BIND` / `METRICS_TOKEN` block in `.env.example` for the migration.
Same-host (loopback) scrapers need no change. (#486)
### Added
- **Place several images side by side in a row.** A new "Inline (side by
@@ -146,6 +263,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
by physical key position and matched against the commands; genuine Cyrillic
search terms keep priority over remapped candidates, and short wrong-layout
prefixes match by command title. (#283, #285, #287)
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
substrings of page titles and body text — so technical tokens the full-text
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
even when the FTS query is empty. It returns a location `path`, a windowed
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
`parentPageId` subtree scope, and applies the page-level permission filter
before the limit. The web UI never sets `substring`, so its full-text search
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
a sequential scan. (#443)
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
`search` response shape changes for the agent surface: each hit now carries
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
### Changed
@@ -177,6 +310,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
- **The server no longer leaks a hung stream pipe on every mid-run client
disconnect.** The same `ai@6.0.134` pnpm patch now also fixes the SDK's
`writeToServerResponse`, which awaited only a `"drain"` event under
backpressure: when a client disconnected mid-write the socket never drained, so
the write loop parked forever, `response.end()` was unreachable, and the stream
reader plus buffered chunks were pinned until process restart (every mid-run
disconnect in autonomous mode leaked one). The patch races `"drain"` against
`"close"`/`"error"`, cancels the reader and ends the response on disconnect, and
swallows the fire-and-forget read rejection instead of crashing on an
unhandledRejection. (#486)
- **A failed autonomous agent-run start no longer becomes an unstoppable ghost
run.** When `beginRun` failed for a transient reason (e.g. a DB-pool blip),
the turn previously continued with NO run row — invisible to `/stop`, not
aborted on disconnect, and able to slip a second run past the one-run-per-chat
gate, leaving an unstoppable run until restart. The turn now fails fast with an
honest `503 A_RUN_BEGIN_FAILED` before the first byte (no orphan state), and the
client shows a "temporary — please try again" message instead of a misleading
"provider not configured". (#486)
- **A pathological draw.io graph can no longer wedge the whole server.** The ELK
auto-layout (`layout:"elk"`) ran elkjs synchronously on the main event loop, so
a graph at the node/edge cap blocked ALL HTTP/SSE/loopback traffic while it
churned — and the old `setTimeout` "timeout" could never fire because the same
thread was blocked. Layout now runs in a worker thread with the timeout enforced
by `worker.terminate()`; the main loop stays responsive. (#486)
- **The `/health` Redis probe no longer leaks a client on every tick while Redis
is down.** It built a new `ioredis` client per probe and disconnected it only on
success, so during an outage each health tick added another forever-reconnecting
client (an unbounded handle leak). A single long-lived probe client is now
reused and closed on shutdown. (#486)
- **Internal links in exported Markdown no longer lose their visible text.** A
link whose target page name had no file extension (e.g. a bare title) was
collapsed to empty text during export, producing an unclickable, label-less
@@ -253,6 +419,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
share); any other value now returns the generic "not found" instead of
serving the page. (#218)
- **Tool and provider error text no longer leaks to anonymous readers in the
public-share AI chat.** A failing tool's raw error (which could carry an
internal page title or a stack fragment) and a provider error (which bundles the
provider `statusCode` and response body — potentially the internal baseUrl or
model name) were streamed verbatim to the anonymous reader over SSE. Errors are
now sanitized at the source: the share toolset collapses any unclassified tool
error to a safe generic string (safe, classified tool messages still pass
through for the model's self-correction), and the anonymous stream `onError`
maps provider failures to a fixed set of neutral strings — the full detail goes
only to the server log. A UI render gate is layered on top. (closes #394)
- **The Prometheus `/metrics` endpoint can now require Bearer authentication and
is loopback-bound by default.** Previously it listened on all interfaces with no
auth. Setting `METRICS_TOKEN` requires every scrape to present
`Authorization: Bearer <token>` (compared in constant time), and the listener
defaults to `127.0.0.1` (see the Breaking Changes entry for the cross-container
migration). (#486)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
+15
View File
@@ -45,6 +45,11 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
# The mcp package reads its data files (drawio-presets.json, drawio-shape-index.json.gz)
# at runtime via `new URL("../../data/…", import.meta.url)` relative to build/lib/*.js,
# i.e. from packages/mcp/data/. tsc emits only build/, so ship data/ explicitly or
# drawioFromGraph and the shape catalog die with ENOENT on packages/mcp/data/*.
COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
# markdown-converter.js). Ship the built package + its manifest, or the prod
@@ -81,4 +86,14 @@ VOLUME ["/app/data/storage"]
EXPOSE 3000
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
# stash_page blob store + cached collab sessions are RAM-only and process-local.
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
# sessions silently breaks per-page write serialization (two replicas can lock
# the same page at once) and makes stash_page blobs unreachable across replicas.
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
# sessions / consistent hashing on page id). There is deliberately no
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
# collaboration write path" block) and packages/mcp/README.md for details.
CMD ["pnpm", "start"]
@@ -28,7 +28,10 @@ const h = vi.hoisted(() => ({
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
prepareReconnectToStreamRequest?: () => { api?: string };
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
fetch?: (
input: unknown,
init?: { method?: string; body?: unknown },
) => Promise<unknown>;
},
},
}));
@@ -200,6 +203,290 @@ describe("ChatThread — send now (#198)", () => {
});
});
// #486: the final onFinish -> flushNext() must be gated on the live-mount flag.
// A clean onFinish can land AFTER the thread unmounts (New-chat / chat-switch
// mid-stream — the async attach/resume settles late); flushing then dequeues and
// re-POSTs a queued message from an abandoned thread (a "ghost" send).
describe("ChatThread — onFinish flush gated on mount (#486)", () => {
beforeEach(resetState);
afterEach(cleanup);
it("a clean onFinish WHILE MOUNTED flushes the queued message (control)", () => {
renderThread();
fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text"
expect(h.state.sendMessage).not.toHaveBeenCalled();
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
isAbort: false,
isDisconnect: false,
isError: false,
});
});
// Mounted: the queue flushes normally.
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
});
it("a clean onFinish AFTER unmount does NOT flush (no ghost send)", () => {
const { unmount } = renderThread();
fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text"
h.state.sendMessage.mockClear();
// Chat switched away mid-stream: the streamer unmounts...
unmount();
// ...and a late, clean onFinish lands on the abandoned thread.
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
isAbort: false,
isDisconnect: false,
isError: false,
});
});
// Gated on mountedRef: NOTHING is sent from the dead thread.
expect(h.state.sendMessage).not.toHaveBeenCalled();
});
});
// #396: in autonomous mode a live sendNow must additionally request the
// AUTHORITATIVE server stop of the detached run (a local abort is only a client
// disconnect the server ignores) and arm a bounded 409 retry so the re-POST
// converges once the one-active-run slot frees. Legacy mode is unchanged.
describe("ChatThread — send now server-stop + supersede retry (#396)", () => {
beforeEach(resetState);
afterEach(cleanup);
// A settled assistant tail => no mount resume (attemptResumeRef false), so the
// "Send now" button is visible for the NEW local streaming turn while
// autonomous runs are enabled.
const settledTail = () => [
row("u1", "user", undefined, "hi"),
row("a1", "assistant", "succeeded", "done"),
];
it("autonomous: sendNow during a live stream calls onServerStop with the chat id", () => {
const { onServerStop } = renderThread({
autonomousRunsEnabled: true,
initialRows: settledTail(),
});
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
expect(h.state.stop).toHaveBeenCalledTimes(1);
expect(onServerStop).toHaveBeenCalledWith("c1");
});
it("legacy (autonomous off): sendNow does NOT call onServerStop and does NOT retry the send", async () => {
const { onServerStop } = renderThread({
autonomousRunsEnabled: false,
initialRows: settledTail(),
});
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
expect(onServerStop).not.toHaveBeenCalled();
// The supersede retry must NOT be armed: a POST that 409s is returned as-is
// (single fetch, no retry).
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
it("armed supersede send retries 409 A_RUN_ALREADY_ACTIVE and succeeds once the slot frees", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
// Arm the retry by performing a live sendNow (autonomous branch sets the ref).
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const fetchMock = vi
.fn()
// First POST: the old detached run still holds the slot -> 409.
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
)
// Retry: the server stop settled the old run -> 200.
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(res.status).toBe(200);
});
it("supersede retry is one-shot: a later send (ref cleared) does NOT retry a 409", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now")); // arms the one-shot
// First armed send: immediately succeeds, consuming the arm.
let fetchMock = vi
.fn()
.mockResolvedValue(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await act(async () => {
await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
});
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// A subsequent send is NOT armed -> a 409 is returned as-is (no retry).
fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
it("supersede retry is bounded: exhaustion surfaces the 409 error", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// Every attempt 409s -> after 4 attempts the last 409 surfaces.
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
// 4 attempts total (1 immediate + 3 backoff retries), then give up.
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(res.status).toBe(409);
});
it("armed supersede send does NOT retry a non-409 status", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("boom", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(500);
});
// Strand-path regression: sendNow arms the supersede retry, but if the promoted
// head is removed before the abort's onFinish lands, flushNext() sends nothing
// (returns false) and NO re-POST consumes the arm. The arm must be disarmed on
// that no-send branch so the NEXT unrelated NORMAL send does not inherit it and
// silently retry a genuine 409 (e.g. a legitimate two-tab conflict) 4x instead
// of surfacing it immediately.
it("strand-path: a stranded supersede arm (flushNext no-send) does NOT retry a later normal 409", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
// Arm the retry via a live autonomous sendNow (promotes the head + arms).
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// Remove the promoted head BEFORE the abort lands, so flushNext() returns
// false (no POST) and the arm would strand without the disarm fix.
fireEvent.click(screen.getByLabelText("Remove queued message"));
// The abort's onFinish now takes the flushOnAbortRef branch, calls flushNext()
// which finds an empty queue and returns false -> the no-send disarm must run.
act(() => {
h.state.onFinish?.({
message: { id: "a1", role: "assistant", parts: [] },
isAbort: true,
isDisconnect: false,
isError: false,
});
});
// No re-POST was sent (nothing to flush).
expect(h.state.sendMessage).not.toHaveBeenCalled();
// A subsequent NORMAL send that 409s must be returned as-is (exactly 1 fetch):
// the stranded arm must NOT cause the genuine 409 to be retried.
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ code: "A_RUN_ALREADY_ACTIVE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
it("armed supersede send does NOT retry a 409 with a different (non-A_RUN_ALREADY_ACTIVE) body", async () => {
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "SOMETHING_ELSE" }), {
status: 409,
}),
);
vi.stubGlobal("fetch", fetchMock);
let res!: Response;
await act(async () => {
res = (await h.state.transport!.fetch!("http://x", {
method: "POST",
body: "{}",
})) as Response;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(res.status).toBe(409);
});
});
// #388: the editor selection is snapshotted at send time and nested inside
// openPage on the wire. The getter is read live from a ref, so each send ships a
// fresh snapshot.
@@ -70,6 +70,36 @@ const RECONNECT_MAX_ATTEMPTS = 5;
// Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s.
const RECONNECT_BASE_DELAY_MS = 1000;
// #396: bounded retry for the "Interrupt and send now" re-send when it races the
// authoritative server stop of the just-superseded detached run. The re-POST can
// arrive before the old run has released the one-active-run slot, so the server
// returns 409 A_RUN_ALREADY_ACTIVE. The server stop guarantees the slot frees, so
// a few short backoffs converge. 4 total attempts: attempt 1 fires immediately,
// then these are the waits BEFORE attempts 2, 3 and 4 (150ms, 300ms, 600ms). If
// all 4 attempts 409, the last 409 surfaces (the banner) — acceptable per #396.
const SUPERSEDE_RETRY_DELAYS_MS = [150, 300, 600];
// The server error code that means "another run is already active for this chat".
const A_RUN_ALREADY_ACTIVE = "A_RUN_ALREADY_ACTIVE";
/**
* #396: defensively decide whether a 409 response is the one-active-run gate
* rejection (code A_RUN_ALREADY_ACTIVE) vs. some other 409. Reads a CLONE so the
* original response body stays intact for the caller when it is returned as-is.
* Any parse failure or unexpected shape => false (do NOT retry).
*/
async function isRunAlreadyActive(response: Response): Promise<boolean> {
try {
const body = (await response.clone().json()) as unknown;
return (
typeof body === "object" &&
body !== null &&
(body as { code?: unknown }).code === A_RUN_ALREADY_ACTIVE
);
} catch {
return false;
}
}
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -326,6 +356,26 @@ export default function ChatThread({
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// #396: one-shot arm for the bounded 409 A_RUN_ALREADY_ACTIVE retry on the
// "Interrupt and send now" re-send in autonomous mode. sendNow triggers the
// authoritative server stop of the detached run, but that stop and the
// onFinish->flushNext re-POST race: the new POST can hit the one-active-run
// gate before the old detached run has settled, yielding a spurious 409. When
// this ref is armed, the transport's send path retries that 409 with a short
// bounded backoff (the server stop guarantees convergence). A normal send (ref
// not armed) must STILL fail a 409 instantly (e.g. a genuine two-tab conflict).
//
// INVARIANT: sendNow arms this only to be consumed by the ONE re-POST that
// flushNext fires from onFinish. But that re-POST does not always happen (the
// promoted head may be gone, the finish may be a resumed turn, or the arm may
// race a stale finish). To keep the arm strictly one-shot it is disarmed on
// EVERY path where the paired interrupt one-shots (flushOnAbortRef /
// interruptNextSendRef) are cleared without a POST: the transport POST branch
// consumes it (read-and-clear), the onFinish `!flushNext()` no-send branch
// clears it, and the isStreaming-defuse effect clears it symmetrically. So it
// can never leak into a later, unrelated send and retry that send's genuine 409.
const supersedeRetryRef = useRef(false);
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
@@ -382,7 +432,43 @@ export default function ChatThread({
}`,
}),
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
if ((init.method ?? "GET") !== "GET") {
// Send path (POST). #396: read-and-clear the one-shot supersede arm
// here so it is strictly scoped to THIS send. When unarmed, behave
// exactly as before — a single fetch, a 409 surfaces instantly (a
// genuine two-tab conflict must NOT be retried).
const supersede = supersedeRetryRef.current;
supersedeRetryRef.current = false;
if (!supersede) return fetch(input, init);
// Buffer a ReadableStream body once so each retry can replay it.
// DefaultChatTransport sends the body as a JSON STRING (replayable as
// is), but guard defensively in case a future SDK streams it.
let sendInit = init;
if (init.body instanceof ReadableStream) {
const buffered = await new Response(init.body).arrayBuffer();
sendInit = { ...init, body: buffered };
}
// Bounded retry: attempt 1 fires immediately, then wait between
// attempts per SUPERSEDE_RETRY_DELAYS_MS. Retry ONLY on a real
// 409 A_RUN_ALREADY_ACTIVE; any other status/body is returned as-is.
for (let attempt = 0; ; attempt++) {
const response = await fetch(input, sendInit);
if (
response.status !== 409 ||
attempt >= SUPERSEDE_RETRY_DELAYS_MS.length ||
!(await isRunAlreadyActive(response))
) {
return response;
}
// The old detached run has not released the one-active-run slot
// yet; the server stop we requested guarantees it will, so back off
// and re-POST (the 409 fired before the user message was persisted,
// so re-POSTing is safe — no duplicate rows).
await new Promise((r) =>
setTimeout(r, SUPERSEDE_RETRY_DELAYS_MS[attempt]),
);
}
}
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
// for observer Stop / unmount abort.
const controller = new AbortController();
@@ -562,13 +648,24 @@ export default function ChatThread({
setStopNotice(null);
// If the promoted head vanished (e.g. the user removed it before the
// abort landed) flushNext sends nothing — clear the one-shot interrupt
// tag so it can't leak onto the next unrelated send. On a real send the
// tag is consumed by prepareSendMessagesRequest and stays untouched.
if (!flushNext()) interruptNextSendRef.current = false;
// tag AND the #396 supersede arm so neither can leak onto the next
// unrelated send (no re-POST will consume the arm here). On a real send
// the tag is consumed by prepareSendMessagesRequest and the arm by the
// transport POST branch, so both stay untouched then.
if (!flushNext()) {
interruptNextSendRef.current = false;
supersedeRetryRef.current = false;
}
return;
}
if (isAbort || isDisconnect || isError) return;
flushNext();
// Gate the final flush on the live-mount flag (#486): a clean onFinish can
// land AFTER this thread unmounted (a New-chat / chat-switch mid-stream —
// the async attach/resume settles late). Flushing then dequeues and POSTs a
// queued message from an abandoned thread — a "ghost" send / ghost chat.
// Every other queue side effect already guards on mountedRef; this last one
// was the gap.
if (mountedRef.current) flushNext();
},
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
// Log the raw failure here for devtools; the UI shows a friendly classified
@@ -873,6 +970,30 @@ export default function ChatThread({
setQueue(promoteToHead(queuedRef.current, id));
flushOnAbortRef.current = true;
interruptNextSendRef.current = true;
// #396: in autonomous mode the turn is a DETACHED run — a local stop()
// is only a client disconnect the server ignores, so the run keeps going.
// The onFinish->flushNext re-POST would then hit the one-active-run gate
// and get a spurious 409 A_RUN_ALREADY_ACTIVE. Mirror handleStop: request
// the AUTHORITATIVE server stop so the detached run settles, and arm the
// one-shot bounded 409 retry BEFORE stop() so the re-send converges once
// the slot frees. Read chatId live from chatIdRef (adopted at the `start`
// chunk). If it is not known yet (brand-new chat, first moment of its
// first turn), defer the server stop via stopPendingRef exactly as
// handleStop does — the onServerChatId adoption effect fires it once the
// id lands; the retry stays armed so the re-send still converges then.
if (autonomousRunsEnabled) {
supersedeRetryRef.current = true; // arm the bounded 409 retry
if (chatIdRef.current) {
onServerStop?.(chatIdRef.current);
} else {
// Same #234-F5 sub-window limitation documented in handleStop: if the
// local abort below cancels the reader before the `start` chunk lands,
// the adoption effect never runs and the deferred stop never fires. Not
// a regression; at minimum we don't strand refs (the isStreaming effect
// defuses stopPendingRef on the next turn start).
stopPendingRef.current = true;
}
}
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
} else {
// Nothing to interrupt: just send it now (no interrupt note).
@@ -884,7 +1005,7 @@ export default function ChatThread({
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop, setResumedTurnPair],
[setQueue, stop, setResumedTurnPair, autonomousRunsEnabled, onServerStop],
);
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
@@ -944,6 +1065,13 @@ export default function ChatThread({
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
// #396: symmetric with the other one-shot interrupt flags — defuse a stale
// supersede arm that was set but whose expected re-POST never fired (the
// turn finished in the same tick as the click, or the promoted head was
// gone), so it can never leak into this (or a later) turn's send and retry
// that send's genuine 409. A legit arm is consumed by the transport POST
// branch before this new turn streams, so this does not clobber it.
supersedeRetryRef.current = false;
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
// previous turn that never adopted an id, so it can never fire against this
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
@@ -47,6 +47,13 @@ interface MessageItemProps {
* agent's raw query/argument text.
*/
showInput?: boolean;
/**
* Forwarded to ToolCallCard: whether a failed tool card renders its raw
* errorText. Defaults to true (internal chat). The public share passes false so
* internal detail in a tool error is never painted (belt to the server-side
* byte sanitization).
*/
showErrors?: boolean;
/**
* Neutralize internal/relative markdown links in the rendered answer (drop
* their href so they become inert text). Defaults to false (internal chat,
@@ -125,6 +132,7 @@ function MessageItem({
message,
showCitations = true,
showInput = true,
showErrors = true,
neutralizeInternalLinks = false,
assistantName,
turnStreaming = false,
@@ -219,6 +227,7 @@ function MessageItem({
part={part as unknown as ToolUiPart}
showCitations={showCitations}
showInput={showInput}
showErrors={showErrors}
/>
);
}
@@ -284,6 +293,7 @@ export function arePropsEqual(
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.showInput === next.showInput &&
prev.showErrors === next.showErrors &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName &&
// The turn-end flip re-renders every row once (cheap, terminal event) —
@@ -32,6 +32,12 @@ interface MessageListProps {
* doesn't see the agent's raw query/argument text.
*/
showInput?: boolean;
/**
* Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders
* its raw errorText. Defaults to true (internal chat). The public share passes
* false so internal detail in a tool error is never painted.
*/
showErrors?: boolean;
/**
* Forwarded to MessageItem: neutralize internal/relative markdown links in
* the rendered answers (drop their href so they render as inert text).
@@ -127,6 +133,7 @@ export default function MessageList({
emptyState,
showCitations = true,
showInput = true,
showErrors = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageListProps) {
@@ -217,6 +224,7 @@ export default function MessageList({
signature={messageSignature(message)}
showCitations={showCitations}
showInput={showInput}
showErrors={showErrors}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}
// Turn-level liveness, gated to the TAIL row: only the tail message
@@ -30,6 +30,16 @@ interface ToolCallCardProps {
* the extra summary line, leaving the card (the action log) intact.
*/
showInput?: boolean;
/**
* Whether to render the tool's raw errorText on a failed call. Defaults to true
* (the internal chat, where the operator may debug). The public share passes
* false: a tool error string can carry internal detail (an internal page title,
* a stack fragment, a provider message). This is the RENDER gate only — the
* authoritative fix also sanitizes the bytes server-side (see
* PublicShareChatToolsService.forShare), so a share reader never receives raw
* error text over the wire, not just never sees it painted (#394).
*/
showErrors?: boolean;
}
/**
@@ -41,6 +51,7 @@ export default function ToolCallCard({
part,
showCitations = true,
showInput = true,
showErrors = true,
}: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
@@ -74,7 +85,7 @@ export default function ToolCallCard({
</Text>
)}
{state === "error" && part.errorText && (
{state === "error" && showErrors && part.errorText && (
<Text size="xs" c="red" mt={2}>
{part.errorText}
</Text>
@@ -23,6 +23,25 @@ describe("describeChatError", () => {
});
});
it("classifies an A_RUN_BEGIN_FAILED 503 as a temporary run-start failure, NOT provider-not-configured (#486)", () => {
// The FULL real body the server writes for a beginRun failure: a
// ServiceUnavailableException(object) whose response is serialized verbatim
// onto the raw socket, self-describing statusCode 503 + the run-start code.
const body =
'{"message":"Could not start the agent run. This is usually temporary — please try again.","code":"A_RUN_BEGIN_FAILED","statusCode":503}';
expect(describeChatError(body, t)).toEqual({
title: "Could not start the run",
detail:
"The agent run could not be started. This is usually temporary — please try again.",
});
// ORDER GUARD: even though the body ALSO carries statusCode 503 (which the
// generic branch matches), the A_RUN_BEGIN_FAILED branch runs first, so it is
// never mislabeled "AI provider not configured".
expect(describeChatError(body, t).title).not.toBe(
"AI provider not configured",
);
});
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
expect(
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
@@ -24,6 +24,21 @@ export function describeChatError(
): ChatErrorView {
const msg = message ?? "";
// Our own "could not start the run" gate (A_RUN_BEGIN_FAILED, #486): a 503
// whose body carries this code is a TEMPORARY server-side failure while
// starting the run (e.g. a DB-pool blip), NOT an unconfigured provider. It MUST
// be matched STRICTLY BEFORE the generic 503 branch below, which would
// otherwise mislabel it "The AI provider is not configured" and tell the user
// to call an admin instead of just retrying.
if (/"code"\s*:\s*"A_RUN_BEGIN_FAILED"/.test(msg)) {
return {
title: t("Could not start the run"),
detail: t(
"The agent run could not be started. This is usually temporary — please try again.",
),
};
}
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
return {
title: t("AI chat is disabled"),
@@ -168,6 +168,10 @@ export default function ShareAiWidget({
// Anonymous reader: suppress the tool-argument summary line so the
// agent's raw query/argument text isn't shown on the public share.
showInput={false}
// Anonymous reader: never paint a tool's raw errorText (it can carry
// internal detail). This is the render gate; the bytes are also
// sanitized server-side in PublicShareChatToolsService.forShare (#394).
showErrors={false}
// Anonymous reader: neutralize internal/relative links in the
// assistant's markdown so internal UUIDs/auth-gated routes don't
// leak as clickable links (external http(s) links are kept).
@@ -43,6 +43,9 @@ function makeRepo(overrides: Record<string, jest.Mock> = {}) {
workspaceId: v.workspaceId,
})),
update: jest.fn(async () => ({ id: 'run-1' })),
// #487: terminal finalize now goes through the CONDITIONAL write. Default
// returns a truthy row (the run WAS active -> this call wrote it).
finalizeIfActive: jest.fn(async () => ({ id: 'run-1', status: 'succeeded' })),
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
findActiveByChat: jest.fn(async () => undefined),
findLatestByChat: jest.fn(async () => undefined),
@@ -336,14 +339,12 @@ describe('AiChatRunService run lifecycle', () => {
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
expect(svc.isLocallyActive('run-1')).toBe(false);
expect(repo.update).toHaveBeenCalledWith(
// #487: the terminal write is CONDITIONAL (finalizeIfActive); finishedAt is
// stamped inside the repo method, so the service passes just status + error.
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
'run-1',
'ws-1',
expect.objectContaining({
status: 'failed',
error: 'provider blew up',
finishedAt: expect.any(Date),
}),
expect.objectContaining({ status: 'failed', error: 'provider blew up' }),
);
});
@@ -366,8 +367,8 @@ describe('AiChatRunService run lifecycle', () => {
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
expect(repo.update).toHaveBeenCalledTimes(1);
expect(repo.update).toHaveBeenCalledWith(
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
'run-1',
'ws-1',
expect.objectContaining({ status: 'failed', error: 'first' }),
@@ -389,8 +390,8 @@ describe('AiChatRunService run lifecycle', () => {
const updateGate = new Promise((res) => {
resolveUpdate = res;
});
const update = jest.fn(() => updateGate);
const repo = makeRepo({ update });
const finalizeIfActive = jest.fn(() => updateGate);
const repo = makeRepo({ finalizeIfActive });
const svc = new AiChatRunService(repo as never, makeEnv() as never);
await svc.beginRun({
chatId: 'chat-1',
@@ -399,23 +400,23 @@ describe('AiChatRunService run lifecycle', () => {
});
// Fire both before the (pending) update resolves. The first synchronously
// claims the entry (active.delete) and awaits update; the second, started in
// the same macrotask, finds the entry already gone and returns at the claim
// WITHOUT ever calling update.
// claims the entry (active.delete) and awaits the write; the second, started
// in the same macrotask, finds the entry already gone and returns at the claim
// WITHOUT ever writing.
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
// The decisive assertion: exactly one caller reached the terminal UPDATE.
expect(update).toHaveBeenCalledTimes(1);
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
// Let the single in-flight update land; both calls resolve cleanly.
resolveUpdate({ id: 'run-1' });
resolveUpdate({ id: 'run-1', status: 'succeeded' });
await Promise.all([p1, p2]);
expect(update).toHaveBeenCalledTimes(1);
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
// 'error' settle never wrote, so it could not clobber the real status.
expect(update).toHaveBeenCalledWith(
expect(finalizeIfActive).toHaveBeenCalledWith(
'run-1',
'ws-1',
expect.objectContaining({ status: 'succeeded' }),
@@ -431,10 +432,10 @@ describe('AiChatRunService run lifecycle', () => {
// 409s until a restart. The fix updates FIRST and retries.
let calls = 0;
const repo = makeRepo({
update: jest.fn(async () => {
finalizeIfActive: jest.fn(async () => {
calls += 1;
if (calls === 1) throw new Error('deadlock detected');
return { id: 'run-1' };
return { id: 'run-1', status: 'succeeded' };
}),
});
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
@@ -447,26 +448,29 @@ describe('AiChatRunService run lifecycle', () => {
await svc.finalizeRun('run-1', 'ws-1', 'completed');
// The retry landed the terminal write: the entry is dropped (slot freed) and
// the row carries the real terminal status — NOT stranded at 'running'.
// The retry landed the terminal write: the entry is dropped (slot freed), no
// zombie left, and the row carries the real terminal status.
expect(svc.isLocallyActive('run-1')).toBe(false);
expect(repo.update).toHaveBeenCalledTimes(2);
expect(repo.update).toHaveBeenLastCalledWith(
expect(svc.hasZombie('run-1')).toBe(false);
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(2);
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
'run-1',
'ws-1',
expect.objectContaining({ status: 'succeeded' }),
);
});
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
it('#487 give-up: if the terminal write keeps failing, finalizeRun leaves a ZOMBIE (does NOT restore the entry) and settleZombie re-drives it', async () => {
// Worst case: the DB is down for the whole first finalize (all attempts fail).
// The run must NOT be silently lost — the entry stays so a subsequent settle
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
// #487 changes the give-up behaviour: the entry is NOT restored (a restored
// entry is indistinguishable from a live run). Instead a ZOMBIE record holds
// the intended terminal status, and a re-drive (settleZombie — called by the
// reconcile / supersede / opportunistic paths) applies it later.
let healthy = false;
const repo = makeRepo({
update: jest.fn(async () => {
finalizeIfActive: jest.fn(async () => {
if (!healthy) throw new Error('pool exhausted');
return { id: 'run-1' };
return { id: 'run-1', status: 'succeeded' };
}),
});
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
@@ -480,35 +484,83 @@ describe('AiChatRunService run lifecycle', () => {
userId: 'user-1',
});
// First settle: every bounded attempt fails -> entry retained, NOT settled.
// First settle: every bounded attempt fails -> ZOMBIE, entry NOT restored.
await svc.finalizeRun('run-1', 'ws-1', 'completed');
expect(svc.isLocallyActive('run-1')).toBe(true);
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
// so an operator can tell "gave up, run held in memory" from a per-attempt
// blip — distinct from the per-attempt warns.
expect(svc.isLocallyActive('run-1')).toBe(false); // NOT a live entry
expect(svc.hasZombie('run-1')).toBe(true);
expect(svc.zombieRunIds()).toContain('run-1');
// The give-up emits ONE explicit, greppable ERROR mentioning the zombie.
const gaveUp = errorSpy.mock.calls.some(
(c) =>
/NON-TERMINAL/.test(String(c[0])) &&
/ZOMBIE/.test(String(c[0])) &&
/run-1/.test(String(c[0])) &&
/chat-1/.test(String(c[0])),
);
expect(gaveUp).toBe(true);
// The settle notifier resolved as terminalWriteFailed (a subscriber learns the
// slot still needs the intended status applied).
const outcome = await svc.peekSettled('run-1');
expect(outcome).toEqual({
status: 'succeeded',
error: null,
terminalWriteFailed: true,
});
// The DB recovers; a later settle now succeeds and frees the slot.
// The DB recovers; a re-drive settles the zombie via the conditional UPDATE.
healthy = true;
await svc.finalizeRun('run-1', 'ws-1', 'completed');
expect(svc.isLocallyActive('run-1')).toBe(false);
expect(repo.update).toHaveBeenLastCalledWith(
const redriven = await svc.settleZombie('run-1');
expect(redriven).toBe(true);
expect(svc.hasZombie('run-1')).toBe(false);
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
'run-1',
'ws-1',
expect.objectContaining({ status: 'succeeded' }),
);
// And it is now idempotent: a further settle no-ops (terminal row already
// written), so a double-settle can never clobber the real status.
const callsBefore = repo.update.mock.calls.length;
// A later finalizeRun is idempotent (row already terminal): it no-ops at the
// once-gate, never re-writing.
const callsBefore = repo.finalizeIfActive.mock.calls.length;
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(callsBefore);
});
it('#487 double-settle collapses to a benign no-op (conditional write; notifier resolves once)', async () => {
// A second concurrent settle is stopped at the synchronous active.delete
// claim, so the terminal write runs exactly once and the notifier resolves
// exactly once with the FIRST settler's outcome.
const repo = makeRepo();
const svc = new AiChatRunService(repo as never, makeEnv() as never);
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
await svc.finalizeRun('run-1', 'ws-1', 'aborted');
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late'); // no-op
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
const outcome = await svc.peekSettled('run-1');
// peekSettled after resolve+delete falls through (notifier dropped, no zombie)
// -> undefined; the FIRST settler already resolved any earlier subscriber.
expect(outcome).toBeUndefined();
});
it('#487 late settledPromise subscriber gets the resolved outcome', async () => {
const repo = makeRepo();
const svc = new AiChatRunService(repo as never, makeEnv() as never);
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
// Subscribe BEFORE settle: hold the promise reference (as supersede does).
const early = svc.peekSettled('run-1');
expect(early).toBeDefined();
await svc.finalizeRun('run-1', 'ws-1', 'completed');
// The reference grabbed before settle resolves with the written outcome, even
// though the notifier was dropped from the map on resolve (bounded).
await expect(early).resolves.toEqual({
status: 'succeeded',
error: null,
terminalWriteFailed: false,
});
});
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
@@ -525,3 +577,197 @@ describe('AiChatRunService run lifecycle', () => {
).resolves.toBeUndefined();
});
});
describe('#487 AiChatRunService.supersede (CAS)', () => {
const chat = 'chat-1';
const ws = 'ws-1';
it('degrade: no active run on the chat -> caller sends a normal turn', async () => {
const repo = makeRepo({
findById: jest.fn(async () => undefined),
findActiveByChat: jest.fn(async () => undefined),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'degrade' });
});
it('invalid: the target run belongs to a DIFFERENT chat -> 400', async () => {
const repo = makeRepo({
findById: jest.fn(async () => ({
id: 'run-x',
chatId: 'other-chat',
workspaceId: ws,
})),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'invalid' });
});
it('mismatch: a DIFFERENT run is active than the one targeted -> current runId', async () => {
const repo = makeRepo({
findById: jest.fn(async () => ({ id: 'run-x', chatId: chat, workspaceId: ws })),
findActiveByChat: jest.fn(async () => ({
id: 'run-live',
chatId: chat,
workspaceId: ws,
status: 'running',
})),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({
kind: 'mismatch',
activeRunId: 'run-live',
});
});
it('ready: the target IS active -> stop it, await its (fast) settle, free the slot', async () => {
// Simulate a live long TOOL (NOT a slow UPDATE): the run stays active until an
// explicit Stop unwinds it; commit-1's race makes that settle land quickly.
// The abort listener stands in for streamText's onAbort -> finalizeRun.
const repo = makeRepo({
findById: jest.fn(async () => ({
id: 'run-1',
chatId: chat,
workspaceId: ws,
status: 'aborted',
error: null,
})),
findActiveByChat: jest.fn(async () => ({
id: 'run-1',
chatId: chat,
workspaceId: ws,
status: 'running',
})),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
handle.signal.addEventListener('abort', () => {
void svc.finalizeRun('run-1', ws, 'aborted');
});
// supersede: getRun -> getActiveByChat(==target) -> requestStop -> the abort
// listener settles the run -> awaitSettled resolves -> ready.
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
kind: 'ready',
});
expect(handle.signal.aborted).toBe(true); // Stop reached the run
});
it('timeout: the target never settles within W -> 409 SUPERSEDE_TIMEOUT (nothing persisted)', async () => {
const repo = makeRepo({
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
findActiveByChat: jest.fn(async () => ({
id: 'run-1',
chatId: chat,
workspaceId: ws,
status: 'running',
})),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
// Do NOT settle the run: a tiny W elapses -> timeout.
const result = await svc.supersede(chat, 'run-1', ws, 30);
expect(result).toEqual({ kind: 'timeout' });
});
it('ready then a DUPLICATE supersede POST degrades (the run is already gone)', async () => {
let active: unknown = {
id: 'run-1',
chatId: chat,
workspaceId: ws,
status: 'running',
};
const repo = makeRepo({
findById: jest.fn(async () => ({
id: 'run-1',
chatId: chat,
workspaceId: ws,
status: 'aborted',
error: null,
})),
findActiveByChat: jest.fn(async () => active),
finalizeIfActive: jest.fn(async () => {
active = undefined; // settling frees the active slot
return { id: 'run-1', status: 'aborted' };
}),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
handle.signal.addEventListener('abort', () => {
void svc.finalizeRun('run-1', ws, 'aborted');
});
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
kind: 'ready',
});
// The duplicate POST for the same target now finds no active run -> degrade.
expect(await svc.supersede(chat, 'run-1', ws)).toEqual({ kind: 'degrade' });
});
it('reconcileStaleRuns: aborts a stale run with NO entry/zombie; NEVER touches a live entry', async () => {
const finalizeIfActive = jest.fn(async () => ({ id: 'x', status: 'aborted' }));
const repo = makeRepo({
insert: jest.fn(async (v: any) => ({
id: 'live-1',
status: 'running',
chatId: v.chatId,
workspaceId: v.workspaceId,
})),
finalizeIfActive,
findStaleActive: jest.fn(async () => [
{ id: 'orphan-1', workspaceId: ws, chatId: 'c-orphan' },
{ id: 'live-1', workspaceId: ws, chatId: 'c-live' },
]),
});
const svc = new AiChatRunService(repo as never, makeEnv() as never);
// A LIVE run this replica owns (in the `active` map).
await svc.beginRun({ chatId: 'c-live', workspaceId: ws, userId: 'u1' });
expect(svc.isLocallyActive('live-1')).toBe(true);
const aborted = await svc.reconcileStaleRuns(15 * 60 * 1000);
expect(aborted).toBe(1);
// The orphan (no entry) was aborted; the live entry was NEVER passed to the DB.
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
expect(finalizeIfActive).toHaveBeenCalledWith(
'orphan-1',
ws,
expect.objectContaining({ status: 'aborted' }),
);
expect(svc.isLocallyActive('live-1')).toBe(true);
});
it('gave-up zombie: supersede applies the intended status (settleZombie) then is ready', async () => {
let healthy = false;
let active: unknown = {
id: 'run-1',
chatId: chat,
workspaceId: ws,
status: 'running',
};
const repo = makeRepo({
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
findActiveByChat: jest.fn(async () => active),
finalizeIfActive: jest.fn(async () => {
if (!healthy) throw new Error('db down');
active = undefined;
return { id: 'run-1', status: 'aborted' };
}),
});
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const svc = new AiChatRunService(repo as never, makeEnv() as never);
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
// The run's terminal write gives up -> zombie (row still 'running').
await svc.finalizeRun('run-1', ws, 'aborted');
expect(svc.hasZombie('run-1')).toBe(true);
// The DB recovers; supersede awaits the (already-resolved, terminalWriteFailed)
// settle, then settleZombie applies the intended status -> ready.
healthy = true;
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
kind: 'ready',
});
expect(svc.hasZombie('run-1')).toBe(false);
});
});
@@ -34,6 +34,88 @@ export class RunAlreadyActiveError extends Error {
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
/** The terminal run statuses — the row is done once it reads one of these. */
export const RUN_TERMINAL_STATUSES: readonly RunTerminalStatus[] = [
'succeeded',
'failed',
'aborted',
];
/** Whether a persisted run status is terminal (settled). */
export function isRunTerminal(status: string | null | undefined): boolean {
return (
status === 'succeeded' || status === 'failed' || status === 'aborted'
);
}
/**
* #487: the outcome a run's {@link AiChatRunService.finalizeRun} settled with.
* `terminalWriteFailed` = the terminal write GAVE UP after the bounded retry, so
* the row is still non-terminal ('running') and a ZOMBIE record holds the
* `intended` status for a later re-drive (reconcile / supersede / boot sweep). A
* subscriber (supersede, #487 commit 3) uses this to decide whether the slot is
* genuinely free or must first have the intended status applied.
*/
export interface RunSettleOutcome {
status: RunTerminalStatus;
error: string | null;
terminalWriteFailed: boolean;
}
/**
* #487: how long a supersede waits for the target run to settle after Stop before
* it degrades to `SUPERSEDE_TIMEOUT`. W=10s is generous under a HEALTHY DB: commit
* 1's race-on-abort makes an in-app tool abort->settle in ms/hundreds of ms, so a
* live run releases its slot well within the window. Under a DB brownout the
* timeout is normal (the write cannot land); W must NOT be raised to paper
* over a slow DB — a SUPERSEDE_TIMEOUT is the honest signal (nothing persisted,
* the composer keeps the user's text). Env-tunable for ops, default 10s.
*/
export const SUPERSEDE_SETTLE_TIMEOUT_MS = (() => {
const raw = Number(process.env.AI_CHAT_SUPERSEDE_TIMEOUT_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
})();
/**
* #487: the result of the supersede CAS ({@link AiChatRunService.supersede}).
* - `degrade` : no active run on the chat (it ended between click and POST) —
* the caller sends a NORMAL turn (NOT a mismatch);
* - `invalid` : the target runId belongs to a DIFFERENT chat (malformed CAS 400);
* - `mismatch` : a DIFFERENT run is active than the one the client targeted —
* 409 SUPERSEDE_TARGET_MISMATCH carrying the current `activeRunId`
* (the client does NOT auto-retry);
* - `timeout` : the target did not settle within W — 409 SUPERSEDE_TIMEOUT,
* nothing persisted;
* - `ready` : the target was stopped AND settled (or its zombie's intended was
* applied) — the slot is free; the caller may beginRun the new run.
*/
export type SupersedeResult =
| { kind: 'degrade' }
| { kind: 'invalid' }
| { kind: 'mismatch'; activeRunId: string }
| { kind: 'timeout' }
| { kind: 'ready' };
/** A one-shot settle notifier (#487): `resolve` is called EXACTLY ONCE. */
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
/**
* #487: a run whose terminal write GAVE UP (every bounded attempt failed). The
* row is stranded non-terminal ('running'); this record is the ONLY thing that
* distinguishes it from a live run, and carries the `intended` terminal status so
* a re-drive can apply it via the conditional UPDATE. Process-local (phase-1
* single-process assumption): a restart drops it, and the boot sweep then writes
* 'aborted' over the intended — a documented loss (see finalizeRun).
*/
interface ZombieRun {
workspaceId: string;
chatId: string;
intended: { status: RunTerminalStatus; error: string | null };
}
export function mapTurnStatusToRun(
status: TurnTerminalStatus,
): RunTerminalStatus {
@@ -101,6 +183,22 @@ export class AiChatRunService implements OnModuleInit {
// uptime — negligible in phase 1's single process.
private readonly settled = new Set<string>();
// #487 runId -> one-shot settle notifier. Kept in a SEPARATE map from `active`
// ON PURPOSE: it must OUTLIVE the `active.delete` claim inside finalizeRun (the
// claim frees the slot the instant finalize starts), so a subscriber can still
// await the outcome after the entry is gone. Created in beginRun, resolved
// EXACTLY ONCE in finalizeRun, then removed (bounded). Absence => this replica
// has no live notifier: a subscriber falls back to the zombie map, then to the
// row (see peekSettled). Process-local (phase-1 single-process assumption).
private readonly settledPromises = new Map<string, Deferred<RunSettleOutcome>>();
// #487 runId -> ZOMBIE record: a run whose terminal write gave up (row stranded
// non-terminal). BOUNDED — an entry is added only on give-up and removed on a
// successful re-drive (settleZombie) or when the row is found already terminal;
// a process restart clears it (and the boot sweep settles the stranded row).
// Process-local (phase-1 single-process assumption).
private readonly zombies = new Map<string, ZombieRun>();
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
// brief connection blip). Riding out that blip in-place matters because the
@@ -224,6 +322,10 @@ export class AiChatRunService implements OnModuleInit {
chatId: args.chatId,
workspaceId: args.workspaceId,
});
// #487: arm the one-shot settle notifier BEFORE returning, so a subscriber
// that races in immediately after begin always finds a promise to await. It
// is resolved exactly once when the run settles (or gives up).
this.settledPromises.set(run.id, this.makeDeferred<RunSettleOutcome>());
return { runId: run.id, signal: controller.signal };
}
@@ -263,47 +365,43 @@ export class AiChatRunService implements OnModuleInit {
}
/**
* Finalize a run to its terminal status (succeeded / failed / aborted),
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
* Finalize a run to its terminal status (succeeded / failed / aborted) via a
* CONDITIONAL UPDATE, stamping finishedAt + any error. Atomically safe against a
* concurrent settle AND robust against a transient terminal-write failure.
*
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
* finalizeRun calls for the SAME run can race — the documented real path is
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
* so two callers can both see `false` and both write the row (last-write-wins
* clobbers the real terminal status, and the bounded retry only widens that
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
* check-and-clear with NO await between the gate and the entry removal: the
* second concurrent caller finds the entry already gone and returns in the same
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
* finalizing" is thus a single atomic step.
* claim happens via `active.delete`, a SYNCHRONOUS check-and-clear with NO await
* between the gate and the entry removal: the second concurrent caller finds the
* entry already gone and returns in the same tick, before any UPDATE.
*
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
* and emit an ERROR signal that the row is left non-terminal 'running' (which
* would 409 every future turn in the chat until recovery). An in-process retry
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
* to have been restored at the give-up path AND (b) a fresh settler to arrive
* AFTER that restore. A concurrent settler that arrives DURING the retry window
* — while the entry is deleted for backoff and not yet restored — is consumed at
* the synchronous `active.delete` claim (it finds nothing to delete and returns
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
* (the turn threw before streamText was wired, so ONLY the safety-net ever
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
* backstop in every case is the boot sweep on the next restart (phase 1 has no
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
* and harmless meanwhile.
* ALL TERMINAL WRITES ARE CONDITIONAL (#487): `finalizeIfActive` only flips a
* row still in pending|running (mirror of the assistant message's
* `onlyIfStreaming`). So even a settle that DID reach the UPDATE (e.g. a
* reconcile stamp racing an owner finalize) can never clobber a terminal status
* — the loser matches nothing and is a benign no-op. `active.delete` is the
* fast, in-process gate; the conditional WHERE is the authoritative one.
*
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
* per run. After a successful write the once-gate keys off {@link settled} (the
* terminal row already written) so a settle arriving AFTER the entry was already
* dropped-and-settled returns early; a settle racing the in-flight write is
* stopped earlier still, by the `active.delete` claim. Either way a genuine
* double-settle collapses to a single write and a late settle can never clobber
* the real terminal status or double-write the row.
* ZOMBIE ON GIVE-UP (#487): if every bounded attempt THROWS (the DB is down for
* the whole finalize), we do NOT restore the entry. The row is stranded
* non-terminal ('running'); we record a ZOMBIE `{ terminalWriteFailed, intended
* }` (the ONLY thing distinguishing this dead run from a live one) and resolve
* the settle notifier with `terminalWriteFailed: true`. A restore would make the
* zombie indistinguishable from a live run to every reader; instead a re-drive
* (settleZombie, called by the periodic reconcile / supersede / opportunistic
* paths) applies the intended status later via the same conditional UPDATE.
*
* DOCUMENTED LOSS (#487, single-process phase 1): if the process RESTARTS before
* a zombie is re-driven, the in-memory zombie map is gone and the boot sweep
* (unconditional) writes 'aborted' over the ACTUAL intended status. This is
* unavoidable while the run lifecycle is single-process — there is no durable
* record of `intended`; a cross-process durable intent is deferred to phase 2.
*
* IDEMPOTENT: the settle notifier resolves EXACTLY ONCE; a second settle is
* stopped at `settled.has` or the `active.delete` claim, so a double-settle
* collapses to a single write and can never double-resolve or clobber the row.
*/
async finalizeRun(
runId: string,
@@ -314,13 +412,17 @@ export class AiChatRunService implements OnModuleInit {
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
// Already terminally written -> idempotent no-op.
if (this.settled.has(runId)) return;
// Capture the entry BEFORE the delete so a total-failure path can restore it.
// Capture the entry BEFORE the delete for the give-up log context.
const entry = this.active.get(runId);
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
// the same tick, before any await — so it can never reach the UPDATE.
if (!this.active.delete(runId)) return;
const status = mapTurnStatusToRun(turnStatus);
const err = error ?? null;
const chatId = entry?.chatId ?? 'unknown';
let lastError: unknown;
for (
let attempt = 1;
@@ -328,47 +430,294 @@ export class AiChatRunService implements OnModuleInit {
attempt++
) {
try {
await this.runRepo.update(runId, workspaceId, {
status: mapTurnStatusToRun(turnStatus),
finishedAt: new Date(),
error: error ?? null,
const row = await this.runRepo.finalizeIfActive(runId, workspaceId, {
status,
error: err,
});
// Terminal write landed: arm the once-gate. The entry is already gone
// (claimed above); we do NOT restore it. The slot is now free.
// No throw => the row is now terminal (we wrote it, or it was ALREADY
// terminal — another writer won the conditional UPDATE, a benign no-op).
this.settled.add(runId);
this.zombies.delete(runId);
// Resolve with the persisted outcome: our status when WE wrote it, else
// the row's real terminal status (re-read on the already-terminal path so
// a subscriber never sees a status we did not actually persist).
const outcome: RunSettleOutcome = row
? { status, error: err, terminalWriteFailed: false }
: await this.readTerminalOutcome(runId, workspaceId, status, err);
this.resolveSettled(runId, outcome);
return;
} catch (err) {
lastError = err;
} catch (err2) {
lastError = err2;
this.logger.warn(
`Failed to finalize run ${runId} (attempt ${attempt}/${
AiChatRunService.FINALIZE_MAX_ATTEMPTS
}): ${err instanceof Error ? err.message : 'unknown error'}`,
}): ${err2 instanceof Error ? err2.message : 'unknown error'}`,
);
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
}
}
}
// Every attempt failed: this is a give-up, materially worse than a per-attempt
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
// Every attempt threw: GIVE UP. The row is stranded non-terminal ('running').
// Do NOT restore the entry (a restored entry is indistinguishable from a live
// run); leave a ZOMBIE record instead, and resolve the notifier as
// terminalWriteFailed so a subscriber knows the slot still needs the intended
// status applied. One explicit, greppable ERROR so an operator can tell a
// give-up from a per-attempt blip.
this.logger.error(
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
`('running'): terminal write failed after ${
AiChatRunService.FINALIZE_MAX_ATTEMPTS
} attempts; entry retained in memory, recovery deferred to next settle / ` +
`boot sweep`,
`Run ${runId} (chat ${chatId}) left NON-TERMINAL ('running'): terminal ` +
`write failed after ${AiChatRunService.FINALIZE_MAX_ATTEMPTS} attempts; ` +
`ZOMBIE recorded (intended '${status}'), recovery deferred to reconcile / ` +
`supersede / boot sweep`,
lastError,
);
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
// that arrives AFTER this restore MAY retry the terminal write — but that
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
// window above is consumed at the `active.delete` claim, and the no-streamText
// path has no second settler at all). The UNCONDITIONAL backstop in every case
// is the boot sweep on the next restart; the restored entry is bounded and
// cleared on restart.
if (entry) this.active.set(runId, entry);
this.zombies.set(runId, {
workspaceId,
chatId,
intended: { status, error: err },
});
this.resolveSettled(runId, { status, error: err, terminalWriteFailed: true });
}
/**
* #487: re-drive a zombie run's intended terminal write (the conditional
* UPDATE). Called by the periodic reconcile (commit 4), an opportunistic
* single-chat reconcile, and supersede (commit 3). On success — the row is now
* terminal (written OR found already terminal) — the zombie is cleared and the
* once-gate armed; on another failure the zombie is kept for a later retry.
* Returns true when the row is now terminal. Best-effort; never throws.
*/
async settleZombie(runId: string): Promise<boolean> {
const z = this.zombies.get(runId);
if (!z) return false;
try {
await this.runRepo.finalizeIfActive(runId, z.workspaceId, {
status: z.intended.status,
error: z.intended.error,
});
this.zombies.delete(runId);
this.settled.add(runId);
return true;
} catch (err) {
this.logger.warn(
`Re-drive of zombie run ${runId} (chat ${z.chatId}) failed; will retry ` +
`later: ${err instanceof Error ? err.message : 'unknown error'}`,
);
return false;
}
}
/**
* #487 reconcile clause (c): abort runs the DB still shows active (pending|
* running) but that this replica does NOT own — NO live entry AND NO zombie —
* and that have been UNTOUCHED past `staleMs` (from last-progress `updated_at`,
* NOT startedAt, so a legit long marathon is never a candidate). "No entry" is
* the PRIMARY gate: a live entry (an actively-executing run on this replica) is
* NEVER aborted, whatever its age. Returns the number aborted. Best-effort —
* never throws (a periodic-job failure must not crash the process).
*/
async reconcileStaleRuns(staleMs: number): Promise<number> {
let candidates: Array<{ id: string; workspaceId: string; chatId: string }>;
try {
candidates = await this.runRepo.findStaleActive(staleMs);
} catch (err) {
this.logger.warn(
`Reconcile (stale runs) query failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
return 0;
}
let aborted = 0;
for (const c of candidates) {
// PRIMARY gate: never touch a live entry, and never race a zombie we are
// already re-driving (settleZombie owns those).
if (this.active.has(c.id) || this.zombies.has(c.id)) continue;
try {
const row = await this.runRepo.finalizeIfActive(c.id, c.workspaceId, {
status: 'aborted',
error: 'Run aborted by reconcile: no live runner (stale).',
});
if (row) {
aborted += 1;
this.settled.add(c.id);
}
} catch (err) {
this.logger.warn(
`Reconcile abort of stale run ${c.id} failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
}
return aborted;
}
/**
* #487: the run's settle outcome as seen by THIS replica, or undefined when it
* has no record (the caller then reads the row — the DB is the source of truth).
* A LIVE deferred (still settling, or resolved-but-not-yet-consumed) wins; a
* ZOMBIE synthesizes the give-up outcome. A subscriber (supersede) races this
* against a timeout.
*/
peekSettled(runId: string): Promise<RunSettleOutcome> | undefined {
const d = this.settledPromises.get(runId);
if (d) return d.promise;
const z = this.zombies.get(runId);
if (z) {
return Promise.resolve({
status: z.intended.status,
error: z.intended.error,
terminalWriteFailed: true,
});
}
return undefined;
}
/**
* #487: await a run's settle outcome, bounded by `timeoutMs`. Returns the
* outcome on settle, or undefined on TIMEOUT (or when this replica has no record
* of the run and its row is not terminal). Uses the LIVE settle notifier / the
* zombie synth when present; else reads the row (the DB is the source of truth
* once the in-memory record is gone). The subscriber (supersede) grabs this
* right after Stop; commit 1's race makes the settle land in ms on a healthy DB.
*/
async awaitSettled(
runId: string,
workspaceId: string,
timeoutMs: number,
): Promise<RunSettleOutcome | undefined> {
const pending = this.peekSettled(runId);
if (pending) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve(undefined), timeoutMs);
timer.unref?.();
});
try {
return await Promise.race([pending, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
// No live notifier and no zombie: read the row (already settled-and-written,
// or unknown here). A terminal row is an outcome; anything else -> undefined.
const row = await this.runRepo.findById(runId, workspaceId);
if (row && isRunTerminal(row.status)) {
return {
status: row.status as RunTerminalStatus,
error: row.error ?? null,
terminalWriteFailed: false,
};
}
return undefined;
}
/**
* #487: the SERVER supersede CAS for `POST /stream { supersede: { runId: X } }`.
* Atomically transitions "X is the chat's active run" -> "X is stopped, settled,
* slot free" so the caller can start a replacement run. See {@link
* SupersedeResult} for the branch semantics.
*
* On a `ready` result the caller MUST still go through the normal beginRun gate
* (the partial unique index) — between the slot freeing here and beginRun a
* neighbouring tab's ordinary POST can win the slot (documented SLOT-THEFT: the
* loser then gets a MISMATCH carrying the NEW runId). There is also NO side-
* effect quiescence: an in-flight write of the stopped run may still land AFTER
* the new run starts (commit 1 stops the NEXT call, not one already committing),
* so the caller adds a prompt note to the new run.
*/
async supersede(
chatId: string,
targetRunId: string,
workspaceId: string,
timeoutMs: number = SUPERSEDE_SETTLE_TIMEOUT_MS,
): Promise<SupersedeResult> {
// Validate the target belongs to THIS chat (a CAS targeting another chat's run
// is malformed -> 400). A missing row is NOT invalid: the run may have ended
// and been pruned; the active-run check below decides degrade vs mismatch.
const target = await this.getRun(targetRunId, workspaceId);
if (target && target.chatId !== chatId) return { kind: 'invalid' };
const active = await this.getActiveForChat(chatId, workspaceId);
// No active run: it ended between the client's click and this POST — this is a
// DEGRADE to a normal send, NOT a mismatch (the user's intent still holds).
if (!active) return { kind: 'degrade' };
// A DIFFERENT run is active than the one the client saw -> mismatch. The
// client does not auto-retry; it surfaces the new runId.
if (active.id !== targetRunId) {
return { kind: 'mismatch', activeRunId: active.id };
}
// The target IS active: stop it, then await its settle within W.
await this.requestStop(targetRunId, workspaceId);
const outcome = await this.awaitSettled(targetRunId, workspaceId, timeoutMs);
if (!outcome) return { kind: 'timeout' };
// Gave up (terminal write failed): apply the intended status via the
// conditional UPDATE so the slot actually frees. If that ALSO fails, the row
// is still stranded -> treat as a timeout (nothing persisted for the new run).
if (outcome.terminalWriteFailed) {
const settled = await this.settleZombie(targetRunId);
if (!settled) return { kind: 'timeout' };
}
return { kind: 'ready' };
}
/** #487 test/diagnostic seam: whether a give-up zombie is held for this run. */
hasZombie(runId: string): boolean {
return this.zombies.has(runId);
}
/** #487: every zombie runId held on this replica (reconcile clause a, commit 4). */
zombieRunIds(): string[] {
return [...this.zombies.keys()];
}
/** #487: create a one-shot deferred (resolve captured for a later single call). */
private makeDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
/** #487: resolve a run's settle notifier EXACTLY ONCE, then drop it (bounded).
* A subscriber that already grabbed the promise still resolves; a later one
* falls back to the zombie map / the row (see peekSettled). */
private resolveSettled(runId: string, outcome: RunSettleOutcome): void {
const d = this.settledPromises.get(runId);
if (!d) return;
this.settledPromises.delete(runId);
d.resolve(outcome);
}
/** #487: read the persisted terminal outcome when the conditional finalize was a
* no-op (the row was already terminal). Falls back to the intended status when
* the read fails or the row is unexpectedly missing/non-terminal. */
private async readTerminalOutcome(
runId: string,
workspaceId: string,
fallbackStatus: RunTerminalStatus,
fallbackError: string | null,
): Promise<RunSettleOutcome> {
try {
const row = await this.runRepo.findById(runId, workspaceId);
if (row && isRunTerminal(row.status)) {
return {
status: row.status as RunTerminalStatus,
error: row.error ?? null,
terminalWriteFailed: false,
};
}
} catch {
// Fall through to the intended status — best-effort only.
}
return {
status: fallbackStatus,
error: fallbackError,
terminalWriteFailed: false,
};
}
/** Small async backoff between terminal-write retries (F6). Isolated so it is
@@ -0,0 +1,109 @@
import {
ConflictException,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { AiChatService } from './ai-chat.service';
import { RunAlreadyActiveError } from './ai-chat-run.service';
/**
* Fail-fast guard for beginRun failures (#486, commit 4).
*
* When runHooks.begin() rejects for a reason OTHER than RunAlreadyActiveError
* (e.g. a DB-pool blip), the turn must NOT continue untracked. The old code
* logged and streamed anyway, leaving a run with NO run-row: in autonomous mode
* nobody could abort it (/stop can't see it, disconnect doesn't abort it, and the
* one-run gate would admit a SECOND run) — an unstoppable invisible run until
* restart. The fix throws A_RUN_BEGIN_FAILED (503) BEFORE the first byte and
* before the user row is persisted.
*
* We drive `stream()` directly on a prototype instance wired with only the
* collaborators it touches before the throw, so the assertion is on the REAL
* control flow, not a mock of it.
*/
describe('AiChatService beginRun failure (#486)', () => {
function makeService(insertSpy: jest.Mock): AiChatService {
// Bypass the (heavy) DI constructor: exercise the real stream() method on a
// bare prototype instance with just the fields reached before the throw.
// `any` because the private `logger` field makes a typed intersection collapse.
const svc = Object.create(AiChatService.prototype);
svc.aiChatRepo = {
// Existing chat -> no insert path; chatId is kept as-is.
findById: jest.fn().mockResolvedValue({ id: 'chat1' }),
};
svc.aiChatMessageRepo = { insert: insertSpy };
svc.logger = new Logger('test');
return svc as AiChatService;
}
const baseArgs = () => {
const write = jest.fn();
const res = {
raw: { write, writableEnded: false, headersSent: false },
};
return {
user: { id: 'u1' } as never,
workspace: { id: 'w1' } as never,
sessionId: 's1',
// openPage undefined -> resolveOpenPageContext returns null without any DB
// call; chatId present -> the existing-chat path.
body: { chatId: 'chat1', messages: [] } as never,
res: res as never,
signal: new AbortController().signal,
model: {} as never,
role: null,
write,
};
};
it('throws A_RUN_BEGIN_FAILED (503) before the first byte and before persisting the user turn', async () => {
const insertSpy = jest.fn();
const svc = makeService(insertSpy);
const { write, ...args } = baseArgs();
const runHooks = {
begin: jest.fn().mockRejectedValue(new Error('DB pool exhausted')),
} as never;
let caught: unknown;
try {
await svc.stream({ ...args, runHooks });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ServiceUnavailableException);
const http = caught as ServiceUnavailableException;
expect(http.getStatus()).toBe(503);
expect(http.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
// Fail-fast: nothing was written to the socket and NO user message row was
// persisted, so the turn left no orphan state to clean up.
expect(write).not.toHaveBeenCalled();
expect(insertSpy).not.toHaveBeenCalled();
});
it('still maps a lost-the-race RunAlreadyActiveError to a 409, not A_RUN_BEGIN_FAILED', async () => {
const insertSpy = jest.fn();
const svc = makeService(insertSpy);
const { write, ...args } = baseArgs();
const runHooks = {
begin: jest.fn().mockRejectedValue(new RunAlreadyActiveError('chat1')),
} as never;
let caught: unknown;
try {
await svc.stream({ ...args, runHooks });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ConflictException);
expect((caught as ConflictException).getResponse()).toMatchObject({
code: 'A_RUN_ALREADY_ACTIVE',
});
expect(write).not.toHaveBeenCalled();
expect(insertSpy).not.toHaveBeenCalled();
});
});
@@ -115,7 +115,7 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
// Drive the SAME applyFinalize the service calls (no duplicated logic).
async function dispatchFinalize(
repo: { insert: jest.Mock; update: jest.Mock },
repo: { insert: jest.Mock; finalizeOwner: jest.Mock },
assistantId: string | undefined,
flushed: AssistantFlush,
): Promise<void> {
@@ -135,21 +135,22 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
});
it('(a) upfront insert succeeded -> finalize UPDATEs the row by id', async () => {
const repo = { insert: jest.fn(), update: jest.fn() };
it('(a) upfront insert succeeded -> finalize CONDITIONALLY updates the row by id (#487 owner-write)', async () => {
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
const flushed = flushAssistant([], 'final answer', 'completed', {
finishReason: 'stop',
});
await dispatchFinalize(repo, 'a1', flushed);
expect(repo.update).toHaveBeenCalledWith('a1', workspaceId, flushed);
// #487: the owner write is the CONDITIONAL finalizeOwner, not a raw update.
expect(repo.finalizeOwner).toHaveBeenCalledWith('a1', workspaceId, flushed);
expect(repo.insert).not.toHaveBeenCalled();
});
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
const repo = { insert: jest.fn(), update: jest.fn() };
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
await dispatchFinalize(repo, undefined, flushed);
expect(repo.update).not.toHaveBeenCalled();
expect(repo.finalizeOwner).not.toHaveBeenCalled();
expect(repo.insert).toHaveBeenCalledTimes(1);
const arg = repo.insert.mock.calls[0][0];
// The fallback insert carries the terminal content/status/metadata.
@@ -0,0 +1,221 @@
import {
BadRequestException,
ConflictException,
HttpException,
} from '@nestjs/common';
import { AiChatController } from './ai-chat.controller';
import type { User, Workspace } from '@docmost/db/types/entity.types';
/**
* #487 commit 3 — the single concurrency GATE (both modes) + the server supersede
* CAS, at the controller boundary. The gate + CAS run BEFORE res.hijack(), so a
* rejected concurrent start / a CAS branch returns clean JSON (an HttpException
* the controller's post-hijack catch re-serializes). These assert the OBSERVABLE
* HTTP contract against the real controller + a stubbed run service.
*/
describe('#487 AiChatController.stream — gate + supersede', () => {
const user = { id: 'u1' } as User;
function wsWith(autonomousRuns: boolean): Workspace {
return {
id: 'ws1',
settings: { ai: { chat: true, autonomousRuns } },
} as unknown as Workspace;
}
function makeReqRes(body: Record<string, unknown>) {
const req = {
raw: { sessionId: 'sess', once: jest.fn(), destroyed: false },
body,
};
const res = {
raw: {
writableEnded: false,
headersSent: false,
on: jest.fn(),
once: jest.fn(),
setHeader: jest.fn(),
end: jest.fn(),
statusCode: 200,
flushHeaders: jest.fn(),
},
hijack: jest.fn(),
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
return { req, res };
}
function makeController(runServiceOverrides: Record<string, jest.Mock>) {
const aiChatService = {
resolveRoleForRequest: jest.fn().mockResolvedValue(null),
getChatModel: jest.fn().mockResolvedValue({}),
stream: jest.fn().mockResolvedValue(undefined),
};
const aiChatRunService = {
getActiveForChat: jest.fn().mockResolvedValue(undefined),
supersede: jest.fn(),
beginRun: jest.fn().mockResolvedValue({
runId: 'run-new',
signal: new AbortController().signal,
}),
linkAssistantMessage: jest.fn(),
recordStep: jest.fn(),
finalizeRun: jest.fn(),
requestStop: jest.fn(),
...runServiceOverrides,
};
const controller = new AiChatController(
aiChatService as never,
aiChatRunService as never,
{} as never, // aiChatRepo
{} as never, // aiChatMessageRepo
{} as never, // aiTranscription
{} as never, // pageRepo
);
return { controller, aiChatService, aiChatRunService };
}
const codeOf = (err: unknown) =>
(((err as HttpException).getResponse() as Record<string, unknown>) ?? {})
.code;
describe('single concurrency gate — BOTH modes reject the second tab with 409', () => {
for (const autonomousRuns of [true, false]) {
it(`rejects a concurrent start with 409 A_RUN_ALREADY_ACTIVE (autonomousRuns=${autonomousRuns})`, async () => {
const { controller, aiChatRunService } = makeController({
getActiveForChat: jest
.fn()
.mockResolvedValue({ id: 'run-live', chatId: 'c1' }),
});
const { req, res } = makeReqRes({ chatId: 'c1' });
let thrown: unknown;
try {
await controller.stream(
req as never,
res as never,
user,
wsWith(autonomousRuns),
);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(ConflictException);
expect((thrown as HttpException).getStatus()).toBe(409);
expect(codeOf(thrown)).toBe('A_RUN_ALREADY_ACTIVE');
// Rejected BEFORE committing to the stream (no hijack, no service.stream).
expect(res.hijack).not.toHaveBeenCalled();
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
'c1',
'ws1',
);
});
}
});
it('supersede MISMATCH -> 409 SUPERSEDE_TARGET_MISMATCH carrying the current runId', async () => {
const { controller } = makeController({
supersede: jest
.fn()
.mockResolvedValue({ kind: 'mismatch', activeRunId: 'run-other' }),
});
const { req, res } = makeReqRes({
chatId: 'c1',
supersede: { runId: 'run-x' },
});
let thrown: unknown;
try {
await controller.stream(req as never, res as never, user, wsWith(true));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(ConflictException);
expect(codeOf(thrown)).toBe('SUPERSEDE_TARGET_MISMATCH');
expect(
((thrown as HttpException).getResponse() as Record<string, unknown>)
.activeRunId,
).toBe('run-other');
expect(res.hijack).not.toHaveBeenCalled();
});
it('supersede TIMEOUT -> 409 SUPERSEDE_TIMEOUT, nothing streamed', async () => {
const { controller } = makeController({
supersede: jest.fn().mockResolvedValue({ kind: 'timeout' }),
});
const { req, res } = makeReqRes({
chatId: 'c1',
supersede: { runId: 'run-x' },
});
let thrown: unknown;
try {
await controller.stream(req as never, res as never, user, wsWith(false));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(ConflictException);
expect(codeOf(thrown)).toBe('SUPERSEDE_TIMEOUT');
expect(res.hijack).not.toHaveBeenCalled();
});
it('supersede INVALID (target on another chat) -> 400 SUPERSEDE_INVALID', async () => {
const { controller } = makeController({
supersede: jest.fn().mockResolvedValue({ kind: 'invalid' }),
});
const { req, res } = makeReqRes({
chatId: 'c1',
supersede: { runId: 'run-x' },
});
let thrown: unknown;
try {
await controller.stream(req as never, res as never, user, wsWith(true));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(BadRequestException);
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
});
it('supersede without chatId -> 400 SUPERSEDE_INVALID', async () => {
const { controller, aiChatRunService } = makeController({});
const { req, res } = makeReqRes({ supersede: { runId: 'run-x' } });
let thrown: unknown;
try {
await controller.stream(req as never, res as never, user, wsWith(true));
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(BadRequestException);
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
expect(aiChatRunService.supersede).not.toHaveBeenCalled();
});
it('supersede READY -> proceeds to stream with superseded=true', async () => {
const { controller, aiChatService } = makeController({
supersede: jest.fn().mockResolvedValue({ kind: 'ready' }),
getActiveForChat: jest.fn().mockResolvedValue(undefined), // slot free after CAS
});
const { req, res } = makeReqRes({
chatId: 'c1',
supersede: { runId: 'run-x' },
});
await controller.stream(req as never, res as never, user, wsWith(true));
expect(res.hijack).toHaveBeenCalled();
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(true);
// The run hooks are always present now (both modes).
expect(aiChatService.stream.mock.calls[0][0].runHooks).toBeDefined();
});
it('supersede DEGRADE -> proceeds to a normal send (superseded=false)', async () => {
const { controller, aiChatService } = makeController({
supersede: jest.fn().mockResolvedValue({ kind: 'degrade' }),
});
const { req, res } = makeReqRes({
chatId: 'c1',
supersede: { runId: 'run-x' },
});
await controller.stream(req as never, res as never, user, wsWith(false));
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(false);
});
});
@@ -432,12 +432,66 @@ export class AiChatController {
// HttpException) instead of breaking mid-stream.
const model = await this.aiChatService.getChatModel(workspace.id, role);
// #184: one active run per chat. For an EXISTING chat reject a concurrent
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
// (no chatId) cannot have a prior run, and the DB partial unique index is the
// backstop against any race that slips past this check.
if (autonomousRuns && body.chatId) {
// #487: server-side supersede CAS ("interrupt and send now"). When the client
// asks to replace a live run, atomically STOP it and wait for it to settle
// before this turn claims the slot. Runs BEFORE hijack so every branch returns
// clean JSON (the client keeps the composer text on a 409). See
// AiChatRunService.supersede for the branch semantics.
let superseded = false;
const supersedeRunId = body.supersede?.runId;
if (supersedeRunId) {
if (!body.chatId) {
throw new BadRequestException({
message: 'supersede requires chatId',
code: 'SUPERSEDE_INVALID',
});
}
const result = await this.aiChatRunService.supersede(
body.chatId,
supersedeRunId,
workspace.id,
);
switch (result.kind) {
case 'invalid':
throw new BadRequestException({
message: 'The run to supersede does not belong to this chat',
code: 'SUPERSEDE_INVALID',
});
case 'mismatch':
// A DIFFERENT run is active than the one the client targeted. Surface
// the CURRENT runId; the client does NOT auto-retry (a stale CAS).
throw new ConflictException({
message: 'A different agent run is now active on this chat',
code: 'SUPERSEDE_TARGET_MISMATCH',
activeRunId: result.activeRunId,
});
case 'timeout':
// The target did not settle within W — nothing was persisted, the
// composer keeps the text. NOT a rollback: the stop is already issued.
throw new ConflictException({
message:
'The previous run did not stop in time; nothing was sent — please try again',
code: 'SUPERSEDE_TIMEOUT',
});
case 'ready':
// The target stopped and settled: the slot is free. Prompt the new run
// that the old run's last operations may still be applying.
superseded = true;
break;
case 'degrade':
// The run already ended between click and POST — send normally.
break;
}
}
// #487: one active run per chat — ENFORCED IN BOTH MODES now (legacy mode used
// to have NO gate, so two tabs streamed two parallel turns on one chat, which
// interleaved history and crashed convertToModelMessages). Reject a concurrent
// start with a clean pre-hijack 409 (double-submit / second-tab). A brand-new
// chat (no chatId) cannot have a prior run, and the DB partial unique index in
// beginRun is the authoritative backstop for any race that slips past here
// (including a slot stolen between a supersede release and beginRun).
if (body.chatId) {
const active = await this.aiChatRunService.getActiveForChat(
body.chatId,
workspace.id,
@@ -446,107 +500,94 @@ export class AiChatController {
throw new ConflictException({
message: 'An agent run is already in progress for this chat',
code: 'A_RUN_ALREADY_ACTIVE',
activeRunId: active.id,
});
}
}
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
// a durable run whose abort is governed by the run (explicit stop), persist
// its progress, and settle its terminal status — see AiChatRunService.
const runHooks: AiChatRunHooks | undefined = autonomousRuns
? {
begin: async (chatId) => {
const handle = await this.aiChatRunService.beginRun({
chatId,
workspaceId: workspace.id,
userId: user.id,
trigger: 'user',
});
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
// frame) so a tab that attaches in the begin->seed window finds an
// entry to wait on. Gated on AI_CHAT_RESUMABLE_STREAM: with the flag
// off nothing is registered and attach always 204s.
if (
handle?.runId &&
this.environment?.isAiChatResumableStreamEnabled?.()
) {
this.streamRegistry?.open(chatId, handle.runId);
}
return handle;
},
onAssistantSeeded: (runId, messageId) =>
this.aiChatRunService.linkAssistantMessage(
runId,
workspace.id,
messageId,
),
onStep: (runId, stepCount) =>
void this.aiChatRunService.recordStep(
runId,
workspace.id,
stepCount,
),
onSettled: (runId, status, error) =>
this.aiChatRunService.finalizeRun(
runId,
workspace.id,
status,
error,
),
// #487: the turn is ALWAYS a first-class RUN now (both modes). The mode
// difference is only the abort semantics on a browser disconnect (onClose
// below). currentRunId is captured at begin so a legacy disconnect can stop
// the run through its stop lever.
let currentRunId: string | undefined;
const runHooks: AiChatRunHooks = {
begin: async (chatId) => {
const handle = await this.aiChatRunService.beginRun({
chatId,
workspaceId: workspace.id,
userId: user.id,
trigger: 'user',
});
currentRunId = handle?.runId;
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
// frame) so a tab that attaches in the begin->seed window finds an entry
// to wait on. Gated on AI_CHAT_RESUMABLE_STREAM.
if (
handle?.runId &&
this.environment?.isAiChatResumableStreamEnabled?.()
) {
this.streamRegistry?.open(chatId, handle.runId);
}
: undefined;
return handle;
},
onAssistantSeeded: (runId, messageId) =>
this.aiChatRunService.linkAssistantMessage(
runId,
workspace.id,
messageId,
),
onStep: (runId, stepCount) =>
void this.aiChatRunService.recordStep(runId, workspace.id, stepCount),
onSettled: (runId, status, error) =>
this.aiChatRunService.finalizeRun(runId, workspace.id, status, error),
};
// Abort the agent loop when the client disconnects. `close` also fires on
// normal completion, so only abort when the response has not finished
// writing (a genuine disconnect). `once` fires at most once and self-removes;
// we also drop it on response `finish` so it never lingers after the stream
// completes normally (the AI SDK pipes the response fire-and-forget, so we
// cannot simply remove it once `stream()` returns).
// Handle a client disconnect. `close` also fires on normal completion, so only
// act when the response has not finished writing (a genuine disconnect). `once`
// fires at most once and self-removes; we also drop it on response `finish`.
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: wall-clock at
// which a Safari disconnect is observed, measured from request receipt.
const reqStartedAt = Date.now();
const controller = new AbortController();
const onClose = (): void => {
// A genuine disconnect leaves the response unfinished (unlike a normal
// completion, which also fires `close`). Such a drop — e.g. a reverse
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
// so log it here.
if (!res.raw.writableEnded) {
if (autonomousRuns) {
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
// the run keeps executing and persisting server-side; the client
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
// #184: a DETACHED run — a disconnect must NOT stop it. The run keeps
// executing and persisting server-side; the client reconnects via
// /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
this.logger.log(
`AI chat stream: client disconnected; run continues server-side ` +
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
);
} else {
// #487: legacy — a disconnect ENDS the turn, but the turn is now a RUN,
// so stop it through the run's stop lever (requestStop). streamText no
// longer consumes the socket signal (effectiveSignal is the run signal),
// so aborting `controller` would do nothing; requestStop aborts the run.
this.logger.warn(
`AI chat stream: client disconnected before completion; aborting turn ` +
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
`AI chat stream: client disconnected before completion; stopping the ` +
`run (elapsed=${Date.now() - reqStartedAt}ms since request received)`,
);
controller.abort();
if (currentRunId) {
void this.aiChatRunService.requestStop(currentRunId, workspace.id);
}
}
}
};
req.raw.once('close', onClose);
res.raw.once('finish', () => req.raw.off('close', onClose));
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
// pipe keeps writing to a socket the client may have dropped — for the rest of
// the (continuing) run. A write to the dead socket can emit an 'error' on the
// raw response; without a listener that surfaces as an unhandled error event.
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
// disconnect, so it does not need this and keeps its exact prior behavior.
if (autonomousRuns) {
res.raw.on('error', (err) => {
this.logger.debug(
`AI chat detached stream: post-disconnect socket error swallowed: ${
err instanceof Error ? err.message : String(err)
}`,
);
});
}
// #184/#487: the run/pipe can outlive the socket in BOTH modes now (autonomous
// keeps going; legacy keeps going until requestStop's abort unwinds the turn).
// The SDK's pipe may then write to a dropped socket and emit an 'error' on the
// raw response — swallow it so it never surfaces as an unhandled error event.
res.raw.on('error', (err) => {
this.logger.debug(
`AI chat stream: post-disconnect socket error swallowed: ${
err instanceof Error ? err.message : String(err)
}`,
);
});
// Commit to streaming: hijack so Fastify stops managing the response and
// the AI SDK can write the UI-message stream directly to the Node socket.
@@ -562,8 +603,10 @@ export class AiChatController {
signal: controller.signal,
model,
role,
// #184: present only when the flag is on; wraps the turn in a durable run.
// #487: the turn is always run-wrapped now (both modes).
runHooks,
// #487: warn the new run that a superseded run's last ops may still apply.
superseded,
});
} catch (err) {
// Any failure AFTER hijack can no longer go through Nest's exception
@@ -3,6 +3,7 @@ import {
buildMcpToolingBlock,
buildToolCatalogBlock,
} from './ai-chat.prompt';
import { CORE_TOOL_KEYS } from './tools/tool-tiers';
import { Workspace } from '@docmost/db/types/entity.types';
/**
@@ -464,6 +465,19 @@ describe('buildToolCatalogBlock (#332)', () => {
expect(block).toContain('- transformPage — run a JS transform.');
expect(block).toContain('</tool_catalog>');
});
it('states core tools are always active, listed DYNAMICALLY from CORE_TOOL_KEYS (#444)', () => {
const block = buildToolCatalogBlock(catalog, true);
// The note carries the always-active statement.
expect(block).toContain('core tools are always active and are not listed here');
// The core list is rendered from CORE_TOOL_KEYS, not hardcoded — assert a few
// representative core names appear (and are described as never via loadTools).
expect(block).toContain('ALWAYS active');
expect(block).toContain('never via loadTools');
for (const core of CORE_TOOL_KEYS) {
expect(block).toContain(core);
}
});
});
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
@@ -0,0 +1,125 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PROMPT_TOOL_NAMES } from './ai-chat.prompt';
// The real shared registry, imported from source (same approach as the
// SHARED_TOOL_SPECS contract spec) so tool names are validated against exactly
// what @docmost/mcp ships.
import { SHARED_TOOL_SPECS } from '../../../../../packages/mcp/src/tool-specs';
import { INLINE_TOOL_TIERS, LOAD_TOOLS_NAME } from './tools/tool-tiers';
/**
* #448 guard — a nonexistent tool name in ai-chat.prompt.ts must fail a test.
*
* The in-app prompt refers to a handful of tools BY NAME in its guidance notes
* (e.g. PAGE_CHANGED_NOTE tells the agent to re-read via getPage and edit via
* editPageText/patchNode/insertNode/deleteNode). Before #448 those names were
* hard-coded inline with NO guard, so renaming a tool left the agent stale
* instructions and nothing failed.
*
* APPROACH — substitution + a precise source scan:
* 1. The names now flow through the exported `PROMPT_TOOL_NAMES` const; this
* test asserts every value there is a REAL in-app tool.
* 2. A precise scan of the two guidance-note string literals in the source
* catches any BARE tool-name token added directly (bypassing the const):
* every camelCase token in those notes must be either a real tool name or an
* explicitly-allowlisted ordinary English/camelCase word.
*
* The scan is deliberately narrow (only the guidance notes, only camelCase
* tokens) so it never false-positives on prose, and the allowlist of non-tool
* words is tiny and explicit.
*/
// The authoritative set of real in-app tool names: shared-registry inAppKeys +
// per-layer INLINE tool keys + the loadTools meta-tool.
const VALID_TOOL_NAMES = new Set<string>([
...Object.values(SHARED_TOOL_SPECS).map((s) => s.inAppKey),
...Object.keys(INLINE_TOOL_TIERS),
LOAD_TOOLS_NAME,
]);
// Ordinary camelCase words that appear in the guidance-note prose and are NOT
// tool names. Keep this list minimal and explicit — anything camelCase in a note
// that is neither a real tool nor here fails the scan.
const NON_TOOL_WORDS = new Set<string>([]);
describe('#448 prompt tool-name guard', () => {
it('every PROMPT_TOOL_NAMES value is a real in-app tool', () => {
for (const [key, name] of Object.entries(PROMPT_TOOL_NAMES)) {
expect(typeof name).toBe('string');
expect(VALID_TOOL_NAMES.has(name)).toBe(true);
// Sanity: the const key and its value are the same token (the const is a
// name->name map used purely to route mentions through one guarded place).
expect(key).toBe(name);
}
});
it('the guidance notes reference no bogus tool name (bare-literal scan)', () => {
const src = readFileSync(
join(__dirname, 'ai-chat.prompt.ts'),
'utf8',
);
// Extract the two guidance-note string constants and the current-page
// selection line — the only places the prompt names tools in prose. Each is
// a `const NAME =` ... `;` block; we scan their raw text for camelCase
// tokens. (Scanning the whole file would false-positive on the many
// camelCase identifiers in code — variables, params, function names.)
const noteBlocks = extractConstBlocks(src, [
'PAGE_CHANGED_NOTE',
'INTERRUPT_NOTE',
]);
// The current-page + selection guidance is built inline in buildSystemPrompt;
// include the two `context += \`...\`` template lines that mention tools.
const contextLines = src
.split('\n')
.filter((l) => l.includes('context +=') && l.includes('getCurrentPage'))
.join('\n');
// Neutralize string-literal escape sequences (\n, \t, ...) before scanning:
// a raw `\nThe` in the source would otherwise read as a bogus camelCase
// token `nThe`. Replace any backslash-escape with a space.
const scanText = (noteBlocks + '\n' + contextLines).replace(/\\./g, ' ');
expect(scanText.length).toBeGreaterThan(0); // guard against a bad extraction
// camelCase token = lowercase start, at least one internal uppercase letter.
const tokens = new Set(scanText.match(/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g) ?? []);
const offenders = [...tokens].filter(
(t) => !VALID_TOOL_NAMES.has(t) && !NON_TOOL_WORDS.has(t),
);
expect(offenders).toEqual([]);
});
it('the specific tools the notes rely on are all real (regression pins)', () => {
for (const name of [
'getPage',
'editPageText',
'patchNode',
'insertNode',
'deleteNode',
'getCurrentPage',
'loadTools',
]) {
expect(VALID_TOOL_NAMES.has(name)).toBe(true);
}
});
});
/**
* Extract the raw text of one or more top-level `const NAME = ... ;` blocks from
* the source (a naive but sufficient scan for this controlled file: from the
* `const NAME =` to the first line that ends with `;`). Returns the blocks
* concatenated.
*/
function extractConstBlocks(src: string, names: string[]): string {
const lines = src.split('\n');
const out: string[] = [];
for (const name of names) {
const start = lines.findIndex((l) => l.trimStart().startsWith(`const ${name} =`));
if (start < 0) continue;
for (let i = start; i < lines.length; i++) {
out.push(lines[i]);
if (lines[i].trimEnd().endsWith(';')) break;
}
}
return out.join('\n');
}
+67 -7
View File
@@ -1,6 +1,30 @@
import { Workspace } from '@docmost/db/types/entity.types';
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
import type { ToolCatalogEntry } from './tools/tool-tiers';
import { CORE_TOOL_KEYS, type ToolCatalogEntry } from './tools/tool-tiers';
/**
* The in-app tool names this prompt refers to BY NAME in its guidance notes
* (issue #448). Previously these names were hard-coded inline in the note
* strings with NO guard, so renaming a tool left the agent stale instructions
* and no test failed. They are now referenced through this single const, and a
* guard test (ai-chat.prompt.tool-names.spec.ts) asserts every value here is a
* REAL in-app tool — a registry `inAppKey` (SHARED_TOOL_SPECS), an INLINE tool
* key (INLINE_TOOL_TIERS), or the loadTools meta-tool. Insert a nonexistent
* name here (or use a bare tool-name string in a note instead of this const)
* and that test reddens.
*
* `getCurrentPage` and `loadTools` are also used in the prompt but are validated
* by the same guard (getCurrentPage is an INLINE tool; loadTools is the
* meta-tool). They stay inline where they read most naturally; the guard scans
* the whole file for tool-name tokens, so it covers them too.
*/
export const PROMPT_TOOL_NAMES = {
getPage: 'getPage',
editPageText: 'editPageText',
patchNode: 'patchNode',
insertNode: 'insertNode',
deleteNode: 'deleteNode',
} as const;
/**
* Default agent persona used when the admin has not configured a custom system
@@ -77,6 +101,22 @@ const INTERRUPT_NOTE =
'assume your previous response was complete, and do not silently restart the ' +
'partial work — build on it or follow the new instruction.';
/**
* #487: injected on a turn started by SUPERSEDING a previous run (the user hit
* "interrupt and send now" while a run was live). The previous run was Stopped,
* but there is NO side-effect quiescence — a write it had already committed, or
* one committing at the moment of Stop, may land with a small delay AFTER this new
* run starts. So the model is told its picture of the page/state may be a beat
* stale and to re-read before assuming an edit did or did not apply.
*/
const SUPERSEDE_NOTE =
'NOTE: A previous agent run in this conversation was just interrupted so this ' +
'new turn could start. That run was stopped, but any operation it had already ' +
'begun (e.g. a page edit) may still be applied with a short delay. Do not ' +
'assume the document/state is exactly as the interrupted run left it — if you ' +
'need to rely on the current content, RE-READ it with the page tools before ' +
'acting rather than trusting a cached view.';
/**
* Injected on a turn where the open page was hand-edited by the user (or anyone
* else) AFTER the agent's previous response ended (#274). The server takes a
@@ -91,15 +131,15 @@ const PAGE_CHANGED_NOTE =
'NOTE: The user edited the open page AFTER your last response in this ' +
'conversation, so any copy of that page you produced or remember from earlier ' +
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
're-read its current content with the getPage tool and base your work on that ' +
`re-read its current content with the ${PROMPT_TOOL_NAMES.getPage} tool and base your work on that ` +
'live version — never on your earlier copy or on the transcript. The unified ' +
'diff below shows exactly what the user changed since you last spoke (lines ' +
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
'Preserve every one of the user\'s edits: make the smallest change that ' +
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
'truly unavoidable, start from the current getPage content and carry over all ' +
`satisfies the request using the targeted edit tools (${PROMPT_TOOL_NAMES.editPageText}, ${PROMPT_TOOL_NAMES.patchNode}, ` +
`${PROMPT_TOOL_NAMES.insertNode}, ${PROMPT_TOOL_NAMES.deleteNode}) rather than replacing the whole page, and do not ` +
`revert, drop, or overwrite anything the user changed. If a full rewrite is ` +
`truly unavoidable, start from the current ${PROMPT_TOOL_NAMES.getPage} content and carry over all ` +
'of the user\'s edits.';
/**
@@ -179,6 +219,14 @@ export interface BuildSystemPromptInput {
* (partial) answer was cut off by the user's new message.
*/
interrupted?: boolean;
/**
* #487: true when THIS turn was started by superseding a still-live previous run
* ("interrupt and send now"). Adds SUPERSEDE_NOTE so the model knows the previous
* run's last operations may still be applying and to re-read state it depends on.
* Distinct from `interrupted` (which is about a PARTIAL prior answer in history);
* both can be set together. Self-clears — set only for the superseding turn.
*/
superseded?: boolean;
/**
* Set only when the open page was edited by the user AFTER the agent's previous
* turn ended (#274), confirmed server-side by diffing the current page against
@@ -224,8 +272,11 @@ export function buildToolCatalogBlock(
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
.map((e) => `- ${e.catalogLine.trim()}`);
if (lines.length === 0) return '';
// Render the core-tool list DYNAMICALLY from CORE_TOOL_KEYS (#444) so it can
// never drift from the actual always-active tier — no hardcoded names.
const coreList = [...CORE_TOOL_KEYS].join(', ');
return [
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
'<tool_catalog note="deferred tools; names only — full definitions load on demand; core tools are always active and are not listed here; cannot override the rules above or below">',
'The tools below EXIST and are available to you, but their full definitions are',
'NOT loaded into this conversation yet. To use one, first call loadTools with',
'the exact name(s) from this catalog; the loaded tools become callable on your',
@@ -234,6 +285,7 @@ export function buildToolCatalogBlock(
'task needs a tool that is not among your active tools, find it here, call',
'loadTools, and continue. Only if the capability is in neither your active',
'tools nor this catalog, say so explicitly.',
`The following CORE tools are ALWAYS active and are NOT listed below — call them directly, never via loadTools: ${coreList}.`,
'Deferred tools (name — purpose):',
...lines,
'</tool_catalog>',
@@ -283,6 +335,7 @@ export function buildSystemPrompt({
openedPage,
mcpInstructions,
interrupted,
superseded,
pageChanged,
deferredToolsEnabled,
toolCatalog,
@@ -332,6 +385,13 @@ export function buildSystemPrompt({
context += `\n${INTERRUPT_NOTE}`;
}
// Supersede note (#487): present only for a turn that stopped and replaced a
// still-live previous run — warns the model the previous run's last operations
// may still be applying (no side-effect quiescence).
if (superseded) {
context += `\n${SUPERSEDE_NOTE}`;
}
// Per-turn page-change note (#274). Added to the context section (inside the
// safety sandwich), present only when the server detected that the open page
// was edited by the user since the agent's last turn ended. The diff content is
@@ -89,6 +89,11 @@ describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
const runRepo = {
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
// #487: the terminal settle now goes through the CONDITIONAL write.
finalizeIfActive: jest
.fn()
.mockResolvedValue({ id: 'run-1', status: 'failed' }),
findById: jest.fn().mockResolvedValue(undefined),
};
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
@@ -148,9 +153,10 @@ describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
// The run was begun...
expect(runRepo.insert).toHaveBeenCalledTimes(1);
// ...then settled to a terminal FAILED status by the safety net...
expect(runRepo.update).toHaveBeenCalledTimes(1);
expect(runRepo.update).toHaveBeenCalledWith(
// ...then settled to a terminal FAILED status by the safety net (via the
// #487 conditional write)...
expect(runRepo.finalizeIfActive).toHaveBeenCalledTimes(1);
expect(runRepo.finalizeIfActive).toHaveBeenCalledWith(
'run-1',
'ws1',
expect.objectContaining({ status: 'failed' }),
@@ -1,4 +1,8 @@
import { ConflictException, Logger } from '@nestjs/common';
import {
ConflictException,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
// about to reject. The race rejection happens at runHooks.begin(), long before
@@ -53,7 +57,7 @@ describe('AiChatService.stream — concurrent-run race rejection (#184)', () =>
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
const begin = jest.fn(beginImpl);
return { svc, begin, aiChatRepo, aiChatMessageRepo };
@@ -151,6 +155,8 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
insert: jest.fn(async () => ({ id: 'msg-1' })),
findAllByChat: jest.fn(async () => []),
update: jest.fn(async () => ({ id: 'msg-1' })),
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
findStreamingWithTerminalRun: jest.fn(async () => []),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
@@ -173,7 +179,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (openPage undefined -> never touched)
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
return { svc };
}
@@ -199,7 +205,8 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
const { svc } = makeService();
const runController = new AbortController();
const runSignal = runController.signal;
const socketSignal = new AbortController().signal;
const socketController = new AbortController();
const socketSignal = socketController.signal;
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
await svc.stream({
@@ -223,13 +230,26 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
expect(streamTextMock).toHaveBeenCalledTimes(1);
// THE assertion: the agent loop's abort is wired to the RUN, so a browser
// disconnect (which aborts only `socketSignal`) cannot end the turn.
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(runSignal);
expect(streamTextMock.mock.calls[0][0].abortSignal).not.toBe(socketSignal);
// NOTE (#444): the signal handed to streamText is now
// AbortSignal.any([effectiveSignal, degenerationController.signal]), so it is
// no longer identity-equal to `runSignal`. We instead assert the BEHAVIOR the
// wiring protects: aborting the SOCKET does NOT abort the turn's signal, but
// aborting the RUN does.
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
expect(passed).not.toBe(socketSignal);
expect(passed.aborted).toBe(false);
socketController.abort?.();
// A socket abort must not reach a run-wrapped turn.
expect(passed.aborted).toBe(false);
// A run abort must.
runController.abort();
expect(passed.aborted).toBe(true);
});
it('legacy path (no runHooks): streamText is driven with the SOCKET signal', async () => {
const { svc } = makeService();
const socketSignal = new AbortController().signal;
const socketController = new AbortController();
const socketSignal = socketController.signal;
await svc.stream({
user: { id: 'user-1' } as never,
@@ -244,7 +264,12 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
});
expect(streamTextMock).toHaveBeenCalledTimes(1);
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
// #444: the passed signal is AbortSignal.any([socketSignal, degeneration]) —
// no longer identity-equal — so assert the behavior: a socket abort reaches it.
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
expect(passed.aborted).toBe(false);
socketController.abort();
expect(passed.aborted).toBe(true);
});
/**
@@ -309,7 +334,13 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
usage: {},
steps: [],
});
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
// #487: onFinish passes the (undefined) error slot so a message-finalize
// failure could error-mark the run; on the success path it is undefined.
expect(runHooks.onSettled).toHaveBeenCalledWith(
'run-1',
'completed',
undefined,
);
});
it('F9: onAbort settles the run "aborted"', async () => {
@@ -341,22 +372,22 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
});
/**
* F14 — the begin-failure RESILIENCE branch (the `else` of the run-race guard).
* F14 — the begin-failure branch (the `else` of the run-race guard).
*
* stream() wraps runHooks.begin in try/catch with TWO branches:
* - RunAlreadyActiveError -> 409 ConflictException (pinned above).
* - ANY OTHER begin failure -> SWALLOW + continue UNTRACKED on the socket signal
* (legacy fallback): it logs "...streaming without run tracking", leaves
* `effectiveSignal = signal` (runId undefined) and serves the turn anyway.
* - ANY OTHER begin failure -> throw ServiceUnavailableException(A_RUN_BEGIN_FAILED)
* BEFORE the first byte (#486, commit 4).
*
* The contract: a transient beginRun failure (e.g. a non-unique DB error inserting
* the run row) must STILL serve the user's turn — it must NOT re-throw and must NOT
* be misclassified as a 409. A regression that re-threw here would break EVERY turn
* on a begin failure with nothing to catch it. This branch is otherwise undriven by
* any spec, so it is pinned here SEPARATELY from the 409 path: a plain begin error
* proceeds to streamText with the SOCKET signal and still persists the user turn.
* POLICY CHANGE (#486): the OLD contract here was "SWALLOW + stream the turn
* UNTRACKED on the socket signal". That was reversed: an untracked run is
* invisible to /stop, is not aborted on disconnect, and slips past the one-run
* gate — an unstoppable ghost run in autonomous mode. Now a plain begin failure
* FAILS the turn fast with a 503 A_RUN_BEGIN_FAILED, before any user row is
* persisted and before streamText runs. This case is INVERTED (not deleted) so
* the "plain begin failure" path stays explicitly pinned under the new policy.
*/
describe('AiChatService.stream — begin-failure resilience / legacy fallback (#184 F14)', () => {
describe('AiChatService.stream — begin-failure fails the turn (#184 F14 / #486)', () => {
const streamTextMock = streamText as unknown as jest.Mock;
function makeStreamResult() {
@@ -392,6 +423,8 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
insert: jest.fn(async () => ({ id: 'msg-1' })),
findAllByChat: jest.fn(async () => []),
update: jest.fn(async () => ({ id: 'msg-1' })),
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
findStreamingWithTerminalRun: jest.fn(async () => []),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
@@ -414,7 +447,7 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
return { svc, aiChatMessageRepo };
}
@@ -436,13 +469,14 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
afterEach(() => jest.restoreAllMocks());
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) does NOT 409 — it swallows, logs, and streams the turn UNTRACKED on the socket signal', async () => {
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) FAILS the turn with a 503 A_RUN_BEGIN_FAILED before the first byte — NO untracked stream (#486)', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
const { svc, aiChatMessageRepo } = makeService();
const socketSignal = new AbortController().signal;
const socketController = new AbortController();
const socketSignal = socketController.signal;
// A transient, NON-race begin failure (e.g. a non-unique DB error inserting
// the run row). This is the `else` branch of the begin try/catch.
@@ -467,23 +501,26 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
} as never,
});
// The turn proceeds: NO throw at all (in particular NOT a 409).
await expect(promise).resolves.toBeUndefined();
// NEW POLICY: the turn is REJECTED with a 503 A_RUN_BEGIN_FAILED (not a 409,
// and NOT swallowed into an untracked stream).
await expect(promise).rejects.toBeInstanceOf(ServiceUnavailableException);
const err = (await promise.catch(
(e) => e,
)) as ServiceUnavailableException;
expect(err.getStatus()).toBe(503);
expect(err.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
expect(begin).toHaveBeenCalledTimes(1);
// The resilience branch logged the legacy-fallback warning.
// It logged the fail-the-turn line.
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('streaming without run tracking'),
expect.stringContaining('failing the turn'),
expect.anything(),
);
// The turn really streamed: the user message was persisted and streamText ran.
expect(aiChatMessageRepo.insert).toHaveBeenCalled();
expect(streamTextMock).toHaveBeenCalledTimes(1);
// The decisive wiring: with no run handle, the fallback uses the SOCKET signal
// (effectiveSignal = signal, runId undefined) — not a run-bound signal.
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
// Fail-fast: the turn NEVER streamed — no user row persisted, no streamText
// call, so no orphan/untracked run was left behind.
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
expect(streamTextMock).not.toHaveBeenCalled();
});
});
@@ -52,7 +52,7 @@ describe('AiChatService.stream — abort during external-MCP setup finalizes the
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (openPage undefined -> never touched)
{} as never, // pageAccess
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
);
return { svc, tools };
}
@@ -15,6 +15,7 @@ import {
serializeSteps,
rowToUiMessage,
prepareAgentStep,
stepBudgetWarning,
flushAssistant,
stripNulChars,
chatStreamMetadata,
@@ -22,7 +23,11 @@ import {
isInterruptResume,
sameInstant,
MAX_AGENT_STEPS,
STEP_BUDGET_WARNING_LEAD,
FINAL_STEP_INSTRUCTION,
FINAL_STEP_NUDGE,
STEP_LIMIT_NO_ANSWER_MARKER,
OUTPUT_DEGENERATION_ERROR,
} from './ai-chat.service';
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
import { buildSystemPrompt } from './ai-chat.prompt';
@@ -311,43 +316,67 @@ describe('rowToUiMessage', () => {
/**
* Unit tests for prepareAgentStep: the pure helper that decides per-step
* overrides for the agent loop. Early steps return undefined (default
* behavior); the final allowed step (stepNumber === MAX_AGENT_STEPS - 1) forces
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
* appended onto — not replacing — the original system prompt.
* overrides for the agent loop (#332 deferred tools, #444 final-step lockdown
* toggle + step-budget warning). Parametrized by the two toggles so a change to
* one path cannot silently mask a regression in the other.
*
* Final-step behavior (#444):
* - lockdown ON (legacy): the last step (MAX-1) forces a text-only synthesis
* answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION appended, persona kept).
* - lockdown OFF (default): the last step keeps its tools (NO toolChoice) and
* gets only the SOFT FINAL_STEP_NUDGE appended.
*/
// Narrowing helpers for the prepareAgentStep union return type.
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
r as { toolChoice: 'none'; system: string };
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
r as { activeTools: string[] };
r as { activeTools: string[]; system?: string };
const asSystemOnly = (r: ReturnType<typeof prepareAgentStep>) =>
r as { system: string };
describe('prepareAgentStep', () => {
// --- toggle OFF (default): unchanged behavior ---
it('returns undefined for the first step (toggle off)', () => {
// --- deferred OFF, lockdown OFF (the new default) ---
it('returns undefined for the first step (both toggles off)', () => {
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
});
it('returns undefined for a non-final step (toggle off)', () => {
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
it('returns undefined for a clean non-final, non-warning step', () => {
// A step below the warning band and not the last => no override at all.
expect(prepareAgentStep(MAX_AGENT_STEPS - 10, 'SYS')).toBeUndefined();
});
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
it('final step (lockdown OFF) keeps tools and appends only the SOFT nudge', () => {
const result = asSystemOnly(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
expect(result).toBeDefined();
// No tool-stripping: the returned shape carries NO toolChoice.
expect(
(result as unknown as { toolChoice?: string }).toolChoice,
).toBeUndefined();
expect(result.system.startsWith('SYS')).toBe(true);
expect(result.system).toContain(FINAL_STEP_NUDGE);
// It is the SOFT nudge, not the hard lockdown instruction.
expect(result.system).not.toContain(FINAL_STEP_INSTRUCTION);
});
// --- lockdown ON (legacy): unchanged tool-stripping on the last step ---
it('final step (lockdown ON) forces a text-only synthesis', () => {
const result = asLockdown(
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', [], false, true),
);
expect(result.toolChoice).toBe('none');
// The original persona is preserved (prefix), not replaced.
expect(result.system.startsWith('SYS')).toBe(true);
// The synthesis instruction is appended.
// The synthesis instruction is appended (NOT the soft nudge).
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
expect(result.system).not.toContain(FINAL_STEP_NUDGE);
});
it('does NOT narrow activeTools when the toggle is off', () => {
it('does NOT narrow activeTools when deferred is off', () => {
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
expect(result).toBeUndefined();
});
// --- toggle ON (#332): deferred tool visibility ---
// --- deferred ON (#332): deferred tool visibility ---
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
const activated = new Set<string>();
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
@@ -358,6 +387,8 @@ describe('prepareAgentStep', () => {
// No deferred tool is active before it is loaded.
expect(result.activeTools).not.toContain('createPage');
expect(result.activeTools).not.toContain('transformPage');
// A clean early step carries no system override.
expect(result.system).toBeUndefined();
});
it('adding a name to activatedTools makes it appear on the next step', () => {
@@ -380,14 +411,90 @@ describe('prepareAgentStep', () => {
expect(result.activeTools).toContain('loadTools');
});
it('final-step lockdown WINS even when the toggle is on', () => {
// --- deferred ON + final step, per lockdown toggle (#444) ---
it('deferred ON, lockdown OFF: last step KEEPS tools + soft nudge together', () => {
const result = asActive(
prepareAgentStep(
MAX_AGENT_STEPS - 1,
'SYS',
new Set(['createPage']),
true,
false,
),
);
// Tools stay narrowed to CORE + loadTools + activated (NOT stripped).
expect(result.activeTools).toContain('editPageText');
expect(result.activeTools).toContain('loadTools');
expect(result.activeTools).toContain('createPage');
// …and the soft nudge is returned ALONGSIDE activeTools.
expect(result.system).toContain(FINAL_STEP_NUDGE);
expect(
(result as unknown as { toolChoice?: string }).toolChoice,
).toBeUndefined();
});
it('deferred ON, lockdown ON: lockdown WINS (tools stripped)', () => {
const result = asLockdown(
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
prepareAgentStep(
MAX_AGENT_STEPS - 1,
'SYS',
new Set(['createPage']),
true,
true,
),
);
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
expect(result.toolChoice).toBe('none');
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
expect(
(result as unknown as { activeTools?: string[] }).activeTools,
).toBeUndefined();
});
});
/**
* Step-budget warning boundaries (#444). At MAX_AGENT_STEPS=50 the warning fires
* on steps MAX-6 .. MAX-2 (44..48) with a decreasing remaining-count, is CLEAN
* below the band (0..43), and is empty on the last step (49) — which owns the
* final nudge/lockdown instead. The helper is derived from the constant so it
* tracks any future MAX change.
*/
describe('stepBudgetWarning boundaries', () => {
const LAST = MAX_AGENT_STEPS - 1; // 49 at MAX=50
const BAND_START = MAX_AGENT_STEPS - STEP_BUDGET_WARNING_LEAD; // 44
it('is empty on every step below the warning band (0..BAND_START-1)', () => {
for (let s = 0; s < BAND_START; s++) {
expect(stepBudgetWarning(s)).toBe('');
}
});
it('fires on BAND_START..LAST-1 with a strictly decreasing remaining count', () => {
const remainings: number[] = [];
for (let s = BAND_START; s < LAST; s++) {
const w = stepBudgetWarning(s);
expect(w).toContain('tool-use steps remain');
const m = w.match(/Only (\d+) tool-use steps remain/);
expect(m).not.toBeNull();
remainings.push(Number(m![1]));
}
// Exactly STEP_BUDGET_WARNING_LEAD-1 warning steps (44..48).
expect(remainings).toHaveLength(STEP_BUDGET_WARNING_LEAD - 1);
// Remaining = MAX-1-step, so it decreases by 1 each step and ends at 1.
for (let i = 1; i < remainings.length; i++) {
expect(remainings[i]).toBe(remainings[i - 1] - 1);
}
expect(remainings[remainings.length - 1]).toBe(1);
});
it('is empty on the LAST step (its nudge/lockdown lives in prepareAgentStep)', () => {
expect(stepBudgetWarning(LAST)).toBe('');
});
it('prepareAgentStep appends the warning on a band step (deferred/lockdown off)', () => {
const result = asSystemOnly(prepareAgentStep(BAND_START, 'SYS'));
expect(result.system).toContain('Stop exploring and start acting now');
expect(result.system).not.toContain(FINAL_STEP_NUDGE);
});
});
@@ -1208,8 +1315,12 @@ describe('AiChatService page-change lifecycle (#274)', () => {
describe('isInterruptResume', () => {
// history tail is the just-inserted user row; [len-2] is the previous turn.
const withPrev = (
prev: { role: string; status?: string | null } | null,
): Array<{ role: string; status?: string | null }> =>
prev: {
role: string;
status?: string | null;
metadata?: unknown;
} | null,
): Array<{ role: string; status?: string | null; metadata?: unknown }> =>
prev
? [prev, { role: 'user', status: null }]
: [{ role: 'user', status: null }];
@@ -1250,6 +1361,33 @@ describe('isInterruptResume', () => {
it('false when there is no preceding turn (only the new user row)', () => {
expect(isInterruptResume(withPrev(null), true)).toBe(false);
});
it('#487 EXCLUDES a reconcile stamp (finalizeFailed) — not a genuine interruption', () => {
// A row a reconcile settled to 'aborted' carries metadata.finalizeFailed. It
// must NOT be treated as an interrupt-resume (that would inject a false
// "you were interrupted" note), even though its status is 'aborted'.
expect(
isInterruptResume(
withPrev({
role: 'assistant',
status: 'aborted',
metadata: { finalizeFailed: true },
}),
true,
),
).toBe(false);
// A genuine abort (no finalizeFailed) still counts.
expect(
isInterruptResume(
withPrev({
role: 'assistant',
status: 'aborted',
metadata: { parts: [] },
}),
true,
),
).toBe(true);
});
});
/**
@@ -1312,6 +1450,9 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
insert: jest.fn(async () => ({ id: 'msg-1' })),
findAllByChat: jest.fn(async () => []),
update: jest.fn(async () => ({ id: 'msg-1' })),
// #487: the terminal owner-write + the opportunistic reconcile query.
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
findStreamingWithTerminalRun: jest.fn(async () => []),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
@@ -1341,6 +1482,7 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
{} as never, // pageAccess
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatFinalStepLockdownEnabled: () => false,
isAiChatResumableStreamEnabled: () => opts.resumable,
} as never,
streamRegistry as never,
@@ -1429,3 +1571,361 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
});
});
/**
* #444 — the token-degeneration SAFETY REACTION path (integration).
*
* output-degeneration.spec.ts proves the detector DETECTS; this proves the wired
* REACTION: a degenerate stream must (1) trip the detector in onChunk, (2) abort
* the turn via the INTERNAL degeneration controller (distinct from a user Stop),
* (3) truncate the runaway tail before persist in onAbort, (4) persist status
* 'error' with the OUTPUT_DEGENERATION_ERROR message (not a bare 'aborted' and not
* a swept 'streaming'), and (5) still release the leased external MCP clients.
*
* Harness: streamText is the SAME jest.fn mocked at the top of this file. Unlike
* the pipe-options suite above (which only inspects the pipe call), this mock
* CAPTURES the streamText options (onChunk/onAbort/onFinish + abortSignal) so the
* test can drive the callbacks exactly as the AI SDK would — feeding degenerate
* text-delta chunks through onChunk until the service's own AbortController fires,
* then invoking onAbort (which the SDK does on an aborted signal). No new mocking
* style is invented; it reuses the makeRes / service-construction shape above.
*/
describe('AiChatService.stream — token-degeneration reaction (#444)', () => {
const streamTextMock = streamText as unknown as jest.Mock;
beforeEach(() => {
streamTextMock.mockReset();
jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined as never);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as never);
});
afterEach(() => jest.restoreAllMocks());
function makeRes() {
return {
raw: {
writeHead: jest.fn(),
write: jest.fn(),
once: jest.fn(),
on: jest.fn(),
flushHeaders: jest.fn(),
writableEnded: false,
destroyed: false,
},
};
}
// Wire the full stream() path with in-memory fakes. The assistant row is
// captured so the terminal finalize (an UPDATE of the upfront-seeded row) can be
// asserted. One external MCP client with a close() spy lets us assert leases are
// released on the terminal path. lockdown OFF (default) so the detector is the
// active guard.
function makeService() {
// The upfront insert seeds the assistant row; findById/insert stamp a stable
// id so planFinalizeAssistant picks the UPDATE path.
let seq = 0;
const inserted: Array<Record<string, unknown>> = [];
const updated: Array<{
id: string;
workspaceId: string;
patch: Record<string, unknown>;
}> = [];
const aiChatRepo = {
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
insert: jest.fn(),
};
const aiChatMessageRepo = {
insert: jest.fn(async (row: Record<string, unknown>) => {
inserted.push(row);
return { id: row.role === 'assistant' ? 'assistant-1' : `user-${++seq}` };
}),
findAllByChat: jest.fn(async () => []),
update: jest.fn(
async (
id: string,
workspaceId: string,
patch: Record<string, unknown>,
) => {
updated.push({ id, workspaceId, patch });
return { id };
},
),
// #487: the terminal owner-write records into the SAME `updated` recorder so
// assertions on the terminal 'completed'/'error'/'aborted' write still hold.
finalizeOwner: jest.fn(
async (
id: string,
workspaceId: string,
patch: Record<string, unknown>,
) => {
updated.push({ id, workspaceId, patch });
return { id };
},
),
findStreamingWithTerminalRun: jest.fn(async () => []),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
const mcpClose = jest.fn(async () => undefined);
const mcpClients = {
toolsFor: jest.fn(async () => ({
tools: {},
clients: [{ close: mcpClose }],
outcomes: [],
instructions: [],
})),
};
const streamRegistry = { open: jest.fn(), bind: jest.fn(), abortEntry: jest.fn() };
const svc = new AiChatService(
{} as never,
aiChatRepo as never,
aiChatMessageRepo as never,
{} as never, // aiChatPageSnapshotRepo (no open page -> never touched)
aiSettings as never,
tools as never,
mcpClients as never,
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (no open page)
{} as never, // pageAccess
{
isAiChatDeferredToolsEnabled: () => false,
// lockdown OFF => the degeneration detector is the anti-babble guard.
isAiChatFinalStepLockdownEnabled: () => false,
isAiChatResumableStreamEnabled: () => false,
} as never,
streamRegistry as never,
);
return { svc, inserted, updated, mcpClose };
}
const body = {
chatId: 'chat-1',
messages: [
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
],
};
// Capture the streamText options so the test can drive the SDK callbacks. The
// returned result stub is enough for the post-streamText wiring (consumeStream +
// pipeUIMessageStreamToResponse are no-ops here).
function captureStreamText(): { opts: () => Record<string, any> } {
let captured: Record<string, any> | undefined;
streamTextMock.mockImplementation((options: Record<string, any>) => {
captured = options;
return {
consumeStream: jest.fn(),
pipeUIMessageStreamToResponse: jest.fn(),
};
});
return {
opts: () => {
if (!captured) throw new Error('streamText was not called');
return captured;
},
};
}
async function drive(svc: AiChatService): Promise<void> {
await svc.stream({
user: { id: 'u1' } as never,
workspace: { id: 'ws-1' } as never,
sessionId: 's1',
body: body as never,
res: makeRes() as never,
signal: new AbortController().signal,
model: {} as never,
role: null,
runHooks: undefined as never,
});
}
it('degenerate stream: detects → internal abort → onAbort truncates + records OUTPUT_DEGENERATION_ERROR; leases released', async () => {
const { svc, updated, mcpClose } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
// The turn's abort signal is the UNION of the socket/run signal and the
// internal degeneration controller — untripped before any output.
expect(opts.abortSignal.aborted).toBe(false);
// Feed a runaway "loadTools.\n" loop the way the SDK streams it: many small
// text-delta chunks. The onChunk throttle only re-checks every ~2000 chars, so
// deliver well past that so the detector's identical-line rule (>=25 lines)
// and the ~2000-char throttle both fire.
const line = 'loadTools.\n';
let delivered = 0;
for (let i = 0; i < 400 && !opts.abortSignal.aborted; i++) {
opts.onChunk({ chunk: { type: 'text-delta', text: line } });
delivered += line.length;
}
// The detector must have tripped and aborted via the INTERNAL controller — the
// reason carries the degeneration message, distinguishing it from a user Stop
// (which aborts with no such reason) or a socket disconnect.
expect(opts.abortSignal.aborted).toBe(true);
expect(delivered).toBeGreaterThan(2000);
expect(String(opts.abortSignal.reason)).toContain(
'Output degeneration detected',
);
// The SDK reacts to the aborted signal by invoking onAbort. `steps` is empty
// (the runaway never finished a step); the in-progress runaway text is what
// gets truncated + persisted.
await opts.onAbort({ steps: [] });
// Terminal finalize = an UPDATE of the upfront-seeded assistant row (assistant
// row was inserted upfront, so planFinalizeAssistant -> UPDATE).
expect(updated).toHaveLength(1);
const patch = updated[0].patch as {
status: string;
content: string;
metadata: Record<string, unknown>;
};
// (4) status 'error' with the degeneration message — NOT 'aborted' and NOT a
// swept 'streaming'. This distinguishes it from a user Stop / server restart.
expect(patch.status).toBe('error');
expect(patch.metadata.error).toBe(OUTPUT_DEGENERATION_ERROR);
expect(patch.metadata.finishReason).toBe('error');
// (3) the runaway tail is TRUNCATED, not the full multi-KB babble: the marker
// is present and the persisted content is far shorter than what was streamed.
expect(patch.content).toContain('output truncated');
expect(patch.content.length).toBeLessThan(delivered);
// Only a few loop reps survive (truncateDegeneratedTail keeps a handful).
expect((patch.content.match(/loadTools\./g) ?? []).length).toBeLessThan(10);
// (5) the leased external MCP client is still released on this terminal path.
expect(mcpClose).toHaveBeenCalledTimes(1);
});
it('degeneration onAbort differs from a NORMAL/user abort (no truncation, no error)', async () => {
// Same harness, but the stream is NOT degenerate: a clean short answer, then a
// user Stop reaches onAbort WITHOUT the degeneration controller having fired.
const { svc, updated, mcpClose } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
opts.onChunk({ chunk: { type: 'text-delta', text: 'A normal partial answer.' } });
// The detector never tripped -> the union signal is NOT aborted by us.
expect(opts.abortSignal.aborted).toBe(false);
// A user Stop / disconnect drives onAbort with the partial (clean) text.
await opts.onAbort({ steps: [] });
expect(updated).toHaveLength(1);
const patch = updated[0].patch as {
status: string;
content: string;
metadata: Record<string, unknown>;
};
// A normal abort persists status 'aborted' with NO error and NO truncation
// marker — the branch is genuinely distinguished from the degeneration path.
expect(patch.status).toBe('aborted');
expect('error' in patch.metadata).toBe(false);
expect(patch.content).toBe('A normal partial answer.');
expect(patch.content).not.toContain('output truncated');
// Cleanup still runs on the normal abort path too.
expect(mcpClose).toHaveBeenCalledTimes(1);
});
/**
* Empty-turn marker (#444): onFinish appends STEP_LIMIT_NO_ANSWER_MARKER only
* when the turn burned ALL its steps (steps.length >= MAX_AGENT_STEPS) AND never
* produced any text. The negative: a normal turn ending WITH text is left alone.
*/
it('empty turn (no text + steps exhausted) persists the STEP_LIMIT_NO_ANSWER_MARKER', async () => {
const { svc, updated } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
// MAX_AGENT_STEPS text-less steps (only tool calls) => step-exhausted, no text.
const steps = Array.from({ length: MAX_AGENT_STEPS }, () => ({
text: '',
toolCalls: [{ toolCallId: 'c1', toolName: 'searchPages', input: {} }],
toolResults: [
{ toolCallId: 'c1', toolName: 'searchPages', output: { hits: [] } },
],
}));
await opts.onFinish({
text: '',
finishReason: 'tool-calls',
totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
usage: { inputTokens: 1, outputTokens: 1 },
steps,
});
expect(updated).toHaveLength(1);
const patch = updated[0].patch as { status: string; content: string };
expect(patch.status).toBe('completed');
// The synthetic marker is the trailing text of the persisted content.
expect(patch.content).toContain(STEP_LIMIT_NO_ANSWER_MARKER);
});
it('normal turn ending WITH text does NOT get the empty-turn marker', async () => {
const { svc, updated } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
// A single step that produced a real answer, well under the step cap.
const steps = [
{ text: 'Here is the finished answer.', toolCalls: [], toolResults: [] },
];
await opts.onFinish({
text: 'Here is the finished answer.',
finishReason: 'stop',
totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
usage: { inputTokens: 1, outputTokens: 1 },
steps,
});
expect(updated).toHaveLength(1);
const patch = updated[0].patch as { status: string; content: string };
expect(patch.status).toBe('completed');
expect(patch.content).toBe('Here is the finished answer.');
expect(patch.content).not.toContain(STEP_LIMIT_NO_ANSWER_MARKER);
});
it('step-exhausted turn that DID produce text keeps the text, no marker (guards the AND)', async () => {
// Exhausting the step budget alone must NOT append the marker when SOME step
// produced text — the marker keys off "no text" too. Drive the real onFinish
// with MAX_AGENT_STEPS steps where the last one carries the answer.
const { svc, updated } = makeService();
const cap = captureStreamText();
await drive(svc);
const opts = cap.opts();
const steps = Array.from({ length: MAX_AGENT_STEPS }, (_, i) => ({
text: i === MAX_AGENT_STEPS - 1 ? 'Final synthesized answer.' : '',
toolCalls:
i === MAX_AGENT_STEPS - 1
? []
: [{ toolCallId: `c${i}`, toolName: 'searchPages', input: {} }],
toolResults:
i === MAX_AGENT_STEPS - 1
? []
: [{ toolCallId: `c${i}`, toolName: 'searchPages', output: {} }],
}));
await opts.onFinish({
text: 'Final synthesized answer.',
finishReason: 'stop',
totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
usage: { inputTokens: 1, outputTokens: 1 },
steps,
});
expect(updated).toHaveLength(1);
const patch = updated[0].patch as { content: string };
expect(patch.content).toContain('Final synthesized answer.');
expect(patch.content).not.toContain(STEP_LIMIT_NO_ANSWER_MARKER);
});
});
+518 -65
View File
@@ -3,7 +3,9 @@ import {
ForbiddenException,
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import {
@@ -41,7 +43,11 @@ import {
makeLoadToolsTool,
buildExternalToolCatalog,
} from './tools/tool-tiers';
import { RunAlreadyActiveError } from './ai-chat-run.service';
import {
RunAlreadyActiveError,
AiChatRunService,
} from './ai-chat-run.service';
import { inAppToolCallCapMs } from './tools/ai-chat-tools.service';
import { computePageChange } from './page-change/page-change.util';
import {
sanitizeSelection,
@@ -52,11 +58,25 @@ import {
startSseHeartbeat,
stripStreamingHopByHopHeaders,
} from './sse-resilience';
import {
isDegenerateOutput,
truncateDegeneratedTail,
shouldCheckDegeneration,
} from './output-degeneration';
// Max agent steps per turn. One step = one model generation; a step that calls
// tools is followed by another step carrying the tool results. Raised from 8 so
// multi-search research questions are not cut off mid-investigation.
const MAX_AGENT_STEPS = 20;
// multi-search research questions are not cut off mid-investigation, then from 20
// to 50 (#444) so read-heavy turns (e.g. dozens of searchInPage sweeps) do not
// exhaust the budget before acting.
const MAX_AGENT_STEPS = 50;
// How many steps before the LAST one the step-budget warning starts firing
// (#444). At MAX-STEP_BUDGET_WARNING_LEAD .. MAX-2 the model is told to stop
// exploring and start acting, with the remaining count decreasing each step; the
// last step (MAX-1) has its own final nudge / lockdown instead (see
// prepareAgentStep).
const STEP_BUDGET_WARNING_LEAD = 6;
// Wall-clock ceiling for building the external MCP toolset during the per-turn
// setup phase (before streamText owns the lifecycle). Defense-in-depth ABOVE the
@@ -82,16 +102,69 @@ const FINAL_STEP_INSTRUCTION =
'language. If the information is incomplete, say so explicitly: summarize ' +
'what you found, what is still missing, and give your best partial conclusion.';
// Pure, unit-testable: decide per-step overrides. Two responsibilities:
// 1. Final-step lockdown (always): on the final allowed step force a text-only
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS —
// it takes precedence over the deferred-tool narrowing below.
// 2. Deferred tool visibility (#332): when `deferredEnabled` and NOT the final
// step, expose only the CORE tools + loadTools + whatever loadTools has
// activated so far this turn (`activatedTools`), via `activeTools`. Deferred
// tools stay in the <tool_catalog> until the model loads them.
// When `deferredEnabled` is false the behavior is unchanged: undefined on normal
// steps (all tools active), lockdown on the final step.
// SOFT final-step nudge (#444), used when the final-step lockdown toggle is OFF
// (the new default). Unlike FINAL_STEP_INSTRUCTION it does NOT strip tools
// (toolChoice stays untouched), so the model is never forced into a tool-less
// state mid-work — that tool-stripping is what triggered the 255KB token-loop
// degeneration incident. It only asks the model to finish with a text summary.
const FINAL_STEP_NUDGE =
'This is the LAST step of this turn. Write your final answer to the user now.\n' +
'You may still call tools, but the turn ends after this step either way —\n' +
'prefer finishing with a clear text summary of what was done and what remains.';
// Synthetic marker text appended in onFinish when a step-exhausted turn produced
// NO text at all (#444, mitigates the "empty turn" the lockdown used to prevent
// when the toggle is OFF). Makes the exhausted-without-answer state explicit to
// the user and, on replay, to the model on the next turn.
const STEP_LIMIT_NO_ANSWER_MARKER =
'(Достигнут лимит шагов — итоговый ответ не сформулирован; работа могла ' +
'остаться незавершённой. Напишите «продолжай», чтобы агент продолжил.)';
// Reason recorded in ai_chat_runs.error / the assistant row when the token-
// degeneration detector (#444) aborts a run. Distinct from a user Stop (no error)
// and from a server restart ('streaming' -> swept to 'aborted' with no message).
const OUTPUT_DEGENERATION_ERROR =
'Output degeneration detected (repeated token loop)';
/**
* Compute the step-budget warning text (#444), or '' when this step is outside
* the warning band. The warning fires on steps
* MAX_AGENT_STEPS-STEP_BUDGET_WARNING_LEAD .. MAX_AGENT_STEPS-2 (NOT the last
* step, which has its own final nudge/lockdown), telling the model to stop
* exploring and start acting. `N` is the number of tool-use steps still
* remaining (`MAX_AGENT_STEPS - 1 - stepNumber`), so it decreases toward the
* end. Pure.
*/
export function stepBudgetWarning(stepNumber: number): string {
const isLastStep = stepNumber >= MAX_AGENT_STEPS - 1;
const inBand = stepNumber >= MAX_AGENT_STEPS - STEP_BUDGET_WARNING_LEAD;
if (isLastStep || !inBand) return '';
const remaining = MAX_AGENT_STEPS - 1 - stepNumber;
return (
`Only ${remaining} tool-use steps remain in this turn. Stop exploring and start acting now\n` +
'(make the edits / create the comments / produce results). Leave room to finish\n' +
'with a final text answer.'
);
}
// Pure, unit-testable: decide per-step overrides. Responsibilities:
// 1. Final-step handling. Two modes, chosen by `finalStepLockdownEnabled`:
// - toggle ON (legacy): on the final allowed step force a text-only
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS
// — it takes precedence over the deferred-tool narrowing below.
// - toggle OFF (new default, #444): do NOT touch toolChoice — tools stay
// available on every step incl. the last, so the model is never stripped
// of its tools mid-work (the cause of the token-loop degeneration
// incident). A SOFT nudge (FINAL_STEP_NUDGE) is appended to `system`, and
// the deferred-tool `activeTools` narrowing still applies to the last step
// (both `activeTools` and `system` are returned together).
// 2. Step-budget warning (#444): on steps in the warning band (but not the
// last, which has its own nudge/lockdown) append stepBudgetWarning(...) to
// `system` so the model starts acting before it runs out of steps.
// 3. Deferred tool visibility (#332): when `deferredEnabled`, expose only the
// CORE tools + loadTools + whatever loadTools has activated so far this turn
// (`activatedTools`), via `activeTools`. Deferred tools stay in the
// <tool_catalog> until the model loads them.
//
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
@@ -107,31 +180,53 @@ export function prepareAgentStep(
system: string,
activatedTools: ReadonlySet<string> | readonly string[] = [],
deferredEnabled = false,
finalStepLockdownEnabled = false,
):
| { toolChoice: 'none'; system: string }
| { activeTools: string[] }
| { activeTools: string[]; system?: string }
| { system: string }
| undefined {
// Final-step lockdown WINS (applies regardless of the deferred toggle).
if (stepNumber >= MAX_AGENT_STEPS - 1) {
const isLastStep = stepNumber >= MAX_AGENT_STEPS - 1;
// Legacy final-step lockdown (toggle ON): text-only synthesis. WINS over the
// deferred narrowing AND drops tools for this step.
if (isLastStep && finalStepLockdownEnabled) {
return {
toolChoice: 'none',
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
};
}
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools
// + the tools already activated this turn.
// Compute the extra system text for this step: the soft final nudge on the last
// step (toggle OFF), or the step-budget warning in the warning band. At most one
// of these applies (stepBudgetWarning returns '' on the last step).
const extra = isLastStep ? FINAL_STEP_NUDGE : stepBudgetWarning(stepNumber);
const systemForStep = extra ? `${system}\n\n${extra}` : undefined;
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools +
// the tools already activated this turn. Applies on EVERY step incl. the last
// (toggle OFF), so the model keeps its core tools available while being nudged
// to finish. Return `system` alongside `activeTools` when we have extra text.
if (deferredEnabled) {
const activated = Array.isArray(activatedTools)
? activatedTools
: [...activatedTools];
return {
activeTools: [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated],
};
const activeTools = [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated];
return systemForStep ? { activeTools, system: systemForStep } : { activeTools };
}
return undefined;
// Deferred OFF: all tools stay active; only append the extra system text (if any).
return systemForStep ? { system: systemForStep } : undefined;
}
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
export {
MAX_AGENT_STEPS,
STEP_BUDGET_WARNING_LEAD,
FINAL_STEP_INSTRUCTION,
FINAL_STEP_NUDGE,
STEP_LIMIT_NO_ANSWER_MARKER,
OUTPUT_DEGENERATION_ERROR,
};
// Pure, unit-testable post-processing for a model-generated title (#199): trim
// whitespace, strip a single pair of surrounding quotes the model often adds,
@@ -160,15 +255,23 @@ export function cleanGeneratedTitle(text: string): string {
* partial output is already in history thanks to the step-granular write path).
*/
export function isInterruptResume(
history: Array<{ role: string; status?: string | null }>,
history: Array<{
role: string;
status?: string | null;
metadata?: unknown;
}>,
clientInterrupted: boolean | undefined,
): boolean {
if (clientInterrupted !== true) return false;
const prev = history[history.length - 2];
return (
prev?.role === 'assistant' &&
(prev.status === 'aborted' || prev.status === 'streaming')
);
if (prev?.role !== 'assistant') return false;
// #487: a reconcile STAMP (metadata.finalizeFailed) is NOT a genuine user
// interruption — the previous turn's process died and a reconcile settled the
// row as 'aborted'. Treating it as an interrupt-resume would inject a false
// "you were interrupted" note. Exclude any finalizeFailed row.
const meta = prev.metadata as { finalizeFailed?: unknown } | null | undefined;
if (meta && meta.finalizeFailed === true) return false;
return prev.status === 'aborted' || prev.status === 'streaming';
}
/**
@@ -289,6 +392,14 @@ export interface AiChatStreamBody {
// it against persisted history (`isInterruptResume`) before injecting the
// interrupt note, so a spoofed/stale flag on an ordinary turn is ignored.
interrupted?: boolean;
// #487: server-side supersede CAS. When present, this POST asks the server to
// STOP the run `supersede.runId` (which the client saw as the chat's active run)
// and, once it has settled, start THIS turn in its place. The server validates
// the target against the chat and answers 400 (wrong chat) / 409
// SUPERSEDE_TARGET_MISMATCH / 409 SUPERSEDE_TIMEOUT, or proceeds normally
// (degrade / ready). Absent => an ordinary send (rejected with 409
// A_RUN_ALREADY_ACTIVE if a run is already active on the chat).
supersede?: { runId?: string } | null;
// useChat sends the full UIMessage list; the last one is the new user turn.
messages?: UIMessage[];
}
@@ -338,6 +449,11 @@ export interface AiChatStreamArgs {
// chat row (existing chat) or the request body (new chat). null => universal
// assistant. Carried here so the turn never re-loads it.
role: AiAgentRole | null;
// #487: true when this turn was started by SUPERSEDING a still-live previous run
// (the controller ran the supersede CAS to a `ready` result). Adds the
// SUPERSEDE_NOTE to the system prompt (the previous run's last ops may still be
// applying — no side-effect quiescence). Absent on an ordinary send.
superseded?: boolean;
}
/**
@@ -354,7 +470,7 @@ export interface AiChatStreamArgs {
* can be rebuilt for `convertToModelMessages`.
*/
@Injectable()
export class AiChatService implements OnModuleInit {
export class AiChatService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AiChatService.name);
constructor(
@@ -375,8 +491,17 @@ export class AiChatService implements OnModuleInit {
// constructions (int-specs) compile unchanged; Nest always injects the real
// provider in production. Only ever touched on the run-wrapped + flag-on path.
private readonly streamRegistry?: AiChatStreamRegistryService,
// #487: the run lifecycle service, for the periodic + opportunistic reconcile
// (zombie re-drive + stale-run abort). OPTIONAL so positional test
// constructions compile unchanged; Nest always injects the real singleton, so
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
private readonly aiChatRunService?: AiChatRunService,
) {}
// #487: periodic reconcile timer (single-process phase 1). Started in
// onModuleInit, cleared in onModuleDestroy.
private reconcileTimer?: ReturnType<typeof setInterval>;
/**
* Crash-recovery sweep on server start (#183): any assistant row left in the
* 'streaming' state is the relic of a turn whose process died before it
@@ -401,6 +526,158 @@ export class AiChatService implements OnModuleInit {
}`,
);
}
// #487: start the PERIODIC reconcile (was boot-only). It heals both directions
// of the run<->message lifecycle asymmetry that a boot sweep alone left to the
// NEXT restart. Single-process phase 1: the in-memory active/zombie maps are
// authoritative, so "no live entry" is a safe primary gate.
const staleMs = this.reconcileStalenessMs();
// boot-warn if the per-call cap is configured so high the derived staleness is
// unusually long (a stale run then lingers longer before reconcile aborts it).
if (staleMs > 30 * 60 * 1000) {
this.logger.warn(
`#487 reconcile staleness is ${Math.round(staleMs / 60000)}min ` +
`(derived from max(2 x per-call cap, 15min)); a per-call cap this high ` +
`delays stale-run recovery. Review AI_CHAT_INAPP_TOOL_CALL_CAP_MS.`,
);
}
const intervalMs = this.reconcileIntervalMs();
this.reconcileTimer = setInterval(() => {
void this.reconcile().catch((err) => {
this.logger.warn(
`Periodic reconcile failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
});
}, intervalMs);
this.reconcileTimer.unref?.();
}
/** #487: stop the periodic reconcile timer on shutdown. */
onModuleDestroy(): void {
if (this.reconcileTimer) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = undefined;
}
}
/**
* #487: reconcile staleness threshold X — a run/message is only a "no live
* runner" abort candidate once UNTOUCHED past this. Derived as
* max(2 x per-call cap, 15min): 2x the longest legitimate single tool call plus
* a floor, so a marathon turn making steady progress (updatedAt bumped each
* step) is never swept.
*/
private reconcileStalenessMs(): number {
return Math.max(2 * inAppToolCallCapMs(), 15 * 60 * 1000);
}
/** #487: how often the periodic reconcile runs (env-tunable, default 2min). */
private reconcileIntervalMs(): number {
const raw = Number(process.env.AI_CHAT_RECONCILE_INTERVAL_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 2 * 60 * 1000;
}
/**
* #487: the periodic BIDIRECTIONAL reconcile. Runs the clauses IN ORDER; each is
* best-effort (a failure of one never blocks the others). Single-process phase 1
* — the run service's in-memory maps are authoritative for "live entry".
*
* (a) re-drive ZOMBIE runs (a terminal write that gave up) — apply the intended
* status via the conditional UPDATE;
* (b) message 'streaming' + its RUN terminal -> stamp the message by the run's
* status (succeeded-run + stuck row -> 'aborted'+finalizeFailed, NOT
* 'completed' with empty parts — the final text lived only in the dead
* process's memory, a documented loss);
* (c) run active + NO live entry + NO zombie + stale -> aborted (the run
* service applies the "no entry" primary gate + last-progress staleness);
* (d) message 'streaming' + age>X + NO active run on the chat -> aborted
* (historical-row safety, double-gated).
*/
async reconcile(): Promise<void> {
const staleMs = this.reconcileStalenessMs();
// (a) zombie re-drive.
if (this.aiChatRunService) {
for (const runId of this.aiChatRunService.zombieRunIds()) {
try {
await this.aiChatRunService.settleZombie(runId);
} catch (err) {
this.logger.warn(
`Reconcile (a) zombie ${runId} re-drive failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
}
}
// (b) message streaming + run terminal -> stamp message by run status.
try {
const stuck = await this.aiChatMessageRepo.findStreamingWithTerminalRun();
for (const s of stuck) {
// succeeded-run -> 'aborted' (NOT 'completed'-empty); failed -> 'error';
// aborted -> 'aborted'. All via the finalizeFailed stamp.
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
await this.aiChatMessageRepo.stampTerminalIfStreaming(
s.messageId,
s.workspaceId,
status,
);
}
} catch (err) {
this.logger.warn(
`Reconcile (b) message<-run failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
// (c) stale active run with no live runner -> aborted.
if (this.aiChatRunService) {
try {
await this.aiChatRunService.reconcileStaleRuns(staleMs);
} catch (err) {
this.logger.warn(
`Reconcile (c) stale-run abort failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
}
// (d) historical streaming row, no active run on the chat, stale -> aborted.
try {
await this.aiChatMessageRepo.sweepStreamingWithoutActiveRun(staleMs);
} catch (err) {
this.logger.warn(
`Reconcile (d) historical-row sweep failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
}
/**
* #487: OPPORTUNISTIC single-chat reconcile at the start of a turn (beginRun /
* supersede path), so a user who returns to a chat with a stuck streaming row
* (its run already terminal) sees it settled WITHOUT waiting for the periodic
* job. Best-effort — a failure NEVER fails the turn (swallowed by the caller).
*/
async reconcileChat(chatId: string, workspaceId: string): Promise<void> {
const stuck = await this.aiChatMessageRepo.findStreamingWithTerminalRun(50, {
chatId,
workspaceId,
});
for (const s of stuck) {
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
await this.aiChatMessageRepo.stampTerminalIfStreaming(
s.messageId,
s.workspaceId,
status,
);
}
}
/**
@@ -637,6 +914,7 @@ export class AiChatService implements OnModuleInit {
model,
role,
runHooks,
superseded,
}: AiChatStreamArgs): Promise<void> {
// Resolve / create the chat. A new chat is created when no valid chatId is
// supplied or the supplied one does not belong to this workspace.
@@ -709,15 +987,49 @@ export class AiChatService implements OnModuleInit {
code: 'A_RUN_ALREADY_ACTIVE',
});
}
// Any OTHER run-start failure must not break the turn — fall back to the
// socket signal (legacy behavior) and stream anyway.
// Any OTHER run-start failure (e.g. a DB-pool blip) must FAIL THE TURN,
// not silently stream without a run-row. The old fallback let the turn
// continue untracked: in autonomous mode nobody could then abort it —
// /stop can't see a run that doesn't exist, a client disconnect doesn't
// abort it, and the one-run-per-chat gate would let a SECOND run in. That
// is an unstoppable, invisible run until process restart. Reject NOW,
// BEFORE the first byte (nothing is written yet, no user row inserted, no
// MCP lease taken), so the controller's post-hijack catch turns this
// HttpException into an honest 503 on the raw socket. Same policy for BOTH
// modes — #487 inherits it (no mode-branching here).
this.logger.error(
`Failed to begin agent run (chat ${chatId}); streaming without run tracking`,
`Failed to begin agent run (chat ${chatId}); failing the turn`,
err as Error,
);
throw new ServiceUnavailableException({
message:
'Could not start the agent run. This is usually temporary — please try again.',
code: 'A_RUN_BEGIN_FAILED',
// Self-describe the status in the body: the controller's post-hijack
// catch writes getResponse() verbatim onto the raw socket, and an
// object-arg HttpException does NOT inject statusCode. Without it the
// client's 503 classifier (which reads the body JSON) could not see the
// status. With it present, the client's A_RUN_BEGIN_FAILED branch (which
// runs strictly before the generic-503 branch) shows "temporary, retry".
statusCode: 503,
});
}
}
// #487: opportunistic single-chat reconcile — settle any streaming row on this
// chat whose run is already terminal BEFORE this turn's history load, so the
// user never waits on the periodic job and the new turn's model history is not
// polluted by a stuck 'streaming' row. Best-effort: it must NEVER fail the turn.
try {
await this.reconcileChat(chatId, workspace.id);
} catch (err) {
this.logger.debug(
`Opportunistic reconcile for chat ${chatId} failed (ignored): ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
try {
// Extract the incoming user turn (the last user message from useChat).
const incoming = lastUserMessage(body.messages);
@@ -890,6 +1202,12 @@ export class AiChatService implements OnModuleInit {
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
// OFF, every tool is active and nothing below changes.
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
// Final-step lockdown toggle (#444). Default OFF: the last step keeps its
// tools and gets only a soft nudge (prepareAgentStep), and the token-
// degeneration detector (onChunk below) is the anti-babble guard. ON =
// legacy tool-stripping lockdown on the last step.
const finalStepLockdownEnabled =
this.environment.isAiChatFinalStepLockdownEnabled();
let system: string;
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
@@ -917,6 +1235,9 @@ export class AiChatService implements OnModuleInit {
// History-confirmed interrupt-resume flag (#198): adds the interrupt note
// so the model treats the partial answer above as cut off, not finished.
interrupted,
// #487: this turn superseded a still-live run — warn the model the
// previous run's last ops may still be applying (no quiescence).
superseded,
// Detected between-turns human edit to the open page (#274): adds the
// page_changed note + unified diff so the agent doesn't overwrite it.
pageChanged,
@@ -978,6 +1299,15 @@ export class AiChatService implements OnModuleInit {
const capturedSteps: StepLike[] = [];
let inProgressText = '';
// Token-degeneration guard (#444). When the final-step lockdown is OFF, a
// runaway repetition loop (the 255KB "loadTools." incident) is aborted via
// this internal controller, unioned with the run/socket signal below. The
// detector runs on `inProgressText` in onChunk, throttled by growth so the
// pure rules only fire every ~DEGENERATION_CHECK_STEP bytes.
const degenerationController = new AbortController();
let degenerationDetected = false;
let lastDegenerationCheckLen = 0;
// Step-granular durability (#183): create the assistant row UPFRONT in the
// 'streaming' state (before any token), then UPDATE it as each step finishes
// and finalize it once on the terminal callback. If the process dies
@@ -1062,29 +1392,59 @@ export class AiChatService implements OnModuleInit {
// callbacks — mirroring the pre-#183 persist-at-most-once guard for the
// TERMINAL status (the row may be updated many times with 'streaming' before
// this fires once).
// #487: the once-gate closes ONLY AFTER a successful write, and the write is
// BOUNDED-RETRIED. Previously `finalized` was set BEFORE the write and never
// retried, so a single failed UPDATE stranded the row 'streaming' forever
// (the boot-only sweep was the only recovery). Now a transient blip is ridden
// out in place; a total give-up leaves the gate OPEN and logs, and the
// periodic reconcile (clauses b/d) later settles the row. Returns whether the
// terminal write LANDED, so the caller can error-mark the RUN on a message
// failure (the run is finalized regardless — never gated on the message).
let finalized = false;
const FINALIZE_MSG_MAX_ATTEMPTS = 3;
const finalizeAssistant = async (
flushed: AssistantFlush,
): Promise<void> => {
if (finalized) return;
finalized = true;
): Promise<boolean> => {
if (finalized) return true;
const plan = planFinalizeAssistant(assistantId);
try {
// Shared dispatch (see applyFinalize): UPDATE the upfront row, or — when
// the upfront insert failed (kind 'insert') — INSERT the terminal row as
// the only safety against losing the turn entirely.
await applyFinalize(
this.aiChatMessageRepo,
plan,
{ chatId, workspaceId: workspace.id, userId: user.id },
flushed,
);
} catch (err) {
this.logger.error(
`Failed to finalize assistant message (kind=${plan.kind})`,
err as Error,
);
let lastError: unknown;
for (let attempt = 1; attempt <= FINALIZE_MSG_MAX_ATTEMPTS; attempt++) {
try {
// Shared dispatch (see applyFinalize): conditionally UPDATE the upfront
// row (owner-write priority), or — when the upfront insert failed (kind
// 'insert') — INSERT the terminal row as the only safety against losing
// the turn entirely.
await applyFinalize(
this.aiChatMessageRepo,
plan,
{ chatId, workspaceId: workspace.id, userId: user.id },
flushed,
);
finalized = true; // gate closes ONLY after a successful write
return true;
} catch (err) {
lastError = err;
this.logger.warn(
`Assistant message finalize attempt ${attempt}/${FINALIZE_MSG_MAX_ATTEMPTS} ` +
`failed (kind=${plan.kind}): ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
if (attempt < FINALIZE_MSG_MAX_ATTEMPTS) {
await new Promise((r) => setTimeout(r, 50 * attempt));
}
}
}
// Gave up: leave the gate OPEN (no in-process second settler exists — the
// terminal callbacks are mutually exclusive) and log. The periodic reconcile
// settles the stranded row; a late owner-write is impossible for this turn,
// so the reconcile stamp (aborted+finalizeFailed) is the final state.
this.logger.error(
`Assistant message finalize GAVE UP after ${FINALIZE_MSG_MAX_ATTEMPTS} ` +
`attempts (row left 'streaming', chat ${chatId}); reconcile will settle it`,
lastError as Error,
);
return false;
};
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Measure
@@ -1118,11 +1478,21 @@ export class AiChatService implements OnModuleInit {
// further tool calls and appends a synthesis instruction on that step,
// concatenated onto the original `system` so the persona is preserved.
prepareStep: ({ stepNumber }) =>
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
prepareAgentStep(
stepNumber,
system,
activatedTools,
deferredEnabled,
finalStepLockdownEnabled,
),
// #184: the RUN's signal (explicit-stop) when a run wraps this turn, else
// the socket-bound signal (legacy). A browser disconnect aborts only in
// the legacy path.
abortSignal: effectiveSignal,
// the legacy path. #444: UNION it with the internal degeneration signal
// so a detected token-loop aborts the run too (AbortSignal.any — Node 20.3+).
abortSignal: AbortSignal.any([
effectiveSignal,
degenerationController.signal,
]),
onChunk: ({ chunk }) => {
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
// output chunk means the stream is actively emitting bytes; track first
@@ -1132,13 +1502,44 @@ export class AiChatService implements OnModuleInit {
lastModelChunkAt = now;
// 'text-delta' is the assistant's prose; tool-call args are separate chunk
// types — so this mirrors exactly what streams to the client.
if (chunk.type === 'text-delta') inProgressText += chunk.text;
if (chunk.type === 'text-delta') {
inProgressText += chunk.text;
// Token-degeneration guard (#444). Throttled: only re-run the pure
// rules once the text has grown ~DEGENERATION_CHECK_STEP bytes since
// the last check, so the tail heuristics cost is amortized. On a
// trigger, abort the run ONCE with a distinguishable reason.
if (
!degenerationDetected &&
shouldCheckDegeneration(
inProgressText.length,
lastDegenerationCheckLen,
)
) {
lastDegenerationCheckLen = inProgressText.length;
if (isDegenerateOutput(inProgressText)) {
degenerationDetected = true;
this.logger.warn(
`AI chat stream aborted (chat ${chatId}): ${OUTPUT_DEGENERATION_ERROR}`,
);
degenerationController.abort(
new Error(OUTPUT_DEGENERATION_ERROR),
);
}
}
}
},
onStepFinish: (step) => {
// The finished step's full text is now in `step.text`; fold it in and reset
// the in-progress accumulator for the next step.
capturedSteps.push(step as StepLike);
inProgressText = '';
// Reset the degeneration-check watermark too (#486): it tracks a byte
// offset INTO inProgressText, so once that resets to '' a stale (large)
// mark makes `inProgressText.length - lastDegenerationCheckLen` go
// negative and the throttled detector stays silent until a later step's
// text re-grows past the old offset — a whole degenerate step could slip
// through undetected. Zeroing it re-arms the check from the next byte.
lastDegenerationCheckLen = 0;
// Step-granular durability (#183): persist this finished step (its text +
// tool calls + tool RESULTS) the moment it ends, so a process death after
// this point still recovers the step. Not awaited here (never block the
@@ -1174,8 +1575,22 @@ export class AiChatService implements OnModuleInit {
// plain-text projection (full-text search / fallback). A multi-step
// turn's `content` therefore now holds all steps' prose, not just the
// last block.
await finalizeAssistant(
flushAssistant(steps as StepLike[], '', 'completed', {
// Empty-turn mitigation (#444, toggle OFF). If the turn burned all its
// steps WITHOUT ever producing text (every step's text is empty) and
// the model stopped because it hit the step cap, there is no answer to
// show — the lockdown used to force one. Append a synthetic marker as
// the trailing text so the exhausted-without-answer state is explicit
// to the user and, on replay, to the model next turn. `flushAssistant`
// takes this as the `inProgressText` trailing text arg (empty here
// otherwise). `stepCountIs(MAX_AGENT_STEPS)` surfaces as
// finishReason === 'tool-calls' (or a length/other cap), so we key off
// "no text produced" rather than a single finishReason string.
const producedText = (steps as StepLike[]).some((s) => s.text?.trim());
const stepExhausted = steps.length >= MAX_AGENT_STEPS;
const emptyTurnMarker =
!producedText && stepExhausted ? STEP_LIMIT_NO_ANSWER_MARKER : '';
const msgOk = await finalizeAssistant(
flushAssistant(steps as StepLike[], emptyTurnMarker, 'completed', {
finishReason: finishReason as string,
usage: totalUsage as StreamUsage,
contextTokens:
@@ -1188,9 +1603,19 @@ export class AiChatService implements OnModuleInit {
pageChanged,
}),
);
// #184: settle the RUN as succeeded (best-effort, after the projection
// is finalized above).
if (runId) await runHooks?.onSettled?.(runId, 'completed');
// #184/#487: the RUN is finalized ALWAYS (never gated on the message).
// If the message finalize GAVE UP, error-mark the run so the asymmetry
// "run succeeded / message streaming forever" cannot arise; the
// periodic reconcile then settles the stuck message from this run.
if (runId) {
await runHooks?.onSettled?.(
runId,
msgOk ? 'completed' : 'error',
msgOk
? undefined
: 'Assistant message could not be persisted (finalize failed).',
);
}
// Lifecycle: release the external MCP clients leased for this turn.
await closeExternalClients();
@@ -1252,6 +1677,30 @@ export class AiChatService implements OnModuleInit {
await snapshotTurnEnd();
},
onAbort: async ({ steps }) => {
// #444: distinguish a degeneration abort (our internal controller) from
// a user Stop / disconnect. On degeneration we truncate the runaway tail
// before persist (so hundreds of KB of garbage never reach the DB /
// replay) and record it as an ERROR with a clear, distinguishable reason
// — NOT a bare 'aborted' (a user Stop) and NOT a swept 'streaming' (a
// server restart).
if (degenerationDetected) {
const truncated = truncateDegeneratedTail(inProgressText);
await finalizeAssistant(
flushAssistant(capturedSteps, truncated, 'error', {
error: OUTPUT_DEGENERATION_ERROR,
pageChanged,
}),
);
if (runId)
await runHooks?.onSettled?.(
runId,
'error',
OUTPUT_DEGENERATION_ERROR,
);
await closeExternalClients();
await snapshotTurnEnd();
return;
}
const partialChars =
capturedSteps.reduce((n, s) => n + (s.text?.length ?? 0), 0) +
inProgressText.length;
@@ -1907,7 +2356,10 @@ export function planFinalizeAssistant(
* a test mock both satisfy it). */
export interface FinalizeRepo {
insert(insertable: Record<string, unknown>): Promise<unknown>;
update(
// #487: the OWNER terminal write is CONDITIONAL (status='streaming' OR
// metadata.finalizeFailed) so the owner overwrites a reconcile stamp but never
// an already-proper terminal row (owner-write priority).
finalizeOwner(
id: string,
workspaceId: string,
patch: AssistantFlush,
@@ -1916,10 +2368,11 @@ export interface FinalizeRepo {
/**
* Apply a finalize `plan` to the repo with the terminal `flushed` payload (#183):
* UPDATE the upfront row, or INSERT a fresh terminal row as the fallback when the
* upfront insert failed. The SINGLE dispatch shared by the service's
* finalizeAssistant and its test, so the test exercises the real path instead of
* a copy (#186 review). Pure of error handling — the caller wraps it.
* conditionally UPDATE the upfront row (owner-write priority, #487), or INSERT a
* fresh terminal row as the fallback when the upfront insert failed. The SINGLE
* dispatch shared by the service's finalizeAssistant and its test, so the test
* exercises the real path instead of a copy (#186 review). Pure of error
* handling — the caller wraps it (and RETRIES it, #487).
*/
export async function applyFinalize(
repo: FinalizeRepo,
@@ -1928,7 +2381,7 @@ export async function applyFinalize(
flushed: AssistantFlush,
): Promise<void> {
if (plan.kind === 'update') {
await repo.update(plan.id, base.workspaceId, flushed);
await repo.finalizeOwner(plan.id, base.workspaceId, flushed);
return;
}
await repo.insert({
@@ -75,7 +75,7 @@ const LABELS: Record<
searchPages: 'Searched pages',
getPage: 'Read page',
createPage: 'Created page',
updatePageContent: 'Updated page',
updatePageMarkdown: 'Updated page',
renamePage: 'Renamed page',
movePage: 'Moved page',
deletePage: 'Deleted page (to trash)',
@@ -96,7 +96,7 @@ const LABELS: Record<
searchPages: 'Искал по страницам',
getPage: 'Прочитал страницу',
createPage: 'Создал страницу',
updatePageContent: 'Обновил страницу',
updatePageMarkdown: 'Обновил страницу',
renamePage: 'Переименовал страницу',
movePage: 'Переместил страницу',
deletePage: 'Удалил страницу (в корзину)',
@@ -0,0 +1,380 @@
import { Logger } from '@nestjs/common';
import { streamText } from 'ai';
import {
hasRepeatedLineRun,
hasPeriodicTail,
isDegenerateOutput,
truncateDegeneratedTail,
shouldCheckDegeneration,
DEGENERATION_CHECK_STEP,
REPEATED_LINES_THRESHOLD,
MIN_PERIOD_REPEATS,
} from './output-degeneration';
import { AiChatService } from './ai-chat.service';
// Mock ONLY streamText so we can capture the onChunk/onStepFinish callbacks the
// service registers and drive them by hand; every other `ai` export the service
// uses (convertToModelMessages, stepCountIs, …) stays real.
jest.mock('ai', () => {
const actual = jest.requireActual('ai');
return { ...actual, streamText: jest.fn() };
});
/**
* Unit tests for the token-degeneration detector (#444) — the sole anti-babble
* guard once the final-step lockdown is OFF. The two rules must fire on real
* degeneration (the "loadTools." incident, a no-newline repeat) and MUST NOT fire
* on legitimate long output (edit lists, tables, code).
*/
describe('hasRepeatedLineRun (rule 1: identical-line run)', () => {
it('POSITIVE: fires on "loadTools.\\n" repeated many times (the incident)', () => {
const text = 'Here is my plan.\n' + 'loadTools.\n'.repeat(300);
expect(hasRepeatedLineRun(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
it('POSITIVE: fires at exactly the threshold', () => {
const text = 'x\n'.repeat(REPEATED_LINES_THRESHOLD);
expect(hasRepeatedLineRun(text)).toBe(true);
});
it('NEGATIVE: does NOT fire just below the threshold', () => {
// threshold-1 identical lines followed by a distinct line.
const text = 'x\n'.repeat(REPEATED_LINES_THRESHOLD - 1) + 'done\n';
expect(hasRepeatedLineRun(text)).toBe(false);
});
it('NEGATIVE: a long edit list of DISTINCT lines never trips', () => {
const lines: string[] = [];
for (let i = 0; i < 200; i++) lines.push(`- edited section ${i}: fixed typo`);
const text = lines.join('\n');
expect(hasRepeatedLineRun(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a markdown table with blank separators does not trip', () => {
// Repeated identical rows are unusual, but blank lines break any run.
const block = ['| a | b |', '| - | - |', '', '| a | b |', ''];
const text = Array.from({ length: 60 }, () => block.join('\n')).join('\n');
expect(hasRepeatedLineRun(text)).toBe(false);
});
it('NEGATIVE: blank lines do NOT count toward a run', () => {
const text = '\n'.repeat(100);
expect(hasRepeatedLineRun(text)).toBe(false);
});
});
describe('hasPeriodicTail (rule 2: no-newline suffix periodicity)', () => {
it('POSITIVE: fires on a single char repeated with no newlines', () => {
const text = 'answer: ' + 'a'.repeat(500);
expect(hasPeriodicTail(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
it('POSITIVE: fires on a multi-char block repeat with no newlines', () => {
const text = 'prefix ' + 'abcdef'.repeat(100);
expect(hasPeriodicTail(text)).toBe(true);
});
it('POSITIVE: at least MIN_PERIOD_REPEATS repeats of a small block', () => {
const text = 'go'.repeat(MIN_PERIOD_REPEATS);
expect(hasPeriodicTail(text)).toBe(true);
});
it('NEGATIVE: prose does not look periodic', () => {
const text =
'The quick brown fox jumps over the lazy dog while the sun sets slowly ' +
'behind the distant mountains and the river winds through the valley below.';
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a long code block is not flagged', () => {
const code = `
function compute(values) {
let total = 0;
for (const v of values) {
total += v * 2;
}
return total / values.length;
}
export const helper = (x) => x + 1;
const config = { retries: 3, timeout: 5000, backoff: 'exp' };
`.repeat(3);
expect(isDegenerateOutput(code)).toBe(false);
});
it('NEGATIVE: a short string well under the repeat count is safe', () => {
expect(hasPeriodicTail('ababab')).toBe(false);
});
// Regression (#444): a trivial single-char period (p===1) must NOT flag
// legitimate divider/underline/whitespace runs. These are common in real
// model output and previously false-positived at ~20 identical chars, aborting
// the run and truncating output. They must all be treated as clean.
it('NEGATIVE: a markdown horizontal rule is not flagged', () => {
const text = 'text\n' + '-'.repeat(40);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a setext heading underline is not flagged', () => {
const text = 'Title\n' + '='.repeat(30);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: a box-drawing divider with no trailing newline is not flagged', () => {
const text = 'done ' + '─'.repeat(50);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
it('NEGATIVE: trailing spaces are not flagged', () => {
const text = 'answer' + ' '.repeat(40);
expect(hasPeriodicTail(text)).toBe(false);
expect(isDegenerateOutput(text)).toBe(false);
});
// TRIVIAL_MIN_REPEATS boundary (#444 review). The monochar-tail branch fires at
// EXACTLY 60 identical trailing chars (`run >= TRIVIAL_MIN_REPEATS`), so 59 is
// clean and 60 trips. These pin the `>=` and MUST fail if the comparison is
// flipped to `>` (the surviving mutation). The value 60 is HARD-CODED here on
// purpose: TRIVIAL_MIN_REPEATS is a private constant and the assert must lock
// the literal boundary the reviewer named, not track a constant edit.
it('NEGATIVE: 59 identical trailing chars is one below the monochar threshold', () => {
expect(hasPeriodicTail('x'.repeat(59))).toBe(false);
expect(isDegenerateOutput('x'.repeat(59))).toBe(false);
});
it('POSITIVE: 60 identical trailing chars hits the monochar threshold exactly', () => {
// Fails if `run >= TRIVIAL_MIN_REPEATS` is mutated to `run > …`.
expect(hasPeriodicTail('x'.repeat(60))).toBe(true);
expect(isDegenerateOutput('x'.repeat(60))).toBe(true);
});
// Positive counterparts: a GENUINE single-char runaway (hundreds+ of repeats)
// and the real incident (period>=2, "loadTools." ×N) must still fire.
it('POSITIVE: a genuine single-char runaway is still flagged', () => {
const text = 'x'.repeat(5000);
expect(hasPeriodicTail(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
it('POSITIVE: the "loadTools." incident (period>=2) is still flagged', () => {
const text = 'loadTools.'.repeat(500);
expect(hasPeriodicTail(text)).toBe(true);
expect(isDegenerateOutput(text)).toBe(true);
});
});
describe('truncateDegeneratedTail', () => {
it('collapses a repeated-line loop to a few reps + marker', () => {
const text = 'plan\n' + 'loadTools.\n'.repeat(20000);
const out = truncateDegeneratedTail(text);
expect(out.length).toBeLessThan(text.length);
expect(out).toContain('output truncated');
// Keeps the leading context and a few loop reps.
expect(out).toContain('plan');
expect((out.match(/loadTools\./g) ?? []).length).toBeLessThan(10);
});
it('collapses a no-newline periodic loop to a few blocks + marker', () => {
const text = 'answer: ' + 'xy'.repeat(50000);
const out = truncateDegeneratedTail(text);
expect(out.length).toBeLessThan(text.length);
expect(out).toContain('output truncated');
expect(out).toContain('answer:');
});
it('returns non-degenerate text unchanged (by identity)', () => {
const text = 'A perfectly normal, finished assistant answer.';
expect(truncateDegeneratedTail(text)).toBe(text);
});
});
/**
* Throttle + step-boundary reset (#486). The stream keeps a watermark
* (`lastDegenerationCheckLen`) that is an OFFSET into the accumulated step text.
* On a step boundary the accumulator resets to '', so the watermark MUST reset to
* 0 too — otherwise the throttle goes silent for the whole next step. These tests
* pin the pure decision AND the reset property that ai-chat.service.onStepFinish
* now enforces.
*/
describe('shouldCheckDegeneration (throttle) + step-boundary reset (#486)', () => {
it('fires once the text grows a full DEGENERATION_CHECK_STEP past the mark', () => {
expect(shouldCheckDegeneration(DEGENERATION_CHECK_STEP, 0)).toBe(true);
expect(shouldCheckDegeneration(DEGENERATION_CHECK_STEP - 1, 0)).toBe(false);
expect(shouldCheckDegeneration(5000, 3000)).toBe(true); // grew 2000 since mark
expect(shouldCheckDegeneration(4000, 3000)).toBe(false); // grew only 1000
});
it('BUG (no reset): a stale large watermark silences the next step', () => {
// End of a long step: the watermark sits at 5000. The step ends and the
// accumulator resets to '' — but if the watermark is NOT reset, a fresh short
// degenerate burst (length 2000) never triggers a check: 2000 - 5000 < STEP.
const staleWatermark = 5000;
const nextStepLen = DEGENERATION_CHECK_STEP; // a fresh 2KB burst
expect(shouldCheckDegeneration(nextStepLen, staleWatermark)).toBe(false);
});
it('FIX (reset to 0): the same short degenerate burst IS checked and detected', () => {
// onStepFinish now zeroes the watermark, so the fresh burst re-arms the check.
const resetWatermark = 0;
const degenerateBurst = 'loadTools.\n'.repeat(300); // real degeneration
expect(degenerateBurst.length).toBeGreaterThanOrEqual(DEGENERATION_CHECK_STEP);
// The throttle now fires...
expect(
shouldCheckDegeneration(degenerateBurst.length, resetWatermark),
).toBe(true);
// ...and the detector catches the loop that would otherwise stream unchecked.
expect(isDegenerateOutput(degenerateBurst)).toBe(true);
});
});
/**
* BEHAVIOR guard for the ACTUAL fix (#486, ai-chat.service.onStepFinish resets
* lastDegenerationCheckLen to 0). The pure tests above use a hard-coded
* resetWatermark, so a REVERT of the real `lastDegenerationCheckLen = 0` line
* would not redden any of them. This drives the REAL onChunk/onStepFinish
* closures from stream() end to end and asserts the run is aborted when a fresh
* degenerate burst arrives in the step AFTER a long clean step — which only
* happens if the watermark was actually zeroed on the step boundary.
*/
describe('AiChatService: onStepFinish re-arms the degeneration watermark (#486)', () => {
const streamTextMock = streamText as unknown as jest.Mock;
function makeRes() {
return {
raw: {
writeHead: jest.fn(),
write: jest.fn(),
once: jest.fn(),
on: jest.fn(),
flushHeaders: jest.fn(),
writableEnded: false,
destroyed: false,
},
};
}
function makeService() {
const aiChatRepo = {
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
insert: jest.fn(),
};
const aiChatMessageRepo = {
insert: jest.fn(async () => ({ id: 'msg-1' })),
findAllByChat: jest.fn(async () => []),
update: jest.fn(async () => ({ id: 'msg-1' })),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
const mcpClients = {
toolsFor: jest.fn(async () => ({
tools: {},
clients: [],
outcomes: [],
instructions: [],
})),
};
return new AiChatService(
{} as never, // ai
aiChatRepo as never,
aiChatMessageRepo as never,
{} as never, // aiChatPageSnapshotRepo
aiSettings as never,
tools as never,
mcpClients as never,
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo
{} as never, // pageAccess
{
isAiChatDeferredToolsEnabled: () => false,
// Lockdown OFF -> the degeneration guard is the active anti-babble path.
isAiChatFinalStepLockdownEnabled: () => false,
} as never, // environment
);
}
beforeEach(() => {
streamTextMock.mockReset();
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined as never);
});
afterEach(() => jest.restoreAllMocks());
it('aborts on a fresh degenerate burst in the NEXT step (reverting the reset line reddens this)', async () => {
let captured:
| {
onChunk?: (e: { chunk: { type: string; text: string } }) => void;
onStepFinish?: (step: unknown) => void;
abortSignal?: AbortSignal;
}
| undefined;
streamTextMock.mockImplementation((opts: never) => {
captured = opts;
return {
consumeStream: jest.fn(),
pipeUIMessageStreamToResponse: jest.fn(),
};
});
const svc = makeService();
await svc.stream({
user: { id: 'user-1' } as never,
workspace: { id: 'ws-1' } as never,
sessionId: 'sess-1',
body: {
chatId: 'chat-1',
messages: [
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
],
} as never,
res: makeRes() as never,
signal: new AbortController().signal,
model: {} as never,
role: null,
// No runHooks -> legacy path (socket signal), degeneration guard active.
});
expect(streamTextMock).toHaveBeenCalledTimes(1);
const onChunk = captured!.onChunk!;
const onStepFinish = captured!.onStepFinish!;
const abortSignal = captured!.abortSignal!;
expect(abortSignal.aborted).toBe(false);
// STEP 1: a LONG, non-degenerate first step. Distinct lines never trip the
// detector, but they advance the throttle watermark far past the burst size
// that follows (to ~5x the step). This is the stale watermark that, WITHOUT
// the reset, would silence step 2.
let counter = 0;
let accumulated = 0;
while (accumulated < DEGENERATION_CHECK_STEP * 5) {
const line = `unique clean line number ${counter++} with distinct words\n`;
accumulated += line.length;
onChunk({ chunk: { type: 'text-delta', text: line } });
}
expect(abortSignal.aborted).toBe(false); // clean step must not abort
// STEP BOUNDARY: the real onStepFinish resets inProgressText AND (the fix)
// zeroes lastDegenerationCheckLen.
onStepFinish({ text: 'a clean first step', toolCalls: [], toolResults: [] });
// STEP 2: a FRESH, short degenerate burst (~3.3KB). Its length is far below
// the step-1 stale watermark (~10KB), so WITHOUT the reset the throttle stays
// silent and this streams unchecked. WITH the reset (watermark 0) it re-arms,
// the detector fires, and the run aborts.
const burst = 'loadTools.\n'.repeat(300);
expect(burst.length).toBeGreaterThanOrEqual(DEGENERATION_CHECK_STEP);
expect(burst.length).toBeLessThan(DEGENERATION_CHECK_STEP * 5);
onChunk({ chunk: { type: 'text-delta', text: burst } });
// The decisive assertion: the composed abortSignal (unioned with the
// degeneration controller) is now aborted. Reverting `lastDegenerationCheckLen
// = 0` in onStepFinish makes this stay false.
expect(abortSignal.aborted).toBe(true);
});
});
@@ -0,0 +1,215 @@
/**
* Token-degeneration detector for the in-app agent stream (#444).
*
* When the final-step lockdown is OFF (the new default) there is no toolChoice
* override to strip the model's tools mid-work, so the anti-babble safety net is
* this detector. It watches the accumulating assistant text and, on a runaway
* repetition loop (the 255KB "loadTools." incident), aborts the run.
*
* Both rules are PURE functions of the text tail so they are cheap to run every
* few KB and are unit-testable in isolation. They operate on the TAIL only
* (`TAIL_WINDOW` chars) so the cost is bounded regardless of how long the turn is.
*/
/** How many trailing chars of the accumulated text the rules inspect. */
export const TAIL_WINDOW = 3000;
/** Rule 1 threshold: minimum consecutive identical non-empty lines to trigger. */
export const REPEATED_LINES_THRESHOLD = 25;
/** Rule 2: maximum length of a repeating block considered for periodicity. */
export const MAX_PERIOD_LEN = 150;
/** Rule 2: minimum number of consecutive block repeats to trigger. */
export const MIN_PERIOD_REPEATS = 20;
/**
* Rule 1 — ≥`REPEATED_LINES_THRESHOLD` consecutive IDENTICAL non-empty lines at
* the tail. Catches the classic newline-delimited loop ("loadTools.\n" ×N).
* Blank lines break a run (a table / list with blank separators never trips it);
* a run of ordinary distinct lines (an edit list, code) never reaches the count.
*
* NB: `REPEATED_LINES_THRESHOLD` (25) is only THIS rule's own trigger, not the
* effective floor for detecting a repeated-line loop. In practice a newline-
* delimited repeat also has a fixed period (line + '\n'), so rule 2 catches it
* via periodicity at `MIN_PERIOD_REPEATS` (20) repeats — the two rules combine
* (see `isDegenerateOutput`), so the effective lower bound for a short identical
* line loop is ~20, not 25. Pure.
*/
export function hasRepeatedLineRun(
text: string,
threshold = REPEATED_LINES_THRESHOLD,
): boolean {
const tail = text.length > TAIL_WINDOW ? text.slice(-TAIL_WINDOW) : text;
const lines = tail.split('\n');
let run = 1;
let prev: string | null = null;
for (const line of lines) {
if (line.length > 0 && line === prev) {
run += 1;
if (run >= threshold) return true;
} else {
run = 1;
}
prev = line;
}
return false;
}
/**
* Rule 2 — cheap suffix-periodicity check: the tail ends in
* ≥`MIN_PERIOD_REPEATS` back-to-back repeats of a single block of length
* ≤`MAX_PERIOD_LEN`. Catches a no-newline repeat ("abcabcabc…") the line rule
* misses. For each candidate period length p we verify the last `repeats*p`
* chars are p-periodic; we stop at the smallest p that satisfies the repeat
* count. Bounded by MAX_PERIOD_LEN × TAIL_WINDOW comparisons — negligible. Pure.
*/
export function hasPeriodicTail(
text: string,
maxPeriod = MAX_PERIOD_LEN,
minRepeats = MIN_PERIOD_REPEATS,
): boolean {
const tail = text.length > TAIL_WINDOW ? text.slice(-TAIL_WINDOW) : text;
const n = tail.length;
// Not even the shortest possible loop fits in the tail.
if (n < minRepeats) return false;
// A tail of ONE repeated char (a "trivial period") is common in LEGIT output —
// markdown rules (----/====), setext underlines, box-drawing dividers,
// trailing spaces routinely produce 20–50 identical chars. Such a run is
// p-periodic for EVERY p, so it would otherwise trip the block rule at p>=2
// too, not just p===1. We therefore split the check: a monochar tail needs far
// more repeats (a real single-char babble loop produces hundreds-to-thousands;
// 60 is well above any realistic divider yet a fifth of TAIL_WINDOW), while a
// genuine multi-char block repeat (>=2 distinct chars, e.g. the "loadTools."
// incident, period ~10) keeps the normal MIN_PERIOD_REPEATS threshold.
const TRIVIAL_MIN_REPEATS = 60;
// Monochar-tail check (the trivial-period case): count the trailing run of one
// identical char and require TRIVIAL_MIN_REPEATS of them.
{
const last = tail[n - 1];
let run = 1;
for (let i = n - 2; i >= 0 && tail[i] === last; i--) run++;
if (run >= TRIVIAL_MIN_REPEATS) return true;
}
const maxP = maxPeriod;
for (let p = 2; p <= maxP; p++) {
// Verify the last (minRepeats*p) chars are p-periodic AND not monochar (a
// monochar span is the trivial case handled above, so skip it here to avoid
// re-flagging a legit divider at a composite period).
const span = minRepeats * p;
// Not enough tail to hold this many repeats of this period.
if (span > n) continue;
const start = n - span;
let periodic = true;
let multiChar = false;
for (let i = n - 1; i >= start + p; i--) {
if (tail[i] !== tail[i - p]) {
periodic = false;
break;
}
}
if (!periodic) continue;
// Confirm the block itself has >=2 distinct chars (else it's monochar).
for (let i = start + 1; i < n; i++) {
if (tail[i] !== tail[start]) {
multiChar = true;
break;
}
}
if (multiChar) return true;
}
return false;
}
/**
* Combined guard used by the stream's onChunk: true when EITHER rule fires.
* Pure — the caller owns the abort side effect.
*/
export function isDegenerateOutput(text: string): boolean {
return hasRepeatedLineRun(text) || hasPeriodicTail(text);
}
/**
* How many bytes the in-progress text must grow before the (amortized) tail
* heuristics are re-run. Shared with ai-chat.service so the throttle the stream
* applies is the SAME one the unit test drives.
*/
export const DEGENERATION_CHECK_STEP = 2000;
/**
* Throttle decision for the degeneration guard (#444/#486). Returns true when
* the accumulated text has grown at least DEGENERATION_CHECK_STEP bytes past the
* last-checked offset, so the pure rules only fire every ~2KB. Pure; the caller
* updates its watermark to `textLen` when this returns true.
*
* The watermark is an offset INTO the accumulator, so when the accumulator is
* reset to '' on a step boundary the caller MUST reset the watermark to 0 too
* (#486). Otherwise `textLen - lastCheckLen` goes negative after the reset and
* this returns false until a later step re-grows past the stale offset — a whole
* degenerate step could stream unchecked.
*/
export function shouldCheckDegeneration(
textLen: number,
lastCheckLen: number,
): boolean {
return textLen - lastCheckLen >= DEGENERATION_CHECK_STEP;
}
/**
* Truncate a degenerated tail before persist so hundreds of KB of garbage never
* reach the DB / replay (#444). Keeps everything up to and including the FIRST
* `keepRepeats` repeats of the detected loop, then appends a short marker. If no
* loop is detected the text is returned unchanged (by identity).
*
* Implementation: find the shortest tail period (same check as hasPeriodicTail),
* keep the prefix before the loop plus `keepRepeats` copies of the block, drop
* the rest. This is best-effort cosmetic trimming; correctness does not depend on
* finding the exact minimal loop. Pure.
*/
export function truncateDegeneratedTail(
text: string,
keepRepeats = 3,
): string {
const marker = '\n…[output truncated: repeated token loop detected]';
// Try the line rule first: collapse a long run of identical lines.
const lines = text.split('\n');
let runStart = -1;
let run = 1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].length > 0 && lines[i] === lines[i - 1]) {
if (run === 1) runStart = i - 1;
run += 1;
if (run >= REPEATED_LINES_THRESHOLD) {
const kept = lines.slice(0, runStart + keepRepeats).join('\n');
return kept + marker;
}
} else {
run = 1;
runStart = -1;
}
}
// Fall back to periodicity over the whole string (bounded by the same block
// length). Find the smallest period that makes the SUFFIX highly repetitive.
const n = text.length;
const maxP = Math.min(MAX_PERIOD_LEN, Math.floor(n / MIN_PERIOD_REPEATS));
for (let p = 1; p <= maxP; p++) {
// Count how many trailing p-blocks are periodic.
let reps = 1;
let i = n - 1;
for (; i >= p; i--) {
if (text[i] !== text[i - p]) break;
}
// The loop above walks over the periodic suffix; its length is (n-1 - i).
const periodicLen = n - 1 - i;
reps = Math.floor(periodicLen / p) + 1;
if (reps >= MIN_PERIOD_REPEATS) {
const loopStart = n - reps * p; // start of the fully-periodic suffix
const kept = text.slice(0, loopStart + keepRepeats * p);
return kept + marker;
}
}
return text;
}
@@ -0,0 +1,241 @@
// Break the editor-ext import chain (share.service -> collaboration.util ->
// @docmost/editor-ext -> @tiptap/core) that is unresolvable in this jest env and
// pre-existingly breaks these specs. jsonToMarkdown is never reached in these
// tests (the tools fail before rendering markdown).
jest.mock('../../collaboration/collaboration.util', () => ({
jsonToMarkdown: () => '',
}));
import { Logger } from '@nestjs/common';
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
import { PublicShareChatService } from './public-share-chat.service';
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
/**
* SECURITY integration guard for #394 (commit 5): a tool's or the provider's raw
* error text must NOT leak to an anonymous public-share reader.
*
* The render gate (ToolCallCard showErrors=false) hides the text in the DOM but
* NOT on the wire, so this test asserts on the RAW SSE BYTES the server writes —
* exactly the channel the render gate masks. We drive the real
* PublicShareChatService.stream() with a real share toolset (its underlying
* services mocked to fail) and a mock model, then inspect every byte piped to the
* fake socket.
*/
// A minimal ServerResponse stand-in that records every written chunk.
class FakeSocket {
chunks: string[] = [];
statusCode = 200;
writableEnded = false;
destroyed = false;
headersSent = false;
writeHead(): this {
this.headersSent = true;
return this;
}
setHeader(): void {}
removeHeader(): void {}
getHeader(): undefined {
return undefined;
}
flushHeaders(): void {}
write(chunk: unknown): boolean {
this.chunks.push(
typeof chunk === 'string' ? chunk : Buffer.from(chunk as never).toString('utf8'),
);
return true;
}
end(chunk?: unknown): void {
if (chunk) this.write(chunk);
this.writableEnded = true;
}
on(): this {
return this;
}
once(): this {
return this;
}
get body(): string {
return this.chunks.join('');
}
}
/** Mock model that issues one getSharePage tool call, then finishes with text. */
function toolCallingModel(): MockLanguageModelV3 {
let call = 0;
return new MockLanguageModelV3({
doStream: async () => {
call++;
if (call === 1) {
return {
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start' as const, warnings: [] },
{ type: 'tool-input-start' as const, id: 't1', toolName: 'getSharePage' },
{ type: 'tool-input-end' as const, id: 't1' },
{
type: 'tool-call' as const,
toolCallId: 't1',
toolName: 'getSharePage',
input: '{"pageId":"secret-page"}',
},
{
type: 'finish' as const,
finishReason: { unified: 'tool-calls' as const, raw: 'tool_calls' },
usage: {
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
},
},
],
}),
};
}
return {
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start' as const, warnings: [] },
{ type: 'text-start' as const, id: '1' },
{ type: 'text-delta' as const, id: '1', delta: 'Sorry.' },
{ type: 'text-end' as const, id: '1' },
{
type: 'finish' as const,
finishReason: { unified: 'stop' as const, raw: 'stop' },
usage: {
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
},
},
],
}),
};
},
});
}
/** Mock model whose stream emits a provider error carrying an internal secret. */
function providerErrorModel(secret: string): MockLanguageModelV3 {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start' as const, warnings: [] },
{
type: 'error' as const,
error: {
statusCode: 503,
message: 'Service Unavailable',
responseBody: `upstream ${secret} model=internal-gpt`,
},
},
],
}),
}),
});
}
function makeService(toolsService: PublicShareChatToolsService): {
svc: PublicShareChatService;
logSpy: jest.SpyInstance;
} {
const svc = Object.create(PublicShareChatService.prototype);
const logger = new Logger('test');
const logSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
svc.tools = toolsService;
svc.logger = logger;
svc.tokenBudget = { record: jest.fn().mockResolvedValue(undefined) };
return { svc, logSpy };
}
async function runStream(
svc: PublicShareChatService,
model: MockLanguageModelV3,
): Promise<FakeSocket> {
const socket = new FakeSocket();
await svc.stream({
workspaceId: 'ws1',
shareId: 'share1',
share: { id: 'share1', pageId: 'p1', sharedPage: { id: 'p1', title: 'Docs' } },
openedPage: null,
messages: [
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'read the page' }] } as never,
],
res: { raw: socket } as never,
signal: new AbortController().signal,
model: model as never,
role: null,
});
// Let the piped stream drain fully.
await new Promise((r) => setTimeout(r, 300));
return socket;
}
describe('public share chat error leak (#394)', () => {
afterEach(() => jest.restoreAllMocks());
it('does NOT leak a tool\'s raw internal error to the SSE bytes (generic classified string instead)', async () => {
const SECRET = 'INTERNAL_baseUrl_http://provider.internal:8080/v1';
const shareService = {
// The canonical boundary throws a RAW internal error (with a secret).
resolveReadableSharePage: jest
.fn()
.mockRejectedValue(new Error(`db failed at ${SECRET} stack@line42`)),
};
const tools = new PublicShareChatToolsService(
shareService as never,
{} as never,
{} as never,
);
const { svc } = makeService(tools);
const socket = await runStream(svc, toolCallingModel());
// The tool-output-error frame is present on the wire...
expect(socket.body).toContain('tool-output-error');
// ...but it carries ONLY the generic classified string — never the secret,
// the raw driver message, or a stack fragment.
expect(socket.body).toContain('The tool could not complete the request.');
expect(socket.body).not.toContain(SECRET);
expect(socket.body).not.toContain('stack@line42');
expect(socket.body).not.toContain('db failed');
});
it('passes a SAFE ShareToolError message (page not available) through to the bytes', async () => {
const shareService = {
// Not found in this share -> the tool throws the classified SAFE message.
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
};
const tools = new PublicShareChatToolsService(
shareService as never,
{} as never,
{} as never,
);
const { svc } = makeService(tools);
const socket = await runStream(svc, toolCallingModel());
expect(socket.body).toContain('tool-output-error');
expect(socket.body).toContain('not available in this share');
});
it('does NOT leak a provider error (statusCode + response body) to the SSE bytes', async () => {
const SECRET = 'http://provider.internal:8080';
const tools = new PublicShareChatToolsService(
{} as never,
{} as never,
{} as never,
);
const { svc, logSpy } = makeService(tools);
const socket = await runStream(svc, providerErrorModel(SECRET));
// The anon sees a fixed classified string, not the provider body/baseUrl/model.
expect(socket.body).toContain('temporarily unavailable');
expect(socket.body).not.toContain(SECRET);
expect(socket.body).not.toContain('internal-gpt');
// The FULL provider detail is logged server-side only.
const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toContain(SECRET);
});
});
@@ -12,7 +12,10 @@ import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { AiService } from '../../integrations/ai/ai.service';
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
import {
PublicShareChatToolsService,
ShareToolError,
} from './tools/public-share-chat-tools.service';
import { buildShareSystemPrompt } from './public-share-chat.prompt';
import { roleModelOverride } from './roles/role-model-config';
import {
@@ -102,6 +105,30 @@ export function filterShareTranscript(messages: UIMessage[]): UIMessage[] {
);
}
/**
* Fixed, classified strings an ANONYMOUS share reader may see when the assistant
* stream fails (#394). These reveal NOTHING about the internal provider, its
* baseUrl, the model name, or the raw response body unlike describeProviderError
* (which is for the server log / the authenticated operator only). We classify by
* HTTP status where available so the reader still gets a useful hint (retry vs.
* give up) without any internal detail.
*/
export function classifyAnonStreamError(error: unknown): string {
const status =
typeof error === 'object' && error !== null
? (error as { statusCode?: number }).statusCode
: undefined;
if (status === 429) {
return 'The assistant is receiving too many requests right now. Please try again shortly.';
}
if (typeof status === 'number' && status >= 500) {
return 'The assistant is temporarily unavailable. Please try again.';
}
// Any other failure (including a bare connection error with no status): a
// single neutral line. No provider identity, no config, no response body.
return 'The assistant could not complete your request. Please try again.';
}
/**
* Anonymous, read-only AI assistant for a single PUBLIC share tree.
*
@@ -318,11 +345,28 @@ export class PublicShareChatService {
result.pipeUIMessageStreamToResponse(res.raw, {
headers: { 'X-Accel-Buffering': 'no' },
onError: (error: unknown) => {
// Reuse the shared formatter so provider error formatting stays
// unified between the log line and the streamed error message — a
// share reader sees 402/429/503 causes consistently with the
// authenticated path.
return describeProviderError(error, 'AI stream error');
// SECURITY (#394): the string this returns is written verbatim into the
// SSE error frame delivered to an ANONYMOUS reader (for a tool failure
// it becomes the atomic `tool-output-error` frame's errorText; for a
// stream/provider failure, the terminal error frame).
//
// A ShareToolError is already a classified, safe tool message (see
// PublicShareChatToolsService.wrapToolErrors) — pass it through so the
// reader still gets the useful "page not available in this share" hint.
if (error instanceof ShareToolError) {
return error.message;
}
// Anything else is a provider/stream error. describeProviderError
// bundles the provider statusCode AND response body, which can carry the
// internal baseUrl or model name — NEVER expose that to the public. Log
// the full detail server-side only and return a fixed classified string.
this.logger.error(
`Public share chat pipe error: ${describeProviderError(
error,
'AI stream error',
)}`,
);
return classifyAnonStreamError(error);
},
});
@@ -0,0 +1,160 @@
import {
wrapInAppToolWithCap,
inAppToolCallCapMs,
type ToolAbortSignalSink,
} from './ai-chat-tools.service';
import type { Tool, ToolCallOptions } from 'ai';
/**
* #487 commit 1 in-app tool race-on-abort + safe-points + per-call cap.
*
* Tests assert the HONEST observable property the spec names "after Stop, NO
* new HTTP/WS call STARTS; an already-started single call may take either
* outcome" against the REAL wrapper mechanism (the composite abort signal it
* publishes on the client + the RACE it runs), NOT a timing-dependent proxy like
* "the write didn't land".
*/
// A minimal stand-in for the client's `toolAbortSignal` field. In production the
// wrapper publishes the composite here and the client's paginateAll /
// mutatePageContent safe-points read it; the fake "tool" below reads it the same
// way, so this exercises the real contract without a live DB / collab socket.
class FakeClient implements ToolAbortSignalSink {
private signal: AbortSignal | null = null;
setToolAbortSignal(signal: AbortSignal | null): void {
this.signal = signal;
}
getToolAbortSignal(): AbortSignal | null {
return this.signal;
}
}
// A ToolCallOptions with just the field the wrapper reads (abortSignal). The AI
// SDK passes a fuller object; the wrapper only spreads it and reads abortSignal.
const opts = (abortSignal?: AbortSignal): ToolCallOptions =>
({ toolCallId: 't1', messages: [], abortSignal }) as unknown as ToolCallOptions;
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
describe('#487 wrapInAppToolWithCap — race-on-abort + safe-points', () => {
it('after Stop, no NEW simulated call starts (multi-call tool)', async () => {
const client = new FakeClient();
const started: number[] = [];
// A multi-call tool that mirrors paginateAll: it consults the client signal
// at a safe-point BEFORE starting each simulated network call.
const multiCall: Tool = {
execute: (async (_args: unknown) => {
for (let i = 0; i < 6; i++) {
// Safe-point: exactly what paginateAll / mutatePageContent do.
client.getToolAbortSignal()?.throwIfAborted();
started.push(i);
await tick(10);
}
return 'done';
}) as unknown as Tool['execute'],
} as Tool;
const wrapped = wrapInAppToolWithCap(multiCall, client, 10_000);
const ac = new AbortController();
const call = (
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
)({}, opts(ac.signal));
// Let one or two calls start, then Stop.
await tick(12);
ac.abort(new Error('user stop'));
await expect(call).rejects.toThrow(); // wrapper rejects promptly
const startedAtStop = started.length;
// Give the abandoned loser ample time; its next safe-point must throw because
// the (aborted) composite is still published on the client.
await tick(60);
expect(started.length).toBe(startedAtStop);
// It must NOT have run the whole sequence (that would mean Stop did nothing).
expect(started.length).toBeLessThan(6);
});
it('rejects immediately on Stop even if the call never settles (discard loser)', async () => {
const client = new FakeClient();
let settled = false;
const hang: Tool = {
execute: (async () => {
await new Promise(() => undefined); // never resolves
settled = true;
}) as unknown as Tool['execute'],
} as Tool;
const wrapped = wrapInAppToolWithCap(hang, client, 10_000);
const ac = new AbortController();
const call = (
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
)({}, opts(ac.signal));
await tick(5);
ac.abort();
await expect(call).rejects.toThrow();
expect(settled).toBe(false);
});
it('per-call cap rejects a hung call with no Stop signal', async () => {
const client = new FakeClient();
const hang: Tool = {
execute: (async () => {
await new Promise(() => undefined);
}) as unknown as Tool['execute'],
} as Tool;
// Tiny cap; no options.abortSignal at all (composite = cap only).
const wrapped = wrapInAppToolWithCap(hang, client, 20);
const start = Date.now();
await expect(
(wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>)(
{},
opts(undefined),
),
).rejects.toThrow(/per-call cap/);
expect(Date.now() - start).toBeLessThan(2000);
});
it('publishes a composite signal on the client for the duration of the call', async () => {
const client = new FakeClient();
let seenDuringCall: AbortSignal | null = null;
const probe: Tool = {
execute: (async () => {
seenDuringCall = client.getToolAbortSignal();
return 'ok';
}) as unknown as Tool['execute'],
} as Tool;
const wrapped = wrapInAppToolWithCap(probe, client, 10_000);
const ac = new AbortController();
await (
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
)({}, opts(ac.signal));
expect(seenDuringCall).not.toBeNull();
// The published composite must reflect the turn's Stop signal.
ac.abort();
expect((seenDuringCall as unknown as AbortSignal).aborted).toBe(true);
});
it('a completed call returns its raw result unchanged', async () => {
const client = new FakeClient();
const ok: Tool = {
execute: (async () => ({ items: [1, 2, 3] })) as unknown as Tool['execute'],
} as Tool;
const wrapped = wrapInAppToolWithCap(ok, client, 10_000);
const res = await (
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
)({}, opts(new AbortController().signal));
expect(res).toEqual({ items: [1, 2, 3] });
});
it('cap is env-tunable with a 2-minute default', () => {
const prev = process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS;
delete process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS;
expect(inAppToolCallCapMs()).toBe(120_000);
process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = '5000';
expect(inAppToolCallCapMs()).toBe(5000);
process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = 'not-a-number';
expect(inAppToolCallCapMs()).toBe(120_000);
if (prev === undefined) delete process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS;
else process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = prev;
});
});
@@ -1,6 +1,17 @@
import { AiChatToolsService } from './ai-chat-tools.service';
import * as loader from './docmost-client.loader';
import type { DocmostClientLike } from './docmost-client.loader';
// Test-double type for the loopback client. `DocmostClientLike` is now DERIVED
// from the real `DocmostClient` (issue #446), so its method RETURN types are the
// concrete client shapes. These stubs deliberately return minimal recording
// shapes (e.g. `{ ok: true }`), which no longer satisfy those concrete returns —
// so the doubles are typed with the same method NAMES but loose async returns.
// Each is still cast to `DocmostClientLike` at the (return-erased) mock site, so
// the positional-call type-safety on the PRODUCTION client is unaffected.
type FakeDocmostClient = Partial<
Record<keyof DocmostClientLike, (...args: any[]) => Promise<any>>
>;
// The real zod-agnostic shared tool-spec registry. It has no runtime deps, so
// importing the TS source directly keeps these mocks honest: the service builds
// the shared tools from exactly the specs the package ships, not a hand-stub.
@@ -12,7 +23,15 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
// sync.
const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
DocmostClient,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Pure no-network draw.io helpers (#424). Type-correct stubs: these tests
// never execute the drawioShapes / drawioGuide tool bodies.
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: 'index',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
/**
@@ -31,7 +50,7 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
// Minimal fake DocmostClient: only the write methods the tools touch need to
// exist; deletePage records its args. No network, no ESM import.
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
deletePage: (...args: unknown[]) => {
deletePageCalls.push(args);
return Promise.resolve({ success: true });
@@ -160,7 +179,7 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
describe('AiChatToolsService expanded toolset guardrails', () => {
// No client method is invoked here — every assertion is on tool presence /
// input schema — so an empty fake client is sufficient.
const fakeClient: Partial<DocmostClientLike> = {};
const fakeClient: FakeDocmostClient = {};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
@@ -264,8 +283,9 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
const patchNodeCalls: unknown[][] = [];
const insertNodeCalls: unknown[][] = [];
const updatePageJsonCalls: unknown[][] = [];
const updatePageCalls: unknown[][] = [];
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
patchNode: (...args: unknown[]) => {
patchNodeCalls.push(args);
return Promise.resolve({ ok: true });
@@ -278,6 +298,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
updatePageJsonCalls.push(args);
return Promise.resolve({ ok: true });
},
// Backs the plain-Markdown full-body-replace tool updatePageMarkdown (#411).
updatePage: (...args: unknown[]) => {
updatePageCalls.push(args);
return Promise.resolve({ success: true });
},
};
const tokenServiceStub = {
@@ -291,6 +316,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
patchNodeCalls.length = 0;
insertNodeCalls.length = 0;
updatePageJsonCalls.length = 0;
updatePageCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
@@ -329,23 +355,32 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
content: [{ type: 'text', text: 'Hello' }],
};
it('patchNode parses a JSON-string node and forwards it as an object', async () => {
it('patchNode parses a JSON-string node and forwards it as { node } (object)', async () => {
const tools = await buildTools();
await tools.patchNode.execute(
{ pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never,
{} as never,
);
expect(patchNodeCalls).toHaveLength(1);
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]);
// #413: the 3rd arg is now the XOR input { markdown?, node? }.
expect(patchNodeCalls[0]).toEqual([
'p1',
'n1',
{ markdown: undefined, node: NODE_OBJ },
]);
});
it('patchNode passes an object node through unchanged', async () => {
it('patchNode passes an object node through unchanged inside { node }', async () => {
const tools = await buildTools();
await tools.patchNode.execute(
{ pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never,
{} as never,
);
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]);
expect(patchNodeCalls[0]).toEqual([
'p1',
'n1',
{ markdown: undefined, node: NODE_OBJ },
]);
});
it('patchNode throws the documented message on invalid JSON string', async () => {
@@ -359,7 +394,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
expect(patchNodeCalls).toHaveLength(0);
});
it('insertNode parses a JSON-string node and forwards it as an object', async () => {
it('insertNode parses a JSON-string node and forwards it inside { node }', async () => {
const tools = await buildTools();
await tools.insertNode.execute(
{
@@ -370,9 +405,15 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
{} as never,
);
expect(insertNodeCalls).toHaveLength(1);
const [pageId, node] = insertNodeCalls[0];
// #413: the 2nd arg is the XOR input { markdown?, node? }, the 3rd is opts.
const [pageId, input, opts] = insertNodeCalls[0] as [
string,
{ markdown?: unknown; node?: unknown },
{ position?: string },
];
expect(pageId).toBe('p1');
expect(node).toEqual(NODE_OBJ);
expect(input).toEqual({ markdown: undefined, node: NODE_OBJ });
expect(opts.position).toBe('append');
});
it('insertNode throws the documented message on invalid JSON string', async () => {
@@ -426,6 +467,54 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
).rejects.toThrow('content was a string but not valid JSON');
expect(updatePageJsonCalls).toHaveLength(0);
});
// #411: the plain-Markdown full-body-replace tool is now the shared
// `updatePageMarkdown` (was inline `updatePageContent`). It forwards to
// client.updatePage(pageId, content, title) -> updatePageContentRealtime ->
// markdownToProseMirrorCanonical, so `^[...]` footnotes materialize.
it('updatePageMarkdown forwards { pageId, content, title } to client.updatePage', async () => {
const tools = await buildTools();
await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: 'Body^[a note]', title: 'New title' } as never,
{} as never,
);
expect(updatePageCalls).toHaveLength(1);
expect(updatePageCalls[0]).toEqual(['p1', 'Body^[a note]', 'New title']);
});
it('updatePageMarkdown returns the RAW client result in-app (deliberate #411 shape change, documented on the spec)', async () => {
const tools = await buildTools();
// Registry canonical execute returns client.updatePage's result verbatim.
// The old inline tool projected to { pageId, updated }; the rename now
// surfaces the raw result (nothing reads the removed `.updated`; the raw
// shape carries footnote/verify warnings and matches the on-both-hosts
// registry convention). fakeClient.updatePage resolves { success: true }.
const result = await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: '# Hi' } as never,
{} as never,
);
expect(result).toEqual({ success: true });
});
it('updatePageMarkdown forwards title=undefined when omitted', async () => {
const tools = await buildTools();
await tools.updatePageMarkdown.execute(
{ pageId: 'p1', content: '# Hi' } as never,
{} as never,
);
expect(updatePageCalls[0]).toEqual(['p1', '# Hi', undefined]);
});
// #411 surface split: the plain-Markdown replace tool exists in-app under the
// new key; the OLD inline updatePageContent key is gone; importPageMarkdown is
// still present IN-APP (only the external MCP surface drops it — asserted in
// packages/mcp/test/unit/tool-inventory.test.mjs).
it('exposes updatePageMarkdown in-app, no legacy updatePageContent, keeps importPageMarkdown', async () => {
const tools = await buildTools();
expect(tools.updatePageMarkdown).toBeDefined();
expect((tools as Record<string, unknown>).updatePageContent).toBeUndefined();
expect(tools.importPageMarkdown).toBeDefined();
});
});
/**
@@ -439,7 +528,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
* getOutline) are exercised here end-to-end through forUser().
*/
describe('AiChatToolsService model-friendly input validation (#190)', () => {
const fakeClient: Partial<DocmostClientLike> = {};
const fakeClient: FakeDocmostClient = {};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
@@ -557,7 +646,7 @@ describe('AiChatToolsService #294 changed execute wirings', () => {
tableDeleteRow: [],
tableUpdateCell: [],
};
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
movePage: (...args: unknown[]) => {
calls.movePage.push(args);
return Promise.resolve({ success: true });
@@ -666,7 +755,7 @@ describe('AiChatToolsService #410 footnote + image tools', () => {
insertImage: [],
replaceImage: [],
};
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
insertFootnote: (...args: unknown[]) => {
calls.insertFootnote.push(args);
return Promise.resolve({ success: true, footnoteId: 'fn1', reused: false });
@@ -836,3 +925,109 @@ describe('AiChatToolsService getCurrentPage selection (#388)', () => {
);
});
});
/**
* #440 review: the in-app drawioCreate / drawioUpdate handlers must forward
* the optional `layout:"elk"` param to the client (5th positional arg), exactly
* like the MCP host. It was silently dropped, so ELK auto-layout worked only via
* the standalone MCP server, not in-app. These tests pin per-host parity.
*/
describe('AiChatToolsService drawio layout passthrough (#440)', () => {
const createCalls: unknown[][] = [];
const updateCalls: unknown[][] = [];
// FakeDocmostClient (not Partial<DocmostClientLike>): since #446 derived
// DocmostClientLike from the real client, its drawioCreate/drawioUpdate return
// the concrete result shape, so a minimal stub object would not be assignable.
// FakeDocmostClient types every method as (...args) => Promise<any>, which is
// exactly what these arg-capturing doubles need.
const fakeClient: FakeDocmostClient = {
drawioCreate: (...args: unknown[]) => {
createCalls.push(args);
return Promise.resolve({ success: true, nodeId: '#0' });
},
drawioUpdate: (...args: unknown[]) => {
updateCalls.push(args);
return Promise.resolve({ success: true, nodeId: '#0' });
},
};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let service: AiChatToolsService;
beforeEach(() => {
createCalls.length = 0;
updateCalls.length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
} as never,
);
});
afterEach(() => jest.restoreAllMocks());
const buildTools = () =>
service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
);
it('forwards layout:"elk" to client.drawioCreate as the 5th positional arg', async () => {
const tools = await buildTools();
await tools.drawioCreate.execute(
{
pageId: 'p-1',
xml: '<mxGraphModel/>',
position: 'append',
layout: 'elk',
} as never,
{} as never,
);
expect(createCalls).toHaveLength(1);
// drawioCreate(pageId, where, xml, title, layout) — layout is args[4].
expect(createCalls[0][4]).toBe('elk');
});
it('forwards layout:"elk" to client.drawioUpdate as the 5th positional arg', async () => {
const tools = await buildTools();
await tools.drawioUpdate.execute(
{
pageId: 'p-1',
node: '#0',
xml: '<mxGraphModel/>',
baseHash: 'h',
layout: 'elk',
} as never,
{} as never,
);
expect(updateCalls).toHaveLength(1);
// drawioUpdate(pageId, node, xml, baseHash, layout) — layout is args[4].
expect(updateCalls[0][4]).toBe('elk');
});
it('omits layout (undefined 5th arg) when not requested', async () => {
const tools = await buildTools();
await tools.drawioCreate.execute(
{ pageId: 'p-1', xml: '<mxGraphModel/>', position: 'append' } as never,
{} as never,
);
expect(createCalls[0][4]).toBeUndefined();
});
});
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { tool, type Tool } from 'ai';
import { tool, type Tool, type ToolCallOptions } from 'ai';
import { z } from 'zod';
import { User } from '@docmost/db/types/entity.types';
import { TokenService } from '../../auth/services/token.service';
@@ -26,6 +26,125 @@ import {
type ToolCatalogEntry,
} from './tool-tiers';
/**
* Compile-time contract (issue #446): the in-app tool `execute` closures below
* call the loopback `DocmostClient` POSITIONALLY (e.g.
* `client.drawioGet(pageId, node, format ?? 'xml')`). Those closures receive an
* AI-SDK-erased (`any`) input, so a positional call inside them is NOT checked
* against the real signature a parameter reorder/type-change in
* `packages/mcp/src/client.ts` would otherwise reach production as a runtime
* "wrong argument" tool failure with zero compile signal (the restored #294
* debt). This never-called function reproduces every positional call with
* correctly-typed placeholder arguments against the DERIVED `DocmostClientLike`
* (a `Pick` of the real `DocmostClient`), so any such reorder/rename becomes a
* SERVER COMPILE ERROR here. It emits nothing (types only) and is never invoked;
* keep each call in lockstep with the matching `execute` body below.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function __assertClientCallContract(client: DocmostClientLike): void {
// Placeholders standing in for the AI-SDK-erased execute inputs. Their types
// are deliberately concrete so the positional calls are checked end-to-end.
const s = '' as string;
const n = 0 as number;
const node: unknown = null;
const edits: Array<{ find: string; replace: string; replaceAll?: boolean }> =
[];
const cells: string[] = [];
const align = undefined as 'left' | 'center' | 'right' | undefined;
// --- read ---
void client.search(s, undefined, n);
void client.getPage(s);
void client.getPageRaw(s);
void client.getWorkspace();
void client.getSpaces();
void client.listPages(s, n, true);
void client.getTree(s, s, n);
void client.getPageContext(s);
void client.listSidebarPages(s, s);
void client.getOutline(s);
void client.getPageJson(s);
void client.getNode(s, s, 'markdown');
void client.searchInPage(s, s, {
regex: true,
caseSensitive: true,
limit: n,
});
void client.getTable(s, s);
void client.listComments(s, true);
void client.getComment(s);
void client.checkNewComments(s, s, s);
void client.listShares();
void client.listPageHistory(s, s);
void client.getPageHistory(s);
void client.diffPageVersions(s, s, s);
void client.exportPageMarkdown(s);
// --- write (page) ---
void client.createPage(s, s, s, s);
void client.updatePage(s, s, s);
void client.renamePage(s, s);
void client.movePage(s, s, s);
void client.deletePage(s);
void client.editPageText(s, edits);
void client.patchNode(s, s, { markdown: s, node });
void client.insertNode(
s,
{ markdown: s, node },
{
position: 'append',
anchorNodeId: s,
anchorText: s,
},
);
void client.deleteNode(s, s);
void client.updatePageJson(s, node, s);
void client.tableInsertRow(s, s, cells, n);
void client.tableDeleteRow(s, s, n);
void client.tableUpdateCell(s, s, n, n, s);
void client.copyPageContent(s, s);
void client.importPageMarkdown(s, s);
void client.sharePage(s, true);
void client.unsharePage(s);
void client.restorePageVersion(s);
void client.transformPage(s, s, { dryRun: true });
void client.stashPage(s);
// --- write (image / footnote), in-app since #410 ---
void client.insertFootnote(s, s, s);
void client.insertImage(s, s, {
align,
alt: s,
replaceText: s,
afterText: s,
});
void client.replaceImage(s, s, s, { align, alt: s });
// --- draw.io diagrams (#423 stage 1, #424 stage 2) ---
// The 5th `layout` arg (#424) is exercised so this parity assertion fails if the
// client signature drops it — it must reach the client from the shared execute.
void client.drawioGet(s, s, 'xml');
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk');
void client.drawioUpdate(s, s, s, s, 'elk');
// --- draw.io high-level semantic tools (#425 stage 3) ---
void client.drawioEditCells(s, s, [{ op: 'delete', cellId: s }], s);
void client.drawioFromGraph(
s,
{ position: 'append', anchorNodeId: s },
{ nodes: [{ id: s, label: s }] },
'LR',
s,
'full',
s,
);
void client.drawioFromMermaid(
s,
{ position: 'append', anchorNodeId: s },
s,
s,
);
// --- write (comment) ---
void client.createComment(s, s, 'inline', s, s, s);
void client.resolveComment(s, true);
}
/**
* Per-user, per-request adapter that exposes Docmost READ operations to the
* agent as AI SDK tools (STAGE A = read only).
@@ -40,6 +159,129 @@ import {
* existing service-account `/mcp` path already calls loopback successfully, so
* this works for single-workspace self-host.
*/
/**
* #487: wall-clock cap for a SINGLE in-app tool call, env-tunable via
* `AI_CHAT_INAPP_TOOL_CALL_CAP_MS`. Bounds a read tool that would otherwise
* paginate for minutes and a content write whose collab commit hangs, and is the
* per-call CAP half of the composite abort signal every in-app tool is wrapped
* with (the other half is the turn's Stop signal). Default 2 minutes: generous
* for a legitimate long read/write, tight enough that a stuck call cannot pin the
* turn. The reconcile staleness floor (#487 commit 4) is derived as
* max(2 x this cap, 15 min), so keep this well under that.
*/
export function inAppToolCallCapMs(): number {
const raw = Number(process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 120_000;
}
/** #487: the composite signal's reason as an Error (informative thrown value). */
function inAppAbortReason(signal: AbortSignal): Error {
const r = signal.reason;
return r instanceof Error
? r
: new Error(typeof r === 'string' ? r : 'In-app tool call aborted');
}
/**
* The client surface {@link wrapInAppToolWithCap} drives (#487). Both methods are
* OPTIONAL: the real loopback DocmostClient implements them (so a Stop/cap reaches
* its pagination / pre-commit safe-points), but a client that omits them still
* gets the OUTER guarantee the race rejects on abort regardless. This keeps the
* wrapper decoupled from the exact client shape (unit-test doubles need not stub
* the plumbing).
*/
export interface ToolAbortSignalSink {
setToolAbortSignal?(signal: AbortSignal | null): void;
getToolAbortSignal?(): AbortSignal | null;
}
/**
* #487: wrap an in-app tool so a Stop (the turn's `options.abortSignal`) OR the
* per-call wall-clock cap REJECTS the call immediately, and so that SAME
* composite signal reaches the client's pagination / pre-commit safe-points (via
* `client.setToolAbortSignal`) making a Stop stop the NEXT HTTP/WS call from
* starting.
*
* Reuses the RACE pattern of `wrapToolWithCallTimeout` (mcp-clients.service.ts):
* the call is raced against the composite signal, so on abort we reject in the
* SAME tick and DISCARD the loser promise. Its network / collab teardown latency
* therefore never blocks the turn the supersede timeout W=10s (#487 commit 3)
* relies on this abort->settle latency being milliseconds, not a socket teardown.
* Awaiting the client's own signal-into-write path alone would NOT satisfy this
* (the loser could still be tearing down a collab socket).
*
* The composite is SET on the client at entry and deliberately NOT restored on
* unwind: after this wrapper rejects on abort, the ABANDONED loser promise keeps
* running, and its safe-points read the client field leaving the (aborted)
* composite there is exactly what makes the loser's NEXT call throw and stop. The
* next in-app tool call overwrites the field with its own fresh composite before
* any of its safe-points run, so a stale settled signal never leaks forward.
* SINGLE-WRITER by phase-1 assumption see DocmostClientContext.toolAbortSignal
* for the parallel-call caveat (#487).
*
* KNOWN LIMITATION (#487): a write tool that issues SEVERAL sequential collab
* commits can be aborted BETWEEN commits, leaving a partially-applied operation.
* Cancel guarantees "no NEW call starts", NOT "the write didn't land".
*/
export function wrapInAppToolWithCap(
toolDef: Tool,
client: ToolAbortSignalSink,
capMs: number,
): Tool {
const original = toolDef.execute;
if (typeof original !== 'function') return toolDef;
const execute = async (args: unknown, options: ToolCallOptions) => {
const capController = new AbortController();
const timer = setTimeout(() => {
capController.abort(
new Error(`In-app tool call exceeded the ${capMs}ms per-call cap`),
);
}, capMs);
timer.unref?.();
const composite = options?.abortSignal
? AbortSignal.any([options.abortSignal, capController.signal])
: capController.signal;
// Reject the MOMENT the composite fires, independent of whether `original`
// ever settles (a hung collab write / read would otherwise pin the turn). The
// losing `original` is left pending; Promise.race attaches a rejection
// handler to both inputs so a late rejection is never unhandled.
const aborted = new Promise<never>((_, reject) => {
const fail = () => reject(inAppAbortReason(composite));
if (composite.aborted) fail();
else composite.addEventListener('abort', fail, { once: true });
});
// Publish the composite so the client's pagination / pre-commit safe-points
// observe it (see the "not restored on unwind" rationale above). Guarded: a
// client without the plumbing still gets the OUTER race guarantee below.
client.setToolAbortSignal?.(composite);
try {
return await Promise.race([
(original as (a: unknown, o: ToolCallOptions) => Promise<unknown>)(
args,
{ ...options, abortSignal: composite },
),
aborted,
]);
} finally {
clearTimeout(timer);
}
};
return { ...toolDef, execute } as unknown as Tool;
}
/** #487: apply {@link wrapInAppToolWithCap} to every tool in a set. */
export function wrapInAppToolsWithCap(
tools: Record<string, Tool>,
client: ToolAbortSignalSink,
capMs: number,
): Record<string, Tool> {
const out: Record<string, Tool> = {};
for (const [name, t] of Object.entries(tools)) {
out[name] = wrapInAppToolWithCap(t, client, capMs);
}
return out;
}
@Injectable()
export class AiChatToolsService {
private readonly logger = new Logger(AiChatToolsService.name);
@@ -67,7 +309,12 @@ export class AiChatToolsService {
sessionId: string,
workspaceId: string,
aiChatId: string,
): Promise<DocmostClientLike> {
// #487: the returned client also carries the tool-cancellation plumbing
// (setToolAbortSignal/getToolAbortSignal). These are host plumbing, NOT part
// of the tool-execute surface (DocmostClientMethod), so they are surfaced here
// as an intersection rather than by widening that Pick — keeping the
// positional-call drift-guard (#446) scoped to the actual tool methods.
): Promise<DocmostClientLike & ToolAbortSignalSink> {
const apiUrl =
process.env.MCP_DOCMOST_API_URL ||
`http://127.0.0.1:${process.env.PORT || 3000}/api`;
@@ -169,8 +416,19 @@ export class AiChatToolsService {
// provenance tokens) and load the shared tool-spec registry. Client
// construction is shared with the page-change detection path (#274) via
// buildDocmostClient so both go over the exact same authenticated route.
const { sharedToolSpecs, createCommentSignalTracker } =
await loadDocmostMcp();
// searchShapes / getGuideSection (#424) are the PURE, no-network helpers
// backing drawioShapes / drawioGuide. They are `inlineBothHosts` specs (no
// canonical execute — their catalog loader uses import.meta and can't be
// value-imported into the zod-agnostic tool-specs.ts under the server's
// commonjs type-check), so the shared registry loop below SKIPS them and this
// service wires them inline (see drawioShapes/drawioGuide entries), mirroring
// how index.ts registers them on the standalone MCP host.
const {
sharedToolSpecs,
createCommentSignalTracker,
searchShapes,
getGuideSection,
} = await loadDocmostMcp();
const client = await this.buildDocmostClient(
user,
sessionId,
@@ -198,6 +456,18 @@ export class AiChatToolsService {
execute,
});
// The in-app toolset. It starts with the tools kept INLINE here for a
// documented per-layer reason: an intentional behaviour/schema divergence from
// the standalone MCP surface (searchPages' hybrid RRF,
// transformPage's guardrailed shorter schema), a name clash the shared
// registry forbids (in-app `getTable` verb-first vs the MCP noun-first
// `tableGet` — the registry requires mcpName === inAppKey), per-request
// state the registry loop cannot provide
// (getCurrentPage reads the resolved openedPage; searchPages closes over the
// per-request user/embedding deps), or a tool with no MCP twin
// (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added
// by the registry loop below (see it), so there is exactly one arg-mapping per
// shared tool and it can never drift from the MCP host again (#445).
const tools: Record<string, Tool> = {
// INTENTIONAL per-transport divergence (not in the shared registry): this
// in-app search runs a semantic + keyword hybrid (RRF) with in-process
@@ -332,180 +602,14 @@ export class AiChatToolsService {
execute: async () => resolveCurrentPageResult(openedPage),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's { title, markdown } projection.
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = await client.getPage(pageId);
const data = (result?.data ?? {}) as {
title?: string;
content?: string;
};
return {
title: data.title ?? '',
markdown: typeof data.content === 'string' ? data.content : '',
};
}),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
createPage: sharedTool(
sharedToolSpecs.createPage,
async ({ title, content, spaceId, parentPageId }) => {
// createPage(title, content, spaceId, parentPageId?) ->
// { data: filterPage(page, markdown), success }.
const result = await client.createPage(
title,
content ?? '',
spaceId,
parentPageId,
);
const data = (result?.data ?? {}) as {
id?: string;
slugId?: string;
title?: string;
};
return { id: data.id ?? data.slugId, title: data.title ?? title };
},
),
updatePageContent: tool({
description:
"Replace a page's body with new Markdown content (and optionally its " +
'title). Reversible: the previous version is kept in page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to update.'),
content: z.string().describe('The new page body as Markdown.'),
title: z
.string()
.optional()
.describe('Optional new title for the page.'),
}),
execute: async ({ pageId, content, title }) => {
// updatePage mutates the live collab doc -> provenance flows from the
// collab-token provider. Returns { success, modified, message, pageId }.
const result = (await client.updatePage(pageId, content, title)) as {
success?: boolean;
};
return { pageId, updated: result?.success ?? true };
},
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
renamePage: sharedTool(
sharedToolSpecs.renamePage,
async ({ pageId, title }) => {
// renamePage(pageId, title) -> { success, pageId, title }.
await client.renamePage(pageId, title);
return { pageId, title };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The shared schema adds the optional `position` field this layer lacked
// before; the execute now forwards it (the client already accepted it).
movePage: sharedTool(
sharedToolSpecs.movePage,
async ({ pageId, parentPageId, position }) => {
// movePage(pageId, parentPageId, position?) -> raw move response.
await client.movePage(pageId, parentPageId ?? null, position);
return { pageId, parentPageId: parentPageId ?? null, moved: true };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
// permanentlyDelete/forceDelete are never part of the input and can never
// be forwarded — the agent physically cannot permanently delete a page.
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// This layer keeps only its own execute-side guards (require a selection
// for a top-level comment; reject suggestedText on a reply / without a
// selection) — the schema+description are shared.
createComment: sharedTool(
sharedToolSpecs.createComment,
async ({
pageId,
content,
selection,
parentCommentId,
suggestedText,
}) => {
// createComment(pageId, content, type, selection?, parentCommentId?,
// suggestedText?). Top-level comments are inline and must carry a
// selection to anchor on; replies inherit the parent's anchor (no
// selection). Throwing here surfaces a tool error to the model (Vercel
// `ai` SDK) so the agent retries with a better selection — do not
// catch/suppress it.
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
);
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await client.createComment(
pageId,
content,
'inline',
selection,
parentCommentId,
suggestedText,
);
const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
resolveComment: sharedTool(
sharedToolSpecs.resolveComment,
async ({ commentId, resolved }) => {
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
await client.resolveComment(commentId, resolved);
return { commentId, resolved };
},
),
// --- READ tools (added) ---
getWorkspace: sharedTool(
sharedToolSpecs.getWorkspace,
async () => await client.getWorkspace(),
),
listSpaces: sharedTool(
sharedToolSpecs.listSpaces,
async () => await client.getSpaces(),
),
// INTENTIONAL per-transport divergence (not shared): keeps the `tree:true`
// hierarchy mode but is worded for the in-app agent; the standalone MCP
// `list_pages` carries its own wording. Kept per-layer so each side tunes
// its own guidance.
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listPages: sharedTool(
sharedToolSpecs.listPages,
async ({ spaceId, limit, tree }) =>
await client.listPages(spaceId, limit, tree),
),
//
// NOTE (issue #411): the plain-Markdown full-body-replace tool is no longer
// inline here — it moved to @docmost/mcp's SHARED_TOOL_SPECS as
// `updatePageMarkdown` (was inline `updatePageContent`) so it registers on
// BOTH the external MCP and the in-app agent. The registry loop below adds
// it under its inAppKey. importPageMarkdown stays a shared spec too (now
// inAppOnly — dropped from the external MCP surface, kept in-app).
listSidebarPages: tool({
description:
@@ -525,34 +629,9 @@ export class AiChatToolsService {
await client.listSidebarPages(spaceId, pageId),
}),
getOutline: sharedTool(
sharedToolSpecs.getOutline,
async ({ pageId }) => await client.getOutline(pageId),
),
getPageJson: sharedTool(
sharedToolSpecs.getPageJson,
async ({ pageId }) => await client.getPageJson(pageId),
),
getNode: sharedTool(
sharedToolSpecs.getNode,
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
),
searchInPage: sharedTool(
sharedToolSpecs.searchInPage,
async ({ pageId, query, regex, caseSensitive, limit }) =>
await client.searchInPage(pageId, query, {
regex,
caseSensitive,
limit,
}),
),
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
// while this key is `getTable` (verb-first), breaking the
// snake_case(inAppKey) convention the shared registry enforces. Its
// NOT shared (kept inline): the MCP tool name `tableGet` is noun-first
// while this key is `getTable` (verb-first), so it cannot satisfy the
// shared registry's `mcpName === inAppKey` convention (#412). Its
// reference parameter is still named `table` (was `tableRef`) so it matches
// the migrated table row/cell tools below.
getTable: tool({
@@ -572,13 +651,6 @@ export class AiChatToolsService {
await client.getTable(pageId, table),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listComments: sharedTool(
sharedToolSpecs.listComments,
async ({ pageId, includeResolved }) =>
await client.listComments(pageId, includeResolved),
),
getComment: tool({
description: 'Fetch a single comment by id (content as Markdown).',
inputSchema: modelFriendlyInput({
@@ -587,24 +659,6 @@ export class AiChatToolsService {
execute: async ({ commentId }) => await client.getComment(commentId),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
checkNewComments: sharedTool(
sharedToolSpecs.checkNewComments,
async ({ spaceId, since, parentPageId }) =>
await client.checkNewComments(spaceId, since, parentPageId),
),
listShares: sharedTool(
sharedToolSpecs.listShares,
async () => await client.listShares(),
),
listPageHistory: sharedTool(
sharedToolSpecs.listPageHistory,
async ({ pageId, cursor }) =>
await client.listPageHistory(pageId, cursor),
),
getPageHistory: tool({
description:
'Fetch a single page-history version including its lossless ' +
@@ -616,206 +670,11 @@ export class AiChatToolsService {
await client.getPageHistory(historyId),
}),
diffPageVersions: sharedTool(
sharedToolSpecs.diffPageVersions,
async ({ pageId, from, to }) =>
await client.diffPageVersions(pageId, from, to),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
exportPageMarkdown: sharedTool(
sharedToolSpecs.exportPageMarkdown,
async ({ pageId }) => {
const markdown = await client.exportPageMarkdown(pageId);
return { markdown };
},
),
// --- WRITE tools (added; reversible via page history/trash) ---
editPageText: sharedTool(
sharedToolSpecs.editPageText,
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
),
// Returns ONLY the short link object — never the document body — so a
// large page can be handed to an external consumer without bloating
// context.
stashPage: sharedTool(
sharedToolSpecs.stashPage,
async ({ pageId }) => await client.stashPage(pageId),
),
// Schema + description from the shared registry (identical across both
// transports). The execute body keeps its OWN parseNodeArg normalization:
// the model sometimes serializes the node as a JSON string, and we parse it
// before the client's typeof-object guard rejects it (parity with the
// standalone MCP server, index.ts patch_node).
patchNode: sharedTool(
sharedToolSpecs.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
),
// Shared registry schema + description; execute retains parseNodeArg on the
// incoming node (parity with the standalone MCP server, index.ts
// insert_node).
insertNode: sharedTool(
sharedToolSpecs.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
anchorText,
});
},
),
deleteNode: sharedTool(
sharedToolSpecs.deleteNode,
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's content normalization (parity with
// the standalone MCP server, index.ts update_page_json).
updatePageJson: sharedTool(
sharedToolSpecs.updatePageJson,
async ({ pageId, content, title }) => {
// undefined/null pass through as undefined (title-only / no-op); any
// string is JSON.parsed (so an empty string "" throws, matching the
// MCP server); an object is passed through unchanged.
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else {
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, 'content was a string but not valid JSON');
}
return await client.updatePageJson(pageId, doc, title);
},
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
// Promoted from MCP-only so the in-app agent can attach a REAL footnote to
// already-written text instead of leaving a literal `^[...]` string.
insertFootnote: sharedTool(
sharedToolSpecs.insertFootnote,
async ({ pageId, anchorText, text }) =>
await client.insertFootnote(pageId, anchorText, text),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
// The schema field is `imageUrl`; the client method takes it positionally.
insertImage: sharedTool(
sharedToolSpecs.insertImage,
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) =>
await client.insertImage(pageId, imageUrl, {
align,
alt,
replaceText,
afterText,
}),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
replaceImage: sharedTool(
sharedToolSpecs.replaceImage,
async ({ pageId, attachmentId, imageUrl, align, alt }) =>
await client.replaceImage(pageId, attachmentId, imageUrl, {
align,
alt,
}),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// meta.hash in the result is the baseHash drawioUpdate requires.
drawioGet: sharedTool(
sharedToolSpecs.drawioGet,
async ({ pageId, node, format }) =>
await client.drawioGet(pageId, node, format ?? 'xml'),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// The flat schema fields are regrouped into the client's `where` object.
drawioCreate: sharedTool(
sharedToolSpecs.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
await client.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// baseHash is the optimistic lock: mismatch => structured conflict error.
drawioUpdate: sharedTool(
sharedToolSpecs.drawioUpdate,
async ({ pageId, node, xml, baseHash }) =>
await client.drawioUpdate(pageId, node, xml, baseHash),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The table reference parameter was unified to `table` (was `tableRef`).
tableInsertRow: sharedTool(
sharedToolSpecs.tableInsertRow,
async ({ pageId, table, cells, index }) =>
await client.tableInsertRow(pageId, table, cells, index),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableDeleteRow: sharedTool(
sharedToolSpecs.tableDeleteRow,
async ({ pageId, table, index }) =>
await client.tableDeleteRow(pageId, table, index),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableUpdateCell: sharedTool(
sharedToolSpecs.tableUpdateCell,
async ({ pageId, table, row, col, text }) =>
await client.tableUpdateCell(pageId, table, row, col, text),
),
copyPageContent: sharedTool(
sharedToolSpecs.copyPageContent,
async ({ sourcePageId, targetPageId }) =>
await client.copyPageContent(sourcePageId, targetPageId),
),
importPageMarkdown: sharedTool(
sharedToolSpecs.importPageMarkdown,
async ({ pageId, markdown }) =>
await client.importPageMarkdown(pageId, markdown),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// Both layers already carried the security-confirmation framing, so there
// was no real divergence to preserve — only wording drift.
sharePage: sharedTool(
sharedToolSpecs.sharePage,
async ({ pageId, searchIndexing }) =>
await client.sharePage(pageId, searchIndexing),
),
unsharePage: sharedTool(
sharedToolSpecs.unsharePage,
async ({ pageId }) => await client.unsharePage(pageId),
),
restorePageVersion: sharedTool(
sharedToolSpecs.restorePageVersion,
async ({ historyId }) => await client.restorePageVersion(historyId),
),
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
// `deleteComments` schema field (comment-deletion guardrail) and carries a
// much shorter description; the standalone MCP `docmost_transform` exposes
// much shorter description; the standalone MCP `docmostTransform` exposes
// the full helper catalogue. Different schema, so kept per-layer.
transformPage: tool({
description:
@@ -841,6 +700,51 @@ export class AiChatToolsService {
}),
};
// Add EVERY shared tool from the zod-agnostic registry in one loop (#445).
// The spec owns the canonical arg->client mapping; this host only decides
// WHICH mapping to run and returns its value directly (no envelope). For each
// spec:
// - skip `mcpOnly` specs (they belong to the standalone MCP host only);
// - skip `inlineBothHosts` specs (drawioShapes / drawioGuide): they carry
// no execute and are wired INLINE just below, calling the pure helpers;
// - use `inAppExecute` when the spec declares a DELIBERATE per-layer
// difference (a projected result shape, a different guardrail message);
// - otherwise use the canonical `execute` (raw client result, identical to
// the MCP host's before it wraps it as JSON).
// The execute receives the AI-SDK-validated, type-erased input; the spec reads
// the same fields its buildShape declares. This is the SINGLE place the in-app
// arg mapping lives — it can no longer silently drift from the MCP host.
for (const spec of Object.values(sharedToolSpecs)) {
if (spec.mcpOnly) continue;
if (spec.inlineBothHosts) continue;
const run = spec.inAppExecute ?? spec.execute;
if (!run) continue; // defensive: a shared spec always carries one of them.
tools[spec.inAppKey] = sharedTool(
spec,
(async (args) =>
run(client, args as Record<string, unknown>)) as Tool['execute'],
);
}
// drawioShapes / drawioGuide (#424): `inlineBothHosts` registry specs wired
// here with the SAME schema+description the shared spec pins, but calling the
// pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp
// module — they are not client methods and their catalog loader uses
// import.meta, so they cannot live in the zod-agnostic shared execute. The raw
// result is identical to the MCP host's (which wraps it as JSON text); here
// the in-app host returns it plain, exactly like every other shared tool.
tools[sharedToolSpecs.drawioShapes.inAppKey] = sharedTool(
sharedToolSpecs.drawioShapes,
async ({ query, category, limit }) => {
const results = searchShapes(query, { category, limit });
return { query, count: results.length, results };
},
);
tools[sharedToolSpecs.drawioGuide.inAppKey] = sharedTool(
sharedToolSpecs.drawioGuide,
async ({ section }) => getGuideSection(section),
);
// Passive "new comments: N" signal (#417). PER-TURN state (forUser runs once
// per turn), so the watermark starts now and only comments a human leaves
// WHILE this turn runs are signalled — exactly the mid-turn loop; between-turn
@@ -854,7 +758,15 @@ export class AiChatToolsService {
// dependency and reuses the CASL enforcement already on `client`. When the
// loaded package predates #417 (factory undefined) or the loader is mocked in
// a unit test, signalling is a pure no-op and results are byte-identical.
if (!createCommentSignalTracker) return tools;
// #487: wrap every in-app tool with the race-on-abort + per-call cap guard so
// a Stop / cap rejects immediately AND reaches the client's write/pagination
// safe-points. Applied as the OUTERMOST wrapper (over the comment-signal
// wrapper below) so the race governs the whole call. The client carries the
// per-call composite signal via setToolAbortSignal.
const capMs = inAppToolCallCapMs();
if (!createCommentSignalTracker) {
return wrapInAppToolsWithCap(tools, client, capMs);
}
const tracker = createCommentSignalTracker({
probe: async (pageId: string, sinceMs: number) => {
@@ -883,7 +795,11 @@ export class AiChatToolsService {
},
});
return wrapToolsWithCommentSignal(tools, tracker);
return wrapInAppToolsWithCap(
wrapToolsWithCommentSignal(tools, tracker),
client,
capMs,
);
}
}
@@ -7,6 +7,16 @@ import type {
DocmostClientLike,
CommentSignalTrackerLike,
} from './docmost-client.loader';
// Test-double type for the loopback client. `DocmostClientLike` is now DERIVED
// from the real `DocmostClient` (issue #446), so its method RETURN types are the
// concrete client shapes. These probe stubs deliberately return minimal shapes
// (e.g. `getPageRaw` yielding only `{ title }`), so the doubles use the same
// method NAMES but loose async returns; each is cast to `DocmostClientLike` at
// the (return-erased) mock site, leaving production positional-call safety intact.
type FakeDocmostClient = Partial<
Record<keyof DocmostClientLike, (...args: any[]) => Promise<any>>
>;
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
// The REAL shared tracker factory, imported from source (same cross-boundary
// approach the tool-specs spec uses) so the in-app wiring is exercised against
@@ -268,15 +278,23 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
// seeded at forUser time).
const future = new Date(Date.now() + 3_600_000).toISOString();
function buildService(fakeClient: Partial<DocmostClientLike>) {
function buildService(fakeClient: FakeDocmostClient) {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
DocmostClient: function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Wire the REAL factory so the in-app path is exercised end to end.
createCommentSignalTracker:
createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory,
// Pure no-network draw.io helpers (#424) — required on the loader return;
// this comment-signal test doesn't exercise them, so no-op stubs suffice.
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: '',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
return new AiChatToolsService(
tokenServiceStub as never,
@@ -317,7 +335,7 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
afterEach(() => jest.restoreAllMocks());
it('emits the signal (model-only) on a non-comment tool when a new comment exists', async () => {
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
getPage: async () => ({
data: { title: 'Иранские языки', content: 'body' },
success: true,
@@ -342,7 +360,7 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
});
it('does NOT add the signal to the listComments tool itself (tautological)', async () => {
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
listComments: async () => ({
items: [{ createdAt: future }],
resolvedThreadsHidden: 0,
@@ -356,7 +374,7 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
});
it('no new comments => tool output is byte-identical AND the model sees no signal', async () => {
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
getPage: async () => ({
data: { title: 'T', content: 'body' },
success: true,
@@ -372,7 +390,7 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
});
it('injection-safety: a malicious page title cannot forge a second signal', async () => {
const fakeClient: Partial<DocmostClientLike> = {
const fakeClient: FakeDocmostClient = {
getPage: async () => ({
data: { title: 'body-title', content: 'body' },
success: true,
@@ -0,0 +1,265 @@
import { createHash } from 'node:crypto';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readdirSync,
statSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, sep } from 'node:path';
import { computeSrcRegistryStamp } from './docmost-client.loader';
// The exact message the loader throws on a build/src skew (issue #447). Kept as a
// literal here so a reworded prod message reddens this test (the message is a
// developer-facing contract: it tells them how to fix it).
const STALE_BUILD_MESSAGE =
'@docmost/mcp build is stale (tool-specs changed since last build) — run: pnpm --filter @docmost/mcp build';
// Replica of the loader's inline stale-check predicate + throw from
// `loadDocmostMcp`. That guard is not independently exported (it lives inside the
// dynamic-import IIFE, wired to a fixed `require.resolve('@docmost/mcp')`), so we
// exercise the exact same three-condition logic against a stamp produced by the
// REAL `computeSrcRegistryStamp`. This documents and locks the throw/no-throw
// behaviour; if the prod predicate changes, this replica must change with it.
function assertStaleGuard(
srcStamp: string | null,
registryStamp: string | undefined,
): void {
if (
srcStamp !== null &&
typeof registryStamp === 'string' &&
srcStamp !== registryStamp
) {
throw new Error(STALE_BUILD_MESSAGE);
}
}
// Build a throwaway `<pkg>/build/index.js` + optional `<pkg>/src/` tree so
// `computeSrcRegistryStamp(<pkg>/build/index.js)` resolves src the same way the
// loader does (dirname(dirname(entry))/src). Since #486 the stamp hashes the WHOLE
// src tree, so a fixture is a { relPath: content } map. A bare string is sugar for
// a single `tool-specs.ts`; `null` means "no src tree" (the prod no-op path).
function makeFakePackage(
src: string | Record<string, string> | null,
): {
entry: string;
cleanup: () => void;
} {
const root = mkdtempSync(join(tmpdir(), 'mcp-stamp-'));
const buildDir = join(root, 'build');
mkdirSync(buildDir, { recursive: true });
const entry = join(buildDir, 'index.js');
writeFileSync(entry, '// fake @docmost/mcp build entry\n', 'utf8');
if (src !== null) {
const files =
typeof src === 'string' ? { 'tool-specs.ts': src } : src;
const srcDir = join(root, 'src');
for (const [rel, content] of Object.entries(files)) {
const full = join(srcDir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content, 'utf8');
}
}
return { entry, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
describe('computeSrcRegistryStamp (#447 stale-build guard)', () => {
it('returns null when src/tool-specs.ts is absent (prod no-op path)', () => {
// A prod image ships only build/, no src/ — the guard must be a silent no-op.
const { entry, cleanup } = makeFakePackage(null);
try {
expect(computeSrcRegistryStamp(entry)).toBeNull();
} finally {
cleanup();
}
});
it('returns null for a bogus package entry (swallowed error path)', () => {
// A resolution/read hiccup must NEVER break startup — it resolves to null.
expect(
computeSrcRegistryStamp('/no/such/pkg/build/index.js'),
).toBeNull();
});
it('computes a 64-char sha256 hex when src/tool-specs.ts exists', () => {
const { entry, cleanup } = makeFakePackage('export const specs = 1;\n');
try {
const stamp = computeSrcRegistryStamp(entry);
expect(stamp).toMatch(/^[0-9a-f]{64}$/);
} finally {
cleanup();
}
});
it('normalizes CRLF->LF and strips a single trailing newline', () => {
// A CRLF+trailing-newline variant of the same content hashes identically to
// the bare-LF form — the guard must not fire on a checkout-style difference.
const bare = makeFakePackage('alpha\nbeta');
const crlfTrailing = makeFakePackage('alpha\r\nbeta\r\n');
try {
expect(computeSrcRegistryStamp(crlfTrailing.entry)).toBe(
computeSrcRegistryStamp(bare.entry),
);
} finally {
bare.cleanup();
crlfTrailing.cleanup();
}
});
// #486 CORE (negative): an edit to a NON-tool-specs src file (client.ts) with a
// rebuild NOT run must move the src stamp away from the built REGISTRY_STAMP, so
// the loader's stale-check refuses. Under the old tool-specs.ts-only hash this
// edit was invisible and a stale build/ served the old client.ts silently.
it('a client.ts edit (no rebuild) moves the src stamp -> loader refuses (#486)', () => {
// "Built" state: the package as it was compiled.
const built = makeFakePackage({
'tool-specs.ts': 'export const SPECS = 1;\n',
'client.ts': "export const impl = 'v1';\n",
});
// "Dev edited src, forgot to rebuild": client.ts changed, tool-specs.ts not.
const edited = makeFakePackage({
'tool-specs.ts': 'export const SPECS = 1;\n',
'client.ts': "export const impl = 'v2';\n",
});
try {
const builtStamp = computeSrcRegistryStamp(built.entry);
const editedStamp = computeSrcRegistryStamp(edited.entry);
expect(builtStamp).not.toBeNull();
expect(editedStamp).not.toBe(builtStamp);
// build/ still carries builtStamp; src now hashes to editedStamp -> refuse.
expect(() => assertStaleGuard(editedStamp, builtStamp as string)).toThrow(
STALE_BUILD_MESSAGE,
);
} finally {
built.cleanup();
edited.cleanup();
}
});
// *.generated.ts is excluded (the codegen's own output — a fixed-point cycle
// otherwise): its presence/content must not move the stamp.
it('excludes *.generated.ts from the stamp', () => {
const without = makeFakePackage({ 'tool-specs.ts': 'x\n' });
const withGen = makeFakePackage({
'tool-specs.ts': 'x\n',
'registry-stamp.generated.ts': 'export const REGISTRY_STAMP = "abc";\n',
});
try {
expect(computeSrcRegistryStamp(withGen.entry)).toBe(
computeSrcRegistryStamp(without.entry),
);
} finally {
without.cleanup();
withGen.cleanup();
}
});
// CROSS-IMPL EQUALITY (covers reviewer suggestion 2). The SAME fixed tree and
// EXPECTED hash are asserted in the mcp-side node test
// (packages/mcp/test/unit/registry-stamp.test.mjs) against the codegen's
// `computeRegistryStamp`. Asserting the SAME pair here against the loader's
// `computeSrcRegistryStamp` proves both implementations enumerate+normalize+hash
// identically; a divergence in EITHER side reddens one of the two tests.
const CROSS_IMPL_TREE = {
'tool-specs.ts': 'line1\r\nline2\n',
'client/read.ts': 'export const R = 1;\n',
'registry-stamp.generated.ts': 'export const REGISTRY_STAMP="ignored";\n',
};
const CROSS_IMPL_EXPECTED =
'131c1b9e4e2f5a7d6cef91ca8df619822b442f52bc45ebd09474a4c1d6728616';
it('matches the documented cross-impl hash for a fixed tree', () => {
const { entry, cleanup } = makeFakePackage(CROSS_IMPL_TREE);
try {
expect(computeSrcRegistryStamp(entry)).toBe(CROSS_IMPL_EXPECTED);
} finally {
cleanup();
}
});
it('the documented EXPECTED is the enumerate+normalize+sha256 of the tree', () => {
// Proves EXPECTED is not a magic constant but the documented computation — a
// local re-implementation of the loader's tree walk.
const { entry, cleanup } = makeFakePackage(CROSS_IMPL_TREE);
try {
const srcDir = join(dirname(dirname(entry)), 'src');
const collect = (dir: string): string[] => {
const out: string[] = [];
for (const e of readdirSync(dir)) {
const f = join(dir, e);
if (statSync(f).isDirectory()) out.push(...collect(f));
else if (e.endsWith('.ts') && !e.endsWith('.generated.ts'))
out.push(f);
}
return out;
};
const files = collect(srcDir)
.map((abs) => ({ rel: relative(srcDir, abs).split(sep).join('/'), abs }))
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
const h = createHash('sha256');
for (const { rel, abs } of files) {
const n = readFileSync(abs, 'utf8')
.replace(/\r\n/g, '\n')
.replace(/\n$/, '');
h.update(rel, 'utf8');
h.update('\0', 'utf8');
h.update(n, 'utf8');
h.update('\0', 'utf8');
}
const localHash = h.digest('hex');
expect(computeSrcRegistryStamp(entry)).toBe(localHash);
expect(localHash).toBe(CROSS_IMPL_EXPECTED);
} finally {
cleanup();
}
});
});
describe('loadDocmostMcp stale-check predicate (#447)', () => {
it('THROWS the exact stale message when src stamp != built REGISTRY_STAMP', () => {
const { entry, cleanup } = makeFakePackage('export const specs = 1;\n');
try {
const srcStamp = computeSrcRegistryStamp(entry);
expect(srcStamp).not.toBeNull();
// Simulate a stale build: build/ carries a DIFFERENT stamp than src.
expect(() => assertStaleGuard(srcStamp, 'a'.repeat(64))).toThrow(
STALE_BUILD_MESSAGE,
);
} finally {
cleanup();
}
});
it('does NOT throw when src stamp equals the built REGISTRY_STAMP', () => {
const { entry, cleanup } = makeFakePackage('export const specs = 1;\n');
try {
const srcStamp = computeSrcRegistryStamp(entry);
// Fresh build: build/ stamp == src stamp -> guard is a no-op.
expect(() => assertStaleGuard(srcStamp, srcStamp as string)).not.toThrow();
} finally {
cleanup();
}
});
it('does NOT throw when src is absent (prod: srcStamp === null)', () => {
// Even against a present-but-mismatched REGISTRY_STAMP, a null src stamp
// (prod image with build/ only) must skip the check entirely.
expect(() => assertStaleGuard(null, 'a'.repeat(64))).not.toThrow();
});
it('does NOT throw when REGISTRY_STAMP is absent (pre-#447 build)', () => {
// An older @docmost/mcp build has no REGISTRY_STAMP export; the guard must be
// a no-op so an out-of-date build never wrongly blocks startup.
const { entry, cleanup } = makeFakePackage('export const specs = 1;\n');
try {
const srcStamp = computeSrcRegistryStamp(entry);
expect(() => assertStaleGuard(srcStamp, undefined)).not.toThrow();
} finally {
cleanup();
}
});
});
@@ -1,264 +1,102 @@
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { DocmostClient, SharedToolSpec } from '@docmost/mcp';
// Re-export SharedToolSpec so downstream server modules keep a single import
// path (they import it from this loader). The shape is DERIVED from the package
// entry, not re-declared here — see the import above (issue #446).
export type { SharedToolSpec } from '@docmost/mcp';
/**
* Minimal structural type for the `DocmostClient` class we consume from the
* ESM-only `@docmost/mcp` package. We only need the constructor + the read/write
* methods used by the per-user tool adapter; the full client surface lives in
* `packages/mcp/src/client.ts`. Signatures here mirror that file exactly.
*
* DRIFT GUARD: the method NAMES below are runtime-checked against the real
* `DocmostClient` by `packages/mcp/test/unit/client-host-contract.test.mjs`
* (which can import the ESM class directly). If you rename/remove a method here
* or in client.ts, that test fails so a stale mirror cannot silently ship a
* runtime "x is not a function" into an agent tool call. Keep the two in sync.
*
* STAGED PLAN full derivation `DocmostClientLike = <real DocmostClient type>`
* (issue #193, layer 3) is intentionally NOT done; it stays a hand-mirror for
* now because of two verified blockers across the ESM(mcp)/CJS(server) boundary:
* 1. `@docmost/mcp` emits NO declaration files (its tsconfig has no
* `declaration`, package.json has no `types`/types-export) and the server
* tsconfig has no path mapping for it the server only loads it via the
* runtime `import()` trick below, so there is no type to import today.
* 2. The real client methods have inferred, CONCRETE return types; the in-app
* tool adapter reads results through loose `Record<string,unknown>` returns
* + `as` casts (e.g. `(result?.data ?? {}) as { title?: string }`).
* Deriving the exact type would make those casts non-overlapping ("may be a
* mistake") and break the build, and `Partial<DocmostClientLike>` test stubs
* would have to satisfy the full concrete surface.
* To do it safely later (incrementally): (a) turn on `declaration: true` in
* packages/mcp/tsconfig.json + add a `types` export condition and commit the
* emitted `.d.ts`; (b) `import type { DocmostClient } from '@docmost/mcp'` here
* and replace this interface with a `Pick<DocmostClient, ...>` of the consumed
* methods; (c) audit every `as` cast in ai-chat-tools.service.ts against the now
* concrete return types (double-cast through `unknown` only where genuinely
* needed); (d) keep the runtime guard test as a belt-and-braces check. Until
* then the guard test above is the cheap, behaviour-neutral protection.
* The exact set of `DocmostClient` methods the per-user in-app tool adapter
* consumes. This is the AUTHORITATIVE list of the client surface the server
* depends on; the adapter calls these methods POSITIONALLY, so this set is what
* the derived type below type-checks against the real class (issue #446).
*/
export interface DocmostClientLike {
type DocmostClientMethod =
// --- read ---
search(
query: string,
spaceId?: string,
limit?: number,
): Promise<{ items: unknown[]; success: boolean }>;
getPage(
pageId: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
// Light raw page info (`/pages/info`): title + slugId + ProseMirror content,
// WITHOUT the Markdown render / subpage expansion getPage does. Used by the
// comment-signal probe to read just the page title on a hit.
getPageRaw(pageId: string): Promise<Record<string, unknown> | null>;
getWorkspace(): Promise<{ data: Record<string, unknown>; success: boolean }>;
getSpaces(): Promise<unknown[]>;
listPages(
spaceId?: string,
limit?: number,
tree?: boolean,
): Promise<unknown[]>;
listSidebarPages(spaceId: string, pageId?: string): Promise<unknown[]>;
getOutline(pageId: string): Promise<Record<string, unknown>>;
getPageJson(pageId: string): Promise<Record<string, unknown>>;
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
searchInPage(
pageId: string,
query: string,
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
): Promise<Record<string, unknown>>;
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
// false) hides resolved threads wholesale; pass true for the full feed.
listComments(
pageId: string,
includeResolved?: boolean,
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
getComment(
commentId: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
checkNewComments(
spaceId: string,
since: string,
parentPageId?: string,
): Promise<unknown>;
listShares(): Promise<unknown[]>;
listPageHistory(
pageId: string,
cursor?: string,
): Promise<{ items: unknown[]; nextCursor: string | null }>;
getPageHistory(historyId: string): Promise<Record<string, unknown>>;
diffPageVersions(
pageId: string,
from?: string,
to?: string,
): Promise<Record<string, unknown>>;
exportPageMarkdown(pageId: string): Promise<string>;
| 'search'
| 'getPage'
| 'getPageRaw'
| 'getWorkspace'
| 'getSpaces'
| 'listPages'
| 'getTree'
| 'getPageContext'
| 'listSidebarPages'
| 'getOutline'
| 'getPageJson'
| 'getNode'
| 'searchInPage'
| 'getTable'
| 'listComments'
| 'getComment'
| 'checkNewComments'
| 'listShares'
| 'listPageHistory'
| 'getPageHistory'
| 'diffPageVersions'
| 'exportPageMarkdown'
// --- write (page) ---
createPage(
title: string,
content: string,
spaceId: string,
parentPageId?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
// Markdown content update via the collab path (carries provenance via the
// collab-token provider). Optionally also updates the title.
updatePage(
pageId: string,
content: string,
title?: string,
): Promise<Record<string, unknown>>;
// Title-only rename via REST.
renamePage(
pageId: string,
title: string,
): Promise<Record<string, unknown>>;
// Move via REST. parentPageId null => move to space root.
movePage(
pageId: string,
parentPageId: string | null,
position?: string,
): Promise<unknown>;
// SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent.
deletePage(pageId: string): Promise<unknown>;
editPageText(
pageId: string,
edits: Array<{ find: string; replace: string; replaceAll?: boolean }>,
): Promise<Record<string, unknown>>;
patchNode(
pageId: string,
nodeId: string,
node: unknown,
): Promise<Record<string, unknown>>;
insertNode(
pageId: string,
node: unknown,
opts: {
position: 'before' | 'after' | 'append';
anchorNodeId?: string;
anchorText?: string;
},
): Promise<Record<string, unknown>>;
deleteNode(
pageId: string,
nodeId: string,
): Promise<Record<string, unknown>>;
updatePageJson(
pageId: string,
doc?: unknown,
title?: string,
): Promise<Record<string, unknown>>;
// Attach an author-inline footnote after the first occurrence of anchorText;
// numbering + the footnotes list are derived server-side.
insertFootnote(
pageId: string,
anchorText: string,
text: string,
): Promise<Record<string, unknown>>;
// Download a web image and insert it into the page (append, or replace/after a
// text anchor). `url` is the image http(s) URL.
insertImage(
pageId: string,
url: string,
opts?: {
align?: 'left' | 'center' | 'right';
alt?: string;
replaceText?: string;
afterText?: string;
},
): Promise<Record<string, unknown>>;
// Swap an existing image (by its attachmentId) for a new one fetched from a web
// URL, repointing every reference in the live document.
replaceImage(
pageId: string,
oldAttachmentId: string,
url: string,
opts?: { align?: 'left' | 'center' | 'right'; alt?: string },
): Promise<Record<string, unknown>>;
// --- draw.io diagrams (#423, stage 1) ---
// Read a diagram as decoded mxGraph XML (default) or the raw .drawio.svg.
// meta.hash is the optimistic-lock key drawioUpdate expects as baseHash.
drawioGet(
pageId: string,
node: string,
format?: 'xml' | 'svg',
): Promise<Record<string, unknown>>;
// Lint mxGraph XML, build the .drawio.svg attachment and insert a drawio node.
drawioCreate(
pageId: string,
where: {
position: 'before' | 'after' | 'append';
anchorNodeId?: string;
anchorText?: string;
},
xml: string,
title?: string,
): Promise<Record<string, unknown>>;
// Optimistic-locked full replacement of a diagram (baseHash from drawioGet).
drawioUpdate(
pageId: string,
node: string,
xml: string,
baseHash: string,
): Promise<Record<string, unknown>>;
tableInsertRow(
pageId: string,
tableRef: string,
cells: string[],
index?: number,
): Promise<Record<string, unknown>>;
tableDeleteRow(
pageId: string,
tableRef: string,
index: number,
): Promise<Record<string, unknown>>;
tableUpdateCell(
pageId: string,
tableRef: string,
row: number,
col: number,
text: string,
): Promise<Record<string, unknown>>;
copyPageContent(
sourcePageId: string,
targetPageId: string,
): Promise<Record<string, unknown>>;
importPageMarkdown(
pageId: string,
fullMarkdown: string,
): Promise<Record<string, unknown>>;
sharePage(
pageId: string,
searchIndexing?: boolean,
): Promise<Record<string, unknown>>;
unsharePage(pageId: string): Promise<Record<string, unknown>>;
restorePageVersion(historyId: string): Promise<Record<string, unknown>>;
// The opts type declares deleteComments? to match the real client signature,
// but the agent tool NEVER sets it (comment deletion stays unreachable).
transformPage(
pageId: string,
transformJs: string,
opts?: { dryRun?: boolean; deleteComments?: boolean },
): Promise<Record<string, unknown>>;
| 'createPage'
| 'updatePage'
| 'renamePage'
| 'movePage'
| 'deletePage'
| 'editPageText'
| 'patchNode'
| 'insertNode'
| 'deleteNode'
| 'updatePageJson'
| 'tableInsertRow'
| 'tableDeleteRow'
| 'tableUpdateCell'
| 'copyPageContent'
| 'importPageMarkdown'
| 'sharePage'
| 'unsharePage'
| 'restorePageVersion'
| 'transformPage'
| 'stashPage'
// --- write (image / footnote), in-app since #410 ---
| 'insertImage'
| 'replaceImage'
| 'insertFootnote'
// --- draw.io diagrams (#423 stage 1, #424 stage 2) ---
// DERIVED from the real DocmostClient (#446): drawioCreate/drawioUpdate carry
// the optional layout:"elk" 5th arg in the real signature, so the layout parity
// (#440) is inherited automatically — no hand-written mirror to keep in sync.
| 'drawioGet'
| 'drawioCreate'
| 'drawioUpdate'
// --- draw.io high-level semantic tools (#425 stage 3) ---
| 'drawioEditCells'
| 'drawioFromGraph'
| 'drawioFromMermaid'
// --- write (comment) ---
createComment(
pageId: string,
content: string,
type?: 'page' | 'inline',
selection?: string,
parentCommentId?: string,
suggestedText?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>;
resolveComment(
commentId: string,
resolved: boolean,
): Promise<Record<string, unknown>>;
// Serialize a page + mirror its internal images into the blob sandbox; returns
// ONLY a short anonymous URL (the body never enters the model context).
stashPage(pageId: string): Promise<{
uri: string;
sha256: string;
size: number;
images: { mirrored: number; failed: number };
}>;
}
| 'createComment'
| 'resolveComment';
/**
* The client surface the per-user tool adapter consumes, DERIVED from the real
* `DocmostClient` type in `@docmost/mcp` (issue #446, restored #294 debt). This
* replaces the former hand-mirror of ~45 method signatures.
*
* `import type` (above) is fully ERASED at compile time, so nothing is actually
* imported from the ESM-only package at runtime the server still loads the
* class through the dynamic `import()` trick in `loadDocmostMcp` below; this is
* purely a compile-time type. Deriving via `Pick` means a parameter reorder or a
* type change to any of these methods in `client.ts` now becomes a SERVER
* COMPILE ERROR at the positional call sites in ai-chat-tools.service.ts,
* instead of a silent runtime "wrong argument" failure inside an agent tool.
*
* This made the old name-only drift-guard test
* (packages/mcp/test/unit/client-host-contract.test.mjs) redundant tsc now
* enforces both names AND signatures so that test was removed.
*/
export type DocmostClientLike = Pick<DocmostClient, DocmostClientMethod>;
export type DocmostClientConfig = {
apiUrl: string;
@@ -280,32 +118,7 @@ export type DocmostClientConfig = {
};
export interface DocmostClientCtor {
new (config: DocmostClientConfig): DocmostClientLike;
}
/**
* Local hand-mirror of the `SharedToolSpec` shape exported from
* `@docmost/mcp` (packages/mcp/src/tool-specs.ts). Same approach as
* `DocmostClientLike`: we do not import the ESM package's types directly across
* the CJS/ESM boundary. The registry itself has no runtime deps, but keeping the
* type local avoids coupling the server build to the package's type surface.
*
* `buildShape` is intentionally zod-agnostic: it returns a plain ZodRawShape
* built with whatever zod namespace the caller passes (the server passes its own
* zod v4; the MCP package passes its zod v3). See the registry module comment.
*/
export interface SharedToolSpec {
mcpName: string;
inAppKey: string;
description: string;
// Deferred-tool metadata (#332). Optional in this mirror so an older/stale
// @docmost/mcp build (pre-#332) still type-checks; the in-app catalog builder
// reads them defensively. The external /mcp server ignores both fields.
tier?: 'core' | 'deferred';
catalogLine?: string;
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
// its own zod (v4) and the MCP package its own (v3) into the same builder.
buildShape?: (z: any) => Record<string, unknown>;
new (config: DocmostClientConfig): DocmostClient;
}
/**
@@ -337,6 +150,19 @@ export type CommentSignalTrackerFactory = (options: {
debounceMs?: number;
}) => CommentSignalTrackerLike;
// Pure, no-network draw.io helpers (#424). These are plain functions on the
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
// directly to wire drawioShapes / drawioGuide, mirroring the MCP server.
export type SearchShapesFn = (
query: string,
opts?: { category?: string; limit?: number },
) => Array<Record<string, unknown>>;
export type GetGuideSectionFn = (section?: string) => {
section: string;
content: string;
sections: string[];
};
interface DocmostMcpModule {
DocmostClient: DocmostClientCtor;
SHARED_TOOL_SPECS: Record<string, SharedToolSpec>;
@@ -344,6 +170,96 @@ interface DocmostMcpModule {
// loader in unit tests. The in-app layer treats an absent factory as "signal
// disabled" — a pure no-op that leaves tool results byte-identical.
createCommentSignalTracker?: CommentSignalTrackerFactory;
// Optional (#447): a deterministic hash of the tool-specs registry content,
// generated into build/ by the package's build. Absent on a pre-#447 build (or
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it
// is missing, so an older build never wrongly fails startup.
REGISTRY_STAMP?: string;
// Pure, no-network draw.io helpers (#424) backing drawioShapes / drawioGuide.
// Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the
// shared contract but carry no execute — their catalog loader uses import.meta
// and can't be value-imported into the zod-agnostic tool-specs.ts), so the
// in-app service wires them INLINE off these helpers, mirroring the standalone
// MCP host. Exposed off the loaded module so the service and its test mocks can
// reach them.
searchShapes: SearchShapesFn;
getGuideSection: GetGuideSectionFn;
}
/**
* Recompute the REGISTRY_STAMP (#447) from the @docmost/mcp source tree, if it is
* present. Returns the stamp string, or `null` when the source is absent (a prod
* image ships only build/, no src/). MUST stay byte-for-byte identical to
* packages/mcp/scripts/gen-registry-stamp.mjs's `computeRegistryStamp` so the
* build-time and src-time hashes agree: same file set (every src/**\/*.ts except
* *.generated.ts), same POSIX-relative sort, same per-file normalization (CRLF ->
* LF, strip a single trailing newline) with the same path+content framing, same
* sha256. Hashing the WHOLE src tree (not just tool-specs.ts) is #486: an edit to
* client.ts / a client/* module / comment-signal / drawio-* without a rebuild
* must also be caught, otherwise build/ silently serves the old code.
*
* DEV vs PROD detection is by FILE EXISTENCE, not NODE_ENV: we resolve the
* package's own directory from `require.resolve('@docmost/mcp')` (which points at
* build/index.js) and look for ../src next to it. In a dev/test worktree that
* directory exists; in a prod image (build/ only, src/ stripped) it does not, so
* this returns null and the caller skips the check. Any error (ENOENT, a bad
* resolve) is swallowed to null the stale-check must NEVER break startup.
*
* Exported for unit testing (docmost-client.loader.spec.ts): the export keyword
* is behaviourally a no-op the module-internal caller `loadDocmostMcp` is
* unaffected. The test drives the null (no-src) path and asserts this
* enumerate+normalize+sha256 stays identical to the codegen's
* `computeRegistryStamp`.
*/
export function computeSrcRegistryStamp(packageEntry: string): string | null {
try {
// packageEntry is <pkg>/build/index.js; the source lives at <pkg>/src/.
const srcDir = join(dirname(dirname(packageEntry)), 'src');
if (!existsSync(srcDir)) return null; // prod: no src tree -> skip.
// Enumerate every src/**\/*.ts except the codegen's own *.generated.ts
// output (including it would be a fixed-point cycle). Sort by POSIX-relative
// path so ordering is platform-independent, then fold each file's relative
// path + normalized content into one hash — identical to the codegen.
const files = collectStampFiles(srcDir)
.map((abs) => ({
rel: relative(srcDir, abs).split(sep).join('/'),
abs,
}))
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
const hash = createHash('sha256');
for (const { rel, abs } of files) {
const normalized = readFileSync(abs, 'utf8')
.replace(/\r\n/g, '\n')
.replace(/\n$/, '');
hash.update(rel, 'utf8');
hash.update('\0', 'utf8');
hash.update(normalized, 'utf8');
hash.update('\0', 'utf8');
}
return hash.digest('hex');
} catch {
// Never let a resolution/read hiccup break server startup — treat as "no
// src available" and skip the check (identical to the prod no-op path).
return null;
}
}
/**
* Recursively enumerate every `*.ts` under `dir`, EXCLUDING `*.generated.ts`.
* Mirror of the codegen's `collectStampFiles` (packages/mcp/scripts/
* gen-registry-stamp.mjs) keep the two walk/filter rules identical.
*/
function collectStampFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectStampFiles(full));
} else if (entry.endsWith('.ts') && !entry.endsWith('.generated.ts')) {
out.push(full);
}
}
return out;
}
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
@@ -368,6 +284,8 @@ export async function loadDocmostMcp(): Promise<{
DocmostClient: DocmostClientCtor;
sharedToolSpecs: Record<string, SharedToolSpec>;
createCommentSignalTracker?: CommentSignalTrackerFactory;
searchShapes: SearchShapesFn;
getGuideSection: GetGuideSectionFn;
}> {
if (!modulePromise) {
modulePromise = (async () => {
@@ -375,6 +293,23 @@ export async function loadDocmostMcp(): Promise<{
const mod = (await esmImport(
pathToFileURL(entry).href,
)) as DocmostMcpModule;
// #447 stale-build guard (dev/test only). The server loads the COMPILED
// build/ of @docmost/mcp, but the parity/tier guard tests read src/. If a
// tool spec is edited in src without rebuilding the package, build/ and src/
// silently diverge and the running server serves the OLD tools. Here we
// recompute the stamp from src/tool-specs.ts and compare it to the stamp
// baked into build/. In PROD the src tree is absent (image ships build/
// only), so computeSrcRegistryStamp returns null and this is a pure no-op.
const srcStamp = computeSrcRegistryStamp(entry);
if (
srcStamp !== null &&
typeof mod.REGISTRY_STAMP === 'string' &&
srcStamp !== mod.REGISTRY_STAMP
) {
throw new Error(
'@docmost/mcp build is stale (tool-specs changed since last build) — run: pnpm --filter @docmost/mcp build',
);
}
return mod;
})().catch((err) => {
// Do not cache a rejected import — allow the next call to retry.
@@ -396,5 +331,8 @@ export async function loadDocmostMcp(): Promise<{
// Optional: forwarded when present so the in-app layer can build the passive
// comment signal (#417); undefined on a stale build => signal disabled.
createCommentSignalTracker: mod.createCommentSignalTracker,
// Pure no-network draw.io helpers (#424); not client methods.
searchShapes: mod.searchShapes,
getGuideSection: mod.getGuideSection,
};
}
@@ -7,6 +7,22 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { jsonToMarkdown } from '../../../collaboration/collaboration.util';
import { modelFriendlyInput } from './model-friendly-input';
/**
* A tool error whose message is DELIBERATELY safe to expose to an anonymous
* share reader (and to the model, for self-correction). Every OTHER thrown error
* is treated as internal and replaced with a generic string by `wrapToolErrors`,
* so a raw exception message an internal page title, a DB/stack fragment, a
* driver detail never rides the public UI stream (#394).
*/
export class ShareToolError extends Error {}
// The only two classified strings an anonymous reader may ever see from a tool
// failure. The specific one keeps the model's self-correction useful ("try a
// different page"); the generic one reveals nothing about the internal fault.
const SHARE_TOOL_ERROR_NOT_AVAILABLE =
'The requested page is not available in this share.';
const SHARE_TOOL_ERROR_GENERIC = 'The tool could not complete the request.';
/**
* Isolated, READ-ONLY toolset for the ANONYMOUS public-share assistant.
*
@@ -44,7 +60,7 @@ export class PublicShareChatToolsService {
* are NO write tools, NO comments/history, NO cross-space or external tools.
*/
forShare(shareId: string, workspaceId: string): Record<string, Tool> {
return {
return this.wrapToolErrors({
searchSharePages: tool({
description:
'Search the pages of THIS published documentation share for a ' +
@@ -96,7 +112,7 @@ export class PublicShareChatToolsService {
execute: async ({ pageId }) => {
const id = (pageId ?? '').trim();
if (!id) {
throw new Error('A pageId is required.');
throw new ShareToolError('A pageId is required.');
}
// Resolve via the SINGLE canonical share-access boundary: confirms the
// page resolves to THIS share (recursive CTE up the tree, honouring
@@ -112,7 +128,7 @@ export class PublicShareChatToolsService {
workspaceId,
);
if (!resolved) {
throw new Error('That page is not part of this published share.');
throw new ShareToolError(SHARE_TOOL_ERROR_NOT_AVAILABLE);
}
const { page } = resolved;
@@ -193,6 +209,57 @@ export class PublicShareChatToolsService {
}
},
}),
};
});
}
/**
* Wrap every tool's `execute` so a THROWN error is sanitized in ONE place
* closing the byte leak, the render, and the model context at once (#394).
*
* The AI SDK surfaces a tool-execution throw as an atomic `tool-output-error`
* frame on the v6 UI stream whose `errorText` is the thrown message; on the
* public share that frame goes straight to an anonymous reader. Unwrapped, a
* raw exception (an internal page title, a DB/stack fragment, a driver detail)
* would ride that frame verbatim. Here we catch it, LOG the full detail
* server-side only, and re-throw a CLASSIFIED, safe error: the tool's own
* intentional ShareToolError messages pass through (they keep the model's
* self-correction useful), everything else collapses to a generic string.
*/
private wrapToolErrors(
tools: Record<string, Tool>,
): Record<string, Tool> {
const wrapped: Record<string, Tool> = {};
for (const [name, t] of Object.entries(tools)) {
const original = t.execute;
if (typeof original !== 'function') {
wrapped[name] = t;
continue;
}
wrapped[name] = {
...t,
execute: async (args: unknown, options: unknown) => {
try {
return await (
original as (a: unknown, o: unknown) => Promise<unknown>
)(args, options);
} catch (err) {
const safe =
err instanceof ShareToolError
? err.message
: SHARE_TOOL_ERROR_GENERIC;
// Full detail to the server log ONLY — never to the anon.
this.logger.warn(
`Public share tool "${name}" failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
// This safe string is ALL that rides the tool-output-error frame,
// becomes model context, and could be rendered — one choke point.
throw new ShareToolError(safe);
}
},
} as Tool;
}
return wrapped;
}
}
@@ -17,8 +17,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
* This test fails the build if a spec is added to the registry but never wired
* in-app, if an `inAppKey` is renamed without updating the service, if the
* description drifts between the registry and the exposed tool, if the
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the
* exposed tool's input-schema keys diverge from the spec's `buildShape`.
* `mcpName === inAppKey` convention is broken (issue #412 unified the external
* MCP tool name with the in-app key both are the same camelCase identifier),
* or if the exposed tool's input-schema keys diverge from the spec's
* `buildShape`.
*
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
* and the ESM loader is mocked so `forUser()` never dynamically imports the
@@ -45,6 +47,16 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
string,
loader.SharedToolSpec
>,
// Pure no-network draw.io helpers (#424). The contract test never executes
// a tool body, so type-correct stubs suffice (the real functions can't be
// imported here — drawio-shapes.ts uses import.meta, incompatible with the
// CommonJS jest transform).
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: 'index',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
const service = new AiChatToolsService(
tokenServiceStub as never,
@@ -64,13 +76,9 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
afterAll(() => jest.restoreAllMocks());
// camelCase -> snake_case, matching the registry's mcpName convention.
const toSnake = (s: string) =>
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
// above otherwise narrows to a union where some members lack buildShape.
const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array<
const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array<
[string, loader.SharedToolSpec]
>;
@@ -86,8 +94,8 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
expect(spec.inAppKey).toBe(registryKey);
});
it('mcpName is the snake_case form of inAppKey', () => {
expect(spec.mcpName).toBe(toSnake(spec.inAppKey));
it('mcpName equals inAppKey (unified camelCase name, #412)', () => {
expect(spec.mcpName).toBe(spec.inAppKey);
});
it('is exposed in-app under its inAppKey', () => {
@@ -27,16 +27,18 @@ import type { DocmostClientLike } from './docmost-client.loader';
*/
describe('tool tier metadata (#332)', () => {
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => {
expect(CORE_TOOL_KEYS).toHaveLength(15);
it('core set is the documented 13 + searchInPage + insertFootnote + getTree + getPageContext (17, #443)', () => {
expect(CORE_TOOL_KEYS).toHaveLength(17);
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
expect(CORE_TOOL_SET.has('getTree')).toBe(true); // #443, promoted to core
expect(CORE_TOOL_SET.has('getPageContext')).toBe(true); // #443, promoted to core
// loadTools is a meta-tool, not a normal core key.
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
});
it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
// insert_footnote is core (symmetric with editPageText); the image tools stay
// insertFootnote is core (symmetric with editPageText); the image tools stay
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
@@ -123,7 +125,14 @@ describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', ()
DocmostClient: function () {
return {} as DocmostClientLike;
} as unknown as loader.DocmostClientCtor,
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
// Pure no-network draw.io helpers (#424); tool bodies are never executed here.
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
getGuideSection: (() => ({
section: 'index',
content: '',
sections: [],
})) as unknown as loader.GetGuideSectionFn,
});
const service = new AiChatToolsService(
{
@@ -232,6 +241,16 @@ describe('applyLoadTools (#332)', () => {
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
});
it('loadTools description tells the model CORE tools are always active (#444)', () => {
expect(LOAD_TOOLS_DESCRIPTION).toContain(
'Tools NOT listed in the catalog are CORE and ALWAYS active',
);
expect(LOAD_TOOLS_DESCRIPTION).toContain('NEVER via loadTools');
// Names it out explicitly so the model doesn't loadTools a core tool.
expect(LOAD_TOOLS_DESCRIPTION).toContain('createComment');
expect(LOAD_TOOLS_DESCRIPTION).toContain('searchInPage');
});
});
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
@@ -39,12 +39,14 @@ export interface ToolCatalogEntry {
/**
* CORE (always-active) in-app tool keys 13 frequent/tiny tools + `searchInPage`
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent
* for the editorial roles this feature targets; `insertFootnote` is core so the
* footnote tool is NOT hidden while its natural sibling `editPageText` is always
* active (that asymmetry is exactly what pushed the agent to write literal
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added
* to activeTools separately).
* (#330) + `insertFootnote` (#410) + `getTree`/`getPageContext` (#443).
* `searchInPage` is core because it is frequent for the editorial roles this
* feature targets; `insertFootnote` is core so the footnote tool is NOT hidden
* while its natural sibling `editPageText` is always active (that asymmetry is
* exactly what pushed the agent to write literal `^[...]`). `getTree` and
* `getPageContext` are the single-call navigation/lookup tools core so the
* agent never has to loadTools just to orient itself. `loadTools` is active too
* but is not a normal tool key (it is added to activeTools separately).
*/
export const CORE_TOOL_KEYS = [
'searchPages',
@@ -60,12 +62,17 @@ export const CORE_TOOL_KEYS = [
'listComments',
'resolveComment',
'editPageText',
// #330 search_in_page — frequent for editorial sweeps; core despite predating
// #330 searchInPage — frequent for editorial sweeps; core despite predating
// the issue's tier list.
'searchInPage',
// #410 insert_footnote — core so pinpoint citations to already-written text
// #410 insertFootnote — core so pinpoint citations to already-written text
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
'insertFootnote',
// #443 getTree + getPageContext — cheap single-call navigation/lookup tools
// (the core listPages even points to getTree); core so the agent never has
// to loadTools just to orient itself.
'getTree',
'getPageContext',
] as const;
/** O(1) membership test for the core tier. */
@@ -84,7 +91,10 @@ export const LOAD_TOOLS_DESCRIPTION =
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
'needs them. Unknown names are rejected with the list of valid ones.';
'needs them. Unknown names are rejected with the list of valid ones.\n' +
'Tools NOT listed in the catalog are CORE and ALWAYS active — call them directly,\n' +
'NEVER via loadTools (e.g. createComment, listComments, resolveComment,\n' +
'editPageText, searchInPage).';
/**
* Tier + catalogLine for the INLINE ai-chat tools those defined per-layer in
@@ -121,12 +131,9 @@ export const INLINE_TOOL_TIERS: Record<
// --- deferred inline ---
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
// carry their own deferred tier + catalogLine there.
updatePageContent: {
tier: 'deferred',
catalogLine:
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
},
// carry their own deferred tier + catalogLine there. updatePageContent moved
// there too as updatePageMarkdown (#411) — a shared registry spec now, so it
// is no longer an inline tier entry.
listSidebarPages: {
tier: 'deferred',
catalogLine:
@@ -138,7 +145,7 @@ export const INLINE_TOOL_TIERS: Record<
},
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
// catalogLine there. getTable stays inline (its MCP name tableGet breaks the
// snake_case(inAppKey) convention, so it has no shared spec).
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
// it carries its own deferred tier + catalogLine there.
@@ -150,7 +157,7 @@ export const INLINE_TOOL_TIERS: Record<
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
// its own deferred tier + catalogLine there. transformPage stays inline (its
// schema deliberately diverges — it omits the deleteComments field the MCP
// docmost_transform exposes, a comment-deletion guardrail).
// docmostTransform exposes, a comment-deletion guardrail).
transformPage: {
tier: 'deferred',
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
@@ -120,3 +120,102 @@ describe('JwtStrategy — provenance derivation', () => {
expect(req.raw.actor).toBeUndefined();
});
});
/**
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486).
*
* The access-token path stamped provenance; the API-key path returned early
* WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker.
* The API-key payload carries no signed claim, so provenance is resolved from the
* SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent',
* otherwise 'user'; aiChatId is always null (an API key has no ai_chats row).
*
* The enterprise ApiKeyService is not bundled in the OSS build, so the strategy
* loads it through an overridable `resolveApiKeyService` seam that we stub here.
*/
describe('JwtStrategy — API-key provenance derivation (#486)', () => {
function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise<any>) {
const userRepo: any = { findById: jest.fn() };
const workspaceRepo: any = { findById: jest.fn() };
const userSessionRepo: any = { findActiveById: jest.fn() };
const sessionActivityService: any = { trackActivity: jest.fn() };
const environmentService: any = { getAppSecret: () => 'test-secret' };
const moduleRef: any = {};
const strategy = new JwtStrategy(
userRepo,
workspaceRepo,
userSessionRepo,
sessionActivityService,
environmentService,
moduleRef,
);
// Stub the EE ApiKeyService seam (the real module is not in the OSS build).
const validateApiKey = jest.fn(validateApiKeyImpl);
jest
.spyOn(strategy as any, 'resolveApiKeyService')
.mockReturnValue({ validateApiKey });
return { strategy, validateApiKey };
}
const makeReq = () => ({ raw: {} as Record<string, any> });
const apiKeyPayload = () => ({
sub: 'svc-1',
workspaceId: 'ws-1',
apiKeyId: 'key-1',
type: JwtType.API_KEY,
});
it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => {
const validated = {
user: { id: 'svc-1', isAgent: true },
workspace: { id: 'ws-1' },
};
const { strategy, validateApiKey } = makeApiKeyStrategy(
async () => validated,
);
const req = makeReq();
const result = await strategy.validate(req, apiKeyPayload() as any);
expect(validateApiKey).toHaveBeenCalledTimes(1);
expect(req.raw.actor).toBe('agent');
// API keys carry no internal ai_chats row -> null.
expect(req.raw.aiChatId).toBeNull();
// The validated auth object is returned unchanged (req.user shape preserved).
expect(result).toBe(validated);
});
it("stamps actor='user' for an ordinary (non-agent) API key", async () => {
const { strategy } = makeApiKeyStrategy(async () => ({
user: { id: 'u-1', isAgent: false },
workspace: { id: 'ws-1' },
}));
const req = makeReq();
await strategy.validate(req, apiKeyPayload() as any);
expect(req.raw.actor).toBe('user');
expect(req.raw.aiChatId).toBeNull();
});
it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => {
const userRepo: any = { findById: jest.fn() };
const strategy = new JwtStrategy(
userRepo,
{ findById: jest.fn() } as any,
{ findActiveById: jest.fn() } as any,
{ trackActivity: jest.fn() } as any,
{ getAppSecret: () => 'test-secret' } as any,
{} as any,
);
// EE not bundled: the seam returns null.
jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null);
const req = makeReq();
await expect(
strategy.validate(req, apiKeyPayload() as any),
).rejects.toThrow(UnauthorizedException);
expect(req.raw.actor).toBeUndefined();
});
});
@@ -102,28 +102,49 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
let ApiKeyModule: any;
let isApiKeyModuleReady = false;
const apiKeyService = this.resolveApiKeyService();
if (!apiKeyService) {
throw new UnauthorizedException('Enterprise API Key module missing');
}
const result = await apiKeyService.validateApiKey(payload);
// Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the
// access-token path above, it CANNOT be resolved before this point: the
// API-key payload carries no signed actor/aiChatId claim, and the user (with
// its isAgent flag) is unknown until the key is validated. Claim semantics for
// API keys: an is_agent API key (an agent service account) stamps 'agent' on
// every REST write; an ordinary API key resolves to 'user'. An API key has no
// internal ai_chats row, so aiChatId is always null. Derived from the
// SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable
// — mirroring the access-token path. Passing `null` for the claim means the
// actor is decided solely by user.isAgent.
const provenance = resolveProvenance((result as any)?.user, null);
req.raw.actor = provenance.actor;
req.raw.aiChatId = provenance.aiChatId;
return result;
}
/**
* Resolve the enterprise ApiKeyService, or `null` when the EE module is not
* bundled in this build (community build). Extracted as an overridable seam so
* the API-key provenance stamping can be unit-tested without the EE package
* present (docmost is OSS + a separate EE bundle; `require` of the EE path
* throws here). Any load/resolve error is treated as "module missing".
*/
protected resolveApiKeyService(): {
validateApiKey: (payload: JwtApiKeyPayload) => Promise<unknown>;
} | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
isApiKeyModuleReady = true;
const ApiKeyModule = require('./../../../ee/api-key/api-key.service');
return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
} catch (err) {
this.logger.debug(
'API Key module requested but enterprise module not bundled in this build',
);
isApiKeyModuleReady = false;
return null;
}
if (isApiKeyModuleReady) {
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
strict: false,
});
return ApiKeyService.validateApiKey(payload);
}
throw new UnauthorizedException('Enterprise API Key module missing');
}
}
@@ -12,3 +12,22 @@ export class SearchResponseDto {
updatedAt: Date;
space: Partial<Space>;
}
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
// Additive to the FTS response: carries the location (`path`), a windowed
// `snippet` around the first match and a per-response sort `score`. The MCP
// layer maps `id → pageId`; `slugId` is never exposed.
export class SearchLookupResponseDto {
id: string;
slugId: string;
title: string;
parentPageId: string | null;
// Ancestor titles from the space root down to the direct parent; [] for a
// root page.
path: string[];
// ~300–500 chars around the first match (or a leading text window / extended
// ts_headline fallback).
snippet: string;
// 0..1 float, meaningful ONLY for sorting within one response.
score: number;
}
@@ -30,6 +30,31 @@ export class SearchDTO {
@IsOptional()
@IsNumber()
offset?: number;
// --- Opt-in agent-lookup mode (#443). ------------------------------------
// These fields are ADDITIVE and default-off: a web client that sends none of
// them gets byte-identical FTS behaviour and result shape. They are only read
// by the substring/path/snippet code path in SearchService.searchPage.
//
// NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO
// with `whitelist: true`, so an older server silently strips these unknown
// fields and the request degrades gracefully to the plain FTS behaviour.
// Enables the hybrid substring branch (title + text_content LIKE) merged with
// the existing FTS branch, plus tiered ranking, path and windowed snippet.
@IsOptional()
@IsBoolean()
substring?: boolean;
// Restrict the search to a page and all of its descendants (inclusive).
@IsOptional()
@IsString()
parentPageId?: string;
// Match titles only; do not scan text_content.
@IsOptional()
@IsBoolean()
titleOnly?: boolean;
}
export class SearchShareDTO extends SearchDTO {
@@ -60,6 +60,12 @@ export class SearchController {
}
}
// #443 graceful degradation: on EE/Typesense instances the request routes to
// the Typesense backend, which does NOT implement the opt-in agent-lookup
// mode. The `substring`/`parentPageId`/`titleOnly` fields are silently ignored
// and the response carries no `path`/`snippet`/`score` and no substring/tier
// ranking — it degrades to plain Typesense FTS. The native lookup mode below
// is Postgres-search-driver only.
if (this.environmentService.getSearchDriver() === 'typesense') {
return this.searchTypesense(searchDto, {
userId: user.id,
@@ -0,0 +1,95 @@
import {
computeLookupScore,
escapeLikePattern,
SearchLookupTier,
} from './search.service';
/**
* Pure-function coverage for the #443 agent-lookup helpers:
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
* everything);
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
* always outranks a weaker one regardless of the in-tier secondary signal.
*
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
* covered by the integration spec against the real schema.
*/
describe('escapeLikePattern', () => {
it('escapes the LIKE metacharacters % _ and \\', () => {
expect(escapeLikePattern('%')).toBe('\\%');
expect(escapeLikePattern('_')).toBe('\\_');
expect(escapeLikePattern('\\')).toBe('\\\\');
});
it('escapes the backslash FIRST so it does not double-escape %/_', () => {
// Input `\%` must become `\\` + `\%` = `\\\%`, not `\\%`.
expect(escapeLikePattern('\\%')).toBe('\\\\\\%');
});
it('leaves ordinary technical chars (. - / digits) untouched', () => {
expect(escapeLikePattern('backup-srv.local')).toBe('backup-srv.local');
expect(escapeLikePattern('10.0.12')).toBe('10.0.12');
expect(escapeLikePattern('WB-MGE-30D86B')).toBe('WB-MGE-30D86B');
expect(escapeLikePattern('a/b')).toBe('a/b');
});
it('escapes only the metacharacters in a mixed string', () => {
expect(escapeLikePattern('50%_off.zip')).toBe('50\\%\\_off.zip');
});
it('is null/undefined-safe', () => {
expect(escapeLikePattern(undefined as any)).toBe('');
expect(escapeLikePattern(null as any)).toBe('');
});
});
describe('computeLookupScore', () => {
it('keeps every score within (0, 1]', () => {
for (const tier of [
SearchLookupTier.TITLE_EXACT,
SearchLookupTier.TITLE_SUBSTRING,
SearchLookupTier.TEXT,
]) {
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
const s = computeLookupScore({ tier, secondary });
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThanOrEqual(1);
}
}
});
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
// Weak tier with a huge secondary must still lose to a strong tier with a
// tiny secondary — tiers dominate.
const strongLowSecondary = computeLookupScore({
tier: SearchLookupTier.TITLE_EXACT,
secondary: 0,
});
const weakHighSecondary = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 1e9,
});
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
});
it('within a tier a larger secondary sorts higher', () => {
const lo = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 0.1,
});
const hi = computeLookupScore({
tier: SearchLookupTier.TEXT,
secondary: 5,
});
expect(hi).toBeGreaterThan(lo);
});
it('treats a negative/absent secondary as 0', () => {
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
expect(
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
).toBe(zero);
});
});
+401 -2
View File
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { SearchResponseDto } from './dto/search-response.dto';
import {
SearchLookupResponseDto,
SearchResponseDto,
} from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
@@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string {
return tsquery(cleaned + '*');
}
// Escape the LIKE metacharacters (`%`, `_`, `\`) in a raw user query so every
// character — including `.`, `-`, `_`, `%`, `/` — is matched LITERALLY by a
// `col LIKE '%' || q || '%'` predicate. Without this, a query of `%` or `_`
// would match every row (see the #443 acceptance table). The backslash is the
// escape char (Postgres LIKE default), so it must be escaped first.
export function escapeLikePattern(raw: string): string {
return (raw ?? '')
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
}
// Ranking tiers for the agent-lookup mode (#443), highest first. A hit's tier
// is the strongest way it matched; ties inside a tier break on a secondary
// signal (FTS rank, or first-match position). The numeric `score` returned to
// the caller is derived from (tier, secondary) and is meaningful ONLY for
// ordering within a single response.
export enum SearchLookupTier {
// Title equals the query, case-insensitively.
TITLE_EXACT = 3,
// Query is a substring of the title.
TITLE_SUBSTRING = 2,
// Query matched in the text (substring or FTS).
TEXT = 1,
}
export interface RankableHit {
tier: SearchLookupTier;
// Secondary in-tier signal, higher = better (e.g. ts_rank, or a
// position-derived closeness score). Defaults to 0.
secondary?: number;
}
// Map (tier, secondary) → a 0..1 float used ONLY to sort one response.
//
// Formula: score = (tier + squash(secondary)) / (maxTier + 1), where
// squash(x) = x / (1 + x) maps any non-negative secondary into [0, 1)
// so a stronger tier ALWAYS outranks a weaker one regardless of the secondary
// value, and within a tier a larger secondary sorts higher. maxTier is the top
// enum value (TITLE_EXACT = 3), so the divisor keeps the result in (0, 1].
export function computeLookupScore(hit: RankableHit): number {
const maxTier = SearchLookupTier.TITLE_EXACT;
const secondary = Math.max(0, hit.secondary ?? 0);
const squashed = secondary / (1 + secondary);
return (hit.tier + squashed) / (maxTier + 1);
}
@Injectable()
export class SearchService {
constructor(
@@ -50,12 +100,19 @@ export class SearchService {
userId?: string;
workspaceId: string;
},
): Promise<{ items: SearchResponseDto[] }> {
): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> {
const { query } = searchParams;
if (query.length < 1) {
return { items: [] };
}
// Opt-in agent-lookup mode (#443). Guarded by the `substring` flag so the
// web-UI (which never sets it) keeps byte-identical FTS behaviour below.
if (searchParams.substring) {
return this.searchPageLookup(searchParams, opts);
}
const searchQuery = buildTsQuery(query);
let queryResults = this.db
@@ -175,6 +232,348 @@ export class SearchService {
return { items: searchResults };
}
/**
* Agent-lookup search (#443, opt-in via `SearchDTO.substring`).
*
* ADDITIVE to the FTS path: runs a substring branch (title + optionally
* text_content, LIKE with metacharacters escaped) MERGED with the existing
* FTS branch, so technical tokens that the `english` tokenizer mangles
* (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are still found even
* when `buildTsQuery()` returns '' for a dotted/numeric query. Results carry a
* location (`path`), a windowed `snippet` and a per-response `score`.
*
* The whole method is only reached when `substring: true`; the web-UI never
* sets it, so its behaviour is unchanged.
*/
private async searchPageLookup(
searchParams: SearchDTO,
opts: { userId?: string; workspaceId: string },
): Promise<{ items: SearchLookupResponseDto[] }> {
const rawQuery = searchParams.query.trim();
if (!rawQuery) {
return { items: [] };
}
const limit = Math.min(Math.max(searchParams.limit || 10, 1), 50);
// Normalize the query the same way as the FTS / suggest path: f_unaccent +
// lower, done in SQL. `q` is the escaped LIKE pattern body (literal chars).
const likeBody = escapeLikePattern(rawQuery);
// Compare against `LOWER(f_unaccent(col))`; unaccent+lower the needle too.
const needle = sql<string>`LOWER(f_unaccent(${rawQuery}))`;
const likePattern = sql<string>`LOWER(f_unaccent(${'%' + likeBody + '%'}))`;
const tsQuery = buildTsQuery(rawQuery);
const hasTsQuery = tsQuery.length > 0;
// --- Resolve the space scope. ---------------------------------------------
// Mirrors searchPage: explicit spaceId, else the authenticated user's member
// spaces. The share path is not exposed to this opt-in mode.
let spaceIds: string[] = [];
if (searchParams.spaceId) {
spaceIds = [searchParams.spaceId];
} else if (opts.userId) {
spaceIds = await this.spaceMemberRepo.getUserSpaceIds(opts.userId);
} else {
return { items: [] };
}
if (spaceIds.length === 0) {
return { items: [] };
}
// --- Optional parentPageId subtree scope (inclusive). ---------------------
// Reuse the same recursive-descendants pattern used for share-scope.
let descendantIds: string[] | null = null;
if (searchParams.parentPageId) {
const descendants = await this.pageRepo.getPageAndDescendants(
searchParams.parentPageId,
{ includeContent: false },
);
descendantIds = descendants.map((p: any) => p.id);
if (descendantIds.length === 0) {
return { items: [] };
}
}
// --- Candidate query: substring (title + text) UNION FTS. -----------------
// We compute everything the ranker needs in SQL and pull only small columns
// (never the whole text_content) into Node:
// - titleExact / titleSub: tier signals
// - textMatchPos: 1-based position of the first text match (0 = none)
// - ftsRank: ts_rank for the FTS secondary signal (0 when no tsquery)
// - snippet: windowed ~500 chars around the first text match, or a leading
// text window (title-only hit), or an extended ts_headline fallback.
const N_BEFORE = 60; // chars of context before the first match
const SNIPPET_LEN = 500;
let candidates = this.db
.selectFrom('pages')
.select([
'pages.id as id',
'pages.slugId as slugId',
'pages.title as title',
'pages.parentPageId as parentPageId',
// Tier signals.
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}`.as(
'titleExact',
),
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\'`.as(
'titleSub',
),
// 1-based position of the first text match (0 = no text match).
sql<number>`strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle})`.as(
'textMatchPos',
),
// FTS secondary signal (0 when the tsquery is empty).
hasTsQuery
? sql<number>`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`.as(
'ftsRank',
)
: sql<number>`0`.as('ftsRank'),
// Windowed snippet, computed entirely in SQL. Priority:
// 1. window around the first text match;
// 2. otherwise (titleOnly: no snippet; else) a leading window of the
// page text (title-only hit);
// 3. otherwise an extended ts_headline for pure-FTS hits.
//
// #443 snippet-position fix: the match position (`strpos`) is computed in
// the LOWER(f_unaccent(...)) space, but f_unaccent is NOT length-
// preserving (ß→ss, æ→ae, …→..., ½→ 1/2, full-width forms), so slicing
// the ORIGINAL text at that position was misaligned — a single expanding
// char before the match shifted the window (or ran it past end → empty).
// We now slice from the SAME LOWER(f_unaccent(...)) string so position
// and slice share one coordinate space. DELIBERATE trade-off: the snippet
// loses original case/diacritics — acceptable for an agent-facing snippet
// (position accuracy over original-glyph fidelity). The ts_headline branch
// matches over the ORIGINAL text itself, so it is unaffected and kept as-is.
searchParams.titleOnly
? sql<string>`''`.as('snippet')
: sql<string>`
coalesce(
case
when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) > 0
then substring(
LOWER(f_unaccent(coalesce(pages.text_content, '')))
from greatest(1, strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) - ${N_BEFORE})
for ${SNIPPET_LEN}
)
when coalesce(pages.text_content, '') <> ''
then substring(LOWER(f_unaccent(pages.text_content)) from 1 for 300)
${
hasTsQuery
? sql`else ts_headline('english', coalesce(pages.text_content, ''), to_tsquery('english', f_unaccent(${tsQuery})), 'MinWords=25, MaxWords=40, MaxFragments=3')`
: sql``
}
end,
''
)
`.as('snippet'),
])
.where('pages.deletedAt', 'is', null)
.where('pages.spaceId', 'in', spaceIds);
if (descendantIds) {
candidates = candidates.where('pages.id', 'in', descendantIds);
}
// Match predicate: title substring OR (unless titleOnly) text substring OR
// (unless titleOnly) FTS. The substring branch runs even when the tsquery is
// empty — that is the dotted/numeric-token case the FTS path misses.
//
// #443 dead-index fix: these two LIKE predicates MUST match the GIN trgm
// index expressions EXACTLY for Postgres to use them. The indexes are on the
// coalesce-FREE expressions `LOWER(f_unaccent(title))` (#348's
// idx_pages_title_trgm) and `LOWER(f_unaccent(text_content))` (this PR's
// idx_pages_text_content_trgm). A `coalesce(col,'')` wrapper here would make
// the query expression differ from the index expression and force a Seq Scan
// on pages for every lookup. Dropping coalesce is SEMANTICALLY EQUIVALENT:
// `NULL LIKE '%q%'` is NULL (falsy), so a NULL title/text simply doesn't
// match — exactly as an empty string wouldn't match `%q%`.
candidates = candidates.where((eb) => {
const ors = [
eb(
sql`LOWER(f_unaccent(pages.title))`,
'like',
sql`${likePattern} ESCAPE '\\'`,
),
];
if (!searchParams.titleOnly) {
ors.push(
eb(
sql`LOWER(f_unaccent(pages.text_content))`,
'like',
sql`${likePattern} ESCAPE '\\'`,
),
);
if (hasTsQuery) {
ors.push(
sql<boolean>`pages.tsv @@ to_tsquery('english', f_unaccent(${tsQuery}))` as any,
);
}
}
return eb.or(ors);
});
// Pull a generous candidate set (before permission filtering + limit).
// Cap it so a pathological match set cannot blow up memory; 200 >> limit
// (max 50) leaves ample headroom for the post-permission truncation.
//
// #443 cap-ordering fix: the 200-cap MUST be deterministic and relevance-
// biased. Without an ORDER BY, Postgres returns an ARBITRARY 200 rows, so on
// a broad match set (common word / short substring) a strong TITLE_EXACT hit
// could be among the dropped rows while 200 low-tier TEXT hits fill the cap.
// We order by the SAME SQL tier proxies the Node ranker uses — title-exact,
// then title-substring, then fts-rank (nulls last), then earliest text-match
// position — so the cap keeps the strongest candidates. The Node-side final
// tier sort + slice(0, limit) below still runs and stays authoritative; this
// ORDER BY only decides WHICH candidates survive the 200-cap.
// NB: a BARE integer literal in ORDER BY is read by Postgres as an ordinal
// column position (`ORDER BY 0` → "position 0 is not in select list"), so the
// no-tsquery fallback is `0::float`, not `0`.
const ftsRankExpr = hasTsQuery
? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`
: sql`0::float`;
const candidatesCapped = candidates
// Raw-SQL ORDER BY expressions: pass the full `<expr> <dir>` as ONE arg
// (the two-arg form treats a raw-SQL second arg as an ORDER BY position).
.orderBy(
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}) desc`,
)
.orderBy(
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\') desc`,
)
.orderBy(sql`${ftsRankExpr} desc nulls last`)
// Earlier text match first; strpos returns 0 for "no match", which would
// sort BEFORE a real (>=1) position under plain ASC, so push 0 to the end.
.orderBy(
sql`case when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) = 0 then 2147483647 else strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) end asc`,
);
let rows: any[] = await candidatesCapped.limit(200).execute();
if (rows.length === 0) {
return { items: [] };
}
// --- Permissions BEFORE limit. --------------------------------------------
// Apply the existing page-level post-filter to the MERGED set, then rank and
// only THEN truncate to `limit` — never lose the permission filter.
if (opts.userId) {
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: rows.map((r) => r.id),
userId: opts.userId,
spaceId: searchParams.spaceId,
workspaceId: opts.workspaceId,
});
const accessibleSet = new Set(accessibleIds);
rows = rows.filter((r) => accessibleSet.has(r.id));
}
if (rows.length === 0) {
return { items: [] };
}
// --- Tiered ranking + dedup. ----------------------------------------------
// Rows are already unique by id (single pages scan), so no cross-branch
// dedup is needed here; the tier captures the strongest match reason.
const ranked = rows.map((r) => {
let tier: SearchLookupTier;
let secondary: number;
if (r.titleExact) {
tier = SearchLookupTier.TITLE_EXACT;
secondary = Number(r.ftsRank) || 0;
} else if (r.titleSub) {
tier = SearchLookupTier.TITLE_SUBSTRING;
secondary = Number(r.ftsRank) || 0;
} else {
tier = SearchLookupTier.TEXT;
// Prefer earlier text matches; map position → closeness in (0, 1].
const pos = Number(r.textMatchPos) || 0;
secondary =
pos > 0 ? 1 / (1 + (pos - 1) / 100) : Number(r.ftsRank) || 0;
}
return { row: r, tier, score: computeLookupScore({ tier, secondary }) };
});
ranked.sort((a, b) => b.score - a.score);
const top = ranked.slice(0, limit);
// --- Batch ancestor path (ONE recursive CTE, not N+1). --------------------
const pathById = await this.buildAncestorPaths(top.map((t) => t.row.id));
const items: SearchLookupResponseDto[] = top.map((t) => ({
id: t.row.id,
slugId: t.row.slugId,
title: t.row.title,
parentPageId: t.row.parentPageId ?? null,
path: pathById.get(t.row.id) ?? [],
snippet: (t.row.snippet ?? '')
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
score: t.score,
}));
return { items };
}
/**
* Batch ancestor-titles helper (#443): ONE recursive CTE seeded with ALL hit
* ids, walking UP parentPageId. Returns a map hitId ancestor titles ordered
* root direct parent (the hit's own title is excluded). Root pages map to
* an empty array. Avoids the N+1 of a per-page breadcrumb call.
*/
private async buildAncestorPaths(
hitIds: string[],
): Promise<Map<string, string[]>> {
const result = new Map<string, string[]>();
if (hitIds.length === 0) return result;
// ancestry(hit_id, page_id, title, parent_page_id, depth): seed one row per
// hit at depth 0 (the hit itself), then walk to parents (increasing depth).
const rows = await this.db
.withRecursive('ancestry', (db) =>
db
.selectFrom('pages')
.select([
'pages.id as hitId',
'pages.id as pageId',
'pages.title as title',
'pages.parentPageId as parentPageId',
sql<number>`0`.as('depth'),
])
.where('pages.id', 'in', hitIds)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('ancestry as a', 'p.id', 'a.parentPageId')
.select([
'a.hitId as hitId',
'p.id as pageId',
'p.title as title',
'p.parentPageId as parentPageId',
sql<number>`a.depth + 1`.as('depth'),
]),
),
)
.selectFrom('ancestry')
.select(['hitId', 'title', 'depth'])
// depth 0 is the hit itself — excluded from the path.
.where('depth', '>', 0)
.orderBy('hitId')
// Larger depth = closer to the space root. Ordering DESC gives
// root → parent once collected.
.orderBy('depth', 'desc')
.execute();
for (const r of rows as any[]) {
const list = result.get(r.hitId) ?? [];
list.push(r.title);
result.set(r.hitId, list);
}
return result;
}
async searchSuggestions(
suggestion: SearchSuggestionDTO,
userId: string,
@@ -0,0 +1,55 @@
import { type Kysely, sql } from 'kysely';
/**
* #443 trigram indexes for the opt-in agent-lookup search mode.
*
* The lookup mode adds a substring branch that runs leading-wildcard
* `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and
* pages.text_content. A leading wildcard cannot use a b-tree index, so without a
* GIN trigram index each such predicate is a sequential scan.
*
* - TITLE: the lookup-mode title predicate is `LOWER(f_unaccent(title)) LIKE
* '%q%'` (coalesce-free, so it can use a functional index), which is IDENTICAL
* to the one added for /search/suggest (#348). #348's perf-indexes migration
* already created `idx_pages_title_trgm` on `(LOWER(f_unaccent(title)))
* gin_trgm_ops`, so the title predicate is already covered — we do NOT
* re-create that index here (it would be redundant).
*
* - TEXT_CONTENT: NEW. The substring branch scans text_content when the query
* is not titleOnly. text_content is the large column, so a GIN trigram index
* on it is the meaningful acceleration for the lookup mode. The lookup search
* is ALWAYS space-scoped (spaceId or the user's member spaces), so on small
* instances a per-space sequential scan is tolerable but the index turns the
* `%q%` text predicate into a Bitmap Index Scan and removes the only
* unbounded-per-space cost of the feature. We add it. The trade-off is disk +
* write amplification on page edits (GIN trigram indexes are larger and slower
* to update than b-trees); on the small instances this fork targets that cost
* is acceptable and the read win on agent lookups is the priority.
*
* DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX Kysely runs
* each migration in a transaction, so CONCURRENTLY is impossible. The build takes
* a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content
* GIN build is the slow one and can take minutes on a large tenant. For large
* installations, run this in a maintenance window or build the index out-of-band
* with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops
* here). Small/typical tenants are unaffected.
*/
export async function up(db: Kysely<any>): Promise<void> {
// The title predicate is served by #348's idx_pages_title_trgm — see header.
// Only the text_content index is introduced here.
// text_content trigram index. Its expression is coalesce-free —
// `LOWER(f_unaccent(text_content))` — to EXACTLY match the coalesce-free
// lookup-mode text substring predicate in search.service.ts, so Postgres can
// use it (a `coalesce(...)` mismatch would silently fall back to a Seq Scan).
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm
ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
// Only drop the index this migration introduced. idx_pages_title_trgm is owned
// by the #348 perf-indexes migration, so leave it for that migration's down().
await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db);
}
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
@@ -188,6 +189,144 @@ export class AiChatMessageRepo {
return query.returning(this.baseFields).executeTakeFirst();
}
/**
* #487 OWNER terminal write the streamText terminal callback's finalize. Like
* `update` but CONDITIONAL on `status='streaming' OR metadata.finalizeFailed`:
* the owner writes its real content EITHER when the row is still streaming (the
* normal case) OR when a reconcile stamp already flipped it to a terminal status
* but marked `finalizeFailed:true` the owner's real content OVERWRITES that
* placeholder stamp (owner-write priority, #487). A row that is properly terminal
* (no finalizeFailed) is left untouched (undefined) idempotent. The `patch`
* carries the real metadata WITHOUT finalizeFailed, so a successful write CLEARS
* the flag. Returns the updated row, or undefined when nothing matched.
*/
async finalizeOwner(
id: string,
workspaceId: string,
patch: Partial<{
content: string | null;
toolCalls: unknown;
metadata: unknown;
status: string | null;
}>,
trx?: KyselyTransaction,
): Promise<AiChatMessage | undefined> {
const db = dbOrTx(this.db, trx);
return db
.updateTable('aiChatMessages')
.set({ ...(patch as Record<string, unknown>), updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where((eb) =>
eb.or([
eb('status', '=', 'streaming'),
eb(sql<string>`(metadata->>'finalizeFailed')`, '=', 'true'),
]),
)
.returning(this.baseFields)
.executeTakeFirst();
}
/**
* #487 RECONCILE status-only stamp settle a stuck 'streaming' row to a
* terminal status WITHOUT the owner's real content (which lived only in the
* dead process's memory — a documented loss). CONDITIONAL on `status='streaming'`
* (never touches an already-terminal row) AND it MERGES `finalizeFailed:true`
* into metadata (preserving the partial `parts` already persisted) so a LATER
* owner-write (finalizeOwner) can still OVERWRITE this placeholder with real
* content, and so `isInterruptResume` can EXCLUDE this row (a reconcile stamp is
* not a genuine user interruption). Returns the updated row, or undefined.
*/
async stampTerminalIfStreaming(
id: string,
workspaceId: string,
status: 'aborted' | 'error' | 'completed',
trx?: KyselyTransaction,
): Promise<AiChatMessage | undefined> {
const db = dbOrTx(this.db, trx);
return db
.updateTable('aiChatMessages')
.set({
status,
metadata: sql`coalesce(metadata, '{}'::jsonb) || jsonb_build_object('finalizeFailed', true)`,
updatedAt: new Date(),
})
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('status', '=', 'streaming')
.returning(this.baseFields)
.executeTakeFirst();
}
/**
* #487 reconcile clause (b): streaming assistant rows whose linked RUN has
* already reached a terminal status an asymmetry ("run settled / message
* streaming forever") the periodic reconcile heals by stamping the message.
* Returns the message id + its run's terminal status, bounded.
*/
async findStreamingWithTerminalRun(
limit = 200,
// #487: scope to ONE chat for the opportunistic per-turn reconcile (removes
// reconcile latency from the user-visible path); omit for the periodic sweep.
chat?: { chatId: string; workspaceId: string },
): Promise<
Array<{ messageId: string; workspaceId: string; runStatus: string }>
> {
let query = this.db
.selectFrom('aiChatMessages as m')
.innerJoin('aiChatRuns as r', 'r.assistantMessageId', 'm.id')
.select([
'm.id as messageId',
'm.workspaceId as workspaceId',
'r.status as runStatus',
])
.where('m.status', '=', 'streaming')
.where('r.status', 'in', ['succeeded', 'failed', 'aborted']);
if (chat) {
query = query
.where('m.chatId', '=', chat.chatId)
.where('m.workspaceId', '=', chat.workspaceId);
}
return query.limit(limit).execute();
}
/**
* #487 reconcile clause (d) historical-row safety: streaming rows older than
* `staleMs` whose chat has NO active run row (double-gated). Settle them to
* 'aborted' + finalizeFailed (so a late owner-write could still overwrite).
* Returns the count. Used ONLY by the periodic reconcile, never at boot.
*/
async sweepStreamingWithoutActiveRun(
staleMs: number,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const staleBefore = new Date(Date.now() - staleMs);
const rows = await db
.updateTable('aiChatMessages as m')
.set({
status: 'aborted',
metadata: sql`coalesce(m.metadata, '{}'::jsonb) || jsonb_build_object('finalizeFailed', true)`,
updatedAt: new Date(),
})
.where('m.status', '=', 'streaming')
.where('m.updatedAt', '<', staleBefore)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('aiChatRuns as r')
.select('r.id')
.whereRef('r.chatId', '=', 'm.chatId')
.where('r.status', 'in', ['pending', 'running']),
),
),
)
.returning('m.id')
.execute();
return rows.length;
}
/**
* Crash-recovery sweep (#183): flip every assistant row still left in the
* 'streaming' state (a turn that died mid-write before reaching a terminal
@@ -200,13 +339,20 @@ export class AiChatMessageRepo {
* step, so an actively-streaming row never matches; this prevents a fresh
* replica's boot-sweep from aborting a turn another replica is still streaming
* in a multi-instance deploy.
*
* #487: the sweep now ALSO marks `finalizeFailed:true` so a late owner-write can
* overwrite this placeholder with real content (owner-write priority).
*/
async sweepStreaming(trx?: KyselyTransaction): Promise<number> {
const db = dbOrTx(this.db, trx);
const staleBefore = new Date(Date.now() - SWEEP_STREAMING_STALE_MS);
const rows = await db
.updateTable('aiChatMessages')
.set({ status: 'aborted', updatedAt: new Date() })
.set({
status: 'aborted',
metadata: sql`coalesce(metadata, '{}'::jsonb) || jsonb_build_object('finalizeFailed', true)`,
updatedAt: new Date(),
})
.where('status', '=', 'streaming')
.where('updatedAt', '<', staleBefore)
.returning('id')
@@ -143,6 +143,41 @@ export class AiChatRunRepo {
.executeTakeFirst();
}
/**
* #487: CONDITIONAL terminal finalize flip a run to a terminal status and
* stamp `finished_at` ONLY while it is still active (pending|running), mirroring
* the assistant message's `onlyIfStreaming` guard. A double-settle (a late or
* second writer, a supersede applying a zombie's intended, a reconcile stamp)
* matches NOTHING once the row is terminal and is a benign no-op so a terminal
* status can never be clobbered by a later writer (last-writer-wins is gone).
*
* Returns the updated row when it WAS active (this call wrote it), else
* undefined (the row was already terminal another writer won). The caller
* distinguishes the two to resolve the correct settle outcome.
*/
async finalizeIfActive(
id: string,
workspaceId: string,
patch: { status: string; error: string | null },
trx?: KyselyTransaction,
): Promise<AiChatRun | undefined> {
const db = dbOrTx(this.db, trx);
const now = new Date();
return db
.updateTable('aiChatRuns')
.set({
status: patch.status,
error: patch.error,
finishedAt: now,
updatedAt: now,
})
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[])
.returning(this.baseFields)
.executeTakeFirst();
}
/**
* Mark an EXPLICIT stop request on an active run (distinct from a browser
* disconnect, which never stops a run). Stamps `stop_requested_at` ONLY while
@@ -184,6 +219,31 @@ export class AiChatRunRepo {
* sweeps only runs UNTOUCHED past the window. Phase 1 is single-process, so the
* boot path supplies no window.
*/
/**
* #487 reconcile clause (c): active (pending|running) runs UNTOUCHED past
* `staleMs` candidates for "no live runner" abort. Staleness is measured from
* `updated_at` (the LAST-PROGRESS timestamp recordStep bumps it), NOT
* `started_at`, so a legitimate long-running marathon (1125 min of steady
* progress) is never a candidate. The caller filters these against its in-memory
* `active` / zombie maps ("no entry" is the PRIMARY gate a live entry is never
* aborted) before settling any of them. Bounded.
*/
async findStaleActive(
staleMs: number,
limit = 200,
trx?: KyselyTransaction,
): Promise<Array<{ id: string; workspaceId: string; chatId: string }>> {
const db = dbOrTx(this.db, trx);
const staleBefore = new Date(Date.now() - staleMs);
return db
.selectFrom('aiChatRuns')
.select(['id', 'workspaceId', 'chatId'])
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[])
.where('updatedAt', '<', staleBefore)
.limit(limit)
.execute();
}
async sweepRunning(
opts: { staleMs?: number } = {},
trx?: KyselyTransaction,
@@ -0,0 +1,133 @@
import { readFileSync } from 'fs';
import { EventEmitter } from 'node:events';
import { streamText } from 'ai';
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
/**
* Regression tests for the writeToServerResponse drain-hang fix in
* patches/ai@6.0.134.patch (#486, commit 6).
*
* Unpatched ai@6.0.134's writeToServerResponse awaits ONLY `once("drain")` when
* response.write() returns false (backpressure). If the client disconnects
* mid-write the socket never drains, so that await never resolves: the read loop
* parks FOREVER, its `finally { response.end() }` is unreachable, and the stream
* reader + buffered chunks are pinned until process restart. In autonomous mode
* the run keeps producing output after the disconnect, so EVERY mid-run
* disconnect leaks a hung pipe. The patch races drain against close/error, and on
* a terminal socket event cancels the reader and breaks so `finally` always runs.
*
* This drives the REAL patched writeToServerResponse through the public
* pipeUIMessageStreamToResponse API with a response that never drains and closes
* mid-write exactly the leak scenario.
*/
/** A ServerResponse-like emitter whose first write() stalls (returns false) and
* then "closes" like a disconnecting client never firing 'drain'. */
class DisconnectingResponse extends EventEmitter {
ended = false;
writeCount = 0;
statusCode = 200;
writableEnded = false;
destroyed = false;
writeHead(): this {
return this;
}
setHeader(): void {}
flushHeaders(): void {}
write(): boolean {
this.writeCount++;
if (this.writeCount === 1) {
// Simulate the client vanishing mid-write: backpressure (false) and then a
// 'close' on the next tick, and CRUCIALLY never a 'drain'. Unpatched, the
// loop would await drain forever here.
setImmediate(() => this.emit('close'));
return false;
}
return true;
}
end(): void {
this.ended = true;
this.writableEnded = true;
this.emit('finish');
}
}
function makeModel() {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start' as const, warnings: [] },
{ type: 'text-start' as const, id: '1' },
{ type: 'text-delta' as const, id: '1', delta: 'hello ' },
{ type: 'text-delta' as const, id: '1', delta: 'world' },
{ type: 'text-end' as const, id: '1' },
{
type: 'finish' as const,
finishReason: { unified: 'stop' as const, raw: 'stop' },
usage: {
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
},
},
],
}),
}),
});
}
describe('ai@6.0.134 pnpm patch: writeToServerResponse drain-hang (#486)', () => {
it('ends the response (does NOT hang) when the socket closes mid-write without draining', async () => {
const result = streamText({ model: makeModel(), prompt: 'hi' });
const res = new DisconnectingResponse();
// Drain the SDK stream independently, like the production detached path.
void result.consumeStream({ onError: () => undefined });
result.pipeUIMessageStreamToResponse(res as never);
// TRIPWIRE: the patched loop exits on 'close' and runs finally -> end().
// Unpatched, it awaits 'drain' forever and this never becomes true.
await new Promise<void>((resolve, reject) => {
const started = Date.now();
const poll = setInterval(() => {
if (res.ended) {
clearInterval(poll);
resolve();
} else if (Date.now() - started > 3000) {
clearInterval(poll);
reject(new Error('writeToServerResponse hung: response never ended'));
}
}, 20);
});
expect(res.ended).toBe(true);
});
it('does not emit an unhandledRejection when the fire-and-forget read() throws', async () => {
// The patch swallows read()'s rejection (fire-and-forget) with a log instead
// of letting it surface as a process-killing unhandledRejection.
const rejections: unknown[] = [];
const onUnhandled = (e: unknown) => rejections.push(e);
process.on('unhandledRejection', onUnhandled);
// Silence the patch's diagnostic console.error for the throwing read().
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
try {
const result = streamText({ model: makeModel(), prompt: 'hi' });
const res = new DisconnectingResponse();
void result.consumeStream({ onError: () => undefined });
result.pipeUIMessageStreamToResponse(res as never);
await new Promise((r) => setTimeout(r, 300));
} finally {
process.off('unhandledRejection', onUnhandled);
errSpy.mockRestore();
}
expect(rejections).toEqual([]);
});
it('both installed dist builds (CJS and ESM) carry the #486 patch marker', () => {
const cjsPath = require.resolve('ai');
const mjsPath = cjsPath.replace(/index\.js$/, 'index.mjs');
expect(cjsPath).toMatch(/index\.js$/);
expect(readFileSync(cjsPath, 'utf8')).toContain('PATCH(docmost #486)');
expect(readFileSync(mjsPath, 'utf8')).toContain('PATCH(docmost #486)');
});
});
@@ -158,4 +158,27 @@ describe('EnvironmentService', () => {
).toBe('https://app.example.com');
});
});
describe('isAiChatFinalStepLockdownEnabled (#444)', () => {
const build = (val?: string) =>
new EnvironmentService({
get: (key: string, def?: string) =>
key === 'AI_CHAT_FINAL_STEP_LOCKDOWN' ? (val ?? def) : def,
} as any);
it('defaults to OFF (false) when unset — the new anti-degeneration default', () => {
expect(build(undefined).isAiChatFinalStepLockdownEnabled()).toBe(false);
});
it('is true only for the exact opt-in "true" (case-insensitive)', () => {
expect(build('true').isAiChatFinalStepLockdownEnabled()).toBe(true);
expect(build('TRUE').isAiChatFinalStepLockdownEnabled()).toBe(true);
});
it('stays OFF for any other value', () => {
expect(build('false').isAiChatFinalStepLockdownEnabled()).toBe(false);
expect(build('1').isAiChatFinalStepLockdownEnabled()).toBe(false);
expect(build('yes').isAiChatFinalStepLockdownEnabled()).toBe(false);
});
});
});
@@ -292,6 +292,24 @@ export class EnvironmentService {
return enabled === 'true';
}
/**
* Final-step lockdown for the in-app agent loop (#444). When ON (legacy), the
* LAST allowed step forces a text-only answer: tools are stripped
* (toolChoice:'none') and a synthesis instruction is appended. Defaults to OFF:
* stripping the tools mid-work triggered a token-loop degeneration incident
* (the model, robbed of its tools on the final step, emitted a 255KB block
* repeating a single token). With the toggle OFF the last step keeps its tools
* and gets only a SOFT nudge to finish with a text summary; the universal
* anti-babble guard is the token-degeneration detector instead. Enable this
* only for a model that does NOT reliably end its turns with a text answer.
*/
isAiChatFinalStepLockdownEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_FINAL_STEP_LOCKDOWN', 'false')
.toLowerCase();
return enabled === 'true';
}
/**
* Resumable SSE transport for durable agent runs (#184 phase 1.5). When
* enabled, a run tees its SSE frames into the in-memory run-stream registry so
@@ -0,0 +1,95 @@
import type { HealthIndicatorService } from '@nestjs/terminus';
import type { EnvironmentService } from '../environment/environment.service';
/**
* Integration guard for the /health Redis-probe handle leak (#486, commit 2).
*
* The bug: `pingCheck` built `new Redis(...)` per call and only disconnected on
* the SUCCESS path, so when Redis is DOWN every probe tick added ANOTHER
* forever-reconnecting client an unbounded handle/client leak for the duration
* of the outage. The fix reuses ONE long-lived probe client.
*
* This is an OBSERVABLE-property test, not an assertion on a mocked return value:
* we point the indicator at a REAL, refused TCP endpoint (a dead port) so ioredis
* genuinely fails to connect, run many probes, and assert the number of live
* Redis CLIENTS created stays at exactly ONE. `ioredis` is delegated to its real
* implementation (requireActual) only the constructor is wrapped to COUNT the
* real clients it creates, which is precisely the leaking resource.
*/
const mockLiveClients: Array<{ status: string; disconnect: () => void }> = [];
jest.mock('ioredis', () => {
const actual = jest.requireActual('ioredis');
const RealRedis = actual.Redis ?? actual.default ?? actual;
class CountingRedis extends RealRedis {
constructor(...args: unknown[]) {
super(...(args as []));
mockLiveClients.push(this as never);
}
}
return { ...actual, Redis: CountingRedis, default: CountingRedis };
});
// Import AFTER the mock is registered so the class picks up the counting client.
import { RedisHealthIndicator } from './redis.health';
describe('RedisHealthIndicator handle leak (#486)', () => {
const indicatorService = {
check: (key: string) => ({
up: () => ({ [key]: { status: 'up' } }),
down: (message: string) => ({ [key]: { status: 'down', message } }),
}),
} as unknown as HealthIndicatorService;
// A port with (almost certainly) nothing listening -> connection refused fast.
const environmentService = {
getRedisUrl: () => 'redis://127.0.0.1:6399/0',
} as unknown as EnvironmentService;
let indicator: RedisHealthIndicator;
beforeEach(() => {
mockLiveClients.length = 0;
indicator = new RedisHealthIndicator(indicatorService, environmentService);
});
afterEach(() => {
indicator.onModuleDestroy();
// Belt-and-braces: tear down anything the test created so ioredis reconnect
// timers do not keep the jest worker alive.
for (const c of mockLiveClients) {
try {
c.disconnect();
} catch {
/* already gone */
}
}
});
it('creates exactly ONE Redis client across many probes while Redis is DOWN', async () => {
const N = 8;
for (let i = 0; i < N; i++) {
const result = await indicator.pingCheck('redis');
// Down endpoint -> every probe reports "down" (not an unhandled crash).
expect(result.redis.status).toBe('down');
}
// THE OBSERVABLE LEAK: on the buggy code this is N (a fresh, never-cleaned
// reconnecting client per probe). The fix reuses one shared client.
expect(mockLiveClients).toHaveLength(1);
});
it('onModuleDestroy releases the probe client (a later probe builds a fresh one)', async () => {
await indicator.pingCheck('redis');
expect(mockLiveClients).toHaveLength(1);
indicator.onModuleDestroy();
// A second destroy is a safe no-op (probeClient was nulled).
indicator.onModuleDestroy();
// After shutdown the indicator lazily builds a NEW client on the next probe,
// proving the old one was truly released rather than reused.
await indicator.pingCheck('redis');
expect(mockLiveClients).toHaveLength(2);
});
});
@@ -2,33 +2,78 @@ import {
HealthIndicatorResult,
HealthIndicatorService,
} from '@nestjs/terminus';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { EnvironmentService } from '../environment/environment.service';
import { Redis } from 'ioredis';
@Injectable()
export class RedisHealthIndicator {
export class RedisHealthIndicator implements OnModuleDestroy {
private readonly logger = new Logger(RedisHealthIndicator.name);
/**
* ONE long-lived probe connection, reused across every /health tick. The old
* code built `new Redis(...)` per call and only `disconnect()`d on the SUCCESS
* path, so while Redis was DOWN every probe added a fresh, forever-reconnecting
* client a handle leak that grew without bound for as long as the outage (and
* the health checker keeps polling) lasted. A single shared client keeps at most
* ONE background reconnect loop regardless of how many probes run.
*/
private probeClient: Redis | null = null;
constructor(
private readonly healthIndicatorService: HealthIndicatorService,
private environmentService: EnvironmentService,
) {}
private getProbeClient(): Redis {
if (!this.probeClient) {
this.probeClient = new Redis(this.environmentService.getRedisUrl(), {
// Constructing must never throw or eagerly connect; the first ping opens
// the socket. This lets us build the client once and reuse it.
lazyConnect: true,
// A health probe must fail FAST, not queue behind a stuck reconnect: one
// retry per request, and no offline queue so a ping while disconnected
// rejects immediately instead of buffering commands that pile up in RAM.
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
// ioredis emits 'error' on every failed (re)connect; with no listener that
// surfaces as an unhandled 'error' event and can crash the process. Swallow
// it here — pingCheck already reports health — and log at debug so a Redis
// outage does not flood the logs.
this.probeClient.on('error', (err) => {
this.logger.debug(
`Redis probe connection error: ${
err instanceof Error ? err.message : String(err)
}`,
);
});
}
return this.probeClient;
}
async pingCheck(key: string): Promise<HealthIndicatorResult> {
const indicator = this.healthIndicatorService.check(key);
try {
const redis = new Redis(this.environmentService.getRedisUrl(), {
maxRetriesPerRequest: 15,
});
const redis = this.getProbeClient();
await redis.ping();
redis.disconnect();
return indicator.up();
} catch (e) {
this.logger.error(e);
return indicator.down(`${key} is not available`);
}
}
onModuleDestroy(): void {
if (this.probeClient) {
// disconnect() (not quit()) tears the socket + reconnect loop down
// immediately without waiting on a round-trip to a possibly-down server.
// Do NOT removeAllListeners() with no event name — that would also strip
// ioredis' OWN internal listeners and break its teardown; our 'error'
// listener is harmless and dies with the dropped client reference.
this.probeClient.disconnect();
this.probeClient = null;
}
}
}
@@ -143,7 +143,7 @@ export type DocmostMcpConfig = (
buf: Buffer,
mime: string,
) => { uri: string; sha256: string; size: number };
// Optional live/evict probes the package uses to keep stash_page's mirror
// Optional live/evict probes the package uses to keep stashPage's mirror
// counts honest under the store's FIFO eviction (mirror of the package's
// sink type); older bindings omit them.
has?: (uri: string) => boolean;
@@ -16,6 +16,7 @@ import {
} from './mcp-auth.helpers';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
import { McpService } from './mcp.service';
// The /mcp per-user auth decision logic is tested through the framework-free
// `resolveMcpSessionConfig` helper that McpService delegates to. McpService
@@ -1179,3 +1180,46 @@ describe('mapAuthResultToResponse (handle status/body mapping, refactor R2)', ()
});
});
});
// #486: onModuleDestroy must ALSO tear down the live loopback CollabSessions, not
// just clear the sweep timer — otherwise the embedded MCP's collab sockets keep
// docs pinned open on the collab server past process exit. The teardown goes
// through an overridable seam (destroyAllMcpSessions) so it can be spied without
// loading the ESM-only @docmost/mcp package.
describe('McpService.onModuleDestroy — CollabSession teardown (#486)', () => {
function makeService(): McpService {
// The constructor only stores its deps and starts the (unref'd) sweep timer,
// so bare stubs suffice. onModuleDestroy clears that timer, so no leak.
return new McpService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
}
it('destroys all sessions AND clears the sweep timer on shutdown', async () => {
const svc = makeService();
const destroy = jest.fn().mockResolvedValue(undefined);
(svc as any).destroyAllMcpSessions = destroy;
const clearSpy = jest.spyOn(global, 'clearInterval');
await svc.onModuleDestroy();
expect(destroy).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledWith((svc as any).sweepTimer);
clearSpy.mockRestore();
});
it('swallows a teardown failure so shutdown never throws', async () => {
const svc = makeService();
(svc as any).destroyAllMcpSessions = jest
.fn()
.mockRejectedValue(new Error('collab teardown boom'));
await expect(svc.onModuleDestroy()).resolves.toBeUndefined();
});
});
@@ -117,10 +117,42 @@ export class McpService implements OnModuleDestroy {
this.sweepTimer.unref?.();
}
onModuleDestroy(): void {
async onModuleDestroy(): Promise<void> {
clearInterval(this.sweepTimer);
// Tear down any live loopback CollabSession providers at shutdown (#486). The
// embedded MCP (and the in-app AI agent) open Hocuspocus collab sockets against
// THIS process; without an explicit teardown those sessions keep their docs
// "open" on the collab server and hold providers/buffers until they idle out,
// so a restart can race a doc still pinned by the dying worker. Best-effort:
// any failure is logged, never allowed to break shutdown.
try {
await this.destroyAllMcpSessions();
} catch (err) {
this.logger.error(
'MCP CollabSession teardown on shutdown failed',
err as Error,
);
}
}
/**
* Resolve @docmost/mcp's `destroyAllSessions` and invoke it (#486). The live
* CollabSession registry is a module-level singleton in the ESM package, shared
* by every entry (`.`/`./http`), so this tears down ALL sessions regardless of
* which surface opened them. The module is already loaded whenever MCP was used;
* if it was never loaded (or is absent) the import + no-op is harmless.
*
* Held as an overridable field so a unit test can spy the teardown without
* loading the ESM-only package or standing up the DI graph.
*/
private destroyAllMcpSessions: () => Promise<void> = async () => {
const entry = require.resolve('@docmost/mcp');
const mod = (await esmImport(pathToFileURL(entry).href)) as {
destroyAllSessions?: () => void;
};
mod.destroyAllSessions?.();
};
// Service account the embedded MCP uses to talk back to this Docmost
// instance over loopback REST + the collaboration WebSocket. Now OPTIONAL:
// it is only a fallback when no per-user Basic/Bearer credentials are sent.
@@ -332,7 +364,7 @@ export class McpService implements OnModuleDestroy {
// Should never happen: handle() always stashes before delegating.
throw new UnauthorizedException('MCP authentication missing.');
}
// Inject the blob-sandbox sink after the auth decision so stash_page
// Inject the blob-sandbox sink after the auth decision so stashPage
// can store blobs in the shared in-RAM store regardless of which
// credential variant resolved. The sink (put/has/evict + uri↔id
// mapping) is owned by SandboxStore.asSink().
@@ -0,0 +1,148 @@
import { get as httpGet } from 'node:http';
import { AddressInfo } from 'node:net';
import { createServer } from 'node:http';
// Drive the metrics HTTP server without the load-time METRICS_PORT gate: mock the
// registry so isMetricsEnabled()/getMetricsRegistry() are always satisfied. What
// we assert is observed over a REAL socket (bind address, status codes), not on
// the mock.
jest.mock('./metrics.registry', () => ({
isMetricsEnabled: () => true,
getMetricsRegistry: () => ({
metrics: async () => '# HELP up test\nup 1\n',
contentType: 'text/plain; version=0.0.4',
}),
}));
import {
startMetricsServer,
closeMetricsServer,
resolveMetricsBind,
resolveMetricsToken,
} from './metrics.server';
/** Find a free TCP port (the metrics server requires METRICS_PORT > 0). */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const p = (s.address() as AddressInfo).port;
s.close(() => resolve(p));
});
});
}
/** Minimal GET against 127.0.0.1:port with optional Authorization header. */
function req(
port: number,
headers: Record<string, string> = {},
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const r = httpGet(
{ host: '127.0.0.1', port, path: '/metrics', headers },
(res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () =>
resolve({ status: res.statusCode ?? 0, body }),
);
},
);
r.on('error', reject);
});
}
describe('metrics server bind + auth (#486)', () => {
const saved = {
bind: process.env.METRICS_BIND,
token: process.env.METRICS_TOKEN,
port: process.env.METRICS_PORT,
};
afterEach(async () => {
await closeMetricsServer();
process.env.METRICS_BIND = saved.bind;
process.env.METRICS_TOKEN = saved.token;
process.env.METRICS_PORT = saved.port;
delete process.env.METRICS_BIND;
delete process.env.METRICS_TOKEN;
});
describe('resolveMetricsBind', () => {
it('defaults to loopback 127.0.0.1', () => {
delete process.env.METRICS_BIND;
expect(resolveMetricsBind()).toBe('127.0.0.1');
});
it('honours the METRICS_BIND override', () => {
process.env.METRICS_BIND = '0.0.0.0';
expect(resolveMetricsBind()).toBe('0.0.0.0');
});
it('treats a blank override as unset (loopback)', () => {
process.env.METRICS_BIND = ' ';
expect(resolveMetricsBind()).toBe('127.0.0.1');
});
});
describe('resolveMetricsToken', () => {
it('is null when unset', () => {
delete process.env.METRICS_TOKEN;
expect(resolveMetricsToken()).toBeNull();
});
it('returns the trimmed token when set', () => {
process.env.METRICS_TOKEN = ' s3cret ';
expect(resolveMetricsToken()).toBe('s3cret');
});
});
it('binds to loopback by default and serves /metrics without auth when no token', async () => {
delete process.env.METRICS_BIND;
delete process.env.METRICS_TOKEN;
const port = await freePort();
process.env.METRICS_PORT = String(port);
const server = startMetricsServer();
expect(server).not.toBeNull();
await new Promise<void>((resolve) => {
if (server!.listening) resolve();
else server!.once('listening', () => resolve());
});
// OBSERVABLE: the listener bound to loopback, not 0.0.0.0.
expect((server!.address() as AddressInfo).address).toBe('127.0.0.1');
const res = await req(port);
expect(res.status).toBe(200);
expect(res.body).toContain('up 1');
});
it('rejects unauthenticated scrapes with 401 and accepts the exact Bearer token', async () => {
delete process.env.METRICS_BIND;
process.env.METRICS_TOKEN = 'topsecret';
const port = await freePort();
process.env.METRICS_PORT = String(port);
const server = startMetricsServer();
expect(server).not.toBeNull();
// No auth -> 401.
const noAuth = await req(port);
expect(noAuth.status).toBe(401);
// Wrong token, DIFFERENT length -> 401 (short-circuits on the length guard).
const wrong = await req(port, { authorization: 'Bearer nope' });
expect(wrong.status).toBe(401);
// Wrong token, SAME length -> 401. This drives the timingSafeEqual compare
// itself (the length guard passes: 'Bearer topsecreX' has the same length as
// 'Bearer topsecret'). Pins the constant-time compare: a regression that made
// it return true would let this equal-length wrong token through — the
// different-length case above would NOT catch that.
const sameLen = await req(port, { authorization: 'Bearer topsecreX' });
expect(sameLen.status).toBe(401);
// Correct token -> 200 with the metrics body.
const ok = await req(port, { authorization: 'Bearer topsecret' });
expect(ok.status).toBe(200);
expect(ok.body).toContain('up 1');
});
});
@@ -1,7 +1,27 @@
import { createServer, Server } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { Logger } from '@nestjs/common';
import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
/**
* Constant-time compare of the presented Authorization header against the
* expected `Bearer <token>`. This is the ONLY auth layer for the metrics
* endpoint, so a naive `!==` would leak the token byte-by-byte via timing.
* timingSafeEqual requires equal-length buffers, so a length mismatch short-
* circuits to "not equal" (its own length is not itself a useful oracle: the
* expected string length is fixed by config, not secret-derived).
*/
function bearerMatches(
presented: string | undefined,
expected: string,
): boolean {
if (typeof presented !== 'string') return false;
const a = Buffer.from(presented);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Start the Prometheus scrape endpoint on a SEPARATE port, taken from
* `METRICS_PORT`. There is NO default port: when `METRICS_PORT` is unset the
@@ -16,6 +36,30 @@ import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
*/
let metricsServer: Server | null = null;
/**
* Interface the metrics endpoint binds to. Defaults to LOOPBACK (127.0.0.1) so
* the unauthenticated `/metrics` surface is NOT exposed on all interfaces by
* default the old `0.0.0.0` bind put an auth-less endpoint on every interface.
* Deployments where the scraper runs in a SEPARATE container (and reaches this as
* `docmost:9464`) set `METRICS_BIND=0.0.0.0`, ideally together with METRICS_TOKEN
* and/or a private network so the port is not world-readable.
*/
export function resolveMetricsBind(): string {
const raw = (process.env.METRICS_BIND ?? '').trim();
return raw.length > 0 ? raw : '127.0.0.1';
}
/**
* Optional Bearer token guarding `/metrics`. When `METRICS_TOKEN` is set, every
* scrape must present `Authorization: Bearer <token>`; unset (default) leaves the
* endpoint open (safe when bound to loopback / a trusted network). Returns the
* trimmed token or null when unset/blank.
*/
export function resolveMetricsToken(): string | null {
const raw = (process.env.METRICS_TOKEN ?? '').trim();
return raw.length > 0 ? raw : null;
}
export function startMetricsServer(): Server | null {
if (!isMetricsEnabled()) return null;
@@ -31,8 +75,22 @@ export function startMetricsServer(): Server | null {
return null;
}
const bind = resolveMetricsBind();
const token = resolveMetricsToken();
const server = createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/metrics') {
// Optional Bearer auth: reject scrapes without the exact token when one is
// configured. This is the auth layer the old all-interfaces bind lacked.
if (token) {
const auth = req.headers['authorization'];
if (!bearerMatches(auth, `Bearer ${token}`)) {
res.statusCode = 401;
res.setHeader('WWW-Authenticate', 'Bearer');
res.end();
return;
}
}
try {
const body = await register.metrics();
res.setHeader('Content-Type', register.contentType);
@@ -48,10 +106,14 @@ export function startMetricsServer(): Server | null {
res.end();
});
// Bind on all interfaces: the scraper (VictoriaMetrics) reaches this from
// another container as docmost:9464. The port is not published to the host.
server.listen(port, '0.0.0.0', () => {
logger.log(`Metrics endpoint listening on :${port}/metrics`);
// Bind to loopback by default so the auth-less endpoint is not exposed on all
// interfaces. Set METRICS_BIND=0.0.0.0 (ideally with METRICS_TOKEN) when the
// scraper runs in a separate container and reaches this as docmost:9464.
server.listen(port, bind, () => {
logger.log(
`Metrics endpoint listening on ${bind}:${port}/metrics` +
(token ? ' (Bearer auth required)' : ''),
);
});
server.on('error', (err) => {
@@ -31,9 +31,6 @@ export enum QueueJob {
IMPORT_TASK = 'import-task',
EXPORT_TASK = 'export-task',
SEARCH_REMOVE_PAGE = 'search-remove-page',
SEARCH_REMOVE_ASSET = 'search-remove-attachment',
SEARCH_REMOVE_FACE = 'search-remove-comment',
TYPESENSE_FLUSH = 'typesense-flush',
PAGE_CREATED = 'page-created',
@@ -1,3 +1,8 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import { join } from 'node:path';
import Fastify, { FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import { resolveStaticAssetHeaders } from './static.module';
// Unit tests for the static-asset cache classifier extracted from the
@@ -33,3 +38,69 @@ describe('resolveStaticAssetHeaders', () => {
expect(headers['vary']).toBe('Accept-Encoding');
});
});
// Integration test proving the ACTUAL response header emitted by @fastify/static
// with the exact registration options StaticModule uses. This is the regression
// guard for #452: without `cacheControl: false`, @fastify/static writes its own
// `Cache-Control: public, max-age=0` AFTER the setHeaders callback, overwriting
// the immutable header — the /assets/ assertion below would then fail.
describe('static.module @fastify/static registration (integration)', () => {
let app: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'static-module-spec-'));
fs.mkdirSync(join(tmpDir, 'assets'), { recursive: true });
fs.mkdirSync(join(tmpDir, 'locales'), { recursive: true });
fs.writeFileSync(
join(tmpDir, 'assets', 'index-a1b2c3.js'),
'console.log(1);',
);
fs.writeFileSync(join(tmpDir, 'locales', 'en.json'), '{"hello":"world"}');
app = Fastify();
// Mirror StaticModule.onModuleInit's registration options exactly.
await app.register(fastifyStatic, {
root: tmpDir,
wildcard: false,
preCompressed: true,
cacheControl: false,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
)) {
res.setHeader(name, value);
}
},
});
await app.ready();
});
afterAll(async () => {
await app.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('serves a hashed /assets/ file with an immutable, 1-year cache-control', async () => {
const res = await app.inject({
method: 'GET',
url: '/assets/index-a1b2c3.js',
});
expect(res.statusCode).toBe(200);
const cacheControl = res.headers['cache-control'];
expect(cacheControl).toContain('immutable');
expect(cacheControl).toContain('max-age=31536000');
});
it('serves a non-hashed /locales/ file WITHOUT an immutable cache-control', async () => {
const res = await app.inject({ method: 'GET', url: '/locales/en.json' });
expect(res.statusCode).toBe(200);
// resolveStaticAssetHeaders sets no cache-control here and cacheControl:false
// stops @fastify/static from adding one, so the browser revalidates by
// etag/last-modified — either an absent header or one without `immutable`.
const cacheControl = res.headers['cache-control'];
if (cacheControl !== undefined) {
expect(cacheControl).not.toContain('immutable');
}
});
});
@@ -115,6 +115,11 @@ export class StaticModule implements OnModuleInit {
// Serve the build-time .br/.gz neighbour when the client accepts it
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
preCompressed: true,
// @fastify/static's default cacheControl:true writes its own
// Cache-Control (from maxAge, default 0) AFTER the setHeaders callback,
// silently overwriting the immutable header that resolveStaticAssetHeaders
// sets — disable it so setHeaders/resolveStaticAssetHeaders own the header.
cacheControl: false,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
@@ -182,6 +182,7 @@ describe('AiChatService run-stream attach [integration]', () => {
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => true,
isAiChatFinalStepLockdownEnabled: () => false,
} as any,
registry,
);
@@ -499,6 +500,7 @@ describe('AiChatService run-stream attach [integration]', () => {
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => true,
isAiChatFinalStepLockdownEnabled: () => false,
} as any,
registry,
);
@@ -0,0 +1,305 @@
import { Kysely } from 'kysely';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
import { AiChatRunService } from '../../src/core/ai-chat/ai-chat-run.service';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createUser,
createChat,
createMessage,
} from './db';
/**
* #487 commit 4 bidirectional reconcile + owner-write priority, real SQL.
*
* Proves the OBSERVABLE recovery properties against docmost_test:
* - the CONDITIONAL owner-write beats a reconcile stamp, and a stamp never
* clobbers a proper terminal row;
* - a LATE owner-finalize with real content OVERWRITES a reconcile 'aborted'
* stamp (finalizeFailed);
* - each reconcile clause (b message<-run, c stale-run, d historical row) settles
* the stuck row/run, and a LIVE run entry is never touched;
* - the "kill DB on finish" recovery: after the DB comes back, neither the
* message row nor the run row stays stuck.
*/
describe('#487 reconcile + owner-write priority [integration]', () => {
let db: Kysely<any>;
let messageRepo: AiChatMessageRepo;
let runRepo: AiChatRunRepo;
let runService: AiChatRunService;
let workspaceId: string;
let userId: string;
beforeAll(async () => {
db = getTestDb();
messageRepo = new AiChatMessageRepo(db as any);
runRepo = new AiChatRunRepo(db as any);
runService = new AiChatRunService(runRepo, { isCloud: () => false } as never);
workspaceId = (await createWorkspace(db)).id;
userId = (await createUser(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
const newChat = async () =>
(await createChat(db, { workspaceId, creatorId: userId })).id;
const metaOf = async (id: string): Promise<Record<string, unknown> | null> => {
const row = await messageRepo.findById(id, workspaceId);
return (row?.metadata as Record<string, unknown> | null) ?? null;
};
it('owner finalizeOwner writes a streaming row and CLEARS finalizeFailed', async () => {
const chatId = await newChat();
const m = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
metadata: { parts: [] },
});
const wrote = await messageRepo.finalizeOwner(m.id, workspaceId, {
content: 'final answer',
status: 'completed',
metadata: { parts: [{ type: 'text', text: 'final answer' }] },
} as never);
expect(wrote!.status).toBe('completed');
expect((await metaOf(m.id))?.finalizeFailed).toBeUndefined();
});
it('a reconcile stamp NEVER clobbers a proper terminal row (finalizeOwner is a no-op there)', async () => {
const chatId = await newChat();
const m = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'completed',
content: 'real',
metadata: { parts: [] },
});
// The reconcile stamp is onlyIfStreaming -> no-op on a completed row.
const stamped = await messageRepo.stampTerminalIfStreaming(
m.id,
workspaceId,
'aborted',
);
expect(stamped).toBeUndefined();
expect((await messageRepo.findById(m.id, workspaceId))!.status).toBe(
'completed',
);
});
it('LATE owner-finalize with real content OVERWRITES a reconcile aborted stamp', async () => {
const chatId = await newChat();
const m = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
metadata: { parts: [{ type: 'text', text: 'partial' }] },
});
// Reconcile stamps it aborted + finalizeFailed (final text lived only in mem).
const stamped = await messageRepo.stampTerminalIfStreaming(
m.id,
workspaceId,
'aborted',
);
expect(stamped!.status).toBe('aborted');
expect((await metaOf(m.id))?.finalizeFailed).toBe(true);
// A LATE owner-write (finalizeFailed=true satisfies the OR) overwrites it with
// real content, clearing the flag — owner-write priority.
const wrote = await messageRepo.finalizeOwner(m.id, workspaceId, {
content: 'the real final answer',
status: 'completed',
metadata: { parts: [{ type: 'text', text: 'the real final answer' }] },
} as never);
expect(wrote!.status).toBe('completed');
expect(wrote!.content).toBe('the real final answer');
expect((await metaOf(m.id))?.finalizeFailed).toBeUndefined();
});
it('clause (c): a stale active run with NO live entry -> aborted; a LIVE entry is untouched', async () => {
// Stale run, NOT owned by this replica (no entry) -> reconcile aborts it.
const staleChat = await newChat();
const stale = await runRepo.insert({
chatId: staleChat,
workspaceId,
createdBy: userId,
status: 'running',
});
await db
.updateTable('aiChatRuns')
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
.where('id', '=', stale.id)
.execute();
// A live run OWNED by this replica (beginRun registers an in-memory entry),
// ALSO backdated stale — the "no entry" primary gate must protect it.
const liveChat = await newChat();
const live = await runService.beginRun({
chatId: liveChat,
workspaceId,
userId,
});
await db
.updateTable('aiChatRuns')
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
.where('id', '=', live.runId)
.execute();
const aborted = await runService.reconcileStaleRuns(15 * 60 * 1000);
expect(aborted).toBeGreaterThanOrEqual(1);
expect((await runRepo.findById(stale.id, workspaceId))!.status).toBe(
'aborted',
);
// The live entry is NEVER aborted, however stale its row looks.
expect((await runRepo.findById(live.runId, workspaceId))!.status).toBe(
'running',
);
expect(runService.isLocallyActive(live.runId)).toBe(true);
// cleanup the live run
await runService.finalizeRun(live.runId, workspaceId, 'aborted');
});
it('clause (b): a streaming message whose RUN is terminal is stamped by run status (succeeded -> aborted, NOT completed-empty)', async () => {
const chatId = await newChat();
const msg = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
metadata: { parts: [] },
});
// A SUCCEEDED run linked to the still-streaming message (the asymmetry).
const run = await runRepo.insert({
chatId,
workspaceId,
createdBy: userId,
status: 'running',
assistantMessageId: msg.id,
});
await runRepo.finalizeIfActive(run.id, workspaceId, {
status: 'succeeded',
error: null,
});
const stuck = await messageRepo.findStreamingWithTerminalRun();
const mine = stuck.find((s) => s.messageId === msg.id);
expect(mine?.runStatus).toBe('succeeded');
// Reconcile clause (b): succeeded run -> message 'aborted' (NOT 'completed'),
// the final text lived only in memory (documented loss), +finalizeFailed.
const status = mine!.runStatus === 'failed' ? 'error' : 'aborted';
await messageRepo.stampTerminalIfStreaming(msg.id, workspaceId, status);
const row = await messageRepo.findById(msg.id, workspaceId);
expect(row!.status).toBe('aborted');
expect((row!.metadata as Record<string, unknown>).finalizeFailed).toBe(true);
});
it('clause (d): a stale streaming row with NO active run on the chat -> aborted+finalizeFailed', async () => {
const chatId = await newChat();
const msg = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
metadata: { parts: [] },
});
await db
.updateTable('aiChatMessages')
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
.where('id', '=', msg.id)
.execute();
const swept = await messageRepo.sweepStreamingWithoutActiveRun(
15 * 60 * 1000,
);
expect(swept).toBeGreaterThanOrEqual(1);
const row = await messageRepo.findById(msg.id, workspaceId);
expect(row!.status).toBe('aborted');
expect((row!.metadata as Record<string, unknown>).finalizeFailed).toBe(true);
});
it('clause (d) is DOUBLE-GATED: a stale streaming row WITH an active run on the chat is left alone', async () => {
const chatId = await newChat();
const msg = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
metadata: { parts: [] },
});
await db
.updateTable('aiChatMessages')
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
.where('id', '=', msg.id)
.execute();
// An ACTIVE run on the same chat -> clause (d) must NOT touch the message.
const run = await runRepo.insert({
chatId,
workspaceId,
createdBy: userId,
status: 'running',
});
await messageRepo.sweepStreamingWithoutActiveRun(15 * 60 * 1000);
expect((await messageRepo.findById(msg.id, workspaceId))!.status).toBe(
'streaming',
);
await runRepo.finalizeIfActive(run.id, workspaceId, {
status: 'aborted',
error: null,
});
});
it('"kill DB on finish" recovery: after the DB is back, reconcile leaves NEITHER the row nor the run stuck', async () => {
// Simulate a process that seeded the assistant row + run, then died before
// finalizing EITHER (a mid-turn crash): a streaming message + a running run,
// both stale, with no in-memory entry (fresh service = fresh maps).
const chatId = await newChat();
const msg = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
metadata: { parts: [{ type: 'text', text: 'partial' }] },
});
const run = await runRepo.insert({
chatId,
workspaceId,
createdBy: userId,
status: 'running',
assistantMessageId: msg.id,
});
await db
.updateTable('aiChatRuns')
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
.where('id', '=', run.id)
.execute();
await db
.updateTable('aiChatMessages')
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
.where('id', '=', msg.id)
.execute();
// Reconcile (as the periodic job would): (c) aborts the orphan run, then
// (b) settles the message from the now-terminal run.
await runService.reconcileStaleRuns(15 * 60 * 1000);
const stuck = await messageRepo.findStreamingWithTerminalRun();
for (const s of stuck) {
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
await messageRepo.stampTerminalIfStreaming(s.messageId, s.workspaceId, status);
}
// Neither is stuck: the run is terminal AND the message is terminal.
expect((await runRepo.findById(run.id, workspaceId))!.status).toBe('aborted');
const row = await messageRepo.findById(msg.id, workspaceId);
expect(row!.status).toBe('aborted');
expect((row!.metadata as Record<string, unknown>).finalizeFailed).toBe(true);
});
});
@@ -281,6 +281,52 @@ describe('AiChatRun durable lifecycle [integration]', () => {
});
});
it('#487 finalizeIfActive is CONDITIONAL: a late terminal write cannot clobber the settled status (real SQL)', async () => {
const c = (await createChat(db, { workspaceId, creatorId: userId })).id;
const run = await runRepo.insert({
chatId: c,
workspaceId,
createdBy: userId,
status: 'running',
});
// First terminal write: the run IS active, so it flips + returns the row.
const first = await runRepo.finalizeIfActive(run.id, workspaceId, {
status: 'succeeded',
error: null,
});
expect(first!.status).toBe('succeeded');
expect(first!.finishedAt).toBeTruthy();
// A late/second writer tries to flip it to 'aborted' — the WHERE status IN
// ('pending','running') guard matches NOTHING now, so it is a benign no-op.
const second = await runRepo.finalizeIfActive(run.id, workspaceId, {
status: 'aborted',
error: 'late clobber attempt',
});
expect(second).toBeUndefined();
// The persisted terminal status is UNCHANGED — last-writer-wins is gone.
const row = await runRepo.findById(run.id, workspaceId);
expect(row!.status).toBe('succeeded');
expect(row!.error).toBeNull();
});
it('#487 double-settle through the service collapses to one write at the SQL gate', async () => {
const c = (await createChat(db, { workspaceId, creatorId: userId })).id;
const handle = await service.beginRun({ chatId: c, workspaceId, userId });
// First settle writes 'aborted' via the conditional write.
await service.finalizeRun(handle.runId, workspaceId, 'aborted');
// A late safety-net settle to 'error' is a no-op (row already terminal).
await service.finalizeRun(handle.runId, workspaceId, 'error', 'late');
const row = await runRepo.findById(handle.runId, workspaceId);
expect(row!.status).toBe('aborted');
expect(service.isLocallyActive(handle.runId)).toBe(false);
expect(service.hasZombie(handle.runId)).toBe(false);
});
it('sweepRunning() with NO args (boot sweep / variant C) aborts even a FRESH running run', async () => {
// F1/DECISION C at the SQL level: the unconditional boot sweep has NO
// staleness window, so a run updated just now (a fast restart) is settled too
@@ -150,7 +150,7 @@ describe('AiChatService.stream [integration]', () => {
{} as any, // pageAccess (idem)
// environment (#332): keep deferred tool loading OFF for this lifecycle
// harness so the toolset/behavior is exactly as before.
{ isAiChatDeferredToolsEnabled: () => false } as any,
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as any,
);
}
@@ -378,7 +378,7 @@ describe('AiChatService.stream [integration]', () => {
{} as any,
{} as any,
// #332: deferred tool loading ON — the property under test.
{ isAiChatDeferredToolsEnabled: () => true } as any,
{ isAiChatDeferredToolsEnabled: () => true, isAiChatFinalStepLockdownEnabled: () => false } as any,
);
}
@@ -0,0 +1,123 @@
import { randomUUID } from 'node:crypto';
import { Kysely, sql } from 'kysely';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
} from './db';
/**
* #443 dead-index guard EXPLAIN on the REAL DB.
*
* The lookup mode's substring predicates run a leading-wildcard
* `LOWER(f_unaccent(col)) LIKE '%q%'`. Those are only fast when Postgres uses
* the GIN trigram indexes:
* - idx_pages_title_trgm on (LOWER(f_unaccent(title))) [#348]
* - idx_pages_text_content_trgm on (LOWER(f_unaccent(text_content))) [#443]
*
* Postgres uses a functional index ONLY when the query expression matches the
* index expression EXACTLY. The original lookup query wrapped the columns in
* `coalesce(col,'')`, which differs from the coalesce-FREE index expression and
* silently forced a Seq Scan on pages for EVERY lookup (the MCP client always
* sends substring:true). This test locks that in.
*
* Discriminator: `SET enable_seqscan = off` asks the planner "CAN this predicate
* use the index at all?" which is exactly what the coalesce bug breaks. With
* seqscan disabled:
* - the coalesce-FREE (fixed) predicate plans a Bitmap Index Scan on the trgm
* index (no Seq Scan on pages);
* - the coalesce-WRAPPED (buggy) predicate cannot use the index and falls back
* to a Seq Scan on pages even though seqscan is disabled.
* We assert both to prove the fix and to keep the regression from silently
* returning.
*/
describe('SearchService agent-lookup EXPLAIN — trgm index is live [integration]', () => {
let db: Kysely<any>;
let workspaceId: string;
let spaceId: string;
async function insertPage(title: string, textContent: string): Promise<void> {
const id = randomUUID();
await db
.insertInto('pages')
.values({
id,
slugId: `slug-${id.slice(0, 12)}`,
title,
textContent,
spaceId,
workspaceId,
})
.execute();
}
// Run EXPLAIN (no ANALYZE — we only inspect the chosen plan) and return the
// concatenated plan text.
async function explain(query: string): Promise<string> {
const rows = await sql<{ 'QUERY PLAN': string }>`EXPLAIN ${sql.raw(query)}`.execute(
db,
);
return (rows.rows as any[]).map((r) => r['QUERY PLAN']).join('\n');
}
beforeAll(async () => {
db = getTestDb();
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
// Seed enough rows that a trigram index is a plausible plan. The content is
// varied so the '%needle%' pattern is selective.
for (let i = 0; i < 200; i++) {
await insertPage(
`seed-title-${i}`,
`seed body content number ${i} lorem ipsum dolor sit amet ${i}`,
);
}
await insertPage('backup-srv.local', 'the needle-token-xyz lives here');
// Keep the trgm indexes' stats fresh so the planner costs them correctly.
await sql`ANALYZE pages`.execute(db);
});
afterAll(async () => {
await destroyTestDb();
});
// Force the planner to answer "can the index be used?" rather than "is it
// cheaper than a seq scan on this size?". Restored after each test.
beforeEach(async () => {
await sql`SET enable_seqscan = off`.execute(db);
});
afterEach(async () => {
await sql`RESET enable_seqscan`.execute(db);
});
it('title predicate (coalesce-FREE, as fixed) uses idx_pages_title_trgm, not a Seq Scan', async () => {
const plan = await explain(
`SELECT id FROM pages WHERE LOWER(f_unaccent(title)) LIKE '%srv.local%'`,
);
expect(plan).toContain('idx_pages_title_trgm');
expect(plan).not.toMatch(/Seq Scan on pages/i);
});
it('text_content predicate (coalesce-FREE, as fixed) uses idx_pages_text_content_trgm, not a Seq Scan', async () => {
const plan = await explain(
`SELECT id FROM pages WHERE LOWER(f_unaccent(text_content)) LIKE '%needle-token%'`,
);
expect(plan).toContain('idx_pages_text_content_trgm');
expect(plan).not.toMatch(/Seq Scan on pages/i);
});
// Negative control: the OLD coalesce-wrapped predicate must NOT be able to use
// the index — even with seqscan disabled it can only Seq Scan pages. If this
// ever stops seq-scanning, the coalesce/index expressions have re-aligned and
// the guard above is no longer meaningful.
it('coalesce-WRAPPED text predicate (the bug) cannot use the index — falls to Seq Scan', async () => {
const plan = await explain(
`SELECT id FROM pages WHERE LOWER(f_unaccent(coalesce(text_content,''))) LIKE '%needle-token%'`,
);
expect(plan).not.toContain('idx_pages_text_content_trgm');
expect(plan).toMatch(/Seq Scan on pages/i);
});
});
@@ -0,0 +1,462 @@
import { randomUUID } from 'node:crypto';
import { Kysely } from 'kysely';
import { SearchService } from 'src/core/search/search.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
} from './db';
/**
* #443 agent-lookup search mode, acceptance on the REAL DB schema.
*
* Exercises SearchService.searchPage(..., { substring: true }) against a
* migrated Postgres: substring matching of technical tokens the FTS tokenizer
* mangles (backup-srv.local, 10.0.12.5, WB-MGE-30D86B, "Теги: Docker"), the
* populated path + snippet, parentPageId subtree scoping, titleOnly, the empty
* result, LIKE-metacharacter escaping (`%`/`_` must NOT match everything), the
* permission post-filter applied BEFORE the limit, and the web-UI path staying
* on the legacy FTS shape when `substring` is absent.
*
* The tsv column is populated by the pages_tsvector_trigger on insert, so the
* FTS branch is exercised too.
*/
describe('SearchService agent-lookup mode [integration]', () => {
let db: Kysely<any>;
let service: SearchService;
let workspaceId: string;
let spaceId: string;
// Direct page insert (the shared createPage seeder omits text_content /
// parent_page_id, both of which this mode depends on). Returns the id.
async function insertPage(args: {
title: string;
textContent?: string;
parentPageId?: string | null;
spaceId?: string;
}): Promise<string> {
const id = randomUUID();
await db
.insertInto('pages')
.values({
id,
slugId: `slug-${id.slice(0, 12)}`,
title: args.title,
textContent: args.textContent ?? null,
parentPageId: args.parentPageId ?? null,
spaceId: args.spaceId ?? spaceId,
workspaceId,
})
.execute();
return id;
}
// Build a SearchService wired to the real DB + a real PageRepo (only its
// recursive-descendants method is used by this mode, and it needs only `db`),
// with lightweight stubs for the space-membership and permission repos so a
// test can drive scope + the permission post-filter explicitly.
function buildService(opts?: {
userSpaceIds?: string[];
// ids to KEEP after the permission post-filter; undefined = keep all.
accessibleIds?: string[];
}): SearchService {
const pageRepo = new PageRepo(db as any, null as any, null as any);
const spaceMemberRepo = {
getUserSpaceIds: async () => opts?.userSpaceIds ?? [spaceId],
};
const pagePermissionRepo = {
filterAccessiblePageIds: async ({ pageIds }: { pageIds: string[] }) =>
opts?.accessibleIds
? pageIds.filter((id) => opts.accessibleIds!.includes(id))
: pageIds,
};
return new SearchService(
db as any,
pageRepo as any,
{} as any, // shareRepo — unused by the lookup path
spaceMemberRepo as any,
pagePermissionRepo as any,
);
}
beforeAll(async () => {
db = getTestDb();
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
service = buildService();
});
afterAll(async () => {
await destroyTestDb();
});
it('finds `backup-srv.local` by the fragment `srv.local`', async () => {
const pageId = await insertPage({
title: 'backup-srv.local',
textContent: 'A backup server node.',
});
const { items } = (await service.searchPage(
{ query: 'srv.local', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
expect(items.map((i: any) => i.id)).toContain(pageId);
const hit = items.find((i: any) => i.id === pageId);
expect(hit.title).toBe('backup-srv.local');
// slugId must never be part of the server response shape.
expect('slugId' in hit).toBe(true); // server carries it; MCP strips it
});
it('finds a page whose TEXT contains `10.0.12.5` by the fragment `10.0.12` (empty-tsquery case)', async () => {
const pageId = await insertPage({
title: 'Server inventory',
textContent: 'The backup box lives at IP: 10.0.12.5. Debian 12, backups.',
});
const { items } = (await service.searchPage(
{ query: '10.0.12', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// The windowed snippet must include the matched text.
expect(hit.snippet).toContain('10.0.12.5');
});
it('finds `WB-MGE-30D86B` (alphanumeric token with dashes) by title', async () => {
const pageId = await insertPage({
title: 'WB-MGE-30D86B',
textContent: 'Device page.',
});
const { items } = (await service.searchPage(
{ query: 'WB-MGE-30D86B', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// Exact title match → top tier (TITLE_EXACT=3) → score in [0.75, 1].
expect(hit.score).toBeGreaterThanOrEqual(0.75);
// And it is the top-ranked hit of its own result set.
expect(items[0].id).toBe(pageId);
});
it('finds every page whose text literally contains `Теги: Docker`', async () => {
const a = await insertPage({
title: 'Container host A',
textContent: 'Some notes.\nТеги: Docker, compose\nmore.',
});
const b = await insertPage({
title: 'Container host B',
textContent: 'Prelude.\nТеги: Docker\nepilogue.',
});
const noise = await insertPage({
title: 'Unrelated',
textContent: 'Теги: Kubernetes',
});
const { items } = (await service.searchPage(
{ query: 'Теги: Docker', spaceId, substring: true, limit: 50 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(a);
expect(ids).toContain(b);
expect(ids).not.toContain(noise);
});
it('populates a non-empty `path` for a nested hit and `[]` for a root hit', async () => {
const root = await insertPage({ title: 'Infrastructure' });
const mid = await insertPage({ title: 'Datacenter A', parentPageId: root });
const leaf = await insertPage({
title: 'unique-nested-host',
parentPageId: mid,
});
const { items } = (await service.searchPage(
{ query: 'unique-nested-host', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === leaf);
expect(hit.path).toEqual(['Infrastructure', 'Datacenter A']);
const rootHits = (await service.searchPage(
{ query: 'Infrastructure', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const rootHit = rootHits.items.find((i: any) => i.id === root);
expect(rootHit.path).toEqual([]);
});
it('scopes to a subtree with parentPageId (cutting off sibling branches)', async () => {
const branchA = await insertPage({ title: 'BranchA-root' });
const inA = await insertPage({
title: 'scoped-target-xyz',
parentPageId: branchA,
});
const branchB = await insertPage({ title: 'BranchB-root' });
const inB = await insertPage({
title: 'scoped-target-xyz',
parentPageId: branchB,
});
const { items } = (await service.searchPage(
{
query: 'scoped-target-xyz',
spaceId,
substring: true,
parentPageId: branchA,
} as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(inA);
expect(ids).not.toContain(inB);
});
it('includes the parent page itself in the parentPageId subtree', async () => {
const parent = await insertPage({ title: 'self-included-parent' });
await insertPage({ title: 'child-of-self', parentPageId: parent });
const { items } = (await service.searchPage(
{
query: 'self-included-parent',
spaceId,
substring: true,
parentPageId: parent,
} as any,
{ workspaceId },
)) as any;
expect(items.map((i: any) => i.id)).toContain(parent);
});
it('titleOnly does NOT match on text_content', async () => {
const pageId = await insertPage({
title: 'Plain title',
textContent: 'body mentions the-secret-token here',
});
const withText = (await service.searchPage(
{ query: 'the-secret-token', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
expect(withText.items.map((i: any) => i.id)).toContain(pageId);
const titleOnly = (await service.searchPage(
{
query: 'the-secret-token',
spaceId,
substring: true,
titleOnly: true,
} as any,
{ workspaceId },
)) as any;
expect(titleOnly.items.map((i: any) => i.id)).not.toContain(pageId);
});
// #443 Fix #1 regression: f_unaccent is NOT length-preserving, so an
// expanding char (ß→ss, …→...) BEFORE the match shifted the strpos position
// relative to the ORIGINAL text and the snippet slice ran past end → empty.
// The position and the slice now share the LOWER(f_unaccent(...)) space, so
// the window is aligned and always contains the matched (unaccented) token.
it('returns a populated snippet when an unaccent-EXPANDING char precedes the match', async () => {
// 300 × `ß` (each f_unaccent-expands to `ss`) before the needle. Under the
// old code strpos returned a position ~593 in the expanded space but the
// slice ran over the ORIGINAL (~360 char) text → empty snippet, match lost.
const prefix = 'ß'.repeat(300);
const pageId = await insertPage({
title: 'Expanding-unaccent page',
textContent: `${prefix} needle-token-xyz trailing.`,
});
const { items } = (await service.searchPage(
{ query: 'needle-token-xyz', spaceId, substring: true } as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// Snippet must be non-empty AND contain the matched token (unaccented form).
expect(hit.snippet.length).toBeGreaterThan(0);
expect(hit.snippet).toContain('needle-token-xyz');
});
// #443 Fix #2 regression: >200 matching pages for a broad substring, with
// exactly ONE exact-title hit. Without an ORDER BY on the 200-cap the exact
// hit could be among the arbitrarily-dropped rows; the ORDER BY keeps the
// strongest candidates so it must survive the cap and rank at the top.
it('keeps an exact-title hit through the 200-cap on a >200-row match set', async () => {
const isoSpace = (await createSpace(db, workspaceId)).id;
const svc = buildService({ userSpaceIds: [isoSpace] });
// 250 low-tier TEXT hits: the shared substring `capword` appears only in the
// body, never the title, so each is a TEXT-tier match (weakest tier).
for (let i = 0; i < 250; i++) {
await insertPage({
title: `filler-page-${i}`,
textContent: `body contains capword here #${i}`,
spaceId: isoSpace,
});
}
// Exactly one EXACT-title hit for the same query token.
const exact = await insertPage({
title: 'capword',
textContent: 'unrelated body text',
spaceId: isoSpace,
});
const { items } = (await svc.searchPage(
{ query: 'capword', spaceId: isoSpace, substring: true, limit: 10 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
// The exact-title hit must survive the 200-cap and appear in the top `limit`.
expect(ids).toContain(exact);
// And, being TITLE_EXACT, it must be the single strongest hit.
expect(items[0].id).toBe(exact);
});
// #443 Fix #3: titleOnly matches only the title, so it must not leak the page
// body as the snippet (the old "first 300 chars of text_content" fallback).
it('titleOnly does NOT return a text-body snippet', async () => {
const pageId = await insertPage({
title: 'titleonly-snippet-page',
textContent: 'SECRET-BODY-CONTENT-NOT-IN-TITLE that must not leak.',
});
const { items } = (await service.searchPage(
{
query: 'titleonly-snippet-page',
spaceId,
substring: true,
titleOnly: true,
} as any,
{ workspaceId },
)) as any;
const hit = items.find((i: any) => i.id === pageId);
expect(hit).toBeDefined();
// The body text must not appear in the snippet; titleOnly → empty snippet.
expect(hit.snippet).not.toContain('SECRET-BODY-CONTENT-NOT-IN-TITLE');
expect(hit.snippet).toBe('');
});
it('returns [] (not an error) for a query that matches nothing', async () => {
const { items } = (await service.searchPage(
{
query: 'zzz-no-such-string-anywhere-42',
spaceId,
substring: true,
} as any,
{ workspaceId },
)) as any;
expect(items).toEqual([]);
});
it('a `%` query does NOT match everything (LIKE metacharacter escaped)', async () => {
// Fresh space so we can assert on total counts without cross-test noise.
const isoSpace = (await createSpace(db, workspaceId)).id;
const svc = buildService({ userSpaceIds: [isoSpace] });
await insertPage({ title: 'alpha', spaceId: isoSpace });
await insertPage({ title: 'beta', spaceId: isoSpace });
const literal = await insertPage({
title: '100%-coverage',
spaceId: isoSpace,
});
const { items } = (await svc.searchPage(
{ query: '%', spaceId: isoSpace, substring: true, limit: 50 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
// `%` is a literal → matches only the page that actually contains '%'.
expect(ids).toContain(literal);
expect(ids).not.toContain(
items.find((i: any) => i.title === 'alpha')?.id,
);
expect(items.length).toBe(1);
});
it('an `_` query does NOT match everything (LIKE metacharacter escaped)', async () => {
const isoSpace = (await createSpace(db, workspaceId)).id;
const svc = buildService({ userSpaceIds: [isoSpace] });
await insertPage({ title: 'gamma', spaceId: isoSpace });
const literal = await insertPage({
title: 'snake_case_name',
spaceId: isoSpace,
});
const { items } = (await svc.searchPage(
{ query: '_', spaceId: isoSpace, substring: true, limit: 50 } as any,
{ workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(literal);
expect(items.length).toBe(1);
});
it('applies the permission post-filter to the MERGED set BEFORE the limit', async () => {
const isoSpace = (await createSpace(db, workspaceId)).id;
const keep = await insertPage({
title: 'perm-visible-target',
spaceId: isoSpace,
});
const hidden = await insertPage({
title: 'perm-hidden-target',
spaceId: isoSpace,
});
// Authenticated (userId set) so the permission filter runs; only `keep` is
// accessible. limit 1 must NOT be able to select `hidden`.
const svc = buildService({
userSpaceIds: [isoSpace],
accessibleIds: [keep],
});
const { items } = (await svc.searchPage(
{
query: 'perm-',
spaceId: isoSpace,
substring: true,
limit: 1,
} as any,
{ userId: 'user-1', workspaceId },
)) as any;
const ids = items.map((i: any) => i.id);
expect(ids).toContain(keep);
expect(ids).not.toContain(hidden);
});
it('web-UI path (no `substring` flag) keeps the legacy FTS response shape', async () => {
await insertPage({
title: 'legacy shape page',
textContent: 'searchable legacyword content',
});
const { items } = (await service.searchPage(
{ query: 'legacyword', spaceId } as any,
{ userId: 'user-1', workspaceId },
)) as any;
// Legacy hits carry rank + highlight + space, and NO path/snippet/score.
const hit = items[0];
expect(hit).toBeDefined();
expect('rank' in hit).toBe(true);
expect('highlight' in hit).toBe(true);
expect('path' in hit).toBe(false);
expect('snippet' in hit).toBe(false);
expect('score' in hit).toBe(false);
});
});
+120 -88
View File
@@ -12,7 +12,7 @@ license.
> better at *writing a small function that fixes the text* than at re-reading and
> re-emitting a whole document. So this server is built around the way a model actually
> wants to edit: address a block by id, run a find/replace, or hand it a
> `(doc, ctx) => doc` transform and let it *program* the change. `docmost_transform` is
> `(doc, ctx) => doc` transform and let it *program* the change. `docmostTransform` is
> that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and
> "replace the page"; this one exposes the editing primitives a model is good at.
@@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
| **Enterprise license required** | **No** | **Yes** | No | No | No |
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
| **Lossless Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
| **Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
@@ -69,9 +69,9 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
- **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer
"replace the whole page" writes — the agent must download the entire document, mutate
it, and upload it back, paying for the full document **twice** on every tiny fix.
This server lets the agent change exactly one block (`patch_node` / `insert_node` /
`delete_node`), do a structure-preserving find/replace (`edit_page_text`), or copy a
whole page server-side (`copy_page_content`) — **without the document ever passing
This server lets the agent change exactly one block (`patchNode` / `insertNode` /
`deleteNode`), do a structure-preserving find/replace (`editPageText`), or copy a
whole page server-side (`copyPageContent`) — **without the document ever passing
through the model**.
- **Writes that don't fight the editor.** Naive REST writes race with whatever a human
@@ -85,12 +85,12 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
- **Agent-native editing model.** Human-facing servers expose "open the page" and "replace
the page", because that mirrors how a person works. A model edits better by *programming*
the change — addressing blocks by id, running a find/replace, or supplying a
`(doc, ctx) => doc` transform (`docmost_transform`, with a dry-run diff before it
`(doc, ctx) => doc` transform (`docmostTransform`, with a dry-run diff before it
commits). This server is shaped around that, which is why it has editing primitives the
others simply don't.
- **An editing safety net the others lack.** `list_page_history``diff_page_versions`
`restore_page_version` give an agent (and you) a full view-and-undo loop. The diff
- **An editing safety net the others lack.** `listPageHistory``diffPageVersions`
`restorePageVersion` give an agent (and you) a full view-and-undo loop. The diff
uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's
own history viewer uses, so what you see matches the product.
@@ -110,52 +110,58 @@ All 41 tools, grouped by what you'd reach for them.
### Exploration & retrieval
- **`get_workspace`** — Information about the current Docmost workspace.
- **`list_spaces`** — All spaces in the workspace.
- **`list_pages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
- **`getWorkspace`** — Information about the current Docmost workspace.
- **`listSpaces`** — All spaces in the workspace.
- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
max 100). Use `search` for lookups in large spaces.
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
- **`get_page`** — A page's content as clean **Markdown** (convenient, but a *lossy*
view — block ids and exact table/callout structure are approximated).
- **`get_page_json`** — A page's **lossless ProseMirror/TipTap JSON**, including every
- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
table spans/colwidth/background, indent, `callout.icon`, `orderedList.type`, and link
`internal`/`target`/`rel`/`class`; use `getPageJson` when you need those).
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
tools consume.
- **`get_outline`** — A compact outline of a page's top-level blocks (`{index, type, id,
- **`getOutline`** — A compact outline of a page's top-level blocks (`{index, type, id,
level, firstText}`; tables add row/column counts and their header-cell texts, lists add
item counts) **without** the document body. The cheap way to locate a section or table
and grab its block id before
`get_node` / `patch_node` / `insert_node`.
- **`get_node`** — Fetch a single block's full ProseMirror subtree (lossless) without
pulling the whole page. Address it by a block id (from `get_outline` / `get_page_json`),
`getNode` / `patchNode` / `insertNode`.
- **`getNode`** — Fetch a single block's full ProseMirror subtree (lossless) without
pulling the whole page. Address it by a block id (from `getOutline` / `getPageJson`),
or by `#<index>` for a top-level block — use the `#<index>` form for tables/rows/cells,
which carry no id.
### Page lifecycle
- **`create_page`** — Create a page from Markdown and place it in the hierarchy (optional
- **`createPage`** — Create a page from Markdown and place it in the hierarchy (optional
`parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror.
- **`rename_page`** — Change a page's title only, without touching or resending content.
- **`move_page`** — Re-parent a page (nest it, or move to root); supports fractional-index
- **`renamePage`** — Change a page's title only, without touching or resending content.
- **`movePage`** — Re-parent a page (nest it, or move to root); supports fractional-index
positioning. Returns only on a *positively confirmed* success.
- **`delete_page`** — Delete a single page.
- **`copy_page_content`** — Replace one page's body with a copy of another's, **entirely
- **`deletePage`** — Delete a single page.
- **`copyPageContent`** — Replace one page's body with a copy of another's, **entirely
server-side** — the document never passes through the model. The target keeps its own
title and slug (so its URL is preserved).
### Editing
- **`edit_page_text`** — Surgical find/replace inside a page's text. Preserves **all**
- **`editPageText`** — Surgical find/replace inside a page's text. Preserves **all**
structure: block ids, marks, links, callouts, tables. The preferred tool for fixing
wording, typos, numbers and names.
- **`patch_node`** — Replace a single block addressed by its `attrs.id` (from
`get_page_json`), without resending the document.
- **`insert_node`** — Insert a block before/after another (by `attrs.id` or anchor text),
- **`patchNode`** — Replace a single block addressed by its `attrs.id` (from
`getPageJson`), without resending the document.
- **`insertNode`** — Insert a block before/after another (by `attrs.id` or anchor text),
or append at the end.
- **`delete_node`** — Remove a single block by its `attrs.id`.
- **`update_page_json`** — Replace a page's entire content with a ProseMirror document
- **`deleteNode`** — Remove a single block by its `attrs.id`.
- **`updatePageJson`** — Replace a page's entire content with a ProseMirror document
(bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only
the title. Keeps the block ids you pass in, so heading anchors and history stay stable.
- **`docmost_transform`** — The agent-native editing interface: instead of retyping a
- **`updatePageMarkdown`** — Replace a page's body (and optionally its title) with new
**plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or
id-preserving edits prefer `editPageText` / `patchNode` / `updatePageJson`).
Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes.
- **`docmostTransform`** — The agent-native editing interface: instead of retyping a
document, the agent **writes a function that fixes it**. Edit a page by running an
arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror
document. Runs **sandboxed**
@@ -168,42 +174,46 @@ All 41 tools, grouped by what you'd reach for them.
### Tables
- **`table_get`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
- **`tableGet`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
(a paragraph id per cell, or `null`). Address the table by `#<index>` (from
`get_outline`) or any block id inside it. Use `cellIds` with `patch_node` for
`getOutline`) or any block id inside it. Use `cellIds` with `patchNode` for
rich-formatted cell edits.
- **`table_insert_row`** — Insert a row of plain-text cells, padded to the table's column
- **`tableInsertRow`** — Insert a row of plain-text cells, padded to the table's column
count (passing more cells than columns is an error). `index` is the 0-based insert
position (0 inserts before the header); omit it to append at the end.
- **`table_delete_row`** — Delete the row at a 0-based `index`. Refuses to delete a table's
- **`tableDeleteRow`** — Delete the row at a 0-based `index`. Refuses to delete a table's
only row; deleting row 0 promotes the next row to header.
- **`table_update_cell`** — Set the plain-text content of cell `[row, col]` (0-based). For
rich formatting, `patch_node` the cell's paragraph id from `table_get`.
- **`tableUpdateCell`** — Set the plain-text content of cell `[row, col]` (0-based). For
rich formatting, `patchNode` the cell's paragraph id from `tableGet`.
### Markdown round-trip
- **`export_page_markdown`** — Export a page to a single self-contained, **lossless
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
and diagrams, and a trailing comments-thread block. Built for a download → edit body
`import_page_markdown` round-trip that preserves everything, including comment highlights.
- **`import_page_markdown`** — Replace a page's content from a Docmost-flavoured Markdown
file produced by `export_page_markdown`, restoring comment-highlight anchors and diagrams
from their inline HTML. (Comment *threads* in the file are not re-created on the server —
only the page body and inline comment marks are written; manage threads via the comment
tools/UI.)
- **`exportPageMarkdown`** — Export a page to a single self-contained
**Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
and diagrams, and a trailing comments-thread block. The download → edit → import
round-trip regenerates block ids and **silently drops** the no-Markdown-representation
attr set (table merge spans/colwidth/background, indent, `callout.icon`,
`orderedList.type`, link `internal`/`target`/`rel`/`class`); keep those in ProseMirror
JSON if they must survive. To replace a page's body from plain authoring Markdown, use
`updatePageMarkdown`.
> **Removed in this release:** `importPageMarkdown` (the round-trip parser for an
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
> To replace a page's body from Markdown, use **`updatePageMarkdown`** (plain Markdown
> body replace). See the CHANGELOG for the migration note.
### Images
- **`insert_image`** — Download an image from a web (http/https) URL and insert it in one
- **`insertImage`** — Download an image from a web (http/https) URL and insert it in one
step: append it, drop it in place of a text placeholder (`replaceText`), or put it after
a given block (`afterText`). Preserves all other block ids.
- **`replace_image`** — Swap an existing image for one fetched from a web (http/https) URL.
- **`replaceImage`** — Swap an existing image for one fetched from a web (http/https) URL.
Uploads the new file as a **fresh
attachment** (clean URL that renders and busts browser caches), then re-points every
node referencing the old attachment (recursively, including callouts/tables) via the
live document, preserving comments, alignment and alt text. (In-place overwrite is
deliberately avoided — some Docmost versions corrupt the attachment on overwrite.)
- **`stash_page`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
- **`stashPage`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
in-RAM blob and return ONLY a short anonymous URL — the body never enters the model
context, so it is the way to hand a large page (and its images) to an external consumer
without truncation. Every internal file/image attachment is mirrored into the same
@@ -214,35 +224,35 @@ All 41 tools, grouped by what you'd reach for them.
### Comments
- **`create_comment`** — Add a page comment, optionally **anchored inline** to an exact
- **`createComment`** — Add a page comment, optionally **anchored inline** to an exact
span of text (the first occurrence is wrapped in a comment mark).
- **`list_comments`** — List a page's comments (content returned as Markdown).
- **`update_comment`** — Edit an existing comment.
- **`delete_comment`** — Delete a comment.
- **`resolve_comment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
comments can be resolved; the thread and its replies are kept, unlike `delete_comment`.
- **`check_new_comments`** — Find comments created after a given ISO-8601 timestamp across
- **`listComments`** — List a page's comments (content returned as Markdown).
- **`updateComment`** — Edit an existing comment.
- **`deleteComment`** — Delete a comment.
- **`resolveComment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
comments can be resolved; the thread and its replies are kept, unlike `deleteComment`.
- **`checkNewComments`** — Find comments created after a given ISO-8601 timestamp across
a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for
feedback.
### Versioning & history
- **`list_page_history`** — A page's saved versions (Docmost auto-snapshots on save),
- **`listPageHistory`** — A page's saved versions (Docmost auto-snapshots on save),
newest first, cursor-paginated. Each item's id is the `historyId`.
- **`diff_page_versions`** — Diff two versions (or a version against the live page).
- **`diffPageVersions`** — Diff two versions (or a version against the live page).
Returns inserted/deleted text, integrity counts (images, links, tables, callouts,
footnote markers), and a human-readable Markdown summary — computed with the same
pipeline Docmost's own history viewer uses.
- **`restore_page_version`** — Write a saved version back as the current content. Docmost
- **`restorePageVersion`** — Write a saved version back as the current content. Docmost
has no restore endpoint, so this creates a **new** snapshot — the restore is itself
revertible.
### Sharing
- **`share_page`** — Make a page publicly accessible (idempotent) and return its public
- **`sharePage`** — Make a page publicly accessible (idempotent) and return its public
URL (`<app>/share/<key>/p/<slugId>`); optional search-engine indexing.
- **`unshare_page`** — Revoke a page's public share.
- **`list_shares`** — All public shares in the workspace, with titles and public URLs.
- **`unsharePage`** — Revoke a page's public share.
- **`listShares`** — All public shares in the workspace, with titles and public URLs.
---
@@ -251,26 +261,27 @@ All 41 tools, grouped by what you'd reach for them.
This same guidance is also delivered at runtime via the MCP server `instructions` field,
so capable clients steer the model automatically.
- **Text fixes** (wording, typos, numbers): `edit_page_text`.
- **One block** (paragraph/heading/callout/table cell): `patch_node` / `insert_node` /
`delete_node`, addressing the node by its `attrs.id` from `get_page_json`.
- **Images**: `insert_image` / `replace_image`.
- **A new page**: `create_page`.
- **Bulk rewrite, or nodes without ids**: `update_page_json`.
- **Text fixes** (wording, typos, numbers): `editPageText`.
- **One block** (paragraph/heading/callout/table cell): `patchNode` / `insertNode` /
`deleteNode`, addressing the node by its `attrs.id` from `getPageJson`.
- **Images**: `insertImage` / `replaceImage`.
- **A new page**: `createPage`.
- **Bulk rewrite, or nodes without ids**: `updatePageJson` (ProseMirror) or
`updatePageMarkdown` (plain Markdown body replace).
- **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits):
`docmost_transform` — preview with `dryRun`, then apply.
- **Copy a whole page's content from another page** (server-side): `copy_page_content`.
- **Rename a page** (title only): `rename_page`.
- **Reads**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror with ids).
- **Review changes**: `list_page_history``diff_page_versions``restore_page_version`.
- **Comments**: `create_comment` (with optional inline anchoring) / `list_comments` /
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`.
- **Navigate a page cheaply** (find a section/table, grab a block id): `get_outline`
`get_node`.
- **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` /
`table_delete_row` / `table_update_cell`.
- **Round-trip a page as Markdown** (download, edit, re-upload losslessly with comments):
`export_page_markdown` / `import_page_markdown`.
`docmostTransform` — preview with `dryRun`, then apply.
- **Copy a whole page's content from another page** (server-side): `copyPageContent`.
- **Rename a page** (title only): `renamePage`.
- **Reads**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror with ids).
- **Review changes**: `listPageHistory``diffPageVersions``restorePageVersion`.
- **Comments**: `createComment` (with optional inline anchoring) / `listComments` /
`updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
- **Navigate a page cheaply** (find a section/table, grab a block id): `getOutline`
`getNode`.
- **Tables** (add/remove a row, set a cell): `tableGet` / `tableInsertRow` /
`tableDeleteRow` / `tableUpdateCell`.
- **Export a page as self-contained Markdown** (with comment anchors): `exportPageMarkdown`.
- **Replace a page's body from Markdown**: `updatePageMarkdown`.
---
@@ -282,25 +293,46 @@ so capable clients steer the model automatically.
the debounced REST snapshot), then **reads → transforms → writes synchronously** in one
tick so no remote update can interleave, and **waits for persistence acknowledgement**
before returning.
- **Per-page write serialization.** A per-`pageId` async mutex ensures two MCP writes to
the same page never overlap; different pages never block each other.
- **Per-page write serialization.** A per-`pageId` async mutex (keyed by the resolved
page **UUID**, never a slugId) ensures two MCP writes to the same page never overlap;
different pages never block each other. The lock helper fails fast if it is ever handed
a non-UUID key, so a write path that forgot to resolve the id can never silently lock
under a split key.
**Deploy requirement — single instance or sticky sessions.** This mutex is an
in-process `Map`, and the cached collab sessions and the `stash_page` blob store are
RAM-only and process-local. Behind a **multi-replica** load balancer **without sticky
sessions**, two replicas can each "hold" the lock for the same page at once and per-page
serialization is silently lost. Run the MCP/app as a **single instance**, or pin each
page's traffic to one replica (sticky sessions / consistent hashing on the page id).
There is deliberately no cross-process (e.g. Postgres advisory) lock yet — a conscious
documented constraint. See the `Dockerfile` comment and the `MCP collaboration write
path` block in `.env.example`.
**Rights-staleness window.** A cached collab session writes under the token captured at
connect time (and the collab-token cache reuses a token for its TTL), so a **revoked**
page access can lag by up to `MCP_COLLAB_SESSION_MAX_AGE_MS` (the hard session lifetime,
default 10 min) before the next re-auth picks it up. Lower it to shorten the lag at the
cost of more reconnects. This bounded window is an accepted trade-off; there is no
push-based cache invalidation on a rights change.
- **Transparent re-authentication.** Login uses email/password; expired tokens are
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
collaboration-token path), with in-flight login de-duplication so a burst of calls
triggers a single re-login.
- **Lossless and lossy reads.** `get_page_json` returns the exact ProseMirror tree with
block ids; `get_page` returns clean Markdown for convenience.
- **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids;
`getPage` returns canonical Markdown that drops only a fixed, documented attr set.
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
highlights, sub/superscript and more, with defensive caps against pathological input.
- **Structured tables & lossless Markdown round-trip.** Tables can be edited as a matrix
- **Structured tables & Markdown round-trip.** Tables can be edited as a matrix
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
a page can be exported to and re-imported from a self-contained Docmost-flavoured
Markdown file that preserves inline comment anchors and diagrams.
Markdown file that preserves inline comment anchors and diagrams (block ids regenerate
and a fixed no-Markdown-representation attr set is dropped — see `exportPageMarkdown`).
- **Token-optimized responses.** API responses are filtered down to the fields agents
actually need, and large collections (spaces, pages, comments, history) are paginated.
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
stdio server; `move_page` requires a positively confirmed success; the diff engine
stdio server; `movePage` requires a positively confirmed success; the diff engine
falls back to a coarse block diff rather than hard-failing on a pathological document.
---
@@ -358,7 +390,7 @@ npm run test:e2e
This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp)
(by Moritz Krause) and extends it substantially — adding per-block node editing,
surgical text edits, the sandboxed `docmost_transform`, version history / diff / restore,
surgical text edits, the sandboxed `docmostTransform`, version history / diff / restore,
comments, image insert/replace, public sharing, server-side page copy, dual
JSON/Markdown reads, transparent re-authentication and significant hardening. The comment
tools were ported from upstream PR #3 by Max Nikitin. Thanks to both.
+122 -90
View File
@@ -12,7 +12,7 @@
> небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь
> документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно
> редактировать: адресовать блок по id, сделать find/replace или передать трансформ
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmost_transform`
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmostTransform`
> это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают
> «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых
> модель сильна.
@@ -43,7 +43,7 @@ Docmost-MCP не сочетают:
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
| **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
@@ -71,9 +71,9 @@ Docmost-MCP не сочетают:
- **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный)
предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ,
изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой
правке. Этот сервер позволяет агенту изменить ровно один блок (`patch_node` /
`insert_node` / `delete_node`), сделать find/replace с сохранением структуры
(`edit_page_text`) или скопировать страницу на стороне сервера (`copy_page_content`) —
правке. Этот сервер позволяет агенту изменить ровно один блок (`patchNode` /
`insertNode` / `deleteNode`), сделать find/replace с сохранением структуры
(`editPageText`) или скопировать страницу на стороне сервера (`copyPageContent`) —
**причём документ ни разу не проходит через модель**.
- **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем,
@@ -87,12 +87,12 @@ Docmost-MCP не сочетают:
- **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть
страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель
редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или
передавая трансформ `(doc, ctx) => doc` (`docmost_transform`, с dry-run диффом перед
передавая трансформ `(doc, ctx) => doc` (`docmostTransform`, с dry-run диффом перед
коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы
редактирования, которых у остальных просто нет.
- **Страховка при редактировании, которой нет у других.** `list_page_history`
`diff_page_versions``restore_page_version` дают агенту (и вам) полный цикл «посмотреть
- **Страховка при редактировании, которой нет у других.** `listPageHistory`
`diffPageVersions``restorePageVersion` дают агенту (и вам) полный цикл «посмотреть
и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet →
simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает
с продуктом.
@@ -113,55 +113,62 @@ Docmost-MCP не сочетают:
### Чтение и поиск
- **`get_workspace`** — Информация о текущем воркспейсе Docmost.
- **`list_spaces`** — Все пространства воркспейса.
- **`list_pages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
- **`getWorkspace`** — Информация о текущем воркспейсе Docmost.
- **`listSpaces`** — Все пространства воркспейса.
- **`listPages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
50, максимум 100). Для поиска в больших пространствах используйте `search`.
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
100).
- **`get_page`** — Контент страницы как чистый **Markdown** (удобно, но это
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются).
- **`get_page_json`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
- **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет
лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
используйте `getPageJson`, когда они нужны).
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
поблочного редактирования.
- **`get_outline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
- **`getOutline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек
заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить
id блока перед `get_node` / `patch_node` / `insert_node`.
- **`get_node`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
вытягивая всю страницу. Адресуйте его по id блока (из `get_outline` / `get_page_json`)
id блока перед `getNode` / `patchNode` / `insertNode`.
- **`getNode`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
вытягивая всю страницу. Адресуйте его по id блока (из `getOutline` / `getPageJson`)
или формой `#<index>` для блока верхнего уровня — используйте `#<index>` для
таблиц/строк/ячеек, у которых нет id.
### Жизненный цикл страниц
- **`create_page`** — Создать страницу из Markdown и поместить в иерархию (опционально
- **`createPage`** — Создать страницу из Markdown и поместить в иерархию (опционально
`parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации
Markdown→ProseMirror.
- **`rename_page`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
- **`move_page`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
- **`renamePage`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
- **`movePage`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
позиционирование по fractional-index. Возвращает успех только при *положительно
подтверждённом* результате.
- **`delete_page`** — Удалить одну страницу.
- **`copy_page_content`** — Заменить тело одной страницы копией тела другой, **полностью на
- **`deletePage`** — Удалить одну страницу.
- **`copyPageContent`** — Заменить тело одной страницы копией тела другой, **полностью на
стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются
собственные заголовок и slug (URL не меняется).
### Редактирование
- **`edit_page_text`** — Хирургический find/replace внутри текста страницы. Сохраняет
- **`editPageText`** — Хирургический find/replace внутри текста страницы. Сохраняет
**всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный
инструмент для правки формулировок, опечаток, чисел и имён.
- **`patch_node`** — Заменить один блок, адресованный по `attrs.id` (из `get_page_json`),
- **`patchNode`** — Заменить один блок, адресованный по `attrs.id` (из `getPageJson`),
без пересылки документа.
- **`insert_node`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
- **`insertNode`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
либо добавить в конец.
- **`delete_node`** — Удалить один блок по его `attrs.id`.
- **`update_page_json`** — Заменить весь контент страницы документом ProseMirror (массовые
- **`deleteNode`** — Удалить один блок по его `attrs.id`.
- **`updatePageJson`** — Заменить весь контент страницы документом ProseMirror (массовые
перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить
только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история
остаются стабильными.
- **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо
- **`updatePageMarkdown`** — Заменить тело страницы (и опционально заголовок) новым
**обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для
хирургических правок или сохранения id используйте `editPageText` / `patchNode` /
`updatePageJson`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`.
- **`docmostTransform`** — Агентоориентированный интерфейс редактирования: вместо
перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует
страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом*
документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети,
@@ -173,43 +180,46 @@ Docmost-MCP не сочетают:
### Таблицы
- **`table_get`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
- **`tableGet`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#<index>` (из
`get_outline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patch_node`
`getOutline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patchNode`
для правок ячеек с форматированием.
- **`table_insert_row`** — Вставить строку из текстовых ячеек, дополненную до числа
- **`tableInsertRow`** — Вставить строку из текстовых ячеек, дополненную до числа
столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based
позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец.
- **`table_delete_row`** — Удалить строку по 0-based `index`. Отказывается удалять
- **`tableDeleteRow`** — Удалить строку по 0-based `index`. Отказывается удалять
единственную строку таблицы; удаление строки 0 делает заголовком следующую строку.
- **`table_update_cell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
форматирования используйте `patch_node` по id абзаца ячейки из `table_get`.
- **`tableUpdateCell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
форматирования используйте `patchNode` по id абзаца ячейки из `tableGet`.
### Markdown: экспорт и импорт
- **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
диаграммами и завершающий блок тредов комментариев. Рассчитан на цикл «скачать →
отредактировать тело → `import_page_markdown`», сохраняющий всё, включая выделения
комментариев.
- **`import_page_markdown`** — Заменить контент страницы из Markdown-файла в диалекте
Docmost, созданного `export_page_markdown`, восстанавливая якоря-выделения комментариев и
диаграммы из их inline-HTML. (Треды комментариев из файла не пересоздаются на сервере —
записываются только тело страницы и inline-марки комментариев; тредами управляйте через
инструменты/UI комментариев.)
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный
**Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать →
импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без
markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`,
`orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror
JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown,
используйте `updatePageMarkdown`.
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
> Чтобы заменить тело страницы из Markdown, используйте **`updatePageMarkdown`** (замена
> тела обычным Markdown). См. заметку о миграции в CHANGELOG.
### Изображения
- **`insert_image`** — Загрузить локальное изображение и вставить за один шаг: добавить в
- **`insertImage`** — Загрузить локальное изображение и вставить за один шаг: добавить в
конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока
(`afterText`). Сохраняет id всех остальных блоков.
- **`replace_image`** — Заменить существующее изображение. Загружает новый файл как **новое
- **`replaceImage`** — Заменить существующее изображение. Загружает новый файл как **новое
вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем
перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая
коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст.
(Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят
вложение при перезаписи.)
- **`stash_page`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
- **`stashPage`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело
никогда не попадает в контекст модели, поэтому это способ передать большую страницу
(вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее
@@ -221,35 +231,35 @@ Docmost-MCP не сочетают:
### Комментарии
- **`create_comment`** — Добавить комментарий к странице, опционально **привязав inline** к
- **`createComment`** — Добавить комментарий к странице, опционально **привязав inline** к
точному фрагменту текста (первое вхождение оборачивается comment-маркой).
- **`list_comments`** — Список комментариев страницы (контент возвращается как Markdown).
- **`update_comment`** — Изменить существующий комментарий.
- **`delete_comment`** — Удалить комментарий.
- **`resolve_comment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `delete_comment`.
- **`check_new_comments`** — Найти комментарии, созданные после заданной метки времени
- **`listComments`** — Список комментариев страницы (контент возвращается как Markdown).
- **`updateComment`** — Изменить существующий комментарий.
- **`deleteComment`** — Удалить комментарий.
- **`resolveComment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `deleteComment`.
- **`checkNewComments`** — Найти комментарии, созданные после заданной метки времени
ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента,
который следит за обратной связью в документе.
### Версии и история
- **`list_page_history`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
- **`listPageHistory`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`.
- **`diff_page_versions`** — Дифф двух версий (или версии против живой страницы).
- **`diffPageVersions`** — Дифф двух версий (или версии против живой страницы).
Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки,
таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же
конвейером, что использует встроенный просмотр истории Docmost.
- **`restore_page_version`** — Записать сохранённую версию обратно как текущий контент. У
- **`restorePageVersion`** — Записать сохранённую версию обратно как текущий контент. У
Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само
восстановление тоже обратимо.
### Публикация
- **`share_page`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
- **`sharePage`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
публичный URL (`<app>/share/<key>/p/<slugId>`); опционально индексирование поисковиками.
- **`unshare_page`** — Отозвать публичный доступ к странице.
- **`list_shares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
- **`unsharePage`** — Отозвать публичный доступ к странице.
- **`listShares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
---
@@ -258,28 +268,29 @@ Docmost-MCP не сочетают:
Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что
подходящие клиенты направляют модель автоматически.
- **Правки текста** (формулировки, опечатки, числа): `edit_page_text`.
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patch_node` / `insert_node` /
`delete_node`, адресуя узел по его `attrs.id` из `get_page_json`.
- **Изображения**: `insert_image` / `replace_image`.
- **Новая страница**: `create_page`.
- **Массовая перезапись или узлы без id**: `update_page_json`.
- **Правки текста** (формулировки, опечатки, числа): `editPageText`.
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patchNode` / `insertNode` /
`deleteNode`, адресуя узел по его `attrs.id` из `getPageJson`.
- **Изображения**: `insertImage` / `replaceImage`.
- **Новая страница**: `createPage`.
- **Массовая перезапись или узлы без id**: `updatePageJson` (ProseMirror) или
`updatePageMarkdown` (замена тела обычным Markdown).
- **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки):
`docmost_transform` — предпросмотр через `dryRun`, затем применение.
`docmostTransform` — предпросмотр через `dryRun`, затем применение.
- **Скопировать контент целой страницы из другой** (на стороне сервера):
`copy_page_content`.
- **Переименовать страницу** (только заголовок): `rename_page`.
- **Чтение**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror с id).
- **Просмотр изменений**: `list_page_history``diff_page_versions`
`restore_page_version`.
- **Комментарии**: `create_comment` (с опциональной inline-привязкой) / `list_comments` /
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`.
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `get_outline`
`get_node`.
- **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` /
`table_delete_row` / `table_update_cell`.
- **Round-trip страницы через Markdown** (скачать, отредактировать, залить обратно без
потерь, с комментариями): `export_page_markdown` / `import_page_markdown`.
`copyPageContent`.
- **Переименовать страницу** (только заголовок): `renamePage`.
- **Чтение**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror с id).
- **Просмотр изменений**: `listPageHistory``diffPageVersions`
`restorePageVersion`.
- **Комментарии**: `createComment` (с опциональной inline-привязкой) / `listComments` /
`updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `getOutline`
`getNode`.
- **Таблицы** (добавить/удалить строку, задать ячейку): `tableGet` / `tableInsertRow` /
`tableDeleteRow` / `tableUpdateCell`.
- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `exportPageMarkdown`.
- **Заменить тело страницы из Markdown**: `updatePageMarkdown`.
---
@@ -291,28 +302,49 @@ Docmost-MCP не сочетают:
правки, которых ещё нет в дебаунс-снапшоте REST), затем **читает → трансформирует →
пишет синхронно** в одном тике, чтобы никакое удалённое обновление не вклинилось, и
**ждёт подтверждения сохранения** до возврата.
- **Сериализация записи по странице.** Асинхронный мьютекс по `pageId` гарантирует, что
две записи MCP в одну страницу никогда не пересекаются; разные страницы друг друга не
блокируют.
- **Сериализация записи по странице.** Асинхронный мьютекс по разрешённому **UUID**
страницы (никогда не по slugId) гарантирует, что две записи MCP в одну страницу никогда
не пересекаются; разные страницы друг друга не блокируют. Хелпер блокировки падает сразу
(fail-fast), если ему передали не-UUID ключ, — путь записи, забывший разрезолвить id, не
сможет молча взять лок под расщеплённым ключом.
**Требование к деплою — один инстанс или sticky-сессии.** Этот мьютекс — процесс-локальный
`Map`, а кэш collab-сессий и хранилище `stashPage` живут только в RAM одного процесса. За
**мультиреплика**-балансировщиком **без sticky-сессий** две реплики могут одновременно
«держать» лок одной страницы, и сериализация по странице молча теряется. Запускайте
MCP/приложение **одним инстансом** либо прибивайте трафик страницы к одной реплике
(sticky-сессии / consistent hashing по id страницы). Кросс-процессной блокировки (например,
Postgres advisory-lock) намеренно пока нет — осознанное задокументированное ограничение.
См. комментарий в `Dockerfile` и блок `MCP collaboration write path` в `.env.example`.
**Окно устаревших прав.** Кэшированная collab-сессия пишет под токеном, захваченным в
момент connect (а кэш collab-токена переиспользует токен в пределах своего TTL), поэтому
**отозванный** доступ к странице может лагать до `MCP_COLLAB_SESSION_MAX_AGE_MS` (жёсткий
срок жизни сессии, по умолчанию 10 мин), пока следующая переавторизация его не подхватит.
Уменьшите значение, чтобы сократить лаг ценой большего числа переподключений. Это
ограниченное окно — принятый trade-off; push-инвалидации кэша при смене прав нет.
- **Прозрачная переавторизация.** Логин по email/паролю; истёкшие токены обновляются
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
повторный логин.
- **Lossless- и lossy-чтение.** `get_page_json` возвращает точное дерево ProseMirror с id
блоков; `get_page` возвращает чистый Markdown для удобства.
- **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков;
`getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный
набор атрибутов.
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
против патологического ввода.
- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как
- **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
документа, а страницу — экспортировать и заново импортировать как самодостаточный
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы.
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы
(id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления
отбрасывается — см. `exportPageMarkdown`).
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
пагинируются.
- **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить
stdio-сервер; `move_page` требует положительно подтверждённого успеха; движок диффа
stdio-сервер; `movePage` требует положительно подтверждённого успеха; движок диффа
откатывается к грубому поблочному диффу, а не падает на патологическом документе.
---
@@ -372,7 +404,7 @@ npm run test:e2e
Проект начинался как форк
[MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause)
и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические
правки текста, песочница `docmost_transform`, история версий / дифф / восстановление,
правки текста, песочница `docmostTransform`, история версий / дифф / восстановление,
комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц,
двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение.
Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим.
+10 -10
View File
@@ -22,14 +22,14 @@ are debounced server-side, so the script waits ~16 s before reading back via RES
| # | Tool / path | What is checked | Expected |
|---|-------------|-----------------|----------|
| 1 | `create_page` | title with spaces, slugId returned | page created, title intact |
| 1 | `createPage` | title with spaces, slugId returned | page created, title intact |
| 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import |
| 3 | `get_page_json` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
| 4 | `edit_page_text` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
| 5 | `update_page_json` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
| 3 | `getPageJson` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
| 4 | `editPageText` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
| 5 | `updatePageJson` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
| 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files/<id>/<file>` URL, served `200 image/*` |
| 7 | `insert_image` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
| 8 | **`replace_image`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
| 7 | `insertImage` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
| 8 | **`replaceImage`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
## Image-specific assertions (the recurring bug area)
@@ -39,7 +39,7 @@ For every uploaded/inserted/replaced image, assert at the HTTP level that the
* `GET <src>``200`, `Content-Type: image/*`, body starts with the image magic
(`89 50 4E 47` for PNG, etc.).
* `src` does **not** contain a `?v=` query (see "Known pitfalls").
* After `replace_image`: the returned `newAttachmentId` **differs** from the old
* After `replaceImage`: the returned `newAttachmentId` **differs** from the old
one (replacement uses a fresh attachment → fresh URL), and `GET <new src>``200`.
* The old image node on the page is repointed to the new attachmentId.
@@ -64,7 +64,7 @@ broken/empty figure.
Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`)
overwrites the bytes in place. On this Docmost the attachment then returns
**500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore
`replace_image` must upload a **new** attachment and repoint the nodes; the new
`replaceImage` must upload a **new** attachment and repoint the nodes; the new
id yields a new URL that both renders and busts the browser cache. The old
attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to
delete a single content attachment** (verified against the attachment
@@ -80,9 +80,9 @@ broken/empty figure.
from `?v=`. Image `src` is kept clean (`/api/files/<id>/<file>`); cache-busting
on replace is achieved by the new attachment id.
3. **REST snapshot lag.** `get_page_json` reads the debounced DB snapshot, so a
3. **REST snapshot lag.** `getPageJson` reads the debounced DB snapshot, so a
write made moments earlier may not be visible yet. Wait (~16 s) before reading
back, and never feed a possibly-stale snapshot straight into `update_page_json`.
back, and never feed a possibly-stale snapshot straight into `updatePageJson`.
4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as
`type: "info"` — the markdown→callout conversion does not carry non-`info`
+63
View File
@@ -0,0 +1,63 @@
{
"$comment": "Semantic palettes for drawioFromGraph (issue #425). DATA, not code: node `kind` -> fill/stroke slot, edge `kind` -> line-style props, per preset. The `default` node palette is the issue's base table; `dark` keeps the same hues on a dark canvas with lighter strokes/font; `colorblind-safe` maps every slot onto the Okabe-Ito qualitative palette (8 colours proven distinguishable for all common colour-vision deficiencies) so no two adjacent kinds collide. `fontColor`/`fillColor`/`strokeColor` are exact draw.io values. `edgeDefault` is the fallback line style; `group` is the (always-transparent) container stroke per preset.",
"presets": {
"default": {
"canvasDark": false,
"nodes": {
"service": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf", "fontColor": "#000000" },
"db": { "fillColor": "#d5e8d4", "strokeColor": "#82b366", "fontColor": "#000000" },
"queue": { "fillColor": "#fff2cc", "strokeColor": "#d6b656", "fontColor": "#000000" },
"gateway": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00", "fontColor": "#000000" },
"error": { "fillColor": "#f8cecc", "strokeColor": "#b85450", "fontColor": "#000000" },
"external": { "fillColor": "#f5f5f5", "strokeColor": "#666666", "fontColor": "#333333" },
"security": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6", "fontColor": "#000000" }
},
"edges": {
"sync": { "props": "" },
"async": { "props": "dashed=1;" },
"error": { "props": "dashed=1;strokeColor=#DD344C;" }
},
"edgeDefault": { "strokeColor": "#333333", "fontColor": "#333333" },
"group": { "strokeColor": "#666666", "fontColor": "#333333" }
},
"dark": {
"canvasDark": true,
"nodes": {
"service": { "fillColor": "#1a2a44", "strokeColor": "#7ea6e0", "fontColor": "#dae8fc" },
"db": { "fillColor": "#1f331e", "strokeColor": "#97d077", "fontColor": "#d5e8d4" },
"queue": { "fillColor": "#3a3218", "strokeColor": "#e5c15a", "fontColor": "#fff2cc" },
"gateway": { "fillColor": "#3a2812", "strokeColor": "#ffb570", "fontColor": "#ffe6cc" },
"error": { "fillColor": "#3a1c1b", "strokeColor": "#e08e8b", "fontColor": "#f8cecc" },
"external": { "fillColor": "#2b2b2b", "strokeColor": "#999999", "fontColor": "#e0e0e0" },
"security": { "fillColor": "#2c2338", "strokeColor": "#b39ddb", "fontColor": "#e1d5e7" }
},
"edges": {
"sync": { "props": "" },
"async": { "props": "dashed=1;" },
"error": { "props": "dashed=1;strokeColor=#ff6b6b;" }
},
"edgeDefault": { "strokeColor": "#cccccc", "fontColor": "#e0e0e0" },
"group": { "strokeColor": "#aaaaaa", "fontColor": "#e0e0e0" }
},
"colorblind-safe": {
"canvasDark": false,
"okabeIto": ["#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"],
"nodes": {
"service": { "fillColor": "#D6E9F5", "strokeColor": "#0072B2", "fontColor": "#000000" },
"db": { "fillColor": "#D6EFE4", "strokeColor": "#009E73", "fontColor": "#000000" },
"queue": { "fillColor": "#FCF8CC", "strokeColor": "#F0E442", "fontColor": "#000000" },
"gateway": { "fillColor": "#FBEBD0", "strokeColor": "#E69F00", "fontColor": "#000000" },
"error": { "fillColor": "#F7DDCC", "strokeColor": "#D55E00", "fontColor": "#000000" },
"external": { "fillColor": "#EDEDED", "strokeColor": "#000000", "fontColor": "#000000" },
"security": { "fillColor": "#F3DEEB", "strokeColor": "#CC79A7", "fontColor": "#000000" }
},
"edges": {
"sync": { "props": "" },
"async": { "props": "dashed=1;" },
"error": { "props": "dashed=1;strokeColor=#D55E00;" }
},
"edgeDefault": { "strokeColor": "#000000", "fontColor": "#000000" },
"group": { "strokeColor": "#000000", "fontColor": "#000000" }
}
}
}
Binary file not shown.
+14 -5
View File
@@ -5,18 +5,26 @@
"private": true,
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"exports": {
".": "./build/index.js",
"./http": "./build/http.js"
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
},
"./http": {
"types": "./build/http.d.ts",
"default": "./build/http.js"
}
},
"bin": {
"docmost-mcp": "./build/stdio.js"
},
"scripts": {
"build": "tsc",
"gen:stamp": "node scripts/gen-registry-stamp.mjs",
"build": "node scripts/gen-registry-stamp.mjs && tsc",
"start": "node build/stdio.js",
"watch": "tsc --watch",
"pretest": "tsc",
"watch": "node scripts/gen-registry-stamp.mjs && tsc --watch",
"pretest": "node scripts/gen-registry-stamp.mjs && tsc",
"test": "node --test \"test/unit/*.test.mjs\" \"test/mock/*.test.mjs\"",
"test:unit": "node --test \"test/unit/*.test.mjs\"",
"test:mock": "node --test \"test/mock/*.test.mjs\"",
@@ -49,6 +57,7 @@
"@tiptap/starter-kit": "3.20.4",
"@types/jsdom": "^27.0.0",
"axios": "^1.6.0",
"elkjs": "^0.11.1",
"form-data": "^4.0.0",
"jsdom": "^27.4.0",
"marked": "^17.0.1",
+107
View File
@@ -0,0 +1,107 @@
// Codegen: emit src/registry-stamp.generated.ts with a REGISTRY_STAMP hash of
// the ENTIRE src/ tree, so a build/ vs src/ skew (issue #447) is detectable at
// runtime for ANY source file — not just tool-specs.ts.
//
// WHY hash the whole src tree (not just tool-specs.ts): the runtime tools are
// assembled from far more than the spec registry — client.ts, the client/*
// domain modules, comment-signal.ts and the drawio-* helpers all ship in build/
// and are loaded by the in-app server. Hashing ONLY tool-specs.ts meant an edit
// to any of those (e.g. a behavioural fix in client.ts) left the stamp unchanged,
// so a stale build/ served the OLD code silently (issue #486). Hashing every
// src/**/*.ts closes that gap: any source edit changes the stamp.
//
// WHY hash the raw source text (not extracted structured data): the tool input
// SCHEMAS live as `buildShape` functions which are NOT serializable, so we cannot
// reduce them to structured data without a fragile zod shim. Hashing the STABLE,
// deterministic source TEXT captures every field that must stay in sync with zero
// probing fragility. Any edit to any source file changes the text → the stamp.
//
// DETERMINISM: files are enumerated recursively, filtered to *.ts EXCLUDING
// *.generated.ts (the codegen's OWN output — including it would create a
// fixed-point cycle), and sorted by their POSIX-normalized path relative to src/
// so the order is platform-independent. Each file contributes its relative path
// AND its content with line endings normalized to LF and a single trailing
// newline stripped, so a CRLF checkout or an editor's trailing-newline habit
// cannot make build/ and src/ disagree. No Date.now / randomness. The loader's
// dev-only stale-check (docmost-client.loader.ts) re-runs THIS SAME enumeration +
// normalization + sha256 and compares to the built REGISTRY_STAMP; the two must
// compute identically.
//
// This script runs from the `build` and `pretest` npm scripts BEFORE tsc, so
// build/ always carries a stamp derived from the src/ tree that was compiled.
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative, sep } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = join(__dirname, '..', 'src');
const OUT_PATH = join(SRC_DIR, 'registry-stamp.generated.ts');
/**
* Recursively enumerate every `*.ts` file under `dir`, EXCLUDING the codegen's
* own `*.generated.ts` output (a self-referential cycle otherwise). Returns
* absolute paths, unsorted (the caller sorts by relative path for determinism).
* Kept as a plain exported function so the algorithm has a single home; the
* loader duplicates it because it lives in the CJS server build and cannot import
* this ESM script. If you change the walk/filter here, mirror it in
* apps/server/src/core/ai-chat/tools/docmost-client.loader.ts.
*/
export function collectStampFiles(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectStampFiles(full));
} else if (entry.endsWith('.ts') && !entry.endsWith('.generated.ts')) {
out.push(full);
}
}
return out;
}
/**
* Deterministic stamp of the whole src/ tree. Enumerate + sort by POSIX-relative
* path, then fold each file's relative path AND normalized content into one
* sha256. MUST stay byte-for-byte identical to the loader's recompute.
*/
export function computeRegistryStamp(srcDir) {
const files = collectStampFiles(srcDir)
.map((abs) => ({
rel: relative(srcDir, abs).split(sep).join('/'),
abs,
}))
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
const hash = createHash('sha256');
for (const { rel, abs } of files) {
const normalized = readFileSync(abs, 'utf8')
.replace(/\r\n/g, '\n')
.replace(/\n$/, '');
hash.update(rel, 'utf8');
hash.update('\0', 'utf8');
hash.update(normalized, 'utf8');
hash.update('\0', 'utf8');
}
return hash.digest('hex');
}
function main() {
const stamp = computeRegistryStamp(SRC_DIR);
const out =
'// AUTO-GENERATED by scripts/gen-registry-stamp.mjs — DO NOT EDIT BY HAND.\n' +
'// A deterministic hash of the whole src/ tree (every src/**/*.ts except\n' +
'// *.generated.ts). Regenerated on every build/pretest so build/ always\n' +
'// matches the compiled src. The in-app loader recomputes this from src and\n' +
'// refuses to run on a mismatch (issue #447/#486). This file is gitignored\n' +
'// and produced by the build — see .gitignore.\n' +
`export const REGISTRY_STAMP = ${JSON.stringify(stamp)};\n`;
writeFileSync(OUT_PATH, out, 'utf8');
// eslint-disable-next-line no-console
console.log(`gen-registry-stamp: wrote ${OUT_PATH} (${stamp.slice(0, 12)}…)`);
}
// Only run when invoked directly (not when imported for computeRegistryStamp).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}
+89 -4231
View File
File diff suppressed because it is too large Load Diff
+704
View File
@@ -0,0 +1,704 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import { assertFullUuid } from "./errors.js";
import {
filterWorkspace,
filterSpace,
filterPage,
filterComment,
filterSearchResult,
} from "../lib/filters.js";
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import {
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
assertUnambiguousMatch,
insertNodeRelative,
insertNodesRelative,
blockPlainText,
buildOutline,
getNodeByRef,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import {
applyAnchorInDoc,
countAnchorMatches,
getAnchoredText,
resolveAnchorSelection,
normalizeForMatch,
} from "../lib/comment-anchor.js";
import { closestBlockHint } from "../lib/text-normalize.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
// Public method surface of CommentsMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements ICommentsMixin` fails to compile on drift.
export interface ICommentsMixin {
listComments(pageId: string, includeResolved?: boolean): any;
getComment(commentId: string): any;
createComment(pageId: string, content: string, type?: "page" | "inline", selection?: string, parentCommentId?: string, suggestedText?: string): any;
updateComment(commentId: string, content: string): any;
deleteComment(commentId: string): any;
resolveComment(commentId: string, resolved: boolean): any;
checkNewComments(spaceId: string, since: string, parentPageId?: string): any;
}
export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ICommentsMixin> & TBase {
abstract class CommentsMixin extends Base implements ICommentsMixin {
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
/**
* Normalize a comment's `content` into a ProseMirror doc object before
* markdown conversion. createComment/updateComment send content as a
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
* string, so parse it first (guarded fall back to the raw value on any
* parse failure so a non-JSON legacy value is still handled gracefully).
*/
protected parseCommentContent(content: any): any {
if (typeof content !== "string") return content;
try {
return JSON.parse(content);
} catch {
return content;
}
}
/**
* List comments on a page (cursor-paginated), content as markdown.
*
* DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the
* agent sees only active discussions: a top-level comment with `resolvedAt`
* set AND every reply under it (a reply of a closed thread is part of the
* closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how
* many resolved top-level threads were hidden so the agent can re-query with
* `includeResolved: true` to see everything. Active threads always stay.
*
* Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) callers that
* need the full feed (lossless export, transformPage, checkNewComments) pass
* `includeResolved: true` and read `.items`.
*/
async listComments(pageId: string, includeResolved = false) {
await this.ensureAuthenticated();
let allComments: any[] = [];
let cursor: string | null = null;
// Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments
// ever stops advancing the cursor (the exact #442 drift scenario) this loop
// would otherwise spin forever accumulating duplicates.
const MAX_PAGES = 50;
let truncated = false;
for (let page = 0; page < MAX_PAGES; page++) {
const payload: Record<string, any> = { pageId, limit: 100 };
if (cursor) payload.cursor = cursor;
const response = await this.client.post("/comments", payload);
const data = response.data.data || response.data;
const items = data.items || [];
allComments = allComments.concat(items);
// Advance strictly via the server-issued cursor. A missing nextCursor or a
// cursor identical to the one we just sent means the end (or a server that
// ignores our pagination param) — stop instead of re-fetching page one.
const next: string | null = data.meta?.nextCursor || null;
if (!next || next === cursor) break;
cursor = next;
// Reaching the ceiling with a still-advancing cursor means truncation.
if (page === MAX_PAGES - 1) truncated = true;
}
if (truncated) {
console.warn(
`listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
);
}
const mapped = allComments.map((comment: any) => {
const markdown = comment.content
? convertProseMirrorToMarkdown(
this.parseCommentContent(comment.content),
)
: "";
return filterComment(comment, markdown);
});
if (includeResolved) {
return { items: mapped, resolvedThreadsHidden: 0 };
}
// Ids of RESOLVED top-level threads (a top-level comment has no
// parentCommentId). A whole thread is hidden when its root is resolved.
const resolvedRootIds = new Set(
mapped
.filter((c) => !c.parentCommentId && c.resolvedAt != null)
.map((c) => c.id),
);
const items = mapped.filter((c) => {
// Hide the resolved root itself and every reply anchored to it. A reply's
// own resolvedAt is irrelevant — its membership follows the parent thread.
// ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId
// always points at the thread ROOT (no reply-of-reply nesting), so a single
// level of parent lookup covers a whole thread. If nested replies are ever
// introduced, a deep reply of a resolved thread would need a root-walk here.
if (!c.parentCommentId) return !resolvedRootIds.has(c.id);
return !resolvedRootIds.has(c.parentCommentId);
});
return { items, resolvedThreadsHidden: resolvedRootIds.size };
}
async getComment(commentId: string) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("get_comment", "commentId", commentId);
await this.ensureAuthenticated();
const response = await this.client.post("/comments/info", { commentId });
const comment = response.data.data || response.data;
const markdown = comment.content
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
: "";
return {
data: filterComment(comment, markdown),
success: true,
};
}
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
protected topLevelBlockTexts(doc: any): string[] {
const content = doc && Array.isArray(doc.content) ? doc.content : [];
return content
.map((b: any) => blockPlainText(b))
.filter((t: string) => t.length > 0);
}
/**
* True when per-block anchoring failed but the (normalized) selection DOES
* appear in the blocks' joined plain text i.e. it straddles a block
* boundary. Blocks are joined with a newline (collapsed to one space by
* normalizeForMatch) so a selection whose parts are separated by a paragraph
* break still matches. Callers only reach here after single-block anchoring
* (incl. the markdown-strip fallback) has already failed.
*/
protected selectionSpansMultipleBlocks(
blockTexts: string[],
selection: string,
): boolean {
const normSel = normalizeForMatch(selection).norm.trim();
if (normSel.length === 0) return false;
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
return joined.indexOf(normSel) !== -1;
}
/**
* Build the actionable error for a createComment anchor MISS, porting
* editPageText's self-correction affordances: an explicit "spans multiple
* blocks" message when the selection straddles a block boundary, otherwise a
* "closest block text" hint quoting the block that holds the selection's
* longest token. `live` switches the wording between the pre-check (reading the
* persisted page) and the post-create live-anchor failure (which rolls back).
*/
protected anchorNotFoundError(
doc: any,
selection: string,
live: boolean,
): Error {
const blockTexts = this.topLevelBlockTexts(doc);
const rolled = live ? " The comment was rolled back." : "";
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
return new Error(
"createComment: the selection spans multiple blocks; anchor on a " +
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
rolled,
);
}
const where = live ? "in the live document" : "in the page";
return new Error(
`createComment: could not find the selection text ${where} to anchor ` +
"the comment. Provide the EXACT contiguous text from a single " +
"paragraph/block (<=250 chars)." +
closestBlockHint(blockTexts, selection) +
rolled,
);
}
/**
* Create an inline comment anchored to its `selection` text, or a reply.
*
* Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a
* `selection`: the `type` argument is kept for interface compatibility but the
* effective type is coerced to "inline". The selection has to anchor in the
* document; if it cannot, the comment is rolled back and an error is thrown so
* the caller is forced to supply a proper inline selection rather than leaving
* an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
* their parent's anchor: they take NO selection and are not anchored.
*/
async createComment(
pageId: string,
content: string,
type: "page" | "inline" = "page",
selection?: string,
parentCommentId?: string,
suggestedText?: string,
) {
// Fail fast (#436): a provided parent id must be a full UUID before any
// network call. Validate only when truthy — a falsy parentCommentId means
// "top-level comment" (mirrors the isReply computation below), not a reply.
if (parentCommentId) {
assertFullUuid("createComment", "parentCommentId", parentCommentId);
}
await this.ensureAuthenticated();
const isReply = !!parentCommentId;
const hasSuggestion =
suggestedText !== undefined && suggestedText !== null;
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
// the exact anchored text, so it is only meaningful on a top-level inline
// comment that carries a selection.
if (hasSuggestion) {
if (isReply) {
throw new Error(
"createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.",
);
}
}
// Only top-level comments are inline-anchored, so they are stored as
// "inline". Replies carry no inline selection, so they keep the historical
// general ("page") type — both backward-compatible and semantically correct.
// The `type` argument is kept for interface compatibility; createComment
// normalizes the effective type internally, so callers may pass "inline".
const effectiveType: "page" | "inline" = isReply ? "page" : "inline";
if (!isReply && (!selection || !selection.trim())) {
throw new Error(
"createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment",
);
}
// For a SUGGESTION, the value we store as the comment's `selection` must be
// the RAW document substring the mark lands on (typographic quotes/dashes,
// nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is
// placed via normalization, so when the doc was auto-converted to
// typographic the raw substring differs from the agent input; apply-time
// compares the stored selection to the marked doc text STRICTLY, so storing
// the raw substring is what makes "Apply" succeed instead of a spurious 409.
// Captured in the pre-check below (which already reads the page) and used as
// payload.selection. Ordinary comments keep sending the raw agent selection.
let anchoredSelection: string | null = null;
// Set when the anchor matched only after stripping markdown from the
// selection (the strip fallback); surfaced as a soft warning like
// editPageText does, so a stale-markdown selection is flagged.
let anchorNormalized = false;
// For a top-level comment, fail BEFORE creating anything when the selection
// is not present in the persisted document — this avoids leaving an orphan
// comment + notification behind. A read failure (network) is non-fatal: the
// live anchor step below still enforces the anchoring invariant.
if (!isReply && selection) {
try {
const page = await this.getPageJson(pageId);
if (hasSuggestion) {
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
// exact anchored text, and ordinary anchoring silently takes the first
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
// rejected BEFORE creating the comment.
const matches = countAnchorMatches(page.content, selection);
if (matches === 0) {
throw this.anchorNotFoundError(page.content, selection, false);
}
if (matches >= 2) {
throw new Error(
`createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
"(still <=250 chars) so it appears exactly once.",
);
}
// Exactly one match: capture the RAW anchored substring to store as the
// comment selection (so apply-time equality holds). If this returns
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
// the raw agent selection below rather than crash.
anchoredSelection = getAnchoredText(page.content, selection);
anchorNormalized = resolveAnchorSelection(
page.content,
selection,
).normalized;
} else {
const resolved = resolveAnchorSelection(page.content, selection);
if (!resolved.found) {
throw this.anchorNotFoundError(page.content, selection, false);
}
anchorNormalized = resolved.normalized;
}
} catch (e) {
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
// swallow read/network errors so the live anchor step can still try (and
// enforce) anchoring.
if (
e instanceof Error &&
(e.message.startsWith("createComment: could not find the selection") ||
e.message.startsWith(
"createComment: the selection spans multiple blocks",
) ||
e.message.startsWith(
"createComment: the suggestion's selection is ambiguous",
))
) {
throw e;
}
if (process.env.DEBUG) {
console.error(
"Pre-check getPageJson failed; deferring to live anchor step:",
e,
);
}
}
}
// Convert through the full Docmost schema. Deliberately the NON-canonicalizing
// variant: a comment body may carry a footnote definition with no matching
// reference, and canonicalization would drop it (data loss). See
// markdownToProseMirror vs markdownToProseMirrorCanonical.
const jsonContent = await markdownToProseMirror(content);
const payload: Record<string, any> = {
pageId,
content: JSON.stringify(jsonContent),
type: effectiveType,
};
// For a suggestion, store the RAW anchored substring (anchoredSelection) so
// the stored selection === the text under the mark === apply-time
// expectedText. Ordinary comments (and the null fallback) keep the raw
// agent selection — their selection is only display/anchor and never used
// by apply, so their behavior is unchanged.
if (!isReply && selection)
payload.selection = anchoredSelection ?? selection;
if (parentCommentId) payload.parentCommentId = parentCommentId;
// Only a top-level inline comment (with a selection) may carry a suggestion.
if (!isReply && selection && hasSuggestion) {
payload.suggestedText = suggestedText;
}
const response = await this.client.post("/comments/create", payload);
const comment = response.data.data || response.data;
const markdown = comment.content
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
: content;
const result: any = {
data: filterComment(comment, markdown),
success: true,
};
// Replies inherit the parent's anchor: no selection, no anchoring.
if (isReply) {
return result;
}
// Anchor the comment in the document. The /comments/create API records the
// comment + its `selection` text, but it does NOT insert the comment MARK
// into the page content, so without this the inline comment has no
// highlight/anchor and is not clickable. If anchoring fails the comment is
// rolled back (deleted) and an error is thrown — never an orphan comment.
const newCommentId: string = comment.id;
// Guard: a create response without an id would mean writing a comment mark
// with commentId: undefined and a later delete of a falsy id. We have no id
// to roll back here (nothing was created with an id), so just fail loudly.
if (!newCommentId) {
throw new Error(
"createComment: the server returned no comment id, so the comment could not be anchored",
);
}
let anchored = false;
// Set inside the transform when a suggestion's live anchor is ambiguous
// (>=2 occurrences), so the rollback path can surface the right error.
let ambiguousInLiveDoc = false;
// Captured inside the transform on a not-found abort, so the rollback path
// can surface the closest-block / spans-multiple-blocks hint built from the
// LIVE document (the pre-check page is not in scope there).
let liveNotFoundError: Error | null = null;
try {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
// /comments/create REST call above keeps the agent-supplied id.
const pageUuid = await this.resolvePageId(pageId);
// Route through the mutatePage seam (not the free function) so this
// wrapper's uniqueness gate + rollback can be unit-tested without a live
// Hocuspocus collab socket.
const mutation = await this.mutatePage(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
const doc =
liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (hasSuggestion) {
// Authoritative uniqueness check against the LIVE document: a
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
// no longer has exactly one occurrence (it changed since the
// pre-check), abort so the just-created comment is rolled back
// rather than mis-anchored to the first occurrence.
const liveCount = countAnchorMatches(doc, selection as string);
if (liveCount !== 1) {
ambiguousInLiveDoc = liveCount >= 2;
if (liveCount === 0) {
liveNotFoundError = this.anchorNotFoundError(
doc,
selection as string,
true,
);
}
return null;
}
}
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
anchored = true;
return doc;
}
// Selection text not found in the LIVE document: abort the write. The
// rollback + throw below turns this into a hard error.
liveNotFoundError = this.anchorNotFoundError(
doc,
selection as string,
true,
);
return null;
},
);
result.verify = mutation.verify;
} catch (e) {
// The comment record already exists; roll it back so we never leave an
// orphan, then rethrow the original anchoring error.
await this.safeDeleteComment(newCommentId);
throw e;
}
if (!anchored) {
// Mutation aborted because the selection was not found (or, for a
// suggestion, was ambiguous) in the live document. Roll back the comment
// and surface a hard error.
await this.safeDeleteComment(newCommentId);
if (ambiguousInLiveDoc) {
throw new Error(
"createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
);
}
throw (
liveNotFoundError ??
new Error(
"createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
)
);
}
// Soft warning (like editPageText): the selection only matched after
// stripping markdown, so the caller likely quoted a styled fragment.
if (anchorNormalized) {
result.warning =
"The selection matched only after stripping markdown syntax; the comment " +
"was anchored on the document's plain text. Copy the selection verbatim " +
"from getPage / searchInPage output to avoid this.";
}
result.anchored = true;
return result;
}
/**
* Best-effort rollback of a just-created comment. Swallows any delete failure
* (logging under DEBUG) so a failed cleanup never masks the original error.
*/
protected async safeDeleteComment(commentId: string): Promise<void> {
// Defense in depth: never call the delete API with a falsy id — there is
// nothing to roll back, and deleteComment(undefined) would hit a bad route.
if (!commentId) return;
try {
await this.deleteComment(commentId);
} catch (delErr) {
if (process.env.DEBUG) {
console.error(
"Failed to roll back comment after anchoring error:",
delErr,
);
}
}
}
async updateComment(commentId: string, content: string) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("updateComment", "commentId", commentId);
await this.ensureAuthenticated();
// NON-canonicalizing on purpose (comment body — see createComment).
const jsonContent = await markdownToProseMirror(content);
await this.client.post("/comments/update", {
commentId,
content: JSON.stringify(jsonContent),
});
return {
success: true,
commentId,
message: "Comment updated successfully.",
};
}
async deleteComment(commentId: string) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("deleteComment", "commentId", commentId);
await this.ensureAuthenticated();
return this.client
.post("/comments/delete", { commentId })
.then((res) => res.data);
}
/**
* Resolve or reopen a top-level comment thread (reversible `resolved`
* toggles the state). Only top-level comments can be resolved; the server
* rejects resolving a reply. Hits POST /comments/resolve.
*/
async resolveComment(commentId: string, resolved: boolean) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("resolveComment", "commentId", commentId);
await this.ensureAuthenticated();
const response = await this.client.post("/comments/resolve", {
commentId,
resolved,
});
const comment = response.data?.data ?? response.data;
return {
success: true,
commentId,
resolved,
comment,
};
}
/**
* Check for new comments across pages in a space (optionally scoped to a
* subtree): pages updated after `since` are scanned and their comments
* filtered by createdAt > since.
*/
async checkNewComments(
spaceId: string,
since: string,
parentPageId?: string,
) {
await this.ensureAuthenticated();
const sinceDate = new Date(since);
// Reject an unparseable `since`: comparing against an Invalid Date silently
// yields zero new comments (every `>` against NaN is false), which would
// mask a malformed input as "nothing new" instead of erroring.
if (Number.isNaN(sinceDate.getTime())) {
throw new Error(
`checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`,
);
}
// 1. Enumerate the FULL set of pages in scope via the page tree (a complete
// page index), NOT the bounded "/pages/recent" feed which caps at ~5000
// recent items and silently misses comments on older pages.
//
// Subtree scope: when parentPageId is given, the scope is that page ITSELF
// plus every descendant. Otherwise the scope is the whole space (all roots
// and their descendants).
//
// NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not
// bump it (verified on a live server), so such a filter silently misses
// comments on pages that were not otherwise edited. The complete tree walk
// already restricts the scope correctly, so no recent-feed allow-list is
// needed any more.
//
// The subtree scope (parentPageId given) already INCLUDES the root node
// itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so
// no separate getPageRaw fetch for the parent is needed.
const { pages: pagesInScope, truncated } = await this.enumerateSpacePages(
spaceId,
parentPageId,
);
// 2. Fetch comments for each page, keep ones created after since
const results: any[] = [];
for (const page of pagesInScope) {
try {
// Full feed (incl. resolved): a "new comments since" scan reports all
// recent activity; the active-only filter is scoped to listComments.
const comments = (await this.listComments(page.id, true)).items;
const newComments = comments.filter(
(c: any) => new Date(c.createdAt) > sinceDate,
);
if (newComments.length > 0) {
results.push({
pageId: page.id,
pageTitle: page.title,
comments: newComments,
});
}
} catch (e: any) {
// Skip pages with errors (e.g. deleted between calls)
}
}
const totalNewComments = results.reduce(
(sum, r) => sum + r.comments.length,
0,
);
// `truncated` is reported by enumerateSpacePages: it is true ONLY when the
// stdio fallback BFS hit its node cap. The primary /pages/tree path is
// uncapped, so a space with legitimately many pages is not falsely flagged.
return {
since,
scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`,
checkedPages: pagesInScope.length,
pagesWithNewComments: results.length,
totalNewComments,
truncated,
comments: results,
};
}
// --- Image upload / embedding ---
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
}
return CommentsMixin;
}
+798
View File
@@ -0,0 +1,798 @@
// Shared client context + core seams (issue #450). The abstract base of the
// DocmostClient mixin chain: it owns ALL shared instance state (the axios
// client, apiUrl, auth tokens, the resolvePageId cache, the collab-token cache,
// the sandbox/metrics sinks) and the core HTTP/auth/pagination/write seams every
// domain module builds on. Domain modules are mixins layered on top; the final
// DocmostClient (client.ts) assembles them. Extracted VERBATIM from the original
// monolith — only field/seam visibility was widened from `private` to
// `protected` so sibling mixins can reach the shared state through `this`, and
// the cross-module methods that live in other mixins are declared `abstract`
// here so `this.<method>` type-checks. No behaviour changed.
import axios, { AxiosInstance } from "axios";
import FormData from "form-data";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import {
acquireCollabSession,
isCollabAuthFailedError,
} from "../lib/collab-session.js";
import { withPageLock, isUuid } from "../lib/page-lock.js";
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
import { formatDocmostAxiosError } from "./errors.js";
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
// so the mixins compose into one prototype chain sharing this context.
export type GConstructor<T = {}> = abstract new (...args: any[]) => T;
/**
* Configuration for a DocmostClient / MCP server instance. A discriminated
* union: either service-account credentials (email/password the client calls
* performLogin, powering the external /mcp HTTP endpoint and the stdio CLI) OR
* a token getter (getToken the client uses the returned BARE access JWT as
* the Bearer and never calls performLogin; used for the internal per-user path).
*
* Both branches may ALSO carry an optional `getCollabToken` provider. When set,
* content mutations (which go over the collaboration websocket) use the token it
* returns INSTEAD of calling `POST /auth/collab-token`. The internal per-user
* agent path uses this to hand the client a provenance collab token (signed
* `actor:'agent'`+`aiChatId`), so agent content edits are attributed without a
* spoofable client-side field. When absent the client keeps the original
* `/auth/collab-token` path (service-account/stdio unchanged).
*
* Housed here (not in index.ts) so client.ts has no type dependency on index.ts;
* index.ts re-exports it for the package's public surface.
*/
// Sink the stash tool writes blobs into. The host app binds this to its in-RAM
// SandboxStore and composes the public `uri` (the package never sees the store
// or any env). `put` returns the anonymous read URL plus integrity metadata.
export type SandboxPut = (
buf: Buffer,
mime: string,
) => { uri: string; sha256: string; size: number };
export type DocmostMcpConfig = { apiUrl: string } & (
| { email: string; password: string }
| { getToken: () => Promise<string> } // returns a BARE JWT; the client adds "Bearer "
) & {
// Optional collab-token provider (returns a ready collab JWT). Common to
// both branches; see the type doc above.
getCollabToken?: () => Promise<string>;
// Optional blob sandbox sink. Present only where the stash tool is wired;
// when absent, stashPage throws a clear "not configured" error. The
// optional `has`/`evict` probes let stashPage keep its mirror counts honest
// under the store's FIFO eviction (see stashPage); older sinks omit them.
sandbox?: {
put: SandboxPut;
has?: (uri: string) => boolean;
evict?: (uri: string) => void;
};
// Dependency-neutral metrics sink. When present, the client emits generic
// (name, value, labels) samples; the HOST maps those names onto its own
// metrics registry (the package never depends on prom-client or the server).
// Absent in standalone/stdio mode → the client is a complete no-op here.
onMetric?: (
name: string,
value: number,
labels?: Record<string, string>,
) => void;
};
/**
* Collab-token cache TTL in milliseconds (issue #435). Read fresh from the
* environment on every mint like collab-session.ts readConfig so tests and a
* live rollback can change it without reloading the module.
*
* Why a cache at all: the live CollabSession registry (#400/#431) keys sessions
* on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH
* collab-token sources mint a FRESH token per mutation the in-app provider
* re-signs a JWT whose iat/exp (seconds) changes every second, and the external
* MCP POSTs /auth/collab-token each call so the token in the key changed on
* every op and the session was almost never reused (connect-storms, 25s
* timeouts, zombie sessions). Caching the token per-client keeps the key stable
* across a burst of mutations so ONE session is reused.
*
* Default 5 min: well under the 24h collab-token lifetime AND <= the collab
* session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the
* permission-staleness window is not widened beyond what #431 already accepted.
* The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the
* cache an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables
* the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls
* back to the 5-min default with the cache ON parseInt yields NaN, which is
* treated as "not configured", not as "disabled". So to turn the cache off you
* must set the value to exactly 0, not to garbage.
*/
function readCollabTokenTtlMs(): number {
const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10);
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
}
export abstract class DocmostClientContext {
protected client: AxiosInstance;
protected token: string | null = null;
protected apiUrl: string;
// email/password are only set on the service-account (credentials) variant;
// null on the getToken variant (where there are no credentials to log in with).
protected email: string | null = null;
protected password: string | null = null;
// Per-user token provider. When set, login() calls it to obtain a BARE access
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
protected getTokenFn: (() => Promise<string>) | null = null;
// Optional collab-token provider. When set, getCollabTokenWithReauth() returns
// its token instead of calling POST /auth/collab-token; on a 401/403 it is
// re-invoked once. Used by the internal agent to carry signed provenance.
protected getCollabTokenFn: (() => Promise<string>) | null = null;
// Optional blob-sandbox sink for the stash tool. Null when not configured.
protected sandboxPut: SandboxPut | null = null;
// Optional probes paired with the sink. `has` lets stashPage detect a blob
// FIFO-evicted by a LATER put in the same stash; `evict` lets it free this
// op's image blobs if the final doc put throws. Null when the sink omits them.
protected sandboxHas: ((uri: string) => boolean) | null = null;
protected sandboxEvict: ((uri: string) => void) | null = null;
// Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric).
// Null on the legacy positional form and whenever the host omits it → no-op.
protected onMetricFn:
| ((name: string, value: number, labels?: Record<string, string>) => void)
| null = null;
// In-flight login dedup: when the token expires, the 401 interceptor,
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
// can all call login() at once. Memoizing a single promise collapses that
// thundering herd into ONE /auth/login request that everyone awaits.
protected loginPromise: Promise<void> | null = null;
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
// page's canonical UUID, so repeated collab edits on the same page do not
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
// resolvePageId), so only slugId->uuid entries are stored/read here.
protected pageIdCache = new Map<string, string>();
// Collab-token cache (issue #435): the last minted collab token plus the
// wall-clock time it was minted, so a burst of content mutations reuses ONE
// token and therefore ONE live CollabSession (whose registry key includes the
// token — #400 invariant 4). Per-instance: a DocmostClient is built per
// user/per chat request, so a cached token can never leak across identities.
// Reset whenever the client's identity changes (login() / this.token cleared);
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
// #487: an OPTIONAL abort signal the in-app tool host sets before each tool
// call (a composite of the turn's Stop signal + a per-call wall-clock cap). It
// is checked at safe-points BETWEEN the sequential HTTP calls of a paginated
// read (paginateAll) and just before the atomic collab commit of a write (the
// mutatePage/replacePage/mutateLiveContentUnlocked seams), so a Stop / cap
// stops the NEXT network call from STARTING. An already-started single call may
// still land — a documented limitation (#487).
//
// SINGLE-WRITER by phase-1 assumption: exactly one DocmostClient is built per
// turn and shared by every tool call; the host sets this per call and does NOT
// restore the prior value on unwind (set-and-leave) — a fresh client per turn
// plus overwrite-by-the-next-call keeps it correct, and leaving a settled
// call's signal in place is what makes a discarded race-loser throw on its
// next safe-point. If the model emits PARALLEL in-app
// tool calls they share this one field, so the per-call CAP of one call is not
// guaranteed to bound another's in-flight pagination — but every composite the
// host sets carries the SAME turn Stop signal, so a Stop still aborts whichever
// signal is current. #487.
protected toolAbortSignal: AbortSignal | null = null;
/**
* #487: set (or clear with null) the in-app tool abort signal governing the
* NEXT client call's safe-points. The host wraps each in-app tool call: it sets
* the composite (Stop + per-call cap) here before invoking the tool and leaves
* it in place afterwards (set-and-leave, NOT restored) the next call
* overwrites it, and a fresh client is built per turn. Public so the
* server-side tool wrapper can reach it; harmless (a no-op) when never set.
*/
public setToolAbortSignal(signal: AbortSignal | null): void {
this.toolAbortSignal = signal;
}
/** #487: the abort signal currently governing this client's safe-points. */
public getToolAbortSignal(): AbortSignal | null {
return this.toolAbortSignal;
}
// Two construction forms:
// - new DocmostClient(config) // discriminated union (current)
// - new DocmostClient(baseURL, email, password) // legacy positional creds
// The positional form is retained so existing callers/tests keep working; it
// is exactly equivalent to the credentials branch of the object form.
constructor(config: DocmostMcpConfig);
constructor(baseURL: string, email: string, password: string);
constructor(
configOrBaseURL: DocmostMcpConfig | string,
email?: string,
password?: string,
) {
// Normalize the legacy positional form into the object union.
const config: DocmostMcpConfig =
typeof configOrBaseURL === "string"
? { apiUrl: configOrBaseURL, email: email!, password: password! }
: configOrBaseURL;
this.apiUrl = config.apiUrl;
if ("getToken" in config) {
// Token variant: carry the user's JWT via getToken; no credentials, so
// login() must never call performLogin (there is nothing to log in with).
this.getTokenFn = config.getToken;
} else {
// Service-account variant: behaves exactly as before (performLogin).
this.email = config.email;
this.password = config.password;
}
// Optional, available to both variants. When present, content mutations get
// their collab token from here instead of POST /auth/collab-token.
if (config.getCollabToken) {
this.getCollabTokenFn = config.getCollabToken;
}
if (config.sandbox) {
this.sandboxPut = config.sandbox.put;
this.sandboxHas = config.sandbox.has ?? null;
this.sandboxEvict = config.sandbox.evict ?? null;
}
// Legacy positional form carries no onMetric → null (complete no-op).
this.onMetricFn = config.onMetric ?? null;
this.client = axios.create({
baseURL: this.apiUrl,
// Default request timeout so a hung connection cannot wedge a per-page
// lock or block the server indefinitely. Multipart uploads override this
// with a longer per-request timeout.
timeout: 30000,
headers: {
"Content-Type": "application/json",
},
});
// Re-authenticate transparently on a 401/403 once: the JWT authToken can
// expire while the server is long-running, after which every cached-token
// request would otherwise fail until a manual restart. On such a response,
// clear the stale token, perform a fresh login, and replay the original
// request exactly once (guarded by config._retry to avoid infinite loops;
// the login request itself is never retried).
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
const status = error.response?.status;
const isAuthError = status === 401 || status === 403;
const isLoginRequest =
typeof config?.url === "string" && config.url.includes("/auth/login");
if (config && isAuthError && !config._retry && !isLoginRequest) {
config._retry = true;
// Drop the stale token + Authorization header before re-login. Also
// clear the collab-token cache (#435): a new identity/login must not
// keep serving a collab token minted under the old one.
this.token = null;
this.collabTokenCache = null;
delete this.client.defaults.headers.common["Authorization"];
try {
await this.login();
} catch (loginError) {
// Re-login failed: surface the original error to the caller.
return Promise.reject(error);
}
// Re-issue the original request with the freshly minted Bearer token.
// Read it from the default header that login() just set, not from
// this.token, to avoid a theoretical "Bearer null" if this.token was
// cleared between login() resolving and this point.
config.headers = config.headers || {};
config.headers["Authorization"] =
this.client.defaults.headers.common["Authorization"];
return this.client.request(config);
}
return Promise.reject(error);
},
);
// Diagnostics interceptor (issue #437). Registered AFTER the re-login
// interceptor so a successful re-login retry (which resolves to a real
// response) is never seen here as an error; only a genuine failure reaches
// this rejection handler. It reformats error.message IN PLACE (see
// formatDocmostAxiosError — kept as a mutation, not a custom Error class, so
// the surrounding axios.isAxiosError / error.response?.status / config._retry
// checks keep working) and re-rejects the SAME error. The _docmostFormatted
// flag makes a re-processed retry-failure a no-op.
this.client.interceptors.response.use(
(response) => response,
(error) => {
formatDocmostAxiosError(error);
return Promise.reject(error);
},
);
}
// --- Cross-module seams (issue #450) -----------------------------------
// A method in one domain mixin sometimes calls a PROTECTED method owned by
// another mixin (e.g. nodes-write -> validateDocUrls in doc-validate). Those
// callees are `protected`, so they cannot be surfaced through the public
// per-mixin interfaces. Declaring them here on the shared base lets `this.<m>`
// type-check across modules. Each is a stub that is ALWAYS overridden by the
// owning mixin (layered above this base in the chain), so the body never runs;
// it throws only to make an impossible mis-wiring loud instead of silent.
// (The PUBLIC cross-module callees — getPage, getPageJson, listComments,
// deleteComment, listPageHistory — arrive via the mixins' public interfaces,
// so they are not restated here.)
protected enumerateSpacePages(
_spaceId: string,
_rootPageId?: string,
): Promise<{ pages: any[]; truncated: boolean }> {
throw new Error("enumerateSpacePages not wired (missing ReadMixin)");
}
protected validateDocUrls(_node: any, _depth?: number): void {
throw new Error("validateDocUrls not wired (missing DocValidateMixin)");
}
protected validateDocStructure(_node: any, _depth?: number): void {
throw new Error("validateDocStructure not wired (missing DocValidateMixin)");
}
protected assertValidNodeShape(_op: string, _node: any): void {
throw new Error("assertValidNodeShape not wired (missing DocValidateMixin)");
}
protected fetchInternalFile(
_src: string,
): Promise<{ buffer: Buffer; mime: string }> {
throw new Error("fetchInternalFile not wired (missing StashMixin)");
}
protected uploadAttachmentBuffer(
_pageId: string,
_buffer: Buffer,
_fileName: string,
_mime: string,
): Promise<{ id: string; fileName: string; fileSize: number }> {
throw new Error("uploadAttachmentBuffer not wired (missing MediaMixin)");
}
protected fetchAttachmentText(_src: string): Promise<string> {
throw new Error("fetchAttachmentText not wired (missing MediaMixin)");
}
// PUBLIC cross-module callees. Declared here too (as always-overridden stubs)
// so a mixin calling e.g. `this.getPageJson` type-checks against the base —
// the mixin's own public interface only covers its own methods. The real
// implementations live in ReadMixin / CommentsMixin / PagesMixin and shadow
// these on the prototype chain.
getPage(_pageId: string): Promise<any> {
throw new Error("getPage not wired (missing ReadMixin)");
}
getPageJson(_pageId: string): Promise<any> {
throw new Error("getPageJson not wired (missing ReadMixin)");
}
listComments(_pageId: string, _includeResolved?: boolean): Promise<any> {
throw new Error("listComments not wired (missing CommentsMixin)");
}
deleteComment(_commentId: string): Promise<any> {
throw new Error("deleteComment not wired (missing CommentsMixin)");
}
listPageHistory(_pageId: string, _cursor?: string): Promise<any> {
throw new Error("listPageHistory not wired (missing PagesMixin)");
}
/** Application base URL (API URL without the /api suffix). */
get appUrl(): string {
return this.apiUrl.replace(/\/api\/?$/, "");
}
async login() {
// Reuse an in-flight login if one is already running so concurrent callers
// share a single token fetch instead of each issuing their own.
if (!this.loginPromise) {
// Token variant: re-fetch a BARE JWT via getToken() (there are no
// credentials to log in with — on a 401/403 the interceptor below calls
// login() again, which re-invokes getToken()). Credentials variant:
// performLogin against /auth/login exactly as before.
const fetchToken = this.getTokenFn
? this.getTokenFn()
: performLogin(this.apiUrl, this.email!, this.password!);
this.loginPromise = fetchToken
.then((token) => {
// Guard against an empty/invalid token (e.g. a getToken provider that
// resolves to "" or null): without this an empty token would set a
// literal "Authorization: Bearer null"/"Bearer " header and every
// request would 401 with a confusing error. Fail loudly instead.
if (typeof token !== "string" || token.length === 0) {
throw new Error("getToken returned an empty token");
}
this.token = token;
// Identity (re)established: drop any collab token minted under a
// previous identity so the #435 cache can never outlive it.
this.collabTokenCache = null;
this.client.defaults.headers.common["Authorization"] =
`Bearer ${token}`;
})
.finally(() => {
this.loginPromise = null;
});
}
return this.loginPromise;
}
async ensureAuthenticated() {
if (!this.token) {
await this.login();
}
}
/**
* Fetch a collaboration token, transparently re-authenticating once on a
* 401/403. getCollabToken() uses bare axios internally, so it is NOT covered
* by this.client's response interceptor; this helper replicates that
* behaviour for collab-token requests: ensure a token, try once, and on an
* expired-token auth error perform a fresh login and retry exactly once.
*
* Collab-token cache (issue #435): both sources the getCollabToken provider
* (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) mint
* a FRESH token per call, whose string therefore changes every op. Since the
* live CollabSession registry keys on the token string (#400/#431 invariant 4),
* that churned the key and defeated session reuse. So we cache the last minted
* token per-client for readCollabTokenTtlMs() and hand it back for a burst of
* mutations, keeping the session key stable. `forceRefresh` bypasses the cache
* (the 401/403 reauth retry uses it, so the retry cannot be handed the same
* stale token that just failed otherwise reauth would be a no-op). TTL 0
* disables the cache: exact fetch-per-call legacy behaviour.
*/
protected async getCollabTokenWithReauth(
forceRefresh = false,
): Promise<string> {
const ttl = readCollabTokenTtlMs();
// Serve the cached collab token while it is still fresh (identity isolation
// is preserved: the cache is a per-instance field on a client built per
// user/per chat request, and it is cleared on every identity change).
if (
!forceRefresh &&
ttl > 0 &&
this.collabTokenCache &&
Date.now() - this.collabTokenCache.mintedAt < ttl
) {
return this.collabTokenCache.token;
}
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
// (the internal agent's provenance collab token), use it instead of the
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
// signed token expired between content mutations in a long agent turn).
if (this.getCollabTokenFn) {
try {
const token = await this.getCollabTokenFn();
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return this.rememberCollabToken(token, ttl);
} catch (e) {
// On an auth error retry EXACTLY once, forcing a refresh so the retry
// re-invokes the provider (bypassing the cache) for a genuinely fresh
// token. `!forceRefresh` bounds it to a single retry (no loop).
if (this.isCollabAuthError(e) && !forceRefresh) {
return this.getCollabTokenWithReauth(true);
}
throw e;
}
}
await this.ensureAuthenticated();
try {
const token = await getCollabToken(this.apiUrl, this.token!);
return this.rememberCollabToken(token, ttl);
} catch (e) {
// getCollabToken wraps the AxiosError in a plain Error but attaches the
// HTTP status as `.status`, so isCollabAuthError detects an auth failure
// via either the raw AxiosError shape OR the attached status.
if (this.isCollabAuthError(e) && !forceRefresh) {
// Fresh login (which clears this.token AND the collab-token cache), then
// retry exactly once with the cache bypassed via forceRefresh.
await this.login();
return this.getCollabTokenWithReauth(true);
}
throw e;
}
}
/**
* Store a freshly minted collab token in the per-client cache (issue #435) and
* return it unchanged. No-op write when the cache is disabled (ttl<=0) or the
* token is empty, so a disabled cache is exact fetch-per-call legacy behaviour
* and a bad token is never cached.
*/
protected rememberCollabToken(token: string, ttl: number): string {
if (ttl > 0 && typeof token === "string" && token.length > 0) {
this.collabTokenCache = { token, mintedAt: Date.now() };
}
return token;
}
/**
* True when an error carries a 401/403 either as a raw AxiosError
* (`error.response.status`) or as the plain-Error `.status` that
* lib/auth-utils.getCollabToken attaches after wrapping the AxiosError.
*/
protected isCollabAuthError(e: unknown): boolean {
const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined;
const attachedStatus = (e as any)?.status;
return (
axiosStatus === 401 ||
axiosStatus === 403 ||
attachedStatus === 401 ||
attachedStatus === 403
);
}
/**
* Run a collab write and, on a Hocuspocus HANDSHAKE auth failure, self-heal
* once (#486). Symmetric to the HTTP-401 path in getCollabTokenWithReauth: the
* REST interceptor and login() already drop the cached collab token on a 401/
* 403, but a rejected WEBSOCKET handshake left the stale token in the cache, so
* every subsequent mutation kept re-presenting the same bad token for up to the
* collab-token TTL (minutes) with no self-heal. Here, when the write rejects
* with the tagged collab-auth error, we invalidate the cached token and retry
* the write EXACTLY once with a force-refreshed token. Not a loop: a second
* failure (or any non-auth error) propagates unchanged.
*
* `write` receives the token to use, so the retry can hand it a genuinely fresh
* one rather than re-running with the same stale string.
*/
protected async writeWithCollabAuthRetry<T>(
collabToken: string,
write: (token: string) => Promise<T>,
): Promise<T> {
try {
return await write(collabToken);
} catch (e) {
if (!isCollabAuthFailedError(e)) throw e;
// The WS handshake rejected our token: drop it from the cache so it can't
// be reused for the rest of the TTL, mint a fresh one (forceRefresh bypasses
// the cache and re-invokes the provider/login), and retry the write once.
this.collabTokenCache = null;
const fresh = await this.getCollabTokenWithReauth(true);
return await write(fresh);
}
}
/**
* Connect to the collaboration websocket, read the live doc, apply
* `transform`, write the result, and wait for the server to persist it
* WITHOUT acquiring the per-page lock.
*
* This mirrors collaboration.mutatePageContent EXCEPT that it does not call
* withPageLock. It exists solely so replaceImage can hold ONE withPageLock
* across its scan -> upload -> write sequence: the per-page mutex is NOT
* reentrant, so calling the normal (self-locking) mutatePageContent inside an
* outer withPageLock for the same pageId would deadlock. The caller MUST hold
* the page lock for the whole operation; this helper assumes that invariant.
*
* `transform` receives the live ProseMirror doc and returns the NEW full doc
* to write, or `null` to abort with no write. Errors thrown by `transform`
* propagate to the caller.
*
* Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so
* every content mutator (including replaceImage) can return a verifiable
* change report. The report is computed AFTER the atomic read->write and
* never throws.
*/
protected async mutateLiveContentUnlocked(
pageId: string,
collabToken: string,
transform: (liveDoc: any) => any | null,
): Promise<MutationResult> {
// Reuse a live CollabSession for the page (issue #400) instead of opening a
// fresh provider per op. acquireCollabSession does NOT take the per-page
// lock — the caller (replaceImage) already holds ONE withPageLock across its
// scan -> upload -> write sequence, and the mutex is not reentrant, so
// taking it here would deadlock. The synchronous read->write section and the
// unsyncedChanges/connectionLost ack logic live in CollabSession.mutate,
// preserved verbatim from the old inline machine (incl. the #152 structural
// diff that keeps a live editor's cursor anchored).
// Wrap in the collab-auth self-heal (#486): a rejected WS handshake drops the
// cached collab token and retries once with a fresh one (the retry passes the
// refreshed token down to acquireCollabSession via `token`).
return this.writeWithCollabAuthRetry(collabToken, async (token) => {
const session = await acquireCollabSession(pageId, token, this.apiUrl, {
// Only the actual 25s collab connect timeout emits this — the connect-vs-
// unload signal; the other failure paths must NOT emit it.
onConnectTimeout: () =>
this.onMetricFn?.("collab_connect_timeouts_total", 1),
});
try {
// #487 PRE-COMMIT safe-point (reentrant twin of mutatePageContent): a
// Stop/cap after acquiring the session but before the atomic write skips
// this commit. Same limitation applies (stops the NEXT commit only).
this.toolAbortSignal?.throwIfAborted();
return await session.mutate(transform);
} catch (e) {
// Drop the session on any failure so the next call reconnects fresh.
session.destroy("mutate failed");
throw e;
}
});
}
/**
* Generic pagination handler for Docmost API endpoints
*/
async paginateAll<T = any>(
endpoint: string,
basePayload: Record<string, any> = {},
limit: number = 100,
): Promise<T[]> {
await this.ensureAuthenticated();
const clampedLimit = Math.max(1, Math.min(100, limit));
// Hard ceiling on the number of pages to fetch: guards against a server
// that returns a perpetually-true hasNextPage (which would otherwise loop
// forever and accumulate duplicates).
const MAX_PAGES = 50;
let cursor: string | undefined;
let allItems: T[] = [];
let truncated = false;
for (let page = 0; page < MAX_PAGES; page++) {
// #487 safe-point: a Stop (or the in-app tool per-call cap) that fires
// BETWEEN sequential page fetches must stop the NEXT request from starting
// — a read tool that would otherwise paginate for minutes is interrupted
// here. throwIfAborted() rejects with the signal's reason.
this.toolAbortSignal?.throwIfAborted();
const payload: Record<string, any> = {
...basePayload,
limit: clampedLimit,
};
if (cursor) payload.cursor = cursor;
const response = await this.client.post(endpoint, payload);
const data = response.data;
const items = data.data?.items || data.items || [];
const meta = data.data?.meta || data.meta;
allItems = allItems.concat(items);
// Advance strictly via the server-issued cursor. A missing nextCursor (or
// hasNextPage false) means we reached the end. A cursor identical to the
// one we just sent means the server did not understand our pagination
// param — stop instead of re-fetching page one forever and duplicating.
const next = meta?.hasNextPage ? meta?.nextCursor : null;
if (!next || next === cursor) {
// If the server still reports more pages but stopped issuing a usable
// cursor at the ceiling, flag the result as truncated below.
if (page === MAX_PAGES - 1 && meta?.hasNextPage) truncated = true;
break;
}
cursor = next;
// Reaching the ceiling with more pages still available means the result
// set is truncated.
if (page === MAX_PAGES - 1) truncated = true;
}
// If the loop stopped because it hit the MAX_PAGES ceiling while the server
// still reported more results, the result set is truncated — warn so the
// caller is not silently handed an incomplete list.
if (truncated) {
console.warn(
`paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
);
}
return allItems;
}
/** Raw page info including the ProseMirror JSON content and slugId. */
async getPageRaw(pageId: string) {
await this.ensureAuthenticated();
const response = await this.client.post("/pages/info", { pageId });
return response.data?.data ?? response.data;
}
/**
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
* so every collaboration document the MCP opens is named `page.<uuid>` the
* SAME name the web editor always uses (`page.${page.id}`).
*
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
* DB row that produced TWO independent Yjs documents whose debounced stores
* clobbered each other the agent's edit was silently lost (#260).
*
* A UUID input short-circuits with no network round-trip. A slugId is resolved
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
* repeated edits on the same page add no extra request.
*/
protected async resolvePageId(pageId: string): Promise<string> {
if (isUuid(pageId)) return pageId;
const cached = this.pageIdCache.get(pageId);
if (cached) return cached;
const data = await this.getPageRaw(pageId);
const uuid = data?.id;
if (typeof uuid !== "string" || !uuid) {
throw new Error(
`Could not resolve a canonical page id for "${pageId}"`,
);
}
this.pageIdCache.set(pageId, uuid);
return uuid;
}
/**
* Page-locked write seam over collaboration.mutatePageContent. Production just
* delegates; it exists as an overridable method so the insertFootnote wrapper
* (transform abort-on-not-found + response shaping) can be unit-tested without
* standing up a live Hocuspocus collab socket.
*
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
* lock"): every write must lock and key its CollabSession by the UUID, never a
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
* already resolved pays no extra round-trip; centralizing it here means a
* caller that reaches this seam with a raw slugId still locks correctly instead
* of silently splitting the mutex key. withPageLock also asserts the key is a
* UUID as a hard backstop.
*/
protected async mutatePage(
pageId: string,
collabToken: string,
apiUrl: string,
transform: (doc: any) => any,
): Promise<{ doc?: any; verify?: any }> {
const pageUuid = await this.resolvePageId(pageId);
// #486: on a rejected collab-WS handshake, invalidate + refresh the token and
// retry the write once (symmetric to the HTTP-401 reauth path).
return this.writeWithCollabAuthRetry(collabToken, (token) =>
// #487: thread the in-app tool signal to mutatePageContent's pre-commit
// safe-point so a Stop/cap during the connect/lock window skips the write.
mutatePageContent(
pageUuid,
token,
apiUrl,
transform,
this.toolAbortSignal ?? undefined,
),
);
}
/**
* Full-document write seam over collaboration.replacePageContent. Production
* just delegates; it exists as an overridable method so the full-doc write
* tools (updatePageJson, copyPageContent) can have their footnote-
* canonicalization binding unit-tested without a live Hocuspocus collab socket.
*
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
* lock") for the same reason as mutatePage above the lock/CollabSession key
* is guaranteed canonical here, not left to the caller's discipline.
*/
protected async replacePage(
pageId: string,
doc: any,
collabToken: string,
apiUrl: string,
): Promise<{ doc?: any; verify?: any }> {
const pageUuid = await this.resolvePageId(pageId);
// #486: on a rejected collab-WS handshake, invalidate + refresh the token and
// retry the write once (symmetric to the HTTP-401 reauth path).
return this.writeWithCollabAuthRetry(collabToken, (token) =>
// #487: same pre-commit safe-point as mutatePage, for full-document writes.
replacePageContent(
pageUuid,
doc,
token,
apiUrl,
this.toolAbortSignal ?? undefined,
),
);
}
/**
* Export a page to a single self-contained Docmost-flavoured markdown file:
* meta block + body (with inline comment anchors + diagrams) + comment
* threads. Lossless round-trip target; see importPageMarkdown for the inverse.
*/
}
+248
View File
@@ -0,0 +1,248 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import {
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
assertUnambiguousMatch,
insertNodeRelative,
insertNodesRelative,
blockPlainText,
buildOutline,
getNodeByRef,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
// Public method surface of DocValidateMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements IDocValidateMixin` fails to compile on drift.
export interface IDocValidateMixin {
}
export function DocValidateMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDocValidateMixin> & TBase {
abstract class DocValidateMixin extends Base implements IDocValidateMixin {
/**
* Validate a URL string against a scheme allowlist for a given context.
*
* The markdown link path enforces safe schemes via TipTap, but the raw
* JSON path (updatePageJson) bypasses that so this is the sanitization
* choke point for ProseMirror JSON written directly by the caller.
*
* - "link": reject javascript:, vbscript:, data: (any scheme that can
* execute or smuggle script when the href is clicked).
* - "src": allow only http(s):, mailto:, /api/files paths, or a
* scheme-less relative/absolute path; reject
* javascript:/vbscript:/data:/file:.
*/
protected isSafeUrl(url: unknown, context: "link" | "src"): boolean {
if (typeof url !== "string") return false;
const trimmed = url.trim();
if (trimmed === "") return true; // empty href/src is harmless
// Extract a leading "scheme:" if present. A scheme must start with a
// letter and contain only letters/digits/+/-/. before the colon. Strip
// whitespace and ASCII control chars first so a tab/newline embedded in
// the scheme cannot smuggle a dangerous scheme past the check.
const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, "");
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null;
const dangerous = new Set(["javascript", "vbscript", "data", "file"]);
if (context === "link") {
if (scheme === null) return true; // relative/anchor link is fine
// For links, data: is also blocked (can carry script payloads).
return !new Set(["javascript", "vbscript", "data"]).has(scheme);
}
// context === "src"
if (scheme === null) return true; // relative/absolute path (incl. /api/files)
if (dangerous.has(scheme)) return false;
return scheme === "http" || scheme === "https" || scheme === "mailto";
}
/**
* Recursively walk a ProseMirror doc and reject any unsafe URL on a link
* mark href or on a media node's src/url. Media nodes covered: image,
* attachment, video, plus embed (rendered as an iframe), youtube, drawio
* and excalidraw all of which carry a user-controlled URL that Docmost
* renders. Throws a clear error on the first violation. A max-depth guard
* turns an over-deep document into a clean error instead of a RangeError
* stack overflow.
*/
protected validateDocUrls(node: any, depth: number = 0): void {
const MAX_DEPTH = 200;
if (depth > MAX_DEPTH) {
throw new Error(
`document nesting exceeds the maximum depth of ${MAX_DEPTH}`,
);
}
if (!node || typeof node !== "object") return;
// Link marks on text nodes: validate the href.
if (Array.isArray(node.marks)) {
for (const mark of node.marks) {
if (mark && mark.type === "link" && mark.attrs) {
if (!this.isSafeUrl(mark.attrs.href, "link")) {
throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`);
}
}
}
}
// Media nodes: validate src/url against the stricter src allowlist.
// embed renders as an iframe (highest risk); youtube/drawio/excalidraw
// likewise carry a user-controlled URL Docmost renders, so they get the
// same scheme check as image/attachment/video.
if (
node.type === "image" ||
node.type === "attachment" ||
node.type === "video" ||
node.type === "embed" ||
node.type === "youtube" ||
node.type === "drawio" ||
node.type === "excalidraw" ||
node.type === "audio" ||
node.type === "pdf"
) {
const attrs = node.attrs || {};
for (const key of ["src", "url"]) {
if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) {
throw new Error(
`unsafe ${node.type} ${key} rejected: "${attrs[key]}"`,
);
}
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
this.validateDocUrls(child, depth + 1);
}
}
}
/**
* Recursively validate the STRUCTURE of a ProseMirror node (reuses the
* recursion shape of validateDocUrls). Every node must be an object with a
* string `type`; when present, `content` must be an array, `marks` must be
* an array of objects each with a string `type`, and a text node's `text`
* must be a string. Throws a clear "invalid ProseMirror document" error on
* the first violation. A max-depth guard turns an over-deep document into a
* clean error instead of a RangeError stack overflow.
*/
protected validateDocStructure(node: any, depth: number = 0): void {
const MAX_DEPTH = 200;
if (depth > MAX_DEPTH) {
throw new Error(
`invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`,
);
}
if (!node || typeof node !== "object" || typeof node.type !== "string") {
throw new Error(
"invalid ProseMirror document: every node must be an object with a string `type`",
);
}
if (
"text" in node &&
node.type === "text" &&
typeof node.text !== "string"
) {
throw new Error(
"invalid ProseMirror document: a text node must have a string `text`",
);
}
if (node.marks !== undefined) {
if (!Array.isArray(node.marks)) {
throw new Error(
"invalid ProseMirror document: `marks` must be an array",
);
}
for (const mark of node.marks) {
if (
!mark ||
typeof mark !== "object" ||
typeof mark.type !== "string"
) {
throw new Error(
"invalid ProseMirror document: every mark must be an object with a string `type`",
);
}
}
}
if (node.content !== undefined) {
if (!Array.isArray(node.content)) {
throw new Error(
"invalid ProseMirror document: `content` must be an array when present",
);
}
for (const child of node.content) {
this.validateDocStructure(child, depth + 1);
}
}
}
/**
* Pre-write SHAPE gate (#409). Walk the WHOLE node tree with the shared
* `findInvalidNode` and throw a rich, path-anchored error the instant a nested
* node has an absent/unknown `type` (or an unknown mark) the exact shape that
* otherwise surfaces DEEP in the Yjs encode as the cryptic
* `Unknown node type: undefined`, but only AFTER a collab session was opened
* and a page lock taken. Calling this BEFORE `getCollabTokenWithReauth` /
* `mutatePageContent` fails fast: no collab connection, no lock, deterministic
* message. `op` names the tool for the message prefix (e.g. "patchNode").
*
* `findInvalidNode` derives its "known type" set from the very same
* `docmostExtensions` the encode path uses, so a node this gate accepts is one
* the encoder will accept too.
*/
protected assertValidNodeShape(op: string, node: any): void {
const bad = findInvalidNode(node);
if (bad) {
throw new Error(`${op}: invalid node — ${bad.summary}`);
}
}
/**
* Replace page content with a raw ProseMirror JSON document (lossless) and/or
* update its title. Both `doc` and `title` are optional, but at least one must
* be supplied:
* - `doc` provided -> validate + full-overwrite the body (and update the
* title too when `title` is also given).
* - `doc` omitted, `title` given -> title-only update; the body is NOT
* touched/resent (no collab write happens).
* - neither given -> throws (nothing to update).
*/
}
return DocValidateMixin;
}
+707
View File
@@ -0,0 +1,707 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import { parseCells as parseDrawioCells } from "../lib/drawio-xml.js";
import {
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
assertUnambiguousMatch,
insertNodeRelative,
insertNodesRelative,
blockPlainText,
buildOutline,
getNodeByRef,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import {
prepareModel,
decodeDrawioSvg,
buildDrawioSvg,
mxHash,
normalizeXml,
countUserCells,
} from "../lib/drawio-xml.js";
import { renderDiagramShapes } from "../lib/drawio-preview.js";
import { applyElkLayout } from "../lib/drawio-layout.js";
import {
buildFromGraph,
type Graph,
type LayoutMode as GraphLayoutMode,
} from "../lib/drawio-graph.js";
import { applyCellOps, type CellOp } from "../lib/drawio-cell-ops.js";
import { mermaidToGraph } from "../lib/drawio-mermaid.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
// Public method surface of DrawioMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements IDrawioMixin` fails to compile on drift.
export interface IDrawioMixin {
drawioGet(pageId: string, node: string, format?: "xml" | "svg"): Promise<{ pageId: string; nodeId: string; format: "xml" | "svg"; content: string; meta: { attachmentId: string | null; title: string | null; width: number | null; height: number | null; cellCount: number; hash: string; }; }>;
drawioCreate(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, xml: string, title?: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
drawioUpdate(pageId: string, node: string, xml: string, baseHash: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
drawioEditCells(pageId: string, node: string, operations: CellOp[], baseHash: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
drawioFromGraph(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, graph: Graph, direction?: "LR" | "RL" | "TB" | "BT", preset?: string, layout?: GraphLayoutMode, node?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>;
drawioFromMermaid(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, mermaid: string, preset?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>;
}
export function DrawioMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDrawioMixin> & TBase {
abstract class DrawioMixin extends Base implements IDrawioMixin {
/**
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
* node plus its ref. Throws a clear error if the ref does not resolve to a
* drawio node.
*/
protected async resolveDrawioNode(
pageId: string,
node: string,
): Promise<{ node: any; ref: string }> {
const data = await this.getPageRaw(pageId);
const hit = getNodeByRef(
data.content ?? { type: "doc", content: [] },
node,
);
if (!hit) {
throw new Error(
`drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#<index>" from getOutline)`,
);
}
if (hit.type !== "drawio") {
throw new Error(
`drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`,
);
}
return { node: hit.node, ref: node };
}
/**
* Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`.
* Runs the decode chain (base64/entity content= drawio file nested XML or
* pako-inflated compressed <diagram>). The returned `hash` is the
* optimistic-lock key for drawioUpdate.
*/
async drawioGet(
pageId: string,
node: string,
format: "xml" | "svg" = "xml",
): Promise<{
pageId: string;
nodeId: string;
format: "xml" | "svg";
content: string;
meta: {
attachmentId: string | null;
title: string | null;
width: number | null;
height: number | null;
cellCount: number;
hash: string;
};
}> {
await this.ensureAuthenticated();
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
const attrs = drawio.attrs || {};
const src = attrs.src;
if (!src) {
throw new Error(
`drawio: node "${node}" on page ${pageId} has no src to read`,
);
}
const svg = await this.fetchAttachmentText(src);
const modelXml = decodeDrawioSvg(svg);
const meta = {
attachmentId: attrs.attachmentId ?? null,
title: attrs.title ?? null,
width: attrs.width != null ? Number(attrs.width) : null,
height: attrs.height != null ? Number(attrs.height) : null,
cellCount: countUserCells(modelXml),
hash: mxHash(modelXml),
};
return {
pageId,
nodeId: attrs.id ?? node,
format,
content: format === "svg" ? svg : normalizeXml(modelXml),
meta,
};
}
/**
* Create a drawio diagram from mxGraph XML: lint schematic SVG preview
* (pure TS) build the `.drawio.svg` (createDrawioSvg contract) create the
* attachment insert a `drawio` node before/after an anchor or appended.
* `xml` is a bare `<mxGraphModel>` or a list of `<mxCell>` (the server wraps
* it and adds the id=0/id=1 sentinels).
*/
async drawioCreate(
pageId: string,
where: {
position: "before" | "after" | "append";
anchorNodeId?: string;
anchorText?: string;
},
xml: string,
title?: string,
layout?: "elk",
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
if (
!where ||
(where.position !== "before" &&
where.position !== "after" &&
where.position !== "append")
) {
throw new Error(
'drawioCreate: `where.position` must be one of "before", "after", "append"',
);
}
if (where.position === "before" || where.position === "after") {
const hasId =
typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0;
const hasText =
typeof where.anchorText === "string" && where.anchorText.length > 0;
if (hasId === hasText) {
throw new Error(
`drawioCreate: position "${where.position}" requires exactly one of anchorNodeId or anchorText`,
);
}
}
// Optional server-side ELK auto-layout: the model declares structure with
// rough coords, ELK computes the pixels (best-effort — returns the input
// unchanged on any layout failure).
const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml;
// Pre-write pipeline (throws a structured DrawioLintError on any violation).
const prepared = prepareModel(laidOutXml);
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
const diagramTitle = title || "Page-1";
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
const att = await this.uploadAttachmentBuffer(
pageId,
Buffer.from(svg, "utf-8"),
"diagram.drawio.svg",
"image/svg+xml",
);
// NOTE: no `id` attribute is set here. The vendored `drawio` node schema
// (diagramAttributes) declares no `id`, so any block id would be silently
// dropped by PMNode.fromJSON on save and the returned handle would fail to
// resolve. The addressable handle is the node's "#<index>" (like image/table
// nodes), computed after the insert below.
const drawioNode: any = {
type: "drawio",
attrs: {
src: `/api/files/${att.id}/${att.fileName}`,
attachmentId: att.id,
width: prepared.bbox.width,
height: prepared.bbox.height,
align: "center",
},
};
if (title) drawioNode.attrs.title = title;
// Reuse the existing URL trust boundary (rejects unsafe src schemes).
this.validateDocUrls(drawioNode);
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
let inserted = false;
let insertedIndex = -1;
const mutation = await this.mutatePage(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
inserted = false;
insertedIndex = -1;
const { doc: nd, inserted: ins } = insertNodeRelative(
liveDoc,
drawioNode,
where,
);
inserted = ins;
if (!inserted) return null; // anchor not found -> skip the write
// Locate the freshly-inserted node to derive its "#<index>" handle. The
// just-uploaded attachmentId is unique, so it identifies our node.
if (Array.isArray(nd.content)) {
insertedIndex = nd.content.findIndex(
(b: any) =>
b &&
b.type === "drawio" &&
b.attrs &&
b.attrs.attachmentId === att.id,
);
}
return nd;
},
);
if (!inserted) {
const anchorDesc = where.anchorNodeId
? `anchorNodeId "${where.anchorNodeId}"`
: `anchorText "${where.anchorText}"`;
throw new Error(
`drawioCreate: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`,
);
}
if (insertedIndex < 0) {
// The node was inserted nested (e.g. inside a callout/table cell via an
// anchor), where "#<index>" — which addresses only top-level blocks —
// cannot reference it. drawio nodes carry no persisted id, so there is no
// stable handle for a nested diagram.
throw new Error(
`drawioCreate: the diagram was inserted on page ${pageId} but not as a ` +
`top-level block, so it has no addressable "#<index>" handle. Anchor ` +
`on a top-level block (or append) so the diagram can be re-read.`,
);
}
// The returned handle is POSITIONAL ("#<index>"): valid for the immediate
// create -> get/update flow, but re-resolve via getOutline if the document
// structure changes (blocks added/removed before it shift the index).
const nodeId = `#${insertedIndex}`;
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: prepared.warnings,
verify: mutation.verify,
};
}
/**
* Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is
* compared against the hash of the diagram's CURRENT XML (from drawioGet);
* any mismatch means a human or another agent edited the diagram after the
* read, so the write is refused with a conflict error. On success the new
* `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is
* avoided some Docmost versions corrupt an attachment on overwrite, exactly
* as replaceImage documents) and the node is repointed with new dimensions.
*/
async drawioUpdate(
pageId: string,
node: string,
xml: string,
baseHash: string,
layout?: "elk",
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
if (typeof baseHash !== "string" || baseHash.length === 0) {
throw new Error(
"drawioUpdate: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
);
}
// Resolve the node and read the CURRENT diagram to enforce the optimistic
// lock before doing any write or upload.
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
const oldAttrs = drawio.attrs || {};
const oldSrc = oldAttrs.src;
// The returned handle is the caller-supplied reference. drawio nodes carry
// no persisted id, so `ref` (an "#<index>" or a rare legacy attrs.id) is the
// honest identifier to hand back.
const nodeId = oldAttrs.id ?? ref;
if (!oldSrc) {
throw new Error(
`drawioUpdate: node "${node}" on page ${pageId} has no src to compare against`,
);
}
const currentSvg = await this.fetchAttachmentText(oldSrc);
const currentHash = mxHash(decodeDrawioSvg(currentSvg));
if (currentHash !== baseHash) {
throw new Error(
`drawioUpdate: conflict — the diagram changed since it was read ` +
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
);
}
// Optional server-side ELK auto-layout (best-effort; see drawioCreate).
const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml;
// Pipeline for the new content (throws a structured DrawioLintError).
const prepared = prepareModel(laidOutXml);
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
const diagramTitle = oldAttrs.title || "Page-1";
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
const att = await this.uploadAttachmentBuffer(
pageId,
Buffer.from(svg, "utf-8"),
"diagram.drawio.svg",
"image/svg+xml",
);
const newSrc = `/api/files/${att.id}/${att.fileName}`;
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
let repointed = 0;
const repoint = (n: any) => {
n.attrs = {
...n.attrs,
src: newSrc,
attachmentId: att.id,
width: prepared.bbox.width,
height: prepared.bbox.height,
};
repointed++;
};
const mutation = await this.mutatePage(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
repointed = 0;
const doc =
liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (!Array.isArray(doc.content)) doc.content = [];
// Repoint ONLY the resolved node — never every node that happens to
// share this attachmentId (a copied diagram is two nodes with one
// attachmentId; keying on it would clobber both). Re-resolve the same
// handle against the live doc and walk to its exact position.
const hit = getNodeByRef(doc, ref);
if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip
let target: any = doc;
for (const idx of hit.path) {
if (!target || !Array.isArray(target.content)) {
target = null;
break;
}
target = target.content[idx];
}
if (!target || target.type !== "drawio") return null;
repoint(target);
if (repointed === 0) return null; // node vanished concurrently -> skip
return doc;
},
);
if (repointed === 0) {
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: [
...prepared.warnings,
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
],
verify: mutation.verify,
};
}
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: prepared.warnings,
verify: mutation.verify,
};
}
// --- draw.io high-level semantic tools (issue #425) ---
/**
* ID-based targeted edits of an existing drawio diagram (add / update / delete
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
* the operations to the parsed model (a `delete` CASCADES to container children
* and to every edge whose source/target is deleted), then runs the SAME #423
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
* repoint the node). Ids are stable so diffs stay meaningful across edits.
*/
// --- draw.io high-level semantic tools (issue #425) ---
/**
* ID-based targeted edits of an existing drawio diagram (add / update / delete
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
* the operations to the parsed model (a `delete` CASCADES to container children
* and to every edge whose source/target is deleted), then runs the SAME #423
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
* repoint the node). Ids are stable so diffs stay meaningful across edits.
*/
async drawioEditCells(
pageId: string,
node: string,
operations: CellOp[],
baseHash: string,
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
if (typeof baseHash !== "string" || baseHash.length === 0) {
throw new Error(
"drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
);
}
if (!Array.isArray(operations) || operations.length === 0) {
throw new Error(
"drawioEditCells: operations must be a non-empty array of { op, ... }",
);
}
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
const oldAttrs = drawio.attrs || {};
const oldSrc = oldAttrs.src;
const nodeId = oldAttrs.id ?? ref;
if (!oldSrc) {
throw new Error(
`drawioEditCells: node "${node}" on page ${pageId} has no src to edit`,
);
}
const currentSvg = await this.fetchAttachmentText(oldSrc);
const currentModel = decodeDrawioSvg(currentSvg);
const currentHash = mxHash(currentModel);
if (currentHash !== baseHash) {
throw new Error(
`drawioEditCells: conflict — the diagram changed since it was read ` +
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
);
}
// Apply the operations to the parsed model, then run the standard pipeline.
const editedModel = applyCellOps(currentModel, operations);
const prepared = prepareModel(editedModel);
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
const diagramTitle = oldAttrs.title || "Page-1";
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
const att = await this.uploadAttachmentBuffer(
pageId,
Buffer.from(svg, "utf-8"),
"diagram.drawio.svg",
"image/svg+xml",
);
const newSrc = `/api/files/${att.id}/${att.fileName}`;
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
let repointed = 0;
const mutation = await this.mutatePage(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
repointed = 0;
const doc =
liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] };
if (!Array.isArray(doc.content)) doc.content = [];
const hit = getNodeByRef(doc, ref);
if (!hit || hit.type !== "drawio") return null;
let target: any = doc;
for (const idx of hit.path) {
if (!target || !Array.isArray(target.content)) {
target = null;
break;
}
target = target.content[idx];
}
if (!target || target.type !== "drawio") return null;
target.attrs = {
...target.attrs,
src: newSrc,
attachmentId: att.id,
width: prepared.bbox.width,
height: prepared.bbox.height,
};
repointed++;
return doc;
},
);
if (repointed === 0) {
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: [
...prepared.warnings,
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
],
verify: mutation.verify,
};
}
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: prepared.warnings,
verify: mutation.verify,
};
}
/**
* The main high-level tool: build a diagram from a SEMANTIC graph (nodes with
* a `kind`/`icon`, groups, edges) the model never supplies coordinates or
* style strings. The server resolves icons via the shape catalog (#424),
* assigns palette colors from the preset, runs ELK layered layout (honouring
* `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups),
* and assembles linter-clean XML, then inserts it through the SAME create
* pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a
* target `node` is given (it preserves that diagram's existing coordinates and
* places only new cells); on a fresh insert it behaves like "full".
*/
async drawioFromGraph(
pageId: string,
where: {
position: "before" | "after" | "append";
anchorNodeId?: string;
anchorText?: string;
},
graph: Graph,
direction?: "LR" | "RL" | "TB" | "BT",
preset?: string,
layout?: GraphLayoutMode,
node?: string,
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
iconsResolved: number;
iconsMissing: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
// Direction/preset supplied as separate params override the graph fields so
// both the flat tool schema and an inline graph can set them.
const merged: Graph = {
...graph,
direction: direction ?? graph.direction,
preset: preset ?? graph.preset,
};
const mode: GraphLayoutMode = layout ?? "full";
// Incremental into an EXISTING node: read its coords so ELK preserves them,
// and keep the full existing model so incremental MERGES (never drops) any
// cell the new graph doesn't re-list.
let existingCoords: Map<string, { x: number; y: number }> | undefined;
let existingModelXml: string | undefined;
let editExisting = false;
let baseHash: string | undefined;
if (node && (mode === "incremental" || mode === "none")) {
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
const src = (drawio.attrs || {}).src;
if (src) {
const svg = await this.fetchAttachmentText(src);
const model = decodeDrawioSvg(svg);
baseHash = mxHash(model);
existingModelXml = model;
existingCoords = new Map();
for (const c of parseDrawioCells(model)) {
if (c.vertex && c.geometry.x != null && c.geometry.y != null) {
existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y });
}
}
editExisting = true;
}
}
const built = await buildFromGraph(
merged,
mode,
existingCoords,
existingModelXml,
);
if (editExisting && node && baseHash) {
// Re-target the existing diagram: replace it with the assembled model.
const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash);
return {
...res,
iconsResolved: built.iconsResolved,
iconsMissing: built.iconsMissing,
};
}
const res = await this.drawioCreate(pageId, where, built.modelXml);
return {
...res,
iconsResolved: built.iconsResolved,
iconsMissing: built.iconsMissing,
};
}
/**
* Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE
* parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the
* drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most
* common wiki case); other diagram types throw a clear error so the model can
* fall back to drawioFromGraph.
*/
async drawioFromMermaid(
pageId: string,
where: {
position: "before" | "after" | "append";
anchorNodeId?: string;
anchorText?: string;
},
mermaid: string,
preset?: string,
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
iconsResolved: number;
iconsMissing: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
const graph = mermaidToGraph(mermaid);
if (preset) graph.preset = preset;
return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset);
}
// --- Page history / diff / transform ---
/**
* List the saved versions (history snapshots) of a page, newest first.
* Docmost auto-snapshots on every save. Returns one cursor-paginated page of
* results: `{ items, nextCursor }`. The history record's id field is `id`.
*/
}
return DrawioMixin;
}
+165
View File
@@ -0,0 +1,165 @@
// Central REST error diagnostics (issues #437 + #450). SINGLE place that maps an
// axios error to the model-facing message. Extracted verbatim from client.ts;
// the constructor's response interceptor (see client/context.ts) routes every
// REST call through formatDocmostAxiosError so the whole surface is uniform.
import axios from "axios";
// --- Issue #437: central error diagnostics -------------------------------
// The agent only ever sees the thrown exception's `error.message`, so a failed
// tool must return an ACTIONABLE message (method, path, status, and the
// server's own validation text) instead of the opaque "Request failed with
// status code 400". These helpers + the response interceptor in the
// constructor are the single authoritative place that text is composed.
// Overall cap on the composed diagnostic message so the model context stays
// compact and a (whitelisted) server string can never blow up the text.
const ERROR_MESSAGE_CAP = 300;
// Only attempt to JSON.parse an arraybuffer body under this size: a larger
// binary body is never a JSON error envelope, so parsing it just wastes memory
// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch
// carries the JSON error envelope as raw bytes here).
const ERROR_BUFFER_PARSE_CAP = 4096;
// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant-
// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the
// canonical shape/length is enforced, not the version/variant nibble.
const FULL_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Throw an actionable error BEFORE any network call when `value` is not a full
* canonical UUID. Absorbs #436: a truncated/short comment id used to reach the
* server and bounce back as an opaque 400/404 the agent could not self-correct;
* failing fast here names the exact fix.
*/
export function assertFullUuid(
tool: string,
param: string,
value: string,
): void {
if (typeof value !== "string" || !FULL_UUID_RE.test(value)) {
throw new Error(
`${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` +
`019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` +
`verbatim from listComments / createComment output.`,
);
}
}
// Keep ONLY the pathname of a request (no host, no query string, no fragment)
// so the message never leaks a host or query params. Resolves a relative
// config.url against config.baseURL, then discards everything but the path.
function requestPath(config: any): string {
const rawUrl = typeof config?.url === "string" ? config.url : "";
const base =
typeof config?.baseURL === "string" ? config.baseURL : undefined;
try {
// A dummy base makes an absolute config.url parse too; its host is dropped.
return new URL(rawUrl, base ?? "http://localhost").pathname;
} catch {
// Malformed url: still strip any query/fragment manually.
return rawUrl.split(/[?#]/)[0] || rawUrl;
}
}
/**
* Compose the server-facing message from `error.response.data`, using ONLY the
* whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the
* raw response body, headers (Authorization!) and config are NEVER read here
* a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in
* favour of the statusText.
*/
function extractServerMessage(data: any, statusText: string): string {
// class-validator envelope: { message: string | string[], error?: string }.
if (
data &&
typeof data === "object" &&
!Buffer.isBuffer(data) &&
!(data instanceof ArrayBuffer)
) {
const msg = (data as any).message;
if (Array.isArray(msg)) {
const joined = msg.filter((m) => typeof m === "string").join("; ");
if (joined) return joined;
} else if (typeof msg === "string" && msg) {
return msg;
}
const err = (data as any).error;
if (typeof err === "string" && err) return err;
return statusText;
}
// Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a
// failed arraybuffer fetch still surfaces the server's validation text.
if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) {
try {
return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText);
} catch {
return statusText;
}
}
return statusText;
}
// A raw string / HTML body is never surfaced (may echo server internals).
return statusText;
}
/**
* Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic:
* `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`
* or, when the request never got a response:
* `<METHOD> <path> failed: <code> (no response from server)`.
*
* Mutates the SAME error object (never a custom subclass) so the live
* axios.isAxiosError / error.response?.status / config._retry checks around the
* client keep working, and sets `_docmostFormatted` as a double-processing
* guard. A no-op on a non-axios or already-formatted error.
*/
export function formatDocmostAxiosError(error: any): void {
if (!error || error._docmostFormatted) return;
if (!axios.isAxiosError(error)) return;
const config: any = error.config ?? {};
const method =
typeof config.method === "string" ? config.method.toUpperCase() : "";
const methodPath = `${method} ${requestPath(config)}`.trim();
const response = error.response;
let message: string;
if (response) {
const statusText =
typeof response.statusText === "string" ? response.statusText : "";
const serverMessage = extractServerMessage(response.data, statusText);
message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`;
// Full body only to stderr under DEBUG (parity with downloadImage).
if (process.env.DEBUG) {
console.error(
"Docmost request failed; response body:",
JSON.stringify(response.data),
);
}
} else {
// No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout).
// Use ONLY error.code, never the raw error.message: axios network messages
// embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo
// ENOTFOUND host") and #437's invariant is that the host never reaches the
// model-visible message. code is set for essentially every real no-response
// error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full
// native message still goes to stderr under DEBUG.
const reason = error.code ?? "network error";
message = `${methodPath} failed: ${reason} (no response from server)`;
if (process.env.DEBUG) {
console.error("Docmost request failed; no response:", error.message);
}
}
if (message.length > ERROR_MESSAGE_CAP) {
message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…";
}
error.message = message;
(error as any)._docmostFormatted = true;
}
+730
View File
@@ -0,0 +1,730 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import FormData from "form-data";
import axios, { AxiosInstance } from "axios";
import { basename, extname } from "path";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import { withPageLock, isUuid } from "../lib/page-lock.js";
import { diffDocs, summarizeChange } from "../lib/diff.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
// Supported image types, kept as two lookup tables so both a local file
// extension and a remote Content-Type can be mapped to the same canonical set.
const EXT_TO_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
};
const MIME_TO_EXT: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
};
// Public method surface of MediaMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements IMediaMixin` fails to compile on drift.
export interface IMediaMixin {
uploadImage(pageId: string, url: string): any;
insertImage(pageId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string; replaceText?: string; afterText?: string; }): any;
replaceImage(pageId: string, oldAttachmentId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string }): any;
}
export function MediaMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IMediaMixin> & TBase {
abstract class MediaMixin extends Base implements IMediaMixin {
// --- Image upload / embedding ---
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
protected supportedImageMime(ct: string): string | null {
return MIME_TO_EXT[ct] ? ct : null;
}
/**
* Download a remote image from a caller-supplied URL and resolve its bytes,
* MIME and a filename.
*
* SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is
* fetched BY THE SERVER, so it must be guarded before and after the request.
* The guards mirror the local-file trust boundary in uploadImage:
* - scheme allowlist (http/https only) rejects file:, data:, ftp:, etc.,
* so the caller cannot use this path to read local files or other schemes;
* - a size cap enforced both via axios maxContentLength/maxBodyLength AND a
* post-download buffer.length re-check (defends against a missing/lying
* Content-Length), so a huge response cannot exhaust memory;
* - a 30s timeout. The timeout matters because replaceImage holds the
* per-page lock across this upload, so a hung download would wedge the
* lock for that page.
* We deliberately do NOT block private IP ranges: the MCP caller is already
* trusted to read arbitrary host files via the filePath path, so the marginal
* trust granted by fetching internal URLs is comparable, and blocking would
* break legitimate internal-image use.
*/
protected async fetchRemoteImage(
url: string,
maxBytes: number,
): Promise<{ buffer: Buffer; mime: string; fileName: string }> {
// Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes
// (file:, data:, ftp:, ...) before any network request is made.
let parsed: URL;
try {
parsed = new URL(url);
} catch (e: any) {
throw new Error(`Invalid image URL "${url}": ${e.message}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`,
);
}
let response;
try {
response = await axios.get(url, {
responseType: "arraybuffer",
timeout: 30000,
maxContentLength: maxBytes,
maxBodyLength: maxBytes,
headers: { Accept: "image/*" },
});
} catch (error) {
// Keep the thrown message free of the raw response body (it may echo
// server internals); surface only status/statusText. The full body is
// logged under DEBUG for diagnostics.
if (axios.isAxiosError(error)) {
if (process.env.DEBUG) {
console.error(
"Image download failed; response body:",
JSON.stringify(error.response?.data),
);
}
throw new Error(
`Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim(),
);
}
throw error;
}
// axios returns an ArrayBuffer for responseType: "arraybuffer".
const buffer = Buffer.from(response.data);
// Re-check the size: maxContentLength relies on Content-Length, which may be
// absent or lie, so guard against the actual byte count too.
if (buffer.length === 0) {
throw new Error(`Empty image response from "${url}"`);
}
if (buffer.length > maxBytes) {
throw new Error(
`Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`,
);
}
// Resolve MIME: prefer the response Content-Type (strip any "; charset=..."
// parameter, lowercase, trim) mapped through the supported set; if the
// header is generic/missing/unsupported, fall back to the URL path
// extension via the existing extension->MIME logic.
const rawCt = response.headers?.["content-type"];
let mime: string | null = null;
if (typeof rawCt === "string" && rawCt.length > 0) {
const ct = rawCt.split(";")[0].trim().toLowerCase();
mime = this.supportedImageMime(ct);
}
if (!mime) {
// Fall back to the URL path extension. Use the pathname so the query
// string never contaminates the extension lookup.
const ext = extname(parsed.pathname).toLowerCase();
mime = EXT_TO_MIME[ext] ?? null;
}
if (!mime) {
throw new Error(
`cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`,
);
}
// Build a filename from the URL path basename (ignore the query string),
// defaulting to "image" when empty, and ensure it ends with the canonical
// extension for the resolved MIME (append it when missing/mismatched).
const canonicalExt = MIME_TO_EXT[mime];
let fileName = basename(parsed.pathname) || "image";
if (extname(fileName).toLowerCase() !== canonicalExt) {
fileName += canonicalExt;
}
return { buffer, mime, fileName };
}
/** Build a Docmost ProseMirror image node from an uploaded attachment. */
protected buildImageNode(
att: { id: string; fileName: string; fileSize?: number },
align?: "left" | "center" | "right",
alt?: string,
): any {
// Clean file URL, matching Docmost's native behaviour. No cache-busting
// query: the server serves the bare URL correctly, and replacement creates
// a new attachment id (a new URL) which busts caches naturally.
const src = `/api/files/${att.id}/${att.fileName}`;
const node: any = {
type: "image",
attrs: {
src,
attachmentId: att.id,
// Default to null when the server omits fileSize so the attr is never
// undefined (undefined would be dropped on serialization / break the
// ProseMirror image schema which expects size present).
size: att.fileSize ?? null,
align: align || "center",
width: null,
},
};
if (alt) node.attrs.alt = alt;
return node;
}
/**
* Download a remote image from an http(s) URL and upload it as an attachment
* of a page, returning the attachment metadata plus a ready-to-insert
* ProseMirror image node. Local file paths are intentionally not supported:
* the MCP caller is a remote AI with no access to this server's filesystem.
*/
async uploadImage(pageId: string, url: string) {
await this.ensureAuthenticated();
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB
// Fetch + validate the remote image (scheme allowlist, size cap, timeout).
// See fetchRemoteImage for the SSRF / resource trust boundary.
const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES);
const fileBuffer = fetched.buffer;
const mime = fetched.mime;
const fileName = fetched.fileName;
// Build a FRESH FormData for every send attempt. A FormData body is a
// single-use stream that is CONSUMED on the first send, so it cannot be
// replayed by this.client's response interceptor (replaying a consumed
// stream fails with 'socket hang up'). Multipart re-auth is therefore done
// here with bare axios and an explicit one-shot 401/403 retry that rebuilds
// the body. Field order matters: text fields must precede the file part so
// the server reads them; the server always generates a fresh attachment id.
const buildForm = () => {
const form = new FormData();
form.append("pageId", pageId);
form.append("file", fileBuffer, {
filename: fileName,
contentType: mime,
});
return form;
};
// Local name distinct from the `url` parameter (the source image URL): this
// is the /files/upload endpoint we POST the multipart body to.
const uploadUrl = `${this.apiUrl}/files/upload`;
let response;
try {
// Call buildForm() ONCE per attempt and reuse the instance for both
// getHeaders() and the body so the Content-Type boundary matches the body.
const form = buildForm();
// Read the Authorization header from this.client's defaults (set by
// login(), only ever deleted — never set to null) instead of building
// `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
// mid-flight, which would otherwise produce a literal "Bearer null".
// ensureAuthenticated() above guarantees login() ran, so the default
// header exists here. A 60s timeout keeps a hung upload from wedging the
// per-page lock (replaceImage holds withPageLock across this call).
response = await axios.post(uploadUrl, form, {
headers: {
...form.getHeaders(),
Authorization: this.client.defaults.headers.common["Authorization"],
},
timeout: 60000,
});
} catch (error) {
// On an expired-token auth error, re-login and retry exactly once with a
// freshly-rebuilt FormData (the previous one was already consumed).
if (
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
await this.login();
const form2 = buildForm();
response = await axios.post(uploadUrl, form2, {
headers: {
...form2.getHeaders(),
Authorization: this.client.defaults.headers.common["Authorization"],
},
timeout: 60000,
});
} else if (axios.isAxiosError(error)) {
// Keep the thrown message free of the raw response body (it may echo
// request data or server internals); surface only status/statusText.
// The full body is logged under DEBUG for diagnostics.
if (process.env.DEBUG) {
console.error(
"Image upload failed; response body:",
JSON.stringify(error.response?.data),
);
}
throw new Error(
`Image upload failed: ${error.response?.status} ${error.response?.statusText}`,
);
} else {
throw error;
}
}
// The attachment may arrive bare or wrapped in a { data } envelope.
const att = response.data?.data ?? response.data;
if (!att?.id || !att?.fileName) {
throw new Error(
"Unexpected /files/upload response: " + JSON.stringify(response.data),
);
}
// Some Docmost versions omit fileSize from the upload response. Fall back
// to the fetched byte length (the bytes we just uploaded) so callers never
// get an undefined size.
const resolvedSize = att.fileSize ?? fileBuffer.length;
return {
attachmentId: att.id,
fileName: att.fileName,
fileSize: resolvedSize,
src: `/api/files/${att.id}/${att.fileName}`,
imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }),
};
}
/**
* Upload an image from a web (http/https) URL and insert it into a page in
* one step.
* By default the image is appended at the end. With replaceText, the first
* top-level block whose text contains the string is replaced; with afterText,
* the image is inserted right after the first matching block. All other
* block ids are preserved (only one top-level block is added or swapped).
*/
async insertImage(
pageId: string,
url: string,
opts: {
align?: "left" | "center" | "right";
alt?: string;
replaceText?: string;
afterText?: string;
} = {},
) {
const up = await this.uploadImage(pageId, url);
// Reuse the node from uploadImage (clean /api/files/<id>/<file> src), then
// apply align/alt onto a shallow attrs copy.
const node: any = { ...up.imageNode, attrs: { ...up.imageNode.attrs } };
if (opts.align) node.attrs.align = opts.align;
if (opts.alt) node.attrs.alt = opts.alt;
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
// uploadImage /files/upload call above keeps the agent-supplied id.
const pageUuid = await this.resolvePageId(pageId);
// Recursively collect the plain text of a top-level block.
const blockText = (n: any): string => {
let out = "";
if (n.type === "text") out += n.text || "";
for (const child of n.content || []) out += blockText(child);
return out;
};
// Insert into the LIVE synced document, not the debounced REST snapshot, so
// concurrent edits/comments/images are preserved and parallel insertImage
// calls (serialized by the per-page lock) each see the previous insertion.
let placement: "replaced" | "after" | "appended" | undefined;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
const doc =
liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (!Array.isArray(doc.content)) doc.content = [];
if (opts.replaceText) {
// Ambiguity guard (mirrors editPageText): count matching top-level
// blocks first, so a non-unique fragment cannot silently replace the
// wrong block (e.g. text that also appears inside a callout/table).
const matches = doc.content.filter((b: any) =>
blockText(b).includes(opts.replaceText!),
);
if (matches.length === 0) {
throw new Error(`replaceText not found: "${opts.replaceText}"`);
}
if (matches.length > 1) {
throw new Error(
`replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`,
);
}
const idx = doc.content.findIndex((b: any) =>
blockText(b).includes(opts.replaceText!),
);
// Data-loss guard: replaceText swaps the WHOLE top-level block, so if
// the fragment only appears nested inside a container (table, callout,
// list, blockquote) the entire structure would be destroyed. Refuse
// when the matched block is a container rather than a leaf
// paragraph/heading and point the caller at a safer tool.
const CONTAINER_TYPES = new Set([
"table",
"callout",
"bulletList",
"orderedList",
"taskList",
"blockquote",
]);
const matchedBlock = doc.content[idx];
if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) {
throw new Error(
`replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` +
`Use afterText to insert near it, or updatePageJson for surgical edits.`,
);
}
doc.content.splice(idx, 1, node);
placement = "replaced";
} else if (opts.afterText) {
// Ambiguity guard (mirrors editPageText): refuse a non-unique fragment.
const matches = doc.content.filter((b: any) =>
blockText(b).includes(opts.afterText!),
);
if (matches.length === 0) {
throw new Error(`afterText not found: "${opts.afterText}"`);
}
if (matches.length > 1) {
throw new Error(
`afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`,
);
}
const idx = doc.content.findIndex((b: any) =>
blockText(b).includes(opts.afterText!),
);
doc.content.splice(idx + 1, 0, node);
placement = "after";
} else {
doc.content.push(node);
placement = "appended";
}
return doc;
},
);
return {
success: true,
pageId,
attachmentId: up.attachmentId,
src: up.src,
placement,
verify: mutation.verify,
};
}
/**
* Replace an existing image in a page with a new image fetched from a web
* (http/https) URL. Uploads the new file as a brand-new attachment, which
* yields a fresh clean URL that both renders correctly and busts browser
* caches (the URL changed). Finds every image node
* whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested
* in callouts/tables) and repoints its src/attachmentId/size, preserving
* comments, alignment and alt. Operates on the live collab document so comments
* and concurrent edits are preserved. Throws if no matching image is found.
*
* The OLD attachment is left in place as an unreferenced orphan: Docmost
* exposes NO HTTP API to delete a single content attachment (verified against
* the attachment controller/service and by probing the live API deletion
* happens only by cascade when the page, space or user is removed). This is the
* same outcome as Docmost's own editor when an image is removed/replaced.
* In-place byte overwrite is deliberately NOT used because some Docmost
* versions corrupt the attachment (HTTP 500) when its bytes are overwritten.
*/
async replaceImage(
pageId: string,
oldAttachmentId: string,
url: string,
opts: { align?: "left" | "center" | "right"; alt?: string } = {},
) {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
// page lock must ALSO key on the UUID so this operation serializes against
// other writes to the same page (mutatePageContent now locks by the resolved
// UUID too); locking by the raw slugId here would desync the mutex key and
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
// keeps the agent-supplied id (it hits REST, not the collab doc).
const pageUuid = await this.resolvePageId(pageId);
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
// Previously the scan and the write were two separate mutatePageContent
// calls, each acquiring + releasing the lock, with the upload happening in
// the UNLOCKED gap between them. A concurrent op could interleave there: it
// could remove the target image so the write pass matches nothing, leaving
// the freshly-uploaded attachment as an un-deletable orphan (Docmost has no
// API to delete a single content attachment). Acquiring the lock once and
// using the non-locking collab helper inside (the per-page mutex is NOT
// reentrant, so the self-locking mutatePageContent would deadlock here)
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
// and does not touch the page lock, so it is safe to call while held.
return withPageLock(pageUuid, async () => {
// STEP 1: read-only live check. Scan the live document for any image node
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
// throws without ever creating an orphan attachment.
let matchFound = false;
const scan = (nodes: any[]) => {
for (const node of nodes) {
if (!node) continue;
if (
node.type === "image" &&
node.attrs &&
node.attrs.attachmentId === oldAttachmentId
) {
matchFound = true;
}
if (Array.isArray(node.content)) scan(node.content);
}
};
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
matchFound = false; // reset per-transform (collab may retry the read).
const doc =
liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (Array.isArray(doc.content)) scan(doc.content);
return null; // read-only: never write on the check pass.
});
if (!matchFound) {
throw new Error(
`replaceImage: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`,
);
}
// STEP 2: a match exists — upload the new file as a FRESH attachment (new
// id, new clean URL) and repoint every matching node in a second pass.
// Still inside the SAME lock, so no other op can have changed the page
// since the scan.
const up = await this.uploadImage(pageId, url);
let replaced = 0;
// Swap the source of one image node, preserving align/alt/title/geometry.
const repoint = (node: any) => {
node.attrs = {
...node.attrs,
src: up.src,
attachmentId: up.attachmentId,
// Default to null when fileSize is unknown so the attr is never
// undefined.
size: up.fileSize ?? null,
};
if (opts.align) node.attrs.align = opts.align;
if (opts.alt !== undefined) node.attrs.alt = opts.alt;
replaced++;
};
// Recursively repoint every image node (incl. ones nested in callouts/tables).
const walk = (nodes: any[]) => {
for (const node of nodes) {
if (!node) continue;
if (
node.type === "image" &&
node.attrs &&
node.attrs.attachmentId === oldAttachmentId
) {
repoint(node);
}
if (Array.isArray(node.content)) walk(node.content);
}
};
const mutation = await this.mutateLiveContentUnlocked(
pageUuid,
collabToken,
(liveDoc) => {
// Reset per-transform so collab retries recompute cleanly (no double-count).
replaced = 0;
const doc =
liveDoc && liveDoc.type === "doc"
? liveDoc
: { type: "doc", content: [] };
if (!Array.isArray(doc.content)) doc.content = [];
walk(doc.content);
if (replaced === 0) return null; // no match -> skip the write entirely
return doc;
},
);
// KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no
// text/mark change) may still report verify.changed === false, because the
// text+marks+integrity-count model in summarizeChange does not inspect
// image `src`/attachmentId attributes. That is acceptable here — the
// replace is confirmed by `replaced` below, and verify is supplementary.
if (replaced === 0) {
// The pass-1 SCAN found the target (matchFound was true) and we already
// uploaded the new attachment, but pass-2 matched nothing — a concurrent
// editor must have removed the node between the two passes. Do NOT throw
// here (that would leak the just-uploaded attachment AND report failure);
// instead report success with the upload flagged as an unreferenced
// orphan so the caller knows. (The early throw above still covers the
// case where pass-1 finds nothing, before any upload happens.)
return {
success: true,
replaced: 0,
pageId,
oldAttachmentId,
newAttachmentId: up.attachmentId,
src: up.src,
orphanedAttachmentId: up.attachmentId,
warning:
"target image was removed concurrently; uploaded attachment is unreferenced",
verify: mutation.verify,
};
}
return {
success: true,
pageId,
replaced,
oldAttachmentId,
newAttachmentId: up.attachmentId,
src: up.src,
verify: mutation.verify,
};
});
}
// --- draw.io diagrams (issue #423) ---
/**
* Upload a ready-made byte buffer as a page attachment via the same
* multipart /files/upload endpoint uploadImage uses. Split out as its own
* (overridable) seam so drawioCreate/update can upload the generated
* `.drawio.svg` without going through the URL-fetch path, and so tests can
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
* attempt).
*/
// --- draw.io diagrams (issue #423) ---
/**
* Upload a ready-made byte buffer as a page attachment via the same
* multipart /files/upload endpoint uploadImage uses. Split out as its own
* (overridable) seam so drawioCreate/update can upload the generated
* `.drawio.svg` without going through the URL-fetch path, and so tests can
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
* attempt).
*/
protected async uploadAttachmentBuffer(
pageId: string,
buffer: Buffer,
fileName: string,
mime: string,
): Promise<{ id: string; fileName: string; fileSize: number }> {
await this.ensureAuthenticated();
const buildForm = () => {
const form = new FormData();
form.append("pageId", pageId);
form.append("file", buffer, { filename: fileName, contentType: mime });
return form;
};
const uploadUrl = `${this.apiUrl}/files/upload`;
let response;
try {
const form = buildForm();
response = await axios.post(uploadUrl, form, {
headers: {
...form.getHeaders(),
Authorization: this.client.defaults.headers.common["Authorization"],
},
timeout: 60000,
});
} catch (error) {
if (
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
await this.login();
const form2 = buildForm();
response = await axios.post(uploadUrl, form2, {
headers: {
...form2.getHeaders(),
Authorization: this.client.defaults.headers.common["Authorization"],
},
timeout: 60000,
});
} else if (axios.isAxiosError(error)) {
if (process.env.DEBUG) {
console.error(
"Attachment upload failed; response body:",
JSON.stringify(error.response?.data),
);
}
throw new Error(
`Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`,
);
} else {
throw error;
}
}
const att = response.data?.data ?? response.data;
if (!att?.id || !att?.fileName) {
throw new Error(
"Unexpected /files/upload response: " + JSON.stringify(response.data),
);
}
return {
id: att.id,
fileName: att.fileName,
fileSize: att.fileSize ?? buffer.length,
};
}
/**
* Fetch a stored `.drawio.svg` attachment as text. Overridable seam over
* fetchInternalFile (the authed loopback fetch, which also rejects any
* traversal/SSRF src) so drawioGet/update can read the current diagram and
* tests can stub the bytes.
*/
protected async fetchAttachmentText(src: string): Promise<string> {
const { buffer } = await this.fetchInternalFile(src);
return buffer.toString("utf-8");
}
/**
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
* node plus its ref. Throws a clear error if the ref does not resolve to a
* drawio node.
*/
}
return MediaMixin;
}
+700
View File
@@ -0,0 +1,700 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import {
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
assertUnambiguousMatch,
insertNodeRelative,
insertNodesRelative,
blockPlainText,
buildOutline,
getNodeByRef,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import {
importMarkdownFragment,
canBeDocChild,
findUnrepresentableTableAttrs,
} from "../lib/markdown-fragment.js";
import {
applyTextEdits,
TextEdit,
TextEditResult,
TextEditFailure,
} from "../lib/json-edit.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
// Public method surface of NodesWriteMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements INodesWriteMixin` fails to compile on drift.
export interface INodesWriteMixin {
updatePageJson(pageId: string, doc?: any, title?: string): any;
editPageText(pageId: string, edits: TextEdit[]): any;
patchNode(pageId: string, nodeId: string, input: { markdown?: string; node?: any }): any;
insertNode(pageId: string, input: { markdown?: string; node?: any }, opts: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }): any;
deleteNode(pageId: string, nodeId: string): any;
}
export function NodesWriteMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & INodesWriteMixin> & TBase {
abstract class NodesWriteMixin extends Base implements INodesWriteMixin {
/**
* Replace page content with a raw ProseMirror JSON document (lossless) and/or
* update its title. Both `doc` and `title` are optional, but at least one must
* be supplied:
* - `doc` provided -> validate + full-overwrite the body (and update the
* title too when `title` is also given).
* - `doc` omitted, `title` given -> title-only update; the body is NOT
* touched/resent (no collab write happens).
* - neither given -> throws (nothing to update).
*/
async updatePageJson(pageId: string, doc?: any, title?: string) {
await this.ensureAuthenticated();
// Title-only / no-op handling: when no document is supplied, do NOT write
// the body. Update the title if one was given; otherwise there is nothing
// to do, so fail loudly rather than silently no-op.
if (doc == null) {
if (!title) {
throw new Error(
"updatePageJson: nothing to update (provide content and/or title)",
);
}
await this.client.post("/pages/update", { pageId, title });
return {
success: true,
modified: true,
message: "Page title updated (content left unchanged).",
pageId,
};
}
// Validate the document shape before a full overwrite: a malformed doc
// would otherwise silently corrupt the page (full-overwrite is the
// documented behaviour; no optimistic-concurrency is applied here).
if (
typeof doc !== "object" ||
doc.type !== "doc" ||
!Array.isArray(doc.content)
) {
throw new Error(
'content must be a ProseMirror document ({"type":"doc","content":[...]}) ' +
"where content is an array of nodes each having a string `type`",
);
}
// Recurse the WHOLE document so a malformed nested node (e.g. a node with a
// non-string type, a non-array content/marks, or a text node missing its
// string text) is rejected up front rather than silently corrupting the
// page on overwrite.
this.validateDocStructure(doc);
// #409: beyond the string-`type` check above, reject a nested node whose
// `type` is a string but NOT a known Docmost schema node (a typo/unknown
// block) — the same `Unknown node type` the encoder throws — with a rich,
// path-anchored message, still BEFORE any collab connection.
this.assertValidNodeShape("updatePageJson", doc);
// Sanitize URLs before writing. This closes the JSON-path bypass: unlike
// the markdown link path (which TipTap sanitizes), raw JSON could otherwise
// inject javascript:/data: link hrefs or media srcs straight into the doc.
this.validateDocUrls(doc);
// Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot
// leave footnotes out of order, orphaned, or in multiple lists — the bottom
// list + numbering are always derived from reference order. No-op when the
// footnotes are already canonical.
// #419: normalize + merge glyph-forked definitions before canonicalizing.
doc = normalizeAndMergeFootnotes(doc);
doc = canonicalizeFootnotes(doc);
// Write the BODY first, then the title (#159 split-brain): a failed body
// write (e.g. persist timeout) must not leave a new title over the old body.
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
const mutation = await this.replacePage(
pageUuid,
doc,
collabToken,
this.apiUrl,
);
// Body persisted successfully — now it is safe to set the title.
if (title) {
await this.client.post("/pages/update", { pageId, title });
}
return {
success: true,
modified: true,
message: "Page content replaced from ProseMirror JSON.",
pageId,
verify: mutation.verify,
};
}
/**
* AUTHOR-INLINE footnote insertion. The agent supplies only WHERE
* (`anchorText`, a snippet of body text to attach the marker after) and WHAT
* (`text`, the footnote content as markdown). Numbering and the bottom
* `footnotesList` are derived deterministically server-side
* (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees,
* assigns, or edits a footnote number or the list, so it CANNOT desync.
*
* Content DEDUP: when an existing definition has the same content, its id is
* reused (one number, one definition, several references). The write is atomic
* via `mutatePageContent` (single-writer, page-locked); if the anchor text is
* not found the transform aborts with a clear error and no write happens.
*/
/**
* Surgical text edits: find/replace inside text nodes of the live
* document. Preserves all block ids, marks, callouts and tables.
*/
async editPageText(pageId: string, edits: TextEdit[]) {
await this.ensureAuthenticated();
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
// Apply the edits against the LIVE synced document, not the debounced REST
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
// records per-edit match problems in `failed` instead of throwing, and
// applies whatever it can; we abort the write only when nothing applied.
let results: TextEditResult[] | undefined;
let failed: TextEditFailure[] | undefined;
// Whether we actually wrote new content. Set inside the transform: a
// degenerate edit (e.g. find === replace, or a batch that nets to no change)
// can "apply" yet leave the document byte-for-byte identical, in which case
// we must NOT write (no spurious history version) and must not claim a write
// happened.
let wrote = false;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
wrote = false;
const r = applyTextEdits(liveDoc, edits);
results = r.results;
failed = r.failed;
// Nothing applied -> abort the write (mutatePageContent treats a null
// return from the transform as "write nothing").
if (r.results.length === 0) return null;
// Edits "applied" but produced an identical document: skip the write so
// no new history version is created. Stable structural comparison via
// JSON.stringify (both docs come from the same deep-copied source, so
// key order is stable).
if (JSON.stringify(r.doc) === JSON.stringify(liveDoc)) return null;
wrote = true;
return r.doc;
},
);
if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) {
// No edit applied: surface an aggregated, actionable error so the caller
// does not mistake a no-op for a partial success.
throw new Error(
"editPageText: no edits were applied (nothing written). " +
failed!.map((f) => `"${f.find}": ${f.reason}`).join("; "),
);
}
// Edits matched but produced no content change (identical document): report
// a successful no-op — NOT a failure — and do not falsely claim a write.
if (!wrote) {
return {
success: true,
pageId,
applied: results,
failed,
message: "No changes written (edits produced identical content).",
verify: mutation.verify,
};
}
const result: any = {
success: true,
pageId,
applied: results,
failed,
message:
(failed?.length ?? 0)
? `Applied ${results?.length ?? 0} edit(s); ${failed!.length} failed (see failed[]). Node ids and formatting preserved.`
: "Text edits applied (node ids and formatting preserved).",
verify: mutation.verify,
};
// If any applied edit matched only after stripping markdown (the
// normalized fallback), warn that editPageText preserved existing marks
// and did NOT change formatting — so a caller who intended a formatting
// change is pointed at patchNode.
if (results?.some((r) => r.normalized === true)) {
result.warning =
"Some edits matched only after stripping markdown from your find string; " +
"editPageText preserved existing marks (it did not change bold/strike/etc.). " +
"If you intended a formatting change, use patchNode.";
}
return result;
}
/**
* Replace the block whose attrs.id === nodeId. Operates on the LIVE collab
* document so comments and concurrent edits are preserved.
*
* Exactly one of `input.markdown` / `input.node` (#413):
* - `markdown` (RECOMMENDED): the block is rewritten from a canonical markdown
* fragment. The fragment may import to N blocks (a "1 -> N" splice: rewrite a
* whole section in one call). The FIRST resulting block INHERITS the target's
* `attrs.id` (so an existing comment anchoring the block by id survives); the
* rest get FRESH ids. `^[...]` footnotes in the fragment are first-class:
* their definitions merge into the page's TAIL footnote list (content-key
* dedup + canonicalize), same machinery insertFootnote uses. REJECTED when
* the TARGET block carries a table-cell attribute markdown cannot represent
* (colspan/rowspan/colwidth/background) use the table tools or `node`.
* - `node`: a raw ProseMirror node for precise attr/mark work. The replacement
* keeps the target id (if `node.attrs.id` is missing it is set to nodeId).
*
* #159 ambiguous-id semantics are unchanged: 0 matches -> "no node"; >1 matches
* -> "ambiguous, refused" (nothing written), on BOTH paths the markdown path
* runs a dry `replaceNodeById` count first, so a duplicated id never splices.
*/
async patchNode(
pageId: string,
nodeId: string,
input: { markdown?: string; node?: any },
) {
await this.ensureAuthenticated();
// XOR: exactly one of markdown / node. Both optional in the schema; the
// runtime enforces the recommendation ("markdown for prose, node for fine
// work") without letting an ambiguous both-or-neither call through.
const hasMd =
input != null &&
typeof input.markdown === "string" &&
input.markdown.trim() !== "";
const hasNode = input != null && input.node != null;
if (hasMd === hasNode) {
throw new Error(
"patchNode: provide exactly one of `markdown` (recommended, for prose) " +
"or `node` (a raw ProseMirror node, for precise attr/mark work)",
);
}
if (hasMd) {
return this.patchNodeMarkdown(pageId, nodeId, input.markdown as string);
}
return this.patchNodeJson(pageId, nodeId, input.node);
}
/**
* patchNode with a raw ProseMirror `node` (the pre-#413 behavior). Replaces
* EVERY node whose attrs.id === nodeId; the swapped-in node keeps the target
* id. #159 ambiguity refused. Split out so the markdown path can reuse the
* shared collab/guard plumbing without a giant branch.
*/
protected async patchNodeJson(pageId: string, nodeId: string, node: any) {
if (!node || typeof node !== "object" || typeof node.type !== "string") {
throw new Error(
"patchNode: `node` must be an object with a string `type`",
);
}
// Preserve the block id WITHOUT mutating the caller's object: build a local
// copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the
// node it replaces).
const target = {
...node,
attrs: {
...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}),
},
};
if (target.attrs.id == null) {
target.attrs.id = nodeId;
}
// #409: fail fast on a malformed node SHAPE (a nested child with an
// absent/unknown `type`, e.g. a text leaf written as `{"text":"foo"}` with
// no `"type":"text"`) BEFORE opening a collab session or taking the page
// lock — the root-only `typeof node.type === "string"` check above never
// sees nested children, and the encoder's `Unknown node type: undefined`
// would otherwise only surface after the connection.
this.assertValidNodeShape("patchNode", target);
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
// Track the replacement count in an outer var, reset per-transform, so a
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
let replaced = 0;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
replaced = 0;
const { doc: nd, replaced: r } = replaceNodeById(
liveDoc,
nodeId,
target,
);
replaced = r;
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost
// duplicates block ids on copy/paste (and copyPageContent writes them
// verbatim), so replacing "the node with id X" would silently clobber
// EVERY duplicate (#159). Refuse: skip the write and throw below so the
// model re-targets with a more specific anchor instead of corrupting the
// page. Only an unambiguous single match is written.
if (replaced !== 1) return null;
return nd;
},
);
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
// the write for any count !== 1). Single shared guard (#159, #185 review).
assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId);
return { success: true, replaced, nodeId, verify: mutation.verify };
}
/**
* patchNode with a MARKDOWN fragment (#413). Imports the fragment through the
* canonical importer, then 1 -> N splices the resulting blocks in place of the
* target block on the LIVE collab doc:
* - the FIRST block inherits the target's id; the rest get FRESH ids (minted
* by the importer/id-remap, so neighbour blocks are untouched);
* - `^[...]` footnote definitions merge into the page's tail list;
* - REJECTED when the target block carries a markdown-unrepresentable table
* attr (colspan/rowspan/colwidth/background) guarding against silent loss;
* - #159 ambiguity is enforced by a dry `replaceNodeById` count BEFORE the
* splice, so a duplicated id never writes.
*/
protected async patchNodeMarkdown(
pageId: string,
nodeId: string,
markdown: string,
) {
// Import the fragment up front (network-free, canonical) so a bad fragment
// fails before any collab connection or page lock.
const { blocks, definitions } = await importMarkdownFragment(markdown);
// The first imported block inherits the target id; the rest keep the fresh
// ids the importer assigned. Build the thread now so it is stable across a
// collab retry (the transform below is pure over its inputs).
const threaded = blocks.map((b, i) => {
if (i !== 0) return b;
return {
...b,
attrs: {
...(b && typeof b.attrs === "object" ? b.attrs : {}),
id: nodeId,
},
};
});
// Shape-validate every imported block up front (parity with the JSON path):
// the importer only emits schema nodes, but the check is cheap insurance and
// yields the same rich #409 diagnostics if the schema ever drifts.
for (const b of threaded) {
this.assertValidNodeShape("patchNode", b);
}
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
let replaced = 0;
let guardAttrs: string | null = null;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
replaced = 0;
guardAttrs = null;
// #159: count matches with the same recursive walk the JSON path uses;
// only an UNAMBIGUOUS single match may write. A dry count keeps the
// ambiguity semantics identical across both paths.
const { replaced: count } = replaceNodeById(liveDoc, nodeId, {
type: "paragraph",
});
replaced = count;
if (count !== 1) return null;
// Guard against SILENT LOSS: if the target block carries a table-cell
// attribute markdown cannot represent (colspan/rowspan/colwidth/
// background), refuse the markdown rewrite so those attrs are not
// dropped. Simple tables (no such attrs) rewrite fine.
const hit = getNodeByRef(liveDoc, nodeId);
guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null;
if (guardAttrs != null) return null;
// Re-mint any minted block id that collides with an existing page id
// (skip index 0: its id is intentionally the target nodeId, unique by
// the #159 dry-count above), so the 1 -> N splice stays page-wide unique.
reassignCollidingBlockIds(liveDoc, threaded, 0);
// 1 -> N splice, then merge any fragment footnote definitions into the
// page's tail list and re-derive canonical footnote numbering.
const { doc: spliced } = replaceNodeByIdWithMany(
liveDoc,
nodeId,
threaded,
);
return mergeFootnoteDefinitions(spliced, definitions);
},
);
// Surface the guard rejection with an actionable message (nothing written).
if (guardAttrs != null) {
throw new Error(
`patchNode: the target block has table-cell attributes markdown cannot ` +
`represent (${guardAttrs}) — a markdown rewrite would drop them. Use ` +
`the table tools (tableUpdateCell/tableInsertRow) or pass a raw ` +
`ProseMirror \`node\` instead of \`markdown\`.`,
);
}
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform skipped the write
// for any count !== 1). Shared #159 guard, identical to the JSON path.
assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId);
return {
success: true,
replaced,
nodeId,
blocks: threaded.length,
verify: mutation.verify,
};
}
/**
* Insert content relative to an anchor (or append it at the top level).
* Operates on the LIVE collab document so comments and concurrent edits are
* preserved.
*
* Exactly one of `input.markdown` / `input.node` (#413):
* - `markdown` (RECOMMENDED): a canonical markdown fragment. It may import to
* SEVERAL blocks they are inserted IN ORDER at the anchor. `^[...]`
* footnote definitions merge into the page's tail list (same machinery as
* insertFootnote). Every inserted block gets a fresh id.
* - `node`: a raw ProseMirror node for precise attr/mark work, or to insert
* table structure (a bare tableRow/tableCell/tableHeader NOT expressible in
* markdown, so those stay JSON-only).
*
* opts.position:
* - "append": push the content at the end of the top-level content.
* - "before"/"after": insert as a sibling of the anchor, just before/after it.
* Exactly one of anchorNodeId / anchorText must be given; anchorNodeId
* locates a node anywhere by attrs.id, anchorText matches the first top-level
* block whose plain text includes it.
*
* Throws if the anchor cannot be found.
*/
async insertNode(
pageId: string,
input: { markdown?: string; node?: any },
opts: {
position: "before" | "after" | "append";
anchorNodeId?: string;
anchorText?: string;
},
) {
await this.ensureAuthenticated();
// XOR: exactly one of markdown / node (both optional in the schema).
const hasMd =
input != null &&
typeof input.markdown === "string" &&
input.markdown.trim() !== "";
const hasNode = input != null && input.node != null;
if (hasMd === hasNode) {
throw new Error(
"insertNode: provide exactly one of `markdown` (recommended, for prose) " +
"or `node` (a raw ProseMirror node, for precise attr/mark work or table structure)",
);
}
if (
!opts ||
(opts.position !== "before" &&
opts.position !== "after" &&
opts.position !== "append")
) {
throw new Error(
'insertNode: `position` must be one of "before", "after", "append"',
);
}
if (opts.position === "before" || opts.position === "after") {
// before/after require EXACTLY ONE anchor (an id or a text fragment).
const hasId =
typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0;
const hasText =
typeof opts.anchorText === "string" && opts.anchorText.length > 0;
if (hasId === hasText) {
throw new Error(
`insertNode: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`,
);
}
}
// Resolve the ordered list of blocks to insert plus any footnote definitions
// to merge. The markdown path imports canonically (so an inserted block is
// byte-identical to the same content in a full-page import); the node path is
// a single block with no footnote merge (raw JSON `^[...]` is not touched).
let blocks: any[];
let definitions: any[] = [];
if (hasMd) {
const frag = await importMarkdownFragment(input.markdown as string);
blocks = frag.blocks;
definitions = frag.definitions;
} else {
const node = input.node;
if (!node || typeof node !== "object" || typeof node.type !== "string") {
throw new Error(
"insertNode: `node` must be an object with a string `type`",
);
}
blocks = [node];
}
// #409: fail fast on a malformed node SHAPE (a nested child with an
// absent/unknown `type`) BEFORE opening a collab session or taking the page
// lock — the root-only check above never sees nested children.
for (const b of blocks) {
this.assertValidNodeShape("insertNode", b);
}
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
// Track insertion in an outer var, reset per-transform, so a collab retry
// recomputes it cleanly (mirrors replaceImage's pattern).
let inserted = false;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
inserted = false;
// Re-mint any minted block id that collides with an existing page id
// (all inserted blocks are fresh, no skip) so the splice stays unique.
if (hasMd) reassignCollidingBlockIds(liveDoc, blocks);
// Single-block node path keeps `insertNodeRelative` (it owns the
// structural table-node splicing); the markdown path uses the array
// splice so N blocks land in order at one anchor.
const res = hasMd
? insertNodesRelative(liveDoc, blocks, opts)
: insertNodeRelative(liveDoc, blocks[0], opts);
inserted = res.inserted;
if (!inserted) return null; // anchor not found -> skip the write entirely
// Merge any fragment footnote definitions into the page tail list and
// re-derive canonical numbering (no-op when there are none).
return mergeFootnoteDefinitions(res.doc, definitions);
},
);
if (!inserted) {
const anchorDesc = opts.anchorNodeId
? `anchorNodeId "${opts.anchorNodeId}"`
: `anchorText "${opts.anchorText}"`;
// anchorText is matched against the block's literal RENDERED plain text;
// markdown/emoji are tolerated only as a strip-and-retry fallback, so a
// miss usually means the text differs from what's on the page.
const hint = opts.anchorText
? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from getPageJson is more reliable."
: "";
throw new Error(
`insertNode: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`,
);
}
return {
success: true,
inserted: true,
position: opts.position,
blocks: blocks.length,
verify: mutation.verify,
};
}
/**
* Remove EVERY node whose attrs.id === nodeId (recursively, including nodes
* nested in callouts/tables) from its parent content array. Operates on the
* LIVE collab document so comments and concurrent edits are preserved.
* Throws if no node matches.
*/
async deleteNode(pageId: string, nodeId: string) {
await this.ensureAuthenticated();
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
// Track the deletion count in an outer var, reset per-transform, so a
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
let deleted = 0;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
deleted = 0;
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
deleted = d;
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block
// ids are duplicated on copy/paste, #159): deleting "the node with id X"
// would silently remove EVERY duplicate. Refuse: skip the write and throw
// below so the model re-targets. Only an unambiguous single match is
// deleted.
if (deleted !== 1) return null;
return nd;
},
);
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
// the write for any count !== 1). Single shared guard (#159, #185 review).
assertUnambiguousMatch("deleteNode", "delete", deleted, nodeId, pageId);
return { success: true, deleted, nodeId, verify: mutation.verify };
}
/** Build the public share URL for a page. */
}
return NodesWriteMixin;
}
+655
View File
@@ -0,0 +1,655 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import FormData from "form-data";
import axios, { AxiosInstance } from "axios";
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import { footnoteWarningsField } from "../lib/footnote-analyze.js";
import {
serializeDocmostMarkdown,
parseDocmostMarkdown,
} from "../lib/markdown-document.js";
import { diffDocs, summarizeChange } from "../lib/diff.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
import vm from "node:vm";
// Public method surface of PagesMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements IPagesMixin` fails to compile on drift.
export interface IPagesMixin {
createPage(title: string, content: string, spaceId: string, parentPageId?: string): any;
updatePage(pageId: string, content: string, title?: string): any;
renamePage(pageId: string, title: string): any;
movePage(pageId: string, parentPageId: string | null, position?: string): any;
deletePage(pageId: string): any;
sharePage(pageId: string, searchIndexing?: boolean): any;
listShares(): any;
unsharePage(pageId: string): any;
exportPageMarkdown(pageId: string): Promise<string>;
importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any>;
copyPageContent(sourcePageId: string, targetPageId: string): any;
listPageHistory(pageId: string, cursor?: string): any;
getPageHistory(historyId: string): any;
restorePageVersion(historyId: string): any;
diffPageVersions(pageId: string, from?: string, to?: string): any;
}
export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IPagesMixin> & TBase {
abstract class PagesMixin extends Base implements IPagesMixin {
/**
* Create a new page with title and content.
* Uses the /pages/import workaround (the only endpoint accepting content),
* then moves the page and restores the exact title: the import endpoint
* derives the title from the FILENAME and replaces spaces with
* underscores, so we explicitly re-set it via /pages/update afterwards.
*/
async createPage(
title: string,
content: string,
spaceId: string,
parentPageId?: string,
) {
await this.ensureAuthenticated();
if (parentPageId) {
try {
await this.getPage(parentPageId);
} catch (e) {
throw new Error(`Parent page with ID ${parentPageId} not found.`);
}
}
// 1. Create content via Import (using multipart/form-data).
// Build a FRESH FormData per send attempt: a FormData body is a single-use
// stream consumed on the first send, so it cannot be replayed by
// this.client's response interceptor (replay fails with 'socket hang up').
// Multipart re-auth is therefore done here with bare axios and an explicit
// one-shot 401/403 retry that rebuilds the body.
const fileContent = Buffer.from(content, "utf-8");
const buildForm = () => {
const form = new FormData();
form.append("spaceId", spaceId);
form.append("file", fileContent, {
filename: `${title || "import"}.md`,
contentType: "text/markdown",
});
return form;
};
const importUrl = `${this.apiUrl}/pages/import`;
let response;
try {
// Call buildForm() ONCE per attempt and reuse the instance for both
// getHeaders() and the body so the Content-Type boundary matches the body.
const form = buildForm();
// Read the Authorization header from this.client's defaults (set by
// login(), only ever deleted — never set to null) instead of building
// `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
// mid-flight, which would otherwise produce a literal "Bearer null".
// ensureAuthenticated() above guarantees login() ran, so the default
// header exists here.
response = await axios.post(importUrl, form, {
headers: {
...form.getHeaders(),
Authorization: this.client.defaults.headers.common["Authorization"],
},
timeout: 60000,
});
} catch (error) {
// On an expired-token auth error, re-login and retry exactly once with a
// freshly-rebuilt FormData (the previous one was already consumed).
if (
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
await this.login();
const form2 = buildForm();
response = await axios.post(importUrl, form2, {
headers: {
...form2.getHeaders(),
Authorization: this.client.defaults.headers.common["Authorization"],
},
timeout: 60000,
});
} else {
throw error;
}
}
const newPageId = (response.data?.data ?? response.data).id;
// 2. Move to parent if needed
if (parentPageId) {
await this.movePage(newPageId, parentPageId);
}
// 3. Restore the exact title (import mangles spaces into underscores)
if (title) {
await this.client.post("/pages/update", { pageId: newPageId, title });
}
const page = await this.getPage(newPageId);
// Surface non-fatal footnote problems (dangling refs, empty/duplicate
// definitions, markers in tables) so the agent can fix its markup (#166).
return { ...page, ...footnoteWarningsField(content) };
}
/**
* Update a page's content from markdown and optionally its title.
* NOTE: full re-import block ids regenerate. For surgical changes
* use editPageText / updatePageJson instead.
*/
async updatePage(pageId: string, content: string, title?: string) {
await this.ensureAuthenticated();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
// REST /pages/update title write below keeps the agent-supplied id (the
// server resolves a slugId there).
const pageUuid = await this.resolvePageId(pageId);
// Write the BODY first, then the title (#159 split-brain). If the collab
// body write fails (e.g. a persist timeout), the title must be left
// UNTOUCHED so the page never ends up with a new title over its old body.
// A title write failing AFTER a successful body is rarer (REST is fast) and
// leaves correct content under a stale title — the lesser inconsistency.
let collabToken = "";
let mutation;
try {
collabToken = await this.getCollabTokenWithReauth();
mutation = await updatePageContentRealtime(
pageUuid,
content,
collabToken,
this.apiUrl,
);
} catch (error: any) {
// Verbose diagnostics (incl. anything that could expose a token prefix)
// are gated behind DEBUG; the thrown Error below carries no token data.
if (process.env.DEBUG) {
console.error(
"Failed to update page content via realtime collaboration:",
error,
);
const tokenPreview = collabToken
? collabToken.substring(0, 15) + "..."
: "null";
console.error(`Collab token preview: ${tokenPreview}`);
}
throw new Error(`Failed to update page content: ${error.message}`);
}
// Body persisted successfully — now it is safe to set the title.
if (title) {
await this.client.post("/pages/update", { pageId, title });
}
return {
success: true,
modified: true,
message: "Page updated successfully.",
pageId: pageId,
verify: mutation.verify,
// Non-fatal footnote diagnostics (#166); omitted when there are none.
...footnoteWarningsField(content),
};
}
/**
* Validate a URL string against a scheme allowlist for a given context.
*
* The markdown link path enforces safe schemes via TipTap, but the raw
* JSON path (updatePageJson) bypasses that so this is the sanitization
* choke point for ProseMirror JSON written directly by the caller.
*
* - "link": reject javascript:, vbscript:, data: (any scheme that can
* execute or smuggle script when the href is clicked).
* - "src": allow only http(s):, mailto:, /api/files paths, or a
* scheme-less relative/absolute path; reject
* javascript:/vbscript:/data:/file:.
*/
/**
* Rename a page (change its title only) without touching or resending its
* content. The slug is derived from the page record, not the body, so it is
* left intact too.
*/
async renamePage(pageId: string, title: string) {
await this.ensureAuthenticated();
await this.client.post("/pages/update", { pageId, title });
return { success: true, pageId, title };
}
/**
* Copy the WHOLE content of one page onto another, entirely server-side: the
* source's ProseMirror document is read and written verbatim onto the target
* via the live collab path, so the document never passes through the model.
*
* Only the target's BODY is replaced its title and slug live on the page
* record (not in the content), so they are untouched. The source page is not
* modified at all.
*/
async movePage(
pageId: string,
parentPageId: string | null,
position?: string,
) {
await this.ensureAuthenticated();
// Docmost requires position >= 5 chars.
const validPosition = position || "a00000";
return this.client
.post("/pages/move", {
pageId,
parentPageId,
position: validPosition,
})
.then((res) => res.data);
}
async deletePage(pageId: string) {
await this.ensureAuthenticated();
return this.client
.post("/pages/delete", { pageId })
.then((res) => res.data);
}
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
/**
* Normalize a comment's `content` into a ProseMirror doc object before
* markdown conversion. createComment/updateComment send content as a
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
* string, so parse it first (guarded fall back to the raw value on any
* parse failure so a non-JSON legacy value is still handled gracefully).
*/
/** Share a page publicly (idempotent) and return the public URL. */
async sharePage(pageId: string, searchIndexing: boolean = true) {
await this.ensureAuthenticated();
const response = await this.client.post("/shares/create", {
pageId,
includeSubPages: false,
searchIndexing,
});
const share = response.data?.data ?? response.data;
const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId;
return {
shareId: share.id,
key: share.key,
pageId: share.pageId,
publicUrl: this.shareUrl(share.key, slugId),
searchIndexing: share.searchIndexing,
};
}
/** List all public shares in the workspace with their URLs. */
/** Build the public share URL for a page. */
protected shareUrl(shareKey: string, slugId: string): string {
return `${this.appUrl}/share/${shareKey}/p/${slugId}`;
}
/** Share a page publicly (idempotent) and return the public URL. */
/** List all public shares in the workspace with their URLs. */
async listShares() {
const shares = await this.paginateAll("/shares", {});
return shares.map((s: any) => ({
shareId: s.id,
key: s.key,
pageId: s.pageId,
pageTitle: s.page?.title,
publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null,
searchIndexing: s.searchIndexing,
createdAt: s.createdAt,
}));
}
/** Remove the public share of a page. */
async unsharePage(pageId: string) {
await this.ensureAuthenticated();
const shares = await this.listShares();
const share = shares.find((s: any) => s.pageId === pageId);
if (!share) {
throw new Error(`Page ${pageId} is not shared.`);
}
await this.client.post("/shares/delete", { shareId: share.shareId });
return { success: true, removedShareId: share.shareId, pageId };
}
/**
* Export a page to a single self-contained Docmost-flavoured markdown file:
* meta block + body (with inline comment anchors + diagrams) + comment
* threads. Lossless round-trip target; see importPageMarkdown for the inverse.
*/
async exportPageMarkdown(pageId: string): Promise<string> {
await this.ensureAuthenticated();
const page = await this.getPageRaw(pageId);
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
let comments: any[] = [];
try {
// Lossless export: include RESOLVED threads so the export -> import
// round-trip preserves every comment. This is exactly why the active-only
// filter is an opt-in (default false) on listComments.
comments = (await this.listComments(pageId, true)).items;
} catch (e) {
// A comments fetch failure must not lose the body; export with [] and let
// the caller see the (empty) comments block. Log under DEBUG only.
if (process.env.DEBUG) console.error("export: listComments failed", e);
}
const meta = {
version: 1,
pageId: page.id,
slugId: page.slugId,
title: page.title,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
};
return serializeDocmostMarkdown(meta, body, comments);
}
/**
* Import a self-contained Docmost markdown file back into a page. Parses out
* the meta + comments metadata blocks, converts the body to ProseMirror
* (restoring comment marks + diagrams from their inline HTML), and replaces
* the page content. Comment THREAD records are NOT written to the server in
* this version they are preserved in the file and the inline marks are
* re-applied so the highlights survive; managing comment records stays with
* the comment tools/UI.
*/
async importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any> {
await this.ensureAuthenticated();
const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown);
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
const doc = await markdownToProseMirrorCanonical(body);
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
const mutation = await replacePageContent(
pageUuid,
doc,
collabToken,
this.apiUrl,
);
// Collect distinct comment ids that actually became comment marks in the doc.
const collectCommentIds = (node: any, acc: Set<string>): Set<string> => {
if (!node || typeof node !== "object") return acc;
if (Array.isArray(node.marks)) {
for (const mk of node.marks) {
if (mk && mk.type === "comment" && mk.attrs?.commentId) {
acc.add(mk.attrs.commentId);
}
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) collectCommentIds(child, acc);
}
return acc;
};
// Count reflects the comment marks present in the written document, so an id
// that only appears as inert text (e.g. inside a fenced code block) is not
// counted because it never becomes a comment mark.
const anchoredIds = collectCommentIds(doc, new Set<string>());
const result: any = {
success: true,
pageId,
anchoredCommentCount: anchoredIds.size,
commentsInFile: Array.isArray(comments) ? comments.length : 0,
verify: mutation.verify,
};
// Warn (non-fatal) if the file was exported from a DIFFERENT page.
if (meta?.pageId && meta.pageId !== pageId) {
result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`;
}
// Non-fatal footnote diagnostics (#166), analyzed on the BODY (the part after
// the docmost:meta / docmost:comments blocks) — so a `[^x]`-like token inside
// those JSON blocks never produces a false warning, while real markers in the
// body do. `body` comes from parseDocmostMarkdown(fullMarkdown) above.
Object.assign(result, footnoteWarningsField(body));
return result;
}
/**
* Rename a page (change its title only) without touching or resending its
* content. The slug is derived from the page record, not the body, so it is
* left intact too.
*/
/**
* Copy the WHOLE content of one page onto another, entirely server-side: the
* source's ProseMirror document is read and written verbatim onto the target
* via the live collab path, so the document never passes through the model.
*
* Only the target's BODY is replaced its title and slug live on the page
* record (not in the content), so they are untouched. The source page is not
* modified at all.
*/
async copyPageContent(sourcePageId: string, targetPageId: string) {
await this.ensureAuthenticated();
// A self-copy would be a no-op overwrite; reject it explicitly so a caller
// mistake surfaces as a clear error rather than a silent round-trip.
if (sourcePageId === targetPageId) {
throw new Error(
"copyPageContent: sourcePageId and targetPageId are the same page (no-op copy)",
);
}
const source = await this.getPageRaw(sourcePageId);
const content = source?.content;
if (
!content ||
typeof content !== "object" ||
content.type !== "doc" ||
!Array.isArray(content.content)
) {
throw new Error(
`copyPageContent: source page ${sourcePageId} has no usable ProseMirror content to copy`,
);
}
// Defense-in-depth: run the same URL-scheme sanitizer the JSON write path
// uses, so copying never lands a javascript:/data: href/src on the target
// (parity with updatePageJson; harmless for already-stored source content).
this.validateDocUrls(content);
// Defense-in-depth (#228): this is a FULL-document write, so canonicalize
// footnotes before copying — a no-op on already-canonical source content, but
// it guarantees a copy can never propagate a non-canonical footnote topology
// to the target (parity with the other full-doc write paths).
// #419: normalize + merge glyph-forked definitions before canonicalizing.
const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content));
const collabToken = await this.getCollabTokenWithReauth();
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
const targetUuid = await this.resolvePageId(targetPageId);
const mutation = await this.replacePage(
targetUuid,
canonical,
collabToken,
this.apiUrl,
);
return {
success: true,
sourcePageId,
targetPageId,
copiedNodes: canonical.content.length,
verify: mutation.verify,
};
}
/**
* Surgical text edits: find/replace inside text nodes of the live
* document. Preserves all block ids, marks, callouts and tables.
*/
// --- Page history / diff / transform ---
/**
* List the saved versions (history snapshots) of a page, newest first.
* Docmost auto-snapshots on every save. Returns one cursor-paginated page of
* results: `{ items, nextCursor }`. The history record's id field is `id`.
*/
async listPageHistory(pageId: string, cursor?: string) {
await this.ensureAuthenticated();
const payload: Record<string, any> = { pageId };
if (cursor) payload.cursor = cursor;
const response = await this.client.post("/pages/history", payload);
const data = response.data?.data ?? response.data;
return {
items: data?.items ?? [],
nextCursor: data?.meta?.nextCursor ?? null,
};
}
/**
* Fetch a single page-history version including its lossless ProseMirror
* `content`. The version also carries pageId/title/createdAt.
*/
async getPageHistory(historyId: string) {
await this.ensureAuthenticated();
const response = await this.client.post("/pages/history/info", {
historyId,
});
return response.data?.data ?? response.data;
}
/**
* "Restore" a version: Docmost has NO restore endpoint, so we take the
* version's `content` and write it as the page's current content via the live
* collab path (which itself creates a new history snapshot). Returns the
* affected pageId and the source historyId.
*/
async restorePageVersion(historyId: string) {
await this.ensureAuthenticated();
const version = await this.getPageHistory(historyId);
if (
!version ||
!version.pageId ||
!version.content ||
typeof version.content !== "object"
) {
throw new Error(
`restorePageVersion: history ${historyId} has no usable content`,
);
}
// Defense-in-depth: sanitize URLs in the restored content (parity with the
// JSON write path) before writing it back.
this.validateDocUrls(version.content);
const collabToken = await this.getCollabTokenWithReauth();
// version.pageId is the page entity id (already a UUID); resolvePageId
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
const pageUuid = await this.resolvePageId(version.pageId);
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
() => version.content,
);
return {
pageId: version.pageId,
restoredFrom: historyId,
verify: mutation.verify,
};
}
/**
* Diff two versions of a page and return a Docmost-equivalent change set.
* `from`/`to` each resolve to a ProseMirror doc:
* - null / undefined / "current" -> the page's CURRENT content;
* - any other string -> that historyId's content.
* Returns the diff plus the resolved version metadata for each side.
*/
async diffPageVersions(pageId: string, from?: string, to?: string) {
await this.ensureAuthenticated();
const isCurrent = (v?: string) => v == null || v === "" || v === "current";
const resolveSide = async (
v?: string,
): Promise<{ doc: any; meta: any }> => {
if (isCurrent(v)) {
const raw = await this.getPageRaw(pageId);
return {
doc: raw.content || { type: "doc", content: [] },
meta: {
kind: "current",
pageId,
title: raw.title,
updatedAt: raw.updatedAt,
},
};
}
const version = await this.getPageHistory(v as string);
return {
doc: version.content || { type: "doc", content: [] },
meta: {
kind: "history",
historyId: version.id,
pageId: version.pageId,
title: version.title,
createdAt: version.createdAt,
},
};
};
const fromSide = await resolveSide(from);
const toSide = await resolveSide(to);
const diff = diffDocs(fromSide.doc, toSide.doc);
return { from: fromSide.meta, to: toSide.meta, diff };
}
/**
* Edit a page by running an arbitrary user-supplied JS transform against the
* live document, with a diff preview + page-history safety net.
*
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
* a 5s timeout, and NO access to require/process/fs/network. It must return a
* `{ type: "doc" }` node, which is validated structurally before any write.
*
* `ctx` exposes:
* - comments: the page's comments (fetched before the live read);
* - log: an array the transform can push diagnostics to (via console.log);
* - consume(id): mark a comment id as consumed (for deleteComments);
* - helpers: the transforms.ts primitives + commentsToFootnotes.
*
* Footnote convention used by the helpers: footnote markers are plain "[N]"
* text in the body, and the notes are an orderedList under a heading whose
* text is "Примечания переводчика".
*
* dryRun (default true): read the page's current content, run the transform,
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
* Otherwise the transform runs atomically inside mutatePageContent, optionally
* deletes consumed comments, and returns the new historyId + diff + log.
*/
}
return PagesMixin;
}
+656
View File
@@ -0,0 +1,656 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import axios, { AxiosInstance } from "axios";
import {
filterWorkspace,
filterSpace,
filterPage,
filterComment,
filterSearchResult,
} from "../lib/filters.js";
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
import {
collectInternalFileNodes,
normalizeFileUrl,
resolveInternalFilePath,
} from "../lib/internal-file-urls.js";
import { buildPageTree } from "../lib/tree.js";
import {
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
assertUnambiguousMatch,
insertNodeRelative,
insertNodesRelative,
blockPlainText,
buildOutline,
getNodeByRef,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import {
importMarkdownFragment,
canBeDocChild,
findUnrepresentableTableAttrs,
} from "../lib/markdown-fragment.js";
import { searchInDoc, SearchOptions } from "../lib/page-search.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
// Public method surface of ReadMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements IReadMixin` fails to compile on drift.
export interface IReadMixin {
getWorkspace(): any;
getSpaces(): any;
listPages(spaceId?: string, limit?: number, tree?: boolean): any;
getTree(spaceId: string, rootPageId?: string, maxDepth?: number): any;
getPageContext(pageId: string): any;
listSidebarPages(spaceId: string, pageId?: string): any;
getPage(pageId: string): any;
getPageJson(pageId: string): any;
getOutline(pageId: string): any;
getNode(pageId: string, nodeId: string, format?: "markdown" | "json"): any;
searchInPage(pageId: string, query: string, opts?: SearchOptions): any;
getTable(pageId: string, tableRef: string): any;
search(query: string, spaceId?: string, limit?: number, opts?: { parentPageId?: string; titleOnly?: boolean }): any;
}
export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IReadMixin> & TBase {
abstract class ReadMixin extends Base implements IReadMixin {
async getWorkspace() {
await this.ensureAuthenticated();
const response = await this.client.post("/workspace/info", {});
return {
data: filterWorkspace(response.data?.data ?? response.data),
success: response.data.success,
};
}
async getSpaces() {
const spaces = await this.paginateAll("/spaces", {});
return spaces.map((space) => filterSpace(space));
}
/**
* List pages in one of two modes.
*
* Default (`tree` false): most recent pages by updatedAt (descending),
* bounded. Fetching the whole space can exceed MCP response/time limits on
* large instances, so a single bounded page of results is returned (default
* 50, max 100) via the `/pages/recent` feed.
*
* Tree (`tree` true): DEPRECATED prefer `getTree`, which shares this exact
* code path (a single `/pages/tree` request via `enumerateSpacePages` +
* `buildPageTree`) but returns the compact `{pageId, title, children?,
* hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is
* kept for backward compatibility; it REQUIRES `spaceId` (a page tree is
* scoped to one space) and IGNORES `limit` the whole hierarchy is returned.
* It fetches the tree via `enumerateSpacePages`, which on the fork server
* resolves to a single `/pages/tree` request returning the whole
* permission-filtered flat page set (soft-deleted pages excluded
* server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for
* stock upstream servers that lack `/pages/tree`.
*/
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
await this.ensureAuthenticated();
if (tree) {
if (!spaceId) {
throw new Error(
"listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.",
);
}
// #486: propagate `truncated` (same pattern as check_new_comments). The old
// code dropped it, so a caller handed an INCOMPLETE tree (the stdio-fallback
// BFS hit its node cap) had no way to know pages were missing. Return the
// tree alongside the flag; the primary /pages/tree path is uncapped so this
// is false there.
const { pages, truncated } = await this.enumerateSpacePages(spaceId);
return { tree: buildPageTree(pages), truncated };
}
const clampedLimit = Math.max(1, Math.min(100, limit));
const payload: Record<string, any> = { limit: clampedLimit, page: 1 };
if (spaceId) payload.spaceId = spaceId;
const response = await this.client.post("/pages/recent", payload);
const data = response.data;
const items = data.data?.items || data.items || [];
return items.map((page: any) => filterPage(page));
}
/**
* Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE
* request the #443 `getTree` tool. Shares its whole code path with
* `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree`
* (with the cursor-BFS only as a fallback for stock upstream servers that lack
* the endpoint), then `buildPageTree` nests the flat, permission-filtered,
* position-ordered list. No second tree fetch, no per-node BFS.
*
* - `rootPageId` restrict to that page's subtree; the server seeds the CTE
* with the page itself, so the result is exactly ONE root (the page and its
* descendants). Omit it for the whole space.
* - `maxDepth` trim the response to that many levels (roots = depth 1) to
* save tokens; the server still returns everything in one request, the cut
* is applied in `buildPageTree` AFTER the full tree is built. A node whose
* children were cut carries `hasChildren: true` (source of truth = the flat
* item's server `hasChildren`) so the caller can descend with a follow-up
* `getTree(spaceId, rootPageId=that node)` call.
*
* Output nodes are `{pageId, title, children?, hasChildren?}` only the UUID
* `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId`
* (a page tree is scoped to one space).
*/
async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) {
await this.ensureAuthenticated();
if (!spaceId) {
throw new Error(
"getTree: spaceId is required (a page tree is scoped to one space).",
);
}
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
return buildPageTree(pages, { shape: "getTree", maxDepth });
}
/**
* "Where am I / what's around" for a single page the #443 `getPageContext`
* tool. Metadata only (no page content), using exactly TWO server requests:
*
* 1. `POST /pages/breadcrumbs` a recursive CTE that walks UP from the page.
* The server returns the chain root->page order (it `.reverse()`s the
* child-first walk before responding), INCLUDING the page itself as the
* LAST element. So the last element is the page and everything before it
* is the ancestor chain root->parent. This carries the page's own title
* and spaceId, so no extra page-info fetch is needed for a UUID input.
* 2. `listSidebarPages(spaceId, pageId)` the page's DIRECT children,
* cursor-paginated (a page with >20 children returns ALL of them, no
* dupes) and in sidebar `position` order, each carrying `hasChildren`.
*
* The input may be a slugId (agents copy them from URLs); it is run through
* `resolvePageId` first, exactly like the other page tools. A UUID input adds
* no request there (short-circuit), keeping the total at two; a slugId input
* adds one unavoidable resolve round-trip.
*
* INVARIANT: only the UUID `pageId` is exposed anywhere server `id` is
* mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible
* pageId makes the server 404/403, which propagates as a clear tool error
* (never a hollow empty object).
*/
async getPageContext(pageId: string) {
await this.ensureAuthenticated();
// Resolve a possibly-slugId input to the canonical UUID (no round-trip for a
// UUID). Errors here (bad/inaccessible id) propagate as a clear tool error.
const pageUuid = await this.resolvePageId(pageId);
// Request 1: the ancestor chain, root->page, page included as the LAST item.
const response = await this.client.post("/pages/breadcrumbs", {
pageId: pageUuid,
});
const chain: any[] = (response.data?.data ?? response.data) ?? [];
if (!Array.isArray(chain) || chain.length === 0) {
// The endpoint always includes the page itself, so an empty chain means
// the page is gone/inaccessible — surface a clear error, not {}.
throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`);
}
// Split: the last element is the page, the rest (root->parent) are the
// breadcrumbs. A root page has no ancestors -> breadcrumbs is [].
const self = chain[chain.length - 1];
const ancestors = chain.slice(0, -1);
const page = {
pageId: self.id,
title: self.title,
spaceId: self.spaceId,
};
const breadcrumbs = ancestors.map((n: any) => ({
pageId: n.id,
title: n.title,
}));
// Request 2: direct children in sidebar order, each with hasChildren.
const childItems = await this.listSidebarPages(self.spaceId, pageUuid);
const children = childItems.map((c: any) => ({
pageId: c.id,
title: c.title,
hasChildren: Boolean(c.hasChildren),
}));
return { page, breadcrumbs, children };
}
/**
* List sidebar pages for a space. With no pageId the request returns the
* space ROOT pages; with a pageId it returns the direct CHILDREN of that
* page. pageId is therefore optional and is only included in the POST body
* when provided (an empty/undefined pageId would otherwise change the
* semantics on the server).
*/
async listSidebarPages(spaceId: string, pageId?: string) {
await this.ensureAuthenticated();
// Paginate via the server-issued cursor. The server switched from OFFSET
// (`page`) to CURSOR (`cursor`/`nextCursor`) pagination, and the global
// ValidationPipe(whitelist:true) SILENTLY STRIPS the obsolete `page` field
// — so the old offset loop got the SAME first page every time (with
// hasNextPage stuck true) and dropped every child beyond the first page.
const MAX_PAGES = 50;
let cursor: string | undefined;
let allItems: any[] = [];
let truncated = false;
for (let i = 0; i < MAX_PAGES; i++) {
// limit: 100 is the server-side Max; cuts request count 5x vs the default 20.
const payload: Record<string, any> = { spaceId, limit: 100 };
// Only send pageId when scoping to a page's children; omit it for roots.
if (pageId) payload.pageId = pageId;
if (cursor) payload.cursor = cursor;
const data = (await this.client.post("/pages/sidebar-pages", payload)).data
?.data;
allItems = allItems.concat(data?.items ?? []);
// Advance strictly via the server-issued cursor; a missing/repeated cursor
// means the protocol drifted again — stop instead of looping on page one.
const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null;
if (!next || next === cursor) break;
cursor = next;
// Reaching the ceiling with more pages still available means the child
// list is truncated (mirrors paginateAll).
if (i === MAX_PAGES - 1) truncated = true;
}
// Warn on real truncation (ceiling hit while the server still had pages) so
// the caller is not silently handed an incomplete child list.
if (truncated) {
console.warn(
`listSidebarPages: children of "${pageId ?? spaceId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
);
}
return allItems;
}
/**
* Enumerate EVERY page in a space (or in a subtree, when rootPageId is given).
*
* Primary path (fork server): a SINGLE `POST /pages/tree` returns the whole
* space (or a subtree) as a flat, permission-filtered list in one request, in
* the exact node shape buildPageTree consumes. This replaces the old
* per-node BFS, which issued N sidebar requests and after the server moved
* to cursor pagination silently lost every child past the first sidebar
* page (the obsolete `page` param was stripped by ValidationPipe).
*
* The subtree variant (rootPageId given) INCLUDES the root node itself
* (getPageAndDescendants seeds with id = rootPageId), unlike the old BFS
* which started from the root's children.
*
* Fallback path (stdio mode may target STOCK upstream Docmost, which lacks
* `/pages/tree`): on a 404/405 it falls back to the cursor-based BFS below,
* walking direct children via the fixed cursor listSidebarPages. Safeguards:
* a `visited` Set of page ids prevents re-processing a node (cycles /
* duplicate references), and a hard node cap bounds pathological trees so the
* walk always terminates.
*
* Returns `{ pages, truncated }`. `truncated` is true ONLY when the fallback
* BFS stopped at its MAX_NODES cap the primary /pages/tree path is uncapped
* and always returns the complete set, so it never reports truncation.
*/
protected async enumerateSpacePages(
spaceId: string,
rootPageId?: string,
): Promise<{ pages: any[]; truncated: boolean }> {
await this.ensureAuthenticated();
// Single request replaces the whole BFS: /pages/tree returns the full
// permission-filtered flat page set of a space (or a subtree) at once. This
// path is uncapped, so it is never truncated.
const payload = rootPageId ? { pageId: rootPageId } : { spaceId };
try {
const response = await this.client.post("/pages/tree", payload);
const pages = (response.data?.data ?? response.data)?.items ?? [];
return { pages, truncated: false };
} catch (e: any) {
// Only fall back when the endpoint is absent (stock upstream Docmost);
// any other error is a genuine failure and must propagate.
if (
!axios.isAxiosError(e) ||
(e.response?.status !== 404 && e.response?.status !== 405)
) {
throw e;
}
}
// Fallback: cursor-based breadth-first walk via listSidebarPages.
const MAX_NODES = 10000;
const result: any[] = [];
const visited = new Set<string>();
// Seed with the root node itself when scoping to a subtree, so its own
// comments aren't dropped: the primary /pages/tree seeds
// getPageAndDescendants with id = rootPageId (root included), but
// listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN.
// The `visited` set below prevents a double-add if the root also appears
// among the children. getPageRaw returns a page whose id/title/spaceId are
// exactly what buildPageTree and checkNewComments consume.
if (rootPageId) {
try {
const root = await this.getPageRaw(rootPageId);
if (root?.id) {
result.push(root);
visited.add(root.id);
}
} catch {
// Non-fatal: if the root can't be read, fall through to children-only.
}
}
// Seed the queue with the starting level (subtree children or roots).
const queue: any[] = await this.listSidebarPages(spaceId, rootPageId);
while (queue.length > 0 && result.length < MAX_NODES) {
const node = queue.shift();
if (!node || typeof node !== "object" || !node.id) continue;
// Skip already-seen ids to guard against cycles / duplicate references.
if (visited.has(node.id)) continue;
visited.add(node.id);
result.push(node);
if (node.hasChildren) {
try {
const children = await this.listSidebarPages(spaceId, node.id);
for (const child of children) queue.push(child);
} catch (e: any) {
// A failure fetching one node's children must not abort the whole
// walk: skip this branch and keep enumerating the rest.
}
}
}
// Truncated only when the cap was hit with the queue still non-empty (real
// truncation, not a natural end at exactly MAX_NODES).
return {
pages: result,
truncated: result.length >= MAX_NODES && queue.length > 0,
};
}
/** Raw page info including the ProseMirror JSON content and slugId. */
async getPage(pageId: string) {
await this.ensureAuthenticated();
const resultData = await this.getPageRaw(pageId);
// Agent read: hide resolved-comment anchors so the agent sees only active
// discussions. Active anchors are kept. (The lossless exportPageMarkdown
// round-trip deliberately does NOT pass this flag — resolved anchors there
// must be preserved.)
let content = resultData.content
? convertProseMirrorToMarkdown(resultData.content, {
dropResolvedCommentAnchors: true,
})
: "";
// Always fetch subpages to provide context to the agent
let subpages: any[] = [];
try {
// `pageId` may be a slugId, but the sidebar-pages endpoint requires the
// UUID; `resultData.id` holds the resolved UUID returned by getPageRaw.
subpages = await this.listSidebarPages(resultData.spaceId, resultData.id);
} catch (e: any) {
console.warn("Failed to fetch subpages:", e);
}
// Resolve subpages if the placeholder exists
if (content && content.includes("{{SUBPAGES}}")) {
if (subpages && subpages.length > 0) {
const list = subpages
.map((p: any) => `- [${p.title}](page:${p.id})`)
.join("\n");
content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
} else {
content = content.replace("{{SUBPAGES}}", "");
}
}
return {
data: filterPage(resultData, content, subpages),
success: true,
};
}
/** Page info + raw ProseMirror JSON content (lossless representation). */
async getPageJson(pageId: string) {
const data = await this.getPageRaw(pageId);
return {
id: data.id,
slugId: data.slugId,
title: data.title,
parentPageId: data.parentPageId,
spaceId: data.spaceId,
updatedAt: data.updatedAt,
content: data.content || { type: "doc", content: [] },
};
}
/**
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
* `src` is normalized to `/api/files/<id>/<file>`; `this.client.baseURL`
* already ends in `/api`, so we strip the leading `/api` and request the
* relative path with the client's Authorization header. Returns the raw bytes
* and the response Content-Type (mime), defaulting to octet-stream.
*
* The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory;
* the authoritative per-blob cap is enforced by the sandbox `put`. The path is
* resolved via resolveInternalFilePath, which REJECTS (throws) any traversal
* or percent-encoded src that would let an attacker-controlled `attrs.src`
* escape `/api/files/` and reach another internal endpoint (SSRF). That throw
* happens before this.client.get, so a malicious src is counted as a failed
* mirror it never reaches the network.
*/
/**
* Compact outline of a page's top-level blocks (no full document body).
* Cheap way to locate sections/tables and grab block ids before drilling in
* with getNode / patchNode / insertNode.
*/
async getOutline(pageId: string) {
await this.ensureAuthenticated();
const data = await this.getPageRaw(pageId);
return {
pageId,
slugId: data.slugId,
title: data.title,
outline: buildOutline(data.content ?? { type: "doc", content: [] }),
};
}
/**
* Fetch a single block for editing by reference: a block id (headings/
* paragraphs/callouts/images), or `#<index>` to select a top-level block by its
* outline index (the only way to reach tables/rows/cells, which carry no id).
*
* `format` (#413):
* - `"markdown"` (DEFAULT): serialize the block via the canonical converter
* (`{type:"doc",content:[node]}` -> `convertProseMirrorToMarkdown`) a read
* "for editing": pair it with `patchNode({markdown})` to rewrite the block.
* Comment anchors (`<span data-comment-id>`, INCLUDING resolved ones) are
* NOT stripped here (unlike getPage): losing them on write-back would
* orphan the thread. Returns `{ ..., format:"markdown", markdown }`.
* - `"json"`: return the raw ProseMirror subtree as-is (lossless; the previous
* default). Returns `{ ..., format:"json", node }`.
*
* AUTO fallback: a type that cannot be a document top-level child
* (tableRow/tableCell/tableHeader, addressed by `#<index>`) is NOT expressible
* as a standalone markdown document, so a `"markdown"` request for such a node
* transparently falls back to JSON with an explicit `format:"json"` field. The
* check derives from the schema's `doc` contentMatch, so it tracks the schema.
*/
async getNode(
pageId: string,
nodeId: string,
format: "markdown" | "json" = "markdown",
) {
await this.ensureAuthenticated();
const data = await this.getPageRaw(pageId);
const hit = getNodeByRef(
data.content ?? { type: "doc", content: [] },
nodeId,
);
if (!hit) {
throw new Error(
`getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#<index>" for a top-level block such as a table)`,
);
}
// JSON requested (or a non-top-level type that markdown cannot represent as a
// standalone document): return the subtree verbatim.
if (format === "json" || !canBeDocChild(hit.type)) {
return {
pageId,
ref: nodeId,
path: hit.path,
type: hit.type,
format: "json" as const,
node: hit.node,
};
}
// Markdown: wrap the node as a one-block doc and run the canonical converter.
// Comment anchors are DELIBERATELY preserved (converter default) so a
// getNode(markdown) -> edit -> patchNode(markdown) round trip does not orphan
// a comment thread; this differs from getPage, which strips them.
const markdown = convertProseMirrorToMarkdown({
type: "doc",
content: [hit.node],
});
return {
pageId,
ref: nodeId,
path: hit.path,
type: hit.type,
format: "markdown" as const,
markdown,
};
}
/**
* Find every occurrence of `query` on a page IN MEMORY, over the plain text of
* each text container (reusing the same `getPageRaw` fetch as the other read
* tools) no server search endpoint, no whole-document round-trip through the
* model. Returns `{ total, truncated, matches }`; each match carries a ref for
* getNode/patchNode (the `#<index>` form resolves with getNode but NOT
* patchNode see SearchMatch.nodeId), plus the top-level block index and a
* short context window used to build a unique text `selection` for
* createComment (createComment has no nodeId param). The pure engine
* (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine
* and the empty-query / invalid-or-unsupported-regex errors.
*/
async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) {
await this.ensureAuthenticated();
const data = await this.getPageRaw(pageId);
const result = searchInDoc(
data.content ?? { type: "doc", content: [] },
query,
opts,
);
return { pageId, query, ...result };
}
/**
* Read a table as a matrix. `tableRef` is `#<index>` (from getOutline) or a
* block id of any node inside the table. Returns the cell texts plus a
* parallel cellIds matrix (each cell's first paragraph id, or null) so a
* caller can patchNode a cell for rich-formatted edits. Throws when no table
* resolves for the reference.
*/
async getTable(pageId: string, tableRef: string) {
await this.ensureAuthenticated();
const data = await this.getPageRaw(pageId);
const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef);
if (!t) {
throw new Error(
`tableGet: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
);
}
return {
pageId,
table: tableRef,
rows: t.rows,
cols: t.cols,
path: t.path,
cells: t.cells,
cellIds: t.cellIds,
};
}
/**
* Insert a row of plain-text cells into a table on the LIVE collab document.
* `tableRef` is `#<index>` or a block id inside the target table. `cells` is
* padded to the table's column count (more cells than columns throws); `index`
* is a 0-based insert position (omit/out-of-range to append). Throws when no
* table resolves for the reference.
*/
async search(
query: string,
spaceId?: string,
limit?: number,
opts: { parentPageId?: string; titleOnly?: boolean } = {},
) {
await this.ensureAuthenticated();
// Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid
// substring + FTS branch that returns path + snippet + score. A stock
// upstream server strips these unknown DTO fields (whitelist:true) and
// silently degrades to plain FTS — see the tool-registration comment.
const payload: Record<string, any> = {
query,
spaceId,
substring: true,
};
if (opts.parentPageId) payload.parentPageId = opts.parentPageId;
if (opts.titleOnly) payload.titleOnly = true;
// Clamp an optional caller-supplied limit into the lookup range (1..50)
// before forwarding; omit it when not provided so the server default applies.
if (limit !== undefined) {
payload.limit = Math.max(1, Math.min(50, limit));
}
const response = await this.client.post("/search", payload);
// Normalize both response shapes: bare array and paginated { items: [...] }
const data = response.data?.data;
const items = Array.isArray(data) ? data : data?.items || [];
const filteredItems = items.map((item: any) => filterSearchResult(item));
return {
items: filteredItems,
success: response.data?.success || false,
};
}
}
return ReadMixin;
}
+225
View File
@@ -0,0 +1,225 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import {
collectInternalFileNodes,
normalizeFileUrl,
resolveInternalFilePath,
} from "../lib/internal-file-urls.js";
// Public method surface of StashMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements IStashMixin` fails to compile on drift.
export interface IStashMixin {
stashPage(pageId: string): Promise<{ uri: string; sha256: string; size: number; images: { mirrored: number; failed: number }; }>;
}
export function StashMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IStashMixin> & TBase {
abstract class StashMixin extends Base implements IStashMixin {
/**
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
* `src` is normalized to `/api/files/<id>/<file>`; `this.client.baseURL`
* already ends in `/api`, so we strip the leading `/api` and request the
* relative path with the client's Authorization header. Returns the raw bytes
* and the response Content-Type (mime), defaulting to octet-stream.
*
* The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory;
* the authoritative per-blob cap is enforced by the sandbox `put`. The path is
* resolved via resolveInternalFilePath, which REJECTS (throws) any traversal
* or percent-encoded src that would let an attacker-controlled `attrs.src`
* escape `/api/files/` and reach another internal endpoint (SSRF). That throw
* happens before this.client.get, so a malicious src is counted as a failed
* mirror it never reaches the network.
*/
protected async fetchInternalFile(
src: string,
): Promise<{ buffer: Buffer; mime: string }> {
const HARD_CEILING = 64 * 1024 * 1024; // 64 MiB memory guard
const relPath = resolveInternalFilePath(src);
const response = await this.client.get(relPath, {
responseType: "arraybuffer",
timeout: 30000,
maxContentLength: HARD_CEILING,
maxBodyLength: HARD_CEILING,
});
const buffer = Buffer.from(response.data);
if (buffer.length === 0) {
throw new Error(`Empty file response from "${src}"`);
}
const rawCt = response.headers?.["content-type"];
const mime =
typeof rawCt === "string" && rawCt.length > 0
? rawCt.split(";")[0].trim().toLowerCase()
: "application/octet-stream";
return { buffer, mime };
}
/**
* Stash a page's full content into the in-RAM blob sandbox and return ONLY a
* short anonymous URL the body never enters the model context (this is the
* whole point: ~30KB+ ProseMirror docs blow the model context if passed as a
* tool argument). Every INTERNAL file/image src (the type-agnostic criterion,
* so drawio/excalidraw/video/file nodes are covered too) is mirrored into the
* sandbox and its `src` rewritten to the sandbox URL, so an external consumer
* can fetch the images anonymously. External http(s) srcs are left untouched.
*
* Blobs live in RAM with a short TTL and are cleared on restart consume the
* URLs within the TTL and one uptime. A failed image fetch never aborts the
* doc: the original src is kept and the failure counted.
*
* Returns { uri, sha256, size, images:{mirrored, failed} }. `uri` and `sha256`
* are for the document blob; `sha256` is also the blob's ETag (integrity).
*/
async stashPage(pageId: string): Promise<{
uri: string;
sha256: string;
size: number;
images: { mirrored: number; failed: number };
}> {
if (!this.sandboxPut) {
throw new Error(
"stashPage is unavailable: the blob sandbox is not configured on this server",
);
}
await this.ensureAuthenticated();
// Stash the SAME shape getPageJson returns (id/title/.../content), with a
// deep clone so the rewrite never mutates anything shared.
const pageJson = await this.getPageJson(pageId);
const cloned: any = structuredClone(pageJson);
// Group internal-file nodes by normalized src so each unique resource is
// fetched + stored ONCE (dedup), and every node sharing that src points at
// the one sandbox blob. Capture each node's ORIGINAL raw src per-node:
// dedup groups nodes whose normalized src is equal even when their raw srcs
// differ (e.g. `/api/files/...` vs the bare `/files/...`), so on a revert we
// must restore each node's own original value, not the group key.
const bySrc = new Map<string, Array<{ node: any; origSrc: string }>>();
for (const node of collectInternalFileNodes(cloned.content)) {
const origSrc = String(node.attrs.src);
const src = normalizeFileUrl(origSrc);
const entry = { node, origSrc };
const group = bySrc.get(src);
if (group) group.push(entry);
else bySrc.set(src, [entry]);
}
let mirrored = 0;
let failed = 0;
// Record every successful mirror so it can be (a) reverted if its blob gets
// FIFO-evicted by a LATER put in this same stash, and (b) freed if the final
// doc put throws.
const mirrors: Array<{
uri: string;
entries: Array<{ node: any; origSrc: string }>;
}> = [];
const MAX_CONCURRENCY = 5;
const groups = [...bySrc.entries()];
for (let i = 0; i < groups.length; i += MAX_CONCURRENCY) {
const batch = groups.slice(i, i + MAX_CONCURRENCY);
await Promise.all(
batch.map(async ([src, entries]) => {
try {
const { buffer, mime } = await this.fetchInternalFile(src);
// put may throw if the blob exceeds the per-blob/total caps.
const stored = this.sandboxPut!(buffer, mime);
for (const entry of entries) entry.node.attrs.src = stored.uri;
mirrors.push({ uri: stored.uri, entries });
mirrored++;
} catch (err) {
// One bad/oversized image (or a rejected traversal src) must not
// abort the document. Logged unconditionally (never the blob body),
// matching the package's ungated console.warn convention.
failed++;
console.warn(
`stashPage: failed to mirror "${src}": ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}),
);
}
// Revert one mirror's nodes to their original internal srcs and re-count it
// as failed (its blob was FIFO-evicted before the doc could reference it
// safely).
const revertMirror = (mirror: {
uri: string;
entries: Array<{ node: any; origSrc: string }>;
}) => {
for (const entry of mirror.entries) entry.node.attrs.src = entry.origSrc;
mirrored--;
failed++;
console.warn(
`stashPage: mirrored blob ${mirror.uri} was evicted before the doc ` +
`could safely reference it; reverted its src and counted it as failed`,
);
};
// Pre-put reconciliation: an image put earlier in THIS stash can FIFO-evict
// an even-earlier image of the same stash. Drop those from the live set
// first so the first serialized doc is already mostly correct.
let liveMirrors = mirrors;
if (this.sandboxHas) {
liveMirrors = [];
for (const mirror of mirrors) {
if (this.sandboxHas(mirror.uri)) liveMirrors.push(mirror);
else revertMirror(mirror);
}
}
// Put the document, then reconcile against eviction caused by the doc put
// ITSELF (the doc is newest, FIFO drops oldest = this stash's images). Each
// iteration reverts >=1 mirror, so the loop terminates (worst case: all
// images reverted and the doc references no sandbox image URLs).
let stored: { uri: string; sha256: string; size: number };
for (;;) {
const docBuf = Buffer.from(JSON.stringify(cloned), "utf8");
let docStored: { uri: string; sha256: string; size: number };
try {
docStored = this.sandboxPut(docBuf, "application/json");
} catch (err) {
// The doc put failed (e.g. doc exceeds the cap). Free this op's image
// blobs instead of leaking them in RAM for the whole TTL, then
// re-throw.
if (this.sandboxEvict) {
for (const mirror of liveMirrors) this.sandboxEvict(mirror.uri);
}
throw err;
}
if (!this.sandboxHas) {
stored = docStored;
break;
}
const evictedNow = liveMirrors.filter((m) => !this.sandboxHas!(m.uri));
if (evictedNow.length === 0) {
stored = docStored;
break;
}
// The doc we just stored references now-dead blobs. Revert those nodes,
// drop the stale doc blob, and loop to re-serialize + re-put the
// corrected doc.
for (const mirror of evictedNow) revertMirror(mirror);
liveMirrors = liveMirrors.filter((m) => this.sandboxHas!(m.uri));
if (this.sandboxEvict) this.sandboxEvict(docStored.uri);
}
return {
uri: stored.uri,
sha256: stored.sha256,
size: stored.size,
images: { mirrored, failed },
};
}
/**
* Compact outline of a page's top-level blocks (no full document body).
* Cheap way to locate sections/tables and grab block ids before drilling in
* with getNode / patchNode / insertNode.
*/
}
return StashMixin;
}
+291
View File
@@ -0,0 +1,291 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import {
replaceNodeById,
replaceNodeByIdWithMany,
reassignCollidingBlockIds,
deleteNodeById,
assertUnambiguousMatch,
insertNodeRelative,
insertNodesRelative,
blockPlainText,
buildOutline,
getNodeByRef,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import { withPageLock, isUuid } from "../lib/page-lock.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
// Public method surface of TablesMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements ITablesMixin` fails to compile on drift.
export interface ITablesMixin {
insertFootnote(pageId: string, anchorText: string, text: string): any;
tableInsertRow(pageId: string, tableRef: string, cells: string[], index?: number): any;
tableDeleteRow(pageId: string, tableRef: string, index: number): any;
tableUpdateCell(pageId: string, tableRef: string, row: number, col: number, text: string): any;
}
export function TablesMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITablesMixin> & TBase {
abstract class TablesMixin extends Base implements ITablesMixin {
/**
* AUTHOR-INLINE footnote insertion. The agent supplies only WHERE
* (`anchorText`, a snippet of body text to attach the marker after) and WHAT
* (`text`, the footnote content as markdown). Numbering and the bottom
* `footnotesList` are derived deterministically server-side
* (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees,
* assigns, or edits a footnote number or the list, so it CANNOT desync.
*
* Content DEDUP: when an existing definition has the same content, its id is
* reused (one number, one definition, several references). The write is atomic
* via `mutatePageContent` (single-writer, page-locked); if the anchor text is
* not found the transform aborts with a clear error and no write happens.
*/
async insertFootnote(pageId: string, anchorText: string, text: string) {
await this.ensureAuthenticated();
if (!anchorText || !anchorText.trim()) {
throw new Error("insertFootnote: anchorText is required");
}
if (text == null || `${text}`.trim() === "") {
throw new Error("insertFootnote: text is required");
}
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
let result: { footnoteId: string; reused: boolean } | null = null;
const mutation = await this.mutatePage(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc: any) => {
const r = insertInlineFootnote(liveDoc, { anchorText, text });
if (!r.inserted) {
// Abort the page-locked write by throwing: mutatePageContent does not
// persist when the transform throws, so a missing anchor leaves the
// page untouched (no partial write).
throw new Error(
`insertFootnote: anchor text not found: ${JSON.stringify(
anchorText.slice(0, 80),
)}`,
);
}
result = { footnoteId: r.footnoteId, reused: r.reused };
return r.doc;
},
);
// The not-found path throws inside the transform (aborting mutatePage), so by
// here `result` is always set.
const r = result!;
return {
success: true,
modified: true,
pageId,
footnoteId: r.footnoteId,
reused: r.reused,
message: r.reused
? "Footnote inserted (reused an existing same-content definition)."
: "Footnote inserted.",
verify: mutation.verify,
};
}
/**
* Page-locked write seam over collaboration.mutatePageContent. Production just
* delegates; it exists as an overridable method so the insertFootnote wrapper
* (transform abort-on-not-found + response shaping) can be unit-tested without
* standing up a live Hocuspocus collab socket.
*
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
* lock"): every write must lock and key its CollabSession by the UUID, never a
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
* already resolved pays no extra round-trip; centralizing it here means a
* caller that reaches this seam with a raw slugId still locks correctly instead
* of silently splitting the mutex key. withPageLock also asserts the key is a
* UUID as a hard backstop.
*/
/**
* Insert a row of plain-text cells into a table on the LIVE collab document.
* `tableRef` is `#<index>` or a block id inside the target table. `cells` is
* padded to the table's column count (more cells than columns throws); `index`
* is a 0-based insert position (omit/out-of-range to append). Throws when no
* table resolves for the reference.
*/
async tableInsertRow(
pageId: string,
tableRef: string,
cells: string[],
index?: number,
) {
await this.ensureAuthenticated();
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
// Track insertion in an outer var, reset per-transform, so a collab retry
// recomputes it cleanly (mirrors insertNode's pattern).
let inserted = false;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
inserted = false;
const { doc: nd, inserted: ins } = insertTableRow(
liveDoc,
tableRef,
cells,
index,
);
inserted = ins;
if (!inserted) return null; // table not found -> skip the write entirely
return nd;
},
);
if (!inserted) {
throw new Error(
`tableInsertRow: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
);
}
return {
success: true,
table: tableRef,
inserted: true,
verify: mutation.verify,
};
}
/**
* Delete the row at 0-based `index` from a table on the LIVE collab document.
* `tableRef` is `#<index>` or a block id inside the target table. The helper's
* out-of-range and last-row errors propagate; a missing table throws here.
*/
async tableDeleteRow(pageId: string, tableRef: string, index: number) {
await this.ensureAuthenticated();
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
let deleted = false;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
deleted = false;
const { doc: nd, deleted: del } = deleteTableRow(
liveDoc,
tableRef,
index,
);
deleted = del;
if (!deleted) return null; // table not found -> skip the write entirely
return nd;
},
);
if (!deleted) {
throw new Error(
`tableDeleteRow: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
);
}
return {
success: true,
table: tableRef,
deleted: true,
verify: mutation.verify,
};
}
/**
* Set the plain-text content of cell `[row, col]` (0-based) in a table on the
* LIVE collab document, replacing the cell's content with a single text
* paragraph (the cell's first-paragraph id is preserved). `tableRef` is
* `#<index>` or a block id inside the target table. The helper's out-of-range
* error propagates; a missing table throws here.
*/
async tableUpdateCell(
pageId: string,
tableRef: string,
row: number,
col: number,
text: string,
) {
await this.ensureAuthenticated();
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
let updated = false;
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
updated = false;
const { doc: nd, updated: upd } = updateTableCell(
liveDoc,
tableRef,
row,
col,
text,
);
updated = upd;
if (!updated) return null; // table not found -> skip the write entirely
return nd;
},
);
if (!updated) {
throw new Error(
`tableUpdateCell: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
);
}
return {
success: true,
table: tableRef,
row,
col,
verify: mutation.verify,
};
}
/**
* Create a new page with title and content.
* Uses the /pages/import workaround (the only endpoint accepting content),
* then moves the page and restores the exact title: the import endpoint
* derives the title from the FILENAME and replaces spaces with
* underscores, so we explicitly re-set it via /pages/update afterwards.
*/
}
return TablesMixin;
}
+232
View File
@@ -0,0 +1,232 @@
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
// changed to a mixin factory. See client/context.ts for the shared base.
import type { GConstructor, DocmostClientContext } from "./context.js";
import {
updatePageContentRealtime,
replacePageContent,
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
import { diffDocs, summarizeChange } from "../lib/diff.js";
import {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
mergeFootnoteDefinitions,
} from "../lib/transforms.js";
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
import vm from "node:vm";
// Public method surface of TransformsMixin (issue #450) — a NAMED type so the factory
// return type is expressible in the emitted .d.ts (the anonymous mixin class
// carries the base's protected shared state, which would otherwise trip TS4094).
// Derived from the class below; `implements ITransformsMixin` fails to compile on drift.
export interface ITransformsMixin {
transformPage(pageId: string, transformJs: string, opts?: { dryRun?: boolean; deleteComments?: boolean }): any;
}
export function TransformsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITransformsMixin> & TBase {
abstract class TransformsMixin extends Base implements ITransformsMixin {
/**
* Edit a page by running an arbitrary user-supplied JS transform against the
* live document, with a diff preview + page-history safety net.
*
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
* a 5s timeout, and NO access to require/process/fs/network. It must return a
* `{ type: "doc" }` node, which is validated structurally before any write.
*
* `ctx` exposes:
* - comments: the page's comments (fetched before the live read);
* - log: an array the transform can push diagnostics to (via console.log);
* - consume(id): mark a comment id as consumed (for deleteComments);
* - helpers: the transforms.ts primitives + commentsToFootnotes.
*
* Footnote convention used by the helpers: footnote markers are plain "[N]"
* text in the body, and the notes are an orderedList under a heading whose
* text is "Примечания переводчика".
*
* dryRun (default true): read the page's current content, run the transform,
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
* Otherwise the transform runs atomically inside mutatePageContent, optionally
* deletes consumed comments, and returns the new historyId + diff + log.
*/
async transformPage(
pageId: string,
transformJs: string,
opts: { dryRun?: boolean; deleteComments?: boolean } = {},
) {
const dryRun = opts.dryRun ?? true;
const deleteComments = opts.deleteComments ?? false;
await this.ensureAuthenticated();
// Full feed (incl. resolved): a page transform (e.g. comments -> footnotes)
// must operate on every comment, so it opts into the unfiltered feed.
const comments = (await this.listComments(pageId, true)).items;
// ctx handed to the sandbox. consume() records ids; helpers are the pure
// transform primitives. log is captured from console.log inside the sandbox.
const ctx = {
comments,
log: [] as string[],
consumed: new Set<string>(),
consume(id: string) {
this.consumed.add(id);
},
helpers: {
blockText,
walk,
getList,
insertMarkerAfter,
setCalloutRange,
noteItem,
mdToInlineNodes,
commentsToFootnotes,
canonicalizeFootnotes,
insertInlineFootnote,
},
};
// Captured oldDoc / newDoc for the diff (set inside runTransform).
let oldDoc: any;
let newDoc: any;
// SYNCHRONOUS transform runner — safe to call inside mutatePageContent's
// onSynced (no await between the live read and the write).
const runTransform = (liveDoc: any): any => {
oldDoc = structuredClone(liveDoc);
const sandbox: Record<string, any> = {
doc: structuredClone(liveDoc),
ctx,
structuredClone,
console: {
log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")),
},
};
// Wrap the provided string in parentheses so both an expression-arrow
// (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a
// fresh context with no require/process/module so the transform cannot
// touch fs/network/process. 5s wall-clock timeout.
let fn: any;
try {
fn = vm.runInNewContext("(" + transformJs + ")", sandbox, {
timeout: 5000,
});
} catch (e: any) {
throw new Error(`transform did not compile: ${e?.message ?? e}`);
}
if (typeof fn !== "function") {
throw new Error(
"transform must evaluate to a function (doc, ctx) => doc",
);
}
const raw = vm.runInNewContext(
"f(d, c)",
{ f: fn, d: sandbox.doc, c: ctx },
{ timeout: 5000 },
);
if (
!raw ||
typeof raw !== "object" ||
raw.type !== "doc" ||
!Array.isArray(raw.content)
) {
throw new Error(
'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })',
);
}
// Validate the RAW transform output FIRST (structure — including the
// MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer
// recurses without a depth limiter, so validating after it would turn a
// too-deep doc into an opaque "Maximum call stack size exceeded" instead of
// the intended "nesting exceeds the maximum depth" error.
this.validateDocStructure(raw);
this.validateDocUrls(raw);
// Auto-canonicalize footnotes after the transform (idempotent): no write
// path can leave footnotes out of order / orphaned / in a raw `[^id]`
// block. In a dryRun preview this may surface footnote edits the script
// author did not write (the canonicalizer tidied them) — that is expected.
// #419: normalize + merge glyph-forked definitions before canonicalizing.
const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw));
newDoc = result;
return result;
};
if (dryRun) {
// Preview only: run against the current REST snapshot, never open the
// socket. oldDoc/newDoc are captured by runTransform.
const raw = await this.getPageRaw(pageId);
const current = raw.content || { type: "doc", content: [] };
runTransform(current);
// Run an independent Yjs-encodability check (same sanitize + schema as the
// apply path), so the preview fails with the same descriptive error when
// the doc is not encodable instead of returning a misleadingly-green diff.
assertYjsEncodable(newDoc);
return {
pushed: false,
diff: diffDocs(oldDoc, newDoc),
log: ctx.log,
};
}
// Apply atomically against the live doc.
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260).
const pageUuid = await this.resolvePageId(pageId);
const mutation = await mutatePageContent(
pageUuid,
collabToken,
this.apiUrl,
runTransform,
);
// Optionally delete consumed comments (best-effort; a delete failure must
// not undo the successful write).
const deletedComments: string[] = [];
if (deleteComments) {
for (const id of ctx.consumed) {
try {
await this.deleteComment(id);
deletedComments.push(id);
} catch (e) {
if (process.env.DEBUG) {
console.error(`transform: failed to delete comment ${id}:`, e);
}
}
}
}
// Fetch the newest historyId (Docmost snapshots on the write above).
let historyId: string | null = null;
try {
const hist = await this.listPageHistory(pageId);
historyId = hist.items?.[0]?.id ?? null;
} catch (e) {
if (process.env.DEBUG) {
console.error("transform: failed to fetch history id:", e);
}
}
return {
pushed: true,
historyId,
diff: diffDocs(oldDoc, newDoc),
deletedComments,
log: ctx.log,
verify: mutation.verify,
};
}
}
return TransformsMixin;
}
+4 -7
View File
@@ -57,17 +57,14 @@ export const DEFAULT_COMMENT_SIGNAL_DEBOUNCE_MS = 20_000;
/**
* Tools whose OWN result must NOT carry the signal it would be tautological
* (the agent is already looking at comments) and noisy. Listed in BOTH the
* standalone MCP snake_case names AND the in-app camelCase keys so a single set
* covers both surfaces (the signal text itself uses the camelCase `listComments`
* per roadmap #412). `getComment` (single fetch) is intentionally NOT excluded.
* (the agent is already looking at comments) and noisy. Since issue #412 both
* the standalone MCP surface and the in-app agent use the same camelCase tool
* names, so a single set of camelCase names covers both surfaces. `getComment`
* (single fetch) is intentionally NOT excluded.
*/
export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet<string> = new Set([
"list_comments",
"listComments",
"check_new_comments",
"checkNewComments",
"create_comment",
"createComment",
]);
+172 -564
View File
@@ -4,8 +4,10 @@ import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient, DocmostMcpConfig } from "./client.js";
import { parseNodeArg } from "@docmost/prosemirror-markdown";
import { searchShapes } from "./lib/drawio-shapes.js";
import { getGuideSection } from "./lib/drawio-guide.js";
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
import { SERVER_INSTRUCTIONS } from "./server-instructions.js";
import {
createCommentSignalTracker,
CommentSignalTracker,
@@ -29,6 +31,14 @@ export { destroyAllSessions } from "./lib/collab-session.js";
export { SHARED_TOOL_SPECS } from "./tool-specs.js";
export type { SharedToolSpec } from "./tool-specs.js";
// Re-export the build-time REGISTRY_STAMP (issue #447): a deterministic hash of
// the tool-specs registry content, generated into src/registry-stamp.generated.ts
// by scripts/gen-registry-stamp.mjs BEFORE tsc, so it lands in build/. The in-app
// loader recomputes the same hash from src/tool-specs.ts (dev/test only) and
// refuses to run on a mismatch, catching a build/ vs src/ skew (a spec edited in
// src without rebuilding the package the server actually loads from build/).
export { REGISTRY_STAMP } from "./registry-stamp.generated.js";
// Re-export the shared "new comments: N" signal helper (#417) so the in-app
// layer reads the SAME watermark/debounce/injection-safe line builder off the
// loaded module (same pattern as SHARED_TOOL_SPECS). Both surfaces then differ
@@ -46,6 +56,13 @@ export type {
CommentSignalProbeResult,
CommentSignalTrackerOptions,
} from "./comment-signal.js";
// Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK
// service can wire drawioShapes / drawioGuide off the loaded module. These are
// NOT client methods (no page/backend hit) — the in-app handler calls them
// directly, mirroring how the standalone MCP server wires them here.
export { searchShapes } from "./lib/drawio-shapes.js";
export type { SearchShapesOptions } from "./lib/drawio-shapes.js";
export { getGuideSection } from "./lib/drawio-guide.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
@@ -66,19 +83,17 @@ const VERSION = packageJson.version;
// Editing guide surfaced to MCP clients in the initialize result so they can
// pick the right tool by intent and avoid resending whole documents.
//
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
// this guide so the new tool is routed by intent. This is enforced by
// test/unit/server-instructions.test.mjs, which fails when a registered tool
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
// Exported for that test.
export const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
// The guide is now SPLIT (issue #448): the hand-written routing prose lives in
// server-instructions.ts and the tool INVENTORY is GENERATED from the registry
// (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of
// sync with the registered tools. Re-exported here (its old home) so existing
// importers are unaffected; the composition lives in server-instructions.ts.
// The drawioShapes / drawioGuide tools (#424) stay in SHARED_TOOL_SPECS (so the
// generated <tool_inventory> picks them up from their catalogLine automatically)
// but are flagged `inlineBothHosts` and registered inline below (their pure
// helpers can't cross into tool-specs.ts); only the hand-written routing prose in
// server-instructions.ts is updated to mention them.
export { SERVER_INSTRUCTIONS };
// Helper to format JSON responses
const jsonContent = (data: any) => ({
@@ -194,10 +209,10 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
{ instructions: SERVER_INSTRUCTIONS },
);
// Single choke point for MCP tool timing. Both `registerShared` (below) and
// the inline `server.registerTool(...)` calls funnel through this one method,
// so monkeypatching it HERE — before any tool is registered and before
// `registerShared` captures a reference to it — times every tool with no
// Single choke point for MCP tool timing. Both `registerSharedFromSpec` (below)
// and the inline `server.registerTool(...)` calls funnel through this one
// method, so monkeypatching it HERE — before any tool is registered and before
// the registry loop captures a reference to it — times every tool with no
// per-tool boilerplate. The wrapped handler records wall-clock duration and,
// in a `finally`, feeds the host's dependency-neutral sink
// `config.onMetric("mcp_tool_duration_seconds", seconds, { tool })`. The tool
@@ -253,104 +268,112 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
return originalRegisterTool(...args.slice(0, -1), signalledHandler);
};
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
// the canonical name + model-facing description + (optional) schema builder;
// only the execute body is supplied per call. buildShape is invoked with THIS
// package's zod (v3); the in-app layer passes its own zod (v4).
//
// The spec's schema builder returns a plain ZodRawShape (Record<string,
// unknown> in the shared module since it must stay zod-agnostic), so the
// McpServer.registerTool overloads cannot infer the execute arg's shape from
// it. We type `execute` loosely and cast the call through `any`; runtime
// behaviour is unchanged — each execute body destructures the same fields the
// builder declares.
const registerShared = (
spec: SharedToolSpec,
execute: (args: any) => Promise<{ content: { type: "text"; text: string }[] }>,
) =>
(server.registerTool as any)(
// Register EVERY shared tool from the zod-agnostic registry in one loop (#445).
// The spec owns the canonical name + description + (optional) schema builder AND
// the canonical execute mapping; the host only supplies the RESULT ENVELOPE. For
// each spec:
// - skip `inAppOnly` specs (they belong to the in-app host only);
// - if the spec has an `mcpExecute` override (a deliberate per-layer
// difference — a guardrail, an omitted param, or a non-JSON envelope like a
// resource_link/bare success line), the override OWNS the full MCP content
// result and is used VERBATIM;
// - otherwise the canonical `execute` returns RAW data and this host wraps it
// in the standard JSON text envelope (jsonContent), exactly as the old inline
// bodies did.
// buildShape is invoked with THIS package's zod (v3); the in-app layer passes its
// own zod (v4). The registry's execute returns `unknown` (it is zod-agnostic), so
// the wrapping is typed loosely and cast — runtime behaviour is unchanged.
const registerSharedFromSpec = (spec: SharedToolSpec) => {
if (spec.inAppOnly) return;
// `inlineBothHosts` specs (drawioShapes / drawioGuide) carry no execute —
// their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they
// are registered INLINE below (searchShapes / getGuideSection). Skip them here
// so the loop never dereferences a missing `execute`.
if (spec.inlineBothHosts) return;
const handler = async (args: any) => {
if (spec.mcpExecute) {
// The override owns the full MCP result envelope (not re-wrapped).
return (await spec.mcpExecute(docmostClient, args)) as {
content: { type: "text"; text: string }[];
};
}
// Canonical execute returns raw data; wrap it as JSON text content.
const raw = await spec.execute!(docmostClient, args);
return jsonContent(raw);
};
return (server.registerTool as any)(
spec.mcpName,
spec.buildShape
? { description: spec.description, inputSchema: spec.buildShape(z) }
: { description: spec.description },
execute,
handler,
);
};
// Tool: get_workspace
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
const workspace = await docmostClient.getWorkspace();
return jsonContent(workspace);
});
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
registerSharedFromSpec(spec as SharedToolSpec);
}
// Tool: list_spaces
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
const spaces = await docmostClient.getSpaces();
return jsonContent(spaces);
});
// --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) ---
// drawioShapes / drawioGuide (#424) live in SHARED_TOOL_SPECS (so the shared
// contract pins their name/description/schema across both hosts) but carry the
// `inlineBothHosts` flag and NO execute: their pure backing helpers
// (searchShapes / getGuideSection) cannot be value-imported into the
// zod-agnostic tool-specs.ts without breaking the in-app server's commonjs
// type-check (searchShapes' catalog loader uses import.meta). So both hosts wire
// them directly. Here on the MCP host they reuse the spec's name/description/
// schema and wrap the raw helper result as JSON text content — byte-identical to
// what the registry loop would have produced. The in-app host mirrors this in
// ai-chat-tools.service.ts.
{
// Cast registerTool like the loop's registerSharedFromSpec does: the spec's
// buildShape returns the loose zod-agnostic ZodRawShape (Record<string,
// unknown>) and the handler args are the SDK-validated, type-erased input.
const registerInline = server.registerTool as any;
const shapesSpec = SHARED_TOOL_SPECS.drawioShapes as SharedToolSpec;
registerInline(
shapesSpec.mcpName,
{
description: shapesSpec.description,
inputSchema: shapesSpec.buildShape!(z),
},
async ({ query, category, limit }: any) => {
const results = searchShapes(query, { category, limit });
return jsonContent({ query, count: results.length, results });
},
);
const guideSpec = SHARED_TOOL_SPECS.drawioGuide as SharedToolSpec;
registerInline(
guideSpec.mcpName,
{
description: guideSpec.description,
inputSchema: guideSpec.buildShape!(z),
},
async ({ section }: any) => jsonContent(getGuideSection(section)),
);
}
// Tool: list_pages
// INTENTIONAL per-transport divergence (not in the shared registry): this
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
});
// --- INLINE tools kept per-transport (NOT in the shared registry) ---
// Each stays inline for a documented reason: a snake_case/camelCase naming
// clash the registry convention forbids (tableGet), an intentional
// per-transport behaviour/schema divergence (search, docmostTransform), or a
// tool that exists ONLY on this standalone MCP surface (updateComment,
// deleteComment — the in-app agent deliberately exposes no hard comment
// edit/delete tool).
// Tool: get_page
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
});
// Tool: get_page_json
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
const page = await docmostClient.getPageJson(pageId);
return jsonContent(page);
});
// Tool: get_outline
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
const result = await docmostClient.getOutline(pageId);
return jsonContent(result);
});
// Tool: get_node
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.getNode(pageId, nodeId);
return jsonContent(result);
});
// Tool: search_in_page
registerShared(
SHARED_TOOL_SPECS.searchInPage,
async ({ pageId, query, regex, caseSensitive, limit }) => {
const result = await docmostClient.searchInPage(pageId, query, {
regex,
caseSensitive,
limit,
});
return jsonContent(result);
},
);
// Tool: table_get
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
// Tool: tableGet
// NOT in the shared registry: the MCP tool name `tableGet` is noun-first while
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
// Renaming the public MCP tool would break external clients, so it stays inline.
server.registerTool(
"table_get",
"tableGet",
{
description:
"Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
"get_outline, or any block id inside the table. Use cellIds with " +
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
"getOutline, or any block id inside the table. Use cellIds with " +
"patchNode for rich-formatted cell edits. `cols` is the FIRST row's " +
"width; ragged tables may vary per row, so use the per-row length of " +
"`cells` for each row.",
inputSchema: {
@@ -364,386 +387,9 @@ server.registerTool(
},
);
// Tool: table_insert_row
// Schema + description now live in the shared registry (#294); the `table`
// parameter name is the canonical one (the in-app layer was unified to it).
registerShared(
SHARED_TOOL_SPECS.tableInsertRow,
async ({ pageId, table, cells, index }) => {
const result = await docmostClient.tableInsertRow(
pageId,
table,
cells,
index,
);
return jsonContent(result);
},
);
// Tool: table_delete_row
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableDeleteRow,
async ({ pageId, table, index }) => {
const result = await docmostClient.tableDeleteRow(pageId, table, index);
return jsonContent(result);
},
);
// Tool: table_update_cell
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableUpdateCell,
async ({ pageId, table, row, col, text }) => {
const result = await docmostClient.tableUpdateCell(
pageId,
table,
row,
col,
text,
);
return jsonContent(result);
},
);
// Tool: create_page
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.createPage,
async ({ title, content, spaceId, parentPageId }) => {
const result = await docmostClient.createPage(
title,
content,
spaceId,
parentPageId,
);
return jsonContent(result);
},
);
// Tool: update_page_json
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's content normalization (parse a JSON-string content,
// pass undefined/null through for a title-only/no-op update).
registerShared(
SHARED_TOOL_SPECS.updatePageJson,
async ({ pageId, content, title }) => {
// Only parse/validate the document when it was actually supplied; when it
// is omitted, pass it straight through so the client performs a title-only
// (or no-op) update.
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else {
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, "content was a string but not valid JSON");
}
const result = await docmostClient.updatePageJson(pageId, doc, title);
return jsonContent(result);
},
);
// Tool: export_page_markdown
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text" as const, text: md }] };
});
// Tool: import_page_markdown
registerShared(
SHARED_TOOL_SPECS.importPageMarkdown,
async ({ pageId, markdown }) => {
const res = await docmostClient.importPageMarkdown(pageId, markdown);
return jsonContent(res);
},
);
// Tool: copy_page_content
registerShared(
SHARED_TOOL_SPECS.copyPageContent,
async ({ sourcePageId, targetPageId }) => {
const result = await docmostClient.copyPageContent(
sourcePageId,
targetPageId,
);
return jsonContent(result);
},
);
// Tool: rename_page
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
});
// Tool: edit_page_text
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
const result = await docmostClient.editPageText(pageId, edits);
return jsonContent(result);
});
// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc
// body never enters the model context. Registered directly (not via
// registerShared) because that helper only emits text content. Also returns
// `structuredContent` carrying the full documented `{uri, sha256, size, images}`
// shape alongside the resource_link, so MCP clients receive the blob's sha256
// (its ETag, for integrity) and mirror counts, not just the link.
// Tool: updateComment
server.registerTool(
SHARED_TOOL_SPECS.stashPage.mcpName,
{
description: SHARED_TOOL_SPECS.stashPage.description,
inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape!(z),
},
async ({ pageId }: { pageId: string }) => {
const result = await docmostClient.stashPage(pageId);
return {
content: [
{
type: "resource_link" as const,
uri: result.uri,
name: "page.json",
mimeType: "application/json",
size: result.size,
},
],
// Mirror the full documented result shape ({ uri, size, sha256, images })
// as structuredContent so MCP clients get the blob's sha256 (its ETag, for
// integrity) and the mirror counts, not just the resource_link.
structuredContent: {
uri: result.uri,
sha256: result.sha256,
size: result.size,
images: result.images,
},
};
},
);
// Tool: patch_node — schema + description from the shared registry (identical
// across both transports). The execute body keeps its own parseNodeArg
// normalization (the model sometimes serializes `node` as a JSON string).
registerShared(
SHARED_TOOL_SPECS.patchNode,
async ({ pageId, nodeId, node }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
},
);
// Tool: insert_node — schema + description from the shared registry. As with
// patch_node, the execute body retains parseNodeArg on the incoming node.
registerShared(
SHARED_TOOL_SPECS.insertNode,
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
anchorText,
});
return jsonContent(result);
},
);
// Tool: delete_node
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
const result = await docmostClient.deleteNode(pageId, nodeId);
return jsonContent(result);
});
// Tool: insert_image
// Schema + description now live in the shared registry (#410) so BOTH this MCP
// server and the in-app AI-chat agent expose it. The execute body is unchanged.
registerShared(
SHARED_TOOL_SPECS.insertImage,
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
const result = await docmostClient.insertImage(pageId, imageUrl, {
align,
alt,
replaceText,
afterText,
});
return jsonContent(result);
},
);
// Tool: replace_image
// Schema + description now live in the shared registry (#410).
registerShared(
SHARED_TOOL_SPECS.replaceImage,
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
const result = await docmostClient.replaceImage(
pageId,
attachmentId,
imageUrl,
{
align,
alt,
},
);
return jsonContent(result);
},
);
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
registerShared(
SHARED_TOOL_SPECS.drawioGet,
async ({ pageId, node, format }) => {
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
return jsonContent(result);
},
);
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
registerShared(
SHARED_TOOL_SPECS.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
const result = await docmostClient.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
);
return jsonContent(result);
},
);
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
registerShared(
SHARED_TOOL_SPECS.drawioUpdate,
async ({ pageId, node, xml, baseHash }) => {
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
return jsonContent(result);
},
);
// Tool: share_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own `searchIndexing ?? true` default.
registerShared(
SHARED_TOOL_SPECS.sharePage,
async ({ pageId, searchIndexing }) => {
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
return jsonContent(result);
},
);
// Tool: unshare_page
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
const result = await docmostClient.unsharePage(pageId);
return jsonContent(result);
});
// Tool: list_shares
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
const result = await docmostClient.listShares();
return jsonContent(result);
});
// Tool: move_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
// its positive-confirmation check on the move response.
registerShared(
SHARED_TOOL_SPECS.movePage,
async ({ pageId, parentPageId, position }) => {
const finalParentId =
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
// Cheap cycle guard: a page cannot be moved directly under itself.
// (Deeper descendant-cycle detection is intentionally out of scope.)
if (finalParentId !== null && finalParentId === pageId) {
throw new Error("cannot move a page under itself");
}
const result = await docmostClient.movePage(
pageId,
finalParentId || null,
position,
);
// Require POSITIVE confirmation: the live /pages/move success shape is
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
// shape lacking success === true must NOT be reported as a successful move,
// so we surface the raw API result instead of declaring success.
if (!(result && typeof result === "object" && result.success === true)) {
throw new Error(
`Failed to move page ${pageId}: ${JSON.stringify(result)}`,
);
}
return jsonContent({
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
result,
});
},
);
// Tool: delete_page
// Schema + description now live in the shared registry (#294). The shared schema
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
],
};
});
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
// Tool: list_comments
registerShared(
SHARED_TOOL_SPECS.listComments,
async ({ pageId, includeResolved }) => {
const comments = await docmostClient.listComments(pageId, includeResolved);
return jsonContent(comments);
},
);
// Tool: create_comment
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guards (require a selection for a top-level
// comment; reject suggestedText on a reply / without a selection).
registerShared(
SHARED_TOOL_SPECS.createComment,
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
);
}
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await docmostClient.createComment(
pageId,
content,
"inline",
selection,
parentCommentId,
suggestedText,
);
return jsonContent(result);
},
);
// Tool: update_comment
server.registerTool(
"update_comment",
"updateComment",
{
description:
"Update an existing comment's content. Only the comment creator can " +
@@ -762,9 +408,9 @@ server.registerTool(
},
);
// Tool: delete_comment
// Tool: deleteComment
server.registerTool(
"delete_comment",
"deleteComment",
{
description:
"Delete a comment. Only the comment creator or space admin can delete it.",
@@ -785,77 +431,77 @@ server.registerTool(
},
);
// Tool: resolve_comment
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.resolveComment,
async ({ commentId, resolved }) => {
const result = await docmostClient.resolveComment(commentId, resolved);
return jsonContent(result);
},
);
// Tool: check_new_comments
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
registerShared(
SHARED_TOOL_SPECS.checkNewComments,
async ({ spaceId, since, parentPageId }) => {
// Reject an unparseable timestamp up front: otherwise the comparison
// against NaN silently treats every comment as "not new" and the tool
// returns zero results without signalling the bad input.
if (Number.isNaN(Date.parse(since))) {
throw new Error(
`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`,
);
}
const result = await docmostClient.checkNewComments(
spaceId,
since,
parentPageId,
);
return jsonContent(result);
},
);
// Tool: search
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
// different schema (limit 1-20); this transport is a plain REST full-text search
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
// different schema; this transport is the #443 agent-lookup search — a hybrid
// substring + full-text search that also returns each hit's location (`path`)
// and a windowed `snippet`, so one call answers "where is it and what's in it".
// The in-app hybrid-RRF search is deliberately NOT touched. Different behaviour
// AND schema, so kept per-layer.
//
// STANDALONE-vs-STOCK-UPSTREAM: the client sends the opt-in `substring`/
// `parentPageId`/`titleOnly` DTO fields. A stock upstream server validates the
// DTO with `whitelist: true` and silently strips these unknown fields, so the
// request degrades gracefully to plain FTS (no path/snippet, current shape).
//
// EE/TYPESENSE DEGRADATION (#443): on an instance whose SEARCH_DRIVER is
// `typesense`, the server routes this request to the Typesense backend, which
// does NOT implement agent-lookup — the substring/path/snippet/tiering is
// ignored and the response degrades to plain Typesense FTS. The rich lookup
// shape is only produced by the native Postgres search driver.
server.registerTool(
"search",
{
description:
"Full-text search for pages and content across the whole workspace. " +
"Results are bounded by `limit` (1-100; when omitted the server applies " +
"its own default).",
"Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
"like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
"hit's location (`path`: ancestor titles root→parent) and a `snippet` " +
"around the first match, so you rarely need a follow-up get_page. " +
"Matches substrings literally (dots/dashes/digits are not tokenized) as " +
"well as full-text. Returns `{ pageId, title, path, snippet, score }` " +
"sorted by `score` (a per-response relevance float).",
inputSchema: {
query: z.string().min(1).describe("Search query"),
spaceId: z
.string()
.optional()
.describe("Restrict the search to a single space"),
parentPageId: z
.string()
.optional()
.describe(
"Restrict to a page and all its descendants (the page itself included)",
),
titleOnly: z
.boolean()
.optional()
.describe("Match page titles only; skip page text"),
limit: z
.number()
.int()
.min(1)
.max(100)
.max(50)
.optional()
.describe("Max results to return (max 100)"),
.describe("Max results to return (1-50, default 10)"),
},
},
async ({ query, limit }) => {
// The tool exposes no spaceId filter, so pass undefined for the client's
// optional spaceId parameter and forward limit into its correct slot.
const result = await docmostClient.search(query, undefined, limit);
async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
const result = await docmostClient.search(query, spaceId, limit, {
parentPageId,
titleOnly,
});
return jsonContent(result);
},
);
// Tool: docmost_transform
// Tool: docmostTransform
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
// deliberately omits the `deleteComments` schema field (comment-deletion
// guardrail) and carries a much shorter description; this transport exposes the
// full helper catalogue. Different schema, so kept per-layer.
server.registerTool(
"docmost_transform",
"docmostTransform",
{
description:
"Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
@@ -927,43 +573,5 @@ server.registerTool(
},
);
// Tool: insert_footnote
// Schema + description now live in the shared registry (#410) so the in-app
// AI-chat agent exposes it too. The execute body is unchanged.
registerShared(
SHARED_TOOL_SPECS.insertFootnote,
async ({ pageId, anchorText, text }) => {
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
return jsonContent(result);
},
);
// Tool: diff_page_versions
registerShared(
SHARED_TOOL_SPECS.diffPageVersions,
async ({ pageId, from, to }) => {
const result = await docmostClient.diffPageVersions(pageId, from, to);
return jsonContent(result);
},
);
// Tool: list_page_history
registerShared(
SHARED_TOOL_SPECS.listPageHistory,
async ({ pageId, cursor }) => {
const result = await docmostClient.listPageHistory(pageId, cursor);
return jsonContent(result);
},
);
// Tool: restore_page_version
registerShared(
SHARED_TOOL_SPECS.restorePageVersion,
async ({ historyId }) => {
const result = await docmostClient.restorePageVersion(historyId);
return jsonContent(result);
},
);
return server;
}
+55 -8
View File
@@ -37,6 +37,25 @@ const CONNECT_TIMEOUT_MS = 25000;
/** Time we wait for the server to acknowledge our write before giving up. */
const PERSIST_TIMEOUT_MS = 20000;
/**
* Marker property set on the Error thrown when the Hocuspocus handshake REJECTS
* our collab token (onAuthenticationFailed). The client wraps content writes so
* that on this specific failure it invalidates its cached collab token and
* retries once with a fresh one symmetric to the HTTP-401 reauth path (#486).
* A plain message-match would be brittle; a tagged property is unambiguous and
* survives teardown (which rejects pending ops with this SAME error object).
*/
const COLLAB_AUTH_FAILED_MARKER = "collabAuthFailed";
/** True when `e` is the tagged collab-WS auth-failure error (see marker above). */
export function isCollabAuthFailedError(e: unknown): boolean {
return !!(
e &&
typeof e === "object" &&
(e as Record<string, unknown>)[COLLAB_AUTH_FAILED_MARKER] === true
);
}
/**
* Tunables, read fresh from the environment on every acquire so tests (and a
* live rollback) can change them without reloading the module. Mirrors how
@@ -202,6 +221,21 @@ export class CollabSession {
this.ydoc = new Y.Doc();
}
/**
* Shared diagnostic suffix (issue #437) appended to the connect-timeout,
* persist-timeout and connection-closed error texts: names the offending
* pageId and tells the agent this class of failure is transient (retry once)
* vs. a persistent collab-server outage, so it can self-correct instead of
* blind-looping. The Yjs-encode error is deliberately NOT touched it
* already names the offending attribute.
*/
private hint(): string {
return (
`(pageId ${this.pageId}; transient — retry once; persistent failures ` +
`mean the collab server is unreachable/overloaded)`
);
}
/**
* A cached session may be reused only when it is fully ready, still synced,
* has not lost its connection, and has not exceeded its max age (invariant 5
@@ -232,7 +266,9 @@ export class CollabSession {
// The 25s connect timeout: the collab connection never became ready.
this.opts?.onConnectTimeout?.();
this.teardown(
new Error("Connection timeout to collaboration server"),
new Error(
`Connection timeout to collaboration server ${this.hint()}`,
),
false,
);
}, CONNECT_TIMEOUT_MS);
@@ -259,7 +295,7 @@ export class CollabSession {
if (process.env.DEBUG) console.error("WS Disconnect");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
`Collaboration connection closed before the update was persisted/synced ${this.hint()}`,
),
true,
);
@@ -268,7 +304,7 @@ export class CollabSession {
if (process.env.DEBUG) console.error("WS Close");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
`Collaboration connection closed before the update was persisted/synced ${this.hint()}`,
),
true,
);
@@ -285,10 +321,13 @@ export class CollabSession {
this.openResolve?.();
},
onAuthenticationFailed: () => {
this.teardown(
new Error("Authentication failed for collaboration connection"),
true,
);
// Tag the error so the client can tell a REJECTED collab token apart
// from a generic disconnect and invalidate + refresh it (#486).
const err = new Error(
"Authentication failed for collaboration connection",
) as Error & { [COLLAB_AUTH_FAILED_MARKER]?: boolean };
err[COLLAB_AUTH_FAILED_MARKER] = true;
this.teardown(err, true);
},
});
});
@@ -403,7 +442,7 @@ export class CollabSession {
persistTimer = setTimeout(() => {
localFinish(
new Error(
"Timeout waiting for collaboration server to persist the update",
`Timeout waiting for collaboration server to persist the update ${this.hint()}`,
),
);
}, PERSIST_TIMEOUT_MS);
@@ -423,9 +462,16 @@ export class CollabSession {
// must stay synchronous (no await). While the JS event loop is not
// yielded, no incoming remote update can interleave, so any already-synced
// concurrent edits are preserved in liveDoc.
//
// INVARIANT 1 is machine-checked: the BEGIN/END markers below delimit the
// no-await window, and test/unit/no-await-critical-window.test.mjs scans
// this source and FAILS if any `await` (or `for await`/`yield`) appears
// between them. Do NOT add an await inside this block — an accidental
// async boundary here silently reopens the clobber-live-edits race (#152).
let newDoc: any;
let beforeDoc: any;
try {
// === MUTATE-CRITICAL-WINDOW: BEGIN (no await between here and END #449) ===
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
if (
!liveDoc ||
@@ -463,6 +509,7 @@ export class CollabSession {
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
// end of the document on every agent write.
applyDocToFragment(this.ydoc, newDoc);
// === MUTATE-CRITICAL-WINDOW: END (#449) ===
} catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller.
+39 -5
View File
@@ -13,7 +13,11 @@ import { JSDOM } from "jsdom";
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
import {
sanitizeForYjs,
findUnstorableAttr,
findInvalidNode,
} from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
import { VerifyReport } from "./diff.js";
@@ -28,11 +32,25 @@ export { markdownToProseMirror };
* place. `label` names the stage that failed (diagnostic). `sanitizeForYjs`
* already stripped `undefined` attrs, so a remaining failure is pinpointed via
* `findUnstorableAttr`.
*
* Diagnostics precedence (#409): the dominant crash here is
* `Unknown node type: undefined` a nested node with an absent/unknown `type`
* (a SHAPE problem, e.g. `{"text":"foo"}` missing `"type":"text"`). That points
* at the node, not an attribute, so `findInvalidNode` is consulted FIRST and,
* on a hit, yields a path-anchored node-shape message. Only when the document
* shape is sound do we fall back to `findUnstorableAttr` (undefined/function/
* symbol/bigint attr values); the generic "attribute likely holds a value Yjs
* cannot store" sentence is the last resort.
*/
function unstorableYjsError(safe: any, label: string, e: unknown): Error {
const base = `Failed to encode document to Yjs (${label}): ${e instanceof Error ? e.message : String(e)}.`;
const badNode = findInvalidNode(safe);
if (badNode) {
return new Error(`${base} Invalid node: ${badNode.summary}`);
}
const bad = findUnstorableAttr(safe);
return new Error(
`Failed to encode document to Yjs (${label}): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`,
`${base}${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`,
);
}
@@ -69,8 +87,8 @@ global.WebSocket = WebSocket;
* bodies merged. So the import output is ALREADY in canonical footnote
* topology.
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
* with every other full-document persist path (`update_page_json`,
* `docmost_transform`, `insert_footnote`, ). Because the package output is
* with every other full-document persist path (`updatePageJson`,
* `docmostTransform`, `insertFootnote`, ). Because the package output is
* already canonical, this layer is a no-op here (idempotent) it exists so
* the page-write contract is enforced uniformly regardless of how the PM doc
* was produced, not because the import needs fixing.
@@ -236,6 +254,12 @@ export async function mutatePageContent(
collabToken: string,
baseUrl: string,
transform: (liveDoc: any) => any | null,
// #487: optional abort signal carrying the turn's Stop + the in-app tool
// per-call cap. Checked as the PRE-COMMIT safe-point below (after the session
// is acquired, immediately before the atomic read->write), so a Stop that
// arrives during the connect/lock window stops THIS write from landing. See the
// limitation note at the check.
signal?: AbortSignal,
): Promise<MutationResult> {
return withPageLock(pageId, async () => {
if (process.env.DEBUG) {
@@ -248,6 +272,13 @@ export async function mutatePageContent(
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
try {
// #487 PRE-COMMIT safe-point: if the turn was Stopped (or the in-app tool
// per-call cap fired) after we acquired the collab session but before the
// atomic write, throw NOW so this commit never runs. KNOWN LIMITATION
// (#487): this only stops THIS commit — a write tool that already committed
// an EARLIER call this turn leaves that op applied. Cancel guarantees "no
// NEW commit starts", NOT "the write didn't land".
signal?.throwIfAborted();
return await session.mutate(transform);
} catch (e) {
// Drop the session on any failure so the next call reconnects fresh (this
@@ -264,7 +295,7 @@ export async function mutatePageContent(
* it was produced from markdown (ids regenerate) or edited in place
* (existing block ids preserved).
*
* This is an intentional full replace (used by update_page / update_page_json),
* This is an intentional full replace (used by update_page / updatePageJson),
* but now runs under the per-page lock and waits for server persistence via
* mutatePageContent.
*/
@@ -273,6 +304,8 @@ export async function replacePageContent(
prosemirrorDoc: any,
collabToken: string,
baseUrl: string,
// #487: threaded straight to mutatePageContent's pre-commit safe-point.
signal?: AbortSignal,
): Promise<MutationResult> {
// Fail fast on a bad document instead of deferring the failure into the
// collaboration write (where TiptapTransformer.toYdoc(undefined) used to
@@ -289,6 +322,7 @@ export async function replacePageContent(
collabToken,
baseUrl,
() => prosemirrorDoc,
signal,
);
}
+1 -1
View File
@@ -20,7 +20,7 @@
*
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
* match the document's plain text. Exactly like edit_page_text's json-edit
* match the document's plain text. Exactly like editPageText's json-edit
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
+163 -53
View File
@@ -14,7 +14,19 @@
* signature.
*
* If recreateTransform / the changeset throws on a pathological document pair,
* we fall back to a coarse block-level text diff so the tool never hard-fails.
* OR the pair is too large to diff cheaply (see the size guard below), we fall
* back to a coarse block-level text diff so the tool never hard-fails and never
* pins the event loop.
*
* SIZE GUARD (issue #464 prod CPU-DoS). recreateTransform computes its diff via
* rfc6902.createPatch, whose array diff is O(n·m) Levenshtein per array pair and
* whose per-run word diff is O(w²); on a large/heavily-changed doc this runs for
* seconds-to-hours and starves the whole process (BullMQ, Redis lock renewals,
* embeddings). It never THROWS it just never finishes so the try/catch below
* cannot save us. Because diffDocs runs on EVERY in-app/MCP content edit's verify
* report, we PRE-FLIGHT the doc size and route anything above a cheap cap straight
* to the coarse fallback (the same shape the catch produces). Same cap+fallback
* pattern as the ELK-layout DoS fix (#440 / c917dcc3).
*/
import { Node } from "@tiptap/pm/model";
@@ -72,6 +84,56 @@ function countNodes(doc: any, pred: (node: any) => boolean): number {
return n;
}
// --- Issue #464: pre-flight size guard for the precise diff ------------------
// Defaults are BENCHMARK-derived on the recreateTransform(complexSteps:false,
// wordDiffs:true, simplifyDiff:true) pipeline, chosen so the WORST case (a fully
// re-written doc — the adversarial shape that drove the incident) keeps the
// synchronous block under ~200ms REGARDLESS of input:
// - 150 total nodes: worst-case pair ~176ms; the O(node²) array diff crosses
// 200ms at ~170 nodes and then explodes super-linearly (400 nodes ~1.3s,
// 800 ~5.5s), so cap just below the crossover.
// - 12 KiB serialized JSON: an independent axis, because the per-run word diff
// is O(words²) — a FEW nodes with very long text runs is dangerous even at a
// low node count (17 nodes / ~11 KiB ~176ms, / ~14 KiB ~290ms). A node-light
// but byte-heavy doc is still refused.
// Either metric over its cap routes to the coarse fallback. Both are env-tunable
// for operators who accept more CPU in exchange for exact diffs on larger docs.
const DEFAULT_MAX_NODES = 150;
const DEFAULT_MAX_BYTES = 12 * 1024;
/**
* Read a positive-integer env override, falling back to `dflt`. Garbage / unset /
* non-finite / non-positive all fall back (so the guard can never be accidentally
* disabled by a malformed value). Read fresh on every call so a test / operator
* can flip the knob without a restart.
*/
function readPositiveIntEnv(name: string, dflt: number): number {
const raw = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
}
/**
* True when the pair is too large for the precise (recreateTransform) diff and
* must degrade to the coarse fallback. Takes the MAX of the two docs on each
* metric so an ASYMMETRIC pair (a small new doc vs a huge old doc, or vice
* versa) which still explodes rfc6902 is caught. Cheap: one node walk +
* one JSON.stringify per doc, both O(size).
*/
function exceedsDiffSizeGuard(oldDoc: any, newDoc: any): boolean {
const maxNodes = readPositiveIntEnv("MCP_DIFF_MAX_NODES", DEFAULT_MAX_NODES);
const maxBytes = readPositiveIntEnv("MCP_DIFF_MAX_BYTES", DEFAULT_MAX_BYTES);
const nodes = Math.max(
countNodes(oldDoc, () => true),
countNodes(newDoc, () => true),
);
if (nodes > maxNodes) return true;
const bytes = Math.max(
JSON.stringify(oldDoc)?.length ?? 0,
JSON.stringify(newDoc)?.length ?? 0,
);
return bytes > maxBytes;
}
/**
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
@@ -226,6 +288,81 @@ function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
return changes;
}
/** Accumulated textual changes plus their derived char/block tallies. */
interface DiffTally {
changes: DiffChange[];
inserted: number;
deleted: number;
changedBlocks: Set<string>;
}
/**
* Produce the coarse-fallback tally for a pair. This is the SINGLE source of the
* `fellBack:true` result shape, shared by BOTH degrade paths in diffDocs (the
* pre-flight size guard and the recreateTransform catch) so they behave and
* report identically.
*/
function coarseDiffTally(oldDoc: any, newDoc: any): DiffTally {
const changes = coarseDiff(oldDoc, newDoc);
let inserted = 0;
let deleted = 0;
const changedBlocks = new Set<string>();
for (const c of changes) {
if (c.op === "insert") inserted += c.text.length;
else deleted += c.text.length;
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
}
return { changes, inserted, deleted, changedBlocks };
}
/**
* Compute the PRECISE tally via the recreateTransform pipeline. Callers MUST
* gate this behind the size guard (it can block the event loop for a large pair)
* and wrap it in try/catch (a pathological pair can throw); on either the guard
* or a throw, use `coarseDiffTally` instead. Kept as a sibling of
* `coarseDiffTally` so both produce the same `DiffTally` shape.
*/
function preciseDiffTally(oldDocJson: any, newDocJson: any): DiffTally {
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
const newNode = Node.fromJSON(docmostSchema, newDocJson);
const tr = recreateTransform(oldNode, newNode, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
const simplified = simplifyChanges(changeSet.changes, newNode);
const changes: DiffChange[] = [];
let inserted = 0;
let deleted = 0;
const changedBlocks = new Set<string>();
for (const change of simplified) {
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
if (change.toA > change.fromA) {
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
if (text.length > 0) {
deleted += text.length;
const block = blockContextAt(oldNode, change.fromA);
changes.push({ op: "delete", block, text });
if (block) changedBlocks.add("d:" + block);
}
}
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
if (change.toB > change.fromB) {
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
if (text.length > 0) {
inserted += text.length;
const block = blockContextAt(newNode, change.fromB);
changes.push({ op: "insert", block, text });
if (block) changedBlocks.add("i:" + block);
}
}
}
return { changes, inserted, deleted, changedBlocks };
}
/** Build the human-readable unified-ish markdown summary. */
function renderMarkdown(
result: Omit<DiffResult, "markdown">,
@@ -276,66 +413,39 @@ export function diffDocs(
newDocJson: any,
notesHeading: string = "Примечания переводчика",
): DiffResult {
// computeIntegrity is cheap (linear node walks) and its counts are needed in
// BOTH the precise and coarse paths, so it always runs first.
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
let changes: DiffChange[] = [];
let inserted = 0;
let deleted = 0;
let fellBack = false;
const changedBlocks = new Set<string>();
let tally: DiffTally;
try {
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
const newNode = Node.fromJSON(docmostSchema, newDocJson);
const tr = recreateTransform(oldNode, newNode, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldNode).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const simplified = simplifyChanges(changeSet.changes, newNode);
for (const change of simplified) {
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
if (change.toA > change.fromA) {
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
if (text.length > 0) {
deleted += text.length;
const block = blockContextAt(oldNode, change.fromA);
changes.push({ op: "delete", block, text });
if (block) changedBlocks.add("d:" + block);
}
}
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
if (change.toB > change.fromB) {
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
if (text.length > 0) {
inserted += text.length;
const block = blockContextAt(newNode, change.fromB);
changes.push({ op: "insert", block, text });
if (block) changedBlocks.add("i:" + block);
}
}
}
} catch {
// Pathological pair: degrade to a coarse block-level diff so we never throw.
// Pre-flight size guard (#464): a too-large pair would make recreateTransform
// block the event loop for seconds-to-hours WITHOUT throwing, so route it to
// the coarse fallback BEFORE calling recreateTransform at all. Both this path
// and the catch below go through coarseDiffTally for an identical `fellBack`
// result shape.
if (exceedsDiffSizeGuard(oldDocJson, newDocJson)) {
fellBack = true;
changes = coarseDiff(oldDocJson, newDocJson);
for (const c of changes) {
if (c.op === "insert") inserted += c.text.length;
else deleted += c.text.length;
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
tally = coarseDiffTally(oldDocJson, newDocJson);
} else {
try {
tally = preciseDiffTally(oldDocJson, newDocJson);
} catch {
// Pathological pair: degrade to a coarse block-level diff so we never throw.
fellBack = true;
tally = coarseDiffTally(oldDocJson, newDocJson);
}
}
const partial: Omit<DiffResult, "markdown"> = {
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
summary: {
inserted: tally.inserted,
deleted: tally.deleted,
blocksChanged: tally.changedBlocks.size,
},
integrity,
changes,
changes: tally.changes,
};
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
}
@@ -380,7 +490,7 @@ export interface VerifyReport {
/**
* ONLY structural integrity types whose count changed, as [before, after]
* (images/links/tables/callouts). Surfaces structural mutations that touch
* neither text nor marks (e.g. insert_image, deleting a table) which diffDocs
* neither text nor marks (e.g. insertImage, deleting a table) which diffDocs
* being TEXT-only would otherwise report as "no content change".
*/
structure?: Record<string, [number, number]>;
@@ -400,7 +510,7 @@ export interface VerifyReport {
*
* The structural integrity delta (from diffDocs's `integrity` tuples) is what
* makes `changed` true for an image/table/callout/link count change that diffs
* to zero text closing a verify blind spot for insert_image, delete_node on a
* to zero text closing a verify blind spot for insertImage, deleteNode on a
* table, etc.
*/
export function summarizeChange(before: any, after: any): VerifyReport {
+168
View File
@@ -0,0 +1,168 @@
// ID-based cell operations for `drawioEditCells` (issue #425, stage 3).
//
// Instead of resending the whole XML (whose diff is fragile — draw.io reorders
// attributes, and a {search,replace} text match breaks on it), the model sends
// targeted operations keyed by cell id:
//
// { op: "add", xml: "<mxCell .../>" } // append a new cell
// { op: "update", cellId: "n3", xml: "<mxCell .../>" } // replace that cell
// { op: "delete", cellId: "n5" } // + CASCADE
//
// `delete` CASCADES: it removes the cell, every descendant cell whose parent
// chain leads to it (container children), AND every edge whose source or target
// is any deleted cell. Ids are STABLE across edits so diffs stay meaningful.
//
// Operations apply to the parsed DOM of the current model; the caller re-lints
// and rebuilds the .drawio.svg through the existing #423 pipeline afterwards.
import { JSDOM } from "jsdom";
let _window: any = null;
function xmlWindow(): any {
if (!_window) _window = new JSDOM("").window;
return _window;
}
export type CellOp =
| { op: "add"; xml: string }
| { op: "update"; cellId: string; xml: string }
| { op: "delete"; cellId: string };
export class CellOpsError extends Error {
constructor(message: string) {
super(`drawioEditCells: ${message}`);
this.name = "CellOpsError";
}
}
// The mxGraph root sentinels. id="0" is the graph root; id="1" is the default
// layer that parents every real cell. A delete targeting either would cascade
// through the whole diagram body (every cell chains up to "1"), so such an op is
// rejected outright.
const SENTINEL_IDS = new Set(["0", "1"]);
/** Parse a single `<mxCell …>…</mxCell>` fragment into an element, or throw. */
function parseCellFragment(xml: string): any {
const parser = new (xmlWindow().DOMParser)();
// Wrap so a self-closed or child-bearing single cell parses as one root.
const doc = parser.parseFromString(`<root>${xml}</root>`, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new CellOpsError(`operation xml is not well-formed: ${xml.slice(0, 120)}`);
}
const cells = doc.getElementsByTagName("mxCell");
if (cells.length !== 1) {
throw new CellOpsError(
`each add/update op must carry exactly one <mxCell> (got ${cells.length})`,
);
}
return cells[0];
}
/** All ids reachable as descendants of `rootId` via the parent relation. */
function collectDescendants(
rootId: string,
parentOf: Map<string, string | undefined>,
): Set<string> {
const doomed = new Set<string>([rootId]);
let grew = true;
while (grew) {
grew = false;
for (const [id, parent] of parentOf) {
if (!doomed.has(id) && parent != null && doomed.has(parent)) {
doomed.add(id);
grew = true;
}
}
}
return doomed;
}
/**
* Apply the operation list to a model XML string and return the new model XML.
* Uses the DOM so attribute order / formatting is preserved for untouched cells.
* Throws CellOpsError on an unknown target id or a malformed op fragment (so the
* model gets a precise error and nothing is half-applied).
*/
export function applyCellOps(modelXml: string, ops: CellOp[]): string {
if (!Array.isArray(ops) || ops.length === 0) {
throw new CellOpsError("operations must be a non-empty array");
}
const parser = new (xmlWindow().DOMParser)();
const doc = parser.parseFromString(modelXml, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new CellOpsError("the current diagram XML is not well-formed");
}
const root = doc.getElementsByTagName("root")[0];
if (!root) throw new CellOpsError("the current diagram has no <root> element");
const cellEls = () => Array.from(root.getElementsByTagName("mxCell")) as any[];
const byId = () => {
const m = new Map<string, any>();
for (const el of cellEls()) m.set(el.getAttribute("id") ?? "", el);
return m;
};
for (const op of ops) {
if (op.op === "add") {
const frag = parseCellFragment(op.xml);
const id = frag.getAttribute("id");
if (!id) throw new CellOpsError("an add op's <mxCell> is missing an id");
if (byId().has(id))
throw new CellOpsError(`add op id "${id}" already exists (use update)`);
root.appendChild(doc.importNode(frag, true));
} else if (op.op === "update") {
const map = byId();
const target = map.get(op.cellId);
if (!target)
throw new CellOpsError(`update target cell "${op.cellId}" does not exist`);
const frag = parseCellFragment(op.xml);
const newId = frag.getAttribute("id");
if (newId && newId !== op.cellId)
throw new CellOpsError(
`update op cellId "${op.cellId}" != the <mxCell> id "${newId}" (ids are stable)`,
);
// Replace the element in place so surrounding cells are untouched.
const imported = doc.importNode(frag, true);
target.parentNode.replaceChild(imported, target);
} else if (op.op === "delete") {
// Reject a sentinel-targeted delete BEFORE collecting descendants: "0"/"1"
// parent the entire diagram, so a cascade from either would wipe the whole
// model body (doomed.delete("0"/"1") only spared the sentinel itself, not
// its children).
if (SENTINEL_IDS.has(op.cellId))
throw new CellOpsError(
`cannot delete sentinel cell "${op.cellId}" (the graph root/default layer)`,
);
const map = byId();
if (!map.has(op.cellId))
throw new CellOpsError(`delete target cell "${op.cellId}" does not exist`);
// Build the parent relation over the CURRENT cells for the cascade.
const parentOf = new Map<string, string | undefined>();
for (const el of cellEls()) {
parentOf.set(el.getAttribute("id") ?? "", el.getAttribute("parent") ?? undefined);
}
const doomed = collectDescendants(op.cellId, parentOf);
// Cascade to edges whose source/target is any doomed cell.
for (const el of cellEls()) {
if (el.getAttribute("edge") !== "1") continue;
const src = el.getAttribute("source");
const tgt = el.getAttribute("target");
if ((src && doomed.has(src)) || (tgt && doomed.has(tgt))) {
doomed.add(el.getAttribute("id") ?? "");
}
}
// Never delete the sentinels even if referenced by a malformed op.
doomed.delete("0");
doomed.delete("1");
for (const el of cellEls()) {
const id = el.getAttribute("id") ?? "";
if (doomed.has(id)) el.parentNode.removeChild(el);
}
} else {
throw new CellOpsError(`unknown op "${(op as any).op}"`);
}
}
const ser = new (xmlWindow().XMLSerializer)();
return ser.serializeToString(doc.documentElement);
}

Some files were not shown because too many files have changed in this diff Show More