Commit Graph

2284 Commits

Author SHA1 Message Date
agent_coder 58f13a7efd chore(search): retimestamp ru_en migration to 20260707T130000 to avoid collision (#529)
Develop introduced two migrations at the exact 20260707T120000 stamp
(ai-chat-metadata, page-history-kind). Three files sharing one timestamp
is fragile for deploy ordering, so move #529's ru_en-config migration to a
strictly-later, unique 20260707T130000 so it orders cleanly last. It touches
only the ru_en text-search config (different tables), so running it after the
develop migrations is order-independent and safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:37:07 +03:00
agent_coder 2bb48f841f fix(search): S1/W1/W3/W4/S3/S5/S2 — required-term details, CAP note, restored guards (#529)
S1: fetchDetails now builds rank/highlight/matchedFields from the same
[...positive, ...required] set matchedTerms uses, so a required-only query
(`+кофейня`) no longer reports matchedFields:[] / rank:null.

W1: documented that CANDIDATE_CAP bounds pagination reachability (JS-side), not
query compute — a SQL LIMIT would corrupt the exact permission-filtered total.

S2: one-line note that literal-`%`/`_` search was intentionally dropped (total:0).

W3: restored the trgm-index EXPLAIN guard (search-lookup-explain.int-spec) that
asserts the coalesce-free substring predicates use idx_pages_*_trgm, not Seq Scan.

W4: added an integration ordering test proving title-exact > title-substring >
text-only tier dominance under RRF.

S3: added a test that an exact-title hit survives a tiny CANDIDATE_CAP window.

S5: server pretest now also builds @docmost/mcp so the ToolWriteClass import
resolves from clean CI (mirrors editor-ext/prosemirror-markdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:37:07 +03:00
agent_coder daa96eb132 fix(search): B1/W2 — honest page_embeddings.fts swap + true idempotency (#529)
BLOCKER B1: the fts config swap no longer blindly DROP+ADDs the generated
column. swapEmbeddingsFtsConfig now (1) reads the column's actual generation
expression from pg_catalog and TRUE-no-ops when it already references the target
config (real out-of-band escape hatch), and (2) gates the inline ACCESS
EXCLUSIVE rewrite behind SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE (default 'true';
only a literal 'false' opts out), warning that the operator owns the swap.
up() now creates ru_en only when missing (drop-recreate would fail on the fts
hard dependency on a re-run), so up() is genuinely idempotent. down() guards the
config DROP when fts still references ru_en (gated-off path), staying non-fatal.

W2: corrected the header — Kysely runs EACH migration in its own transaction
(not the whole set in ONE); the single-UPDATE atomicity conclusion is unchanged.
Rewrote the runbook: inline path is a full-table ACCESS EXCLUSIVE rewrite of
page_embeddings; removed the false "IF-EXISTS no-op" claim.

Extends the roundtrip test with idempotent-skip (2nd up() no-op) and env-gate
=false (embeddings rewrite skipped, pages.tsv still swapped) assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:37:07 +03:00
agent_coder 4c5230d677 test(search): A1 migration down()/up() reversal roundtrip (#529)
Asserts the ru_en migration is reversible in the correct order: down()
reverts pages.tsv trigger + page_embeddings.fts to english BEFORE dropping
the ru_en config, and up() re-applies it idempotently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:37:07 +03:00
agent_coder 50ca27c5d4 feat(search): A9 web-UI — superset types, match/pagination params, operator hint (#529)
- search.types.ts: IPageSearch is the #529 superset (pageId/snippet/score/
  path/matchedFields/matchedTerms; rank/highlight nullable); add the
  IPageSearchResponse pagination envelope and match/limit/offset params.
- search-spotlight.tsx: operator hint ("exact phrase", +required, -excluded).

The web list already reads response.items, so it picks up the new OR order +
superset with no code change; icon/space/highlight remain (acceptance #12).
Full spotlight pagination UI is deferred within this PR — the API + MCP fully
support total/hasMore/offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:37:07 +03:00
agent_coder de25c258f9 feat(search): A9 MCP — operators, match, pagination in the search tool (#529)
- client/read.ts search(): forward match + offset; surface the pagination
  envelope (total/hasMore/truncatedAtCap/offset) when the #529 server
  returns it (a stock upstream leaves them undefined).
- index.ts search tool: document OR-default + RU/EN morphology, the
  "phrase"/+/- operators, match modes, limit+offset pagination and the
  relevance-CAP unreachable-tail caveat; add match + offset params.
- server-instructions.ts: READ prose + inventory line updated to the new
  contract, enforced by a new tool-inventory.test.mjs assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:37:07 +03:00
agent_coder 7007f6bcf9 feat(search): A2-A9 — unified OR-relevance engine, RRF, pagination (#529)
Rewrite SearchService.searchPage into ONE engine for web-UI, MCP agent and
share:

- A2 server-side query parser (search-query-parser.ts): quote-aware
  tokenizer, leading +/- operators (internal -,.,: literal), "phrase",
  metachar-stripped bare terms; tsquery built as a parameterized AST via
  SQL ||/&&/!! (never string-concat). only-negation/empty short-circuit.
- A3 match=auto routes identifier-like terms (10.31.41, esp32,
  WB-MGE-30D86B) to the substring/trigram branch, words to FTS; word/
  prefix/substring overrides.
- A4 RRF (k=60) fuses the FTS branch (ts_rank_cd) and substring branch
  (title-exact>title-sub>text tier) by RANK; ORDER BY rrf DESC, id.
- A5 exact permission-filtered total (fail-closed via filterAccessiblePageIds
  with the #348 hasRestricted fast-path), CANDIDATE_CAP fusion window,
  offset/limit, hasMore, truncatedAtCap.
- A6 single path (spaceId/share/creatorId/titleOnly/parentPageId/match);
  share uses getPageAndDescendantsExcludingRestricted.
- A7 response superset per hit (id/pageId/slugId/icon/title/space/…/rank/
  highlight/snippet/path/score/matchedFields/matchedTerms).
- A8 buildAncestorPaths now skips deleted + cross-space ancestors.
- A9 DTOs: match, offset, total/hasMore/truncatedAtCap/query/matchedFields.

SEARCH_MODE=or|and toggles the parser; SEARCH_CANDIDATE_CAP tunes the
window. Legacy lookup unit/int specs replaced by parser unit tests + a
13-criteria integration spec on real pg (incl. a permission mutation guard
and a fail-closed propagation test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:35:24 +03:00
agent_coder 4bf06c68cf feat(search): A1 — ru_en text-search configuration + config swap (#529)
Introduce a `ru_en` FTS configuration (english_stem over ascii token
classes, russian_stem over Cyrillic ones) and flip every stored + query
side to it IN LOCKSTEP (acceptance #13):

- new migration creates ru_en, swaps the pages.tsv trigger + reindexes
  existing rows (row-lock UPDATE, no ACCESS EXCLUSIVE), and swaps the
  page_embeddings.fts generated column (documented rewrite/lock trade-off
  for large tenants, mirroring the #443 trgm migration). down() reverts
  tsv/fts to english BEFORE dropping the config (dependency order).
- page-embedding.repo.ts hybridSearch query config english -> ru_en, so
  the RAG lexical leg's query config matches its fts column config.
- search.service.ts current query literals english -> ru_en so the column
  and query configs stay paired (this commit is independently revertable;
  the engine itself is rewritten in the A2-A9 commit).

The reindex is atomic within the single migration transaction (this repo's
Migrator wraps all pending migrations in one tx), so no morphology-desync
window exists and no dual-config read path is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:35:24 +03:00
vvzvlad af44736fb9 Merge pull request 'fix(mcp): drawio — авто-стрип XML-комментариев (гейт на well-formedness) вместо lint-ошибки (#505)' (#545) from fix/505-drawio-strip-comments into develop
Reviewed-on: #545
2026-07-12 17:35:01 +03:00
vvzvlad 2a4d1acfd7 Merge pull request 'fix(prosemirror-markdown): insertNode дописывает пункт в существующий список вместо второго списка (#535)' (#537) from fix/535-list-seam-coalesce into develop
Reviewed-on: #537
2026-07-12 17:34:41 +03:00
vvzvlad 11eb87d58a Merge pull request 'docs(server): getPageBreadCrumbs — предки не утекают (нисходящее наследование ограничений), задокументировано (#471)' (#550) from docs/471-breadcrumb-permission into develop
Reviewed-on: #550
2026-07-12 17:33:55 +03:00
vvzvlad f2c8aa70f3 Merge pull request 'fix(mcp): внятная ошибка на недоступный spaceId вместо «Space permissions not found» (#534)' (#536) from fix/534-mcp-spaceid into develop
Reviewed-on: #536
2026-07-12 17:33:39 +03:00
vvzvlad 661ea8ba07 Merge pull request 'fix(client): reconnect-вход — таймаут-гонка вокруг getRun, hang не залипает FSM в streaming (#541)' (#543) from fix/541-reconnect-timeout into develop
Reviewed-on: #543
2026-07-12 17:32:07 +03:00
vvzvlad 059edccb64 Merge pull request 'test(mcp): hardBreak уже представим в md-каноне (#293) — регресс-гард, закрытие #503' (#544) from fix/503-hardbreak-canon into develop
Reviewed-on: #544
2026-07-12 17:31:55 +03:00
vvzvlad 693a9a350b Merge pull request 'perf(ai-chat) волна C: инкрементальный рендер стрима + append-персист таблица шагов (#492)' (#547) from feat/492-incremental-render into develop
Reviewed-on: #547
2026-07-12 17:31:40 +03:00
agent_coder 8503ff1f3d fix(ai-chat): hydrate crashed mid-run steps for model replay + cover write/read seams (#492)
F1: the server model-replay loaded history via findAllByChat().map(rowToUiMessage)
WITHOUT hydrating parts from ai_chat_run_steps. A HARD crash mid-run (SIGKILL/OOM)
fires no terminal callback, so the assistant row stays parts:[] and its partial
tool-calls/results/text (durable in the steps table) dropped out of the model's
next-turn context. Hydrate needy assistant rows (role==='assistant' &&
!rowHasInlineParts) via findByMessageIds + hydrateAssistantParts before the replay
map — mirroring the controller's withReconstructedParts exactly — guarded on the
optional repo. Fix the now-false interrupt-resume comment.

F2: add a service int-spec that drives the REAL onStep append-persist WRITE branch
through AiChatService.stream with a real AiChatRunStepRepo injected, asserting the
per-step rows' stepIndex + parts slice and the step-marker metadata match a
single-row flush (catches an stepsPersisted-1 off-by-one).

F3: add a controller int-spec that drives withReconstructedParts through getMessages
WITH the repo present (a mid-run marker-only row + its step rows), asserting the
reconstructed metadata.parts and workspace-scoping.

F4: remove the dead countByMessage (zero prod callers; reconstructRunParts derives
stepsPersisted inline) + its now-unused sql import and the redundant test assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:50:50 +03:00
agent_coder 1f2999e5ad fix(mcp): drawio — авто-стрип XML-комментариев вместо lint-ошибки (#505)
Модель органически вставляет `<!-- ... -->` в mxGraph-XML вопреки запрету
в описании тула (природа LLM, промптингом не лечится). Жёсткая ошибка
линтера [no-comments] заставляла её ПОЛНОСТЬЮ перегенерировать диаграмму —
впустую потраченный tool-call на почти каждой первой генерации.

Теперь в общем prepare-пути (`prepareModel`, через который идут
drawioCreate/drawioUpdate/drawioEditCells) комментарии срезаются ДО линта:
`xml.replace(/<!--[\s\S]*?-->/g, "")`. Комментарии не несут семантики, так
что стрип безопасен. Число срезанных уходит в `warnings[]` как
`stripped N XML comment(s)` (только при N > 0) — модель это видит, но НЕ
ретраит.

Стрип ГЕЙТИТСЯ двумя условиями, чтобы регэксп бил только по настоящим
comment-нодам:
  1. well-formedness — сначала парсим модель; на malformed-входе (сырой
     неэкранированный `<!--` внутри значения атрибута на пути create/update,
     где normalizeInput отдаёт строку без парсинга) стрип НЕ выполняется,
     иначе он молча вырезал бы текст автора. Вход отклоняют правила
     value-escaping / well-formed-xml, автор видит реальную ошибку.
  2. отсутствие CDATA — CDATA-секция well-formed, но её текст может содержать
     литеральный `<!-- ... -->`, который НЕ является comment-нодой; стрип
     молча удалил бы контент автора. Настоящий drawio держит подписи в
     атрибутах `value=` (CDATA невозможен), так что исключение CDATA бесплатно
     на легитимных моделях; CDATA-модель с реальным комментарием падает на
     сохранённый backstop no-comments (явная ошибка), а не тихо портится.

Правило `no-comments` в линтере оставлено как defense-in-depth backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:43:03 +03:00
agent_coder 03af65dd06 Merge remote-tracking branch 'gitea/develop' into fix/503-hardbreak-canon 2026-07-12 06:12:53 +03:00
agent_coder dead5fa8a8 test(#503): guard hardBreak representability through the MCP canon
Issue #503 reported a hardBreak sent via updatePageJson being «silently
cut» — no error, no line break — forcing the agent to split lines into
separate paragraphs.

A faithful repro shows the bug does NOT reproduce against develop:
hardBreak is fully representable in the markdown canon in BOTH directions
and survives the real Yjs write path.

- pm -> md: the serializer (now in @docmost/prosemirror-markdown after the
  #293 STEP 5 consolidation) emits the CommonMark hard break `  \n`, not a
  bare `\n`.
- md -> pm: marked tokenizes `  \n` to `<br>`, which generateJSON parses
  back to a hardBreak node in ONE paragraph (not split, not dropped).
- updatePageJson (PM JSON -> applyDocToFragment = PMNode.fromJSON +
  updateYFragment, the real collab-session write encoder -> read back)
  keeps text + hardBreak + text in one paragraph.

The issue's diagnosis points at packages/mcp/.../markdown-converter.ts as
the serializer, but that file is a 15-line re-export shim since #293 STEP 5
— the diagnosis predates the converter consolidation that already closed
both sides. P1 (semantic round-trip) + P2 (byte-fixpoint) hold, and the
node already has broad property/corpus/golden coverage in the package.

No converter change is warranted (switching the break form to `\`+newline
would churn the whole #351 byte-fixpoint corpus against the established
`  \n` repo convention for zero functional gain). This adds one
integration guard at the MCP canon seam covering all three seams of the
reported updatePageJson path. Mutation-verified: neutering the serializer
arm or the importer `<br>` handling reddens seams 1-2; neutering the real
applyDocToFragment write path reddens seam 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:04:18 +03:00
agent_coder 5b17503df7 docs(page): explain breadcrumb ancestors need no per-ancestor permission filter (#471)
getPageBreadCrumbs returns the full ancestor chain filtered only by
deletedAt; the /breadcrumbs endpoint validates validateCanView on the
target page only. Document why this is safe rather than an accepted leak:
page restrictions inherit down the tree, so viewing a page implies the
right to view every ancestor (canUserAccessPage checks the full ancestor
chain). A hidden ancestor would already hide the target itself, making
per-ancestor filtering redundant. Owner decision: document, no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:58:48 +03:00
agent_coder 46bb55dbd1 Merge remote-tracking branch 'gitea/develop' into fix/534-mcp-spaceid 2026-07-12 05:58:05 +03:00
agent_coder 8b34a428f4 fix(mcp): unconditional final ERROR_MESSAGE_CAP backstop in formatSpaceNotAccessible (#536 review)
The list-only cap assumed a well-formed prefix; a pathologically long
agent-supplied spaceId (unvalidated for length) lives in the prefix and bypassed
the cap. Add the same unconditional final slice formatDocmostAxiosError uses, so
the WHOLE message is bounded regardless of spaceId length. No-op for normal
inputs (36-char UUID) where the list cap already keeps it <= 300.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:58:04 +03:00
agent_coder e236782260 fix(mcp): cap #534 space-not-accessible message + cover fail-open guards
Review follow-up (#536).

F1 (test coverage): add two tests that pin the wrapper's fail-open guards, which
previously survived mutation:
- a non-404 error (500) on a wrapped call with a spaceId propagates unchanged and
  triggers NO /spaces sweep (a real 5xx/403 must never be swallowed/reformatted);
- a 404 when the spaceId IS in the accessible index fails open (the 404 is about
  another resource), so it is not falsely rewritten to "not found among spaces".
Both mutation-verified: forcing the non-404 condition to false reddens the first;
removing the id-present guard reddens the second.

F2 (conventions): formatSpaceNotAccessible now honours ERROR_MESSAGE_CAP (300),
the same budget formatDocmostAxiosError enforces. Only the interpolated space
list is truncated (with an ellipsis); the fixed prefix (bad spaceId) and suffix
(listSpaces pointer) are always kept, so the actionable parts survive. Test: 10
long space names -> message <= 300 and still contains the spaceId + listSpaces.
Adjusted the existing list-cap test to short ids/names so it exercises the 10-item
cap + "(+N ещё)" tail below the length cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:49:31 +03:00
agent_coder ae3dfd8de6 Merge remote-tracking branch 'gitea/develop' into feat/492-incremental-render 2026-07-12 05:34:07 +03:00
agent_coder 515a1aaf1d Merge remote-tracking branch 'gitea/develop' into fix/535-list-seam-coalesce 2026-07-12 05:22:26 +03:00
agent_vscode 03eafa6c68 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 05:16:51 +03:00
agent_vscode a42f1ead48 test(ai-chat): align #332 deferred-tool test with #490 cross-turn persistence
The #332 integration test (ai-chat-stream.int-spec.ts) asserted a deferred
tool activated in one turn does NOT leak into the next ("cold start per
turn"). #490 (f5bbfdb2) deliberately reversed that: it persists the
activation set into chat metadata.activatedTools and seeds the next turn
from it, so the model need not re-run loadTools. The test only surfaced now
because the token-estimate CI fix let the integration step run again.

Adopt #490 persistence as the intended behavior:
- flip the turn-2 assertion to expect createPage IS active on the fresh
  turn's first step (seeded from metadata); keep turn-1 cold-start assertions.
- rewrite the test docstring, describe/it titles and comments accordingly.
- fix two stale "not persisted / per-turn" comments in ai-chat.service.ts
  (prepareAgentStep + the streaming-loop activation block); no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:16:47 +03:00
vvzvlad b7a3ec227d Merge pull request 'fix(drawio): content= — entity-XML вместо base64 (кириллица в редакторе) (#507)' (#521) from fix/507-drawio-cyrillic into develop
Reviewed-on: #521
2026-07-12 05:08:57 +03:00
agent_coder dd1fe90515 fix(prosemirror-markdown): guard three-way list coalesce against transitive style loss (#535)
Review follow-up on the list-seam coalescing:

1. The three-way merge only checked each PRE-EXISTING list against the
   inserted one. A default-typed inserted orderedList between two lists with
   explicit DIFFERENT numbering styles passed both pairwise checks through the
   null-typed middle, collapsing all three and silently losing the right
   list's style. Add a `listsMergeable(left, right)` guard to the three-way
   condition. On failure fall through to the single-seam path: a single
   inserted block can be absorbed by at most one neighbour, so prefer the LEFT
   seam (consistent with the three-way survivor choice) and leave the
   incompatible right list separate with its own style.

2. Lock the footnotesList exclusion with a test — inserting a footnotesList
   next to a footnotesList must leave TWO separate blocks (a refactor of the
   allow-list to endsWith("List") would otherwise silently merge them and
   corrupt footnotes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:06:54 +03:00
agent_coder 846341d7d4 fix(drawio): encode literal tab/newline/CR in content= as numeric char-refs (#507 review)
Review follow-up to the base64→entity-XML content= switch. The whole mxfile XML
now lives in one content="..." attribute; XML attribute-value normalization
collapses a LITERAL tab/newline/CR to a single space on DOM read (jsdom and the
real draw.io editor alike), silently flattening multi-line labels and
tab-bearing values that the old base64 form stored verbatim.

Finding 1 (data-loss): both encode paths — buildDrawioSvg's xmlEscape (mcp) and
the import service's escape — now append &#x9;/&#xa;/&#xd; after the four
&<>" replaces (numeric char-refs survive normalization, as draw.io's own export
does). The extractContentAttr regex fallback now decodes those char-refs (hex
case-insensitive plus decimal &#9;/&#10;/&#13;) so it agrees with the DOM path;
&amp; stays decoded last so an escaped &amp;#x9; reads back as literal text.

Finding 2 (dedup): the server's private xmlEscapeAttr is replaced by the shared
htmlEscape helper (& < > " ' — a strict superset, the extra ' is harmless in a
"-delimited value) wrapped in xmlEscapeContent, which adds the three control-char
char-refs on top (htmlEscape does not escape them).

Finding 3 (docs): narrow the CHANGELOG healing claim — only a diagram still
holding its original correct-UTF-8 base64 (not yet opened/autosaved) is
recoverable; one already opened in the editor persisted mojibake at rest and its
text is lost.

Tests: new mcp round-trip test with literal tab/newline/CR in a value (DOM path,
byte-stable) plus a fallback-branch test forcing a malformed wrapper so both
decode paths are proven to agree; new server spec asserting char-ref encoding.
Mutation-checked: dropping the encode replaces reddens both new mcp tests;
dropping only the fallback decode reddens just the fallback test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:05:52 +03:00
agent_coder 32e10ca6d3 fix(drawio): content= пишется entity-XML, не base64 — кириллица не мойбейкает в редакторе (#507)
Реviewer-filed баг: draw.io-редактор декодит base64 content= как Latin-1
(atob-семантика) → кириллица разваливается в мойбейк, автосейв редактора
персистит порчу и убивает превью. Нативная форма draw.io — entity-encoded
mxfile-XML (content="&lt;mxfile…"), DOM-декодится как UTF-8.

Фикс двух write-путей: buildDrawioSvg (mcp, все create/update) и
createDrawioSvg (server Confluence-импорт) теперь XML-эскейпят content=
вместо base64. Декодер уже различает startsWith("&lt;") vs base64 — обе формы
читаются, старые base64-файлы открываются (back-compat), byte-stable
round-trip. CHANGELOG + заметка про лечение старых диаграмм (drawioGet→Update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:05:52 +03:00
vvzvlad 74d212cfd3 Merge pull request 'feat(auth): серверная API-key аутентификация агентов на /mcp — фаза 1 (#501)' (#526) from feat/501-mcp-apikey-auth into develop
Reviewed-on: #526
2026-07-12 05:02:51 +03:00
agent_coder c96fafc4ad perf(ai-chat): append-персист шагов — per-step INSERT вместо переписи строки (#492)
Раньше каждый onStepFinish переписывал ВСЮ строку ассистента (растущий
metadata.parts jsonb со всеми выводами инструментов) → O(n²) объёма записи
на прогон: под MVCC/TOAST апдейт jsonb переписывает всю версию строки, так
что шаг k пишет ~k×вывод. Прогон из 50 шагов по ~100 КБ = сотни МБ WAL и
мёртвых кортежей за ход, что молотит autovacuum. (#490 убрал только ВТОРУЮ
копию в tool_calls; сам metadata.parts всё ещё рос и переписывался.)

Теперь каждый завершённый шаг ДОПИСЫВАЕТСЯ отдельной строкой в лёгкую
таблицу ai_chat_run_steps (только парты этого шага), а строка сообщения
получает дешёвый маркер (stepsPersisted + toolTraceVersion, без растущего
блоба parts). Полный metadata.parts собирается ОДИН раз на финализации.
НЕ jsonb-append (||): апдейт всё равно переписывает всю TOAST-версию —
экономится только сетевой payload, а WAL/мёртвые кортежи остаются; поэтому
именно ОТДЕЛЬНАЯ таблица + INSERT.

Три обязательные интеграции:
- reconstructRunParts(row, stepRows) → { parts, stepsPersisted }: единый
  шов переключения бэкенда. Читает парты из СТРОКИ, если она уже несёт
  inline-parts (старые записи + ЛЮБАЯ финализированная), иначе из ТАБЛИЦЫ
  ШАГОВ (mid-run запись #492). Дискриминатор — наличие непустого
  metadata.parts (флаг схемы не нужен). Потребители (attach-seed,
  delta-poll, export, reconnect) прогоняют строки через hydrateAssistantParts
  на границе чтения — их контракт/вывод не меняется, старые и новые записи
  восстанавливаются идентично.
- сигнал ротации кольца реестра #491 (confirmPersistedStep) теперь стреляет
  на подтверждённый INSERT шага, под тем же контрактом (updateStreaming
  возвращает stepsPersisted / null).
- era-marker toolTraceVersion (#490) больше не ставится полной переписью —
  ставится в маркере шага и на финализации (flushAssistant), остаётся
  консистентным.

Полная обратная совместимость: прогон, записанный по-старому (полная строка,
без строк шагов), восстанавливается/attach/export идентично. При отсутствии
репозитория шагов (позиционные тест-конструкции) — фолбэк на прежний
полнострочный flush (без регрессии, только без выигрыша WAL).

Тесты (реальный pg, int-lane):
- WAL-дельта (pg_current_wal_lsn) на прогоне 40×100КБ: new=4.3МБ vs
  old=90.3МБ (20.8x) — O(Σ шагов) против O(n²); старый путь в тесте И есть
  ревертнутое поведение (мутация-проверка).
- reconstruct-контракт: new-style (таблица шагов) и old-style (inline) прогоны
  восстанавливаются в идентичные parts; hydrate заполняет строку.
- миграция up/down roundtrip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:02:25 +03:00
vvzvlad 9a6389133b Merge pull request 'fix(page-tree/realtime): insertByPosition теряет непоказанных детей (#525)' (#531) from fix/525-insert-unloaded into develop
Reviewed-on: #531
2026-07-12 05:01:38 +03:00
agent_vscode 0b8497e496 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 04:53:26 +03:00
agent_vscode 433252bdb1 fix(ci): build @docmost/token-estimate before its non-nx consumers (#490)
The shared @docmost/token-estimate package (main: ./dist/index.js, dist/
gitignored) was never built in the CI jobs that bypass nx, so develop CI
went red:
- test.yml `test` (pnpm -r test): client vitest failed to resolve the import.
- develop.yml `e2e-server` (direct jest e2e): server history-budget.ts hit
  TS2307 Cannot find module '@docmost/token-estimate'.
The nx-driven jobs (build, e2e-mcp) stayed green via dependsOn: ^build.

- test.yml: add "Build token-estimate" step to the `test` job.
- develop.yml: add "Build token-estimate" step to the `e2e-server` job.
- Dockerfile: ship packages/token-estimate/dist + package.json into the
  installer stage — apps/server depends on it (workspace:*) and imports it
  at runtime, so the prod image would otherwise crash with
  ERR_MODULE_NOT_FOUND (masked so far because publish/smoke never ran).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:53:22 +03:00
agent_coder be70bd2e8e Merge remote-tracking branch 'gitea/develop' into fix/541-reconnect-timeout 2026-07-12 04:37:47 +03:00
agent_coder 2f8c5d9a98 perf(client): инкрементальный рендер стрима ответа (#492)
Путь ответа ассистента теперь рендерится инкрементально, по образцу
StreamingPlainText из ветки reasoning (#492, волна C эпика #497).

Раньше MarkdownPart прогонял ВЕСЬ накопленный ответ через канонический
конвейер (markdownToProseMirrorSync → PMNode.fromJSON → DOMSerializer →
DOMPurify) на КАЖДОМ throttled-тике (~20 Гц). На синтетическом потоке
~100 КБ это 394 вызова renderChatMarkdown — O(числа тиков), причём каждый
вызов заново парсит всю растущую строку.

Теперь:
- StreamingMarkdownText делит текст на блоки по безопасному срезу
  (splitPlainChunks — та же append-only-инвариантность, что у reasoning):
  СТАБИЛИЗИРОВАННЫЕ блоки идут через канонический конвейер и мемоизируются
  (каждый блок парсится РОВНО ОДИН раз), живой ХВОСТ — дешёвый plain-text
  (React-escaped, без парсера/санитайзера/innerHTML) до стабилизации.
- На финализации (флип state → done или конец хода) — ОДИН полный
  канонический рендер всего текста: побайтовая визуальная паритетность с
  прежним выводом (включая <li><p>-обёртки схемы и scoped-CSS из #498).
- Гейт liveness тот же, что у ReasoningBlock: streaming =
  turnStreaming && part.state === "streaming".

Также цикл рендера частей переведён на ИСЧЕРПЫВАЮЩИЙ switch по видам частей
с never-проверкой в default (вместо прежнего WARNING-комментария): новый
закрытый вид части в UIMessagePart теперь ошибка компиляции.

Тесты:
- perf-smoke: на ~100 КБ потоке число вызовов renderChatMarkdown ≤ blocks+2
  и ≪ ticks (O(блоков), не O(тиков)); мутация (снять memo с MarkdownChunk)
  краснит ассерт (78631 вызовов).
- visual-regression: финальный рендер побайтово равен renderChatMarkdown
  всего текста (в т.ч. <li><p>), инкрементальный вид сходится к нему на
  финализации; учтён neutralizeInternalLinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:35:06 +03:00
vvzvlad 6f81067f4d Merge pull request 'feat(#370 PR-1): ядро версий страниц — kind + ручной Save/idle/boundary триггеры' (#374) from feat/370-page-versioning into develop
Reviewed-on: #374
2026-07-12 04:33:39 +03:00
agent_coder 55e8f61b3c Merge remote-tracking branch 'gitea/develop' into feat/501-mcp-apikey-auth 2026-07-12 04:33:17 +03:00
agent_coder 69e04349a0 fix(client): вход в reconnect ограничивает ожидание getRun таймаутом (#541)
При живом обрыве SSE вход в лестницу reconnect происходил только
после резолва getRun(cid) (ре-сид из персиста). Путь REJECT
обрабатывался через .catch, но ЗАВИСШИЙ getRun (соединение есть,
ответа нет) был не ограничен: axios-клиент (lib/api-client.ts) не
имеет timeout, а stalled-idle-cap взводится только ПОСЛЕ входа в
reconnecting/polling. Итог — FSM залипала в `streaming` без баннера
и без поллинга до сокет-таймаута браузера (минуты). Это тот самый
класс тихого зависания, который устраняет эпик #497.

Оборачиваю ожидание getRun гонкой с таймаутом
(RECONNECT_RESEED_TIMEOUT_MS = 4s): по таймауту берётся ТОТ ЖЕ
фолбэк, что и на reject — dropLivePartialAndReplayFromStart() +
enterReconnect(runId), так что лестница/поллинг стартуют и
stalled-idle-cap взводится. Локальный флаг `settled` делает ветви
resolve/reject/timeout взаимоисключающими: поздний резолв getRun
после сработавшего таймаута полностью игнорируется (не входит в
reconnect повторно, не перетирает replay-from-start устаревшим
ре-сидом, ничего не перевзводит). Таймер живёт в reseedTimerRef и
очищается при размонтировании (никаких висящих setTimeout).

Тесты: hang-кейс (getRun не резолвится -> по таймауту FSM в
reconnecting, live-partial сброшен, replay-from-start) и
late-resolve safety (поздний резолв — no-op). Мутация: замена гонки
на голый getRun краснит оба #541-теста.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:29:25 +03:00
agent_vscode ab133fa0d0 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 04:24:56 +03:00
agent_coder d42ca8dc57 test(auth): cover collab-token api-key arm-seam; docs + dead-code cleanup (#501)
Hardening follow-ups from PR #526 review (no defect fixes):

- DO1: add auth.controller.spec tests for the collab-token handler, the
  arm-seam of the anti-laundering defense. Assert getCollabToken is invoked
  with { apiKeyId } only when the SIGNED req.raw marks an api_key principal,
  and undefined otherwise (session, missing apiKeyId, or a spoofed body).
  Verified non-vacuous: nulling the ternary reddens the ARMED test.
- DO2: document the API_KEYS_ENABLED kill-switch in .env.example next to the
  other feature flags (default ON; strict true/false; OFF denies api-key auth
  and 404s the management endpoints).
- DO3: remove dead bindAccessJwtVerifier (+ its now-orphaned AccessJwtVerifier
  interface and its dedicated spec block); prod switched to
  bindMcpBearerVerifier. verifyBearerAccess is retained (still used).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:19:52 +03:00
vvzvlad 25a31e6c0d Merge pull request 'perf(ai-chat): resume-стек #491 — восстановление в develop (осиротел при stacked-мерже #518)' (#540) from feat/491-resume-stack into develop
Reviewed-on: #540
2026-07-12 04:16:54 +03:00
agent_coder 0af7eb30c3 docs(page-tree): correct isUnloadedBranch comment — no DnD-guard consumer (#525 review)
The comment falsely listed a 'DnD move guard' consumer; no DnD path routes
through the predicate (local DnD/create-page use the raw index-based insert).
List the real consumers (handleToggle + realtime insertByPosition/placeByPosition)
and note the local raw-insert path as a #525 follow-up. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:13:49 +03:00
vvzvlad 9a94c8df18 Merge pull request 'perf(ai-chat): дисциплина данных — дедуп tool-outputs + токен-бюджет реплея (#490)' (#510) from feat/490-data-discipline into develop
Reviewed-on: #510
2026-07-12 04:11:08 +03:00
agent_coder 3550bfa411 fix(ai-chat): ре-ревью #518 — CI-фиделити delta, attach-дубль, чистки (#491)
Ребейз на обновлённый feat/490 (PR #510, Option-A): стек #491 перенесён с
6e872f2d на 6e42752a; run-fsm.ts/chat-thread.tsx приехали из develop-версии
(W1/S4: RUN_ALREADY_ACTIVE несёт activeRunId + supersede-CAS адопция) — мои
#491-правки легли сверху без конфликтов, W1 НЕ откачен (run-fsm.ts == develop
байт-в-байт; RUN_ALREADY_ACTIVE диспатчит activeRunId).

1. [CI-fidelity] Дельта-курсор спек падал в CI unit-лейне: `.spec.ts` дефолтил
   на НЕмигрированный docmost → 5/6 ERROR relation does not exist (skip-гард ловит
   только connection-fail). Переименован в `*.int-spec.ts` (исключается из unit-
   regex `.spec.ts$`, гоняется в test:int, чей global-setup мигрирует docmost_test)
   + DSN по умолчанию → docmost_test. Теперь 6/6 реально исполняются в CI-верном
   окружении; overlap-мутация роняет RACE-тесты (не вакуумен).

2. [regression #137/#161] attach: отсутствие `n` схлопывалось в frontier 0 →
   finished-неротированный ран (coverageFloor 0) отдавал ВЕСЬ tail вместо 204;
   парамслесс/легаси-вкладка допишет полный replay → дубль. Различаем ОТСУТСТВИЕ
   `n` (null — не tail-aware) от `n=0` (tail-aware): контроллер шлёт null при
   missing/invalid; registry.attach(n: number|null) 204-ит finished-ран при
   n===null (старый `finished && !expectLive` гейт), n=0 по-прежнему отдаёт хвост.
   Тесты (registry + controller) + mutation-verify: нейтрализация гейта роняет их.

4. [conventions] Ring-кап env-var переименован RUN_STREAM_MAX_BUFFER_BYTES →
   AI_CHAT_RUN_STREAM_MAX_BUFFER_BYTES (префикс как у сиблингов) + запись в
   .env.example (дефолт 4MB, 0/invalid→дефолт, subscriber-cap=2×).

5. [docs] run-fsm.spec.md: item4 переписан («реализовано в #491 — дельта несёт
   run:{id,status}|null; клиент run-поле ещё не потребляет») + добавлена строка
   перехода POLL_IDLE_CAP stopping→idle (Review #4, редьюсер это делает).

6. [simplification] Удалена мёртвая цепочка reconstructRunParts /
   reconstructPartsFromRow (ноль прод-вызовов) + опц. messageRepo-инъекция в
   AiChatRunService + спек-блоки; вернётся с первым реальным вызывателем. Маркер
   metadata.stepsPersisted (реально используемый) сохранён.

DROP-пункты ревьюера (осиротевший import, DELTA_POLL_MAX_ROWS) не трогаю.
Прогон: server tsc 0, ai-chat unit 202, delta-int 6/6 (int-lane), int attach 6/6,
client vitest 403, tsc client 0 ai-chat, mcp 834/0. FSM-инварианты #488 сохранены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder a0ecf21cb5 fix(ai-chat): ре-ревью #491 — дубль на getRun-fail + epoch RUN_FACT + doc (#491)
Ре-ревью нашло регрессию класса #137/#161 на пути отказа getRun при tail-only
re-seed:

- НАХОДКА 1 (MEDIUM/HIGH): в onFinish-disconnect ветка .catch (отказ getRun) на
  локальном дропе входила в reconnect-ladder БЕЗ ре-сида и БЕЗ фильтра «живой»
  частичной строки. anchorRef оставался устаревшим (mount-инициализатор), живая
  частичная строка со шагами N..M-1 — последней; через ~1с реконнект строил
  ?anchor=&n=N_mount, и при живом ране с покрытием от N_mount (flaky-сеть: SSE и
  getRun упали, сеть поднялась за 1с) сервер отдавал кадры ≥N_mount → SDK дописывал
  их к строке, где они УЖЕ есть → дублирование (клиентского дедупа реплея против
  parts нет). Фикс: восстановлена структурная гарантия удалённого resumeStream-
  фильтра — на отказе getRun (и на no-persisted-row, и на no-cid) живая частичная
  строка удаляется из стора по id + anchorRef=null → реконнект реплеит со start в
  ЧИСТЫЙ стор (полная пересборка) либо 204→poll. Нет пути, где attach tail-applies
  на строку с уже присутствующими шагами. Тест: getRun-reject на локальном
  дисконнекте → живая строка отфильтрована + URL без параметров (mutation-verify:
  без фикса тест краснеет — фильтр не срабатывает).

- НАХОДКА 2 (LOW): RUN_FACT в enterReconnect теперь epoch-штампуется (epoch:
  stampEpoch), как везде (postRun): getRun-rtt расширяет окно onFinish→dispatch,
  конкурентный SEND_LOCAL во время rtt теперь дропает устаревший RUN_FACT по I1,
  а не перетирает runFact.runId нового хода.

- НАХОДКА 3 (LOW, doc): run-fsm.spec.md обновлён — stripRef/strippedRowRef →
  anchorRef {id, stepsPersisted}, tail-only + re-seed-from-persist.

FSM run-fsm.ts не тронут; инварианты #488 (epoch/honor-in-stopping/ownership-reset/
disconnect-first/render-gate) сохранены. Клиент ai-chat vitest 399 зелёный, tsc 0
ai-chat-ошибок; сервер delta(6, реально исполняется)/registry/step-marker/attach +
integration attach — зелёное.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder d81781aa27 feat(ai-chat): клиентская активация tail-only resume + delta-поллинг (#491)
Переводит клиент на серверный tail-only контракт resume (задел commit 3),
не трогая FSM run-fsm.ts — меняется только рантайм-обвязка в chat-thread.tsx.

A. Убран STRIP-механизм. Seed теперь содержит ВСЕ персистнутые строки без
   изъятия хвоста (стриминговый хвост — это шаги 0..N-1, к которым SDK-
   продолжение дописывает tail). stripRef/strippedRowRef заменены на anchorRef
   { id, stepsPersisted } — персистнутая assistant-строка, питающая
   ?anchor=<id>&n=<stepsPersisted>. Восстановления stripped-строки на
   204/NONE/starved удалены (строку никто не изымал — нечего восстанавливать);
   invalidateQueries + диспатчи FSM сохранены. Блок anchor-mismatch в reconcile
   сверяется по id из свежей персист-истории, а не по «живой» строке.

B. Вход в attaching/reconnecting — ВСЕГДА через re-seed из персиста; «живой»
   стор НИКОГДА не база для tail-apply. На локальном FINISH_DISCONNECT (и на
   live-follow повторном дропе observer-а) сначала getRun(chatId) → замена
   «живой» частичной строки персистнутой по id (mergeById) + установка anchor,
   и лишь ПОСЛЕ этого диспатч RUN_FACT + FINISH_DISCONNECT (который планирует
   реконнект). Так attach не может продублировать частичный шаг N. Фильтр
   «живой» строки в resumeStream-эффекте убран (его заменяет re-seed). Инварианты
   FSM (I1 epoch-штамп, I4 honor-in-stopping, DISCONNECT-FIRST, сброс ownership на
   терминалах, render-gate) сохранены.

C. URL attach: ?anchor=<id>&n=<stepsPersisted> при наличии якоря, без expect.

D. Degraded-поллинг переведён с полного рефетча всех страниц на дельту:
   useAiChatMessagesQuery больше не поллит (seed один раз), а окно при
   degradedPoll раз в 2.5с зовёт getAiChatMessagesDelta(chatId, cursor) и
   идемпотентно по id мёржит строки в тот же infinite-query кэш через новый
   чистый хелпер mergeDeltaRowsIntoPages. Арминг/разарм (onResumeFallback) и
   idle-cap не тронуты.

Хелперы: seedRows удалён; добавлены stepsPersistedOf и mergeDeltaRowsIntoPages
(+ юнит-тесты на идемпотентность). Тесты chat-thread обновлены под новый URL,
seed-без-стрипа, re-seed-из-персиста на дисконнекте (mutation-verify: падают
без re-seed и при n мимо персиста) и 204→poll-без-restore. Весь ai-chat vitest
зелёный (398), tsc без новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder daca5ce8d6 fix(ai-chat): целостность delta-spec (молчаливый скип) + 2 hardening (#491)
Внутреннее ре-ревью: DB-backed delta-spec молча СКИПАЛСЯ — нулевое покрытие
инварианта DB-clock курсора.

- Импорт `import postgres from 'postgres'` (default) при tsconfig
  module:commonjs без esModuleInterop компилился в `postgres_1.default(...)`,
  а CJS-`postgres` не имеет `.default` → TypeError в beforeAll → пустой catch →
  reachable=false → все 6 тестов уходили в console.warn('SKIP') и return БЕЗ
  ассертов (suite оставался бы зелёным даже при регрессии на new Date()).
  Фикс: `import * as postgres from 'postgres'` (как в рабочем int-харнессе).
- Хардненг харнесса: реальная ошибка программирования в beforeAll больше НЕ
  маскируется под «DB unreachable» — скип легитимен только для сетевого отказа
  (ECONNREFUSED и т.п.), иначе rethrow → suite падает громко.
- Два DB-clock теста использовали jest.useFakeTimers() целиком, что замораживало
  внутренние таймеры postgres.js → awaited DB round-trip зависал на 5s-капе (и
  вешал afterAll). Фейкаем ТОЛЬКО Date (doNotFake всех таймеров) — запрос
  резолвится, а инвариант «стамп от часов БД, не app-clock» по-прежнему доказан
  (скос процесс-часов в 2099 → стамп остаётся на времени БД). Теперь все 6
  тестов РЕАЛЬНО исполняются и зелёные против живого Postgres.

Два дешёвых hardening из ревью:
- registry coverageFloor: пустая ветка возвращает max(currentStamp,
  persistedFloor) — инвариант «клиент с n=persistedFloor всегда покрыт»
  структурный, а не тайминг-зависимый.
- GetChatDeltaDto.cursor: @IsString → @IsISO8601 — битый курсор отсекается 400
  на уровне DTO, а не 500 на `::timestamptz`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00