452a752264e01cb08d9aa43524f9879c079bb0e4
269 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
452a752264 |
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> |
||
|
|
a40a00d5c5 |
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> |
||
|
|
81c0226be7 |
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> |
||
|
|
d5079aa1d8 |
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 . 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> |
||
|
|
28d2560dfd |
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> |
||
|
|
5da12e89f9 |
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> |
||
|
|
2e83c9cebf |
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> |
||
|
|
71375e25ee |
chore(git-sync): drop now-unused dirname import (PR #119 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
dc7a0ec9f5 |
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>
|
||
|
|
969c00aaf1 |
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> |
||
|
|
085a30575f |
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> |
||
|
|
95bc9fe98d |
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> |
||
|
|
cca0bfe306 |
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> |
||
|
|
0dbf85b129 |
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> |
||
|
|
fb357cd52e |
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> |
||
|
|
177d8a31d4 |
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> |
||
|
|
8fa32e8438 |
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> |
||
|
|
fa89cba023 |
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> |
||
|
|
3386bf2865 |
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> |
||
|
|
181a8330f3 |
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
|
||
|
|
04032ae677 |
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> |
||
|
|
d9d1d54aaa |
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> |
||
|
|
593f181bbc |
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>
|
||
|
|
8373360a67 |
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> |
||
|
|
e2493cafa9 |
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> |
||
|
|
5a4d9f84d7 |
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>
|
||
|
|
c5109aa2a3 |
Merge pull request 'feat(footnotes): author-inline footnotes + deterministic server canonicalization (#228)' (#232) from feat/228-inline-footnotes into develop
Reviewed-on: #232 |
||
|
|
c4ed4a4855 |
fix(footnotes): strip bare definitions on rebuild; MCP full-doc + zip-import canonicalize tests (#228)
Review #6 (approve-with-comments) follow-ups: 1. canonicalize step 7 now strips bare footnoteDefinitions at ANY depth (stripFootnoteDefinitionsDeep), not just footnotesList, in BOTH copies. A definition hand-authored outside a list (e.g. nested in a callout via a raw-JSON write path) was left in place while a copy was also added to the rebuilt list -> duplicate, idempotent, self-perpetuating. Runs only in the rebuild path (after the lists are stripped); the fast-path / placement-keep branch is untouched. Added a shared-corpus case (bare def nested in a callout) to pin it in both mirrors. 2. markdown-clipboard: removed the dead top-level footnoteReference check in canonicalizePastedFootnotes (an inline atom is never a top-level slice child; only the descendants scan can find it). Test coverage: 4. New MCP binding tests (full-doc-write-canonicalize.test.mjs): update_page_json and copy_page_content canonicalize the persisted full doc, asserted via a new `replacePage` seam (symmetric to the existing `mutatePage` seam) so no live collab socket is needed. Routed both writers through the seam. 5. New server spec (file-import-task.service.footnote-canonicalize.spec.ts): the zip-import path (processGenericImport) canonicalizes footnotes — real markdown->HTML->JSON via a real ImportService over a temp-dir .md file, DB trx stubbed to capture the persisted page content. FileImportTaskService had no spec before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
40d1cdfc77 |
refactor(review): address #230 third review — callout dedup, ticket/type tidy
Approve-with-comments follow-ups (no blockers): - callout: unify the GitHub-callout feature ticket on #192 (the callout-paste feature the CHANGELOG already tracks); #218 is the public-share security work. Fixed the code comment and test reference. - export/utils.spec: pin current behavior of a leading-dot name (".gitignore" -> "") — same bug class as #204 but unreachable via the sole caller, so document not change. - share.types: narrow ISharedPage to the actual /shares/page-info allowlist (page -> Pick of id/slugId/title/icon/content; trimmed share; dropped the spurious `extends IShare`). Verified all three consumers (shared-page, link-view, mention-view) read only allowlist fields. - editor-ext: extract shared CALLOUT_TYPES / normalizeCalloutType / renderCalloutHtml into callout-common.marked.ts; both tokenizers (`:::type` and `> [!type]`) now share the renderer + type dict while staying separate. Eliminates the byte-identical renderer + duplicated type list. - share.service: extract named predicate shareIdGrantsAccess(requestedShareId, resolvedShare) for the id-or-key fast path (naming only, no control-flow change); kept narrower than resolveReadableSharePage's id-only gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
07ebd8c63e |
fix(footnotes): address PR #232 review — fragment-safe canonicalization, plugin placement parity, dead-code removal (#228)
Must-fix: - Move canonicalizeFootnotes OUT of parseProsemirrorContent. It now runs only on FULL writes (createPage, updatePageContent operation==='replace'), never on an append/prepend fragment (a fragment would lose definition-only footnotes or synthesize a bogus empty list). Add a server binding spec. - Match the live plugin's list PLACEMENT: a single already-canonical footnotesList is left exactly where it sits (the plugin never repositions a sole correct list), so the first write no longer reorders content that follows the list. Applied to BOTH the editor-ext copy and the MCP mirror; pinned by a shared golden corpus case with content after the list. - Fix MCP tool count 38 -> 39 (README x3, AGENTS.md) and the transformJs param help (add canonicalizeFootnotes/insertInlineFootnote). Simplifications: - Remove the dead duplicate re-id mechanism (deriveFootnoteId/suffix/occurrence) from the PURE canonicalizer in both copies — references are never renamed, so the derived ids were never requested; first-wins-drop is the real behaviour. This also makes the editor-ext footnote-util note about "no cross-package copy" true again. - Remove the sentinel round-trip in insertInlineFootnote: a generalized insertNodesAfterAnchor core inserts the footnoteReference node directly. - Drop the redundant per-definition deep clone in step 4 (shallow id-normalizing copy; out is already deep-cloned). Docs / architecture: - Correct the editor-ext copy's "It exists because…" header to its real consumers (server import, page.service create/update, client paste). - Note markdownToProseMirror reuse for create/update comment in collaboration.ts. - A: shared golden JSON corpus exercised by BOTH the editor-ext copy and the MCP mirror (footnote-corpus.ts / .mjs) so "the two copies behave identically" is checkable. - C: split the MCP canonicalizer into a pure mirror + footnote-authoring.ts. - B: import services persist via a different path, so left one-line consolidation comments at the call sites rather than folding (does not fall out cleanly). Tests: insertFootnote wrapper guards + docmost_transform dryRun auto-canonicalize (MCP mock), page.service create/update + append/prepend binding (server jest), shared corpus incl. nested-container reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c9d252cf2a |
fix(review): address PR #230 review — payload type, breadcrumb helper, tests
Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192): - export/utils: correct the misleading getInternalLinkPageName comment — a bare `v1.2` loses its last dot-segment (`v1`); dots survive only in multi-segment names like `v1.2.md` -> `v1.2`. - share: extract toPublicSharePayload(page, share): PublicSharePayload, an explicit allowlist type+mapper replacing the inline literal in the /shares/page-info anonymous path (#218). Add share.controller.spec.ts that stubs getSharedPage returning internal fields and asserts the response key set EXACTLY equals the whitelist (page + share), so any `...shareData` regression or new leaking field fails. Also key-tests the extracted mapper. - breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId) (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode, dropping the as-any casts; else null) and unit-test all three branches. - share-modal: RTL test asserting enabling a share calls mutateAsync with includeSubPages: false (#216 security default). - share.service: one-line note at getSharedPage on the deferred consolidation of the ancestor-aware match into resolveReadableSharePage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fa929c9e86 |
fix(footnotes): canonicalize footnotes on server import + markdown paste (#228)
The footnote canonicalizer was wired into the MCP and editor-ext write paths but NOT into the server's user-facing markdown/HTML import paths, so importing or pasting markdown with out-of-order, reused, or orphan footnotes did not canonicalize -- the exact trigger bug #228 fixes was still reproduced on import. markdownToHtml -> htmlToJson builds ProseMirror JSON directly and never runs the editor's footnoteSyncPlugin, and that plugin does not reorder an existing list, so the stored footnotes kept the source's physical definition order, retained orphans, and did not collapse reused references. Wire canonicalizeFootnotes (already exported from @docmost/editor-ext) into every server markdown/HTML -> page-JSON seam, before persisting: - ImportService.importPage (REST single-file .md/.html import) - FileImportTaskService (zip import worker) - PageService.parseProsemirrorContent (API createPage / updatePageContent) Also hook the client markdown paste: handlePaste applies a manual transaction (returns true), bypassing transformPasted/footnoteSyncPlugin, so a pasted out-of-order markdown footnote block would persist out of order. canonicalizePastedFootnotes reorders a self-contained pasted block (one that carries its own footnotesList) to reference order, deduped and orphan-free; it is deliberately scoped to whole-block pastes so a reference-only paste that reuses a footnote already defined in the target doc is left untouched. canonicalizeFootnotes is pure, idempotent and shape-safe (a doc with no footnotes is unchanged), so it is safe on every write path. Residual: when a pasted block merges into a doc that already has footnotes, ordering relative to the pre-existing footnotes is still governed by the live sync plugin (which does not reorder across the boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2d36641f28 |
test(coverage): add regression tests for issues #192, #206, #204
Additive test coverage across server, editor-ext, client and mcp. #192 — AiChatService.stream integration (Section 3, against real Postgres): - new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real streamText through a seeded ai/test MockLanguageModelV3 and a real Node ServerResponse, covering: onError persists an assistant error record (status 'error' + partial answer + provider cause in metadata); external MCP client closed exactly once on BOTH onFinish and onError; anti-tamper — history is rebuilt from the DB transcript, not from body.messages. #206 — red-team findings (most already fixed+tested in #212): - mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that pageBreak / transclusionReference / mention are silently dropped on Markdown export (characterization + it.fails for the desired survive-export contract). - persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing documenting that a momentarily-empty live doc overwrites non-empty content (left unfixed — a store-side empty-guard is a behaviour change). #204 — test-strategy plan, highest-priority subset: - Phase 1: mcp-clients.lease.spec.ts covers the external MCP client lease/refcount/eviction lifecycle (leak / premature-close / double-close). - Phase 2 data-integrity pure functions: editor-ext table-utils (transpose/moveRow/convert round-trip) and math tokenizer false-positive guard; client emoji-menu (+ it.fails for the unguarded localStorage JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/ pageBreak markdown data-loss + footnote-diff; server export getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cac84dec9b |
refactor(ai-roles): make catalog URL a per-branch image default, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no longer supports a local filesystem directory. The provider fetches only from an http(s):// base URL read at runtime from AI_AGENT_ROLES_CATALOG_URL; an empty or non-http value yields a 502 (catalog unavailable). The image ships a per-branch default for that URL (set in CI), still overridable at runtime via the env var. - provider: drop readLocal + node:fs/node:path; readRelative requires http(s) and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged. - environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment reflects the per-branch build-time default that is runtime-overridable. - Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage as the image default. - CI: develop.yml builds with the develop raw URL; release.yml defines the main raw URL once in workflow env and references it from both build steps. - tests: replace local-fixture tests with remote-mock happy/malformed bundle tests and a non-http => 502 case; path-traversal block uses an https source. - docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2a4ef9267e |
refactor(ai-roles): bake catalog URL at image build, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no longer supports a local filesystem directory. The provider now fetches only from an http(s):// base URL read from AI_AGENT_ROLES_CATALOG_URL; an empty or non-http value yields a 502 (catalog unavailable). The default URL is baked into the Docker image at build time and set per branch in CI. - provider: drop readLocal + node:fs/node:path; readRelative requires http(s) and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged. - environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment updated to reflect build-time injection, remote-only. - Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage. - CI: develop.yml builds with the develop raw URL; release.yml (both build steps) with the main raw URL. - tests: replace local-fixture tests with remote-mock happy/malformed bundle tests and a non-http => 502 case; path-traversal block uses an https source. - docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
19f84ca0e7 |
feat(ai-roles): add importable, multilingual agent roles catalog
Admins can browse a curated catalog of agent roles, import roles/bundles
into a workspace, and update an imported role when the catalog ships a
newer version.
Catalog: a set of JSON files (index.json manifest + bundles/<id>/<lang>.json)
served from a local folder (dev) or a remote http(s) base URL via
AI_AGENT_ROLES_CATALOG_URL. Seeded with the existing 7 RU roles (editorial +
research bundles) plus EN translations.
Server:
- migration: nullable jsonb `source` column on ai_agent_roles
({ slug, language, version }; null => manually created)
- catalog provider: remote fetch with timeout + streaming size cap, or local
read; ^[a-z0-9-]+$ segment guard against path-traversal/SSRF
- admin endpoints: catalog, catalog/bundle, import, update-from-catalog
- import/update match by slug+language; update preserves `enabled`
Client:
- catalog modal with language selector and Import/Installed/Update states
- "Import from catalog" button + empty-state CTA in the roles settings panel
- en-US/ru-RU strings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
ba5cd02439 |
Address PR #197 review: test coverage + dedup + CI log capture
Code-review follow-ups (Approve-with-comments) for batch #197 (context badge #189 / e2e in CI #187 / inline MCP test #170): - server: extract the duplicated chatContextWindow ::text->positive-int coercion (resolve() + getMasked()) into an exported parsePositiveInt helper and unit-test its branches (200000/1.9/0/-5/""/abc/undefined), closing the untested read-path gap. - client: merge the two backward scans over messageRows into one pure, exported selectContextBadge helper (numerator and denominator still taken from the most recent row carrying EACH value) and unit-test the different-rows and fresh-zero-doesn't-shadow cases. - client: extract the MCP "Test" button tristate presentation into a pure mcpTestButtonView helper (collapses the two parallel if/else chains) and unit-test idle/ok-with-tools/ok-no-tools/failed label+tooltip branches. - ci: redirect the backgrounded prod server's stdout/stderr to a log file in e2e-mcp and cat it on failure, so a start-up crash is diagnosable instead of surfacing only as the generic health timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9b61024b95 |
feat(ai-chat): header badge shows current/max context, max from AI settings (#189)
The floating chat window's header badge flipped meaning — a live per-turn token counter while streaming, the persisted context size at rest — so it "reset to 1" on each prompt and conflated two different numbers. Replace it with a stable "current / max" context badge (e.g. `572 / 200k`). The live "Thinking · N tokens" inside the chat body stays; only the duplicate live counter is removed from the header. Max comes from a new admin setting "Context window (tokens)". The server resolves it and attaches `maxContextTokens` to the completed assistant turn's metadata (next to contextTokens), so the badge needs no client-side model resolution and this survives public shares / per-role models. Server: - ai.types: chatContextWindow on AiProviderSettings + PROVIDER_SETTINGS_KEYS + ResolvedAiConfig + MaskedAiSettings. - workspace.repo: chatContextWindow in AI_PROVIDER_SETTINGS_ALLOWED (parity). - update-ai-settings.dto: @IsInt @Min(0) chatContextWindow. - ai-settings.service: coerce the ::text-stored value to a positive int in resolve()/getMasked(). - ai-chat.service: flushAssistant writes metadata.maxContextTokens (>0); the completed turn passes resolved.chatContextWindow. Client: - ai-chat.types: maxContextTokens on the message-row metadata. - ai-chat-window: read maxContextTokens; render "current [/ max]"; drop the liveTurnTokens state/branch and the onLiveTurnTokens prop; new tooltip. - chat-thread: remove the live-turn-token throttle effect and plumbing. - count-stream-tokens: drop the now-dead liveTurnTokens()/types; keep estimateTokens. - settings: chatContextWindow on IAiSettings(+Update) + a NumberInput in the AI provider settings form. i18n: add the badge/settings keys (en, ru); remove the two now-unused keys. Tests: flushAssistant maxContextTokens, DTO validation, trim token tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
27c91e4a69 |
feat(ai-chat): bound external MCP tool calls with per-call timeouts
External MCP tools (web search, crawl) had no per-call timeout: a hung tool call was only broken by the 15-min transport silence timeout shared with the chat provider, and a server that kept the socket warm but never returned could spin until the user cancelled. Add two independent, composing bounds for external MCP traffic (the chat provider path is unchanged): - Silence 5 min: buildPinnedDispatcher now overrides headersTimeout/ bodyTimeout with mcpStreamTimeoutMs() (AI_MCP_STREAM_TIMEOUT_MS, default 300000) on the external-MCP dispatcher only, so a byte-silent upstream is severed in ~5 min instead of 15. - Total per-call 15 min: wrapToolWithCallTimeout wraps each external tool's execute with a fresh AbortController + timer composed with the turn signal via AbortSignal.any (AI_MCP_CALL_TIMEOUT_MS, default 900000). It RACES the call against the abort signal because @ai-sdk/mcp does not settle its in-flight promise on abort, so a warm-but-stuck call would otherwise hang forever. On timeout the call surfaces as a tool-error and the agent loop recovers. Add tests (incl. a never-settling real-client-style stub) and document both env vars in .env.example. |
||
|
|
c065e26d14 |
refactor(ai): retry outside instrumentation + retry-exhaustion test (#179 review)
- Invert the transport layers so the pre-response retry is OUTERMOST and the
provider-HTTP instrumentation is INNER. Before, the retry lived inside
createStreamingFetch (under the instrumentation), so a reset the retry
recovered from logged only a clean "OK status=200" — the
"PRE-RESPONSE FAILED ... ECONNRESET ... idleSincePrevCall" signal went blind
exactly when the fix works, and AI_STREAM_KEEPALIVE_MS couldn't be tuned from
prod data. Now createStreamingFetch is the dispatcher-bound BASE (no retry) and
a new withPreResponseRetry() wraps it; ai.service composes
withPreResponseRetry(createInstrumentedFetch('AiService:provider-http',
createStreamingFetch())), so every attempt — including recovered resets — flows
through the instrumentation. (Also expresses the keepAlive-config vs retry-
behavior boundary structurally, per review #3.)
- Add the retry-exhaustion test: a server that resets EVERY connection, asserting
the call rejects with a retryable connection error AND exactly
PRE_RESPONSE_CONNECT_RETRIES + 1 (= 3) requests reached the server — pinning the
bound and that the final error propagates (guards an off-by-one / infinite loop
/ swallowed error). Existing happy-retry + abort tests moved onto
withPreResponseRetry.
Verified on the stand: a normal turn still streams (reasoning + finish) and the
provider-HTTP telemetry still logs. server tsc + ai/mcp specs green (30).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b0faa2fe32 |
fix(ai-chat): recycle keep-alive sockets + retry pre-response resets (#175)
The real cause of the long-task "Lost connection to the AI provider" — the earlier 300s-timeout fix (#176) was the wrong layer. The provider-HTTP telemetry on the user's deploy shows the failures are PRE-RESPONSE `read ECONNRESET` ~500ms in (not a 300s/15min timeout), correlated with idleSincePrevCall ~42s and large bodies; and crucially a retry of the SAME request often succeeds. A direct probe to the real z.ai endpoint does NOT reset (113KB bodies and a 45s-idle keep-alive reuse both succeed), and another agent (opencode) runs fine from the same infra — so the provider is healthy and the egress network is usable. The difference is the transport: undici's keep-alive pool REUSES a socket that the deployment's egress (NAT / firewall / conntrack) silently dropped during a long idle gap, so the next request resets pre-response. Fix (brings gitmost in line with clients that don't reuse stale sockets): - Keep-alive recycling: the streaming dispatcher (chat fetch AND the external-MCP dispatcher, via the shared streamingDispatcherOptions) now sets keepAliveTimeout + keepAliveMaxTimeout to a 10s recycle window (AI_STREAM_KEEPALIVE_MS), so a connection idle longer than that is closed instead of reused — a long-gap step opens a fresh connection. keepAliveMaxTimeout also caps a server-advertised keep-alive so the provider can't widen the window. - Pre-response connection retry: createStreamingFetch retries a connection-level reset (ECONNRESET / UND_ERR_SOCKET / ECONNREFUSED / EPIPE / *_TIMEOUT) on a fresh connection up to 2 times. This is SAFE because fetch() only rejects before the Response resolves — a started stream is never replayed; an abort (client disconnect) is never retried. Tests: ai-streaming-fetch.spec — keep-alive options, streamKeepAliveMs env, isRetryableConnectError, and a server that resets the first connection so the retry must land on a fresh one (+ aborted requests are not retried). Verified on the stand that a normal turn still streams (reasoning + text + finish) through the new transport. server tsc + ai/mcp specs green. Note: root cause is the deployment's egress dropping idle connections (Traefik is inbound-only); this makes the app resilient to it. AI_STREAM_KEEPALIVE_MS can be lowered if the egress drops faster than ~10s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6edbbab43b |
refactor(ai): unify provider-settings allowlist + stronger chatApiStyle tests (#177 review)
Addresses the second #177 review: - Architecture (the silent allowlist drift): the writable provider-setting keys were maintained by hand in two TS-uncheckable places — the key-loop in ai-settings.service and the SQL ALLOWED list in the generic workspace repo (a miss there silently dropped a field on persist, exactly what bit chatApiStyle). Introduce one typed source of truth PROVIDER_SETTINGS_KEYS in ai.types (`satisfies readonly (keyof AiProviderSettings)[]`), have the service consume it, and keep the repo's own copy (it can't import AI types) guarded by a parity test so any future drift fails in CI. - Tests: - ai.service.include-usage.spec: mocks @ai-sdk/openai-compatible and asserts the factory is called with { includeUsage: true, baseURL, apiKey, fetch, name } — `.provider` alone could not catch a dropped includeUsage (the token-usage zeroing regression); also asserts the 'openai' style does NOT use it. - ai-provider-settings-keys.spec: the allowlist parity check + DTO validation for chatApiStyle (@IsIn accepts both values, rejects garbage, optional). - CHANGELOG: [Unreleased] entries for the new "Protocol" / chatApiStyle setting and the default provider change (openai -> openai-compatible). (#175, #177) server + client tsc clean; 42 ai/settings specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
59190148db |
feat(ai-chat): explicit chatApiStyle selector to surface reasoning (#175)
Rebuilt on develop (after #176) and reworked per review: instead of inferring the provider from baseUrl (`if (baseUrl)`), the admin picks the chat provider EXPLICITLY via a new `chatApiStyle` ('openai-compatible' | 'openai'), mirroring the existing sttApiStyle. A custom baseURL can front real OpenAI too, so the heuristic was fragile. Why reasoning was missing: glm-5.2 (and DeepSeek etc.) stream their thinking as `reasoning_content`, but the official @ai-sdk/openai provider does not map that field. 'openai-compatible' uses @ai-sdk/openai-compatible, which does — so reasoning parts now stream (verified live: reasoning-start/delta/end appear, and disappear when set to 'openai'). - Default (unset) = 'openai-compatible', so existing openai+baseUrl workspaces surface reasoning with no admin action. No DB migration (field lives in the settings.ai.provider JSON blob). - includeUsage: true on the openai-compatible model — without it the provider omits streamed usage, zeroing the live token counter / reasoning-token metadata. The official provider always sent it; this keeps parity. (Confirmed live: usage.totalTokens present.) - openai-compatible has no default endpoint, so with no baseURL (real OpenAI, or a role's cross-driver override that cleared it) it falls back to the official provider. Plumbing: ai.types (ChatApiStyle / CHAT_API_STYLES + AiProviderSettings / MaskedAiSettings), update DTO (@IsIn), ai-settings.service (resolve / getMasked / update allowlist), workspace.repo updateAiProviderSettings ALLOWED (the second, SQL-level allowlist the review missed — without it the field never persisted), ai.service selector. Client: ai-settings-service types + a Protocol <Select> in the chat section + i18n (en/ru). Scope is chat-only (embeddings don't stream reasoning; STT already has sttApiStyle). Tests: ai.service.spec — 4 cases (openai-compatible+baseURL, openai+baseURL, default-unset, openai-compatible-without-baseURL fallback). Verified on the stand: default streams reasoning + usage; 'openai' drops reasoning; the setting round-trips. server + client tsc clean; 36 ai/settings specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
da15b55786 |
refactor(ai): address PR #176 review — finite-timeout wording, env doc, tests, permanent provider-http module
- Wording: every comment now says the stream timeouts are RAISED to a
generous-but-finite ~15-min silence timeout, not "disabled (0)" (the stale
comments contradicted the code, which uses AI_STREAM_TIMEOUT_MS, default
900000ms).
- Architecture (the load-bearing-temporary trap): the streaming fetch reached
the chat provider only by riding the "temporary DIAGNOSTIC" telemetry, so
deleting the telemetry by its own label would silently revert the timeout fix.
Legitimize it: rename ai-http-diagnostics.ts -> ai-provider-http.ts,
createDiagnosticFetch -> createInstrumentedFetch, field aiDiagnosticFetch ->
aiProviderFetch, drop the "temporary" labels, and document the chat transport
(streaming fetch + instrumentation) as one intentional construct.
- Docs: AI_STREAM_TIMEOUT_MS added to .env.example next to AI_EMBEDDING_TIMEOUT_MS.
- Tests:
- ai-provider-http.spec: createInstrumentedFetch delegates to the injected
baseFetch with the same input/init, returns the Response untouched, rethrows
the error, and defaults to global fetch — covering the baseFetch seam.
- ai-streaming-fetch.spec: the delayed-server test is now LOAD-BEARING — with
AI_STREAM_TIMEOUT_MS set below the 1.5s server delay the call actually rejects
(a lost dispatcher -> global 300s default would NOT), proving the configured
dispatcher is wired; plus the default-timeout happy path.
server tsc clean; ai-streaming-fetch / ai-provider-http / ai.service / mcp-servers
/ ai-error specs green (41).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a14560c7c9 |
fix(ai-chat): raise undici's 300s stream timeout for long agent turns (#175)
Long research turns failed mid-task with "Lost connection to the AI provider".
Node's global fetch (undici) defaults BOTH headersTimeout and bodyTimeout to
300_000ms, and the chat provider + the external-MCP dispatcher both ran on it
with no override, so:
- the z.ai chat stream dropped when a late step's huge accumulated context
pushed the model's time-to-first-token past 5 min (the model reasons
server-side with NO streamed reasoning, so the connection is silent until the
first answer token — reproduced: even a trivial glm-5.2 query has a ~4-8s
first-chunk gap; a long run reaches 400k+-token steps), or a reasoning model
paused >5 min between chunks (bodyTimeout);
- the crawl4ai SSE transport, held open across the whole turn, dropped when it
idled >5 min between tool calls.
Fix: a dedicated undici dispatcher whose stream timeouts are raised to a
generous-but-FINITE silence timeout (default 15 min, AI_STREAM_TIMEOUT_MS) on
each path. NOT disabled (0): that would let a genuinely hung provider — with the
client still connected — hang forever, since the turn's abortSignal only fires on
client disconnect. The timeout bounds SILENCE (time-to-first-byte and the gap
BETWEEN chunks), NOT total turn duration, so an arbitrarily long turn that keeps
streaming is never cut; only a stream quiet for >15 min is treated as a hang.
- ai-streaming-fetch.ts: createStreamingFetch() + streamTimeoutMs() /
streamingDispatcherOptions() (the shared, configurable timeout).
- ai.service: the chat provider fetch is createStreamingFetch(), wrapped by the
existing passive ECONNRESET telemetry (createDiagnosticFetch gained an
optional baseFetch) so the telemetry observes the SAME transport.
- mcp-clients: the SSRF-pinned Agent uses streamingDispatcherOptions().
Investigation: reproduced the transport mechanism against the real z.ai endpoint
(a 1ms headersTimeout throws UND_ERR_HEADERS_TIMEOUT — the exact drop) and ran
the actual research agent to a ~428k-token context. Verified the fixed path
streams cleanly live (glm-5.2 turns finish; telemetry confirms the streaming
fetch is in use).
Tests: ai-streaming-fetch.spec (default 15m + env override + invalid fallback +
both-timeouts + streams a delayed response); ai-http-diagnostics + ai/mcp specs
green. server tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4cc8df836f |
chore(ai): passive z.ai provider HTTP telemetry (#175)
Investigate the intermittent (~20-30%) long-turn failure "Lost connection to the AI provider" = AI_RetryError / read ECONNRESET on the gitmost->z.ai link (browser-agnostic, mid-turn). Pure instrumentation, no behavior change: - ai-http-diagnostics.ts: a passive fetch wrapper injected into the OpenAI-compatible (z.ai) client. Per provider HTTP call it logs time-to-headers/status on success, and on a pre-response rejection the latency, error code/cause, request-body size and idle-gap since the previous call. The Response is returned untouched (streaming intact), errors rethrown unchanged; no retry/timeout/dispatcher. - ai.service.ts: wire the instrumented fetch into the openai case only. Lets us classify the reset as connection-phase vs mid-stream before choosing a fix, without repeating the reverted RetryAgent (#140). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5161de8ba9 |
revert(ai-http): drop resilient fetch/RetryAgent layer (#140)
The custom undici RetryAgent + aiFetch transport added for issue #140 did not actually heal mid-stream provider drops: undici's retry path is a Range-based download-resume that SSE/chat-completions endpoints cannot satisfy, so a reset after the first byte only swapped ECONNRESET for a "server does not support the range header" error. Its only real effect was reconnecting a poisoned keep-alive socket before the first byte, and PR #141 on top of it turned the 60s headers timeout into deterministic ~61s failures (plus CONTENT_LENGTH_MISMATCH from retrying a POST body after a timeout abort). The root cause is the z.ai coding endpoint, not our transport. Remove the whole layer and return all AI provider calls to Node's default global fetch. - delete integrations/ai/ai-http.ts and its spec - ai.service.ts: drop the aiFetch import, the AI_BYPASS_RESILIENT_FETCH diagnostic toggle, and fetch:aiFetch from every chat/embedding/STT factory; raw STT call back to global fetch - ai-chat.controller.ts: drop the stream-timing START log + startedAt - ai-chat.service.ts: drop the first-chunk/FINISHED/ERROR timing logs - .env.example: drop AI_BYPASS_RESILIENT_FETCH Reverts: |
||
|
|
d6cd275469 |
test(ai-http): cover header-stall fail-fast + retry (#140)
Extend ai-http.spec with two loopback-server tests: a provider that stalls without sending headers triggers the (lowered) headersTimeout and is retried on a fresh connection, recovering; a healthy fast response passes through in one attempt. No external network calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
35fc58eaaa |
fix(ai-http): fail fast + retry on provider header stall (#140)
The z.ai GLM coding endpoint intermittently accepts the chat request but never sends response headers; undici's default 300s headersTimeout then hung the user for five minutes before failing, and UND_ERR_HEADERS_TIMEOUT was not in the RetryAgent's retried error set, so there was no recovery. headersTimeout only bounds time-to-FIRST-headers (before any body) — it is NOT the streaming budget, so lowering it does not truncate live SSE streams. Cap it (env AI_HTTP_HEADERS_TIMEOUT_MS, default 60s) so a header stall fails fast, and add UND_ERR_HEADERS_TIMEOUT to the retried error codes so the stalled request is retried on a fresh connection (which usually responds in seconds). bodyTimeout kept generous (env AI_HTTP_BODY_TIMEOUT_MS, default 300s) so slow streams with sparse chunks survive. UND_ERR_BODY_TIMEOUT is deliberately NOT retried (mid-body, partial SSE already delivered). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b7abb7ea01 |
feat(ai-http): log detailed fetch error cause chain
Node's fetch returns a generic "fetch failed" error, hiding the actual reason (e.g., ECONNRESET, timeout) in the error's cause chain. This change extracts up to three levels of the cause, formats each with its code and message, and includes the chain in the warning log, making failures more actionable. |