Rework the "WHERE TO WRITE THE RESULT" section of the researcher role so
the report document is created at the very beginning of the run (right
after the plan, before any searches) and filled dynamically after each
finding, instead of being dumped in one pass at the end.
- Rewrite the section identically in bundles/research/ru.yaml and en.yaml
- Bump researcher version 1 -> 2 in index.yaml
- Refresh scripts/content-hashes.json via check.mjs --update-hashes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AI chat SSE endpoints (POST /api/ai-chat/stream, GET
/api/ai-chat/runs/<chatId>/stream, POST /api/shares/ai/stream) must
bypass response buffering AND compression at every proxy in front of
the app — a compressing proxy silently buffers SSE frames until the
response closes (pending request, tokens arriving in one burst,
reloaded tabs degrading to polling). Document the affected paths, the
DevTools tell (Content-Encoding on text/event-stream), and concrete
nginx/Traefik configuration in both READMEs.
Co-Authored-By: Claude Fable 5 <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>
The #351 converter fix (79a461f7) made multi-block list items emit a blank-line
separator between block children (loose list) to stop silent content merging on
re-parse. The prosemirror-markdown goldens were updated in that PR, but the two
mcp unit tests asserting the old tight output were missed — packages/mcp
re-exports the shared converter, so they broke the develop CI run (test job,
`pnpm -r test`, 2/480 failures).
- expect "- Parent\n\n - A..." instead of "- Parent\n - A..."
- expect "1. Parent\n\n - Child" instead of "1. Parent\n - Child"
Full mcp suite: 480/480 green; editor-ext / prosemirror-markdown / client /
git-sync suites green as well.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The resumable-SSE run-stream registry (PR #386/#387) ships behind the
server-side AI_CHAT_RESUMABLE_STREAM env flag, OFF by default: with the
flag off attach always answers 204 and reopened tabs of an active run
fall back to degraded history polling. Document the flag, its default,
its relation to the per-workspace autonomousRuns setting, and the
single-instance constraint in .env.example next to the autonomous-runs
section.
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>
stabilize.test.ts used `data-align="center"` as proof the convergence pass
materialized the drawio node. Since this PR correctly stops emitting the
schema-default center align in the media builders, that marker is no longer a
reliable convergence proof. Assert on the stable canonical markers instead —
`data-type="drawio"` + `data-src="/d.drawio"` — which are always materialized
regardless of the align default. The fixpoint assertion (file2 === file1) is
unchanged; round-trip stays byte-stable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Финальная ступень #351: генератор ЦЕЛЫХ вложенных документов (random walk
по ContentMatch схемы, min-depth fixpoint для терминирования, глубина/размер
ограничены) + инвариант P4 (фазз парсера: для ЛЮБОЙ строки markdownToProseMirror
не бросает и результат валиден по схеме). Инварианты P1 (семантический
round-trip), P2 (байтовый fixpoint со 2-го прохода), P3 (тотальность) — строгие.
Генератор сразу нашёл классы реальных багов конвертера; все починены (инварианты
НЕ ослаблялись — чинился конвертер):
- loose (много-блочные) контейнеры (listItem/taskItem/callout/detailsContent)
склеивали блоки при реимпорте — ТИХАЯ ПОТЕРЯ ДАННЫХ; теперь blank-line
разделитель между блок-детьми (по образцу blockquote).
- соседние sibling-списки одного marker-family (task+bullet и т.п.) сливались
в один список с ПОТЕРЕЙ чекбокса — теперь между ними эмитится инертный
`<!-- -->` разделитель (byte-stable round-trip).
- paragraph textAlign терялся во вложенных li/td/th.
- вложенный codeBlock терял хвостовой перевод строки.
- pageBreak/pageEmbed/subpages/transclusion дропались во вложении
(blockquote/callout/details/li).
- медиа в columns: number→string ширины/высоты и лишний data-align (churn).
- callout `> [!type]`, вложенный в список/цитату, парсился неверно
(prefix-aware regex).
Golden-обновления (6) — прямые следствия loose-container фикса, каждое
round-trip'ится. 2100+ сгенерированных документов (3 seed) — 0 падений P1/P2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the TS2307 trap: the gitignored build/ of @docmost/prosemirror-markdown,
@docmost/git-sync and @docmost/mcp is not honoured by a single-package
pnpm --filter <pkg> test/tsc or a bare pnpm -r test (Nx dependsOn ^build is only
applied by nx run-many), so a consumer's typecheck fails with
Cannot find module '@docmost/...' until those packages are built first.
Mirrors the order .github/workflows/test.yml already uses.
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>
Adds a testing guide covering how to verify features against a running stand:
drive the behaviour under test through the browser (not the API), verify
out-of-band in the DB/git, and the non-obvious traps. Notably the page has two
ProseMirror editors — [aria-label='Page title'] (non-collab) and
[aria-label='Page content'] (the collab body); querySelector('.ProseMirror')
returns the title, so tests must target the body editor and wait ~10s for the
hocuspocus store debounce. Links the new doc from AGENTS.md next to dev-stand.md
and adds a matching gotcha #8 to dev-stand.md.
Co-Authored-By: Claude Opus 4.8 <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] The 'no invisible coverage hole' guard enumerated only
schema.nodes, so MARK attributes silently escaped the value-fuzz completeness
check — link.internal/target/rel/class are never fuzzed and nothing flagged it,
and a new attributed mark would slip through. Added allSchemaMarkAttrKeys() plus a
MARK_ATTR_FUZZED / MARK_ATTR_ALLOWLIST registry and two tests: every schema mark
attr must be in exactly one set (a new one turns it red), and neither set may hold
a stale row.
F2 [WARNING] The ACCEPTED annotation misclassified table colspan/rowspan as
having 'no md representation'. They DO round-trip — a spanned cell makes the
converter emit the whole table as a raw <table> with colspan/rowspan, which the
tiptap parser reads back. They are frozen only because generating a
geometrically-valid spanned table is deferred PR-2 structural work (the flat
generator hardcodes span = 1), not a markdown limit. Reclassified them as
DEFERRED-BUG (distinct from ACCEPTED) so a maintainer does not read them as an
inherent limitation; colwidth / backgroundColor(Name) stay ACCEPTED (the
raw-<table> fallback drops them).
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>
Schema-derived, property-based (fast-check) round-trip tests over flat
single-node ProseMirror documents. One test PR — src/ is untouched; the two
real bugs found are pinned as loud it.fails counterexamples, not fixed here.
- attr-arbitraries.ts: per-attribute four-state arbitraries (absent/default/
nonDefault/degenerate), attribute list sourced from schema.nodes[t].spec.attrs;
a documented override table supplies legal domains for constrained attrs and
distinguishes two frozen classes explicitly — ACCEPTED limitations (no md
representation) vs PINNED bugs (representable but dropped, tracked as
counterexamples).
- text-arbitraries.ts: hostile text corpus (ported from the existing property
test's supported-space guarantees).
- node-generators.ts: flat single-node generators + a completeness contract —
every one of the schema's 45 nodes / 12 marks is either generated or listed in
KNOWN_UNCOVERED with a reason.
- flat-roundtrip.property.test.ts: P1 (semantic round-trip via
docsCanonicallyEqual), P2 (second-pass byte fixpoint — anti GS-EDIT-REVERT),
P3 (totality), generator validity via schema.check(), and an explicit
attribute-value-coverage snapshot so the not-fuzzed set can never grow silently.
- counterexamples: column.width (% dropped on parseFloat -> P2 churn) and
orderedList.start (non-1 start renders as '1.' -> P1 loss) pinned as it.fails.
SEED=20250705, NUM_RUNS=300 per property; ~17s, no OOM (union arbitraries).
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>
Move every SERVER Markdown->ProseMirror path off the editor-ext markdown layer
(`markdownToHtml`, a second marked-based parser) onto the canonical
`@docmost/prosemirror-markdown` package, and add a foreign-markdown normalizer at
the import boundary.
Code:
- `ImportService.processMarkdown` (single `.md` upload) now parses
`markdownToProseMirror(normalizeForeignMarkdown(md))` directly — no HTML hop.
- `PageService.parseProsemirrorContent` markdown case (page create/update with
`format: 'markdown'`) same.
- `FileImportTaskService` (zip import) parses markdown with the package, then
serializes to HTML (`jsonToHtml`) so the SHARED HTML attachment / internal-link
pipeline (processAttachments + formatImportHtml + processHTML) keeps handling
`.md` and `.html` imports uniformly. The markdown PARSE — the drift source — no
longer goes through editor-ext; the PM->HTML->PM hop that follows is lossless
plumbing for attachment resolution, not a second parse.
- `canonicalizeFootnotes` stays as an idempotent #228 safety net for the HTML
path (a no-op on the already-canonical markdown output).
Normalizer (`integrations/import/utils/foreign-markdown.ts`): a TEXT pre-pass,
NOT a parser fork. The strict canonical parser does not accept GFM `[^id]`
reference footnotes (and would misread `[^id]: def` as a CommonMark link-ref
definition, silently corrupting the ref into a bogus link), so the normalizer
rewrites reference footnotes into canonical inline `^[def]` before parsing.
Callout surfaces (`:::type` and `> [!type]`) are intentionally NOT touched — the
canonical parser already accepts BOTH natively, so normalizing them would be
redundant and risk degrading its nesting/code-fence-aware handling.
Fixtures-first: foreign-markdown.spec pins the normalizer and the end-to-end
acceptance (no literal `[^id]`/`:::` leaks; re-export is canonical). The two
footnote-canonicalize specs are updated to the canonical output — the parser
assigns fresh `fn-*` ids, so they now assert by definition BODY order (still
reference-ordered, deduped, orphan-free).
FINAL CHECK: `grep -rn "htmlToMarkdown\|markdownToHtml" apps/server/src` (non
-test) is now empty — both editor-ext markdown-layer functions are gone from the
server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move every SERVER ProseMirror->Markdown path off the editor-ext markdown layer
(`htmlToMarkdown`, a second turndown-based converter) onto the canonical
`@docmost/prosemirror-markdown` package.
- `ExportService.exportPage` (page/space markdown export) and
`collaboration.util.jsonToMarkdown` (used by page.controller's markdown
responses and the AI public-share chat tool) now serialize DIRECTLY from
ProseMirror JSON via `convertProseMirrorToMarkdown` — no HTML intermediate, no
`<colgroup>` scrub (the converter emits GFM tables directly).
This is the SAME serializer the git-sync vault writer feeds, so an exported page
BODY is byte-identical to its vault representation: no more export-md vs vault-md
drift. The HTML export path is unchanged (still `jsonToHtml`).
Emitted markdown moves to the canonical forms: callouts `> [!type]` (not
`:::type`), inline footnotes `^[…]` (not `[^id]`), lossless images
` <!--img {…}-->` (editor-ext dropped width/height/align).
Fixtures-first: export-markdown.spec asserts those canonical forms and the
export==vault-by-construction equality (both call the package converter). The
one deliberate export/vault delta — export prepends the page title as an H1
while the vault carries it in frontmatter — is pinned by a test.
Test infra: declare the `@docmost/prosemirror-markdown` workspace dep; teach
jest to load its ESM build (babel-jest) and stub `@tiptap/react` (server code
imports editor-ext, whose node views reference React renderers only used in a
live browser editor — never on the server).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1: added in-app execute tests for the two wirings that ACTUALLY changed in the
migration (the contract-parity test only checks advertised schema keys, not
execute bodies): movePage forwards the newly-added optional `position` to
client.movePage (and passes undefined position + null parent when omitted); the
table trio (insert/delete/updateCell) forwards the unified `table` param
positionally. A field destructured under the wrong name would have silently
passed undefined to the client (execute is any-cast, tsc won't catch it).
- F2: rewrote the three migrated descriptions that hardcoded snake_case sibling
tool names (which the in-app camelCase layer exposes under different ids,
violating the registry's own transport-neutral-prose convention) into neutral
prose: getPage "use get_page_json" -> "use the lossless page-JSON read tool";
updatePageJson "get_page_json -> ... -> update_page_json" -> "read the page-JSON
view -> modify -> write it back", "prefer rename_page" -> "prefer the rename-page
tool"; exportPageMarkdown "import_page_markdown round-trip" -> "page-Markdown
import round-trip" (the last was a direct regress — the in-app base said the
camelCase importPageMarkdown). (stashPage's pre-existing get_page_json mention is
out of scope, per the reviewer.)
Gate: mcp build 0; ai-chat-tools.service + tool-tiers (catalog-partition) pass,
incl. the 5 new execute-wiring tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Migration ordering" section still described the OLD crash-loop-at-boot
behavior this PR removes ("Kysely refuses to start … rejected at boot"). Rewrote
it to the new two-layer model: the CI migration-order gate is the primary defense
(rename to a current timestamp), and the runtime now sets allowUnorderedMigrations
so the app applies a back-dated migration instead of crash-looping (with the note
that #ensureNoMissingMigrations still guards a removed applied migration, and that
migrations must stay independent since apply order can differ across instances).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1: added '/icons/' to STATIC_PATH_PREFIXES — public/icons/ is copied verbatim
to client/dist (a sibling of the already-included brand/vad/locales), and
index.html references /icons/favicon-*.png on every page load, so those requests
were getting their own route labels instead of collapsing to `static`.
- F2: corrected the comment — only /assets/ is content-hashed (unbounded per
deploy); /vad//brand//locales//icons/ have stable names (repetitive, not
unbounded). Either way none belong in the API-route histogram.
- F3: the negative test now exercises the trailing-slash boundary (the actual
anti-false-collapse guard): '/assets' (no slash), '/assetsx/foo.js',
'/iconset/x.png' must NOT collapse to `static` — cases that a buggy
includes()/slashless-prefix impl would wrongly collapse. Plus '/icons/*' added
to the positive it.each.
Gate: server tsc 0; metrics.spec passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>