Commit Graph

963 Commits

Author SHA1 Message Date
agent_coder 9f3f5b43bd fix(search): F1 term-cap stack-depth guard + restore/extend coverage, drop dead code (#529)
F1: cap parsed terms at MAX_PARSED_TERMS (64) in parseSearchQuery so a huge
pasted query no longer nests the combined tsquery deep enough to blow Postgres'
stack depth limit (HTTP 500); +@MaxLength(10000) on SearchDTO.query as
defense-in-depth. F2/F3: restore titleOnly text_content leak-guard and
parentPageId subtree-scoping coverage in the lexical int-spec. F4: positive
ancestor-path ordering + non-empty snippet asserts. F5: remove dead
SearchLookupTier enum, unused buildTsQuery + pg-tsquery require (and its tests),
and the discarded parentPageId select in fetchDetails. F6: rewrite the stale
#443 DTO comment (parentPageId/titleOnly read by the engine; substring ignored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:03:06 +03:00
agent_coder 747b125912 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 05:23:33 +03:00
agent_coder 01637fa86b 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 05:22:04 +03:00
agent_coder 1413b26287 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 05:21:47 +03:00
agent_coder a4ea22a7f5 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 05:21:13 +03:00
agent_coder 87d5d7ac26 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 05:21:13 +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
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
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 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
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 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
agent_coder daeeb1f3f2 refactor(ai-chat): registry — step-aligned retention + tail-only attach (#491)
Реестр ран-стримов больше не буферизует до 32МБ сырых SSE-кадров на активный
ран и не выливает весь буфер в сокет синхронно при attach — это давало OOM на
1ГБ-контейнере при нескольких марафонских ранах. Теперь кольцо ограничено
(env-настраиваемо, по умолчанию 4МБ) и держится в границах за счёт ротации по
шагам.

Серверная часть (суть коммита):

- Штамповка кадров по шагам в ingestFrame. Штамп кадра = число `finish-step`
  кадров ДО него (с 0); сам finish-step несёт текущее значение, затем счётчик
  инкрементится. Так штамп совпадает с `metadata.stepsPersisted`: клиент с N
  персистнутыми шагами имеет 0..N-1 в сиде и просит хвост `stamp >= N`. Границу
  ловим дешёвым startsWith по `data: {"type":"finish-step"` — форма кадра
  проверена эмпирически против ai@6.0.207 (одна часть на кадр, type всегда
  первый ключ; кавычки в text-delta экранированы, ложных срабатываний нет).

- Кольцо ротируется ТОЛЬКО на подтверждённом персисте шага N
  (`confirmPersistedStep`), сбрасывая кадры `stamp < N` (эти шаги уже на диске и
  придут в свежем сиде). `updateStreaming` теперь СИГНАЛИЗИРУЕТ исход (число
  персистнутых шагов или null), и ротация вызывается лишь при не-null возврате —
  провал персиста ничего не ротирует, кольцо покрывает БОЛЬШЕ (анти-инверсия:
  наивная ротация в .then() после НЕзаписанного шага дырявила бы гарантию).

- Переполнение кольца сверх байтового капа вытесняет старейшие кадры; вытеснение
  ещё-не-персистнутого кадра открывает GAP. Гэп НЕ липкий: floor покрытия
  считается из кольца, поздний персист, проротировав дырявые шаги, его чистит.

- attach(chatId, anchor, n): маркер шага N приходит ТОЛЬКО от клиента (сервер не
  читает строку — N из устаревшего сида дал бы тихую дыру в один шаг). Покрытие
  ОК ⟺ coverageFloor <= n; иначе 204 → клиент рефетчит (больший N) и
  переподключается. Хвост = синтетический `start`-кадр (ран-факт runId/chatId) +
  кадры `stamp >= n`. Инвариант 6 (нет кросс-ран реплея) сохранён через anchor;
  инвариант 4 (снапшот+регистрация в один синхронный тик) сохранён. N-срез
  применяется во ВСЕХ ветках, включая finished-retained: finished + N=N_final →
  пустой хвост + finish-кадр, клиент закрывает стрим.

- Контроллер пишет хвост чанками с учётом drain (writeTailRespectingDrain), а не
  синхронным залпом (вторая половина OOM). Кап подписчика — производное 2× кап
  кольца, обе величины env-резолвятся на инстансе.

Клиент: в тип строки добавлен `metadata.stepsPersisted` (источник N). PIN-SPEC
трип-вайр на ai@6.0.207: `readUIMessageStream({ message })` продолжает последнее
сообщение, `start`-кадр не сбрасывает parts, текст не пересекает finish-step —
на этом держится продолжение при attach; апгрейд ai теперь падает громко.

Тесты (observable-property против РЕАЛЬНОГО реестра/БД): детектор границы на
реальной форме кадра, N-срез (в т.ч. посреди шага), ротация только на
подтверждённом персисте, «персист провалился но кольцо влезло → attach успешен /
провал + переполнение → 204», «устаревший N → 204 → после рефетча успех», очистка
гэпа поздним персистом, finished-retained + N_final, memory-bound (5 параллельных
марафонов сверх 32МБ, каждое кольцо ≤ кап). Обновлены registry/controller specs и
DB-backed интеграционный attach-spec под новую сигнатуру/семантику.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder a919c79cb2 feat(ai-chat): персист step-маркера (#491)
Персист отставал от live-стрима на целый текущий шаг, а run.stepCount как
источник границы шага НЕгоден: recordStep — fire-and-forget, не атомарен с
записью parts (рассинхрон сид↔маркер). Пишем маркер ТЕМ ЖЕ flush'ем, что и
parts:

- flushAssistant стампует metadata.stepsPersisted = число ЗАВЕРШЁННЫХ шагов,
  чьи parts лежат в ЭТОЙ строке (оба выводятся из finished → маркер не может
  разойтись с persisted parts). In-progress хвост (частичный шаг при
  error/abort или mid-stream flush) НЕ считается. Это step-alignment якорь, на
  котором строится resume-стек (ротация кольца по подтверждённому шагу N —
  коммит 3; attach режет хвост по «шаг > N»).
- Контракт AiChatRunService.reconstructRunParts(runId) → { parts,
  stepsPersisted } — единственный интерфейс чтения ЖИВОГО рана (run →
  assistantMessageId → строка → чистый reconstructPartsFromRow). null при
  отсутствии рана / связанной строки / удалённой строки; маркер 0 у pre-#491
  строки — безопасный пол. Потребители: attach (коммит 3), дельта (rows уже
  несут маркер в metadata), экспорт. messageRepo — опциональный 3-й параметр
  конструктора (2-арг тест-конструкции компилируются без изменений).
- /messages и дельта отдают маркер в row автоматически (он внутри metadata).

Тесты: property «stepsPersisted == число завершённых шагов для любого N + parts
согласованы»; частичный хвост не инкрементит маркер; reconstructPartsFromRow
(маркер, дефолт 0, фолбэк на content); reconstructRunParts (резолв + null-кейсы).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
agent_coder 798a81abfe perf(ai-chat): дельта-эндпоинт поллинга + run-факт в ответе (#491)
Degraded-poll рефетчил ВСЕ страницы infinite-query каждые 2.5 c с полными
parts. Вводим дельта-эндпоинт «строки, изменённые после курсора»:

- POST /ai-chat/messages/delta → { rows, cursor, run: {id,status}|null }.
  Курсор — таймстамп часов БД (now()), клиент эхом возвращает его каждый
  поллинг. Окно перекрытия now()−5s ловит строку, закоммиченную с updatedAt
  чуть раньше момента снятия предыдущего курсора на другом autocommit-
  соединении (одиночные UPDATE, длинных транзакций нет). Окно ГАРАНТИРУЕТ
  повторы → контракт: клиентский merge идемпотентен по id (mergeById).
- Run-факт едет В дельте (отдельный /run-поллинг удвоил бы QPS — отвергнуто).
- Все дельта-релевантные записи (message update/finalizeOwner/reconcile/sweep,
  run update/finalizeIfActive/markStopRequested/sweepRunning) стампуют
  updatedAt через SQL now(), а не app-clock new Date(): единая монотонная ось
  курсора, смешанные источники часов были независимым источником пропусков.
- Клиент: сервис-функция getAiChatMessagesDelta + фиксация контракта
  идемпотентности mergeById тестом (свап degraded-поллинга на дельту — в
  коммите 3, вместе с re-seed-путём attach).

Тесты (observable-property на живом Postgres, не моки):
- дельта-семантика + монотонность курсора;
- RACE «коммит позже с updatedAt раньше курсора» — ловится окном перекрытия
  (наивный updatedAt > cursor пропустил бы);
- окно гарантирует повторы → идемпотентный merge;
- updatedAt стампуется часами БД, а не app-clock (фейк системных часов в 2099
  — стамп остаётся на времени БД);
- контроллер: owner-gate, форма ответа, run-факт только {id,status}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:09:00 +03:00
vvzvlad 8abde99611 Merge pull request 'ci: гейты наблюдаемых свойств перед publish — image-smoke, migration-order на push, allowlist fail-closed, property-тесты (#476)' (#477) from ci/476-publish-gates into develop
Reviewed-on: #477
2026-07-12 04:07:24 +03:00
agent_coder 6dad309b51 fix(ai-chat): ревью #510 — Опция A бюджета + 4 DO ревьюера
Эскалация (владелец) — Опция A: 100k только фолбэк для НЕсконфигурированных
инсталляций; при заданном chatContextWindow бюджет = floor(0.7×window) БЕЗ капа
(бюджетер — защита от брика об контекст-окно, не эконом-лимитер). Спек репиннут
resolveReplayBudget(1_000_000)→700_000.

DO1: агрессивный next-turn recovery ×0.5 вынесен в чистую resolveEffectiveReplayThreshold
+ тест линковки replayOverflow→0.5×бюджет (mutation-verified).
DO2: checkNewComments partial-failure — per-page reject скипается (→null), скан
резолвится, порядок выживших сохранён; тест #7 (mutation-verified).
DO3: ai-chat.write-volume.spec.ts → .int-spec.ts (WAL-гард не бежал НИ в одном
CI-lane) + маппер @docmost/token-estimate в jest-integration.json; реальный WAL
на pg:5432 зелёный (трейс v1 140MB→v2 0.04MB).
DO4: CHANGELOG [Unreleased] по #490.
Follow-up: issue #520 (эскалация агрессивной доли при незаданном окне + малом
реальном контексте).

Ребейзнут на develop (волна 1 смержена): только 6 коммитов #490 над develop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:06:24 +03:00
agent_coder b038d96708 perf(ai-chat): snapshotOpenPage fast-path (#490)
snapshotOpenPage делал полный экспорт Markdown + upsert каждый ход. Fast-path: если
снапшот уже существует на ТЕКУЩЕЙ версии страницы (тот же instant updated_at), его
контент уже актуален — пропускаем экспорт+upsert целиком. Ход, не тронувший
открытую страницу (частый случай), больше не делает работы по снапшоту.

Зеркалит read-side fast-path в detectPageChange (sameInstant): оба доверяют, что
правка страницы двигает updated_at. Когда агент/человек ПРАВИЛ страницу этим ходом,
updated_at продвинулся → не совпадает → экспортируем как раньше (правки агента
запекаются в снапшот, инвариант #274 сохранён).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder f5bbfdb2d4 perf(ai-chat): deferred-активация тулов в metadata чата (#490)
Активированный set сбрасывался каждый ход → модель заново гоняла loadTools, чтобы
переактивировать те же тулы (лишний round-trip на каждом ходу). Теперь набор
персистится в metadata чата и сидируется на следующем ходу.

- Миграция: jsonb-колонка metadata на ai_chats (default '{}'); db.d.ts дополнен
  вручную (AiChats.metadata: Generated<Json>).
- seedActivatedTools(metadata, validDeferredNames): читает сохранённый набор,
  ПЕРЕСЕКАЯ с актуальными validDeferredNames — смена allowlist/ролей не воскресит
  несуществующий тул (иначе prepareAgentStep получил бы фантомное активное имя).
  Сид только при deferredEnabled.
- Персист на завершении хода (once-guard, во всех терминальных ветках рядом со
  snapshotTurnEnd): детерминированно отсортированный набор, merge в существующий
  bag (другие ключи сохраняются), запись пропускается если ничего нового не
  активировано (обычный ход не даёт лишней записи).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 875f6ba9c5 feat(ai-chat): токен-бюджет реплея истории + реактивная ветка (#490)
Вся персистентная история реплеится провайдеру КАЖДЫЙ ход, поэтому длинный чат
рано или поздно упирается в контекстное окно и получает провайдерский 400 на
каждом ходу — навсегда (чат «кирпичится»). Бюджетер ограничивает РЕПЛЕЙ (никогда
не мутирует персист — в БД остаётся полная запись), детерминированно и byte-stable
(обрезанный префикс идентичен от хода к ходу → дружелюбно к prompt-cache).

Единый оценщик chars/2.5 (кириллица; chars/4 занижает вдвое) вынесен в shared-пакет
packages/token-estimate; клиентский count-stream-tokens.ts переведён на него ТЕМ ЖЕ
коммитом (два расходящихся оценщика = «бейдж 60%, а бюджет уже режет»).

history-budget.ts (чистый, покрыт тестами):
- resolveReplayBudget(raw): min(100k, 0.7×window) при заданном окне; флэт 100k при
  незаданном (именно эти инсталляции ловят терминальный overflow — warn-лог); 0 =
  явный off-switch. Читается СЫРОЙ chatContextWindow, т.к. parsePositiveInt схлопывает
  0 и unset в undefined (новое поле ResolvedAiConfig.chatContextWindowRaw).
- trimHistoryForReplay: первичный сигнал — провайдерский факт metadata.contextTokens
  прошлого хода; chars-оценка — дельта/раскройка/фолбэк. Порядок: обрезка tool-outputs
  старых ходов (head+tail+маркер) → механическое схлопывание старейших ходов
  (конкатенация, НЕ LLM) → текущий + последние N ходов всегда полные. Пейринг
  tool-call/result сохраняется (схлопывание убирает ОБЕ части).
- isContextOverflowError: классификация провайдерского 400 (статус + паттерны).

Реактивная ветка: превентивная оценка не даёт инварианта (первый переполняющий ход
не имеет usage). onError классифицирует context-overflow → пишет различимую причину
и штампует metadata.replayOverflow; следующий ход бюджетер режет агрессивно
(0.5×), что и раскирпичивает чат. Наблюдаемость: metadata.replayTrimmedToTokens.

ПРИМЕЧАНИЕ по реактивной ветке (форк, требует решения ревьюера): истинный in-turn
re-pipe (перезапуск streamText в тот же ответ) архитектурно несовместим с текущим
пайпом — pipeUIMessageStreamToResponse пишет writeHead СИНХРОННО (подтверждено в
ai@6.0.207), а suite ожидает await stream() c моком, не дёргающим колбэки, — так что
отложенный пайп/ожидание сигнала повесит тесты. Поэтому реализована реактивная
рекавери «классификация → штамп → агрессивный ре-трим на следующем ходу», что даёт
тот же инвариант (чат не кирпичится) без рискованного рефактора стрима.

Тесты (наблюдаемые свойства): объём записи через дельту pg_current_wal_lsn() на живой
gitmost-test-pg вокруг 50-шагового прогона (несжимаемые payload'ы) — trace-колонка
v1=140МБ → v2=0.04МБ (в 3206× меньше), полная строка 289МБ → 140МБ (−51%); dual-shape
не нужен здесь; «окно не задано → бюджет применяется»; реактивная классификация на
реальном 400-шейпе; parity клиент/сервер оценщика.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 7741821ee3 perf(ai-chat): кэш compactToolOutput по identity шага (#490)
compactToolOutput делает JSON.stringify каждого output на КАЖДОМ flush. Т.к.
onStepFinish на шаге N перестраивает всю assistant-строку по всем N накопленным
шагам, а каждый output — 50–200 KB, это O(N²) stringify за ход.

Мемоизация по identity шага: finished-шаг в capturedSteps неизменен и держит
стабильную ссылку между flush'ами, поэтому его parts (и дорогой stringify output)
строятся ровно раз за ход. buildStepParts вынесен в чистую функцию; assistantParts
принимает опциональный StepPartsCache (WeakMap<step, parts>), flushAssistant
пробрасывает его, stream() заводит один WeakMap на ход и передаёт во все flush'и.
Промах кэша (или его отсутствие в тестах/легаси-вызовах) просто пересобирает —
байтового расхождения нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
agent_coder 48a074e0d2 perf(ai-chat): формат трейса tool_calls v2 — outputs только в parts (#490)
Каждый tool-output хранился ДВАЖДЫ: в metadata.parts (assistantParts) И в
tool_calls (serializeSteps). При 50-шаговом ране с outputs по 50–200 KB это
127–510 МБ записи в Postgres за ход (+WAL/TOAST/dead tuples), т.к. onStepFinish
переписывает всю строку. Копия в parts — та, что реально реплеится модели и
рендерится UI/markdown-экспортом, так что копия в трейсе была чистым дублем.

Новый формат элементов tool_calls (v2), парно на каждый вызов:
  {toolName, input}                      — вызов
  {toolName, ok: true}                   — успех (БЕЗ output)
  {toolName, error, kind: 'thrown'}      — брошенный tool-error
  {toolName, error, kind: 'interrupted'} — прерван mid-step (abort/restart)

kind обязателен: синтетический «Tool call did not complete.» при прерывании иначе
неотличим от реального hard-fail и загрязняет error-rate. Различие структурное
(errorsById-хит против синтетической ветки), НЕ per-tool классификатор — soft-
маркеры в трейс не выносятся (остаются в metadata.parts).

metadata.toolTraceVersion: 2 — маркер эры; старые строки НЕ мигрируются
(перезапись гигантских jsonb — тот самый WAL-чарн). serializeSteps пейрит
результаты/ошибки по toolCallId (как assistantParts); общая константа
TOOL_CALL_INCOMPLETE_TEXT держит текст реплея и трейса в синхроне.

docs/reading-ai-logs.md переписан dual-shape: ветвление по toolTraceVersion,
soft-анализ v2 через metadata.parts, правило «не сравнивать агрегаты через границу
эр». UI action-log и markdown-экспорт читают только parts — не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:05:48 +03:00
vvzvlad 17e3b3d882 Merge pull request 'fix(converter): block-escape + attribute contract + dedup mirrors (#493)' (#514) from feat/493-converter into develop
Reviewed-on: #514
2026-07-12 04:04:24 +03:00
vvzvlad 801add63e8 Merge pull request 'fix(ai-chat+security+cache): хвосты стабилизации — транспорт, share-alias, temp-notes, guards (#495)' (#516) from feat/495-tails into develop
Reviewed-on: #516
2026-07-12 04:03:55 +03:00
vvzvlad 7ad072ba2c Merge pull request 'feat(mcp): гарды зеркал реестра + write-целостность (#494)' (#513) from feat/494-mcp-registry-guards into develop
Reviewed-on: #513
2026-07-12 04:03:44 +03:00
agent_coder 216499d57b Merge remote-tracking branch 'gitea/develop' into feat/370-page-versioning
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:31:04 +03:00
agent_coder b041944a23 Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:28:31 +03:00
agent_coder 401d62119c Merge remote-tracking branch 'gitea/develop' into feat/493-converter
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:23:14 +03:00
agent_coder e3ca2dc1d5 test(db): executeTx — покрыть rollback-ветку after-commit-хука
Ревью #516: fakeDb.execute всегда пушил commit — моделировался только commit-путь,
несущий негатив (тело кидает → tx реджектит → дренаж после awaited-tx не бежит →
хук НЕ фаerr) не был заперт. Исходник корректен, это была дыра в тесте.

Добавлен тест: тело бросает → executeTx реджектит, commit не случился, хук не
побежал. Mutation-verify: перенос дренажа в finally → тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder f919ced8c9 fix(db): concurrent-pre-build индексов — переопределять f_unaccent ПЕРЕД циклом
Ревью #516: фича была мёртвой-при-рождении ровно на целевом кейсе. Все три
CONCURRENT_INDEXES используют LOWER(f_unaccent(col)), но index-инлайнящаяся
1-арг форма f_unaccent (SELECT public.unaccent($1)) создаётся ВНУТРИ миграции
20260705, а ensureConcurrentIndexes зовётся ПЕРЕД мигратором. На существующем
тенанте живой f_unaccent — ещё старая 2-арг форма из 20250729, которая НЕ
инлайнится: CREATE INDEX CONCURRENTLY падает («function unaccent(unknown, text)
does not exist … during inlining»), best-effort глотает, и мигратор строит
индексы НЕ-concurrently под тем самым SHARE-локом (эмпирически подтверждено
ревьюером на живом pg).

Фикс: перед циклом идемпотентно переопределяем f_unaccent в 1-арг форму
(output-identical, в lockstep с 20260705), в том же best-effort try/swallow.

Плюс честный докстринг: убрал ложное «worst case = previous behaviour».
Прерванный CONCURRENTLY оставляет INVALID-индекс, который name-based IF NOT
EXISTS не чинит — новый режим отказа (старый in-tx build такого не оставлял).

Тест: переопределялка вызывается ПЕРВОЙ (order), затем индексы CONCURRENTLY вне
транзакции; провал переопределялки не рвёт цикл. Mutation-verify: убрать
pre-loop redef → order-тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 4109b2ef7f fix(security): share-alias reassign 409 — гейт раскрытия по view-праву (доведение #495 к.4)
Коммит 4 закрыл утечку currentPageId на анонимном /availability, но
эквивалентная (и более широкая) дыра оставалась на POST /aliases/set (reassign):
при занятом алиасе и confirmReassign=false 409 отдавал currentPageId И
currentPageTitle ЦЕЛЕВОЙ страницы, а контроллер гейтил только validateCanEdit на
ИСХОДНОЙ странице. Любой участник с одной редактируемой+расшаренной страницей мог
перебирать имена алиасов и мапить их на (id, title) чужих страниц без права
просмотра — тот же класс перечисления, плюс ещё и заголовок.

Фикс: setAlias теперь гейтит раскрытие. currentPageId НЕ отдаётся никогда
(клиент им не пользуется, это перечислимая идентичность). currentPageTitle —
только если validateCanView(целевая, user) проходит; иначе голый «занят» (клиент
и так показывает generic confirm-модалку без заголовка — UX не ломается).
Гейт живёт в сервисе, где строится раскрытие (PageAccessService — @Global, без
цикла); контроллер прокидывает user. Поправлен неточный коммент checkAvailability.

Тесты: viewer → 409 с title, БЕЗ id; не-viewer → 409 без title и без id.
Mutation-verify: вернул утечку (id + безусловный title) → оба теста краснеют.
Контроллер-спек и int-spec обновлены под новую сигнатуру; tsc чист.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 6b27ff0652 feat(ai,mcp): идентичность прогона реиндекса + брендированные PageId/SlugId (#495 item 14)
Аспект A — идентичность прогона реиндекса эмбеддингов.
У статуса/поллинга реиндекса не было идентичности КОНКРЕТНОГО прогона, из-за
чего класс багов «это тот же прогон или новый?» чинили дважды. Теперь каждый
прогон получает свой runId (crypto.randomUUID в start()), он хранится в Redis-
хэше рядом с total/done/startedAt и возвращается в ReindexProgress. Эндпоинт
статуса (getMasked -> MaskedAiSettings) отдаёт runId и reindexStartedAt. Клиент
кеит поллинг на (runId, startedAt): смена runId = НОВЫЙ прогон (сбрасываем
залатанное per-run состояние поллинга), тот же runId = тот же прогон. Всё
best-effort/косметика как и остальной код прогресса: пустой/отсутствующий runId
деградирует мягко и никогда не ломает реиндекс.

Аспект B — брендированные PageId/SlugId в MCP-клиенте + валидация формата в
серверных DTO (семейство инцидентов #435: двойная идентичность страницы —
внутренний id и slugId — гонялась как голая строка и молча путалась).
- packages/mcp/src/lib/page-id.ts: номинальные типы PageId/SlugId + валидирующие
  конструкторы asPageId/asSlugId и гварды isPageId/isSlugId (формат UUID и
  10-символьного slugId). PageId протянут через единственный узел канонизации и
  записи: resolvePageId() теперь возвращает PageId, а ключ per-page лока
  (withPageLock) и точки записи в collab (mutatePageContent/replacePageContent/
  updatePageContentRealtime) требуют бренд — сырой slugId/непроверенный id больше
  не проходит проверку типов (инвариант #260 «resolve-then-lock» теперь на уровне
  компилятора). Публичный вход методов остаётся строкой (это legitimно UUID ИЛИ
  slugId), брендируется каноническое значение.
- apps/server core/page/dto: PageIdDto.pageId получает валидацию формата
  (@Matches, UUID или 10-символьный slugId), так что кривая/подменённая
  идентичность отклоняется на границе, а не падает в repo голой строкой.

Тесты (все зелёные, с mutation-verify каждого):
- server: getMasked отдаёт runId/reindexStartedAt; стор пишет/читает runId и
  мягко деградирует его до ''; DTO отклоняет кривой pageId/slugId и принимает
  валидный.
- client: чистый хелпер reindexRunKey/isNewReindexRun (кеинг поллинга на runId).
- mcp: конструкторы/гварды PageId/SlugId (валидация формата).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder a96ca8e26b perf(db): GIN trigram-индексы строятся CONCURRENTLY вне транзакции
2 GIN trigram-индекса (pages.title, pages.text_content) + users.name строились
plain CREATE INDEX внутри Kysely-транзакции миграции: SHARE-lock блокирует записи
на pages/users на минуты при автодеплое.

Kysely гоняет миграцию в транзакции, а CONCURRENTLY внутри транзакции нельзя.
Поэтому ensureConcurrentIndexes (concurrent-indexes.ts) пре-строит эти индексы
через CREATE INDEX CONCURRENTLY (raw, вне транзакции) ДО миграатора — на
существующей БД миграционный CREATE INDEX IF NOT EXISTS становится no-op и лок не
берётся. Best-effort: на свежей БД (нет pages/f_unaccent) пре-build молча
пропускается, а миграция строит индекс на пустой таблице. Худший случай = прежнее
поведение, лучший — без лока.

CONCURRENT_INDEXES — канонические определения; drift-guard тест сверяет их с
выражениями в миграциях. Тест раннера: CONCURRENTLY+IF NOT EXISTS, вне
транзакции, best-effort (падение одного не рвёт остальные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder f84386f24a fix(temp-notes): свип на старте + FOR UPDATE SKIP LOCKED в транзакции
Две дырки свипера временных заметок:
- Не было свипа на старте: заметки, протухшие во время простоя, ждали до часа
  до первого тика @Interval. Добавил onApplicationBootstrap (best-effort, не
  блокирует boot).
- Гонка без блокировки: re-check и removePage не были атомарны, «Сделать
  постоянной» могла проскочить между ними. Теперь re-check идёт в транзакции с
  FOR UPDATE SKIP LOCKED: сериализуется с toggleTemporary (тот же row-lock) и
  пропускает строки, захваченные другим воркером/инстансом (без двойной
  обработки). Удаление идёт ВНУТРИ этой транзакции.

removePage получил опциональный trx: чтобы удалять под блокировкой без deadlock
на вложенной независимой транзакции. Броадкаст PAGE_SOFT_DELETED при переданном
trx отложен на commit через registerAfterCommit (откат больше не рассылает
фантомное удаление); без trx поведение прежнее.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder c0c44fddb9 fix(security): client-vitals — whitelist route/attr на анонимном эндпоинте
POST /api/telemetry/vitals анонимный, а route/attr не сверялись со словарём:
любой мог писать свободный текст в client_metrics (высокая кардинальность,
инъекция текста/PII/разметки).
- route: экспортировал полный словарь шаблонов из клиентского route-template.ts
  (KNOWN_ROUTE_TEMPLATES — канонический источник), сервер валидирует по зеркалу
  ALLOWED_ROUTE_TEMPLATES: нет в словаре → drop (null), событие остаётся.
- attr: это web-vitals attribution target (CSS-селектор), а не enum — ограничил
  консервативным charset CSS-селектора; всё вне набора → drop.
Клиентский self-consistency тест: templateRoute выдаёт ТОЛЬКО значения из
словаря (иначе легитимные метрики отбрасывались бы). Серверные тесты: raw-путь
и инъекция в route отброшены, PII/разметка в attr отброшены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 91e58e3c9f fix(cache): bustWorkspaceCache — после коммита, не внутри транзакции
bustWorkspaceCache звался сразу после write, но ВНУТРИ переданной транзакции
(до коммита). Окно: параллельный читатель промахивается мимо инвалидированного
ключа, читает ещё НЕ закоммиченную (старую) строку и репопулирует кэш старым
значением; после коммита кэш держит устаревшее до TTL (15 c).

Добавил post-commit-хук в executeTx: registerAfterCommit(trx, fn) регистрирует
side-effect, который дренится ТОЛЬКО после коммита транзакции — причём внешним
executeTx, владеющим trx (проброшенный existingTrx срабатывает на настоящей
границе коммита, а не во вложенном вызове). WeakMap по trx — без утечки.
bustWorkspaceCache теперь: без trx — del сразу (уже автокоммит); с trx —
регистрирует del на post-commit. Ошибка хука не валит уже закоммиченный write.

Тест: порядок body→commit→hook, дренаж на внешней границе, глушение падения хука.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 8d062728e5 fix(page): move-position — валидация charset вместо длины
MovePageDto.position — это fractional-indexing ключ (generateJitteredKeyBetween,
тот же генератор, что в page.service). @MinLength(5)/@MaxLength(12) не совпадали
с реальным диапазоном генератора: плотные between-вставки в глубоком дереве
растят ключ далеко за 12 символов (замерено >40), и валидный ключ, который
сервер сам сгенерил, отклонялся 400 (Gitea #139, п.6).

Теперь валидируем по charset — base-62 алфавит [0-9A-Za-z] — плюс щедрый
@MaxLength(256) как чистый DoS-guard, сильно выше любого реального ключа.
test.failing (bug-lock) распинен в обычный it; добавлен кейс на отклонение
символов вне алфавита (control/separator/инъекция/пустая строка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:02:13 +03:00
agent_coder 0ddeaadeee feat(collab): закрытие api-key→collab отмывания (fail-closed дискриминатор)
api_key-принципал стал полноправным REST-принципалом → мог начеканить 24-ч
COLLAB-токен, который AuthenticationExtension не сверял с api_keys, и ревокация
не доставала websocket-редактирование. Блокировать /collab-token для api_key
нельзя (это правки агентов). Решение fail-closed:

- JwtCollabPayload расширен полем principal ('session'|'api_key') + apiKeyId.
  generateCollabToken штампует его в КАЖДЫЙ токен: 'api_key'+apiKeyId, когда
  чеканит api-key-принципал (внешний MCP-агент), иначе 'session'. Дискриминатор
  ключуется на ОРИГИНЕ (наличии api-key), НЕ на actor:'agent' — внутренний
  session-backed AI-агент остаётся 'session' и на no-check пути.
- /auth/collab-token читает подписью-выведенные req.raw.authType/apiKeyId
  (не тело) и прокидывает origin через getCollabToken → generateCollabToken.
- doAuthenticate: при principal='api_key' на connect прогоняет
  ApiKeyService.validate (отозванный ключ → новых collab-подключений нет; инфра
  пробрасывается). api_key без apiKeyId — reject. Claimless-токен доверяется
  только В ПРЕДЕЛАХ 24-ч grace-окна роллаута (легаси session-токен, api_key его
  начеканить не мог); после окна всякий валидный токен обязан нести
  дискриминатор, поэтому claimless — баг и отвергается (не молча-доверие 24ч).

CollaborationModule импортирует ApiKeyModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:15 +03:00
agent_coder bc632f9d56 feat(mcp): приём api_key на /mcp через allowlist-роутер {ACCESS, API_KEY}
/mcp-Bearer больше не пинит тип к ACCESS. Новый framework-free примитив
verifyMcpBearer верифицирует подпись ОДИН раз (bindMcpBearerVerifier пинит
allowlist {ACCESS, API_KEY}) и роутит:

- API_KEY → сперва биндинг к воркспейсу инстанса (чужой воркспейс — отказ), затем
  общий row-check ApiKeyService.validate. HMAC-верификация (микросекунды) вместо
  bcrypt, это НЕ попытка логина — Basic-путь и анти-брутфорс-лимитер не
  затрагиваются (фикс удушения параллельных чтений агентов).
- ACCESS → прежний verifyBearerAccess (session-active + not-disabled), которому
  передаётся замыкание над уже проверенным payload, так что «проверить один раз»
  и «helper без изменений» сосуществуют.

Bearer-нога resolveMcpSessionConfig униформизирована: любой отказ авторизации →
единый generic-401 (анти-enumeration, класс отказа не течёт в тело), а
непредвиденная (инфра) ошибка ПРОБРАСЫВАЕТСЯ (→5xx), не маскируется под 401.
McpService получает ApiKeyService; McpModule импортирует ApiKeyModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:15 +03:00
agent_coder d3a049d176 feat(api-key): core-модуль выдачи и валидации api-ключей + kill-switch
Форк не несёт EE-модуль ee/api-key, поэтому api_key-токен сегодня отвергается на
любом /api/*. Вводим core-модуль:

- ApiKeyRepo (kysely): insert/findById/findByCreator/findAllInWorkspace/
  softDelete/touchLastUsed, фильтр deleted_at IS NULL.
- ApiKeyService.create — mint-then-insert: сперва uuid7 id, затем чеканка JWT
  без exp, затем строка (сбой чеканки → строки нет; потерянный ответ →
  осиротевшая строка видна в list и самолечится). Токен отдаётся один раз, в
  таблице не хранится.
- ApiKeyService.validate — единый валидатор для jwt.strategy И /mcp. Владелец
  истины о сроке/отзыве — строка api_keys, не JWT. Любой определённый негатив
  (нет строки / expired / disabled / workspace mismatch / kill-switch off) →
  единый bare-401 (анти-enumeration); инфра-ошибка пробрасывается (→5xx), не
  маскируется. Без кэша — немедленная ревокация. last_used_at троттлится 1ч,
  fire-and-forget.
- REST create/list/revoke: create — self + UserThrottlerGuard; list/revoke —
  admin (CASL Manage на API) видит/отзывает все ключи воркспейса, член — только
  свои. api_key-принципал отвергается на всех трёх (токен не управляет
  токенами — GitHub-PAT-семантика). Аудит API_KEY_CREATED/DELETED + структурный
  лог с apiKeyId и IP.
- jwt.strategy: прямой вызов ApiKeyService.validate вместо динамического
  EE-require; штампует req.raw.authType='api_key' и apiKeyId для гварда выше.
- Kill-switch API_KEYS_ENABLED: дефолт ON (деплой без переменной не убивает
  агентов), строгий парс @IsIn(['true','false']) (=0/=off падают на boot, а не
  молча значат «включено»), boot-лог состояния. off → validate deny и
  эндпоинты 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:57:15 +03:00
agent_coder f1cffc2d0f fix(security): share-alias availability не отдаёт currentPageId
Проба доступности алиаса возвращала currentPageId — id страницы, на которую
алиас уже указывает — ЛЮБОМУ аутентифицированному участнику воркспейса без
проверки прав на просмотр этой страницы. Перебором имён алиасов можно было
смапить их на id страниц, к которым доступа нет.

Теперь checkAvailability отдаёт только {alias, valid, available}. Бита
taken/free достаточно для пробы; заголовок целевой страницы всплывает лишь
ПОСЛЕ реальной попытки setAlias (путь 409 ALIAS_REASSIGN_REQUIRED), который
проверяет права. Клиент currentPageId нигде не использовал — убран из типа,
стейта и теста. Серверный спек утверждает отсутствие currentPageId в ответе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:44 +03:00
agent_coder bb9a6fd765 chore(ai-chat): guard-пороги в env + единый маркер дегенерации
Три хвоста детектора петель (#444):
- Пороги детектора дегенерации теперь конфигурируются из env (по образцу
  AI_CHAT_FINAL_STEP_LOCKDOWN): AI_CHAT_DEGENERATION_REPEATED_LINES /
  _PERIOD_MAX_LEN / _PERIOD_MIN_REPEATS / _CHECK_STEP. Резолвер читает сырую
  строку (пусто = unset → компилируемый дефолт), требует ≥1, иначе безопасный
  откат к дефолту (0/отриц. сломал бы детектор). Оператор перенастраивает
  анти-babble-guard без редеплоя.
- Единый маркер: при дегенерации live-стрим показывает нейтральное «Response
  stopped.» (клиент не отличает от ручного Stop), а персист-баннер после refetch
  падал в дженерик. Классифицировал OUTPUT_DEGENERATION_ERROR на клиенте под тот
  же заголовок «Response stopped.» + деталь про петлю — live и refetch больше не
  расходятся.
- STEP_LIMIT_NO_ANSWER_MARKER: вместо хардкодной русской строки в content —
  локаль-нейтральная английская (базовая локаль = i18n-ключ этого репо, читаема
  моделью на реплее); не-русские юзеры больше не видят русский текст.

Тесты: env-пороги гоняются против реальных форм повторов с mutation-проверкой
(поднятый checkStep глушит burst; сниженный repeatedLines триггерит короткий
ран); клиентская классификация — новый кейс в error-message.test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:53:44 +03:00
agent_coder 52763998d3 fix(ai): gemini/ollama через aiStreamingFetch + явный maxRetries
Провайдер-фабрики gemini и ollama (chat-путь) шли на глобальном undici-fetch:
без keep-alive recycle, без ретраев на pre-response reset, с дефолтным
(безграничным по паузе) таймаутом. Классы инцидентов #140/#175/#310 для них
воспроизводимы так же, как для openai. Прокинул this.aiProviderFetch (одна
строка на провайдера) — тот же слоёный instrumented streaming fetch, что уже
стоит на openai.

Плюс явно закрепил maxRetries=2 в обоих streamText-вызовах (authenticated и
public-share): совпадает с дефолтом SDK, но фиксирует потолок против дрейфа
дефолта. Арифметика коннектов на ход: (1 + maxRetries=2) × (1 +
AI_STREAM_PRE_RESPONSE_RETRIES) — два слоя ретраев композируются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:50:35 +03:00
agent_coder d738780370 feat(auth): verifyJwtOneOf + чеканка api-key токенов без exp
TokenService получает примитив type-routing`verifyJwtOneOf(token, allowed[])`:
подпись проверяется один раз, тип обязан входить в явный allowlist, иначе — тот
же generic-throw, что и у verifyJwt. Нужен для /mcp-Bearer, который принимает и
ACCESS, и API_KEY на одном слоте, но без переиспользуемого footgun'а
«verify-and-return-any-type».

generateApiToken теперь чеканит api-key токен БЕЗ клейма exp. Единственный
источник истины о сроке/отзыве ключа — строка api_keys (проверяется на каждом
запросе), не JWT. Общий jwtService зарегистрирован с глобальным
signOptions.expiresIn ('90d'), который мержится в любой sign() — даже
sign(payload, {}) — а {expiresIn: undefined} бросает; поэтому «бессрочный» ключ
через общий signer молча получал бы exp=now+90d. Чеканка идёт через выделенный
signer без expiresIn (issuer 'Docmost' для паритета клеймов). Живой баг
подтверждён эмпирически и покрыт тестом.

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