invalidateOnUpdatePage для sidebar-pages кеша спредил сыро {...sidebarPage, title,
icon} — при title-only событии icon приходит undefined и затирался (и наоборот).
Embed-tree путь 20 строками выше уже гардит undefined; sidebar-ветка пропустила
тот же гард. Применён тот же паттерн: ...(title!==undefined?{title}:{}) +
...(icon!==undefined?{icon}:{}). +2 теста (sidebar title-only/icon-only:
непереданное поле не затирается); мутационно (вернуть сырой спред -> тесты краснеют).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1 [blocking]: share-modal.test.tsx + comment-content-view.test.tsx mocked
page-query without usePageMetaQuery → 3 tests threw (ShareModal uses it
directly, comment-content-view via MentionContent). Added usePageMetaQuery to
both mocks (the space-tree mocks were already fixed; these two were missed).
- F2: restored refetchOnMount:true on useGetSpacesQuery — ["spaces"] is
invalidated only by same-tab mutations (no socket path), so a cross-actor
change (an admin adding/removing THIS user from a space) left the list stale
until a hard reload. The other refetchOnMount removals (favorites/watched —
per-user, same-tab-only gap) stay removed.
- F3: corrected the trash-list + recent-changes KEEP comments — both keys ARE
invalidated (trash-list by 3 mutations, recent-changes by page CRUD), but
invalidateQueries only marks an UNMOUNTED query stale without refetching, so the
mount refetch closes the gap. The old "never invalidated" wording was wrong and
risked a maintainer deleting a live invalidation as dead code.
- F4: tests for the two load-bearing pure paths — invalidate-on-update-page (the
undefined-guard: a title-only event keeps the icon; sibling/unrelated subtrees
untouched) and breadcrumb-path-equal (equal chain → true; any id/slugId/name/
icon change or length diff → false; both-null → true). Exported
breadcrumbPathEqual for the test.
Gate: client tsc 0; the 4 affected/new test files 33 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Outside the editor the UI did background work on every tree event, socket
reconnect, and navigation. Tree infra (virtualization/memo/O(N) utils) was
already good — the cost was in the subscriptions and duplicates around it.
Client-only; behavior 1:1.
- Setter-only atom subscriptions → useSetAtom: space-tree-row, use-tree-mutation,
use-tree-socket no longer subscribe every visible row to the WHOLE treeDataAtom
value (a tree event re-rendered all ~20-30 rows, bypassing the DocTreeRow memo).
space-tree-node-menu / mention-list read the tree imperatively (store.get) in
their handlers only. breadcrumb.tsx uses a selectAtom slice (ancestor chain +
field equality) instead of the whole-tree subscription.
- Socket handler cleanup (BUG): use-tree-socket + use-query-subscription now
socket.off() their named handlers on cleanup (were accumulating listeners on
every reconnect → duplicated invalidations/tree-walks). Mirrors
use-notification-socket.
- Field-update tree path: invalidateOnUpdatePage does a pointwise patch of the
cached embed subtrees instead of a blanket invalidatePageTree() (refetch storm);
structural events keep the blanket invalidate.
- usePageMetaQuery: a content-less select slice for the 13 peripheral subscribers
that read only title/permissions/id, so they stop re-rendering every ~3s while
typing / on every collab page.updated (page.tsx keeps the full query for content).
- page.tsx: skeleton + placeholderData keepPreviousData (no blank flash on nav).
- Removed refetchOnMount:true where socket/mutation invalidation already keeps the
cache fresh (favorite/space/space-watcher/workspace). KEPT it on the 3 queries
with NO other freshness path (trash-list, created-by, recent-changes) — the
global default is refetchOnMount:false, so those overrides are load-bearing.
- Small: resize mousemove/up attached only while dragging; per-row emoji-picker
keydown gated on `opened`; AiChatWindow queries enabled only when the window is
open.
Gate: client tsc 0, client vitest page+websocket 200 passed (+editor suites),
build ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Побочные находки инцидента #400 (правка большой таблицы через MCP подвешивает всё).
1. Гонка connect-vs-unload в @hocuspocus/server 3.4.4 (вероятный источник 25s
connect-таймаутов): createDocument проверяет loadingDocuments/documents, но НЕ
ждёт unloadingDocuments -> новое соединение может захендшейкаться на умирающий
Document -> redis-sync идёт по пути 'doc не загружен', провайдер висит до
таймаута. Апстрим (main) не починен. pnpm-патч (инфра как у yjs-патча): в начале
createDocument await in-flight unload (обёрнут в try/catch — отклонённый unload
не отравляет открытие, поведение как до патча), в ОБОИХ рантаймах (cjs+esm).
Тест hocuspocus-unload-race: реальный createDocument с засеянным in-flight
unload -> не грузит пока unload не осел; при откате патча тест краснеет.
2. Двойная перекодировка в onLoadDocument (persistence.extension.ts): хук строил
НОВЫЙ Y.Doc и возвращал его -> hocuspocus делал applyUpdate(encodeStateAsUpdate)
ВТОРОЙ раз (315КБ на каждую холодную загрузку); в JSON-ветке результат encode
выбрасывался (мёртвый вызов). Теперь стейт применяется прямо в data.document,
возврат undefined (hocuspocus мержит только при возврате Doc); мёртвый encode
убран. Содержимое документа не меняется — только меньше encode/alloc.
3. isDeepStrictEqual по 84КБ JSON на каждом store: замерил — 1.32мс на 90КБ
(immaterial, <50мс порога; доминируют fromYdoc+encodeStateAsUpdate). Изменений
кода НЕТ по правилу задачи (dirty-флаг только при material).
closes#401
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
В ai@6 упавший тул — это tool-error часть в step.content ({type,toolCallId,
toolName,input,error}), а не элемент toolResults. Раньше serializeSteps писал
только toolCalls+toolResults (ошибка терялась, orphan tool-call без результата),
а assistantParts эмитил заглушку 'Tool call did not complete.' (реальный текст
ошибки терялся для мультиходового реплея — модель не знала, почему упало, и
повторяла ошибку).
- StepLike расширен полем content; новый хелпер normalizeToolError (Error/string/
object -> строка, обрезка через существующий compactValue/лимиты).
- serializeSteps: на каждый tool-error пушит парный {toolName, error} тем же
паттерном, что успешный {toolName, output} -> колонка tool_calls фиксирует сбой.
- assistantParts: при наличии tool-error эмитит output-error с РЕАЛЬНЫМ текстом;
заглушка остаётся только для по-настоящему непарных вызовов (прерванных).
- docs/reading-ai-logs.md обновлён под новую форму + cutover-оговорка.
Обратно совместимо: старые строки читаются как раньше, error-элемент аддитивен.
closes#407
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Убран отдельный столбец-маркер .definitionMarker (order:-1, min-width:1.5em),
дававший висячий отступ. Номер сноски теперь рисуется инлайн в начале первого
параграфа через .definitionContent > :first-child::before из CSS-переменной
--footnote-number (в модель документа не попадает, экспорт не затрагивает).
Кегль сносок уменьшен до var(--mantine-font-size-sm). Инвариант #146 (contentDOM
первый в DOM) и логика мульти-бэклинков #168 сохранены.
closes#420
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read-tool outputs were compacted at a 4000-byte gate before being stored
in metadata.parts, which is re-sent to the model every later turn. Whole-page
reads (tens of KB) got shrunk to a 500-char preview plus a "[truncated N chars]"
marker; on the next turn the model read that marker as a source truncation and
re-read the page, wasting tokens and producing wrong behavior.
- Raise MAX_TOOL_OUTPUT_BYTES 4000 -> 200_000 so normal reads are stored and
replayed verbatim; only a single >200 KB output is compacted as a backstop.
- Reword the inline string marker so it reads as a replay-history elision, not
a source truncation, and tells the model it can re-call the tool.
- Update doc comments and enlarge the compactToolOutput unit-test inputs above
the new 200 KB gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Researcher role wrote 40 literal `^[...]` and zero real footnotes: its
incremental write path (insertNode/editPageText) doesn't parse markdown, and
the footnote-capable tool was MCP-only. Promote three tools from inline
MCP-only to the shared registry so the in-app agent gets them too.
- tool-specs.ts: insertFootnote/insertImage/replaceImage added to
SHARED_TOOL_SPECS (mcpName/schema/description moved VERBATIM from the inline
registrations — MCP names + behaviour unchanged for external clients).
- index.ts: the 3 inline registerTool calls become registerShared; drop the
"MCP-only by design" comments.
- ai-chat-tools.service.ts: register the 3 in-app via sharedTool ->
client.insertFootnote/insertImage/replaceImage (imageUrl->url,
attachmentId->oldAttachmentId mapping).
- tool-tiers.ts: insertFootnote -> core (else the original asymmetry recurs —
footnote tool hidden while editPageText is core); images -> deferred.
- research/{en,ru}.yaml FOOTNOTES: `^[...]` parses ONLY on a whole-markdown
write (create/update/import); for a pinpoint citation to existing text use
insertFootnote; via editPageText/insertNode it stays literal.
- json-edit.ts guardrail: an edit_page_text `replace` containing a `^[...]`
token is refused into failed[] with an insert_footnote hint, mirroring the
existing formatting-marker refusal. (Slightly broader net than that mirror —
a literal `^[a-z]` regex class in a replace is also refused; accepted
defense-in-depth, has a no-false-positive test.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the merged MCP-hang fix (16b476a2); post-hoc review DO. The
fix itself is unchanged and prod-working — this adds the coverage + one
coherence fix the review asked for.
Tests (ai-chat.service.setup-abort.spec.ts):
- onLateResolve late-close: a toolset that resolves AFTER the setup race
was lost has its leased clients released (close spy asserted).
- pure 60s deadline (signal NOT aborted): the turn proceeds Docmost-only
(reaches streamText, run NOT finalized 'aborted') — the defense-in-depth
backstop, previously untested.
- legacy no-runId: a setup abort does NOT re-throw (the `runId &&` guard);
together with the deadline test this locks both halves of the catch guard.
Coherence (external-mcp/mcp-clients.service.ts buildEntry):
- The per-server connects now run via Promise.all instead of a sequential
for-await, so total build time is bounded by the slowest single server
(~2×CONNECT_TIMEOUT_MS) instead of the sum. At >6 all-timing-out servers
the sequential build exceeded the outer 60s deadline, inverting the
"per-server bound primary, outer deadline is backstop" invariant. Merge
is done in a sequential post-Promise.all loop in server order, so tool-key
precedence/disambiguation, outcomes and client ordering are byte-identical
to the sequential build; each server keeps its own timeout + failure-close.
Comments: the onLateResolve note now says it releases the lease (refcount),
not force-closes transports (the cache owns them); the invariant comment
reflects the parallel build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single NUL (U+0000) in model/tool output (e.g. a truncated multibyte
read of a web page) is rejected by Postgres in BOTH the content (text) and
toolCalls/metadata (jsonb) columns, so it failed EVERY write of the
streaming assistant row ("invalid input syntax for type json") and silently
dropped the turn's content from the DB while the live stream still showed it.
- add stripNulChars: deep-strips NUL from all strings, returns the same
reference when there is nothing to strip (no needless clone)
- apply it at the flushAssistant choke point (covers content + toolCalls +
metadata for the seed, per-step and terminal writes)
- tests: deep-strip, same-reference, end-to-end via flushAssistant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce the default external-MCP silence timeout from 5 min to 1 min and the
overall call timeout from 15 min to 2 min. Update related tests and comments.
Increase Fastify's JSON body limit to 25 MiB (configurable via
HTTP_JSON_BODY_LIMIT) to accommodate large AI‑chat payloads.
BREAKING CHANGE: shorter MCP timeouts may abort long‑running tool calls that
previously succeeded with the older defaults.
DO-1: add an integration test (test/mock/tool-timing-server.test.mjs) that
constructs a real createDocmostMcpServer with a spy onMetric, links a Client
over InMemoryTransport, invokes get_workspace (no input schema, so the wrapped
handler always runs) and asserts onMetric fired with
("mcp_tool_duration_seconds", <number>, { tool: "get_workspace" }). This locks
that the monkeypatch wraps every tool AND labels with the registration name —
which the isolated timeToolHandler unit test does not. Mutation-verified
(mislabel -> test fails). The tool's expected backend failure (ECONNREFUSED)
is tolerated: the wrapper times in a finally on throw too, so the metric fires.
DO-2: reword the mcp.service.ts "zero overhead when disabled" comment to
"negligible overhead" — the registerTool wrapper still runs performance.now()
+ an async try/finally per tool call when onMetric is undefined (the
`onMetric?.()` short-circuits the label build; cost is immaterial at
tool-call rate), so "zero" was literally inaccurate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
packages/mcp stays free of prom-client/server: it only calls an optional
DocmostMcpConfig.onMetric(name, value, labels?) sink the host provides.
- client.ts: onMetric on the config; fires collab_connect_timeouts_total
once, only inside the 25s connect-timeout callback (the connect-vs-unload
signal). cleanup() clears the timer on every other finish path, so no
double-count.
- index.ts: createDocmostMcpServer monkeypatches server.registerTool (before
registerShared + inline tools are registered) to wrap every handler with
timeToolHandler — times in a finally on success AND throw, re-throws
unchanged, emits mcp_tool_duration_seconds{tool=<registered name>} (bounded
cardinality). Single choke point catches all tools.
- mcp.service.ts: the per-request config resolver injects onMetric ONLY when
isMetricsEnabled(), routing mcp_tool_duration_seconds -> observeMcpTool and
collab_connect_timeouts_total -> incConnectTimeout. Disabled / standalone
(stdio, no onMetric) -> undefined -> zero-overhead no-op.
New node:test unit (tool-timing.test.mjs) covers the wrapper's value/throw
preservation and the standalone no-op. packages/mcp/build/ is gitignored,
not committed (CI rebuilds it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the #355 perf-metrics registry (all behind the METRICS_PORT hard
gate — nullable instruments, no-op helpers when unset). New families:
- collab_doc_load_duration_seconds{size_bucket} — onLoadDocument timed on
the real DB-load paths only (the already-loaded early-return is skipped).
- size_bucket added to collab_store_duration_seconds; storeDocument returns
the ydoc byte length (reusing its single Y.encodeStateAsUpdate, no second
encode) so onStoreDocument observes size without extra cost.
- collab_docs_open (gauge, read-on-scrape via collect() from
hocuspocus.getDocumentsCount() — never inc/dec'd, so it can't drift) +
collab_doc_loads_total / collab_doc_unloads_total (afterLoad/afterUnload).
- collab_connect_duration_seconds — onConnect->connected, correlated by a
WeakMap keyed on the shared request (leak-free; the current client uses
one socket per document).
- collab_auth_duration_seconds — wraps onAuthenticate (success and failure).
- sizeBucket() shared helper (lt64k|lt256k|lt1m|ge1m, 4 bounded values).
The mcp_tool_duration_seconds histogram + collab_connect_timeouts_total
counter helpers are registered here but wired in pass 2 (MCP callback).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A transient network blip during an external-MCP handshake left createMCPClient
pending forever (@ai-sdk/mcp does not settle on abort). getOrBuildEntry caches the
per-workspace build PROMISE, so the never-settling connect poisoned the cache and
every later turn hung at step_count=0 before streamText — the run never finalized,
the row stayed 'running', and the chat was permanently blocked with
A_RUN_ALREADY_ACTIVE (an explicit Stop could not interrupt the un-signalled setup).
- mcp-clients: wrap connect() in a settling timeout (connectWithTimeout) that
closes a late-arriving client, so a hung handshake rejects instead of poisoning
the build cache; the bad server is skipped and the build completes.
- mcp-clients: close a connected-but-unregistered client when tools() fails,
fixing a pre-existing transport leak in buildEntry.
- ai-chat.service: bound the toolsFor build with the run's abort signal AND a
deadline (raceAgainstAbortAndTimeout); re-throw an explicit Stop only when a run
exists (runId) so the run finalizes as 'aborted', and keep the legacy
socket-bound path unchanged; settle the run 'aborted' vs 'error' accordingly.
- tests: cache-not-poisoned + orphan-client-close-once + Stop-during-setup
finalizes the run once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review round: add the two missing test-locks the reviewer asked for —
(1) priority order of PRIMARY_INPUT_FIELDS is now pinned (`{query,title}`
-> "Q", so a reordering breaks the test); (2) the clamp boundary is pinned
exactly (140 chars -> unchanged, no ellipsis; 141 -> 140 + "…"), catching
an off-by-one / `>` vs `>=` regression. Test-only, no production change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For tools without a friendly label (esp. external MCP tools like
Search_web_search) the card showed a generic "Ran tool <name>" with no
sense of what was searched. Add a compact one-line, dimmed summary of the
call's arguments under the label, pulled from part.input.
New pure toolInputSummary(part): picks the first present "primary" field
(query/q/searchQuery/url/urls/title/name/text/prompt), collapses
whitespace, clamps to ~140 chars; arrays render "first (+N)". It returns
undefined during input-streaming (the input grows while state is fixed and
messageSignature doesn't track input, so a live summary would freeze) —
the state flip to input-available re-renders the row with the final value,
so message-signature.ts is left untouched. The value renders ONLY through
Mantine <Text> (React-escaped) — no markdown/HTML, no XSS.
A showInput prop (default true) is threaded MessageList -> MessageItem
(+memo) -> ToolCallCard; the public share widget passes showInput={false}
so an anonymous reader never sees the agent's raw query text (mirrors
showCitations). No JSON fallback when no primary field is present.
closes#392
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snapshot the editor selection at send time (same live-ref pattern as
openPageRef in prepareSendMessagesRequest), carry it nested inside
openPage so it dies with the page on a fail-closed resolve, and surface
it to the model only through the existing core getCurrentPage tool.
The selection TEXT is returned exclusively in the tool result (untrusted
collaborative-page content, treated as data by SAFETY_FRAMEWORK); the
system prompt gets only a fixed one-line flag, never the text/before/
after. sanitizeSelection caps text (4000), before/after (200), blockIds
(<=64 chars, <=20). Selection is a hint, not ground truth — the tool
description tells the agent to localize the fragment before editing.
closes#388
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under a fully parallel `pnpm -r test` run the suite computed five separate
bcrypt cost-12 hashes (one per test, ~300ms idle, multi-second with all cores
saturated) and tripped jest's default 5s per-test timeout ("DISABLED user"
test, suite 31s under load vs 6s isolated).
- compute the hash ONCE in a top-level beforeAll (covers both describe
blocks) and share the read-only string across the five former call sites
- jest.setTimeout(30_000) at module scope so the per-test bcrypt compares
inside verifyUserCredentials get headroom under load too
No change to test names, assertions, order, or the CREDENTIALS_MISMATCH
contract semantics. Suite: 8/8 green, 4.8s isolated (was ~6s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Do 1 [F7 regression]: транзиентный сбой attach больше не роняет строку и не
теряет ран. В transport fetch-wrapper `204 || !response.ok` оба зовут
onNoActiveStream (восстановить stripped-строку + invalidate + арм poll), и catch
зовёт его перед rethrow — раньше !ok/throw только сбрасывали флаг, и на 5xx/502/
network-blip in-progress ассистент-турн исчезал, durable-ран не отслеживался.
onNoActiveStream — суперсет (его часть-г всё ещё чистит флаг), идемпотентен.
Расширяет литеральный block-3 спеки (там был только сброс флага) — по ревью и
в согласии с интенцией окна «poll must survive a server restart».
Do 2 [stability]: attach-GET абортится при unmount + mount-гейтинг сайд-эффектов.
mountedRef: mount-эффект ре-армит true и в cleanup ставит false + abort
attachAbortRef; onNoActiveStream рано выходит на !mounted, onFinish-recovery
гейтится `wasResumed && mountedRef.current`. Снимает до-10-мин спурьёзный поллинг
+ чужую invalidateQueries + утёкший fetch на новооткрытом чате (и StrictMode
double-resume).
Do 3 [coherence]: anchor-mismatch не оставляет вечную dots-строку. Reconcile
после мержа хвоста мержит fresh-history версию stripped-строки, если её id !=
id хвоста — settl'ит осиротевшую streaming-A над раном B.
Тесты: F7 500 → restore+арм; F7 network-throw → restore+арм; unmount при pending
attach → abort + поздние колбэки не летят. vitest src/features/ai-chat 304
зелёных, grep-guard пуст.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Do 1 [test-coverage]: контроллерный тест на флаг-гейт begin-hook open() — при
OFF beginRun зовётся (durable-ран независим), а streamRegistry.open НЕ зовётся
(закрывает регресс: пустая entry → non-null paused attach → зависший SSE вместо
204); при ON — open зовётся с (chatId, runId).
Do 2 [stability]: байт-кап очереди pending paused-подписчика. pendingBytes +
overflowed на Subscriber; в paused-ветке ingestFrame при превышении
SUBSCRIBER_MAX_BUFFERED_BYTES (8MB) подписчик помечается overflowed, pending
чистится, он выбрасывается из entry.subscribers (как overflowed-entry). start()
на overflowed → onEnd (чистый 204-эквивалент, без частичного реплея). Контракт
«start() в том же тике, что attach()» задокументирован в коде — кап это
структурный бэкстоп для phase-2 Redis-await шва. Юнит-тест: paused A + live B,
9×1MB > cap → A выброшен (0 доставок), B получает все 9 живьём, поздний start(A)
→ один onEnd без реплея.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR 1 of 2 (#381, фаза 1.5 #184): серверный реестр SSE-стримов агентских ранов,
чтобы любая вкладка могла подключиться к живому рану с реплеем кадров + живым
хвостом. «Спящий» — весь провод за флагом AI_CHAT_RESUMABLE_STREAM (off по
умолчанию); клиент (PR 2) ещё не написан.
- ai-chat-stream-registry.service.ts: in-memory реестр (open/bind/abortEntry/
attach). attach — снапшот+подписка в ОДНОМ синхронном блоке (инвариант 4: нет
await между `subscribers.add` и `frames.slice()`), paused-подписчик, overflow,
retention с identity-guard (инвариант 2), open поверх live entry даёт ровно
один onEnd (инвариант 3), anchor против кросс-ранового реплея (инвариант 6).
- ai-chat.controller.ts: begin-хук open(chatId, runId) + GET-attach эндпоинт
(403 чужой чат; 204 нет-entry/finished/anchor-мисматч; cleanup до первой
записи + recheck req.raw.destroyed; cap→destroy).
- ai-chat.service.ts: tee SSE-кадров в реестр (consumeSseStream + generateMessageId,
гейт на runId && flag) + abortEntry из внешнего catch.
- environment.service.ts: флаг isAiChatResumableStreamEnabled().
Флаг OFF ⇒ байт-в-байт legacy И #184-фаза-1 (нет start.messageId, нет tee).
Инжектируемые провайдеры НЕ @Optional() → поломка вайринга роняет старт, а не
тихо выключает фичу.
Тесты: registry unit (16), controller.attach (9), service pipe-options (4, вкл.
flag-off-with-runId негатив), int-spec ai-chat-attach (6, реальный MockLanguageModelV3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The int suite could not self-exit after the ESM fix (8e125799) unmasked 4
specs, and was patched with two things: forceExit:true AND a bounded
destroyTestDb (sql.end({ timeout: 5 })). The bounded teardown is the real
fix — postgres.js .end() without a timeout blocks indefinitely on a stuck
pooled connection (the CI-observed "Jest did not exit"); the { timeout: 5 }
grace drains then force-closes sockets so teardown always completes.
forceExit was redundant belt-and-suspenders that also HID whether the
process truly exits on its own. Removing it: every handle-creating spec is
verified to close its handle — ai-chat-stream closes its http.createServer
in a finally, public-share-workspace-limiter closes its ioredis via
redis.quit() in afterAll, and the shared DB pools close via the bounded
destroyTestDb. --detectOpenHandles is clean and the suite self-exits.
Kept: the bounded destroyTestDb (defense for a genuinely stuck connection).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once the ESM transform fix (8e125799) let the four previously-unparseable
int-specs run, the server integration suite stopped exiting: all 66 tests pass,
then jest prints "Jest did not exit one second after the test run has
completed." and idles until the 20-minute job timeout kills it. Separately,
ai-chat-stream.int-spec.ts failed because its afterAll (destroyTestDb) hit the
60s hook timeout — postgres.js .end() waits for in-flight queries forever, so a
leaked/stuck pooled connection hung teardown.
Pragmatic unblock (the underlying open-handle leak is tracked in #382):
- jest-integration.json: add forceExit so jest always exits after the run even
if a suite leaves an open handle.
- db.ts: capture the singleton's raw postgres sql instance and bound the pool
shutdown with sql.end({ timeout: 5 }) (the same bounded-end pattern already
used in global-setup.ts) instead of Kysely.destroy(), so destroyTestDb can no
longer hang the afterAll hook.
forceExit masks residual handle leaks rather than fixing them; the proper
investigation (run with --detectOpenHandles on a pg+redis stand, close the
leaking timers/sockets, then drop forceExit) is filed as #382.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The develop build failed in two jobs, both rooted in the ESM-only workspace
package @docmost/prosemirror-markdown (type: module, built to build/index.js
with `export * from`), which the server imports at runtime (collaboration.util,
page.service). Refactor #345 taught only the unit jest config (package.json
"jest" key) to consume it, leaving the integration and e2e configs — and the
e2e-server CI job — broken:
- `pnpm --filter server test:int` -> SyntaxError: Unexpected token 'export'
(jest did not transform prosemirror-markdown/build/*.js).
- e2e-server job -> TS2307 Cannot find module '@docmost/prosemirror-markdown'
(the package was never built in that job).
Mirror the proven unit config into the two failing jest configs and add the
missing build step:
- jest-integration.json / jest-e2e.json: add a babel-jest transform rule for
`prosemirror-markdown/build/.+\.js$` (before the ts-jest rule so it wins) and
add @docmost/prosemirror-markdown to the transformIgnorePatterns allowlist so
the pnpm-symlinked package is transformed instead of ignored.
- develop.yml: build @docmost/prosemirror-markdown in the e2e-server job (after
editor-ext, before migrations), like the test.yml job already does.
Verified locally: an isolated spec importing the package fails with the exact
SyntaxError under the old config and passes under the new one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-1 нейтрализация ловила только spaced-разделители (`- - -`),
но пропускала сплошные `---`/`***`/`___`: на git-sync round-trip такая
строка становится horizontalRule, а он не несёт текста — строка терялась
целиком (хуже list/quote-порчи). Символ `_` вообще отсутствовал в regexе.
GITMOST_MD_BLOCK_TRIGGER_RE дополнен альтернативой на целую строку-
thematic-break `([-*_])(?:\s*\1){2,}\s*$` (3+ одинаковых `-`/`*`/`_`,
solid или через пробел); прежние группы переведены в non-capturing,
чтобы `\1` ссылался на единственную capture-группу. Обе копии regexа
(bridge и pm-markdown тест) синхронны.
Тесты: bare `---`/`***`/`___` документированы как text-losing
horizontalRule; ZWSP-нейтрализованная форма round-trip'ится параграфом
с сохранённым текстом. Кейсы падают на старом regexе.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-1 review found two issues:
- [regressions] Inserting a transcript line VERBATIM as a paragraph is unsafe on the
git-sync doc->markdown->doc round-trip: the paragraph serializer emits text with no
block-escape, so a line starting at col 0 with `- `/`> `/`# `/`1. `/```` ``` ````/`|`/
`> [!info]` silently re-parses into a list/quote/heading/code/table/callout (the last
hits the #359 callout machinery). Safe under the host contract (every line prefixed
`You:`/`Speaker N:`, which starts with a letter) but the helper didn't enforce it, and
leading-whitespace / stray lines exist. Fix: trim each kept line (drops the indent leak),
and if it STILL begins with a col-0 markdown block trigger, prepend an invisible
zero-width space (U+200B) so the trigger isn't at col 0 — the round-trip keeps it a
paragraph. (Backslash-escape was rejected: `marked` consumes the `\` on re-import, so it
would render visibly then vanish. ZWSP is invisible and never markdown-escaped.) The
serializer's missing block-escape is the pre-existing root cause; this is the boundary
defense. Prefixed transcript lines never match the trigger regex → left byte-exact.
- [test-coverage] The "inserted as TEXT, not HTML/markdown" contract wasn't locked (all
test strings were plain alphabet). Added a test that inserts `<b>bold</b>
<script>alert(1)</script> and *stars* and [link](x)` (with Bold/Italic/Link marks
registered so an insertContent(html) regression would parse them) and asserts one
verbatim text node, no marks, and getHTML() has no live tags.
Verified: apps/client tsc --noEmit 0 errors; gitmost bridge tests 4 passed; a NEW
prosemirror-markdown round-trip test (real convertProseMirrorToMarkdown ->
markdownToProseMirror) proves bare triggers corrupt while the ZWSP-neutralized form stays
a single byte-preserved paragraph and normal You:/Speaker N: lines round-trip byte-exact —
3 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native gitmost.app (stage 2) now sends a `transcript` field to
window.gitmost.createPageWithRecording, but the web bridge ignored it — the page
was created with audio only. Insert it.
- gitmost-recording.ts: add `transcript?: string` to GitmostCreatePagePayload
(plain text, \n-separated `You:` / `Speaker N:` lines, ready to insert). Add a
gitmostInsertTranscriptIntoEditor(editor, transcript) helper: a "Transcript"
heading + one paragraph per non-empty line, each line inserted VERBATIM as a
text node (never HTML → no injection), appended at doc end (below the audio).
No-op when transcript is undefined/empty/whitespace-only/non-string.
- gitmost-global-bridge.tsx (createPageWithRecording): after the audio insert
succeeds, call the helper inside a try/catch — best-effort, so a transcript
failure can never turn the already-successful recording into an error (logs +
still returns ok). Absent transcript → audio-only page, exactly as today.
DoD: recording with speech → page has audio AND a labeled transcript block;
without transcript → unchanged. Closes the web-side dependency of gitmost-app
stage 2. Verified: apps/client tsc --noEmit 0 errors; 2 unit tests
(transcript present → heading+paragraphs; undefined/""/whitespace/number/object
/null → no-op, doc unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The {search-queue} BullMQ queue had producers on every page/space/workspace change
but NO consumer/@Processor — it lives in Docmost's EE edition (external search
drivers), never in this fork. Search works independently via the pages tsvector DB
trigger; the queue was pure dead weight, growing forever (1902 stuck jobs in Redis,
the first real hit of the #355 queue-growing alert — which fired correctly).
Remove the plumbing:
- the 3 producers: drop @InjectQueue(SEARCH_QUEUE) + every searchQueue.add(...) from
page/space/workspace.listener.ts (and the isTypesense() gate that only wrapped the
search enqueue). page.listener.handlePageUpdated did ONLY the search enqueue, so its
@OnEvent(PAGE_UPDATED) handler is removed; the other handlers keep their aiQueue.add.
- the registration (queue.module.ts) and the metrics injection + 'search' depth-metric
entry (metrics-bull.service.ts).
- the constants: QueueName.SEARCH_QUEUE + the 6 unused SEARCH_INDEX_* QueueJob members
(grep-confirmed unreferenced). Shared QueueJob members (PAGE_*, SPACE_DELETED,
WORKSPACE_DELETED — used by the AI queue / embedding/history/notification processors)
are kept.
Search (tsvector trigger, search.service) and the PAGE_UPDATED websocket listener are
untouched. Verified: apps/server tsc --noEmit 0 errors; 6 spec suites / 52 tests green.
Ops (maintainer, out of code scope): one-time Redis cleanup
`redis-cli --scan --pattern "bull:{search-queue}:*" | xargs -r redis-cli del`, then
confirm bullmq_queue_depth{queue="search"} stays 0 (the metric no longer injects it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The patch forks createOutputTransformStream: output==null skips partialOutput
(the OOM fix, already tested), output!=null preserves the original cumulative
accumulation. Only the skip branch was tested; the preserve branch — on which the
patch's "byte-identical when an output strategy is set" safety claim rests — had no
coverage, so a future re-port (patches are re-created via `pnpm patch` on every ai
bump) could silently route output-set calls into the skip branch and leave
partialOutput empty for object/text-output consumers, uncaught.
Add a 4th test: streamText({ ..., experimental_output: Output.text() }), drain
textStream, collect experimental_partialOutputStream, and assert it is non-empty and
cumulative (last partial == full text "Hello, world!"). Reuses the existing
makeModel() harness. Verified on the patched dist: partials are
["Hello","Hello, ","Hello, world!"]. `npx jest ai-sdk-partial-output.patch.spec.ts`
→ 4 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 [WARNING] bundlePhase returned 'allInstalled' when a bundle's only
non-installed role was skipped (0 installed for it), so the collapsed green 'All
installed · up to date' header contradicted the open 'Installed 0 · 1 skipped'
plaque. It now returns 'mixed' whenever a skipped role is present. Fixed the test
that encoded the wrong behavior.
F2 [WARNING] The reason->action branch (name-conflict -> transient overlay +
'Rename & install'; already-installed -> informational, no button) lived only in
the component, untested. Extracted the two decisions into pure, unit-tested
helpers nameConflictSlugs() and partialOffersRename() and wired them into the
modal; both reason values are now covered.
F3 [low] Removed the unused useRef import (client eslint no-unused-vars is off, so
it shipped silently).
F4 [low] Extracted bundleCounts() as the single tally pass; bundlePhase and the
panel both derive from it instead of rescanning the roles array ~5x per render
(the same model<->component consolidation this PR is about).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F9 [WARNING] The line-anchored front-matter regex from round 2 requires a bare
LF after the opening `---`, so a Windows/CRLF foreign file (`---\r\n...`) slips
past the strip and leaks its front-matter into the body (where `title: Foo`
renders as a setext heading that title extraction hijacks). The canonical parser
whose regex shape this copied (page-file.ts) normalizes CRLF -> LF BEFORE its
FRONTMATTER_RE; the import path copied the regex but missed the normalization.
normalizeForeignMarkdown now replaces CRLF with LF first (which also makes
convertReferenceFootnotes' split('\n') consistent). Adds a CRLF fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integrates the designer-handoff Roles Catalog modal, wired to the real API; the
parent ai-agent-roles.tsx and the { opened, onClose, roles } contract are
unchanged.
- Server importFromCatalog now returns per-role lists (createdRoles /
skippedRoles with a reason) alongside the existing counters (compat-preserving),
so the UI can name the conflicting/installed roles.
- New pure view-model (catalog-bundle-model.ts): bundlePhase (empty | allNew |
allInstalled | updates | mixed, ignoring the transient 'skipped'),
installedLangForRole (same-slug-different-language hint), mapCatalogRoleToView —
all unit-tested without mounting.
- Bundle cards with a summary status in the collapsed header (eager useQueries
fan-out over all bundles, sharing the existing per-bundle cache keys), a single
primary action per bundle, checkboxes + select/deselect-all, an inline result
plaque that keeps the modal open, per-bundle and global 'Update all' request
series with progress, and the other-language hint.
- The partial-result plaque distinguishes the skip reason: only a name-conflict
offers 'Rename & install'; an already-installed race is informational (a rename
re-import would just skip again and self-heal into a false success).
- All strings i18n'd (en/ru); mock handoff code (SEED/mockImport/delay) removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F7 [CRITICAL] The round-1 F2(a) fix built ONE alternation regex over all
definition ids (`(id1|id2|...)`). On prefix-chain ids (a, aa, aaa, ...) V8's
regex compiler blows its stack with a fatal, UNCATCHABLE 'RegExpCompiler
Allocation failed' that kills the whole process — strictly worse than the
original per-def thread-hang, and its match cost was still O(text x defs).
Replaced with a single FIXED generic scanner `/\[\^([^\]]+)\]/g` plus a map
lookup in the replacer: genuinely O(total text), no per-document regex
compilation, cannot blow up. Output is identical (only real def ids are inlined).
F8 [WARNING] The frontmatter strip regex was not line-anchored: it closed on the
FIRST `---` anywhere, so a value containing a triple-dash (e.g.
'title: Q1 --- Q2') truncated the frontmatter and leaked the rest into the body.
Replaced with the line-anchored shape the canonical parser already uses
(page-file.ts): open on `---\n`, close on a `\n---` line.
Adds tests: 4000 prefix-chain ids do not crash and stay fast; a frontmatter
value containing '---' is stripped whole.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the round-1 review of #369:
F1 [CRITICAL] Restore prom-client. The prior commit removed it as a 'stray dep',
but metrics.registry.ts imports it unconditionally at startup (main.ts boot), so
a clean frozen install had no prom-client -> server tsc TS2307 + boot crash. It
was surviving only via hoisting from a warm store. Restored to apps/server
dependencies + regenerated the lock (prom-client/tdigest/bintrees return),
keeping the @docmost/prosemirror-markdown dep. Verified: clean frozen install ->
require.resolve('prom-client') ok, server tsc EXIT 0.
F2 [HIGH] Two quadratic ReDoS vectors in foreign-markdown.ts on untrusted import
(runs synchronously on the request thread, 30MB cap):
(a) pass-2 was O(lines x defs) — a per-def RegExp rebuilt and run over every
line. Replaced with ONE precompiled alternation regex over all def ids,
built once per document, with an id->body lookup in the replacer: O(text).
(b) the inline-code split alternation backtracks quadratically on a long
UNCLOSED backtick run. Lines over 8KB now skip the split (left untouched) —
a real footnote line is never that long.
F3 [WARNING] Restore the leading YAML front-matter strip that the retired
markdownToHtml layer did. Without it, Obsidian/Hugo/Jekyll/git-sync files leak
their front-matter into the body (and 'title:' renders as a setext heading that
title extraction can hijack).
F4 [WARNING] Extend the zip-import spec with an image (width+align) + callout
fidelity assertion through the PM->HTML->PM hop (the one hop the package suite
does not cover).
F5/F6 Update AGENTS.md (apps/server is now a prosemirror-markdown consumer) and
make the server pretest build prosemirror-markdown too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The foreign-markdown import normalizer rewrote GFM reference footnotes
(`[^id]` + `[^id]: def`) into canonical inline `^[def]` footnotes, but two
edge cases corrupted content:
1. A `[^id]` inside an inline-code span (backticks) was rewritten like prose
text — only fenced code blocks were protected. Now the rewrite pass splits
each line on inline-code spans and only touches the text outside them.
2. An unbalanced `]` in a definition body truncated the resulting `^[...]`
footnote at the canonical tokenizer, leaking the tail as literal text. The
body's square brackets are now backslash-escaped before wrapping.
Adds golden cases for both.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The step-1 package.json declared the new @docmost/prosemirror-markdown workspace
dep but the lock was not regenerated (CI frozen install would fail), and it also
added a stray prom-client dep (a coder env-workaround for a pre-existing hoisted
import, unrelated to #345 — removed). Regenerated the lock with only the
prosemirror-markdown dep; faithful frozen install now passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>