Commit Graph

1289 Commits

Author SHA1 Message Date
claude code agent 227
3a6dfad3ae fix(git-sync): converge git-ingest with open editor sessions — stop silent revert/data-loss on live pages
A git push to a page with an OPEN editor was silently reverted: the git
commit landed and the DB body updated, but the page in the browser stayed
on the old content and the editor's next autosave overwrote the git change.

Root cause (distributed, not in the merge): writeBody applied the body
merge via collabGateway.openDirectConnection on whichever instance/process
runs git-sync (the api/worker). When an editor is connected to a DIFFERENT
collab instance/process, that opens a SEPARATE, detached Y.Doc. The merge
landed in the detached doc + DB, but the live editor's Y.Doc never received
the Yjs update; its debounced autosave then persisted its STALE state over
the DB, reverting the git change (and, for concurrent edits to different
paragraphs, losing the git side). In one process the bug is invisible
because the direct connection already shares the editor's doc.

Fix: route the body write through the existing custom-event channel (the
same mechanism comment-marks and updatePageContent use) so the merge runs
on the instance that OWNS the live doc. Its update is then broadcast to
every connection (Document.handleUpdate) and the editor's CRDT converges on
the merged result. New CollaborationGateway.writePageBody dispatches to a
new gitSyncWriteBody handler (builds incoming/base docs before opening the
connection — crash-safe — then 3-way/2-way merges into the live fragment);
without redis it runs locally on the single (owning) instance. writeBody
now just forwards the converted ProseMirror bodies + service userId.

Evidence:
- git-ingest-convergence.spec.ts: deterministic two-Y.Doc repro. PATH B
  (undelivered update) asserts the LOSS (the bug); PATH A (update delivered,
  as the owner-routed write does) asserts the git change SURVIVES and that
  concurrent edits to different paragraphs both survive.
- collaboration.handler.git-sync.spec.ts: exercises the real gitSyncWriteBody
  against a shared doc wired to a connected "editor" doc (models the
  owning-instance broadcast) — editor converges, concurrent edit preserved,
  crash-safe on transform failure.
- gitmost-datasource.service.spec.ts: writeBody now routes via writePageBody
  (RED before this change — it called openDirectConnection).

Honest scope: the failure is cross-instance; full multi-instance convergence
needs a live Hocuspocus + redis and is not provable in a unit test, so the
convergence invariant is captured at the Yjs update-exchange level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:11:59 +03:00
claude code agent 227
0c7b73f7d0 fix(git-sync): idempotent first-block reconciliation — stop start-of-doc content duplicating every sync cycle
The block-level body merge keyed each block by its full attribute set,
including the per-block UniqueID the editor stamps on every heading/paragraph.
A body arriving from git is parsed from clean markdown and carries no block
ids, so a live block (id present) never matched the same block coming from git
(no id). The three-way merge's LCS could not anchor on it, and an incoming
block with no matching anchor — content inserted at the TOP of the page — was
re-added on every push/pull cycle: a non-convergent, unbounded duplication loop.

Exclude the volatile 'id' attribute from the block comparison key
(serializeXmlNode) so blocks compare by content across the git round-trip.
The merge keeps the live block INSTANCE (and its id, and any in-flight edit)
for an anchor — picks are by index, not key — so identity is preserved while
reconciliation becomes idempotent. Mirrors canonicalize.ts, which already
strips the regenerated block id from the round-trip idempotency comparison.

Adds a RED-before-fix repro modelling the live-id vs git-no-id asymmetry and
asserting no block growth across cycles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:54:36 +03:00
claude code agent 227
032df2cf31 feat(git-sync): remove the per-cycle delete cap; deletes apply + are logged every cycle
The delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5) was a defense-in-depth
guard that SUPPRESSED a cycle's deletions when the planned count exceeded the
limit. In practice it was a crutch over engine correctness that also blocked
legitimate deletes: deleting a folder with many child pages is a normal action,
and git-sync deletes are SOFT (Trash, reversible), so a blocking limit has little
upside and real downside. There is also no user-facing surface to "confirm" a
large delete from a background sync — the only channel is the operator log.

So: drop the cap entirely. Deletes apply unconditionally; every cycle already
logs its full push plan, per-action `delete: <pageId>` lines, and completion
counts through the engine `log`, so what was deleted (and what was skipped) is
always recorded. Engine correctness (the reconcile/layout/round-trip tests) is
what prevents phantom deletions — not a blocking cap.

Removed: orchestrator `resolveApplyClient` cap hook + `maxDeletes`,
`getGitSyncMaxDeletesPerCycle`, the `GIT_SYNC_MAX_DELETES_PER_CYCLE` env/validation/.env.example,
and the cap tests. (The engine's generic optional `resolveApplyClient` hook is
left as an unused extension point.)

server tsc clean, git-sync + environment jest 174.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:59:12 +03:00
claude code agent 227
87343f241a fix(git-sync): subpages round-trips (was {{SUBPAGES}} literal) + exhaustive all-node round-trip test
subpages exported to the literal `{{SUBPAGES}}`, which has no markdown/HTML
inverse, so on re-import it came back as a plain paragraph holding the visible
text "{{SUBPAGES}}" — the embed rendered as that literal string on the page
after a sync (round-trip data loss, seen live). It now emits the schema-matching
`<div data-type="subpages">` like every other embed node, so the schema's
parseHTML rebuilds the subpages node. Also dropped the leaf-atom content-hole
in the subpages renderHTML.

New committed regression coverage:
- packages/git-sync/test/roundtrip-all-nodes.test.ts — exhaustive serialize ->
  deserialize round trip for ALL 40 node/mark types; each asserts the node/mark
  survives and no `{{...}}` literal leaks. This is the test that caught subpages.
- §13.1 gate (git-sync-converter-gate.spec.ts): subpages added to the green
  corpus (round-trips through the REAL server schema).
- Corrected two PR-authored tests that asserted the old {{SUBPAGES}} loss as
  "by design" — they now assert the fixed round trip.

Also folds in review #1679 coverage-gap tests (no prod change): orchestrator
pollTick/enabledSpaces, datasource 3-way merge dispatch, page.repo
last_updated_source provenance SQL.

git-sync vitest 659 (+1 expected-fail), server tsc clean, server specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:41:42 +03:00
claude code agent 227
e8c5a62410 fix(git-sync): don't run a Docmost cycle on receive-pack info/refs (fixes deterministic push 503)
A git push is a two-request exchange: GET info/refs?service=git-receive-pack
(ref advertisement) then POST git-receive-pack (the pack). The git-HTTP host
classified BOTH as serviceKind 'write' and routed both through
ingestExternalPush, which takes the per-space lock and runs a FULL Docmost
reconcile cycle. So the read-only info/refs advertisement held the lock while a
cycle ran, and the client's immediately-following POST git-receive-pack collided
with that still-running cycle and got 503 — deterministically, every push (and
Obsidian Git's "scan" failed for the same reason, since it probes push
capability via the same receive-pack info/refs).

Fix: only the actual pack-receiving write (POST git-receive-pack) runs under the
lock + cycle. Everything else streams the http-backend directly with no lock and
no cycle — a fetch/clone (read) AND the write-AUTHORIZED but read-only
info/refs?service=git-receive-pack advertisement. Authz is unchanged (the gate
still requires write permission for receive-pack refs); only the side effect of
running a cycle on a read-only request is removed.

Verified end-to-end on a live stand: clone, then `git push` of a new file lands
the page in Docmost (was 503 on every push before). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:21:19 +03:00
claude code agent 227
687482a901 feat(git-sync): per-space toggle for conflict-marker handling on push (#13)
Red-team #13 (conflict markers reaching Docmost) is now a per-space policy
exposed as a UI toggle, instead of a hardcoded behavior. New boolean
`gitSync.autoMergeConflicts` (default FALSE), mirroring the existing per-space
`gitSync.enabled` flag end-to-end (jsonb space settings -> update-space DTO ->
space.service -> client types -> space settings form switch):

- OFF (default, safe): a page whose committed body still has unresolved git
  conflict markers is NOT pushed — it is recorded as a per-page push FAILURE
  ("unresolved conflict markers — resolve in git first"). Recording a failure
  (not a soft skip) deliberately HOLDS refs/docmost/last-pushed so the conflict
  commit is never marked pushed and a later pull cannot clobber the user's
  in-progress resolution; the page retries until the conflict is resolved in git.
- ON: the marker lines are stripped and both sides' content is pushed (the prior
  behavior), so the conflict becomes visible/fixable inside Docmost.

The engine Settings carries `autoMergeConflicts`; runPush threads it into the
update AND create paths. The orchestrator's buildSettings reads the per-space
flag from jsonb (strict opt-in like `enabled`, default false).

Tests: redteam-push-cycle #13 rewritten (default -> not pushed + failure + refs
held; ON -> strip-and-push); space.service + edit-space-form + orchestrator
specs extended. git-sync vitest 618, server jest space+git-sync 163, client
edit-space-form 11, server/client tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:56:55 +03:00
claude code agent 227
b751c4bdc5 docs(git-sync): document GIT_SYNC_BACKEND_TIMEOUT_MS, drop dead consts, fix dangling plan refs
Address the non-red-team documentation/cleanup items from review #1679:
- Document the GIT_SYNC_BACKEND_TIMEOUT_MS watchdog (git http-backend) in
  .env.example and add it to the environment validation schema — it was used
  (getGitSyncBackendTimeoutMs, default 120000) but undocumented/unvalidated.
- Remove the dead GIT_SYNC_DEBOUNCE_MS_DEFAULT / GIT_SYNC_POLL_INTERVAL_MS_DEFAULT
  exports (never imported; environment.service is the single source of defaults).
- Redirect the dangling `plan §X.Y` comment references to issue #194 (the
  git-sync spec moved there when docs/git-sync-plan.md was deleted by this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:34:14 +03:00
claude code agent 227
3d7f434b0c fix(git-sync): red-team hardening — 12 confirmed sync-breaking bugs + regression tests
A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked
findings (9 others triaged out as already-defended). Wrote a reproduction test
per finding (each asserts the CORRECT behavior, so it fails on the bug), then
fixed the production code so every repro goes green. All confirmed bugs:

Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror):
- #1 editor-ext node types silently dropped on export — ported the 8 missing
  canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed,
  status, pageEmbed, transclusionSource/Reference) into the git-sync schema
  mirror and added converter cases that emit their schema-matching HTML instead
  of flattening unknown nodes to '' (this was the critical data-loss flagged in
  review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated.
- #2 top-level image lost width/height/align/attachmentId — now emits an HTML
  <img> (like video/diagrams) when it carries layout attrs; bare images stay
  ![](src). Image node parses width/height as strings so they re-import.
- #3 code block containing a ``` fence corrupted on round-trip — outer fence is
  now widened to (longest-inner-backtick-run + 1).
- #16 deep nesting threw RangeError (page never synced) — added a depth guard
  (MAX_NODE_DEPTH=400) so the converter never overflows the stack.

Push/layout/cycle (engine):
- #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent
  layout — deterministic, order-independent sibling disambiguation; suffix is
  stripped from a path-derived title ONLY when the new name is exactly the old
  title plus the suffix (never a genuine retitle ending in ' ~token').
- #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling —
  ambiguous (parent,title) is no longer adopted (falls back to fresh create).
- #12 a new child under a new parent was created at ROOT — creates are ordered
  parent-before-child with an in-memory created-id map for parent resolution.
- #13 git conflict markers could reach Docmost — bodies are scanned and the
  marker lines stripped (a '=======' line is only treated as a conflict
  separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe).
- #15 a divergent `docmost` mirror was escalated by runPush but dropped by
  runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator.

Server (merge / lock / provenance):
- #9 3-way merge lost a human's block edit when git inserted an adjacent block —
  finer-grained diff3 region merge (via lcs) preserves non-overlapping human
  edits; genuine same-block conflicts still resolve git-wins.
- #10 single-writer race — module-static liveLocks closes the same-process TOCTOU
  window, and a heartbeat refresh that cannot confirm the lock now aborts the
  cycle at its next write checkpoint (cooperative AbortSignal threaded through
  runCycle). Cross-process fencing tokens remain a follow-up.
- #14 sticky-agent provenance overrode an explicit actor='git-sync' write,
  blinding the listener loop-guard — resolveSource now lets an explicit actor
  win over the sticky-agent fallback (explicit agent still wins).

Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541
pass, server tsc clean. A review pass over the fixes caught and corrected a
title-suffix over-strip, an inert abort signal, a document-wide conflict-marker
strip, and two leaf-atom content-holes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:29:02 +03:00
claude_code
4213a12180 fix(git-sync): address PR #119 review (#1571)
Resolve the code-review findings from comment #1571 on PR #119.

Engine (packages/git-sync):
- Idempotent CREATE on retry: before createPage, look the page up in the
  live Docmost tree by (parentPageId, title) and ADOPT it instead of
  duplicating when a prior cycle created it but failed to persist the
  pageId back to disk. Only trust a COMPLETE tree for the lookup; fall
  back to createPage otherwise. Covered by new tests incl. a complete=false
  regression-lock.
- Route applyPullActions diagnostics through an injected logger instead of
  bare console (thread log from the cycle).
- Add a timeout to the git execFile chokepoint (runRaw) so a hung git
  subprocess cannot wedge a sync cycle.
- Translate remaining Russian code comments to English.
- Remove dead standalone-CLI code (parseArgs/PushParsedArgs,
  parseSettings/envSchema, loadSettingsOrExit + config-errors.ts) and the
  matching index exports/specs; keep the Settings type.
- Fix the dangling docs link in package.json.
- Add a schema-surface snapshot guard so any drift in the vendored
  document schema is a loud, must-review CI failure (+ provenance header).

Server (apps/server):
- Add a configurable watchdog timeout to the spawned git http-backend so a
  stalled push cannot hold the per-space lock forever
  (GIT_SYNC_BACKEND_TIMEOUT_MS).
- Close the in-process TOCTOU window in SpaceLockService.withSpaceLock by
  reserving the slot synchronously before acquire.
- Add tests: removePage git-sync provenance (both branches), ensureServable
  force-push-protection git configs, and the phase-B+ datasource methods.

Docs / build:
- AGENTS.md: list git-sync as the fifth workspace package and note the
  three schema mirrors; fix the dangling git-sync-plan.md backlog link.
- pnpm-lock.yaml: add the missing @docmost/git-sync workspace link so
  pnpm install --frozen-lockfile (CI default) succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
c76f255b1c refactor(git-sync): internalize the engine — first-class ESM, no vendoring bridge (#119 review)
Closes the architecture item from the #119 review: drop the "vendored from
docmost-sync" framing and the CJS↔ESM `Function('import()')` bridge so the engine
is a normal first-class gitmost package.

Part 1 — vendoring markers removed (prose only, zero behavior change): reworded
"VENDORED into gitmost" / "vendored from docmost-sync" / "Engine LOGIC is
byte-identical" / "it's a port" comments across the engine. Behavior-bearing
strings are untouched: BOT_AUTHOR_NAME/EMAIL and the `Docmost-Sync-Source:`
provenance trailers (changing them would break git authorship + the loop-guard).

Part 2 — the package is now ESM (matching the sibling @docmost/mcp): `type: module`,
tsconfig Node16, `.js` extensions on relative imports, and a static
`import { marked }` replacing the `new Function('return import(...)')` /
`loadMarked` hack — the bridge is GONE from the package. The CommonJS NestJS
server loads the now-ESM engine via a new `git-sync.loader.ts` that mirrors the
existing `docmost-client.loader.ts` mcp loader exactly (Function-indirected
dynamic import + cached promise + retry-on-reject). The 4 server consumers
(orchestrator/datasource/vault-registry/git-http-backend) call `await loadGitSync()`
for value exports; types stay `import type` (erased). The converter-gate spec —
which needs the real converter — loads the package's TS source via a jest
moduleNameMapper + isolatedModules (documented in that spec); the other git-sync
specs mock the loader.

Verified: engine builds pure ESM (no Function/require leftover), vitest 614,
editor-ext build, server + client tsc, full server jest 1397/0. Live stand
smoke-test: server starts clean on the ESM engine (no ERR_REQUIRE_ESM), a real
sync cycle runs through the loader, and the basic e2e suite is 12/12 (clone via
git-http-backend, push, pull, delete, 3-way merge — all through the new loader).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
5ca4cc4657 test(git-sync): add missing DTO/User imports for the rebased git-sync provenance spec block
The rebase folded develop's agent-provenance PageService spec and the git-sync
provenance spec into one file; the appended git-sync block needs CreatePageDto /
UpdatePageDto / User imports that develop's spec (which used inline `as any`) did
not have. Server tsc + the suite (158 tests, both provenance blocks) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
3d9c508011 fix(git-sync): git-http stream error handlers + close test gaps (#119 review)
Addresses the stability + test-coverage warnings from the #119 review:

- git-http-backend.service.ts: add `'error'` handlers to child.stdout/stderr. An
  EventEmitter 'error' with no listener (e.g. EPIPE when the client aborts
  mid-response) is rethrown by Node as an uncaught exception and crashes the
  process; now swallowed + logged (never echoed to the client).
- TEST INFRA: a jest setupFile shims `navigator`/`MessageChannel` for the `node`
  testEnvironment. react-dom@18 reads `navigator` at module-init (pulled in via
  @docmost/editor-ext -> @tiptap/react), so every spec transitively importing the
  conversion engine — including git-http.service.spec.ts — previously FAILED TO
  LOAD ("navigator is not defined") and ran ZERO tests. With the shim those specs
  now run (git-sync integration: 11 suites / 133 tests green).
- git-http.service.spec.ts: cover the 503 lock-held push path — `ingestExternalPush`
  rejecting `GitSyncLockHeldError` -> 503 + Retry-After + "git-sync busy, retry",
  no double header write (+ the already-headers-sent no-rewrite path).
- git-http-backend.service.spec.ts: unit-test run() — child 'error'/'close' before
  headers -> 500; normal CGI parse+stream; stdout/stderr 'error' (EPIPE) swallowed;
  synchronous spawn throw -> 500.
- page-change.listener.ts: implement OnModuleDestroy to clearTimeout all pending
  debounce timers on shutdown (+ test).
- .env.example: vaults are non-bare working repos, not "bare repos".

(Docs deleted by the stray commit were restored in 9cdbce54.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
1ee18e3ed7 test(git-sync): e2e suites provision a throwaway space — never touch real data
The shell e2e suites defaulted to the General space and created/edited pages
there, polluting real content (and, when several enabled spaces raised poll
contention, flaking on 503s). Now each suite creates its OWN throwaway,
git-sync-enabled space at setup, runs everything against it, and deletes the
space (+ its vault) on exit. Set SPACE_ID explicitly to opt into an existing
space. Also gives the basic suite the 503-retry push helper the advanced one
already had. Verified isolated: basic 12/12, advanced 23/23, no spaces/users/
pages left behind, the real space untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
3e0b0aa7c0 fix(git-sync): never trash a page whose pageId still exists in the tree (cross-cycle move) + browser e2e
Follow-up to 4376c5a6, found by a real BROWSER e2e (the flow the in-diff fix
missed). When the layout reshuffle's two halves land in SEPARATE sync cycles, the
later cycle's diff has only the DELETE of the old path — the matching add was
already pushed — so in-diff D+A coalescing can't see it, and the live page was
still trashed.

Robust fix on the identity invariant the reviewer (and the user) called out: a
page EXISTS iff its pageId is in the vault, regardless of filename. runPush now
collects the pageIds present at ANY path in the current `main` tree and passes
them to computePushActions; a deleted file whose pageId is still tracked
elsewhere is a MOVE, never a deletion. (Built only when the diff has deletes.)

Adds apps/server/test/git-sync-browser-e2e.cjs — a Playwright test that drives the
REAL Docmost web UI: log in, create several untitled pages, type a title, sync,
assert NOTHING is trashed. Reproduced the data loss before this fix; 5/5 green and
stable after. Engine suite 600 green (+2 computePushActions cases:
pageId-still-present -> skip; pageId-gone -> real delete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
5d0d5e7af4 test(git-sync): e2e guard for the untitled-page + retitle data-loss reshuffle
Reproduces the browser bug at the API level: create several untitled pages (all
collapse to the `_` fallback name), retitle one, sync — assert NO page is
trashed and all survive. Caught the data-loss bug fixed in 4376c5a6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
57b9ced95f test(git-sync): basic e2e operates on a dedicated page + cleans up (no real-page pollution)
The push / 3-way-merge cases edited the FIRST real `.md` in the vault, leaving
`E2E-PUSH-*` / `E2E-MERGE-*` marker headings accumulating in a real page, and the
Docmost->git case left its created page in the Trash. Now the suite creates a
dedicated `E2E-SyncTarget-*` page and targets only that, and a teardown
hard-deletes every `E2E-*` fixture page and converges the vault on exit — so runs
never mutate real content and leave the stand clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
a18302cdb4 test(git-sync): add advanced e2e suite — authz, protocol hardening, concurrency, data-loss guard
Output of a generate→critique subagent pass on "what the feature's tests do NOT
cover", implemented + verified against the live stand (20/20). Complements the
basic two-way suite. Covers:

- protocol shape: unknown service subpath -> 400; unknown content-type -> 415
  (global allowlist); PUT/DELETE on pack endpoints -> 400;
- path-traversal: `..%2f..`, `%2e%2e%2f`, bare `.git` space-id -> 400/404, no
  escape, never a file leak;
- authz boundaries: a gitSync-DISABLED space -> 404 (existence hidden) and flips
  to 200 when enabled; a READER member can fetch (200) but is FORBIDDEN to push
  (403); a NON-member of an enabled space gets 403 (NOT 404 — the critic caught a
  wrong generator assumption here; pinned as a contract);
- concurrency: a push while the per-space Redis lock is held -> 503 + Retry-After,
  and the receive-pack does NOT mutate the vault;
- idempotency: repeated no-op cycles never churn `main` / `refs/docmost/last-pushed`;
- data-loss guard (PR #119): deleting MORE than GIT_SYNC_MAX_DELETES_PER_CYCLE is
  HELD — none trashed AND last-pushed does not advance past the delete commit
  (retry-safe, not silently dropped).

Auto-creates/tears down its fixtures (reader/non-member users, a 2nd space) and
resets the vault cache on exit so re-runs and the basic suite stay green. Needs
the vault dir + Redis container reachable (see header). A structural rename/move
case was intentionally left to the engine unit suite (git rename-similarity on
meta-only fixture pages is a fixture artifact, not a feature bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
61aad27fce chore(git-sync): drop now-unused dirname import (PR #119 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
f24c8e20d5 test(git-sync): add a live two-way smart-HTTP e2e suite
A runnable end-to-end suite that drives a LIVE git-sync stand over the real /git
remote — the integration counterpart to the unit tests. 10 checks across the full
feature:
- the auth/authz gate: no creds -> 401, wrong password -> 401, unknown space ->
  404 (existence never revealed), valid creds on a sync space -> 200;
- fetch: git clone over HTTP returns the vault markdown;
- push: a git-side edit propagates into the Docmost page;
- Docmost -> git: a page created via the API materializes as a vault file;
- delete: `git rm` + push soft-deletes the Docmost page (Trash);
- 3-way merge: a new git edit is added without clobbering prior page content.

Parameterized via env (SERVER/SPACE_ID/EMAIL/PASSWORD/DB_CONTAINER) and isolates
its own test page. It boots nothing — see the header for the stand prerequisites
(GIT_SYNC_ENABLED + a per-space gitSync flag + a service user). This is the suite
that caught the smart-HTTP PATH_INFO 404 bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
b0fc49cf9d refactor(git-sync): move the PULL->PUSH cycle into the engine as runCycle (PR #119 review, arch #1)
The reconcile choreography (ensureRepo -> merge-check -> ensureBranch ->
checkout('docmost') -> pull -> push) was hand-rolled in the app orchestrator's
driveCycle, duplicating an order the vendored engine owns and could drift from on
upgrade — the failure mode is data clobber. Lift it into @docmost/git-sync as a
single entry point, `runCycle(deps)`. The orchestrator now calls runCycle and
keeps only the lock (its caller) and the gitmost-specific delete-cap POLICY,
injected as the `resolveApplyClient` hook (the engine does the dry-run, hands the
hook the planned delete count — Infinity if planning failed — and uses whatever
client it returns for the apply). driveCycle drops from ~150 lines to ~30.

Tests:
- engine test/cycle.test.ts: composition (merge-in-progress short-circuit;
  ensureRepo->ensureBranch->checkout staging order before the pull; the cap hook
  is consulted with the planned count; no dry-run when no hook).
- engine test/cycle-roundtrip.test.ts: runCycle against a REAL VaultGit in a temp
  repo with a faked Docmost client — a git-originated CREATE flows pull->push and
  the assigned pageId is written back; an unresolved merge short-circuits before
  any client call.
- orchestrator spec rewired to mock runCycle and assert the wiring + the
  resolveApplyClient cap policy (the engine-internal cycle-order/merge tests moved
  to the engine).

Validated end to end on a live stand (real Postgres/Redis + server): a git clone
-> edit -> push over the /git remote round-trips the change into the Docmost page
through the refactored cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
3b334d9624 fix(git-sync): drop the .git suffix from git http-backend PATH_INFO (smart-HTTP 404)
The /git smart-HTTP host 404'd EVERY fetch and push: PATH_INFO was built as
`/<spaceId>.git/<subpath>`, so `git http-backend` resolved the repo at
`<GIT_PROJECT_ROOT>/<spaceId>.git` — which does not exist. The vault is a NON-bare
working repo (the engine needs a working tree) at `<dataDir>/<spaceId>`, so the
CGI repo path must be `<spaceId>` (git http-backend serves the `.git` inside).
The URL's conventional `.git` suffix is already stripped to `spaceId` by
parseGitPath; re-appending it for PATH_INFO was the bug.

Found by standing up a full e2e stand (real Postgres/Redis + server + a real git
clone/push over the /git remote): clone and push both 404'd until this fix, after
which a clone → edit → push round-trips the change all the way into the Docmost
page.

Also extracts the CGI-env construction into a pure, exported `buildGitBackendCgiEnv`
and adds unit tests (the env build was previously untested — the gap this bug hid
in): a regression guard pinning PATH_INFO to `/<spaceId>/<subpath>` (no `.git`),
plus method/query/content-type/remote-user forwarding and the conditional
GIT_PROTOCOL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
71a96581ca test(git-sync): cover ingestExternalPush in the orchestrator spec (PR #119 review)
Closes the test-coverage warning that the smart-HTTP push ingest path was
unexercised. Adds 5 cases: receive-pack streams BEFORE the Docmost cycle; a
held lock throws GitSyncLockHeldError and runs neither the receive-pack nor the
cycle; a post-push cycle error is swallowed (the push is durable, poll retries)
while the lock is still released; a missing service user runs the receive-pack
but skips the immediate cycle; and a globally-disabled git-sync refuses without
touching the lock.

(The 503/Retry-After mapping in git-http.service is the sibling warning; its spec
is in the repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain, so that case is left for CI.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
306d88c685 refactor(git-sync): extract SpaceLockService from the orchestrator (PR #119 review, arch #2)
The per-space single-writer lock — Redis CAS leader lock (SET NX PX, DEL-CAS and
PEXPIRE-CAS Lua), the in-process mutex, the per-process instanceId and the
heartbeat — lived inline in GitSyncOrchestrator. Extract it into a dedicated
@Injectable() SpaceLockService exposing one narrow surface, withSpaceLock(spaceId,
fn), so the lock is the orchestrator's only Redis-lock touch-point and is testable
in isolation. The orchestrator now injects SpaceLockService and both consumers
(runOnce, ingestExternalPush) go through spaceLock.withSpaceLock — behavior
unchanged (same sentinel returns, same 503-on-lock-held contract). Orchestrator
drops 591→472 lines.

Adds space-lock.service.spec.ts asserting the lock SEMANTICS against a fake Redis
(the test-coverage warning from the review): the SET NX/PX args, the DEL-CAS and
PEXPIRE-CAS Lua + ARGV[1]=instanceId, plus the lock-held / in-progress / throw-
still-releases paths. The orchestrator spec is unchanged in count and stays green
(it now builds the real SpaceLockService over its mock Redis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
0318a148dc docs(git-sync): remove dangling references to the deleted git-sync-plan doc (PR #119 review)
The implementation spec docs/git-sync-plan.md was removed as completed, but ~44
code comments still cited it as "plan §N". Strip those citations (comments only),
keeping each comment grammatical. The vendored engine's own "SPEC §N" references
point at a different, still-present spec and are left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
f923accc3d refactor(git-sync): drop dead DebounceEntry.workspaceId field (PR #119 review)
The debounce map value carried `workspaceId`, but the scheduled cycle closes over
the `workspaceId` argument directly — the field was written and never read.
Replace the entry struct with `Map<string, NodeJS.Timeout>` (the timer handle is
all the map tracks). No behavior change. (page-change.listener.spec is in the
repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain — unaffected by this change; tsc clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
a0e1cde063 refactor(git-sync): extract shared buildLcsTable for the two block diffs (PR #119 review)
The two-way block diff (yjs-body-merge.diffBlocks) and the three-way merge
planner (three-way-merge.lcsPairs) built the identical backward-filled LCS DP
table inline. Extract it to lcs.ts (buildLcsTable); each caller keeps its own
traceback. No behavior change — merge specs unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
259d4ca6fa fix(git-sync): hold refs on suppressed deletes + stamp delete/restore provenance (PR #119 review)
Two stability warnings from the #119 review:

1. delete-cap no longer drops deletions forever. When planned deletes exceed
   GIT_SYNC_MAX_DELETES_PER_CYCLE the apply client's deletePage now THROWS
   instead of resolving to a no-op. A throw is recorded by the engine as a
   per-page failure, so `refs/docmost/last-pushed` is NOT advanced past the
   commit that dropped the files — the next cycle re-diffs from the un-advanced
   ref and re-plans the same deletes (a transient over-cap is retried, not
   silently dropped and then recreated by the next pull). Previously a resolving
   no-op let the engine count `deleted++` with no failure, advance the ref, and
   never replay the deletions.

2. git-sync soft-delete and restore now stamp provenance. deletePage routes
   GIT_SYNC_PROVENANCE through pageService.removePage, and restorePage stamps
   lastUpdatedSource='git-sync' on the restore update — so the page-change
   listener's loop-guard (skip when lastUpdatedSource==='git-sync') recognizes
   both as its own writes instead of scheduling a wasted echo cycle. Done via a
   backward-compatible optional `lastUpdatedSource` param on
   pageRepo.removePage/restorePage (omitted for ordinary user deletes/restores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
7ed33d8127 docs(git-sync): document GIT_SYNC_* env vars; fix stale/non-English comments (PR #119 review)
Addresses the documentation/convention warnings from the #119 review:
- .env.example: add the GIT-SYNC block (9 GIT_SYNC_* vars with defaults), noting
  GIT_SYNC_SERVICE_USER_ID is required when sync is enabled.
- yjs-body-merge.ts: translate the Russian review note in the docstring to
  English (comments-only-in-English rule).
- persistence.extension.ts: correct the stale "git-sync writes are full-body
  replaces" rationale — a git-sync write is now a block-level merge into the live
  doc, which is why it is debounced like a human edit rather than snapshotted.
- history-item.tsx: the GitSyncBadge version is created on the PUSH path (writing
  the git body back into the doc), not by the pull — fix the comment.
- edit-space-form.tsx: log the raw error in the git-sync toggle catch instead of
  swallowing it (AGENTS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
c5b05aacaf chore(mcp): stop committing build/ and node_modules; build in CI/Docker
Same hygiene fix as git-sync (review #2), applied to packages/mcp which had the
identical pre-existing problem: committed build/ (20 files) + node_modules (28,
pnpm symlinks with a baked /home/claude store path).

- git rm --cached packages/mcp/{build,node_modules}.
- .gitignore: add packages/mcp/build/ (packages/*/node_modules/ already covers it).
- Build where consumed: apps/server `pretest` and the CI Test workflow now build
  @docmost/mcp too. The Dockerfile builder already runs `pnpm build` (nx builds
  mcp) and already COPYs packages/mcp/build into the runtime image.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/mcp build`; the mcp
server suites (96 tests) pass against the freshly-built, non-committed output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:20 +03:00
claude code agent 227
f90f3e272a feat(git-sync): three-way body merge using the last-synced base (no edit loss)
Upgrades the 2-way body merge to a real diff3 three-way merge (review #5), so a
block ONLY the human changed is KEPT when git changed a DIFFERENT block — the
2-way merge would revert it to git's stale version.

Engine: the push update loop reads the last-synced pre-image
(`git.showFileAtRef(refs/docmost/last-pushed, path)`) and passes it as the
optional `baseMarkdown` to `client.importPageMarkdown` (the common ancestor).

Server: gitmost-datasource converts base+incoming, and writeBody runs a block-
level diff3 (new three-way-merge.ts `diff3Plan`): live-only change -> keep live,
git-only change -> take git, both-changed -> git wins (conflict policy), inserts/
deletes from either side preserved. Without a base (createPage) it falls back to
the 2-way merge. Crash-safety unchanged (docs built before the connection opens).

Tests: three-way-merge.spec.ts (14 — every diff3 case incl. the cross-block
preservation and conflict policy), yjs-body-merge 3-way (real Y.Docs: human's
block instance preserved while git's block is applied), plus an engine test that
the base is forwarded from showFileAtRef. Existing push assertions updated for the
new base arg. git-sync 589 pass; server merge/datasource/gate 62 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:04 +03:00
claude code agent 227
3bba9425f4 fix(git-sync): merge git body into the live doc block-by-block (no clobber)
Supersedes the active-session "defer" guard with a real merge (review #5 —
"запись делать через мерж", not skip-while-editing).

writeBody no longer does delete-all + re-insert (which discarded a concurrent
editor's in-flight changes on every sync). It now diffs the live body against the
incoming git body at TOP-LEVEL BLOCK granularity (LCS over a canonical structural
serialization) and applies only the minimal inserts/deletes:
- a block a human is editing is left UNTOUCHED when git changed a DIFFERENT block;
- an unchanged resync is a complete 0-op write;
- Yjs CRDT-merges the minimal ops with concurrent edits.

New yjs-body-merge.ts (mergeXmlFragments + cloneXmlNode + diffBlocks) is pure-Yjs
and unit-tested with real Y.Docs (8 tests): identical->0 ops, edit-one-block keeps
the other block instances, append/delete keep neighbours, marks survive the
cross-doc clone. Crash-safety kept: the incoming doc is built before the
connection opens, so a transform failure can't empty the body.

Removed: the ActiveEditSessionError defer path and the now-unused
CollaborationGateway.getActiveEditorCount.

Honest limitation: this is a 2-way merge — for a block BOTH sides changed since the
last sync, git wins (no common ancestor to decide). A full 3-way merge would need
the last-synced base plumbed from the engine; the dominant cases (unchanged
resync, edits to different blocks) are now lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:03 +03:00
claude code agent 227
9c805e8069 chore(git-sync): stop committing build/ and node_modules; build in CI/Docker
Review finding #2: packages/git-sync/build/ (the COMPILED engine) and the
package's node_modules/ were committed. Prod executed the committed build/ while
CI/tests ran src/ and never rebuilt it — so a fix in src/ could pass tests while
stale compiled code shipped (a silent src/prod skew). The committed node_modules
were pnpm symlinks with a baked machine-local store path (/home/claude/...),
useless and misleading for everyone else.

- git rm --cached packages/git-sync/{build,node_modules} (42 + 31 files).
- .gitignore: ignore packages/*/node_modules/ and packages/git-sync/build/.
- Build the package where it is actually consumed: apps/server `pretest` now
  builds @docmost/git-sync (its suite imports the built build/index.js), and the
  CI Test workflow gains an explicit "Build git-sync" step. The Dockerfile builder
  already runs `pnpm build` (nx builds the package) and now COPYs the fresh build/.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/git-sync build`, then
the server converter gate (26/26, imports the rebuilt package) and the git-sync
suite (588 passed) both pass against the freshly-built, non-committed output.

NOTE: packages/mcp/ has the same committed-build/node_modules pattern (pre-existing,
out of this PR's scope) and should get the same treatment in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:18:03 +03:00
claude code agent 227
d716ca385a fix(git-sync): don't clobber pages with a live editing session; crash-safe body write
Review finding #5: the git -> page body write (writeBody) did a full-body replace
(delete-all + re-insert) on the shared Yjs doc. Applied while a human is editing
the page, it discarded their in-flight changes; and TiptapTransformer.toYdoc ran
AFTER the fragment was cleared, so a conversion failure could leave the page with
an empty body.

Fixes:
- Active-session guard: CollaborationGateway.getActiveEditorCount(documentName)
  reports live human (websocket) editor sessions for a doc, excluding server-side
  direct connections. writeBody now throws ActiveEditSessionError when an editor
  is connected. The engine's push loop already isolates each importPageMarkdown in
  try/catch and does not advance the loop-guard on failure, so the write is simply
  retried on the next poll once the editor disconnects — never a clobber.
- Crash-safe conversion: build the replacement Yjs update BEFORE opening the
  connection / clearing the fragment, so a transform failure can never leave the
  body empty.

Also updates the server-side converter gate spec to the corrected round-trip
shape: the block-image hoist no longer leaves a leading empty paragraph (the
git-sync converter fix in 7d39c16b, now reaching the built package).

A true merge of git content into a live Yjs session is out of scope (it needs a
real 3-way text merge with no shared update lineage); deferring the write while a
page is being edited is the safe, owner-approved minimum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude_code
66bd039f8f feat(git-sync): serve spaces over smart-HTTP (gitmost as a two-way git host)
Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude_code
ba15fde809 test(git-sync): add reviewer-requested coverage across engine, server, client
Implements the test cases called out in the PR #119 review threads
(code-review, test-strategy report, red-team) — TESTS ONLY, no production
code changes.

packages/git-sync (vitest):
- lib converter/markdown gaps: pageBreak data-loss (it.fails repro),
  subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge,
  column.width number<->string drift, empty details.
- engine units: parentFolderFile, planReconciliation swap/chained move,
  buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions /
  applyPullActions failure isolation.
- real temp-git integration: diffNameStatus -z rename+add/modify
  alignment, copy-line behavior, per-invocation committer identity (no
  leak into repo/global config).
- ENFORCED type-level GitSyncClient contract via vitest typecheck over a
  *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched).

apps/server (jest):
- orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex
  skip ladder + release-on-throw, merge guard, pull/push order, remote
  template substitution, poll lifecycle.
- page-change listener: loop-guard, debounce coalescing, id resolution,
  error swallowing.
- vault registry, controller authz (trigger + status), env
  validation/getters, page.service git-sync provenance stamping,
  persistence precedence (agent > git-sync > user) + no boundary snapshot,
  space.service audit-delta, space.repo jsonb-merge, converter-gate corpus
  extension (mention/math/details/marks).

apps/client (vitest + testing-library):
- history-item git-sync badge: render gating + non-clickable.
- edit-space-form toggle: initial state, optimistic payload, rollback on
  error, disabled states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
d1a8b48b96 fix(git-sync): address review — configurable poll, always-on loop-guard, cleanup
Comprehensive-review follow-ups (APPROVE WITH SUGGESTIONS; no critical issues):
- poll interval is now actually configurable: replaced the hardcoded
  @Interval('git-sync-poll', 15000) with a dynamic SchedulerRegistry interval
  registered in onModuleInit from getGitSyncPollIntervalMs() (cleared in
  onModuleDestroy); /status and the real cadence now share one config source.
  Boots logging 'poll interval registered (Nms)'.
- loop-guard now ALWAYS applies: the lastUpdatedSource==='git-sync' skip was
  nested inside the !spaceId/!workspaceId branch, so structural self-writes
  (CREATE/MOVE/RESTORE/SOFT_DELETE, which carry spaceId+workspaceId) bypassed it
  and re-triggered cycles. Fetch the page row once, guard unconditionally, then
  resolve space/workspace.
- remove the dead PAGE_CONTENT_UPDATED subscription (it's a BullMQ job, never an
  EventEmitter event; body edits arrive via PAGE_UPDATED).
- fix the stale datasource comment (PageService DOES stamp 'git-sync' now).
- env getters: parseInt radix 10 + NaN/<=0 fallback for poll/debounce (+ max
  deletes), with 6 new environment.service.spec tests.

tsc clean; jest 723 pass; live cycle re-verified post-refactor (ran, push
applied, unflagged 92-page space untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
0692e55981 feat(git-sync): client 'Git sync' provenance badge + git in runtime image (Phase D)
- page-history history-item: a lastUpdatedSource==='git-sync' version renders a
  neutral gray 'Git sync' badge (git-merge icon), NOT the agent badge/deep-link
  (it is not an agent edit). +2 i18n keys.
- Dockerfile: install git in the installer (runtime) stage — VaultGit shells out
  to git, so assertGitAvailable() needs the binary at runtime.
Client tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
55d610b7f8 feat(git-sync): per-space 'Enable Git sync' toggle (Phase C, §7.1)
UI opt-in for git-sync, mirroring the existing sharing/comments settings pattern
(no new endpoint, no new mechanism; orchestrator read query untouched):
- UpdateSpaceDto.gitSyncEnabled?: boolean.
- SpaceRepo.updateGitSyncSettings: jsonb-merge into settings.gitSync.<key>
  (COALESCE || jsonb_build_object — never clobbers sibling sharing/comments);
  stored as a real jsonb boolean so the orchestrator's
  settings->'gitSync'->>'enabled' = 'true' matches.
- SpaceService.updateSpace handles the flag (audit diff) via the existing
  CASL-guarded space update path (Manage/Settings).
- client: Switch in edit-space-form (optimistic mutate + revert-on-error,
  readOnly-aware) + space types + 2 i18n keys.
- space.service.spec extended (calls updateGitSyncSettings; no-op when undefined).
tsc clean (server+client); jest src/core/space 4 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
8201e76c66 fix(git-sync): branch choreography + strict scoping + delete cap (Phase B hardening)
Fixes found by the live pull/push e2e:
- CRITICAL: driveCycle never checked out the 'docmost' branch before
  applyPullActions, so Docmost content was written straight onto 'main',
  clobbering local file edits before push could diff them. Now checkout
  'docmost' before pull (applyPullActions commits there then checks out main +
  merges) — mirrors the engine's pull main(). Round-trip now works both ways.
- add an unresolved-merge guard (SPEC §9): skip the cycle if the vault is
  mid-merge instead of failing on checkout.
- SAFETY: enabledSpaces() is now STRICT opt-in — only spaces with
  settings.gitSync.enabled===true; removed the all-spaces fallback that synced
  every space (incl. a 92-page one) the moment GIT_SYNC_ENABLED flipped.
- SAFETY: per-cycle delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5):
  dry-run the push, and if planned deletes exceed the cap, run the apply with
  deletePage neutralized — phantom absence-deletions from a non-convergent vault
  can't soft-delete real pages. Fails safe if the dry-run throws.
- fix manual trigger: TriggerGitSyncDto.spaceId needs @IsUUID or the global
  whitelist ValidationPipe strips it (arrived undefined -> vault 'undefined').

Live-verified on an isolated flagged space: push (vault file edit -> Docmost
content, stamped lastUpdatedSource='git-sync') and pull (Docmost rename -> vault
file + meta) both work; an unrelated 92-page space stayed untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
901147a224 feat(git-sync): GitSyncModule orchestrator + config + listener (Phase A.4b/B)
Control plane wiring (plan §5-§11):
- PageService create/update/movePage now honor provenance actor 'git-sync'
  (stamp lastUpdatedSource='git-sync'), closing the A.4a gap.
- EnvironmentService: GIT_SYNC_ENABLED / DATA_DIR / REMOTE_TEMPLATE /
  POLL_INTERVAL_MS / DEBOUNCE_MS / SERVICE_USER_ID (required-if-enabled) /
  SSH_KEY_PATH + validation.
- VaultRegistryService: per-space vault path + cached VaultGit.
- GitSyncOrchestrator: per-space Redis leader-lock (SET NX PX + CAS-Lua release,
  randomUUID instanceId) + in-process mutex; runOnce drives the vendored engine
  PULL (readExisting->computePullActions->applyPullActions) then PUSH (runPush)
  with the bound native GitSyncClient + VaultGit; @Interval poll-safety gated on
  GIT_SYNC_ENABLED; imports plain ScheduleModule (TelemetryModule owns forRoot).
- PageChangeListener: @OnEvent PAGE_* -> per-space debounce -> runOnce, with a
  best-effort lastUpdatedSource==='git-sync' loop-guard.
- GitSyncController: admin POST /api/git-sync/trigger + GET /status (ops/e2e).
- GitSyncModule registered in app.module. Enabled-space enumeration uses
  settings.gitSync.enabled, falling back to all live spaces until Phase C writes
  the flag (master gate = GIT_SYNC_ENABLED).

tsc clean; 713 tests/71 suites pass; dev server hot-reloaded the module (route
live, DI graph boots). Live pull/push round-trip verified next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
afe1ba8398 feat(git-sync): native GitmostDataSource + 'git-sync' provenance (Phase A.4a)
Native data plane for git-sync (plan §3, §8.1):
- provenance: widen actor to 'user'|'agent'|'git-sync' (jwt-payload,
  auth-provenance decorator); PersistenceExtension resolves lastUpdatedSource
  with precedence agent > git-sync > user, debounced history (like a human edit,
  not the agent's immediate snapshot).
- GitmostDataSourceService implements @docmost/git-sync's GitSyncClient natively:
  reads via PageRepo/SpaceRepo (listSpaceTree complete:true, getPageJson), writes
  via PageService (create/removePage soft-delete/movePage with computed fractional
  position/update-rename/restore) + the writeBody linchpin through collab
  openDirectConnection('page.'+id, {actor:'git-sync'}) mirroring
  collaboration.handler withYdocConnection 'replace'. bind({workspaceId,userId})
  returns the context-bound client for the orchestrator.
- 10 unit/contract tests (mapping + soft-delete + move-position), tsc clean.

Known gap (closed in A.4b): PageService.create/update/movePage only branch on
actor==='agent'; git-sync provenance is already passed through so the row source
marker propagates once PageService honors 'git-sync'. Module/orchestrator/config
come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
5aaeaaae3c feat(git-sync): CommonJS build + §13.1 editor-ext idempotency gate (Phase A.2)
Make @docmost/git-sync natively consumable by the CommonJS server (and jest):
build to CommonJS (tsconfig module CommonJS, drop type:module, strip .js from
relative imports), and lazy-load the only ESM-only dep (marked) via the dynamic
Function('import()') trick (mirrors docmost-client.loader.ts) with a require()
fallback so vitest's evaluator works too. git-sync tests stay green (314 pass,
3 expected fail).

Add the §13.1 idempotency gate (apps/server .../git-sync-converter-gate.spec.ts):
13 editor-ext docs (paragraphs/headings, marks, links, bullet/ordered/task lists,
blockquote, callouts, code block, hr, table, nested mix) round-trip
content(editor-ext) -> convertProseMirrorToMarkdown -> markdownToProseMirror ->
TiptapTransformer.toYdoc/fromYdoc(tiptapExtensions) -> canonicalize and assert
docsCanonicallyEqual. All green => the vendored converter's docmost-schema is
schema-compatible with editor-ext (no node/mark/attr loss), which the plan §13.1
requires before Phase B. The one intrinsic markdown-image lossiness (width/height
/align can't ride plain ![](src)) is isolated in a KNOWN DIVERGENCE block, not
hidden. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:17:24 +03:00
claude code agent 227
ed3b65c36b Merge remote-tracking branch 'gitea/develop' into batch/issues-2026-06-25
# Conflicts:
#	apps/server/src/core/ai-chat/ai-chat.service.spec.ts
#	apps/server/src/core/ai-chat/ai-chat.service.ts
2026-06-25 12:48:47 +03:00
claude_code
de115ade1e Merge pull request 'feat(ai-chat): persistent history as source of truth — step durability + server export (#183)' (#186) from feat/ai-chat-persistent-history into develop 2026-06-25 12:40:36 +03:00
claude code agent 227
aa7a115f66 refactor(review): address PR #186 re-review (approve-with-comments)
Approve-with-comments re-review; no blockers. All 7 actionable points (8 is a
forward-looking architecture note — recommendation A, keep as-is):

1. chat-markdown.util spec: restore parity coverage of the removed client spec —
   tool error state (+ errorText), unknown-tool fallback (`Ran tool <name>` en /
   `Выполнил инструмент <name>` ru), and the circular-output stringify catch.
2. findAllByChat row cap is now testable (injectable limit) + an int-spec proves
   truncation on a modest volume.
3. Stability: the per-step durability updates are SERIALIZED via a promise chain
   (stepUpdateChain) so they commit in step order — onlyIfStreaming already
   closed the finalize race, this closes inter-step ordering.
4. findAllByChat keeps the NEWEST messages on truncation (order DESC + reverse,
   like findRecent) and logs a warning with chatId, instead of silently dropping
   the newest tail.
5. The LABELS parity comment already references the real path (tool-parts.tsx /
   toolLabelKey) — confirmed accurate.
6. Removed the redundant 'off-by-one boundary' test (strict subset of the two
   adjacent prepareAgentStep cases).
7. Extracted the terminal-finalize dispatch into a shared `applyFinalize`, used
   by BOTH the service's finalizeAssistant and its test — the test now exercises
   the real path, not a copy, so a production drift fails it.

Verified: server build + 325 ai-chat unit + 6 integration; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:28:35 +03:00
claude code agent 227
30c358a2f8 test(review): add the 4 new test-coverage points from PR #185 re-review
The re-review's blocking/structural points (lease leak, dup-id guard test,
body-before-title test, CHANGELOG, pg18, shared jsonb decoder) were already
addressed in commit 24264ef; this adds the 4 genuinely-new coverage requests:

- pt 6: `scrollToReference(id, index?)` exercised against a live editor DOM —
  selects the index-th `sup[data-footnote-ref][data-id]` occurrence, falls back
  to the first for out-of-range, returns false for an empty id (scrollIntoView
  stubbed). (#168)
- pt 7: export `backlinkLabel` and pin the base-26 carry boundary
  (25->z, 26->aa, 27->ab, 51->az, 52->ba). (#168)
- pt 8: integration fail-open — a PRESENT-but-corrupt tool_allowlist (jsonb
  string scalar holding non-array JSON) reads back as null ("no restriction"),
  covering normalizeRow's degrade branch. (#159 #172/#173)
- pt 9: getFootnoteRefCount cache invalidation — adding a `[^a]` reference bumps
  the cached count 2 -> 3. (#168)

Verified: editor-ext footnote 23; client structure 7 + tsc; server int 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:08:21 +03:00
claude code agent 227
ea61c96a7c refactor(review): address PR #186 review (#183 — recency sweep, #174 export, tests, cleanups)
15-point review of the persistent-history PR. Architecture decisions: crash
recovery = recency threshold; tool-label duplication = leave as-is.

Must-fix:
1. Boot-sweep bounded by recency. sweepStreaming now also requires
   `updatedAt < now() - SWEEP_STREAMING_STALE_MS` (10 min), so a fresh replica's
   startup sweep can't abort a turn another replica is actively streaming
   (multi-instance deploy). Int-spec: a FRESH 'streaming' row is NOT swept, a
   STALE one IS.
2. Restore export during the FIRST streaming turn of a new chat (#174). The
   server chatId is now adopted EARLY (in-place, on the start-chunk metadata) via
   a new `onServerChatId` callback wired through use-chat-session → chat-thread,
   so `activeChatId` is set at turn start and the Copy button is live mid-first-
   turn (canExport = !!activeChatId). Hook tests for early/in-place/no-op adopt.
3. Cover finalizeAssistant's fallback-insert branch: extracted pure
   `planFinalizeAssistant(assistantId)` (update when id present, insert when the
   upfront insert failed) + a dispatch harness test for both arms.

Tests: onModuleInit lifecycle spec (sweep called; throw → resolves + warns);
int-spec updatedAt assertion → toBeGreaterThan.

Cleanups: cap findAllByChat at 5000 rows; upfront-insert-failure log carries
chatId+workspaceId; removed the now-dead buildPartialAssistantRecord (only the
spec consumed it; shapes still pinned by the flushAssistant suite); controller
passes `lang: dto.lang` (normalizeLang handles undefined); dropped a no-op
`?? undefined` in errorOf; documented the content-column semantics change
(concatenated step text, UI renders from metadata.parts); CHANGELOG [Unreleased]
entry (#183, #174); reworded the stale LABELS parity comment.

Verified: server build + 323 ai-chat unit + 5 integration; client tsc + 160
ai-chat unit; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:53:25 +03:00
claude code agent 227
f80276d41a refactor(review): address PR #185 review (lease leak, tests, changelog, jsonb seam)
8-point multi-aspect review of the batch PR; security/regressions were clean.

1. Lease leak: the #180 reorder moved `toolsFor` (which leases external MCP
   clients, refCount+1) ahead of buildSystemPrompt + forUser, but the only
   release (closeExternalClients) was bound to the streamText callbacks. A throw
   in between leaked the lease (refCount stuck, undici sockets held until
   restart). Define closeExternalClients right after the lease and wrap
   buildSystemPrompt+forUser in try/catch that closes-then-rethrows.
2. Cover the patch_node/delete_node dup-id refusal (#159 #6): extract the guard
   into a pure `assertUnambiguousMatch` (node-ops) and unit-test 0/1/>1.
3. Regress the body-before-title order (#159 #10): mock-HTTP test (collab fails
   fast against a server with no WS upgrade) asserts /pages/update (title) is
   NEVER posted when the body write fails — for updatePage AND updatePageJson.
4. CHANGELOG [Unreleased]: #180, #168 (Added); #163 (Fixed).
5. Add the missing en-US i18n keys (Back to references / {{label}}).
6. Drop the duplicate content/empty/blank cases in ai-chat.prompt.spec.ts (they
   repeat the buildMcpToolingBlock unit tests); keep only sandwich placement +
   both-safety-copies.
7. CI Postgres pg16 -> pg18 (match docker-compose).
8. jsonb decode seam: shared `parseJsonbValue(value, guard)` in database/utils.ts
   holds the legacy double-encoding self-heal in one place; parseToolAllowlist /
   parseModelConfig keep only a type-guard.

Verified: server build + 124 unit + 15 integration; mcp 311; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8218c1a8ef fix(tree): refresh loaded branches on reconnect so they don't go stale (#159)
Third tree-sync finding (#8). On a socket reconnect after a missed-events gap
(laptop sleep / wifi blip), the resync only invalidated the ROOT sidebar query;
a move/rename/delete that happened INSIDE an already-loaded, expanded branch was
never reflected — the branch stayed stale until the user manually interacted.
(The #2 fix reconciles the root level; this covers the deeper loaded branches.)

- `treeModel.reconcileChildren(tree, parentId, fresh)`: replace a loaded
  branch's DIRECT children with the authoritative fresh set (drop removed, add
  new, reorder to server) while PRESERVING each surviving child's already-loaded
  grandchildren, so deeper expansion is not collapsed. An unloaded branch
  (children === undefined) is left untouched (lazy-load fetches it fresh).
- `loadedOpenBranchIds(tree, openIds)`: the branches a reconnect should refresh
  (open AND loaded). `fetchAllAncestorChildren(..., { fresh: true })` bypasses
  the 30-min sidebar cache so the reconcile sees current data (handler-order
  independent).
- space-tree: on socket `connect`, re-fetch + reconcile each open loaded branch
  of the active space (space-switch-guarded; an unloaded branch is skipped).

Tests: reconcileChildren (drop/add/reorder + preserve grandchildren + unloaded
no-op) and loadedOpenBranchIds (open+loaded only, skip unloaded, nested). The
pure logic is unit-tested; the live socket-reconnect round-trip is not
browser-automated (simulating a reconnect gap is impractical) — sidebar render +
expand were smoke-tested with no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
d7e7489654 fix(tree): stop silent page loss on move-to-unloaded-parent + reconnect ghost roots (#159)
Two confirmed P1 data-loss findings in the sidebar tree sync.

#1 — Move into an unloaded/collapsed parent silently dropped pages. When a
moveTreeNode (or addTreeNode) broadcast targeted a parent whose children were
NOT yet lazy-loaded, `insertByPosition` did `kids = parent.children ?? []` and
inserted the moved node, MATERIALIZING a misleading partial child list
(`[movedNode]`) out of an unloaded (`children === undefined`) parent. The
lazy-load gate fetches only when children are absent/empty, so it then refused
to fetch — leaving the parent showing ONLY the moved node and HIDING all its
other real children (and, when the parent wasn't in the tree at all, the node
was removed and never re-fetched). Fix: `insertByPosition` distinguishes
`children === undefined` (not loaded) from `[]` (loaded-empty) and, for an
unloaded parent, does NOT insert — it leaves children unloaded and just flags
`hasChildren`, so expanding fetches the FULL set (including the moved/added
node) via the existing lazy-load.

#2 — After a socket reconnect, a deleted/moved-away root lingered as a 404
"ghost". `mergeRootTrees` was append-only: it kept every previously-loaded root
and only added new ones, so a root removed during the missed-events gap was
never dropped. It runs only once all root pages are fetched, so the incoming
list is the authoritative complete root set — fix reconciles to it (drop roots
absent from incoming) while PRESERVING each surviving root's lazy-loaded
subtree and refreshing its own fields.

Tests: insertByPosition unloaded-vs-loaded-empty parent; the move reducer
keeps a collapsed destination lazy-loadable instead of partial; mergeRootTrees
drops a ghost root, preserves a surviving subtree, adds new roots, refreshes
fields. The existing "remove when parent not in tree" reducer test still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00