Commit Graph

1911 Commits

Author SHA1 Message Date
agent_coder bfcee6dddc test(prosemirror-markdown): generative round-trip testing — attribute level, flat docs (#351 PR 1)
Schema-derived, property-based (fast-check) round-trip tests over flat
single-node ProseMirror documents. One test PR — src/ is untouched; the two
real bugs found are pinned as loud it.fails counterexamples, not fixed here.

- attr-arbitraries.ts: per-attribute four-state arbitraries (absent/default/
  nonDefault/degenerate), attribute list sourced from schema.nodes[t].spec.attrs;
  a documented override table supplies legal domains for constrained attrs and
  distinguishes two frozen classes explicitly — ACCEPTED limitations (no md
  representation) vs PINNED bugs (representable but dropped, tracked as
  counterexamples).
- text-arbitraries.ts: hostile text corpus (ported from the existing property
  test's supported-space guarantees).
- node-generators.ts: flat single-node generators + a completeness contract —
  every one of the schema's 45 nodes / 12 marks is either generated or listed in
  KNOWN_UNCOVERED with a reason.
- flat-roundtrip.property.test.ts: P1 (semantic round-trip via
  docsCanonicallyEqual), P2 (second-pass byte fixpoint — anti GS-EDIT-REVERT),
  P3 (totality), generator validity via schema.check(), and an explicit
  attribute-value-coverage snapshot so the not-fuzzed set can never grow silently.
- counterexamples: column.width (% dropped on parseFloat -> P2 churn) and
  orderedList.start (non-1 start renders as '1.' -> P1 loss) pinned as it.fails.

SEED=20250705, NUM_RUNS=300 per property; ~17s, no OOM (union arbitraries).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 04:38:40 +03:00
agent_vscode e89ac627dd fix(migrations): rename ai-chat-runs migration to post-merge timestamp
20260627T130000-ai-chat-runs sorted before the already-executed
20260702T120000-ai-chat-page-snapshot, so Kysely's strict ordering
check ("corrupted migrations") crash-looped the server on startup.

- rename 20260627T130000-ai-chat-runs.ts -> 20260704T130000-ai-chat-runs.ts
- update the mirror comment in database/types/db.d.ts
2026-07-05 00:59:05 +03:00
vvzvlad f665f6fdd2 Merge pull request 'feat(ai-chat): autonomous agent runs — phase 1: durable detached runs (#184)' (#234) from feat/184-autonomous-agent-runs into develop
Reviewed-on: #234
2026-07-05 00:40:26 +03:00
vvzvlad 7af85b476e Merge pull request 'feat(observability): дев-часть перф-метрик — /metrics :9464 + client vitals (#355)' (#358) from feat/355-perf-metrics into develop
Reviewed-on: #358
2026-07-05 00:31:04 +03:00
agent_coder 5d8364bb5f fix(#355 review round-2 F9-F11): register-gate test + shutdown idle-close + DB-path metrics gate
- F10 [stability]: closeMetricsServer() now calls server.closeIdleConnections()
  + server.unref() after server.close(). server.close()'s callback doesn't fire
  until keep-alive sockets drain, and the scraper (VictoriaMetrics/vmagent) holds
  an idle keep-alive socket — so onModuleDestroy's awaited close would hang until
  the scraper disconnects or the orchestrator SIGKILLs on the kill-grace window.
  closeIdleConnections() drops idle keep-alive sockets so shutdown completes
  immediately (Node 22, per the Dockerfile base).
- F9 [test]: client-telemetry.module.spec.ts pins the E1=B register() gate — the
  core of the "public endpoint OFF by default" decision: flag unset / any non-
  "true" value ("false"/""/"0"/…) → empty controllers+providers (route absent);
  "true"/"TRUE" → registers VitalsController + VitalsService. A flag-inversion or
  truthiness regression that reopened the anonymous disk-fill surface now fails.
- F11 [regression/perf]: the db_query_duration_seconds token work (firstSqlToken
  regex + Set lookup) is now gated on isMetricsEnabled() in database.module.ts, so
  a non-metrics deployment pays NOTHING per query (previously observeDbQuery
  no-op'd but the token was still computed on every query). Also hoisted the
  13-element known-token Set to a module const (KNOWN_SQL_TOKENS) so it's built
  once, not per query.

Gate: server tsc 0; metrics + vitals + client-telemetry suites pass (incl. the
new register-gate test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:20:26 +03:00
agent_coder d3209b5aab fix(#355 review E1=B + F1-F8): gate client telemetry OFF by default + throttler/lifecycle/overflow fixes
Maintainer resolved E1 as variant B: the public vitals sink + client collection
must be OFF by default (else client_metrics grows unbounded on a self-host deploy
with no external pruner, via an unauthenticated public endpoint).

- F1: new operator flag CLIENT_TELEMETRY_ENABLED (default OFF), SEPARATE from
  METRICS_PORT (Grafana reads the table directly, independent of the scrape port).
  ClientTelemetryModule.register() provides VitalsController ONLY when the flag is
  true (route absent otherwise); the flag reaches the client via window.CONFIG
  (config.ts isClientTelemetryEnabled), and initVitals() early-returns when off.
- F2/F3 [throttler]: this repo's ThrottlerGuard applies EVERY named throttler to
  every guarded route unless skipped. The new VITALS bucket therefore (a) newly
  bound collab-token → 429 behind shared/NAT IPs, and (b) the vitals route didn't
  skip the stricter public-share-ai (5/min) bucket → effective 5/min not 120.
  Fix (additive, global config unchanged): vitals.controller @SkipThrottle the
  other buckets + @Throttle VITALS 120/min; collab-token adds VITALS_THROTTLER to
  its existing @SkipThrottle (restoring its prior effectively-unthrottled state).
- F4: metrics node:http server is closed on shutdown (MetricsServerLifecycle
  OnModuleDestroy → closeMetricsServer(), fired by enableShutdownHooks).
- F5: docSize outside [0, int4-max] drops to null (keeping the event) instead of
  overflowing int4 and failing the WHOLE batch insert (+ 2 tests).
- F6: .env.example documents METRICS_PORT (no default — unset = subsystem OFF) +
  CLIENT_TELEMETRY_ENABLED; fixed the inaccurate "default 9464" wording.
- F7: disabled/non-sampled sessions install ZERO observers — isVitalsActive()
  (enabled && sampled) gates reportClientMetric AND the page-editor
  measurePageOpen + dispatchTransaction wrapping.
- F8: kept db.d.ts hand-added (wontfix) — this repo HAND-CURATES db.d.ts (verified
  across recent fork migrations a32fba63/8c5b57eb/fdeede00); codegen would be the
  deviation. The ClientMetrics interface maps the migration 1:1.

Gate: server tsc 0, client tsc 0, server metrics/vitals/telemetry/throttle 21
tests, client route-template 5. No new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:00:03 +03:00
agent_coder 68899a2c2e feat(ai-chat): durable detached agent runs — phase 1 (#184/#234)
Squashed for a clean rebase onto develop (was 19 commits; the reviewer approved
the net diff at fb246080). Detaches an agent run from the HTTP request/browser
window: a run is a first-class lifecycle object (ai_chat_runs), a browser
disconnect no longer kills it, a concurrent-run insert-gate prevents double runs,
and a reopened chat live-follows a still-running run via a polled observer merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:35:26 +03:00
agent_coder b9f3de80f5 feat(observability): dev-side perf metrics — /metrics + client vitals (#355)
The metrics INFRA is already deployed (VictoriaMetrics scraping docmost:9464,
Grafana dashboards, alerts) with a target `gitmost-app` that is red because the
app half didn't exist. This is that half. The contract (metric names, port,
table, endpoint) is FIXED by the deployed infra and matched exactly.

Server (prom-client):
- A bare node:http `/metrics` server on METRICS_PORT (default 9464), SEPARATE
  from the Fastify :3000 listener so /metrics never exists publicly; the whole
  subsystem is OFF when METRICS_PORT is unset.
- collectDefaultMetrics() + http_request_duration_seconds{method,route,status}
  via a Fastify onResponse hook using the ROUTE TEMPLATE (req.routeOptions.url,
  never the raw URL — bounded cardinality; 404 -> "unknown"), EXCLUDING SSE/
  streaming responses (would record the connection lifetime and poison p95).
- db_query_duration_seconds (Kysely log callback, labelled by the leading SQL
  token), bullmq_queue_depth{queue} (getJobCounts every 15s) +
  bullmq_job_duration_seconds{queue} (worker completed/failed),
  collab_store_duration_seconds (around onStoreDocument).
- POST /api/telemetry/vitals — PUBLIC (sendBeacon) but IP-throttled; ~16KB body
  cap, <=50 events/batch, metric-name + rating whitelist, attr truncated to 120
  chars, batch insert; malformed/foreign/oversized silently dropped and 200'd (no
  browser retry). New migration `client_metrics` (schema byte-identical to the
  contract, both indexes, conditional grafana_ro GRANT; no app-side retention —
  the maintenance container prunes >90d).

Client (web-vitals):
- initVitals() decides sampling ONCE per session (25%, sessionStorage) BEFORE
  subscribing; onINP/onLCP/onCLS/onTTFB (attribution) buffered + flushed via
  navigator.sendBeacon on visibilitychange:hidden and a timer (not fetch-per-
  metric). Custom: editor_tx_ms (dispatchTransaction sync-part timer, >8ms, with
  doc_size), page_open_ms, longtask_ms. Route labels are templates only; no
  titles/slugs/text.

Gate: server + client tsc 0, frozen install 0 (added prom-client + web-vitals +
regenerated the lock), server metrics/vitals tests 11, client route-template 5,
and the migration verified valid against real Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:10:29 +03:00
agent_vscode 5336f06d10 Merge pull request 'fix(e2e)+ci: канон callout '> [!info]' в e2e-mcp + параллельная сборка с гейтом на publish' (#356) from fix/e2e-callout-and-gate-build into develop 2026-07-04 22:42:11 +03:00
agent_vscode 4bd579f7f6 ci(develop): build image in parallel with tests, gate only the publish
Two-phase scheme instead of the sequential gate: the build job runs in
parallel with test/e2e jobs and only warms the buildx GHA cache
(push:false, cache-to mode=max); a new publish job (needs: test,
e2e-server, e2e-mcp, build) rebuilds from the warm cache (near-instant
on hit, full rebuild on eviction — same as the old sequential timing)
and pushes :develop. GHCR login moved to publish; build-args blocks are
kept textually identical between the two jobs so the cache hits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:41:25 +03:00
agent_vscode 7bf1c91a95 ci(develop): gate the :develop image build on e2e suites
Reverse the previous policy where e2e jobs only turned the run red
without blocking the image publish: build.needs now lists test,
e2e-server and e2e-mcp, so a failing test of any kind stops the
:develop image from being built and pushed. Stale policy comments
updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:33:06 +03:00
agent_vscode 6c82c54470 test(mcp): expect Obsidian '> [!info]' callout export in e2e (#333 canon)
PR #333 deliberately changed the canonical markdown export of callout
nodes to the Obsidian-native format ('> [!type]' + blockquote body,
pinned by packages/prosemirror-markdown unit tests); the importer still
parses both ':::type' fences and '> [!type]'. The get_page e2e assertion
was missed in that switch and still expected ':::info', failing the
e2e-mcp job on develop since 4369bbc5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:33:06 +03:00
agent_vscode 382e5196da Merge pull request 'fix(docker): toolchain python3/make/g++ для нативной сборки re2' (#353) from fix/docker-re2-toolchain into develop 2026-07-04 22:11:49 +03:00
agent_vscode 76e0c08cec fix(docker): install python3/make/g++ toolchain for re2 native build
The develop image build broke at `pnpm install --frozen-lockfile`: the new
native dependency re2@1.25.0 (packages/mcp, search_in_page #330) always
compiles from source under pnpm — its prebuilt-binary downloader
(install-artifact-from-github) cannot identify the GitHub repo because pnpm
does not populate npm_package_repository_*/npm_package_json env vars ("No
github repository was identified. Building locally ..."), and node:22-slim
ships no python3/make/g++ for the node-gyp fallback.

- builder stage: add a cache-friendly apt layer with python3 make g++
  before COPY; the stage is discarded so the toolchain may stay.
- installer stage: install the toolchain, run the prod install as the node
  user via `su node -c`, and purge the toolchain — all in one RUN layer so
  the final image stays slim and node_modules ownership needs no extra
  chown layer; USER node is restored right after.

Fixes the failed run 28715009124 (develop docker build); release.yml uses
the same Dockerfile and is covered too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:09:40 +03:00
vvzvlad 8978d69f3e Merge pull request 'fix(converter): стабильность round-trip image/медиа — «» ≡ absent (класс defaults-instability)' (#350) from fix/media-roundtrip-stability into develop
Reviewed-on: #350
2026-07-04 21:30:12 +03:00
agent_coder c192f2a2e1 test(prosemirror-markdown): pin the third state — explicit "" converges once, then idempotent
Reviewer addition to the round-trip stability matrix: besides "attr absent" and
"attr has a real value", a string attr in the empty-string class has a third,
degenerate state — a LITERAL "" (a user types alt/title/name in the editor then
deletes it, and Tiptap persists `attr: ""`, distinct from never-set). The fix's
`getAttribute(...) || null` coercion normalizes such a stored "" to the default
on the FIRST round-trip (a one-time "" -> null diff) and is byte-stable from the
SECOND round-trip on.

Adds a convergence contract to the reusable matrix helper (emptyStringClass flag
+ runConvergenceCase): pass 1 must converge the attr to its schema default (NOT
asserted byte-stable vs the "" input — that is the intended one-time
normalization); pass 2 must deep-equal pass 1 (idempotent thereafter). Driven for
every empty-string-class attr across image + the media family (image/drawio
alt+title, video alt via aria-label, pdf/attachment name, attachment mime).
Documents the one-time normalization so a future sync/QA diff does not flag the
single "" -> null change as converter corruption.

Gate: package suite 33 files / 682 tests passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:17:17 +03:00
vvzvlad d78b985062 Merge pull request 'perf(comment): статический рендер + ленивые редакторы + мемоизация панели (#340)' (#349) from fix/340-comment-panel-perf into develop
Reviewed-on: #349
2026-07-04 20:55:11 +03:00
agent_coder 2ce672709a fix(prosemirror-markdown): stabilize image round-trip — "" ≡ absent on parse (empty-string class)
A stored image authored without `alt` gained a phantom `alt: ""` on every
round-trip (`markdownToProseMirror(convertProseMirrorToMarkdown(doc))`): `marked`
renders `![](src)` as `<img alt="">`, and the stock tiptap Image `alt` parseHTML
(`getAttribute("alt")`) materialized the empty string where the original had no
attribute. That false diff is a real GS-EDIT-REVERT churn source — an agent /
git-sync touch of a page with an image mutates the stored JSON (`absent -> ""`),
producing phantom diffs that can overwrite live edits.

Fix is PARSE-SIDE ("" ≡ absent), so the RAW round-trip is idempotent — not only
the canonical form (history / stored JSON diff on the raw shape; masking it only
in canonicalize would leave that noise). `image.alt`/`title` parseHTML now coerce
`getAttribute(...) || null`, plus defense-in-depth `|| null` across the at-risk
empty-string class (video aria-label, drawio/excalidraw title+alt, pdf name,
attachment name+mime) matching the existing `image.caption || null` precedent.

NOTE — image `align` is NOT changed: it round-trips correctly (center via the
schema default "center", left/right via the `<!--img {...}-->` comment). Its
`toBeUndefined()` in the git-sync gate is canonical-form normalization, not a loss.

Intentional divergence from editor-ext: editor-ext's literal `alt` parseHTML
returns "" verbatim, but this coercion CONVERGES on editor-ext's real STORED
shape (an image inserted without alt has no `alt` attribute -> re-parses absent,
never ""), so the round-trip is idempotent and matches real documents.

Adds a reusable, node-agnostic round-trip-stability matrix helper
(test/roundtrip-stability.helper.ts) — given a node + attr spec it enumerates
default/non-default combos and asserts byte-stability of BOTH the raw and the
canonical round-trip (the documented numeric width/height→string coercion encoded
as an explicit allowed normalization) — driven over image + the whole media
family (video/audio/pdf/attachment/embed/drawio/excalidraw). The only raw
empty-string instability it found was image.alt; the family was already stable.

Gate: package suite 33 files / 672 tests passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:51:34 +03:00
agent_coder a4fc6c7f64 fix(comment): underline mark + draft-surviving tabs + test coverage (#349 review F1-F4)
- F1: render the `underline` mark statically (StarterKit v3 enables Underline;
  comment-editor does not disable it) — an underlined comment no longer degrades
  the whole comment to the read-only editor fallback. renderMarks gains a
  `case "underline" -> <u>`, mirroring the other marks (+ test).
- F2: keep the Open tab panel mounted (`Tabs.Panel value="open" keepMounted`)
  while the heavy Resolved panel still unmounts (`Tabs keepMounted={false}`). A
  per-panel keepMounted overrides the parent's `false` (Mantine 8 TabsPanel), so
  an in-progress reply draft / edit in the Open panel survives an
  Open->Resolved->Open switch, keeping the micro-opt of not mounting the large
  Resolved list.
- F3: cover edit->save->re-render in comment-list-item.test.tsx — save calls
  mutateAsync with JSON.stringify(editContentRef) and a new comment.content prop
  updates the visible body; cancel restores the static body without mutating;
  clearing editContentRef after cancel.
- F4: extract childrenByParent grouping into an exported pure
  `buildChildrenByParent(items)` (unit-tested: nesting, orphan reply, sibling
  order) + new comment-list-with-tabs.test.tsx covering the lazy reply-editor
  activation (stub -> click/focus/Enter mounts the editor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:48:21 +03:00
vvzvlad c252068672 Merge pull request 'feat(ai-chat): отложенная загрузка инструментов (deferred tools + loadTools) (#332)' (#341) from fix/332-deferred-tools into develop
Reviewed-on: #341
2026-07-04 20:47:45 +03:00
agent_coder 68caf8157a test(ai-chat): document AI_CHAT_DEFERRED_TOOLS + pin ON-path & catalog completeness (#341 review F1-F3)
- F1: document AI_CHAT_DEFERRED_TOOLS in .env.example (AI_* section) — default
  ON = deferred loading (compact catalog + loadTools), =false restores the old
  "all tools always active" behavior.
- F2: integration test of the ON path in ai-chat-stream.int-spec.ts — a deferred
  tool activated via loadTools is active on the SAME turn's next step but a fresh
  turn starts cold (CORE + loadTools only), proving the per-turn activatedTools
  Set does not leak across turns/chats. Drives the real streamText loop with a
  MockLanguageModelV3 and inspects recorded per-step activeTools-filtered tools.
- F3: replace the magic toHaveLength(28) in tool-tiers.spec.ts with a two-way
  partition against the LIVE in-app toolset (AiChatToolsService.forUser keys):
  every non-core tool must appear in buildInAppDeferredCatalog and every catalog
  entry must map to a real non-core tool — so a future tool forgotten in
  INLINE_TOOL_TIERS fails the suite instead of silently vanishing from the agent.

No production logic change (mechanism was already reviewed correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:34:42 +03:00
agent_coder cb9c5dda59 perf(comment): static comment renderer + lazy editors + memoized list (#340)
The comment panel lagged for seconds on open and stuttered on every resolve/apply
with many comments (real case: 30 open + 326 resolved ≈ 356 threads), because each
comment body mounted a full TipTap/ProseMirror editor, both tabs mounted at once,
and any mutation re-rendered the whole list.

- CommentContentView: static recursive renderer of comment ProseMirror JSON (no
  editor instance) for the read-only body — supports exactly CommentEditor's node
  set (doc/paragraph/text/hardBreak/mention) + marks (bold/italic/strike/code/
  link), reproducing the 3-level DOM nesting for pixel-identical CSS. Unknown
  node/mark or unparseable content degrades that one comment to the read-only
  CommentEditor; legacy non-JSON strings render as plain text.
  SECURITY: link hrefs are protocol-allowlisted (safeHref, mirroring
  @tiptap/extension-link) so a stored comment with a `javascript:`/`data:` href
  cannot XSS — the old TipTap read-only path sanitized this; the static renderer
  must too. Control-char smuggling (java\tscript:) is stripped before the check.
- MentionContent extracted from MentionView, shared by the TipTap NodeView and the
  static renderer (identical user/page-mention behavior).
- keepMounted={false} on the tabs: the inactive tab no longer mounts its editors.
- Lazy reply editor: a stub until click/focus, then the real editor (kept mounted
  so the draft survives thread re-renders).
- React.memo(CommentListItem) + a childrenByParent map (replaces the per-thread
  O(n^2) filter) + localized reply-send pending state: resolve/apply/reply now
  re-render only the touched thread.
- Progressive first paint: useCommentsQuery no longer blocks on hasNextPage.

Gate: client comment+mention suites 22/22 passed, tsc --noEmit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:20:32 +03:00
claude code agent 227 e431b33bb1 feat(ai-chat): deferred tool loading (tiers + loadTools meta-tool) (#332)
The in-app AI agent shipped all ~41 tool schemas on every model step. This
adds a two-tier catalog: core tools (frequent or one-line) stay always-active;
the rest are advertised as a compact catalog and their full schema is fetched
on demand via the loadTools meta-tool, wired through ai@6 prepareStep's
per-step activeTools.

- tools/tool-tiers.ts: CORE_TOOL_KEYS, INLINE_TOOL_TIERS, applyLoadTools,
  catalog builders (+ tool-tiers.spec.ts, 13 cases).
- ai-chat.service.ts prepareAgentStep: returns activeTools =
  [...CORE_TOOL_KEYS, loadTools, ...activatedTools]; per-turn activated Set.
- ai-chat.prompt.ts: buildToolCatalogBlock renders the deferred catalog.
- mcp/tool-specs.ts: tier + catalogLine metadata (external snake_case /mcp
  transport unchanged).
- EnvironmentService.isAiChatDeferredToolsEnabled(): AI_CHAT_DEFERRED_TOOLS,
  default ON per issue intent (kill-switch =false restores old behavior).

Gate: server ai-chat 631/631, tool-tiers 13/13, mcp 472/472, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:57:11 +03:00
vvzvlad 4369bbc53d Merge pull request 'refactor(converter): единый пакет @docmost/prosemirror-markdown + канон форматов, git-sync и mcp переключены (#293, шаги 2–5)' (#333) from feat/293-B-prosemirror-markdown-pkg into develop
Reviewed-on: #333
2026-07-04 19:35:53 +03:00
claude code agent 227 8e5ad8070b fix(lock): repair pnpm-lock.yaml broken by develop merge (#333 F-lock)
The develop merge (eacc1c48) left an importer pointing at a vitest@4.1.6
peer-variant WITHOUT @vitest/coverage-v8 that has no snapshot entry, so
`pnpm install --frozen-lockfile` failed with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY
(CI + Docker red at install). Regenerated with `pnpm install --lockfile-only
--fix-lockfile` (pnpm 10.4.0, matches packageManager pin): the importer now
resolves to the existing coverage-v8 variant; two transitive pointers realigned.
No package.json / source change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:27:09 +03:00
vvzvlad cfc105c7d6 Merge pull request 'feat(comment): эфемерные предложения-правки — Apply/Dismiss убирают комментарий (#329)' (#338) from fix/329-ephemeral-suggestions into develop
Reviewed-on: #338
2026-07-04 19:22:14 +03:00
claude code agent 227 d7fa6738e5 fix(comment): transactional childless-delete race fix + client dismiss gate + DB int-spec (#329 review round 2)
F4 [critical] — the anti-join `DELETE … WHERE NOT EXISTS(child)` was still racy
under Postgres READ COMMITTED: a reply INSERT holds FOR KEY SHARE on the parent;
the DELETE's start snapshot doesn't see the uncommitted child (NOT EXISTS true),
blocks on the reply's lock, and when the reply commits the parent was only LOCKED
(not modified) so EvalPlanQual does NOT re-check → the DELETE proceeds and CASCADE
destroys the just-committed reply. Replaced with a transaction: SELECT the parent
FOR UPDATE (conflicts with the reply's FOR KEY SHARE → serializes the concurrent
reply), re-check for a child with a FRESH statement in the same tx (a new RC
snapshot sees a just-committed reply), delete only if still childless (return 1)
else return 0 (caller resolves). The FOR UPDATE lock is held to end-of-tx so no
reply can insert between the re-check and the delete. Signature unchanged, so the
service + its mocked unit tests are untouched; docstrings updated.

F5 [warning] — the client Dismiss button was gated only on canComment, but the
server now gates dismiss on owner-or-space-admin, so a non-owner non-admin saw a
button the server 403s. `canShowDismiss` now also requires
`isOwnerOrAdmin = currentUser?.user?.id === comment.creatorId || userSpaceRole ===
"admin"` (the same gate the comment delete-menu already uses); threaded into both
call sites.

F6 [warning] — added a REAL-DB int-spec
(apps/server/test/integration/comment-delete-if-childless.int-spec.ts, + a
createComment seeder): (a) childless → returns 1, row gone; (b) committed reply →
returns 0, parent+reply survive; (c) CONCURRENCY — a second connection inserts a
reply (FOR KEY SHARE) and commits mid-operation while deleteCommentIfChildless
blocks on FOR UPDATE → asserts it returns 0 and both rows survive (a blind
anti-join would lose the reply here). Ran against live Postgres — 3/3 pass.

server tsc clean; comment jest 53 + int-spec 3 (live Postgres) pass. client tsc
clean; comment vitest 56 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:17:52 +03:00
claude code agent 227 e6d8eda8e5 fix(comment): dismiss owner/admin authz + atomic conditional delete + 404-only onError (#329 review)
Maintainer escalation decision (B) + reviewer findings on the ephemeral-
suggestion PR.

Authz (decision B): POST /comments/dismiss-suggestion now gates the destructive
branch on owner-OR-space-admin, mirroring POST /comments/delete exactly (same
SpaceCaslAction.Manage / SpaceCaslSubject.Settings, same owner short-circuit,
same ForbiddenException). A non-owner non-admin who tries to dismiss another's
childless suggestion gets Forbidden before the service runs. Apply stays on
canEdit (accepting an edit is the editor's semantics), unchanged.

F1 [blocking] — atomic conditional delete closes the hasChildren→delete race.
New repo `deleteCommentIfChildless(id)` runs a single
`DELETE FROM comments WHERE id=:id AND NOT EXISTS (SELECT 1 FROM comments child
WHERE child.parent_comment_id = comments.id)` (verified by compiling the Kysely
expression to SQL — the correlated subquery references the OUTER comments.id).
deleteEphemeralSuggestion strips the mark first, then the conditional delete: if
it removed the row → commentDeleted + outcome 'deleted'; if a reply raced in
(0 rows) → fall back to resolveComment (outcome 'resolved') so the discussion and
the new reply survive. No reply can be cascade-deleted anymore.

F2 [warning] — the apply/dismiss onError success-noop is narrowed from 404||400
to 404 ONLY. A 400 means the comment is ALIVE (apply's 400 = the thread was
resolved-not-applied), so it now shows a real error (surfacing the server
message) and KEEPS the comment in cache instead of a false "applied" + dropping a
live thread.

F3 [suggestion] — the 404-race client tests assert the success toast fired.

Tests: server — dismiss authz (owner ok / non-owner-non-admin Forbidden /
space-admin ok), the delete→resolve race (hasChildren=false but conditional
delete returns 0 → resolve, no commentDeleted), delete-path asserts switched to
deleteCommentIfChildless; client — apply-400 and dismiss-400 (kept in cache, red,
not success) + the toast assertions.

server tsc clean, comment+collaboration jest green; client tsc clean, comment
vitest 54 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:17:19 +03:00
claude code agent 227 8d8ecaed82 feat(comment): ephemeral suggestion-edits — Apply/Dismiss remove the comment (#329)
Agent suggestion-edits (comments with suggestedText, #315) piled up: Apply
auto-resolved the thread, cluttering the resolved tab, and the anchors stayed in
the document. Make them ephemeral: resolving (Apply OR the new Dismiss) makes the
comment DISAPPEAR — hard-delete + remove the Yjs `comment` mark — UNLESS the
thread has replies, in which case resolve it (preserve the discussion). Manual
Resolve is unchanged. Scope: only comments with `suggestedText`.

Server:
- New collab event `deleteCommentMark` (collaboration.handler) mirroring
  resolveCommentMark, wiring the existing removeYjsMarkByAttribute to strip the
  anchor from the doc.
- `finalizeAppliedSuggestion` forks on `hasChildren`: replies → apply + resolve
  (outcome 'resolved'); none → apply + hard-delete + mark removal (outcome
  'deleted').
- New `dismissSuggestion` (validates top-level + suggestedText + not applied/not
  resolved) with the same fork; permission `canComment` (NOT canEdit — dismiss
  doesn't change page text); audit COMMENT_SUGGESTION_DISMISSED. New
  POST /comments/dismiss-suggestion; apply stays canEdit.
- Both return `{ outcome: 'deleted' | 'resolved' }` so the client picks the
  optimistic action.

Data-integrity (review F1): the shared `deleteEphemeralSuggestion` removes the
anchor mark FIRST and FATALLY, then deletes the DB row only on success. The row
delete is irreversible, so a mark-removal failure — including the
COLLAB_DISABLE_REDIS "no live instance" hard-error — must abort the whole
operation (→ 5xx, repeatable) rather than swallow the error and leave a permanent
orphan anchor pointing at a deleted comment. `deleteCommentMark` is no longer
best-effort (unlike resolve, where the row is kept and a failed mark is
recoverable).

Client:
- `canShowDismiss` (canComment) alongside `canShowApply` (canEdit); a "Dismiss"
  button next to Apply in the suggestion block.
- `useApplySuggestionMutation`/`useDismissSuggestionMutation` reconcile the cache
  on `outcome` ('deleted' → remove; 'resolved' → relocate to the resolved tab).
- Idempotent races (review F2): BOTH apply and dismiss onError reduce 404/400 to
  success (comment already gone/resolved), dropping it from the cache instead of
  a red error — restores the #315 apply idempotency the ephemeral delete would
  otherwise break.
- i18n Dismiss / "Не применять" (ru/en).

Not done (flagged): deleteCommentMark on the normal /comments/delete path — left
out (would change every non-suggestion delete + needs gateway injection; the
interactive client already strips the mark via unsetComment). Out of scope per
the issue.

Tests: server — apply/dismiss delete-vs-resolve fork, all four dismiss state
guards, the deleteCommentMark handler, controller authz (dismiss=canComment,
apply=canEdit), AND a mark-removal-failure test proving the row is NOT deleted +
the error propagates (F1). client — Dismiss show-conditions, outcome cache
reconciliation, and 404 idempotent race for BOTH dismiss and apply (F2).

Verified: server tsc clean; comment+collaboration jest 144 passed. client tsc
clean; vitest 905 passed | 1 expected-fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:17:19 +03:00
claude code agent 227 eacc1c4811 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into feat/293-B-prosemirror-markdown-pkg
# Conflicts:
#	packages/mcp/build/client.js
#	packages/mcp/build/index.js
#	packages/mcp/build/tool-specs.js
2026-07-04 19:02:52 +03:00
claude code agent 227 8e12aa8ebf fix(build): ship + CI-build @docmost/prosemirror-markdown; refresh AGENTS.md (#333 conformance)
Two infra blockers from the #326-steps-2-5 conformance check — the converter/canon
are correct, but the new shared package wasn't wired into Docker/CI.

BLOCKER 1 (prod): the Docker installer stage copied mcp/build + editor-ext but NOT
packages/prosemirror-markdown. mcp now depends on it (workspace:*) and EAGER-imports
it at runtime — the in-app ai-chat DocmostClient loads build/index.js ->
lib/markdown-converter.js — so the shipped image would resolve a broken workspace
symlink and every ai-chat tool would die with ERR_MODULE_NOT_FOUND. Now the
installer COPYs packages/prosemirror-markdown/build + package.json before the prod
install. (git-sync has no runtime consumer yet — revisit at step 6 with #119.)

BLOCKER 2 (CI red): test.yml/develop.yml build only @docmost/editor-ext before
`pnpm -r test`. That is plain pnpm, which does NOT honour nx `dependsOn: ^build`,
so the package's (gitignored) build/ never appears and its consumers fail:
mcp `pretest: tsc` -> TS2307 Cannot find module '@docmost/prosemirror-markdown',
git-sync vitest typecheck the same. The green local runs only happened because the
coder+reviewer had a full install+build. Added a `pnpm --filter
@docmost/prosemirror-markdown build` step before `pnpm -r test` (mirrors the
editor-ext step); verified the build is clean (tsc exit 0).

Docs (remark 3): AGENTS.md:203 and :285 still told contributors to keep mcp's own
vendored schema mirror "in sync manually" — that copy was deleted by this PR.
Updated both: the converter + schema mirror now live in the SINGLE package
@docmost/prosemirror-markdown (consumed by mcp + git-sync, do NOT reintroduce a
per-package copy); editor-ext is the upstream schema source; the serializer-contract
test guards the boundary. Added the package to the workspace table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:46:33 +03:00
vvzvlad 348dcd0802 Merge pull request 'feat(mcp): search_in_page — внутристраничный поиск для агента (#330)' (#339) from fix/330-search-in-page into develop
Reviewed-on: #339
2026-07-04 18:43:40 +03:00
claude code agent 227 086bc1bf8b docs(mcp): search_in_page regex desc names RE2, not JS regex (#330 review F5)
The RE2 swap narrowed the contract: regex:true rejects lookaround ((?=…)/(?<=…))
and backreferences (\1). The internal JSDoc was updated, but the AGENT-VISIBLE
tool-spec (the only text the agent reads at call time, single-sourced to both
transports) still said 'a JS regular expression' — so an agent would write a
lookahead/backref and hit an error. Updated the .description and the regex flag
.describe() to name RE2 (linear-time, ReDoS-safe), list that char classes / word
boundaries / anchors / quantifiers work while lookaround and backreferences do
NOT, and keep the 'invalid/unsupported regex -> clear error' note.

mcp: tsc clean; tool-specs / server-instructions / contract tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:08:27 +03:00
claude code agent 227 77b245461f fix(mcp): search_in_page regex via re2 (ReDoS-safe) + review DO F1-F4 (#330 review)
Maintainer decision on the escalated ReDoS fork: use re2. The regex path
compiled agent-supplied patterns with `new RegExp` and ran them synchronously in
the shared event-loop; a catastrophic-backtracking pattern (e.g. `(a+)+$`) hung
the whole Node backend for all users (the tool is in both transports incl. the
in-app apps/server agent), and size caps do NOT bound backtracking.

Switch the regex engine to re2 (Google RE2, linear-time, no backtracking):
- `new RE2(query, caseSensitive?'g':'gi')`. RE2 extends RegExp, so eachMatch and
  the zero-length-match lastIndex guard are unchanged.
- Unsupported patterns are now a CLEAN error, not a hang: RE2 throws on invalid
  syntax AND on the backtracking-only features it can't do (lookaround
  (?=…)/(?<=…), backreferences \1) — caught at compile and returned as a clear
  tool error telling the agent to rewrite without them.
- Removed MAX_CONTAINER_TEXT + the per-container slice (re2 is linear, so it's no
  longer a ReDoS defense, and truncating risked silently dropping real matches in
  a long container); kept MAX_PATTERN_LENGTH as a cheap query sanity cap.
- Verified: `(a+)+$` over 50k `a` completes in ~4ms; lookaround/backref throw.
- Added re2 (^1.21.0) to packages/mcp; lockfile updated.

Reviewer DO items:
- F1 [doc]: removed the false "pass nodeId as a comment anchor" claim
  (create_comment has no nodeId param — it needs a text `selection`). Fixed in
  tool-specs.ts + page-search.ts (module + SearchMatch JSDoc) + client.ts; the ref
  is for get_node/patch_node, and for a comment you build a unique text selection
  from before+match+after.
- F2 [doc]: clarified `#<index>` refs (id-less table/cell) are accepted by get_node
  but NOT patch_node (id-only).
- F3 [test]: round-trip — each match's nodeId fed to the real getNodeByRef
  (attrs.id node + `#<index>` table-cell) to prove the ref format is consumable.
- F4 [test]: before/after edge-pinning (match in first 40 chars of a long
  container; index 0 → before==""; container end → after=="").
- New re2 tests: catastrophic patterns complete fast; lookaround/backref → error.

mcp: tsc clean; node --test 472 passed (+5). apps/server: tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:49 +03:00
vvzvlad 77c64c4fd9 Merge pull request 'test(infra): coverage-gate + acceptInvitation atomicity + turn-end unit (#324)' (#335) from fix/324-coverage-gate into develop
Reviewed-on: #335
2026-07-04 17:45:46 +03:00
vvzvlad 2bb71c1a45 Merge pull request 'fix(client): мобильный адаптив 390px — Create page открывает редактор; ревизия хвоста #291 (#325)' (#334) from fix/325-mobile-390 into develop
Reviewed-on: #334
2026-07-04 17:45:35 +03:00
vvzvlad 20248b8c95 Merge pull request 'feat(client): intraline diff в блоке предложения-правки (#331)' (#336) from fix/331-intraline-diff into develop
Reviewed-on: #336
2026-07-04 17:45:10 +03:00
vvzvlad 9274c51053 Merge pull request 'feat(mcp): скрыть resolved-комментарии (якоря + list_comments) от агента (#328)' (#337) from fix/328-resolved-anchor-spam into feat/293-B-prosemirror-markdown-pkg
Reviewed-on: #337
2026-07-04 17:44:55 +03:00
claude code agent 227 832c3cafdf test(mcp): update test-e2e.mjs listComments calls to the {items} shape (#328 review F1)
The listComments Comment[] -> { items, resolvedThreadsHidden } shape change
reached every src/host consumer but not the live-server e2e harness (run via
`node test-e2e.mjs`, not the node --test gate — so the green suite missed it).
The 4 calls now read .items; the post-resolve check passes includeResolved:true
so it still sees the now-resolved root c1 (the default feed hides it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:22:34 +03:00
claude code agent 227 94f60cf0ec docs(client): fix .suggestionChanged comment — bold weight, not underline (#331 review F1)
The header comment claimed the rule adds 'an underline'; it does not — it adds a
color-mix tint + font-weight:700, and the inner comment already notes text-
decoration is omitted on purpose. Aligned the header comment with the rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:21:25 +03:00
claude code agent 227 40d42d61e6 feat(mcp): search_in_page tool — in-page substring/regex search for the agent (#330)
Editorial roles (Corrector/Factchecker) brute-forced `get_node` block-by-block to
find occurrences (unquoted «ё», straight quotes, «т.е.»), burning tokens. New
`search_in_page(pageId, query, {regex?, caseSensitive?, limit?})` reads the page's
ProseMirror JSON via the existing getPageRaw and searches it IN MEMORY — no server
endpoint, no DB/schema change, no touch to the packages/mcp/src/lib schema mirror.

New pure `searchInDoc(doc, query, opts)` (packages/mcp/src/lib/page-search.ts):
recursive descent to each TEXT CONTAINER (paragraph/heading/table-cell paragraph),
glues its inline text via `blockPlainText` (a match survives inline-mark
boundaries — e.g. «т.е.» split across bold/italic), searches literal (indexOf) or
regex, and returns `{ total, truncated, matches:[{ nodeId, blockIndex, type,
before, match, after }] }`. `nodeId` is the container's attrs.id or the
`#<topLevelIndex>` of the enclosing top-level block — the SAME ref format
get_node/patch_node/comment-anchoring accept (verified identical to getNodeByRef),
so the agent goes straight from a hit to a targeted comment; `before`/`after` are
~40-char windows for a unique selection. `total`/`truncated` always reported (never
silent truncation). Lives in the SHARED_TOOL_SPECS registry → exposed in BOTH
transports (external /mcp + in-app AI-chat), with a SERVER_INSTRUCTIONS line and a
DocmostClientLike signature + contract-test entry. Corrector/Factchecker prompts
get a one-line "use search_in_page first" hint (versions bumped, catalog hash lock
refreshed).

Guards: empty/whitespace query → clear error; invalid regex → clear error (not a
generic 500); zero-length regex matches (`\b`, `a*`) skipped with lastIndex
advanced (no loop/flood); MAX_PATTERN_LENGTH=1000, MAX_CONTAINER_TEXT=100k bound
each exec; limit clamped [1,200] (default 50).

Tests: new page-search.test.mjs (17) — literal+regex, case-sensitivity,
mark-boundary glue, nodeId for paragraph/heading (attrs.id) and table-cell
(#<index> fallback), context bounds, limit/total/truncated + clamp, invalid
regex/empty/over-long errors, zero-length skip, empty-doc null-safety.

mcp: tsc clean; node --test 467 passed (+17). apps/server: tsc --noEmit clean
(DocmostClientLike + wiring). catalog check.mjs OK.

Known limitations (from internal review, non-blocking):
- Residual ReDoS: a crafted catastrophic-backtracking pattern (e.g. `(a+)+$`)
  against a large single container can hang the event loop — JS regex is not
  interruptible, so the length caps bound the base but not the backtracking.
  Realistic exposure is low (containers are small; the pattern is supplied by the
  authenticated model). Candidate for a follow-up hardening (safe-regex validation
  or a worker+timeout) if it matters.
- Case-insensitive LITERAL search folds via toLowerCase; a char whose lowercase
  differs in length (e.g. Turkish İ) BEFORE a match could shift the context
  window — negligible for the RU/EN editorial scenario.
- On a `#<index>` table-cell fallback, `type` is the inline container ("paragraph")
  while nodeId addresses the top-level block — addressing is correct; the field is
  documented as the container's type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:51:34 +03:00
claude code agent 227 bcd194ee5d feat(mcp): hide resolved-comment anchors + feed from the agent (#328)
The AI agent (MCP + in-app chat) saw ALL comments incl. resolved via two
channels, cluttering its context and breaking fragment search. Default now:
the agent sees only ACTIVE discussions; resolved is opt-in. Active anchors and
threads are always kept.

Channel 1 — resolved comment anchors on agent reads (converter option):
`convertProseMirrorToMarkdown(content, options?)` gains
`options.dropResolvedCommentAnchors` (default false — zero change for every
existing caller incl. git-sync). Both `case "comment"` emitters (top-level and
the raw-HTML inlineToHtml path) emit BARE text (no `<span data-comment-id>`) when
`resolved && the flag`; active anchors keep their wrapper. mcp `getPage` passes
the flag; `export_page_markdown` does NOT (lossless export must preserve resolved
anchors — that is why it is an opt-in option, not unconditional); `get_page_json`
is untouched (lossless PM JSON). Built on the #293 package converter.

Channel 2 — `list_comments` default active-only: `listComments(pageId,
includeResolved=false)` now returns `{ items, resolvedThreadsHidden }` (was a
bare array). By default a RESOLVED top-level thread is hidden wholesale — the
root AND every reply anchored to it (a thread is gated only by its root's
resolvedAt; a resolved reply under an ACTIVE root stays). `resolvedThreadsHidden`
counts hidden threads so the agent knows to re-query. `includeResolved:true`
returns everything. The `includeResolved` param is added to both tool
registrations (MCP index.ts + in-app ai-chat-tools.service.ts); `DocmostClientLike`
signature updated. Server `findPageComments` is NOT touched — the web UI's tabs
depend on the full feed; filtering is only at the mcp-client level. All internal
call sites (export_page_markdown / checkNewComments / transformPage) updated to
`.items` with `includeResolved:true` to keep their full-feed behavior.

The comment model is assumed FLAT (a reply's parentCommentId points at the
thread root) — documented in the filter; a future reply-of-reply model would
need a root-walk there.

Tests: resolved-comment-anchors.test.ts (6 — anchor dropped with flag / kept
without, for BOTH emitters; active always kept); list-comments-resolved.test.mjs
(4 — resolved thread+reply hidden + counter; includeResolved:true returns all;
an ACTIVE thread with a RESOLVED reply is NOT hidden).

package vitest: 664 passed; tsc clean. mcp: node --test 458 passed; tsc clean.
apps/server + git-sync: tsc clean (converter option default-off).

NOTE: based on feat/293-B (#293/#326 STEP 5) — the converter lives in the
package; this PR is stacked on #333 and its base retargets to develop once #333
merges. mcp/build is gitignored (not committed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:26:43 +03:00
claude code agent 227 f13105333a feat(client): intraline diff highlighting in the suggestion before→after block (#331)
The suggestion block (#315) struck the whole `selection` red and showed the whole
`suggestedText` green, so a one-letter edit (заведем→заведём) highlighted the
entire line. Now only the CHANGED fragments are emphasized intraline, git-style.

Pure, render-only — nothing changes in the DB/backend/MCP/IComment/mutations/
Apply/Badge. New pure `computeSuggestionDiff(old, new) => { old: Segment[], new:
Segment[] }` (Segment = {text, changed}) in suggestion.ts: hybrid word+char —
`diffWordsWithSpace` for the word skeleton, then `diffChars` inside an adjacent
removed+added pair so only the differing letters (not the whole word) are
flagged; a lone insertion/deletion is wholly changed; equal parts are common on
both sides. Concatenating each side reproduces the input (lossless). Wrapped in
`useMemo` on [selection, suggestedText].

comment-list-item.tsx renders per-segment spans instead of two whole <Text>;
changed segments get `.suggestionChanged` (a stronger currentColor tint + bold,
NO text-decoration so the old block's inherited line-through survives on the
changed letters — the whole old line still reads removed, new as added).

`diff@8.0.3` (jsdiff, already in the root package.json) added to
apps/client/package.json (+ lockfile, additive) so the workspace resolves it;
it bundles its own types.

Tests: new suggestion.test.ts (one-letter ё/е; word replacement keeping the
shared word common with no per-letter noise; word insertion/deletion; identical)
— asserts segment text + changed flags, non-vacuous. Two pre-existing
comment-list-item.test assertions switched from getByText (a single text node)
to container.textContent (the new line is now multiple spans) — adapts to the
intended DOM change, not a weakening.

Verified: tsc --noEmit clean; client vitest 892 passed | 1 expected-fail.
Visual/pixel check of the tint at the 390px comment panel needs a human (no
screenshot tooling in-repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:17:07 +03:00
claude code agent 227 08222345ef fix(prosemirror-markdown): escape canon inline-extension triggers = $ ^ in link/alt text (#333 review F5)
F1 (round 1) wrapped the image alt in escapeLinkText, and that helper also guards
the link-form media captions (attachment/pdf/embed). But its character class
covered only stock CommonMark — NOT the Docmost inline EXTENSIONS this same PR
registers on the marked instance: highlight `==x==` (canon #7), math `$x$`
(canon #6), footnote `^[x]` (canon #2). Their triggers `= $ ^` are not CommonMark
punctuation, so an alt or media filename like `x $A$ y`, `use ==bold==`, `^[fn]`,
or `data $A$.csv` was silently turned into a math/highlight/footnote node on
import — the same class of round-trip data loss F1 closed, reintroduced by this
PR's own canon.

Fix: add `= $ ^` to the escapeLinkText class (`/[\\`*_~[\]<&!()=$^]/g`). `\= \$ \^`
decode back to literals (all ASCII punctuation) AND, being escape tokens, stop
the extension tokenizer from matching — verified lossless byte-stable round-trip.
Updated the helper comment to name the two trigger sets (CommonMark + Docmost
inline extensions). Extended the adversarial round-trip tests: image alt gains
`x $A$ y` / `5$ and 10$` / `use ==bold==` / `^[fn]` / `cost $5 == price`; pdf name
gains `data $A$.csv` / `q3 ==final==.pdf` / `5$ and 10$.pdf` / `note ^[x].pdf` —
all byte-stable with the node intact, so the hole can't reopen.

package vitest: 658 passed; tsc clean. git-sync: 268. mcp: 454.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:46:30 +03:00
claude code agent 227 baa41d66ad test(infra): coverage-gate + acceptInvitation atomicity int-spec + turn-end unit (#324)
Tail of #244. Three items:

1. Coverage-gate (main). develop had no coverage tooling at all. Added
   @vitest/coverage-v8@4.1.6 (pinned to the vitest already in use) to the three
   vitest packages — git-sync, editor-ext (which also gains its missing direct
   `vitest` devDep), apps/client — and enabled v8 coverage with per-package
   thresholds (no root vitest config exists, so per-package is the only
   meaningful scope). v8 provider is chosen deliberately: istanbul broke on the
   ESM `@docmost/editor-ext` barrel; v8 collects native runtime coverage and
   never re-parses ESM. `enabled: true` wires the gate into the plain `test`
   script, so `pnpm -r test` (the CI entrypoint) enforces it without a manual
   `--coverage`. Thresholds set ~4-5 pts below measured current coverage so the
   gate PASSES today and FAILS on regression (verified: forcing lines=95 on
   editor-ext exits 1). `all: false` — coverage counts test-touched files;
   documented in the configs (with `all: true` the many untested type/barrel
   files would sink the % and make the gate meaningless).
   Measured→threshold (S/B/F/L): git-sync 91.78/79.16/76.76/92.46 → 88/75/72/88;
   editor-ext 58.58/48.1/64.96/58.91 → 54/44/60/54; client 59.93/58/48.47/59.39
   → 55/53/44/55. All exit 0.

2. acceptInvitation atomicity int-spec. New
   apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts
   (+ createDefaultGroup/createInvitation seeders in test/integration/db.ts per
   its convention). Wires the real WorkspaceInvitationService with real
   User/Group/GroupUser repos against the test Kysely, stubbing only the
   post-commit collaborators. Asserts the invariant protected by
   users_email_workspace_id_unique: (a) two CONCURRENT accepts → exactly one
   fulfilled, one BadRequestException('Invitation already accepted'), membership
   count == 1, invitation consumed; (b) repeated sequential accept → still one
   membership; (c) the survivor is in the workspace default group (whole-tx, no
   torn state). Ran against real Postgres+Redis: 3/3 pass.

3. turn-end decision unit test. `decideTurnEnd` does not exist as a symbol; the
   turn-end logic lives in chat-thread.tsx's onFinish handler. Added a focused
   block to the existing chat-thread.test.tsx (matching its hoisted-mock style):
   clean finish → flush queued (continue); abort/disconnect/error → queue
   preserved (end) with the correct notice; parent notified on every terminal
   outcome. 8 passed (3 existing + 5 new).

Verified: git-sync 712, editor-ext 247, client 888 (all with the gate, exit 0);
int-spec 3/3 (real Postgres); tsc --noEmit clean for client + server;
pnpm install --frozen-lockfile consistent (lockfile additive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:37:28 +03:00
claude code agent 227 1a7b817250 fix(prosemirror-markdown): escape image alt + consolidate schema sanitizers + tidy (#333 review F1-F4)
F1 [critical, data-loss] — escape the image alt in `![alt](src)`. Canon #4 moved
the top-level image off the lossless <img> form onto markdown `![alt](src)`, but
the alt was inserted raw; the importer re-parses the `![alt]` label as CommonMark
inline, so a markdown-active char in a realistic description ("Figure [1]", "the
*new* logo", "a]b[c") broke the round-trip — the image node vanished or emphasis
collapsed. Now `escapeLinkText(imgAttrs.alt ?? "")`, exactly as the link-form
media (attachment/pdf/embed) already escape their visible text. Regression test
added: six active-punctuation alts round-trip byte-stable with the node intact.

F2 [drift] — re-export `clampCalloutType` / `sanitizeCssColor` from the package
barrel and drop the verbatim copies in the mcp schema shim. The copies had
already drifted (the mcp `clampCalloutType` lost the callout-type alias mapping
the package applies), which is exactly the schema drift #293 exists to kill. The
sanitizers now live only in the package; mcp `schema.test.mjs` exercises the
single alias-aware implementation.

F3 [docs] — AGENTS.md:296 said `packages/mcp/build/` is committed; this branch
gitignored it (git-sync/prosemirror-markdown convention). Updated the line to say
it is gitignored and rebuilt in CI/Docker via `pnpm build`.

F4 [cleanup] — removed the dead `test.typecheck` block from the package
vitest.config.ts and deleted tsconfig.vitest.json. Both were copied verbatim from
git-sync; this package has zero `*.test-d.ts` files, and the ported comments
referenced git-sync-only entities. Kept the `docmost-client` resolve alias
(22 tests use it) and the runtime include/environment.

package vitest: 658 passed (+1 F1 regression); tsc clean. git-sync: 268 passed.
mcp: node --test 454 passed; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:17:46 +03:00
claude code agent 227 52beae85b3 fix(client): close mobile sidebar drawer after creating a page (#325)
On mobile the "create page" action is triggered from inside the off-canvas
sidebar drawer (the space sidebar "+" and temporary-note buttons, and the
tree-row "add subpage"). handleCreate navigated to the new page's editor route
but never closed that drawer, so it stayed open on top of the freshly created
page — the editor was hidden behind the page tree ("as if the page didn't
open", #325 item 5).

Close the mobile sidebar (`setMobileSidebar(false)`) right after navigating,
mirroring the existing drawer-close on a tree-row tap (space-tree-row). Placing
it in handleCreate covers all three create entry points in one spot. It is a
no-op on desktop, where the mobile-sidebar atom is already false and only
governs the sub-992px collapsed state — desktop behavior is unchanged.

Verified: `tsc --noEmit` clean; client vitest 887 passed | 1 expected-fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:06:45 +03:00
claude code agent 227 124f5a45a2 refactor(mcp): consume @docmost/prosemirror-markdown, drop the drifted converter copy (#293/#326 step 5)
mcp had its OWN drifted copy of the converter (markdown-converter.ts ~900 lines,
docmost-schema.ts ~1270 lines, markdown-document.ts) — older than the shared
package, missing the git-sync fixes AND the #293 canon. This switches mcp's
converter CORE to @docmost/prosemirror-markdown, so mcp jumps straight to the
canonical format and the drift-generating second copy is gone.

- markdown-converter.ts / markdown-document.ts / docmost-schema.ts become thin
  re-export shims of the package (convertProseMirrorToMarkdown, the docmost:meta
  envelope, docmostExtensions + docmostSchema=getSchema(docmostExtensions)). The
  mcp-only helpers clampCalloutType/sanitizeCssColor are preserved verbatim in
  the schema shim (the package doesn't expose them via its barrel). ~2170 lines
  of the drifted converter/schema bodies deleted.
- collaboration.ts drops its own ~360-line marked pipeline (preprocessCallouts,
  bridgeTaskLists, extractFootnotes, the footnoteRef extension) and re-points to
  the package's markdownToProseMirror, keeping markdownToProseMirrorCanonical and
  all the yjs/collab write glue. footnote-lex/analyze doc comments updated (they
  now describe advisory legacy-syntax diagnostics, not an importer).

Schema parity verified: the package schema is a strict SUPERSET of mcp's old
schema — every node and attr mcp declared is present (the package only adds
status/pageEmbed/transclusion*/subpages.recursive/etc.), so nothing is silently
dropped on the switch. The switch actually FIXES two pre-existing mcp data-loss
bugs its own tests documented: htmlEmbed and pageBreak now round-trip (were
dropped by the old mcp converter).

Footnotes: the package assembles inline ^[body] footnotes on import (sequential
fn-N ids, identical bodies merged), so mcp's canonicalizeFootnotes is now an
idempotent no-op after it (verified). Legacy reference footnotes [^id]/[^id]:
are inert literal text (canon #2 no-backward-compat) — lossless, the text
survives verbatim.

Build hygiene: packages/mcp/build/ is now gitignored and untracked, matching the
git-sync/prosemirror-markdown convention (private package, rebuilt in CI/Docker,
so src and prod can never silently diverge). This also removes a dead untracked
build/_vendored_editor_ext/ artifact that a broad `git add` would otherwise
commit.

Dependency: packages/mcp/package.json gains @docmost/prosemirror-markdown
(workspace:*); pnpm-lock.yaml gets the matching link importer (mirrors git-sync).

mcp tests updated deliberately to the canonical forms (highlight ==, math $…$,
image ![](src)<!--img-->, drawio/media discriminators, subpages/pageBreak
comments, textAlign, inline ^[…] footnotes) with strict assertions; 4 structural
safety-net round-trip tests added.

mcp: node --test 454 passed; tsc clean. package: 657 passed. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:16:09 +03:00
claude code agent 227 b751852425 fix(prosemirror-markdown): converter inventory bugs — spoiler/link-title in raw-HTML, contract test, codeCombined dead code (#293)
The four bugs found during the #293 HTML-emission inventory, fixed in the package:

1. Spoiler mark was silently lost in the raw-HTML path: inlineToHtml (columns /
   spanned cells) had no `case "spoiler"`, so spoilered text there dropped the
   mark on round-trip. Now emits `<span data-spoiler="true">` — the same form the
   top-level serializer uses and exactly what the schema's Spoiler mark parses.

2. Link `title` was dropped in the raw-HTML path: inlineToHtml's link case
   emitted `<a href>` without the title. The schema's link mark carries a
   `title` global attr (DocmostAttributes), so a titled link inside a column now
   round-trips via `<a href … title=…>`.

3. Serializer contract test: emoji/date/toc were flagged as possibly caseless
   inline atoms. Verified they exist in NEITHER the package schema NOR
   editor-ext, so no node handling is needed today. Added
   serializer-contract.test.ts, which derives every node type from the live
   schema (getSchema(docmostExtensions)) and asserts each has an explicit
   serializer `case` — all 45 current node types are covered and present, and a
   future node added without a case will fail this test loudly.

4. codeCombined dead code: `const codeCombined = false` was hardcoded, so every
   `codeCombined ? <html> : <markdown>` ternary always took the markdown branch.
   Removed the variable and the dead HTML-alternative branches (bold/italic/code/
   link/strike). Pure cleanup — output is byte-identical (goldens + full suite
   pass unchanged). The `hasCode` early-return (code excludes other marks) stays.

Tests: spoiler-inside-column and link-title-inside-column round-trips, the
serializer contract test + inline-atom non-empty behavioral checks.

package vitest: 657 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:37:35 +03:00
claude code agent 227 65d81f745a feat(prosemirror-markdown): inline footnotes ^[text] (#293 canon #2)
Footnotes now use the single canonical Pandoc/Obsidian inline form: the note
body is written AT the reference as `^[body]`, and the separate
`<section data-footnotes>` list is NOT emitted in markdown — it is reassembled
on import. New shared module src/lib/footnote.ts.

Serialize (markdown-converter.ts): a top-of-convert pre-scan builds
Map<id, definition> from the footnotesList; a footnoteReference emits
`^[<rendered body>]` (body paragraphs joined by a literal `\n`, real
backslash-n written `\\n`, stray unbalanced `[`/`]` escaped via balanceBrackets
while a balanced `[link](url)` stays intact); footnotesList/footnoteDefinition
emit nothing; an ORPHAN definition (no ref) is appended at doc end as its own
`^[body]` line so bodies are never lost (intentional, documented). The raw-HTML
path (inlineToHtml, columns) emits `<sup data-footnote-ref data-fn-text="…">`,
carrying the text at the ref there too; blockToHtml keeps the schema
`<section>`/`<div>` form for a list nested in a column.

Parse (markdown-to-prosemirror.ts): a `^[…]` inline extension on the dedicated
marked instance BALANCES brackets with a depth counter (respecting `\`-escapes),
so `^[note [a] b]` captures the full content, unbalanced `^[` fails open to
literal text. A post-marked assembleFootnotes pass collects every
`<sup data-fn-text>`, dedups by the EXACT body string, assigns sequential ids
(fn-1, fn-2, … first-seen), builds one `<div data-footnote-def>` per unique body
in a single `<section data-footnotes>`, and strips data-fn-text. No hash is used
(F1): dedup keying on the exact text makes an id collision between DIFFERENT
bodies impossible, while identical bodies still merge; ids are never written to
markdown, so round-trips stay byte-stable, and all id assignment is local to the
one call (race-free).

Correctness hardening from internal review:
- F2: raw user backslashes in a footnote body are doubled (`\`->`\\`) at text
  emission (via a per-conversion inFootnoteBody closure flag) BEFORE the
  serializer's own escapes (`\[ \] \= \$`) are layered on, so a body ending in
  `\` (Windows path, LaTeX, regex) no longer breaks the `^[…]` envelope and
  round-trips exactly; parseInline decodes `\\`->`\`. The old `\n`->`\\n` step is
  subsumed by this and removed.
- N1: assembleFootnotes runs to a FIXED POINT — parseInline of a def body can
  spawn a nested `<sup data-fn-text>` (a legal nested footnote `^[a ^[b] c]`),
  so the section is attached before the loop (querySelectorAll only sees
  attached nodes) and the scan repeats until no pending sup remains; the dedup
  map persists across rounds. Nested and 3+-level footnotes now round-trip
  byte-stably instead of silently dropping the inner body. Bounded by
  MAX_FOOTNOTE_ROUNDS as a fail-open safety net.
- N2: the id counter is seeded past the highest existing fn-<N> so a reused
  section's ids can never collide with generated ones.
- A literal `^[` in prose text is escaped `^\[` so it does not become a phantom
  footnote on re-import (codeBlock/inline-code excluded).

No backward compat: reference form `[^id]`/`[^id]: def` is not parsed (stays
literal). No existing golden asserted the old footnote HTML output.

Tests: new footnote.test.ts (22 cases: basic byte-stable round-trip, bracket
balancing, multi-paragraph `\n`, real backslash-n, dedup both directions,
NESTED + 3-level nest, F1 hash-collision pair surviving as distinct defs, F2
backslash bodies byte-stable, N2 id-seed, column data-fn-text form, orphan def,
no-backward-compat, literal-`^[` prose, fail-open, empty `^[]`).

package vitest: 607 passed; tsc clean. git-sync: 268 passed.

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