Commit Graph

1771 Commits

Author SHA1 Message Date
agent_coder 498a87f3c4 fix(#370): add savePageVersion to DocmostClientMethod union (server in-app tool parity)
The Stage-A save_page_version tool's client method must be listed in the
DocmostClientMethod union so Pick<DocmostClient, DocmostClientMethod> exposes it
to the in-app tool adapter; the shared-tool-specs contract spec asserts parity.
Mirrors the existing restorePageVersion entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_coder 67c94de6ef feat(mcp): agent save_page_version tool — collab stateless transport + role prompts (#370)
Stage A of #370: wire the MCP client transport for the already-shipped server
save-version handler (PR-1 #374). An agent can now pin an intentional named
version (kind='agent', derived server-side from the signed actor) of a page's
CURRENT live collaboration content.

- collab-session.ts: add CollabSession.sendStatelessAndAwait(payload, predicate,
  timeoutMs) — sends a stateless message over the live provider and resolves on
  the first matching reply, with a bounded timeout; lifecycle (inflightReject,
  ready-guard, concurrent fail-fast, idle re-arm) mirrors mutate(). Extend
  CollabProviderLike with sendStateless + the stateless on/off overloads.
- collaboration.ts: add savePageVersionRealtime() — under withPageLock, reuse the
  cached agent-authenticated CollabSession (#400), send {type:'save-version'},
  await {type:'version.saved', …}; no REST read (would race the stale page row).
- client/pages.ts: add savePageVersion(pageId) — resolvePageId +
  getCollabTokenWithReauth + writeWithCollabAuthRetry (#486 self-heal).
- tool-specs.ts: add the deferred savePageVersion spec (+ DocmostClientLike Pick);
  auto-registered on both hosts by the shared loops.
- server-instructions.ts: HISTORY family + routing-prose mention.
- ai-chat-tools.service.ts: contract type-assert for the new client method.
- agent-roles-catalog: tell the content-authoring roles (researcher,
  call-summarizer, ru+en) to save a version when the document is done; bump
  their catalog versions.
- test: unit coverage for savePageVersionRealtime (ack + bounded-timeout paths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_vscode 6b825ad440 fix(client): anchor toasts to the top edge of the viewport
Move the top-anchored Mantine notification containers from top:96px to
top:8px so success toasts (e.g. "Comment resolved successfully") appear
at the very top of the page, above the header/search, instead of
covering page content. Update the accompanying comment to reflect the
new intent (toast renders over the header chrome while visible).
2026-07-12 19:09:30 +03:00
agent_coder 688cb54f26 fix(ai-chat): рекавери режет реплей НИЖЕ настроенного бюджета (#520 Опция B)
Решение владельца по #520 (эпик #497 итерация 5): при повторном overflow реактивная
рекавери ДОЛЖНА иметь право резать бюджет реплея ниже явно настроенного окна —
«чат не должен кирпичиться на overflow» это ИНВАРИАНТ реактивной ветки, а
настроенное окно — заявление о ёмкости модели, не обещание о размере реплея; когда
провайдер 400-ит, реальность бьёт конфиг. (Не конфликтует с #510 Опция A: та про
верхнюю границу НОРМАЛЬНОГО пути — уважать конфиг, пока он влезает.)

- Пол эскалации: max(scaled, min(REPLAY_MIN_FLOOR_TOKENS, floor(0.5×threshold))).
  Было min(REPLAY_MIN_FLOOR_TOKENS, threshold) — жёсткий пол на настроенном бюджете.
  Теперь БОЛЬШОЕ окно эскалирует НИЖЕ настроенного бюджета до фикс. пола 8k; МАЛОЕ
  окно (0.5×threshold < 8k) падает до floor(0.5×threshold) — минимум старый 0.5× cut
  (никогда не хуже, чем раньше), но и не поднимается выше самого бюджета.
  REPLAY_MIN_FLOOR_TOKENS=8k без изменений (константа уже была). Сброс k на чистом
  ходу сохранён (счётчик replayOverflowCount не пишется на чистом финализе).
- Наблюдаемость: warn-лог (настроенный бюджет не влезает, реплей ниже него, уровень
  k) + метка турна metadata.replayBelowConfiguredBudget=true — только когда реплей
  реально ниже настроенного бюджета (не на нормальном in-budget реплее).
- Тесты: перепиновка «не раздувает малый бюджет выше себя» (теперь МОЖЕТ резать ниже,
  Опция B) в обоих spec; новый тест эскалации ниже бюджета (окно 8000→бюджет 5600,
  k=1→2800, сходимость к полу, сброс на чистом ходу) + большое окно (140k→8k
  ступенями); тест метки метадаты. Мутации: пол=бюджет краснит below-budget тесты,
  снятие метки краснит observability-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:01:07 +03:00
agent_coder ec5416068b fix(ai-chat): итеративная эскалация реактивной рекавери при overflow (#520)
Остаточный кирпич (#490/#510): при незаданном chatContextWindow база = плоский
дефолт 100k, а реальное окно модели маленькое (<50k). Фиксированный одинарный
cut 0.5×100k=50k всё равно превышал реальное окно → провайдер снова 400,
строка переставлялась replayOverflow, но булев priorOverflowed уже был true →
второго ужатия не происходило. Чат навсегда застревал на 50k и не восстанавливался.

Фикс (без парсинга тел 400):
- Сигнал из булева переведён в счётчик `metadata.replayOverflowCount` = число
  ПОДРЯД идущих overflow-ходов: инкремент (prior+1) на каждом overflow, сброс в 0
  на любом чистом финализе (чистая строка не пишет поле → читается как 0).
  BACK-COMPAT: старая строка с булевым `replayOverflow:true` читается как k=1.
- resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold·0.5**k),
  min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=0 → база; k=1 → 0.5×; k=2 → 0.25×;
  большой k → упирается в пол 8k (сходимость). null-база (trimming OFF) не трогается;
  пол никогда не поднимает легитимно малый настроенный бюджет выше него самого.
- Пол REPLAY_MIN_FLOOR_TOKENS=8k: ниже него чат не несёт осмысленный недавний
  контекст, и даже малое реальное окно его вмещает; keep-recent-turns сверху.

Тесты: таблица эскалации (k=0/1/2/большой/null), регрессия остаточного кирпича
(база 100k, окно ~40k → сходится ниже 40k, чего фиксированный 0.5× никогда не мог),
жизненный цикл счётчика на реальном pg (инкремент/сброс/back-compat через jsonb),
мутация **k→**1 краснит convergence-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:01:07 +03:00
vvzvlad a990ebd604 Merge pull request 'feat(search) Фаза A: лексический оверхол — ru_en, OR/RRF, операторы, пагинация (#529)' (#538) from feat/529-search-lexical into develop
Reviewed-on: #538
2026-07-12 17:40:43 +03:00
agent_coder fff772cbe2 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 17:37:07 +03:00
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
vvzvlad afc50ead38 Merge pull request 'feat(deploy): version-coherence — WS-анонс версии + guarded reload устаревших вкладок (#481)' (#499) from feat/481-version-coherence into develop
Reviewed-on: #499
2026-07-12 17:37:04 +03:00
vvzvlad ec932e89a3 Merge pull request 'feat(page-history): время работы над статьёй — число + суточный punch-card (#395)' (#551) from feat/395-work-time into develop
Reviewed-on: #551
2026-07-12 17:36:27 +03:00
vvzvlad 490d7965a1 Merge pull request 'feat(comment): Undo в тосте резолва (reopen) + сортировка Resolved по времени резолва (#542)' (#548) from feat/542-resolve-undo into develop
Reviewed-on: #548
2026-07-12 17:36:08 +03:00
vvzvlad 32c9361969 Merge pull request 'feat(mcp): буквальная запись markdown (без math/autolink) + getPage format:text (#502)' (#552) from feat/502-mcp-md-modes into develop
Reviewed-on: #552
2026-07-12 17:35:36 +03:00
vvzvlad c30f910d66 Merge pull request 'fix(prosemirror-markdown): markdown-импорт помечает внутренние ссылки на страницы как internal (#522)' (#524) from fix/522-internal-links into develop
Reviewed-on: #524
2026-07-12 17:35:28 +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 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 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
agent_coder 79b2da686b fix(work-time): clip cross-class padding; gate & payload fixes (#395)
F1: pad-clip adjacent different-class sessions at the raw-gap midpoint so
workMs and agentOnlyMs can never double-count the same wall-clock (default
agentTGap 7m < pIn+pOut 10m made a work-session-ending-in-agent overlap a
nearby agent_only run). Reword the config guard/comment: cross-class
disjointness is now structural, tGap≥pIn+pOut is a kept sanity bound. Add a
cross-class no-double-count test and a real seeded property/fuzz test (250
random timelines × 4 tz) asserting per-class union, cross-class disjointness,
and Σ per-day activeMs == workMs.

F2: page.controller.spec — add a ForbiddenException view-gate reject test that
asserts the rejection propagates and computeWorkTime is NOT reached, locking
validateCanView before compute.

F3: WorkTimeStat renders for agent-only pages (workMs==0, agentOnlyMs>0) with
an `agent:` headline so the punch-card stays reachable.

F4: drop the dead un-bucketed `sessions` list from the PageWorkTime response
and the client work-time types (no client reads it; bucketByDay still consumes
the core sessions internally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 08:31:42 +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 3163f50c98 fix(mcp): route #502 extension flags per write tool; add server import flag (#502 review)
Internal review found the two layered-extension flags landed in the SHARED
markdownToProseMirrorCanonical wrapper, which mis-covered two tools. Move the
decision to each caller by the tool's semantics.

BLOCKER 1 — createPage was NOT covered. createPage POSTs to the server
/pages/import endpoint, which imported with DEFAULTS (math + fuzzy autolink ON),
so an agent's `$x=1$` still became a formula and `www.host` still autolinked.
Add an optional `disableMarkdownExtensions` multipart field to /pages/import
(default OFF, so human file uploads keep math ON); import.controller reads it,
import.service.importPage/processMarkdown thread it into markdownToProseMirror.
MCP createPage sends it true.

BLOCKER 2 — import_page_markdown was wrongly disabled. It goes through the same
wrapper, so hardcoding parseMath:false degraded an exported `$x^2$` to literal
text on re-import, breaking the #328 lossless export→import pair. The wrapper no
longer hardcodes the flags: it takes them from the caller and DEFAULTS to the
package importer defaults (extensions ON). Callers now set them explicitly:
  - updatePageMarkdown (updatePageContentRealtime) -> OFF
  - patch_node/insert_node (importMarkdownFragment) -> OFF (unchanged)
  - import_page_markdown -> DEFAULTS (math ON) — #328 round-trip restored

Tests: rewrite the mcp write test around the corrected per-caller semantics
(agent-write OFF, import_page_markdown DEFAULTS incl. the REAL #328 round-trip);
add a collab-backed wiring test driving client.updatePage (OFF) and
client.importPageMarkdown (DEFAULTS) end-to-end; add a createPage multipart-flag
test; add a server import.service.processMarkdown spec (flag OFF -> literal / no
autolink, default -> math ON for human uploads); reword the prosemirror-markdown
round-trip test to its package-default scope. Mutation-verified both caller
wirings (flip updatePageMarkdown -> defaults reddens the OFF wiring test; make
the wrapper default OFF reddens the #328 round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:58:48 +03:00
agent_coder 3e81f64415 fix(work-time): user idle breaks agent burst; cap tz length (#395)
Only an agent-sourced idle pulse extends an agent burst; a user idle
(human supervision) now falls through to the human branch so the session
is classified `work`, not `agent_only`. Add @MaxLength(64) to tz to match
its comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:53:59 +03:00
agent_coder d36021a111 fix(comment): clear resolved mark only after reopen confirmed
Move the inline ProseMirror comment-mark clear out of the Undo toast's
onClick and into the reopen mutation's onSuccess branch. Clearing it
eagerly flipped the collab mark to unresolved before the reopen result
was known: on a non-404 failure the RQ cache rolls back to resolved but
the doc kept an active highlight the panel treats as resolved, and a
comment refetch can't heal a divergence that lives in the collab doc.
Mirrors the 404 branch's editor-liveness guard/try-catch and the safe
await-then-mark pattern in comment-list-item. The button-triggered
reopen already sets the mark, so the onSuccess call is an idempotent
no-op there.

Tests: reopen failure (500) leaves the mark untouched; null editorRef
on the success path degrades gracefully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:51:38 +03:00
agent_coder c674db2b2f feat(page-history): «время работы над статьёй» — число в UI + суточный punch-card (#395)
Оценка времени работы над страницей как сессионизация истории page_history по
паузам бездействия (WakaTime-подобно), а не span между крайними правками, плюс
drill-down в суточный таймлайн 24ч×дни. Зависит от #374 (kind + idle-пульс).

Server:
- Чистая computeWorkTime(rows, config) (§5): нормализация+дедуп → коллапс
  агентских всплесков по aiChatId → ОДИН проход сессионизации (порог зависит от
  пары: оба agent → agentTGap, иначе tGap; последняя сессия обязательно
  закрывается) → класс готовой сессии (все agent → agent_only, иначе work) →
  добивка (много-сэмпл → [first−P_in, last+P_out], одиночный скаляр →
  [t−P_single, t]) → метрики = union wall-clock по классу. Детерминированная,
  без БД.
- Чистая bucketByDay(sessions, tz) (§6.3): union work/agent_only РАЗДЕЛЬНО, разрез
  по полуночи tz через нативный Intl (DST-корректно, без «+24ч»). Инвариант
  Σ activeMs == workMs держится по построению.
- PageHistoryRepo.findTimelineByPageId — лёгкая проекция без content, ASC.
- PageHistoryService.computeWorkTime + POST /pages/history/time, гейт
  validateCanView как у /history; атрибуция человек/агент по lastUpdatedSource.

Client:
- usePageWorkTime(pageId) шлёт tz зрителя (Intl локаль).
- Кликабельное число «≈ 4 ч 30 мин» в шапке страницы (порог в тултипе).
- Модалка-punch-card: 24-часовые дорожки по дням, окна work/agent разным цветом,
  одиночные (P_single) приглушены, сумма за день, пустой день «—», сворачивание
  длинных серий пустых дней, подпись tz + T_gap. i18n ru-RU + en-US.

Tests: 22 юнита на computeWorkTime/bucketByDay (§7-фикстура ≈1ч32м vs ≈60h наив,
закрытие последней сессии, коллапс/разрыв всплеска, idle-пульс, DST 23/25ч,
полуночный разрез, инвариант, union-без-задвоения); 5 юнитов гейта контроллера;
int-spec на реальном pg (проекция + совпадение с ядром); 6 клиентских на формат.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:32:12 +03:00
agent_coder 44cdb5a4c3 feat(mcp): disable math+autolink in MCP markdown-write; getPage format:text for machine diff (#502)
WRITE: parameterize the canonical importer markdownToProseMirror with
{parseMath, fuzzyLinkify} (defaults true — editor/file-import/git-sync unchanged);
MCP write paths (createPage/updatePageMarkdown/patchNode/insertNode) call with both
false so $...$ stays literal text and bare domains don't autolink (explicit
https:// still links). READ: getPage format:'text' reuses the server jsonToText path
(deterministic flag) for flat machine-diffable text, [image]/[table RxC] placeholders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:18:31 +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 12ed3a0332 feat(comment): undo button in resolve toast + sort resolved tab by resolve time
Part 1 (#542): add an inline Undo (reopen) button to the resolve success
toast in useResolveCommentMutation. Undo re-invokes the same mutation via a
self-ref (declared before useMutation, read at click time — no cycle) and
clears the inline comment mark through an editor ref (survives the originating
CommentListItem unmounting). A closured `done` flag guards a fast double-click
(notifications.hide is async). Reopen keeps the plain toast (no Undo).

onError gains a terminal 404 branch: drop the comment from the cache and
unsetComment its orphaned mark (no rollback → no phantom in Resolved, neutral
"Comment no longer exists"). The generic-failure copy is now directional
(resolve vs. re-open). Autoclose policy const RESOLVE_UNDO_AUTOCLOSE_MS=10000.

Part 2: sort the Resolved tab by resolvedAt DESC via an exported
sortResolvedByResolvedAt helper; coerce with new Date(...) because resolvedAt
is an ISO string at runtime (Date only in the optimistic window).

i18n: add "Failed to re-open comment" and "Comment no longer exists" to
en-US/ru-RU. Tests: 6 resolve-undo cases + 4 sort cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:45:42 +03:00
agent_coder b3cc6a7ff8 Merge remote-tracking branch 'gitea/develop' into feat/481-version-coherence 2026-07-12 05:36:59 +03:00
agent_coder c42cb42413 docs(reload-guard): align header/docstring wording to the window model (#481 review F2 consistency)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:36:58 +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 41030b2c78 test+docs(deploy): покрыть reactive fail-closed guard + выровнять формулировки под оконный бюджет (#499 ревью)
F1: интеграционный тест на reactive путь при storage, который ЧИТАЕТ, но не
ПИШЕТ (quota / Safari private): getItem→null (бюджет доступен, проходим
hasAutoReloaded), setItem→throw → markAutoReloaded()===false → guard на
chunk-load-error-boundary.tsx:43 обязан отбить reload, иначе реактивный путь
зациклил бы reload без сохранённого бюджета. Симметричный proactive случай уже
покрыт guarded-reload.test.tsx. Мутация (снять guard :43) → тест краснеет.

F2: формулировки "per session / this session" в version-coherence.ts и
guarded-reload.tsx приведены к оконной модели ("per RELOAD_WINDOW_MS window" /
"window budget available") — reload-guard.ts и тест восстановления после окна
уже описывают именно окно, а не постоянный one-shot на сессию.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:32:33 +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 74387bb047 test(#522): make the internal-link subset invariant a real cross-package drift guard
Reviewer follow-up on the #522 internal-link import fix. The fix itself is
confirmed correct; these three changes harden its load-bearing subset invariant
and fix a stale doc.

Finding 1 (drift guard): the in-package test only compared isInternalPagePath
against a HAND-COPIED server regex, so a later narrowing of the real server
INTERNAL_LINK_REGEX would leave the test green while the client silently became
WIDER than the server. Add apps/server/.../export/internal-link-parity.spec.ts:
the top layer already depends on @docmost/prosemirror-markdown and exports
isInternalPagePath, so this spec imports the LIVE server INTERNAL_LINK_REGEX AND
the LIVE isInternalPagePath and asserts subset over an accept-corpus — a server
narrowing now reddens CI. The in-package manual copy is demoted from its
"drift guard" role (comment updated to say so; it stays as local documentation).

Finding 2 (charset non-vacuity): the REJECT list was purely structural, so the
mutation widening the slug charset [a-zA-Z0-9-] -> [a-zA-Z0-9-.] survived the
whole suite. Add REJECT cases whose ONLY defect is a forbidden slug character
(abc.def / abc_def / abc%20 / abc~x); assert both isInternalPagePath and the
server regex reject them. The mutation now reddens the new reject test.

Finding 3 (stale JSDoc): canonicalize.ts KNOWN_DEFAULTS module blurb claimed
every entry was read from docmost-schema and import-materialized. The new
link.internal default breaks both claims (source is editor-ext/src/lib/link.ts;
external links leave internal absent/null, never false). Add a bullet documenting
the one editor-sourced, non-materialized default (false ≡ absent/null).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:06:59 +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_coder 64566e9327 test(deploy): согласовать reload-guard/chunk-load тесты с общим оконным бюджетом (#481, rebase reconcile)
После ребейза на develop версии-когерентность (#481, общий one-shot флаг)
и chunk-load boundary (#495, оконный guard) были сведены к ОДНОМУ общему
оконному бюджету в @/lib/reload-guard: не более одного авто-reload за
RELOAD_WINDOW_MS суммарно по обоим путям, при этом второй деплой в той же
вкладке восстанавливается.

- reload-guard.test.ts: переписаны под оконную семантику (ключ chunk-reload-at
  хранит таймстамп, а не флаг); сюда же перенесены чистые тесты shouldAutoReload.
- chunk-load-error-boundary.test.ts: убран импорт shouldAutoReload (переехал в
  reload-guard).
- chunk-load-error-boundary.tsx: handleError экспортирован для теста инварианта.
- reload-budget.integration.test.tsx: инвариант (a) на РЕАЛЬНОМ guard — reload
  на одном пути тратит общий бюджет для другого в пределах окна, после окна
  восстановление, при недоступном sessionStorage ни один путь не перезагружает.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:01:36 +03:00
agent_coder 10a4326fbf docs(deploy): поправить устаревший коммент про auto-reload скрытой вкладки под вариант C (#481, ревью F1)
r1-коммент утверждал «auto-reload on a hidden tab», но вариант C (r2) убрал
авто-reload скрытой вкладки ради защиты несохранённого ввода. Переписал под
фактическое поведение: баннер + отложенный reload на следующей in-app навигации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:55:22 +03:00
agent_coder 68409a8ae9 fix(deploy): variant C — reload по in-app навигации вместо visibilitychange + лог-след + CHANGELOG (#481, ревью)
Правки по ревью #499. Владелец выбрал вариант C по вопросу потери несохранённого
ввода.

F1 (reload по навигации): убрал ветку visibilitychange→reload И немедленный reload
скрытой-при-получении вкладки — visibility больше не влияет на авто-reload вообще.
На реальном mismatch: баннер (кнопка «Обновить» — немедленный reload под тем же
one-shot гардом) + модульный флаг pendingNavReload. Хук useVersionReloadOnNavigation
(useLocation + useEffect по location.key, пропуск первого рендера через useRef;
react-router v7 BrowserRouter компонентный, объекта роутера нет — подписка изнутри
дерева) на СЛЕДУЮЩЕЙ навигации зовёт consumeNavigationReload → performAutoReload
(mark-перед-reload, при spent/непишущемся storage — только баннер). Скрытая вкладка
идёт тем же путём (C единообразно). chunk-load-error-boundary reload не тронут —
остаётся бэкапом для не-навигирующей вкладки. Так reload в безопасной точке (юзер
и так уходит со страницы), а не когда свернул с недописанным комментарием.

F2 (лог-след): общий recordReloadBreadcrumb/takeReloadBreadcrumb (sessionStorage,
переживает reload). performAutoReload пишет breadcrumb {path:proactive,server,
client} + console.warn перед reload; chunk-boundary пишет {path:chunk-boundary};
surfacePreviousReloadBreadcrumb на маунте UserProvider логирует прошлый след.

F3 (CHANGELOG + AGENTS.md): user-facing запись про version-coherence + строка про
артефакт client/dist/version.json.

Тесты: vitest version-coherence+reload-guard+guarded-reload 23 (переписан на
MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ
на 2-й навигации, НЕ на 2-м mismatch one-shot, НЕ от ухода в фон; скрытая вкладка
— только на навигации; Update — сразу; banner-only/write-fail — никогда). Шире:
features/user 34.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:55:22 +03:00
agent_coder fc624f5a4b feat(deploy): version-coherence — WS-анонс версии сервера + guarded reload устаревших вкладок (#481)
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время
передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в
новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html →
WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный
предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал
ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и
делает guarded reload ДО битого чанка. closes #481

- Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз →
  и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл
  тождественны by construction. Сервер читает client/dist/version.json при старте
  (client-version.ts: resolveClientDistPath вынесен из static.module — единый
  источник пути; readClientBuildVersion → версия или '' на любой ошибке,
  fail-safe). НИКАКИХ Docker/ENV-изменений.
- WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent —
  член без operation сломал бы union). ws.gateway читает версию в onModuleInit +
  boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join).
  ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd
  reload флота; естественный реконнект покрывает и single-container, и кластер).
- Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до
  connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction
  (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка
  triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер
  + reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить».
- Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над
  существующим chunk-reload-attempted); chunk-load-error-boundary переведён на
  него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна
  авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled
  storage → после первого reload только баннер, петли нет (проверено трассировкой).

Проверка: server jest client-version 7/7; client vitest version-coherence+
reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника:
APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл.
Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне
автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:54:35 +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