Compare commits

..

50 Commits

Author SHA1 Message Date
vvzvlad
fcf1fdec89 Merge pull request #1 from vvzvlad/develop
Release 0.94.0
2026-06-26 18:23:28 +03:00
claude_code
a7f8ee04b3 docs(changelog): 0.94.0 release notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:24 +03:00
claude_code
378d8b676b 0.94.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:24 +03:00
580f7bd5bb Merge pull request 'Батч: бейдж контекста (#189) + e2e в CI (#187) + inline-тест MCP (#170)' (#197) from batch/issues-189-187-170 into develop
Reviewed-on: #197
2026-06-26 18:09:47 +03:00
claude_code
b538c729c3 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 18:09:00 +03:00
e3b23e0d26 Merge pull request 'fix: bug batch — #161 #190 #207 #159 + #206 findings' (#212) from fix/mainline-bugs-2026-06-26 into develop
Reviewed-on: #212
2026-06-26 17:43:55 +03:00
claude code agent 227
b392219659 fix(ai-mcp): show Failed when the inline Test request itself rejects (#170)
The per-row MCP Test button derived its presentation solely from the test
mutation's data ({ ok, tools } | { ok, error }). When the request itself
rejected (401/403/500/network) there is no payload, so the row silently spun
back to the idle "Test" instead of reporting the failure.

Feed the mutation error into mcpTestButtonView so a reject also renders a red
"Failed", with the tooltip taken from the server message
(error.response.data.message) or a generic i18n fallback. Enable the tooltip
for any non-idle state. Cover the reject branch (with and without a server
message) in the helper unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:37:08 +03:00
claude code agent 227
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>
2026-06-26 17:24:29 +03:00
claude code agent 227
df50f23d58 merge: fold #182 (AI-chat stream throttle + render memoization) into #212 2026-06-26 17:07:54 +03:00
claude code agent 227
eb5c8e6611 refactor(ai-chat): simplify share onFinish token extraction and cover the fallback (#159)
onFinish always receives a totalUsage object, so the `?? {}` guard and
optional chaining were dead. Extract the field-level extraction into a
recordTurnUsage method (totalTokens, else input+output) and unit-test that
recordShareTokens receives the summed value when totalTokens is absent and the
authoritative total when present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
d32ad73158 test(editor-ext): cover the transclusionSource NO_REASSIGN branch in id dedupe (#206)
A colliding transclusionSource id is deliberately NOT reassigned (its id is a
cross-reference key), while a missing id is still filled. Add coverage for both:
two sources sharing an id keep it (red if the NO_REASSIGN guard is removed), and
a source with no id gets a fresh one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
acf2241e23 docs: document SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY and changelog the bug-fix batch (#161 #190 #207 #206 #159)
Document the new per-workspace rolling-day token-budget env var in
.env.example alongside the existing share-assistant cost knobs, and add
[Unreleased] Fixed entries for #161/#190/#207/#206 plus a Security entry
for the #159 token budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
cb61274187 test(ai-chat): simplify msg factory and lock signature↔render coupling
Address non-blocking review items on the AI-chat stream-perf PR:

- Drop the unused `metadata` param from the `msg` test factory in
  message-item.test.ts; no caller passed it.
- Add a per-part-kind coupling guard to message-signature.test.ts that, for
  each part kind rendered today (text, reasoning, tool-*) plus the metadata
  banners, asserts that mutating a field the MessageItem render body DRAWS
  flips messageSignature — an executable lock for the load-bearing memo
  invariant documented in message-signature.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:57:31 +03:00
claude code agent 227
1d610b3a62 fix(ai-chat): add per-workspace rolling-day token budget for anonymous share assistant (#159)
The anonymous public-share assistant only capped the COUNT of requests
(100/hour/workspace), not their cost. One accepted turn runs the agent loop
up to stepCountIs(5), re-sending the whole client-held transcript as input on
every step, while maxOutputTokens caps only the output; the request window is
hourly with no daily ceiling, so a steady stream at the cap sustains ~24x its
count per day. Counting requests therefore does not bound the owner's LLM bill
(red-team finding #5).

Add a second cost contour: a cluster-wide, sliding-window per-workspace TOKEN
budget over a rolling day. It is checked read-only BEFORE a turn streams (429,
no request slot consumed, nothing spent) and the turn's real usage
(totalUsage: input re-sent per step + output, summed across all steps) is
recorded once it finishes via streamText onFinish. Fails closed on the check
(deny when Redis can't prove we're under budget); best-effort on the record.
Env-overridable via SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY (default 1M/day).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:23:48 +03:00
claude code agent 227
6bb9dfdc86 fix(editor-ext): dedupe colliding unique ids on import/normalize (#206)
editor-pm-7: addUniqueIdsToDoc only FILLED missing ids and never deduplicated
existing ones, so a copy/paste or bulk-JSON duplicate that kept its attrs.id
produced two nodes sharing an id. MCP addressed edits (patch_node /
delete_node "before/after id") then hit the wrong node or both.

Walk the configured-type nodes in document order: the first occurrence of an
id keeps it (stable anchor), later duplicates are reassigned a fresh id.
transclusionSource ids are cross-reference keys (references resolve a source by
this id), so they are only filled-when-missing, never reassigned, to avoid
orphaning their references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:09:02 +03:00
claude code agent 227
770ba70541 fix(collab): retry transient store failures so autosave edits aren't lost (#206)
persist-1: onStoreDocument wrapped the page write in a try/catch that only
logged and swallowed the error, then resolved "successfully". hocuspocus
destroys/unloads the in-memory Y.Doc right after the hook resolves (the only
copy of the latest edit), so a transient DB error (deadlock, serialization
failure, dropped connection) silently lost the edit. Worse, the post-store
branch ran on the partially-assigned `page`, broadcasting a phantom
"page.updated" and enqueueing a history snapshot for content never written.

Wrap the write in a small bounded retry (3 attempts) so the save is
re-attempted while we still hold the doc, and clear `page` on failure so the
success-only side effects never report a save that didn't happen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:05:28 +03:00
claude code agent 227
3d47c306fa fix(tree): cycle-guard placeByPosition so out-of-order moves don't drop subtrees (#206)
ui-state-races-1: the server-authoritative move path (placeByPosition, via
applyMoveTreeNode) lacked the isDescendant cycle guard that drag-drop `move`
has. When move events arrive out of order so the destination parent is still
nested inside the moved node's own subtree, remove(source) dropped the whole
subtree (incl. the future parent) and insertByPosition could not re-place it —
the node and all descendants silently vanished with no error/refetch.

Add the isDescendant guard to placeByPosition (returns same ref, like its other
no-op cases) and short-circuit applyMoveTreeNode on the same condition BEFORE
the placed===prev remove-fallback (which would otherwise still drop the
subtree). Leave the tree untouched so a later corrective event / reconnect
reconcile fixes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:00:26 +03:00
claude code agent 227
c919d4f636 fix(page): copy shared attachments for every referencing page on duplicate (#206)
attach-1: when the same attachmentId was referenced by more than one page
in a duplicated subtree, the per-attachmentId map held only a single copy
entry, so the last page processed clobbered the others. The downstream
ownership guard (`attachment.pageId !== oldPageId`) then matched at most one
page and skipped the lone DB row entirely: no blob copied, no new row, every
copy's image 404'd. Key the map to a list of entries and copy one blob/row
per referencing page; drop the now-incorrect ownership guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:57:46 +03:00
claude code agent 227
c4807022f2 fix(page): add cycle/depth guard to recursive tree-traversal CTEs (#207)
getPageBreadCrumbs (ancestor CTE) and forceDelete (descendant CTE) used
withRecursive + unionAll with no CYCLE clause or depth cap. If a parent/child
cycle already exists in the data (e.g. one slipped in via the #7 TOCTOU race),
both queries loop forever — hang / statement timeout. Worse, the move guard
itself runs the ancestor CTE, so a cycle would disable the very guard meant to
prevent it (#207 #8).

Add a depth counter bounded by MAX_PAGE_TREE_DEPTH to both recursive CTEs; the
walk stops at the cap, so a cycle yields a bounded result instead of hanging.
Real page trees are only a few levels deep, so the cap never truncates a
legitimate result. getPageBreadCrumbs selects an explicit column list (not
selectAll) so the internal depth counter never leaks into the breadcrumb shape.

Adds an integration test that seeds an A<->B cycle directly and asserts both
getPageBreadCrumbs and forceDelete return bounded / complete under a short
connection-level statement_timeout instead of hanging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:48:01 +03:00
claude code agent 227
00ca4ff3d6 fix(page): make movePage cycle-check + update atomic to prevent concurrent A<->B cycles (#207)
The server-side move cycle guard (getPageBreadCrumbs) and the move UPDATE ran
as two separate, unlocked statements. Two concurrent moves ("A under B" and
"B under A") could each read the same pre-write acyclic snapshot, both pass the
guard, then persist A.parentPageId=B AND B.parentPageId=A — a parent/child
cycle (TOCTOU, #207 #7).

Run the cycle check and the UPDATE inside one transaction (executeTx) guarded
by a per-space advisory lock (pg_advisory_xact_lock, held until COMMIT) so all
moves within a space serialize: the second mover blocks until the first commits
and then sees the freshly written parent, so its guard rejects the cycle.
getPageBreadCrumbs gains an optional trx so the check runs on the locked snapshot.

Adds an integration test driving two opposing concurrent movePage calls and
asserting no cycle ever persists and exactly one move is rejected. Updates the
movePage unit-test stubs for the new transactional path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:43:57 +03:00
claude code agent 227
ef7d04d1e7 fix(ai-chat): clearer tool-call validation error for dropped pageId in parallel batches (#190)
In-app AI-chat tools used bare zod schemas, so when the model dropped a
required arg (typically pageId) in a parallel/batch tool call, the AI SDK
forwarded zod's raw "expected string, received undefined" text to the model
— not actionable. Add a centralized modelFriendlyInput(shape) wrapper that
keeps the exact JSON Schema contract (required/description/constraints via
z.toJSONSchema draft-7) but replaces the raw zod text with a message naming
each missing/invalid parameter and reminding the model not to drop ids like
pageId in parallel batches. No value is guessed/backfilled (cf. #159).

Applied to every in-app tool: the sharedTool() builder and all inline
inputSchema in ai-chat-tools.service.ts, plus public-share-chat-tools.service.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:30:07 +03:00
claude code agent 227
5b59a70e3f fix(ai-chat): New chat during first-turn stream resets the chat, not just the role badge (#161)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:22:20 +03:00
claude_code
eafd15f0ef docs(ai-chat): document load-bearing invariant of messageSignature memo
PR #182 review (post-fix pass) surfaced two latent correctness risks in the
new MessageItem memo: the per-message signature tracks only [type, text length,
state, error/output presence] + metadata, so a part kind whose VISIBLE content
can change WITHOUT changing those fields would silently freeze a stale row.
Neither is reachable with the current toolset (tool output is set once;
streaming is append-only with a fixed id), so the correct fix is to harden the
documented invariant rather than hash output content on every delta (getPage
returns full page content — hashing it per-delta would tax the hot path this
PR optimizes).

Add a WARNING in messageSignature naming the two future triggers (a tool that
streams `preliminary` output; a client-side regenerate/edit that mutates a
finalized row in place) and the required action (extend the signature).

No behavior change (comment only). vitest src/features/ai-chat 189/189 pass,
tsc clean for the touched files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:44:49 +03:00
claude_code
fbdb8aa16c docs(backlog): delete obsolete backlog documentation files
Removed six outdated markdown files from the `docs/backlog` and other docs directories that were no longer relevant to the project. This cleans up the repository and reduces clutter.
2026-06-25 22:43:26 +03:00
claude_code
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>
2026-06-25 22:39:09 +03:00
claude_code
63c26042ba test(review): address PR #182 review — tests + extract messageSignature, CHANGELOG
Resolve the PR #182 code-review (Request changes) on top of the already-merged
develop (the merge commit preserves both the markdown useMemo and the
collapseBlankLines fix in reasoning-block.tsx).

- Extract messageSignature from message-item.tsx into utils/message-signature.ts
  (matches the feature's "pure UIMessage helper + colocated test" convention) and
  export arePropsEqual so the memo seam is unit-testable. No logic change.
- Add utils/message-signature.test.ts covering every change signal (text grows,
  part appended, state flip, output appears, errorText appears, usage.reasoningTokens
  arriving on finish-step, metadata error/finishReason) plus the negative
  content-identical-clone case.
- Add components/message-item.test.ts for arePropsEqual (each prop diff -> false,
  identity fast-path -> true, same-content-different-object -> true, changed -> false).
- Add components/message-item-memo.test.tsx: render-level proof that finalized text
  parts are not re-parsed when only a tail part grows (MarkdownPart memo).
- CHANGELOG: add the user-facing 100% CPU freeze fix under [Unreleased] / Fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:33:14 +03:00
claude_code
2644fe6a83 feat(ai-chat): inline Test button per external MCP server row (#170)
Add a per-row Test button to the external MCP servers list that shows the
connection result inline (no toasts). Extract the row into AiMcpServerRow so
each row owns its own useTestAiMcpServerMutation instance — independent loading
and result, no cross-row flicker.

States: idle (Test), pending (loading), success (green, "OK · N" with the tool
count), failure (red, "Failed"); a tooltip shows the tool list or the error.
The result resets when url/transport/headers change (the row is keyed by id, so
it does not remount). Backend, service and mutation are unchanged.

- ai-mcp-servers.tsx: AiMcpServerRow + Test button + reset effect + tooltip.
- i18n: add Failed / "OK · {{n}}" (en, ru) and ru Test / tool-list keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
993f884e64 ci(develop): run server + mcp e2e on every develop push without blocking deploy (#187)
Add two independent jobs to develop.yml — e2e-server and e2e-mcp — that run on
each push to develop alongside test/build. `build` stays `needs: test` only, so
a failing e2e never blocks the :develop image build/publish; the red run plus
GitHub's email to the pusher is the notification.

- e2e-server: pgvector + redis services, migrations, apps/server test:e2e.
- e2e-mcp: build editor-ext/server/mcp, migrate, start the prod server
  (REST + /collab in one process), wait for /api/health, seed the admin via
  /api/auth/setup, then run @docmost/mcp test:e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
2f058a6e40 Merge remote-tracking branch 'gitea/develop' into fix/ai-chat-stream-perf
# Conflicts:
#	apps/client/src/features/ai-chat/components/reasoning-block.tsx
2026-06-25 22:21:41 +03:00
claude_code
3ddc329bba Merge pull request 'Батч: ai-chat/footnotes/mcp/db/tree + red-team (#163 #181 #164 #173 #168 #180 + #159 8/10)' (#185) from batch/issues-2026-06-25 into develop 2026-06-25 12:49:15 +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
364838d0b2 test(review): close the two test-coverage gaps from PR #185 auto-review
Approve-with-comments auto-review (8 axes); no blockers. Closes the two flagged
test gaps; the two forward-looking dedup suggestions (reconcileHasChildren helper;
unifying reconcileChildren/mergeRootTrees) are non-blocking architecture notes and
left for a follow-up (as with #186's forward-looking point).

1. Ambiguous-id refusal end-to-end (#159): the patch_node/delete_node guard
   `if (replaced/deleted !== 1) return null` was only covered in pieces — the
   replaceNodeById/deleteNodeById counts and assertUnambiguousMatch in isolation —
   so loosening the guard would not have failed a test. New mock test stands up a
   REAL Hocuspocus collab server seeded (via buildYDoc, same docmost extensions)
   with a two-blocks-one-id document and drives the real client methods: both must
   reject with /ambiguous/ AND never write to collab. Tracked via Hocuspocus
   onChange (fires synchronously per update, unlike the debounced onStoreDocument)
   so a clobbering write is actually observed — verified the test FAILS when the
   guard is loosened to `< 1`.

2. scrollToReference zero-match bail: the branch "non-empty id but querySelectorAll
   returns 0 -> matches[index] ?? matches[0] is undefined -> return false" (the real
   desync: definition present, inline ref removed from the DOM) was uncovered. Added
   a footnote.test.ts case: a definition for 'ghost' with no rendered ref -> false,
   no scroll.

Verified: 313 mcp tests + 24 editor-ext footnote tests; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:18 +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
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
claude code agent 227
8f1af676ba fix(mcp): write page body before title to avoid split-brain on failure (#159)
updatePage (markdown) and updatePageJson wrote the title via REST FIRST, then
the body via collab. If the body write failed (e.g. a collab persist timeout),
the page was left with the NEW title over its OLD body — a split-brain the tool
reported as an error but never repaired (red-team finding #10).

Reorder both: write the body first, and only set the title after the body has
persisted. Now a body-write failure leaves the title untouched (no split-brain).
A title write failing after a successful body is rarer (REST is fast) and leaves
correct content under a stale title — the strictly lesser inconsistency — which
is the same trade-off the issue's "atomic, or roll back the title" intends,
without the fragility of a rollback write that could itself fail.

No unit test: both paths require a live collab provider and the suite has no
provider mock; the change is a pure reordering. All 306 mcp tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
34c5b557ef fix(share): SEO route must not leak a restricted page's title (#159)
`ShareSeoController.getShare` resolved the inherited share with the RAW
`getShareForPage`, which does NOT run the restricted-ancestor gate. So for a
page shared with includeSubPages whose descendant is permission-restricted, the
SEO route served that descendant's real title in <title>/og:title/twitter:title
to anonymous visitors and crawlers — even though the content API returns 404 for
it (red-team finding #3).

Funnel the SEO path through the canonical `resolveReadableSharePage` boundary
(the single place that checks `hasRestrictedAncestor`): a non-readable page now
serves the plain SPA index with no meta. Also honour `isSharingAllowed` — a
share whose workspace/space sharing toggle was flipped off after creation no
longer leaks its title via SEO. Title comes from the server-resolved page;
`buildShareMetaHtml` already emits robots=noindex when the share opted out of
indexing.

Tests (controller routing, fs spied at call time so bcrypt's native loader is
untouched): non-readable page => plain index, no title; sharing-disabled =>
plain index; readable+indexing => title + og:title, no noindex; readable+no-
indexing => noindex. Asserts getShareForPage is never called by the SEO path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
59f0c8b22d fix(ai-chat): validate the open page server-side so the agent edits the right one (#159)
The client sends the "current page" as { id, title } in the request body and the
server echoed BOTH verbatim into the system prompt context and the
getCurrentPage tool. id and title are independently attacker/desync-controllable
(two tabs, stale navigation), so openPage.id could point at page B while
openPage.title said "Page A" — the model then reported "updated Page A" while it
actually edited page B (CASL still allowed it; the user has access). Red-team
finding #4.

Resolve the open page ONCE against the DB via a new `resolveOpenPageContext`:
workspace-scoped lookup + access check, returning the AUTHORITATIVE { id, title }
(title from the DB row, never the client) or null (fail-closed) for a missing /
foreign / inaccessible page. That validated value now feeds the system prompt,
the getCurrentPage tool, AND the new-chat history origin (which previously did
this validation inline, for the id only — now shared, and the title is fixed
too).

Tests: resolveOpenPageContext covers no-id, not-found, foreign-workspace,
Forbidden, non-Forbidden-fault (fail-closed), the DB-title-wins-over-client case,
and null-title coercion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
77ccc596ea feat(ai-chat): per-MCP-server instructions in the agent system prompt (#180)
Admins can now give each EXTERNAL MCP server a free-text instruction ("how/
when to use this server's tools") that the agent receives in its SYSTEM
PROMPT next to the tool descriptions — porting the built-in SERVER_INSTRUCTIONS
idea to admin-configured servers. Trusted, admin-authored text (like a system
prompt); NON-secret, so unlike headersEnc it IS returned in views/forms.

- Migration: nullable `instructions text` on ai_mcp_servers (old rows = null =
  no guidance). Table type + repo insert/update (blank/whitespace -> null via
  blankToNull). DTO `@MaxLength(4000)`. Service threads it through
  McpServerView/toView.
- mcp-clients: `McpServerInstruction { serverName, toolPrefix, instructions }`
  threaded through the toolset/cache/lease. Guidance is built ONLY for a server
  that actually connected AND contributed >=1 callable tool (the allowlist may
  filter all of them out) AND has non-blank text — so a guide never appears for
  tools the agent cannot call. Cached with the toolset, so an edit is picked up
  next turn via the existing CRUD cache invalidation.
- System prompt: `buildMcpToolingBlock` renders an <mcp_tooling> block INSIDE
  the safety sandwich (after context, before the trailing SAFETY_FRAMEWORK) so
  it informs tool choice but cannot override the rules; each section is headed
  by the server's `prefix_*` namespace. Empty/blank -> block omitted. The
  caller (ai-chat.service) now builds the external toolset BEFORE the prompt and
  passes external.instructions; client-handle lifecycle (close-once) unchanged.
- Client: instructions field in types + a Textarea (autosize, maxLength 4000)
  in the MCP-server form with a namespace-prefix hint; i18n (en/ru).

Tests across every layer (prompt block placement + both SAFETY copies; view
blank->null; buildEntry includes guidance only for connected+>=1-tool+non-blank;
DTO MaxLength; repo + integration round-trip; service wiring). Delegated impl
reviewed (APPROVE); applied the import-type follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
e536c6f9a9 ci(test): run the server integration suite against real Postgres/Redis (#159)
The only test command in CI was `pnpm -r test` (unit `.spec.ts` on mocks).
`test:int` (`.int-spec.ts`, real Postgres/Redis) ran nowhere in CI — there
were no DB `services:` — so the cost-cap, FK-cascade, jsonb round-trip and
real AI-apply integration tests never gated a PR, and regressions in those
high-severity paths stayed green (red-team finding #7).

Add `services: postgres (pgvector) + redis` and a `pnpm --filter server
test:int` step. The pgvector image is required because migrations create
vector columns and global-setup runs `CREATE EXTENSION vector`. Service
credentials/db match the defaults in apps/server/test/integration (docmost /
docmost_dev_pw, maintenance db `docmost`, redis 6379), so no TEST_*_URL
overrides are needed; global-setup drops/recreates the isolated docmost_test
DB and migrates it.

NOTE: the workflow change itself can only be validated by an actual CI run
(YAML parses locally); the int-spec suite is verified passing locally on this
branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
fdaf20ca7b fix(mcp): refuse ambiguous patch_node/delete_node on duplicated ids (#159)
Docmost duplicates block ids on copy/paste, and copyPageContent writes the
source document verbatim with the same ids. `patchNode`/`deleteNode` address a
block by `attrs.id` via replaceNodeById/deleteNodeById, which act on EVERY node
sharing the id — so a single patch_node/delete_node could silently
replace/remove multiple unrelated blocks with no signal to the model
(red-team finding #6).

Guard both write paths: when more than one node matches the id, skip the write
entirely (the transform returns null -> no mutation) and throw a clear
"ambiguous id — N nodes share it" error so the model re-targets with a more
specific anchor. Only an unambiguous single match is written; the 0-match and
1-match behavior is unchanged.

The duplicate-count basis is covered by node-ops.test.mjs (replaceNodeById /
deleteNodeById report count===2 for a 2-duplicate doc). The end-to-end guard
is not unit-tested because patchNode/deleteNode require a live collab provider
and the test suite has no provider mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
47a2ae420b feat(footnotes): multi-backlinks — definition returns to ALL its references (#168)
After #166 a repeated `[^a]` is one footnote (reuse): one number, one
definition, N forward links. But the definition's ↩ only returned to the
FIRST reference. Now a definition with N references shows ↩ a b c …, each
backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a
single-reference footnote keeps the plain ↩ unchanged.

- editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached
  alongside the number map in the numbering plugin state; `getFootnoteRefCount`
  getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks
  the index-th `sup[data-footnote-ref][data-id]` occurrence (document order),
  falling back to the first.
- client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …)
  per occurrence when refCount > 1; the chrome stays after the contentDOM so
  the #146 caret invariant holds. i18n keys (ru) added.

Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown
id => 0); structure test gains 3 cases (N lettered links render, click jumps
to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the
backlink row needs a real browser to verify (jsdom can't); the structural and
behavioral contract is covered headless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
1cfad1f6fb fix(db): jsonb double-encoding follow-ups from PR #172 review (#173)
PR #172 fixed the jsonb double-encoding for `tool_allowlist` but the same
class of bug, and the same re-derived workaround, remained elsewhere.

1. model_config (agent roles): jsonbObject still used the buggy `::jsonb`
   bind, so `ai_agent_roles.model_config` round-tripped as a jsonb STRING
   SCALAR. The read-path `typeof === 'object'` check then failed and the
   model override was SILENTLY dropped (role fell back to the default model).
   Fixed to `::text::jsonb` and added `parseModelConfig` + `normalizeRow` so
   every read self-heals already-corrupted rows (no migration).

2. Centralized the write workaround as `jsonbBind()` in database/utils.ts —
   one implementation with one explanation of the quirk — replacing the
   per-repo `jsonbArray` (mcp) and `jsonbObject` (roles).

3. Integration coverage (the fix is a DB round-trip a unit test cannot see;
   the read-side parser MASKS a write regression): new
   ai-mcp-server-repo.int-spec asserts `jsonb_typeof(tool_allowlist)='array'`
   after insert + heals a seeded string-scalar row; ai-agent-roles-repo
   int-spec gains the same for `model_config` (`'object'` + heal).

4. Updated the stale `ai-mcp-servers.types.ts` comment (the driver returns a
   JSON string for legacy rows; the repo normalizes every read).

5. Fail-open logging: a corrupt tool_allowlist degrades to "no restriction"
   (agent gets ALL tools) — normalizeRow now warns (server id only, never
   contents) so the silent widening leaves a trace.

6. Simplified parseToolAllowlist (normalize the string once, then a single
   array-of-strings check) — identical behaviour, all 12 cases still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
a766672574 fix(mcp): replaceImage no longer yanks the cursor (#164)
`mutateLiveContentUnlocked` — the write path used by `replaceImage` — still
did the pre-#152 destructive write (delete the whole fragment + applyUpdate a
fresh Y.Doc), discarding every Yjs node id. y-prosemirror anchors the editor
selection to those ids, so an open editor's cursor snapped to the document
end on every image swap, exactly the #152 jump that the main write path no
longer causes.

Switch it to the same `applyDocToFragment(ydoc, newDoc)` structural diff
(updateYFragment) as the main path, so unchanged nodes keep their ids and the
live cursor stays put. It runs its own atomic transact, so the old explicit
transact/delete is gone; the now-unused docmostExtensions import is dropped.

Regression tests (cursor-stability suite): a sibling paragraph's
RelativePosition survives a top-level image src/attachmentId swap, and an
image nested in a callout, matching the shapes replaceImage produces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
5e8cb628f0 feat(ai-chat): compact reasoning rendering — collapse blank lines (#181)
The "Thinking" (reasoning) block rendered with large vertical gaps: models
emit reasoning with a blank line (\n\n) between every list item and
paragraph, which `marked` turns into loose lists (each <li> wrapped in a
<p>) and separate <p> paragraphs, each carrying a margin.

- Add `collapseBlankLines(text)`: collapse 2+ newlines to one, EXCEPT inside
  fenced code blocks (``` / ~~~) where blank lines are significant. Applied
  in reasoning-block.tsx before renderChatMarkdown, so loose lists become
  tight (no <li><p>) and paragraphs join; `breaks: true` keeps single \n as
  <br>, preserving line breaks. Reasoning-only — the normal answer is
  untouched.
- Drop `white-space: pre-wrap` from `.reasoningText`: on the rendered
  markdown <div> it turned the newlines between block tags into visible
  blank lines on top of the margins. The plain-text fallback <Text> that
  needs pre-wrap already sets it inline.

Tests: collapseBlankLines unit (collapse, fence preservation incl. tilde and
unclosed fences) + rendered-HTML assertions that a blank-line-separated list
becomes a tight list and still parses as a list after a paragraph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8413185a1d fix(ai-chat): tick the live token counter between agent steps (#163)
The header token badge (and the "Thinking… · N tokens" line) froze between
agent steps and jumped in chunks instead of ticking smoothly. liveTurnTokens
returned the authoritative server `usage` VERBATIM as soon as it appeared, but
the server only attaches usage at a step boundary and it is cumulative over
COMPLETED steps — so during the next (in-flight) step the figure stayed frozen
at the previous boundary and the running text estimate was ignored.

Combine both sources per component via max: always compute the running estimate
(chars/≈4 over the message's reasoning/text parts, which includes the in-flight
step) and take max(authoritativeBase, estimate). Between boundaries the estimate
ticks the number up; at a boundary the authoritative figure snaps it exact; and
because the server usage is cumulative and we only ever take the max, the counter
is monotonic (never drops). Reasoning/output stay split; the #151 reasoning-only
authoritative count is preserved.

Backward compatible: in every existing test the estimate is <= the authoritative
figure, so max returns the same value. +4 tests for the in-flight-step-exceeds-
base case (output + reasoning), the authoritative-wins case, and monotonicity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude_code
8fee6a86c2 fix(ai-chat): style GFM tables in assistant chat markdown
Assistant answers containing GFM tables rendered badly in the narrow AI
side panel: `.markdown` only styled p/pre/code/ul/ol and had no table
rules, so tables used the browser default `table-layout: auto`. Combined
with the inherited `word-break: break-word`, columns collapsed to a
single glyph and headers wrapped mid-word ("Секция" -> "Секци / я").

Add table styling scoped to `.markdown`, in line with the editor's
table.css house style:
- make the table a horizontally scrollable block (display:block +
  overflow-x:auto) so wide tables scroll instead of squishing;
- give cells a 6em min-width and restore word-boundary wrapping
  (word-break:normal + overflow-wrap:break-word);
- add 1px borders, padding and a th background (light-dark for dark
  mode); zero out the default <p> margin inside cells.

CSS-only; no markdown-pipeline change (marked already emits GFM tables,
DOMPurify already allows table tags). Applies to the public share too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:53:58 +03:00
claude_code
99d0cb8773 perf(ai-chat): throttle stream + memoize markdown to stop CPU spikes on long runs
On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with
no user interaction: useChat updated state on every streamed token, and
MessageItem/ReasoningBlock re-parsed the whole transcript's markdown (the marked
pipeline + DOMPurify) on every delta. Per-turn work grew quadratically and
saturated the main thread; the SSE stream drove it, so it hung "on its own".

- chat-thread: pass experimental_throttle (50ms) to useChat so the streamed
  messages state re-renders at most ~20 Hz instead of once per token.
- message-item: memoize MessageItem on a cheap per-message content signature
  (the streaming tail still re-renders as it grows; finalized rows are skipped),
  and render each text part via a memoized MarkdownPart so finalized parts are
  not re-parsed. The signature includes usage.reasoningTokens so the
  authoritative "Thinking - N tokens" count still snaps in at finish-step.
- reasoning-block: memoize the markdown render (useMemo on the text) and wrap the
  component in React.memo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:26:44 +03:00
193 changed files with 15732 additions and 3743 deletions

View File

@@ -187,3 +187,11 @@ MCP_DOCMOST_PASSWORD=
# Per-request output-token ceiling for the anonymous assistant (default: 512).
# Worst-case output per accepted call = agent steps (5) × this value.
# SHARE_AI_MAX_OUTPUT_TOKENS=512
#
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
# (input re-sent per step + output, summed across every accepted turn). The
# hourly request cap above bounds how MANY calls run, not how expensive each is,
# so this caps the owner's actual provider bill directly. Like the request cap it
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000

View File

@@ -56,3 +56,160 @@ jobs:
tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
# `build` stays `needs: test` only, so the :develop image still ships even if
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
# to the pusher — that red run + email is the intended notification, not a
# deploy block.
e2e-server:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Run server e2e
run: pnpm --filter ./apps/server test:e2e
# Same rationale as e2e-server: this job is intentionally NOT in
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
# a red run plus GitHub's email to the pusher is the notification mechanism.
e2e-mcp:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
NODE_ENV: production
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Build server
run: pnpm server:build
- name: Build mcp
run: pnpm --filter @docmost/mcp build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Start server (prod)
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
# migration mismatch) is diagnosable; without this the only signal is
# the generic health-loop timeout below, ~120s later.
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
- name: Wait for server health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3000/api/health > /dev/null; then
echo "Server is healthy"
exit 0
fi
sleep 2
done
echo "Server did not become healthy in time"
exit 1
- name: Dump server log on failure
if: failure()
run: cat /tmp/server.log || true
- name: Seed admin
run: |
curl -fsS -X POST http://localhost:3000/api/auth/setup \
-H "Content-Type: application/json" \
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
- name: Run mcp e2e
env:
DOCMOST_API_URL: http://localhost:3000/api
DOCMOST_EMAIL: e2e@example.com
DOCMOST_PASSWORD: E2ePassword123
run: pnpm --filter @docmost/mcp test:e2e

View File

@@ -15,6 +15,38 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
# Real Postgres + Redis so the server integration suite (`*.int-spec.ts`,
# behind `pnpm --filter server test:int`) runs in CI (red-team finding #7).
# Without it, cost-cap / FK-cascade / jsonb-round-trip / real-apply tests
# only ran locally, so regressions in those paths stayed green in CI.
# Postgres uses the pgvector image because migrations create vector columns
# and global-setup runs `CREATE EXTENSION vector`. Credentials/db match the
# defaults in apps/server/test/integration/db.ts + global-setup.ts
# (docmost / docmost_dev_pw, maintenance db `docmost`, redis on 6379), so no
# TEST_*_URL overrides are needed.
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost_dev_pw
POSTGRES_DB: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -36,5 +68,12 @@ jobs:
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run tests
- name: Run unit tests
run: pnpm -r test
# Integration suite against the real Postgres/Redis services above. Runs
# the FK-cascade, cost-cap, jsonb-round-trip and real-apply specs that the
# unit run (mocks only) cannot cover. global-setup drops/recreates the
# isolated `docmost_test` DB and migrates it to latest.
- name: Run server integration tests
run: pnpm --filter server test:int

View File

@@ -10,6 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
the database step by step and exported server-side, the desktop app no longer
freezes at 100% CPU on long agent runs, and MCP writes are badged with
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
per-reference back-links), hardens page moves and duplication against cycles
and lost edits, and caps the anonymous public-share assistant with a
per-workspace rolling-day token budget.
### Added
- **Persistent AI-chat history as the source of truth + server-side export.**
@@ -43,6 +53,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
reasoning-model request shaping). Chosen explicitly rather than inferred from
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
now has an admin-authored `instructions` field ("how/when to use this server's
tools") that is injected into the agent's system prompt next to that server's
tool descriptions. Trusted text, rendered inside the prompt safety sandwich;
shown only for a server that actually connected and contributed ≥1 callable
tool. (#180)
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
### Changed
@@ -69,6 +88,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
per-turn work grew quadratically and saturated the main thread. The stream is now
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
markdown part / reasoning block is memoized, so a long turn no longer re-parses
already-finished content. (#182)
- **Editor: caret/selection landed on the wrong line when clicking inside code
blocks and footnotes.** The affected NodeViews rendered their non-editable
chrome (language menu, footnotes heading, footnote number marker) before the
@@ -78,6 +104,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are nudged after a paste to refresh stale hit-testing geometry. The caret
symptom is macOS-specific and was confirmed manually on macOS; the automated
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
- **AI chat: the live token counter now ticks between agent steps.** During a
multi-step turn the header token badge (and the "Thinking… · N tokens" line)
no longer froze on the previous step's authoritative usage; the current step's
estimate is combined per-component with `max`, so the count rises smoothly and
never jumps backwards. (#163)
- **AI chat: "New chat" during a streaming first turn now resets the whole
chat, not just the role badge.** Starting a new chat mid-stream cleared the
header but left the in-flight turn's messages behind, so the fresh chat opened
pre-populated with the previous conversation; it now fully resets. (#161)
- **AI chat: a dropped tool argument now yields an actionable error.** When the
model omitted a required parameter (typically `pageId`) in a parallel/batch
tool call, the assistant forwarded zod's raw "expected string, received
undefined" text; tool inputs now return a message naming each missing/invalid
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
(#190)
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
under one of its own descendants is rejected in the same transaction as the
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
so a pre-existing cycle can no longer spin a query. (#207)
- **Page/editor robustness batch.** Duplicating a page now copies shared
attachments for every referencing page (not just the first); colliding block
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
wrong node; transient collab store failures are retried so autosave edits
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
(#206)
### Security
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
share assistant now caps a workspace's actual token spend (input + output,
summed across every accepted turn) over a trailing day, on top of the hourly
request cap — so a caller who evades the per-IP throttle still cannot run up
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
## [0.93.0] - 2026-06-21

View File

@@ -114,7 +114,7 @@ community feature, with no enterprise license. Open it from the page header; the
- 🔭 **Viewer comments** — let read-only viewers leave comments.
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
- 🔭 **Offline mode** — offline sync & PWA support.
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.

View File

@@ -115,7 +115,7 @@ real-time-коллаборации Docmost, поэтому запись нико
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.

View File

@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.93.0",
"version": "0.94.0",
"scripts": {
"dev": "node scripts/copy-vad-assets.mjs && vite",
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",

View File

@@ -711,9 +711,12 @@
"Authorization header": "Authorization header",
"Tool allowlist": "Tool allowlist",
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
"Failed": "Failed",
"OK · {{n}}": "OK · {{n}}",
"Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully",
"Clear": "Clear",
@@ -1078,6 +1081,8 @@
"Undo": "Undo",
"Redo": "Redo",
"Backlinks": "Backlinks",
"Back to references": "Back to references",
"Back to reference {{label}}": "Back to reference {{label}}",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
@@ -1164,8 +1169,9 @@
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
"Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize",
"Current context size": "Current context size",
"Tokens generated this turn": "Tokens generated this turn",
"Context size / model limit": "Context size / model limit",
"Context window (tokens)": "Context window (tokens)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",

View File

@@ -406,6 +406,8 @@
"Footnote {{number}}": "Сноска {{number}}",
"Go to footnote": "Перейти к сноске",
"Back to reference": "Вернуться к ссылке",
"Back to references": "Вернуться к ссылкам",
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
"Empty footnote": "Пустая сноска",
"Math inline": "Строчная формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.",
@@ -702,13 +704,19 @@
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Tokens generated this turn": "Токенов сгенерировано за ход",
"Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Окно контекста (токены)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
"OK · {{n}}": "OK · {{n}}",
"Test": "Тест",
"No tools available": "Инструменты недоступны",
"Available tools": "Доступные инструменты",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
@@ -750,6 +758,8 @@
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
"Instructions": "Инструкции",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
"Sources": "Источники",
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"No answer available": "Ответ недоступен",

View File

@@ -45,6 +45,7 @@ import {
shouldCollapseOnOutsidePointer,
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -161,12 +162,6 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
// `null` means no turn is in flight -> the badge falls back to the persisted
// context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
@@ -193,6 +188,7 @@ export default function AiChatWindow() {
const {
threadKey,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
@@ -215,12 +211,25 @@ export default function AiChatWindow() {
// just-failed chat after they chose a fresh one.
const startNewChat = useCallback((): void => {
cancelPendingAdoption();
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
// while a brand-new chat's first turn is still streaming leaves activeChatId
// null (the real id is adopted only at turn end), so setActiveChatId(null)
// alone is a no-op and the reconciler never remounts — the chat/stream/history
// would persist and only the role badge would drop. This always remounts the
// thread into a clean new chat.
startFreshThread();
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
}, [
cancelPendingAdoption,
startFreshThread,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
const selectChat = useCallback(
(chatId: string): void => {
@@ -287,24 +296,19 @@ export default function AiChatWindow() {
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
// PERSISTED rows (updates on chat open/switch); it does not tick live
// mid-stream — acceptable for v1.
const contextTokens = useMemo(() => {
if (!activeChatId || !messageRows) return 0;
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
return meta.contextTokens;
}
const usage = meta.usage;
if (usage) {
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) return fallback;
}
}
return 0;
}, [activeChatId, messageRows]);
//
// The denominator `maxContextTokens` (the model's configured max window) is
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
// on a completed turn, but the numerator and denominator are taken from the
// most recent row carrying EACH value independently — they may land on
// different rows (e.g. a fresh error row can carry contextTokens but not
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
// no row has it (older rows, or no admin-configured limit) — the badge then
// shows just the current size with no denominator.
const { contextTokens, maxContextTokens } = useMemo(
() => selectContextBadge(activeChatId ? messageRows : undefined),
[activeChatId, messageRows],
);
// On (re)open, settle the geometry before paint (useLayoutEffect → no
// first-frame jump): compute an initial top-right placement the first time,
@@ -495,20 +499,17 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
once it finishes, fall back to the persisted context size. Require
> 0 so the very first emit (an empty tail message, count 0) does not
flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>
{formatTokens(liveTurnTokens)}
</span>
</Tooltip>
) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow>
{/* Always show the persisted "current / max" context. The denominator
(the admin-configured model limit) is appended only when known;
not clamped when current > max (shown as-is, e.g. "210k / 200k").
Hidden entirely until a turn has recorded a context figure. */}
{contextTokens > 0 ? (
<Tooltip label={t("Context size / model limit")} withArrow>
<span className={classes.badge}>
{formatTokens(contextTokens)}
{maxContextTokens > 0
? ` / ${formatTokens(maxContextTokens)}`
: ""}
</span>
</Tooltip>
) : null}
@@ -622,6 +623,7 @@ export default function AiChatWindow() {
) : (
<ChatThread
key={threadKey}
threadKey={threadKey}
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
openPage={openPage}
@@ -634,7 +636,6 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}
</div>

View File

@@ -55,6 +55,45 @@
padding-inline-start: 1.4em;
}
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
wide LLM table must scroll horizontally instead of collapsing its columns:
`.markdown` sets `word-break: break-word`, which (with the default table
layout) shrinks columns to a single glyph and wraps headers mid-word
("Секция" -> "Секци / я"). Make the table a horizontally scrollable block,
give cells a readable minimum width, and restore word-boundary wrapping. */
.markdown table {
display: block;
/* lets the table scroll horizontally on its own */
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin-block-end: 0.5em;
}
.markdown th,
.markdown td {
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding: 3px 8px;
/* readable floor; the block scrolls when the row exceeds the panel */
min-width: 6em;
text-align: left;
vertical-align: top;
/* cancel the inherited break-word so words don't split mid-glyph */
word-break: normal;
/* still wrap genuinely long words / URLs at the cell edge */
overflow-wrap: break-word;
}
.markdown th {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
font-weight: 600;
}
/* GFM wraps cell text in <p>; drop its default block margin inside cells. */
.markdown table p {
margin: 0;
}
/* Animated three-dot "typing" indicator shown while the agent is thinking but
has not yet produced any visible text/tool parts. */
.typingDots {
@@ -122,7 +161,11 @@
margin-top: 4px;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
white-space: pre-wrap;
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
rendered markdown <div> it would turn the newlines between block tags
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
margins. The plain-text fallback <Text> that needs pre-wrap sets it
inline itself (see reasoning-block.tsx). */
}
.reasoningText p {

View File

@@ -20,7 +20,6 @@ import {
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import {
dequeue,
enqueueMessage,
@@ -29,6 +28,14 @@ import {
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
// Throttle how often the streamed `messages` state triggers a re-render. Without
// it, useChat updates state on EVERY token, so the whole transcript's markdown
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
// into a quadratic CPU storm that pins the main thread and freezes the UI.
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
// from the token rate.
const STREAM_THROTTLE_MS = 50;
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -38,6 +45,11 @@ export interface OpenPageContext {
interface ChatThreadProps {
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
chatId: string | null;
/** This thread's mount key (the same value the parent uses as React `key`).
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
threadKey?: string;
/** Persisted rows to seed initial messages (existing chats only). */
initialRows?: IAiChatMessageRow[];
/** The page currently open in the workspace, or null on a non-page route.
@@ -59,20 +71,16 @@ interface ChatThreadProps {
/** Called when a turn finishes; the parent refreshes the chat list and, for a
* new chat, adopts the freshly created chat id. `serverChatId` is the
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
onTurnFinished: (serverChatId?: string) => void;
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
* finishing on a thread already abandoned by New chat mid-stream (#161). */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
* chat id streamed on the assistant message metadata, so a brand-new chat
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void;
}
/**
@@ -109,6 +117,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
*/
export default function ChatThread({
chatId,
threadKey,
initialRows,
openPage,
roleId,
@@ -117,7 +126,6 @@ export default function ChatThread({
assistantName,
onTurnFinished,
onServerChatId,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -246,6 +254,8 @@ export default function ChatThread({
id: chatStoreId,
messages: initialMessages,
transport,
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
experimental_throttle: STREAM_THROTTLE_MS,
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
@@ -257,8 +267,10 @@ export default function ChatThread({
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new
// chat — see adopt-chat-id.ts for the full #137 design.
onTurnFinished(extractServerChatId(message));
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
// session ignore this finish if it belongs to a thread abandoned by New chat
// mid-stream (#161).
onTurnFinished(extractServerChatId(message), threadKey);
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
if (isError) setStopNotice(null);
@@ -279,7 +291,7 @@ export default function ChatThread({
// Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView).
console.error("AI chat stream error:", streamError);
onTurnFinished();
onTurnFinished(undefined, threadKey);
},
});
@@ -328,53 +340,6 @@ export default function ChatThread({
// the SAME on-screen banner text can be mirrored into the export (issue #160).
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while
// streaming, authoritative once a step reports usage) is the live figure. When
// the turn ends we emit a final exact value, then `null` so the parent reverts
// the badge to the persisted context size.
const lastEmitRef = useRef(0);
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!onLiveTurnTokens) return;
if (!isStreaming) {
// Turn ended (or never started): clear any pending throttle and revert.
if (emitTimerRef.current) {
clearTimeout(emitTimerRef.current);
emitTimerRef.current = null;
}
lastEmitRef.current = 0;
onLiveTurnTokens(null);
return;
}
const tail = messages[messages.length - 1];
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0;
const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz)
const elapsed = now - lastEmitRef.current;
if (elapsed >= MIN_INTERVAL) {
lastEmitRef.current = now;
onLiveTurnTokens(total);
} else if (!emitTimerRef.current) {
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
emitTimerRef.current = setTimeout(() => {
emitTimerRef.current = null;
lastEmitRef.current = Date.now();
onLiveTurnTokens(total);
}, MIN_INTERVAL - elapsed);
}
}, [messages, isStreaming, onLiveTurnTokens]);
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
// trailing emit can't fire into a torn-down thread's parent.
useEffect(() => {
return () => {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
};
}, []);
// A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated)

View File

@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
// OTHER named export of markdown.ts intact via `importActual`, and override only
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
// still renders. This is the seam that proves the MarkdownPart memo works: a
// finalized text part must NOT be re-parsed on a later streamed delta.
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
import MessageItem from "./message-item";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} />
</MantineProvider>,
);
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
const callsFor = (text: string) =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
describe("MessageItem markdown memoization", () => {
it("does not re-parse finalized text parts when only a tail part grows", () => {
renderChatMarkdownSpy.mockClear();
// Two finalized text parts.
const first = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
const { rerender } = renderRow(first);
// Both finalized parts parsed exactly once on the initial render.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
// A streamed delta: a NEW message object where only a third tail part grows;
// the first two parts' text is byte-identical.
const next = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
{ type: "text", text: "gamm" },
]);
rerender(
<MantineProvider>
<MessageItem message={next} />
</MantineProvider>,
);
// The finalized parts hit the MarkdownPart memo: still parsed at most once
// each across BOTH renders (the resilient invariant). The only new parse is
// for the changed/added tail part.
expect(callsFor("alpha")).toBe(1);
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next: importing the component module pulls in `useTranslation`,
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
// minimal `t` that echoes the key is enough. Mirrors the stub in
// reasoning-block.test.tsx.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { arePropsEqual } from "./message-item";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). A FIXED
* message id is used so a content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
...over,
});
describe("arePropsEqual", () => {
it("returns false when showCitations differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { showCitations: false })),
).toBe(false);
});
it("returns false when neutralizeInternalLinks differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
).toBe(false);
});
it("returns false when assistantName differs", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
).toBe(false);
});
it("returns true on the identity fast path (same message object, equal props)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
it("returns true for the same content in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer" }]);
expect(a).not.toBe(b);
expect(arePropsEqual(props(a), props(b))).toBe(true);
});
it("returns false when content changed in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
});

View File

@@ -1,3 +1,4 @@
import { memo } from "react";
import { Box, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
@@ -10,6 +11,7 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -34,6 +36,39 @@ interface MessageItemProps {
assistantName?: string;
}
/**
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
* primitives, so React.memo's default shallow compare is exactly right (the
* `text` string is compared by value).
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
}: {
text: string;
neutralizeInternalLinks: boolean;
}) {
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
<div
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{text}
</Text>
);
});
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.
@@ -41,12 +76,13 @@ interface MessageItemProps {
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
* User messages render their text as a right-aligned plain bubble.
*
* This component is intentionally NOT memoized: `useChat` replaces the streaming
* assistant message with a freshly cloned object on every streamed delta, so the
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
* per-message content signature: the streaming TAIL message's signature changes
* on each delta so it still re-renders and streams in, while finalized rows are
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
* long turn no longer re-parses the whole transcript on every token.
*/
export default function MessageItem({
function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
@@ -109,24 +145,12 @@ export default function MessageItem({
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
const html = renderChatMarkdown(part.text, {
neutralizeInternalLinks,
});
if (html) {
return (
<div
key={index}
className={classes.markdown}
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Fallback when markdown could not render synchronously: raw text.
return (
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
{part.text}
</Text>
<MarkdownPart
key={index}
text={part.text}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
);
}
@@ -177,3 +201,26 @@ export default function MessageItem({
</Box>
);
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh object whose signature changes each delta, so it
* still re-renders and streams in; every FINALIZED message is skipped, turning a
* per-token whole-transcript re-render into a tail-only one. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
if (
prev.showCitations !== next.showCitations ||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
prev.assistantName !== next.assistantName
) {
return false;
}
// Fast path: identical message object (finalized rows keep their identity
// across deltas) — skip without building signatures.
if (prev.message === next.message) return true;
return messageSignature(prev.message) === messageSignature(next.message);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -1,8 +1,9 @@
import { useState } from "react";
import { memo, useMemo, useState } from "react";
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -26,14 +27,23 @@ interface ReasoningBlockProps {
* Providers that don't stream reasoning TEXT still render this block from the
* authoritative count alone (header only, empty body) so the cost is visible.
*/
export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
const html = trimmed ? renderChatMarkdown(trimmed, {}) : "";
// Memoize the markdown render so toggling `open` (or a parent re-render caused
// by an unrelated streamed delta) does not re-parse the reasoning text; it
// recomputes only when the reasoning text itself changes (while it streams in).
// collapseBlankLines collapses the blank-line gaps the model emits between every
// list item / paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — ONLY here, not in the normal answer.
const html = useMemo(
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
return (
<Box className={classes.reasoningBlock} mb={6}>
@@ -81,3 +91,8 @@ export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
</Box>
);
}
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
// shallow compare), so a parent re-render during streaming of OTHER content does
// not re-run the markdown parse for an already-finalized reasoning block.
export default memo(ReasoningBlock);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react";
import { useChatSession } from "./use-chat-session";
import type { UseChatSessionOptions } from "./use-chat-session";
@@ -227,6 +227,50 @@ describe("useChatSession", () => {
expect(result.current.threadKey).toBe("C");
});
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
// Brand-new chat whose first turn is still streaming: the id is adopted only
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
// chat" must still remount to a clean thread even though the atom is unchanged
// — the render-phase reconciler (null === null) would otherwise do nothing,
// leaving the old chat/stream/history in place (the bug: only the role badge
// dropped).
const { result } = setup({ activeChatId: null, chats: { items: [] } });
const keyBefore = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(keyBefore);
});
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
// abort the abandoned stream on unmount: its onFinish still fires later with
// the real server id, tagged with the OLD (abandoned) mount key. That must not
// adopt — it would yank the user back into the chat they just left.
const { result, setActiveChatId, onInvalidateChatList } = setup({
activeChatId: null,
chats: { items: [] },
});
const abandonedKey = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(abandonedKey);
// The abandoned turn finishes in the background, streaming its real id "A".
result.current.onTurnFinished("A", abandonedKey);
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
// It still refreshes the chat list so the left-behind chat shows in history.
expect(onInvalidateChatList).toHaveBeenCalled();
});
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
// The happy path must keep working: onTurnFinished tagged with the mounted
// thread's own key adopts in place as before.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
const currentKey = result.current.threadKey;
result.current.onTurnFinished("A", currentKey);
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
// Open an existing chat whose history is still loading => loader on.
const { result, rerender } = setup({

View File

@@ -31,9 +31,19 @@ export interface UseChatSessionResult {
threadKey: string;
/** Show the history loader instead of the live thread. */
waitingForHistory: boolean;
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
* even when `activeChatId` is unchanged. The window calls this from
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
* still streaming (activeChatId still null, nothing to diverge) actually
* resets the chat instead of only dropping the role badge (#161). */
startFreshThread: () => void;
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
* thread that produced the turn (omit => "current thread", back-compatible):
* a turn ABANDONED by New chat mid-stream still fires this after its thread
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
* new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
@@ -98,6 +108,15 @@ export function useChatSession(
: switchThread(activeChatId),
);
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
// does not abort a stream on unmount and proxies callbacks through a ref, so an
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
// matching its key against this ref keeps that late finish from adopting the
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
const threadKeyRef = useRef(thread.key);
threadKeyRef.current = thread.key;
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
// reaches the client, so the primary metadata adoption cannot run. We then ARM
@@ -115,7 +134,23 @@ export function useChatSession(
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
// list, which races a second tab — #137; see adopt-chat-id.ts).
const onTurnFinished = useCallback(
(serverChatId?: string) => {
(serverChatId?: string, finishingThreadKey?: string) => {
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
// not abort on unmount and proxies callbacks through a ref). If that late
// finish ran the adoption path it would set activeChatId to the abandoned
// chat's real id and yank the user out of the fresh chat they just opened.
// So adopt / arm the fallback ONLY for the still-mounted thread; an
// abandoned one merely refreshes the chat list (so the left-behind chat
// surfaces in history) and does nothing else. A missing key (undefined)
// means "current thread" — keeps old call sites/tests working.
if (
finishingThreadKey !== undefined &&
finishingThreadKey !== threadKeyRef.current
) {
onInvalidateChatList();
return;
}
// Read the live id from the ref, not the closure: on a failed turn this can
// run twice in one turn (onFinish + onError) before any re-render, and the
// primary branch below updates the ref so the second call sees the adopted id.
@@ -258,9 +293,28 @@ export function useChatSession(
pendingNewChatRef.current = null;
}, []);
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
// so "New chat" pressed while a brand-new chat's first turn is still streaming
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
// end of the turn) is a no-op for it and the abandoned thread/stream/history
// would persist. Dispatching reconcile with a fresh key and chatId:null here
// always produces a new mount key, so React remounts ChatThread (a clean useChat
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
// keeps the reconciler from interfering. Also disarms any pending fallback.
const startFreshThread = useCallback(() => {
pendingNewChatRef.current = null;
dispatch({
type: "reconcile",
chatId: null,
newKey: `new-${generateId()}`,
});
}, []);
return {
threadKey: thread.key,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,

View File

@@ -116,6 +116,9 @@ export interface IAiChatMessageRow {
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
// floating window's header badge.
contextTokens?: number;
// The model's max context window (denominator for the header badge); set
// alongside contextTokens on a completed turn; absent on older rows.
maxContextTokens?: number;
// Set on an assistant row whose turn ended in a provider/stream error; the
// raw provider error text (e.g. "402: ...") for inline display in the thread.
error?: string;

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
describe("collapseBlankLines", () => {
it("collapses a run of 2+ newlines to a single newline", () => {
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
});
it("keeps single newlines untouched", () => {
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
});
it("preserves blank lines INSIDE a fenced code block", () => {
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
// Prose blanks collapse; the blank lines between the ``` fences survive.
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
});
it("handles a tilde fence and preserves its interior blanks", () => {
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
});
it("leaves an unclosed fence's remaining lines verbatim", () => {
const src = "intro\n\n```\nstill\n\nopen";
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
});
it("is a no-op for text with no blank lines", () => {
expect(collapseBlankLines("just one line")).toBe("just one line");
});
});
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
const loose =
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
expect(html).toContain("<li>item one</li>");
expect(html).not.toContain("<li><p>");
// The list still parses as a list after the paragraph (not a paragraph+<br>).
expect(html).toContain("<ul>");
expect(html).toContain("<p>Intro paragraph.</p>");
});
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
const loose = "Intro.\n\n1. first\n\n2. second";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
expect(html).toContain("<ol>");
expect(html).toContain("<li>first</li>");
expect(html).not.toContain("<li><p>");
});
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
const loose = "- a\n\n- b";
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
});
});

View File

@@ -0,0 +1,56 @@
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
/**
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
*
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
* normal assistant answer (where paragraph spacing is intentional).
*
* Fenced code is preserved verbatim: a fence opens on a line whose first
* non-space characters are ``` or ~~~ and closes on the next line that starts
* with the same fence character. Blank lines between fences (significant for
* code formatting) are never collapsed.
*/
export function collapseBlankLines(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let inFence = false;
let fenceChar = "";
for (const line of lines) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const ch = fenceMatch[1][0];
if (!inFence) {
inFence = true;
fenceChar = ch;
} else if (ch === fenceChar) {
inFence = false;
}
out.push(line);
continue;
}
// Inside a fenced block every line (including blanks) is significant.
if (inFence) {
out.push(line);
continue;
}
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
// `\n` between the surrounding content lines.
if (line.trim() === "") continue;
out.push(line);
}
return out.join("\n");
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
/**
* Pure-helper tests for the header context badge selection. Covers the two
* non-obvious rules: numerator and denominator are each taken from the most
* recent row carrying THAT value (they may live on different rows), and a fresh
* row with a zero/absent value must NOT shadow an older positive one.
*/
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
id: Math.random().toString(),
role: "assistant",
content: null,
metadata,
createdAt: "2026-01-01T00:00:00.000Z",
});
describe("selectContextBadge", () => {
it("returns zeros for empty / nullish input", () => {
expect(selectContextBadge(undefined)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge(null)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge([])).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
});
it("reads both figures from the most recent row that carries them", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1500, maxContextTokens: 200000 }),
]),
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
});
it("falls back to legacy usage total for older rows without contextTokens", () => {
expect(
selectContextBadge([
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
]),
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
expect(
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
});
it("takes numerator and denominator from different rows", () => {
// Freshest row (an error turn) carries contextTokens but no max; the older
// completed turn carries the max. Each is picked from its own latest row.
expect(
selectContextBadge([
row({ contextTokens: 800, maxContextTokens: 200000 }),
row({ contextTokens: 1200, error: "402: nope" }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("does not let a fresh zero/absent max shadow an older positive max", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1200, maxContextTokens: 0 }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("skips rows with null metadata", () => {
expect(
selectContextBadge([
row({ contextTokens: 500, maxContextTokens: 200000 }),
row(null),
]),
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
});
it("reports current > max as-is (no clamp)", () => {
expect(
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
});
});

View File

@@ -0,0 +1,49 @@
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Derive the header context badge figures from the persisted message rows.
*
* - `contextTokens` (numerator): how much the conversation now occupies in the
* model's context window. Read from the most recent row carrying a context
* figure — `contextTokens` (final-step input+output) on rows recorded after
* this shipped, else that turn's legacy `usage` total for older rows.
* - `maxContextTokens` (denominator): the model's configured max window, stamped
* alongside `contextTokens` on a completed turn.
*
* Each value is taken from the most recent row carrying THAT value
* independently — they may land on different rows (e.g. a fresh error row can
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
* whichever is still unset. `0` means "no row has it" (older rows, or no
* admin-configured limit); the badge then omits the value.
*/
export function selectContextBadge(
messageRows: readonly IAiChatMessageRow[] | undefined | null,
): { contextTokens: number; maxContextTokens: number } {
let contextTokens = 0;
let maxContextTokens = 0;
if (!messageRows) return { contextTokens, maxContextTokens };
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (contextTokens === 0) {
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
contextTokens = meta.contextTokens;
} else if (meta.usage) {
const usage = meta.usage;
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) contextTokens = fallback;
}
}
if (
maxContextTokens === 0 &&
typeof meta.maxContextTokens === "number" &&
meta.maxContextTokens > 0
) {
maxContextTokens = meta.maxContextTokens;
}
if (contextTokens !== 0 && maxContextTokens !== 0) break;
}
return { contextTokens, maxContextTokens };
}

View File

@@ -1,17 +1,5 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import {
estimateTokens,
liveTurnTokens,
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
describe("estimateTokens", () => {
it("returns 0 for the empty string", () => {
@@ -25,95 +13,3 @@ describe("estimateTokens", () => {
expect(estimateTokens("12345678")).toBe(2);
});
});
describe("liveTurnTokens — estimate path", () => {
it("is all zeros for an undefined message", () => {
expect(liveTurnTokens(undefined)).toEqual({
reasoning: 0,
output: 0,
authoritative: false,
});
});
it("is all zeros for a parts-less message", () => {
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
reasoning: 0,
output: 0,
authoritative: false,
});
});
it("estimates output from text parts", () => {
// 8 chars -> 2 tokens.
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
});
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
const r = liveTurnTokens(
msg([
{ type: "reasoning", text: "12345678" },
{ type: "text", text: "abcd" },
]),
);
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
});
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
const r = liveTurnTokens(
msg([
{ type: "reasoning", text: "abcd" }, // 1
{ type: "text", text: "abcd" }, // 1
{ type: "tool-getPage", state: "output-available" }, // ignored
{ type: "reasoning", text: "abcd" }, // 1
{ type: "text", text: "abcdefgh" }, // 2
]),
);
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
});
it("ignores non text/reasoning parts (tools, step-start)", () => {
const r = liveTurnTokens(
msg([
{ type: "step-start" },
{ type: "tool-getPage", state: "input-available" },
]),
);
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
});
});
describe("liveTurnTokens — authoritative path", () => {
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
const r = liveTurnTokens(
msg([{ type: "text", text: "estimate would be tiny" }], {
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
}),
);
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
});
it("treats missing reasoningTokens as 0 and keeps full output", () => {
const r = liveTurnTokens(
msg([{ type: "text", text: "x" }], {
usage: { inputTokens: 10, outputTokens: 42 },
}),
);
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
});
it("never returns a negative output when reasoning exceeds reported output", () => {
const r = liveTurnTokens(
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
);
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
});
it("falls back to the estimate when metadata has no usage object", () => {
const r = liveTurnTokens(
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
);
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
});
});

View File

@@ -1,18 +1,11 @@
import type { UIMessage } from "@ai-sdk/react";
/**
* Live token counting for a streaming AI-chat turn — split into REASONING
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
* `Thinking… · 60 tokens` next to its thinking indicator.
* Rough client-side token estimation for AI-chat UI affordances.
*
* No provider streams exact per-token usage mid-stream, so the live number is a
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
* once the server attaches it on a step/turn boundary (see the server's
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
* authoritative usage is present we return it verbatim (the number "jumps to
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
* bundle, and be wrong for Gemini/Ollama anyway).
* No provider streams exact per-token usage mid-stream, so any in-flight figure
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
* ("Thinking · N tokens").
*/
/**
@@ -24,71 +17,3 @@ export function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
/** Authoritative per-step/turn usage the server attaches to message metadata. */
export interface AuthoritativeUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** Live token split for a turn's tail (streaming) assistant message. */
export interface LiveTurnTokens {
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
reasoning: number;
/** Answer/output tokens (estimate, or authoritative when available). */
output: number;
/** True when the numbers come from authoritative server usage, not estimate. */
authoritative: boolean;
}
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
const meta = message?.metadata as
| { usage?: AuthoritativeUsage }
| undefined;
const usage = meta?.usage;
if (!usage || typeof usage !== "object") return undefined;
return usage;
}
/**
* Token split for the given (streaming) assistant message.
*
* Prefers AUTHORITATIVE `metadata.usage` when the server has attached it (at a
* step/turn boundary, incl. `reasoningTokens`) — so the live counter snaps to the
* provider's exact figures. Until then it returns a running ESTIMATE summed over
* the message parts: `reasoning` parts feed the reasoning estimate, `text` parts
* feed the output estimate. Multi-part / multi-step turns accumulate naturally
* because every part of the turn is summed.
*
* Providers that don't stream reasoning text still surface a reasoning count once
* the authoritative usage arrives (`usage.reasoningTokens`); on the pure estimate
* path such a turn simply shows `reasoning: 0` until then.
*/
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
if (!message) return { reasoning: 0, output: 0, authoritative: false };
const usage = metadataUsage(message);
if (usage) {
// Authoritative branch: outputTokens already INCLUDES reasoning tokens in the
// AI SDK usage shape, so subtract reasoning out for the "answer" figure (never
// go negative if a provider reports them inconsistently).
const reasoning = usage.reasoningTokens ?? 0;
const totalOutput = usage.outputTokens ?? 0;
const output = Math.max(0, totalOutput - reasoning);
return { reasoning, output, authoritative: true };
}
let reasoning = 0;
let output = 0;
for (const part of message.parts ?? []) {
if (part.type === "reasoning") {
reasoning += estimateTokens((part as { text?: string }).text ?? "");
} else if (part.type === "text") {
output += estimateTokens((part as { text?: string }).text ?? "");
}
}
return { reasoning, output, authoritative: false };
}

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Pure-helper tests for `messageSignature`, the cheap per-message content
* signature that drives MessageItem's memo (a streaming row's signature must
* change on every delta so it re-renders, while a finalized row's stays stable
* so it is skipped). Each test exercises ONE change signal and asserts it flips
* the signature; a content-identical clone must keep an EQUAL signature.
*
* The signature embeds `message.id` and `message.role`, so the `msg` factory
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
* identical content would get different signatures and the negative case would
* be impossible to express.
*/
const msg = (
parts: UIMessage["parts"],
metadata?: unknown,
): UIMessage =>
({
id: "m1",
role: "assistant",
parts,
metadata,
}) as UIMessage;
describe("messageSignature", () => {
it("changes when a text part grows", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([{ type: "text", text: "alpha beta" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a new part is appended", () => {
const before = msg([{ type: "text", text: "alpha" }]);
const after = msg([
{ type: "text", text: "alpha" },
{ type: "text", text: "beta" },
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part's state flips", () => {
const before = msg([
{ type: "tool-getPage", state: "input-streaming" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a tool part gains an output", () => {
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { ok: true },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when a part gains an errorText", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "boom",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
// The specifically-commented edge case: the authoritative turn total lands on
// the final finish-step AFTER the reasoning text length and state are frozen.
// Only the token count appears between these two snapshots, so the signature
// MUST still flip — otherwise the "Thinking · N tokens" header would never
// snap from the live estimate to the exact figure.
const before = msg([
{ type: "reasoning", text: "thinking", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "thinking", state: "done" } as never],
{ usage: { reasoningTokens: 42 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.error appears", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
// A finalized row that is re-created as a fresh object (different parts array
// by reference, same parts by value) must keep an EQUAL signature, so the
// memo skips re-rendering it.
const a = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
const b = msg([
{ type: "text", text: "alpha" },
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
]);
expect(a).not.toBe(b);
expect(messageSignature(a)).toBe(messageSignature(b));
});
});
/**
* Per-part-kind coupling guard for the load-bearing invariant documented at the
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
* MessageItem render body draws, or the memo freezes a stale row. This is an
* executable lock for the part kinds rendered TODAY — read alongside
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
* helper (message-content.ts), which "mirrors MessageItem's render decisions
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
* signature. If a new visible field is rendered without being added here AND to
* the signature, the corresponding assertion below should fail — that is the
* guard. (This intentionally stops short of the render-descriptor refactor:
* adding a part kind or a visible field still requires a human to extend both
* the signature and this block.)
*/
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
it("flips when the visible text changes", () => {
// Streaming is append-only, so the visible text only grows; the signature
// samples its length, so the growth is the change signal.
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer extended" }]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
it("flips when the visible reasoning text changes", () => {
const before = msg([
{ type: "reasoning", text: "think", state: "streaming" } as never,
]);
const after = msg([
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
// finish-step after text length and state are frozen.
const before = msg([
{ type: "reasoning", text: "think", state: "done" } as never,
]);
const after = msg(
[{ type: "reasoning", text: "think", state: "done" } as never],
{ usage: { reasoningTokens: 99 } },
);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
it("flips when the run state changes (running ↔ done icon + label)", () => {
// toolRunState(part.state) selects the spinner/check/error icon.
const before = msg([
{ type: "tool-getPage", state: "input-available" } as never,
]);
const after = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when output arrives (drives the rendered citation links)", () => {
// toolCitations reads part.output to render the "/p/{id}" anchors.
const before = msg([
{ type: "tool-getPage", state: "output-available" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-available",
output: { id: "page-1", title: "Doc" },
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when errorText appears (the visible red error detail line)", () => {
const before = msg([
{ type: "tool-getPage", state: "output-error" } as never,
]);
const after = msg([
{
type: "tool-getPage",
state: "output-error",
errorText: "permission denied",
} as never,
]);
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
describe("metadata banners — render draws error / aborted notices", () => {
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
const before = msg([{ type: "text", text: "answer" }]);
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
const before = msg([{ type: "text", text: "answer" }], {
finishReason: "stop",
});
const after = msg([{ type: "text", text: "answer" }], {
finishReason: "aborted",
});
expect(messageSignature(before)).not.toBe(messageSignature(after));
});
});
});

View File

@@ -0,0 +1,44 @@
import type { UIMessage } from "@ai-sdk/react";
/** Cheap content signature for one message: changes iff something VISIBLE in the
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
* appended, a tool/text part flips state once), so a per-part [type, text
* length, state, error/output presence] tuple + the persisted metadata
* (error/finishReason) is a sufficient change signal without comparing full
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
* text length, state, error/output presence] (e.g. a tool that streams
* `preliminary` output, or a client-side regenerate that edits a finalized
* row in place), extend this signature or the memo will freeze a stale row. */
export function messageSignature(message: UIMessage): string {
const parts = message.parts
.map((p) => {
const any = p as {
type: string;
text?: string;
state?: string;
errorText?: string;
output?: unknown;
};
return [
any.type,
any.text?.length ?? 0,
any.state ?? "",
any.errorText ? 1 : 0,
any.output !== undefined ? 1 : 0,
].join(":");
})
.join("|");
const meta = message.metadata as
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
| undefined;
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
// state are already frozen. Without it in the signature the row's signature would be
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
// to the exact figure.
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
meta?.finishReason ?? ""
}#${meta?.usage?.reasoningTokens ?? ""}`;
}

View File

@@ -1,25 +1,45 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { getFootnoteNumber } from "@docmost/editor-ext";
import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
/**
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
*/
export function backlinkLabel(index: number): string {
let out = "";
let x = index;
while (x >= 0) {
out = String.fromCharCode(97 + (x % 26)) + out;
x = Math.floor(x / 26) - 1;
}
return out;
}
/**
* NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored).
*
* After #166 a footnote can be referenced more than once (one number, one
* definition, N forward links). When it is, the back-link becomes a row of
* per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
* a single-reference footnote keeps the plain ↩.
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
// Read the cached number from the numbering plugin (computed once per doc
// change) rather than recomputing the whole map on every render.
// Read the cached number/ref-count from the numbering plugin (computed once
// per doc change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const refCount = getFootnoteRefCount(editor.state, id);
const handleBack = (e: React.MouseEvent) => {
const jumpTo = (e: React.MouseEvent, index: number) => {
e.preventDefault();
editor.commands.scrollToReference(id);
editor.commands.scrollToReference(id, index);
};
return (
@@ -42,16 +62,47 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
>
{number}.
</span>
<span
className={classes.backLink}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
{refCount > 1 ? (
// Multiple references -> ↩ followed by one lettered link per occurrence.
<span
className={classes.backLinks}
contentEditable={false}
role="group"
aria-label={t("Back to references")}
>
<span className={classes.backLinkArrow} aria-hidden="true">
</span>
{Array.from({ length: refCount }, (_, i) => (
<span
key={i}
className={classes.backLink}
onClick={(e) => jumpTo(e, i)}
role="button"
aria-label={t("Back to reference {{label}}", {
label: backlinkLabel(i),
})}
title={t("Back to reference {{label}}", {
label: backlinkLabel(i),
})}
>
{backlinkLabel(i)}
</span>
))}
</span>
) : (
// Single reference -> the plain ↩ (unchanged behavior).
<span
className={classes.backLink}
contentEditable={false}
onClick={(e) => jumpTo(e, 0)}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
)}
</NodeViewWrapper>
);
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, fireEvent } from "@testing-library/react";
/**
* Structural regression guard for #146 (PR #147).
@@ -36,10 +36,14 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// footnote-definition-view reads a cached number from the numbering plugin;
// stub it so we don't need a live ProseMirror state.
// footnote-definition-view reads a cached number + reference count from the
// numbering plugin; stub them so we don't need a live ProseMirror state. The
// ref-count is a hoisted mutable so a test can drive the single-vs-multi
// backlink branch (#168). Default 1 = single reference (the #146 cases).
const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1,
getFootnoteRefCount: () => mockRefCount.value,
}));
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
@@ -59,7 +63,8 @@ vi.mock("@mantine/core", () => ({
),
}));
vi.mock("@/components/common/copy-button", () => ({
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
CopyButton: ({ children }: any) =>
children({ copied: false, copy: () => {} }),
}));
vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null,
@@ -70,7 +75,9 @@ vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
}));
import FootnotesListView from "./footnotes-list-view";
import FootnoteDefinitionView from "./footnote-definition-view";
import FootnoteDefinitionView, {
backlinkLabel,
} from "./footnote-definition-view";
import CodeBlockView from "../code-block/code-block-view";
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
@@ -141,3 +148,84 @@ describe("#146 editable NodeView contentDOM-first invariant", () => {
},
);
});
// #168: a footnote referenced more than once shows one lettered backlink per
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
// footnote keeps the plain ↩.
describe("#168 footnote definition multi-backlinks", () => {
afterEach(() => {
// Reset the shared ref-count mock so other tests see a single reference.
mockRefCount.value = 1;
});
const makeProps = () =>
({
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: {
state: {},
isEditable: true,
commands: { scrollToReference: vi.fn() },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
}) as any;
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
mockRefCount.value = 3;
const { getByTestId } = render(<FootnoteDefinitionView {...makeProps()} />);
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(Array.from(links).map((l) => l.textContent)).toEqual([
"a",
"b",
"c",
]);
// The ↩ arrow is present (as decorative chrome, not a button).
expect(wrapper.textContent).toContain("↩");
});
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
mockRefCount.value = 3;
const props = makeProps();
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
fireEvent.click(links[1]); // "b"
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
1,
);
});
it("a single-reference footnote renders just one ↩ (no letters)", () => {
mockRefCount.value = 1;
const props = makeProps();
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(links.length).toBe(1);
expect(links[0].textContent).toBe("↩");
fireEvent.click(links[0]);
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
0,
);
});
});
// #185 re-review pt 7: backlinkLabel is base-26 (a..z, then aa…). The component
// tests only cover a,b,c (index 0-2); pin the >= 26 carry boundary.
describe("backlinkLabel base-26 boundary (#168)", () => {
it("maps 0->a, 25->z, 26->aa, 27->ab, 51->az, 52->ba", () => {
expect(backlinkLabel(0)).toBe("a");
expect(backlinkLabel(25)).toBe("z");
expect(backlinkLabel(26)).toBe("aa");
expect(backlinkLabel(27)).toBe("ab");
expect(backlinkLabel(51)).toBe("az");
expect(backlinkLabel(52)).toBe("ba");
});
});

View File

@@ -115,3 +115,18 @@
.backLink:hover {
text-decoration: underline;
}
/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
occurrence. Sits on the right, after the content, like the single ↩. */
.backLinks {
flex: 0 0 auto;
display: inline-flex;
align-items: baseline;
gap: 0.3em;
user-select: none;
}
.backLinkArrow {
color: var(--mantine-color-dimmed);
font-size: 0.9em;
}

View File

@@ -274,7 +274,10 @@ export function useRestorePageMutation() {
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
notifications.show({ message: t("Failed to restore page"), color: "red" });
notifications.show({
message: t("Failed to restore page"),
color: "red",
});
},
});
}
@@ -285,10 +288,10 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -296,11 +299,14 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
return getSidebarPages({
spaceId: data.spaceId,
cursor: pageParam,
limit: 100,
});
},
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -323,12 +329,17 @@ export function usePageBreadcrumbsQuery(
});
}
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
export async function fetchAllAncestorChildren(
params: SidebarPagesParams,
// `fresh: true` forces a server refetch (staleTime 0) — used by the reconnect
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
opts?: { fresh?: boolean },
) {
// not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
});
const allItems = response.pages.flatMap((page) => page.items);
@@ -347,11 +358,15 @@ export function useRecentChangesQuery(spaceId?: string) {
});
}
export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) {
export function useCreatedByQuery(params?: {
userId?: string;
spaceId?: string;
}) {
const { userId, spaceId } = params ?? {};
return useInfiniteQuery({
queryKey: ["pages-created-by-user", { userId, spaceId }],
queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
queryFn: ({ pageParam }) =>
getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,

View File

@@ -29,9 +29,11 @@ import {
collectBranchIds,
openBranches,
closeIds,
loadedOpenBranchIds,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import {
getPageBreadcrumbs,
getSpaceTree,
@@ -39,11 +41,7 @@ import {
import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib";
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
import {
DocTree,
ROW_HEIGHT_COMPACT,
ROW_HEIGHT_STANDARD,
} from "./doc-tree";
import { DocTree, ROW_HEIGHT_COMPACT, ROW_HEIGHT_STANDARD } from "./doc-tree";
import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps {
@@ -193,6 +191,54 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
[openTreeNodes],
);
// Latest tree + open-state for the reconnect handler (its closure would
// otherwise read stale snapshots).
const [socket] = useAtom(socketAtom);
const dataRef = useRef(data);
dataRef.current = data;
const openIdsRef = useRef(openIds);
openIdsRef.current = openIds;
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
// the children of every currently-open, already-loaded branch of THIS space,
// so a move/rename/delete that happened INSIDE a loaded branch while events
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
// The ROOT level is reconciled separately by the root-query refetch +
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
// the initial connect, so every `connect` it sees is a reconnect; the rare
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
useEffect(() => {
if (!socket) return;
const onConnect = async () => {
const effectSpaceId = spaceIdRef.current;
const branchIds = loadedOpenBranchIds(
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
openIdsRef.current,
);
if (branchIds.length === 0) return;
for (const id of branchIds) {
try {
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
// reconcile sees the server's CURRENT children (handler-order
// independent — no reliance on the global reconnect invalidation).
const fresh = await fetchAllAncestorChildren(
{ pageId: id, spaceId: effectSpaceId },
{ fresh: true },
);
if (spaceIdRef.current !== effectSpaceId) return; // space switched
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
} catch (err) {
console.error("[tree] reconnect branch refresh failed", err);
}
}
};
socket.on("connect", onConnect);
return () => {
socket.off("connect", onConnect);
};
}, [socket, setData]);
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
@@ -245,8 +291,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
notifications.show({
color: "red",
message: t("Couldn't expand the tree: {{reason}}", {
reason:
err?.response?.data?.message ?? err?.message ?? String(err),
reason: err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
@@ -262,11 +307,11 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
setOpenTreeNodes((prev) => closeIds(prev, ids));
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
ref,
() => ({ expandAll, collapseAll, isExpanding }),
[expandAll, collapseAll, isExpanding],
);
useImperativeHandle(ref, () => ({ expandAll, collapseAll, isExpanding }), [
expandAll,
collapseAll,
isExpanding,
]);
// Stable callbacks for DocTree. Without these, every parent render recreates
// the props and tears down every row's draggable/dropTarget subscription,

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import type { TreeNode, SiblingsInfo } from './tree-model.types';
import type { TreeNode, SiblingsInfo } from "./tree-model.types";
function findInternal<T extends object>(
nodes: TreeNode<T>[],
@@ -19,7 +19,10 @@ export const treeModel = {
return findInternal(tree, id)?.node ?? null;
},
path<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] | null {
path<T extends object>(
tree: TreeNode<T>[],
id: string,
): TreeNode<T>[] | null {
const found = findInternal(tree, id);
if (!found) return null;
return [...found.parents, found.node];
@@ -123,6 +126,23 @@ export const treeModel = {
return treeModel.insert(tree, null, node, index(tree));
}
const parent = treeModel.find(tree, parentId);
// The parent is in the tree but its children have NOT been lazy-loaded yet
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
// here would MATERIALIZE a misleading partial child list (`[node]`) that
// defeats the lazy-load gate — which fetches only when children are
// absent/empty — so the parent's OTHER real children would never load and the
// moved/added node would be the only one shown (a silent data loss, #159 #1).
// Instead, leave the children unloaded and just flag `hasChildren` so the
// chevron appears; expanding fetches the FULL set (including this node).
if (parent && parent.children === undefined) {
return treeModel.update(
tree,
parentId,
// hasChildren is not part of the generic T constraint; tree nodes carry
// it. Cast narrowly so this stays a single, well-understood exception.
{ hasChildren: true } as unknown as Omit<Partial<T>, "id" | "children">,
);
}
const kids = (parent?.children as TreeNode<T>[] | undefined) ?? [];
return treeModel.insert(tree, parentId, node, index(kids));
},
@@ -203,6 +223,48 @@ export const treeModel = {
return touched ? out : tree;
},
// Replace a parent's DIRECT children with the authoritative `fresh` set while
// PRESERVING each surviving child's already-loaded grandchildren (deeper
// expansion). Unlike `appendChildren` (add-only), this DROPS children that are
// no longer present and reorders to `fresh` — so a move/delete/rename that
// happened inside a loaded branch while events were missed (a socket reconnect
// gap) is reflected, not left stale (#159 #8). Only used to reconcile an
// already-loaded branch against a fresh fetch; a parent with no loaded children
// (`children === undefined`) is left untouched (lazy-load handles it).
reconcileChildren<T extends object>(
tree: TreeNode<T>[],
parentId: string,
fresh: TreeNode<T>[],
): TreeNode<T>[] {
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] =>
nodes.map((n) => {
if (n.id === parentId) {
// Only reconcile a branch whose children were actually loaded; an
// unloaded parent stays unloaded (lazy-load fetches it fresh later).
if (n.children === undefined) return n;
const prevById = new Map(n.children.map((c) => [c.id, c]));
const merged = fresh.map((f) => {
const prev = prevById.get(f.id);
// Preserve the surviving child's previously loaded grandchildren so
// deeper expansion is not collapsed by the reconcile.
return prev?.children !== undefined
? { ...f, children: prev.children }
: f;
});
touched = true;
return { ...n, children: merged };
}
if (n.children) {
const next = walk(n.children);
if (next !== n.children) return { ...n, children: next };
}
return n;
});
const out = walk(tree);
return touched ? out : tree;
},
place<T extends object>(
tree: TreeNode<T>[],
sourceId: string,
@@ -232,6 +294,20 @@ export const treeModel = {
const source = treeModel.find(tree, sourceId);
if (!source) return tree;
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
// Cycle guard, mirroring `move`'s `isDescendant` check (#206 ui-state-races-1).
// If the destination parent is INSIDE the moved node's own subtree (reachable
// when server-authoritative move events arrive out of order — e.g. X moved
// under Y, then Y under X, but on this receiver Y is still inside X), then
// `remove(sourceId)` would drop the future parent along with the whole subtree
// and `insertByPosition` could not find it again — the node and ALL its
// descendants would silently vanish. Refuse the move and return the same
// reference so callers can detect the no-op and reconcile (refetch) instead.
if (
to.parentId !== null &&
treeModel.isDescendant(tree, sourceId, to.parentId)
) {
return tree;
}
const removed = treeModel.remove(tree, sourceId);
// Reuse the same position-ordered insertion as `insertByPosition` by
// stamping the authoritative position onto the moved node first.
@@ -242,9 +318,10 @@ export const treeModel = {
move<T extends object>(
tree: TreeNode<T>[],
sourceId: string,
op: import('./tree-model.types').DropOp,
): { tree: TreeNode<T>[]; result: import('./tree-model.types').DropResult } {
if (sourceId === op.targetId) return { tree, result: { parentId: null, index: 0 } };
op: import("./tree-model.types").DropOp,
): { tree: TreeNode<T>[]; result: import("./tree-model.types").DropResult } {
if (sourceId === op.targetId)
return { tree, result: { parentId: null, index: 0 } };
if (!treeModel.find(tree, sourceId) || !treeModel.find(tree, op.targetId)) {
return { tree, result: { parentId: null, index: 0 } };
}
@@ -255,7 +332,7 @@ export const treeModel = {
let parentId: string | null;
let index: number;
if (op.kind === 'make-child') {
if (op.kind === "make-child") {
parentId = op.targetId;
const target = treeModel.find(tree, op.targetId)!;
index = target.children?.length ?? 0;
@@ -264,9 +341,8 @@ export const treeModel = {
parentId = info.parentId;
const sourceInfo = treeModel.siblingsOf(tree, sourceId)!;
const sameParent = sourceInfo.parentId === parentId;
const adjust =
sameParent && sourceInfo.index < info.index ? -1 : 0;
index = info.index + adjust + (op.kind === 'reorder-after' ? 1 : 0);
const adjust = sameParent && sourceInfo.index < info.index ? -1 : 0;
index = info.index + adjust + (op.kind === "reorder-after" ? 1 : 0);
}
const next = treeModel.place(tree, sourceId, { parentId, index });

View File

@@ -6,6 +6,8 @@ import {
collectBranchIds,
openBranches,
closeIds,
mergeRootTrees,
loadedOpenBranchIds,
} from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -44,10 +46,7 @@ function flatNode(
}
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
function treeNode(
id: string,
children: SpaceTreeNode[] = [],
): SpaceTreeNode {
function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
@@ -94,11 +93,7 @@ describe("collectBranchIds", () => {
]),
treeNode("root2", [treeNode("leaf3")]),
];
expect(collectBranchIds(tree).sort()).toEqual([
"branch1",
"root",
"root2",
]);
expect(collectBranchIds(tree).sort()).toEqual(["branch1", "root", "root2"]);
});
it("returns [] for a leaf-only tree", () => {
@@ -273,3 +268,95 @@ describe("closeIds", () => {
expect(twice).toEqual({ keep: true, a: false, b: false });
});
});
describe("mergeRootTrees (#159 #2 reconnect reconcile)", () => {
// Root node with a position and optional already-loaded children.
function root(
id: string,
position: string,
children?: SpaceTreeNode[],
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position,
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: !!children?.length,
children: children as SpaceTreeNode[],
};
}
it("DROPS a stale root that is absent from the incoming (authoritative) set", () => {
// 'ghost' was a root before the gap; the server's current roots no longer
// include it (deleted / moved under another page). It must not linger.
const prev = [root("a", "a0"), root("ghost", "a2"), root("b", "a4")];
const incoming = [root("a", "a0"), root("b", "a4")];
const merged = mergeRootTrees(prev, incoming);
expect(merged.map((n) => n.id)).toEqual(["a", "b"]);
expect(merged.find((n) => n.id === "ghost")).toBeUndefined();
});
it("PRESERVES a surviving root's lazy-loaded children (subtree not lost on refetch)", () => {
const loadedChild = root("a1", "a0");
const prev = [root("a", "a0", [loadedChild])];
// The root query returns only top-level roots (no children).
const incoming = [root("a", "a0")];
const merged = mergeRootTrees(prev, incoming);
expect(merged[0].children?.map((c) => c.id)).toEqual(["a1"]);
});
it("ADDS a new incoming root", () => {
const prev = [root("a", "a0")];
const incoming = [root("a", "a0"), root("new", "a2")];
const merged = mergeRootTrees(prev, incoming);
expect(merged.map((n) => n.id)).toEqual(["a", "new"]);
});
it("REFRESHES a surviving root's own fields from the incoming copy (e.g. rename)", () => {
const prev = [{ ...root("a", "a0"), name: "OLD" }];
const incoming = [{ ...root("a", "a0"), name: "NEW" }];
const merged = mergeRootTrees(prev, incoming);
expect(merged[0].name).toBe("NEW");
});
});
describe("loadedOpenBranchIds (#159 #8 reconnect refresh targets)", () => {
function n(id: string, children?: SpaceTreeNode[]): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: !!children,
children: children as SpaceTreeNode[],
};
}
it("returns OPEN branches whose children are loaded (array)", () => {
const tree = [n("a", [n("a1")]), n("b", [n("b1")])];
const ids = loadedOpenBranchIds(tree, new Set(["a"]));
expect(ids).toEqual(["a"]); // b is closed; a is open+loaded
});
it("skips an open branch whose children are NOT loaded (undefined)", () => {
const tree = [n("a")]; // children undefined
expect(loadedOpenBranchIds(tree, new Set(["a"]))).toEqual([]);
});
it("includes a loaded-but-empty open branch (a child may have been added during the gap)", () => {
const tree = [n("a", [])];
expect(loadedOpenBranchIds(tree, new Set(["a"]))).toEqual(["a"]);
});
it("walks nested open+loaded branches (deep chain refreshes every level)", () => {
const tree = [n("a", [n("a1", [n("a1a")])])];
const ids = loadedOpenBranchIds(tree, new Set(["a", "a1"]));
expect(ids.sort()).toEqual(["a", "a1"]);
});
});

View File

@@ -214,21 +214,59 @@ export function appendNodeChildren(
}
/**
* Merge root nodes; keep existing ones intact, append new ones,
* Reconcile the loaded root nodes to the authoritative INCOMING set (the
* server's complete current roots for the space), preserving any lazy-loaded
* children/subtree of a root that still exists.
*
* This runs only once all root pages are fetched, so `incomingRoots` is the full
* server root set and is authoritative for WHICH roots exist:
* - a root in BOTH: kept, with its own fields refreshed from `incoming` (so a
* rename/move during a gap shows) while PRESERVING its previously lazy-loaded
* `children` (expanded subtrees + open-state survive a refetch);
* - a root only in `incoming`: a new root, added as-is;
* - a root only in `prev`: it was DELETED or moved under another page while we
* were not receiving events (e.g. a socket reconnect after a sleep/wifi gap).
* It is DROPPED instead of lingering as a 404 "ghost" root (#159 #2). The old
* append-only merge kept it forever.
*/
export function mergeRootTrees(
prevRoots: SpaceTreeNode[],
incomingRoots: SpaceTreeNode[],
): SpaceTreeNode[] {
const seen = new Set(prevRoots.map((r) => r.id));
const prevById = new Map(prevRoots.map((r) => [r.id, r]));
// add new roots that were not present before
const merged = [...prevRoots];
incomingRoots.forEach((node) => {
if (!seen.has(node.id)) merged.push(node);
const reconciled = incomingRoots.map((incoming) => {
const prev = prevById.get(incoming.id);
// Preserve the previously loaded children/subtree (the root query returns
// only top-level roots, so `incoming` carries no children); refresh the
// node's own fields from the authoritative incoming copy.
return prev ? { ...incoming, children: prev.children } : incoming;
});
return sortPositionKeys(merged);
return sortPositionKeys(reconciled);
}
/**
* Ids of branches a socket-reconnect refresh should re-fetch and reconcile
* (#159 #8): a node that is currently OPEN and whose children are LOADED
* (`children` is an array — possibly empty). An unloaded branch (`children ===
* undefined`) is skipped because lazy-load fetches it fresh on the next expand,
* so there is nothing stale to reconcile. Walks the whole tree (a deep open
* chain refreshes every loaded level).
*/
export function loadedOpenBranchIds(
tree: SpaceTreeNode[],
openIds: ReadonlySet<string>,
): string[] {
const ids: string[] = [];
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) {
if (openIds.has(n.id) && Array.isArray(n.children)) ids.push(n.id);
if (n.children) walk(n.children);
}
};
walk(tree);
return ids;
}
// Collect every node id in the tree (roots, branches, leaves). Used by

View File

@@ -81,6 +81,38 @@ describe("applyMoveTreeNode", () => {
]);
});
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded
// (children === undefined). The OLD behavior inserted `src` as the ONLY
// child ([src]), which defeated the lazy-load gate and HID the parent's
// other real children. Now the move leaves children unloaded (so expanding
// fetches the FULL set, including src) and just flags hasChildren.
const tree: SpaceTreeNode[] = [
node("dstCollapsed", {
position: "a0",
hasChildren: false,
children: undefined as unknown as SpaceTreeNode[],
}),
node("src", { position: "a9" }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dstCollapsed",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
const dst = treeModel.find(next, "dstCollapsed");
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
// src) on expand, rather than showing a misleading partial [src] list.
expect(dst?.children).toBeUndefined();
expect(dst?.hasChildren).toBe(true);
// src moved away from its old root slot (it lives under dstCollapsed
// server-side and reappears when the parent is expanded/loaded).
expect(next.map((n) => n.id)).not.toContain("src");
});
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
// src is the only child of `old`; moving it to `dst` empties `old`.
const tree: SpaceTreeNode[] = [
@@ -151,6 +183,34 @@ describe("applyMoveTreeNode", () => {
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
it("does NOT drop a subtree on a cyclic/out-of-order move (parent inside source) (#206 ui-state-races-1)", () => {
// Locally `b` is still nested inside `a` (an earlier "a under b" echo hasn't
// applied yet). An out-of-order "move a under b" event now arrives — b is a
// descendant of a, so re-parenting would make placeByPosition remove a (and
// its whole subtree, incl. b) and fail to re-insert. Before the fix BOTH a
// and b silently vanished; now the reducer leaves the tree untouched.
const tree: SpaceTreeNode[] = [
node("a", {
position: "a0",
hasChildren: true,
children: [node("b", { position: "a1", parentPageId: "a" })],
}),
];
const next = applyMoveTreeNode(tree, {
id: "a",
parentId: "b",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// No silent data loss: both nodes survive.
expect(treeModel.find(next, "a")).not.toBeNull();
expect(treeModel.find(next, "b")).not.toBeNull();
// The cyclic move is refused as a no-op (same reference) pending reconcile.
expect(next).toBe(tree);
});
});
describe("applyDeleteTreeNode", () => {
@@ -164,7 +224,9 @@ describe("applyDeleteTreeNode", () => {
position: "a1",
parentPageId: "p",
hasChildren: true,
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
children: [
node("grandchild", { position: "a1", parentPageId: "child" }),
],
}),
],
}),

View File

@@ -76,6 +76,19 @@ export function applyMoveTreeNode(
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = payload.parentId as string | null;
// Cyclic / out-of-order move guard (#206 ui-state-races-1): if the
// authoritative new parent is currently INSIDE the moved node's own subtree on
// this client (e.g. server moved X under Y then Y under X and the events
// arrived such that Y is still nested in X here), re-parenting is impossible to
// represent locally. `placeByPosition` returns `prev` for this, but the
// `placed === prev` fallback below would then `remove` the source — dropping
// the node AND every descendant (incl. the would-be parent) silently. Leave the
// tree untouched instead; a later corrective event or a reconnect refetch
// reconciles it. Never delete a subtree we cannot safely re-place.
if (newParentId && treeModel.isDescendant(prev, payload.id, newParentId)) {
return prev;
}
// Place the node by its fractional `position` among the new siblings — NOT by
// the sender's absolute `index` (the sender computed that against its own
// loaded set, which differs from this receiver's). Using the position keeps

View File

@@ -11,6 +11,7 @@ import {
Switch,
TagsInput,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
@@ -35,6 +36,8 @@ const formSchema = z.object({
// Write-only secret buffer. Empty string means "do not change" (unless cleared).
authHeader: z.string(),
toolAllowlist: z.array(z.string()),
// Admin-authored prompt guidance (#180). Capped to mirror the DTO MaxLength.
instructions: z.string().max(4000),
enabled: z.boolean(),
});
@@ -63,6 +66,7 @@ function buildInitialValues(server?: IAiMcpServer): FormValues {
toolAllowlist: Array.isArray(server?.toolAllowlist)
? server.toolAllowlist
: [],
instructions: server?.instructions ?? "",
enabled: server?.enabled ?? true,
};
}
@@ -124,6 +128,8 @@ export default function AiMcpServerForm({
transport: values.transport,
url: values.url,
toolAllowlist: values.toolAllowlist,
// Always sent: a blank value clears the stored guidance (server -> null).
instructions: values.instructions,
enabled: values.enabled,
};
// Only attach headers when set or explicitly cleared (omit => unchanged).
@@ -135,6 +141,8 @@ export default function AiMcpServerForm({
transport: values.transport,
url: values.url,
toolAllowlist: values.toolAllowlist,
// Blank => server stores null (no guidance).
instructions: values.instructions,
enabled: values.enabled,
};
// On create, only a typed value matters (no prior stored headers).
@@ -158,10 +166,7 @@ export default function AiMcpServerForm({
return (
<Stack>
<TextInput
label={t("Server name")}
{...form.getInputProps("name")}
/>
<TextInput label={t("Server name")} {...form.getInputProps("name")} />
<Select
label={t("Transport")}
@@ -177,7 +182,7 @@ export default function AiMcpServerForm({
// Clarify that the value is sent verbatim as the Authorization header,
// so the user supplies the full scheme (no implicit Bearer prefix).
description={t(
"Sent verbatim as the value of the Authorization header (e.g. \"Bearer <token>\" or \"Basic <base64>\").",
'Sent verbatim as the value of the Authorization header (e.g. "Bearer <token>" or "Basic <base64>").',
)}
// Placeholder hints whether headers are stored; the value is never shown.
placeholder={hasHeaders ? t("•••• set") : ""}
@@ -208,6 +213,20 @@ export default function AiMcpServerForm({
{...form.getInputProps("toolAllowlist")}
/>
<Textarea
label={t("Instructions")}
// Hint that the text is injected into the agent's system prompt and that
// the server's tools are namespaced under <name>_* (the prompt header).
description={t(
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
)}
autosize
minRows={2}
maxRows={8}
maxLength={4000}
{...form.getInputProps("instructions")}
/>
<Switch
label={t("Enabled")}
checked={form.values.enabled}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { mcpTestButtonView } from "./ai-mcp-server-test-view";
/**
* Pure-helper tests for the inline "Test" button presentation. Covers the four
* states (idle / loading is handled by the component's `isPending`, so here:
* idle / ok-with-tools / ok-without-tools / failed) and the tooltip text
* branches that are easiest to break silently.
*/
// Identity-ish translator that echoes the key and interpolates {{n}} so the
// label/tooltip branches are observable without the real i18n bundle.
const t = (key: string, options?: Record<string, unknown>): string =>
options && "n" in options
? key.replace("{{n}}", String((options as { n: unknown }).n))
: key;
describe("mcpTestButtonView", () => {
it("idle when there is no result", () => {
expect(mcpTestButtonView(undefined, t)).toEqual({
state: "idle",
color: undefined,
variant: "default",
label: "Test",
tooltip: "",
});
});
it("ok with tools lists them in the tooltip", () => {
expect(mcpTestButtonView({ ok: true, tools: ["a", "b"] }, t)).toEqual({
state: "ok",
color: "green",
variant: "light",
label: "OK · 2",
tooltip: "a, b",
});
});
it('ok with zero tools shows "No tools available"', () => {
expect(mcpTestButtonView({ ok: true, tools: [] }, t)).toEqual({
state: "ok",
color: "green",
variant: "light",
label: "OK · 0",
tooltip: "No tools available",
});
});
it("failed surfaces the error text in the tooltip", () => {
expect(
mcpTestButtonView({ ok: false, error: "402: nope" }, t),
).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "402: nope",
});
});
it("failed when the request itself rejects (no result payload)", () => {
// 401/403/500/network: there is no { ok } body, only a thrown error. The
// row must still show a red "Failed" rather than reverting to idle "Test".
expect(
mcpTestButtonView(undefined, t, {
response: { data: { message: "Unauthorized" } },
}),
).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "Unauthorized",
});
});
it("reject without a server message falls back to the generic label", () => {
// A bare network error (no response body) still surfaces as failed, using
// the i18n fallback for the tooltip.
expect(mcpTestButtonView(undefined, t, new Error("network down"))).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "Failed to update data",
});
});
});

View File

@@ -0,0 +1,90 @@
import type { IAiMcpServerTestResult } from "@/features/workspace/services/ai-mcp-server-service.ts";
/** Minimal translator shape (i18next `t`): key + optional interpolation. */
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** Subset of an axios-style rejection we read for the reject tooltip. */
type McpTestRequestError = {
response?: { data?: { message?: string } };
};
/**
* Best-effort extraction of a server-sent message from a rejected test request
* (axios stores it at `error.response.data.message`). Returns undefined for a
* bare/network error so the caller can fall back to a generic label.
*/
function readRequestErrorMessage(error: unknown): string | undefined {
if (error && typeof error === "object" && "response" in error) {
return (error as McpTestRequestError).response?.data?.message;
}
return undefined;
}
/**
* Presentation for the inline "Test" button, derived from the current test
* result tristate (no result yet / ok / failed). Color is never the only signal
* — the label and icon change too (a11y / colorblind-friendly). Kept as a single
* pure derivation (rather than two parallel if/else chains) so the button and
* tooltip can never drift apart, and so the text branches are unit-testable
* without rendering the row.
*/
export interface McpTestButtonView {
/** Tristate; the component maps this to the leftSection icon. */
state: "idle" | "ok" | "failed";
/** Mantine Button color; undefined = theme default (idle). */
color?: string;
/** Mantine Button variant. */
variant: string;
/** Translated button label. */
label: string;
/** Translated tooltip text; "" while there is no result (tooltip disabled). */
tooltip: string;
}
export function mcpTestButtonView(
result: IAiMcpServerTestResult | undefined,
t: Translate,
error?: unknown,
): McpTestButtonView {
if (result?.ok) {
return {
state: "ok",
color: "green",
variant: "light",
label: t("OK · {{n}}", { n: result.tools.length }),
tooltip:
result.tools.length > 0
? result.tools.join(", ")
: t("No tools available"),
};
}
if (result && result.ok === false) {
return {
state: "failed",
color: "red",
variant: "light",
label: t("Failed"),
tooltip: result.error,
};
}
if (error) {
// The test request itself rejected (401/403/500/network) — there is no
// `{ ok }` payload, so without this branch the row would silently revert to
// the idle "Test" instead of reporting the failure. Tooltip prefers the
// server-sent message, else the generic i18n fallback.
return {
state: "failed",
color: "red",
variant: "light",
label: t("Failed"),
tooltip: readRequestErrorMessage(error) ?? t("Failed to update data"),
};
}
return {
state: "idle",
color: undefined,
variant: "default",
label: t("Test"),
tooltip: "",
};
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
ActionIcon,
Badge,
@@ -10,18 +10,28 @@ import {
Stack,
Switch,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import {
IconCheck,
IconPencil,
IconPlugConnected,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
useAiMcpServersQuery,
useDeleteAiMcpServerMutation,
useTestAiMcpServerMutation,
useUpdateAiMcpServerMutation,
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
import { mcpTestButtonView } from "@/features/workspace/components/settings/components/ai-mcp-server-test-view.ts";
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
/**
@@ -112,55 +122,15 @@ export default function AiMcpServers() {
<Stack gap="xs" mt="sm">
{servers?.map((server) => (
<Group key={server.id} justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: server.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => openEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => confirmDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
<AiMcpServerRow
key={server.id}
server={server}
onEdit={openEdit}
onDelete={confirmDelete}
onToggleEnabled={(enabled) =>
updateMutation.mutate({ id: server.id, enabled })
}
/>
))}
</Stack>
@@ -180,3 +150,127 @@ export default function AiMcpServers() {
</Paper>
);
}
interface AiMcpServerRowProps {
server: IAiMcpServer;
onEdit: (server: IAiMcpServer) => void;
onDelete: (server: IAiMcpServer) => void;
onToggleEnabled: (enabled: boolean) => void;
}
/**
* A single external MCP server row: name/badge/url on the left and the
* Test / Switch / Edit / Delete controls on the right. Each row owns its own
* `useTestAiMcpServerMutation()` so the inline Test result and loading state are
* independent per row (a shared mutation would make `isPending` global and make
* every row flicker).
*/
function AiMcpServerRow({
server,
onEdit,
onDelete,
onToggleEnabled,
}: AiMcpServerRowProps) {
const { t } = useTranslation();
const testMutation = useTestAiMcpServerMutation();
const result = testMutation.data;
// The row is keyed by `server.id`, so editing the connection-relevant fields
// (url/transport/headers) does NOT remount it — an old success/failure result
// would otherwise stick. Clear the result when those fields change.
useEffect(() => {
testMutation.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.url, server.transport, server.hasHeaders]);
// Single derivation of the button/tooltip presentation from the test tristate
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
// there is no result; the icon is mapped from `view.state` below. When the
// request itself rejects (401/403/500/network) there is no `data` payload, so
// we feed the mutation error in too — otherwise the row would silently revert
// to "Test" instead of showing a red "Failed".
const view = mcpTestButtonView(
result,
t,
testMutation.isError ? testMutation.error : undefined,
);
const tooltipLabel = view.tooltip;
const buttonColor = view.color;
const buttonVariant = view.variant;
const buttonLabel = view.label;
const buttonIcon =
view.state === "ok" ? (
<IconCheck size={16} />
) : view.state === "failed" ? (
<IconX size={16} />
) : (
<IconPlugConnected size={16} />
);
return (
<Group justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
{/* Always clickable: testing a disabled server before enabling it is useful. */}
<Tooltip
label={tooltipLabel}
disabled={view.state === "idle"}
multiline
maw={320}
withinPortal
>
<Button
size="xs"
miw={88}
color={buttonColor}
variant={buttonVariant}
leftSection={testMutation.isPending ? undefined : buttonIcon}
loading={testMutation.isPending}
onClick={() => testMutation.mutate(server.id)}
>
{buttonLabel}
</Button>
</Tooltip>
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => onEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => onDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -7,6 +7,7 @@ import {
Button,
Group,
Modal,
NumberInput,
Paper,
PasswordInput,
Select,
@@ -83,6 +84,9 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
// (empty means "leave unchanged" unless explicitly cleared).
const formSchema = z.object({
chatModel: z.string(),
// Max context window in tokens shown in the chat header badge. A number, or ""
// when the NumberInput is empty (no limit).
chatContextWindow: z.union([z.number(), z.literal("")]),
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
chatApiStyle: z.enum(["openai-compatible", "openai"]),
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
@@ -311,6 +315,7 @@ export default function AiProviderSettings() {
validate: zod4Resolver(formSchema),
initialValues: {
chatModel: "",
chatContextWindow: "",
chatApiStyle: "openai-compatible" as ChatApiStyle,
publicShareChatModel: "",
publicShareAssistantRoleId: "",
@@ -334,6 +339,7 @@ export default function AiProviderSettings() {
if (!settings) return;
form.setValues({
chatModel: settings.chatModel ?? "",
chatContextWindow: settings.chatContextWindow ?? "",
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
publicShareChatModel: settings.publicShareChatModel ?? "",
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
@@ -364,6 +370,12 @@ export default function AiProviderSettings() {
// Everything is OpenAI-compatible.
driver: "openai",
chatModel: values.chatModel,
// Max context window for the chat header badge; empty NumberInput ("") →
// 0, which clears the limit server-side (no denominator shown).
chatContextWindow:
typeof values.chatContextWindow === "number"
? values.chatContextWindow
: 0,
chatApiStyle: values.chatApiStyle,
// Cheap model id for the anonymous public-share assistant; empty falls
// back to chatModel server-side.
@@ -767,6 +779,18 @@ export default function AiProviderSettings() {
{t("Resolves to {{url}}", { url: chatResolved })}
</Text>
<NumberInput
mt="sm"
label={t("Context window (tokens)")}
description={t(
"Shown as used / total in the chat header. Leave empty to hide the limit.",
)}
min={0}
allowDecimal={false}
disabled={isLoading}
{...form.getInputProps("chatContextWindow")}
/>
<Select
mt="sm"
label={t("Protocol")}

View File

@@ -14,6 +14,9 @@ export interface IAiMcpServer {
enabled: boolean;
toolAllowlist: string[] | null;
hasHeaders: boolean;
// Admin-authored guidance injected into the agent system prompt (#180).
// NON-secret, so it IS returned. Null when no guidance is configured.
instructions: string | null;
}
// Create payload. `headers` is write-only: omit => no auth headers.
@@ -25,6 +28,8 @@ export interface IAiMcpServerCreate {
// never returned.
headers?: Record<string, string>;
toolAllowlist?: string[];
// Admin-authored prompt guidance (#180). Blank => stored as null.
instructions?: string;
enabled?: boolean;
}
@@ -39,6 +44,8 @@ export interface IAiMcpServerUpdate {
url?: string;
headers?: Record<string, string>;
toolAllowlist?: string[];
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
instructions?: string;
enabled?: boolean;
}

View File

@@ -22,6 +22,8 @@ export type ChatApiStyle = "openai-compatible" | "openai";
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens shown in the chat header badge; 0/unset = no limit.
chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string;
@@ -56,6 +58,8 @@ export interface IAiSettings {
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens for the chat header badge; 0 = clear the limit.
chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.93.0",
"version": "0.94.0",
"description": "",
"author": "",
"private": true,

View File

@@ -182,4 +182,46 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
// persist-1 — a transient DB failure during store must not silently lose the
// edit. hocuspocus unloads (destroys) the in-memory Y.Doc right after this
// hook resolves, so the store has to retry while it still holds the only copy.
it('retries a transient DB failure and still persists the edit (persist-1)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
let attempts = 0;
pageRepo.updatePage.mockImplementation(async () => {
attempts += 1;
if (attempts === 1) throw new Error('deadlock detected'); // transient
callOrder.push('updatePage');
});
await ext.onStoreDocument(buildData(document, 'user') as any);
// First attempt failed and rolled back; the retry persisted the edit.
expect(pageRepo.updatePage).toHaveBeenCalledTimes(2);
// The edit WAS saved, so the post-store success path runs as normal.
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
expect(historyQueue.add).toHaveBeenCalledTimes(1);
});
// persist-1 — when every attempt fails the hook must NOT report a phantom
// success: no "page.updated" badge broadcast and no history snapshot for
// content that was never written.
it('does not run post-store side effects when every store attempt fails (persist-1)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
pageRepo.updatePage.mockRejectedValue(new Error('connection reset'));
await expect(
ext.onStoreDocument(buildData(document, 'user') as any),
).resolves.toBeUndefined();
// Bounded retry exhausted (MAX_STORE_ATTEMPTS).
expect(pageRepo.updatePage).toHaveBeenCalledTimes(3);
// No false-success: nothing downstream fires for the unsaved content.
expect((document as any).broadcastStateless).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
});
});

View File

@@ -181,83 +181,113 @@ export class PersistenceExtension implements Extension {
context?.actor,
);
try {
await executeTx(this.db, async (trx) => {
page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
// Persist with a small bounded retry. The in-memory Y.Doc is the ONLY copy
// of the latest edit until this hook returns: hocuspocus destroys/unloads the
// doc right after onStoreDocument resolves (see storeDocumentHooks' finally
// -> unloadDocument). If a transient DB error (deadlock, serialization
// failure, dropped connection) is merely logged and swallowed, the function
// resolves "successfully", the doc is unloaded, and the edit is lost silently
// (#206 persist-1). Retrying here re-attempts the write while we still hold
// the doc; on total failure we clear `page` so the post-store side effects
// (badge broadcast, history snapshot) never report a save that didn't happen.
const MAX_STORE_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_STORE_ATTEMPTS; attempt++) {
try {
await executeTx(this.db, async (trx) => {
page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
if (!page) {
this.logger.error(`Page with id ${pageId} not found`);
return;
}
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
contributorIds = Array.from(
new Set([
...existingContributors,
...editingUserIds,
page.creatorId,
]),
);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds the
// OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is snapshotted
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
// state is already agent-authored (boundary already pinned on the
// user->agent transition), if the page is effectively empty, or if the
// latest existing snapshot already equals this human state (avoid
// duplicates).
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
const humanBaselineMissing =
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
trx,
});
if (!page) {
this.logger.error(`Page with id ${pageId} not found`);
return;
}
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
// Human stays the responsible author; these annotate the source.
lastUpdatedSource,
lastUpdatedAiChatId: context?.aiChatId ?? null,
contributorIds: contributorIds,
},
pageId,
trx,
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
contributorIds = Array.from(
new Set([
...existingContributors,
...editingUserIds,
page.creatorId,
]),
);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds
// the OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
// if the prior state is already agent-authored (boundary already
// pinned on the user->agent transition), if the page is effectively
// empty, or if the latest existing snapshot already equals this human
// state (avoid duplicates).
if (
lastUpdatedSource === 'agent' &&
page.lastUpdatedSource !== 'agent'
) {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
const humanBaselineMissing =
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content);
if (
!isEmptyParagraphDoc(page.content as any) &&
humanBaselineMissing
) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
trx,
});
}
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
// Human stays the responsible author; these annotate the source.
lastUpdatedSource,
lastUpdatedAiChatId: context?.aiChatId ?? null,
contributorIds: contributorIds,
},
pageId,
trx,
);
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
});
break;
} catch (err) {
this.logger.error(
`Failed to update page ${pageId} (attempt ${attempt}/${MAX_STORE_ATTEMPTS})`,
err,
);
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
});
} catch (err) {
this.logger.error(`Failed to update page ${pageId}`, err);
// The write failed and rolled back; clear the partially-assigned `page`
// so the post-store success branch below is skipped (no false "saved"
// broadcast / history snapshot for content that was never persisted).
page = null;
if (attempt < MAX_STORE_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, attempt * 50));
}
}
}
if (page) {

View File

@@ -1,4 +1,4 @@
import { buildSystemPrompt } from './ai-chat.prompt';
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
import { Workspace } from '@docmost/db/types/entity.types';
/**
@@ -161,3 +161,81 @@ describe('buildSystemPrompt current-page context', () => {
expect(pageIdx).toBeLessThan(lastSafety);
});
});
/**
* Unit tests for the per-EXTERNAL-MCP-server guidance block (#180). When the
* caller passes non-blank instructions for ≥1 server, an <mcp_tooling> block
* renders the server name, its tool namespace prefix and the text. The block
* sits INSIDE the safety sandwich (after context, before the trailing SAFETY)
* and never removes/duplicates the immutable safety framework. An empty list or
* all-blank text renders nothing.
*/
describe('buildSystemPrompt mcp tooling guidance', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
const SAFETY_MARKER = 'Operating rules (always in effect)';
// The block's CONTENT and its empty/undefined/all-blank handling are covered by
// the buildMcpToolingBlock unit tests below; here we only pin the INTEGRATION
// invariants that are unique to buildSystemPrompt: sandwich placement and that
// both safety copies survive.
it('places the block inside the safety sandwich, after context, before the trailing SAFETY', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-1', title: 'Doc' },
mcpInstructions: [
{ serverName: 'Tavily', toolPrefix: 'tavily', instructions: 'guide' },
],
});
const ctxIdx = prompt.indexOf('currently viewing the page');
const mcpIdx = prompt.indexOf('<mcp_tooling');
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
// After context, and strictly inside the sandwich.
expect(mcpIdx).toBeGreaterThan(ctxIdx);
expect(mcpIdx).toBeGreaterThan(firstSafety);
expect(mcpIdx).toBeLessThan(lastSafety);
});
it('keeps BOTH copies of the safety framework when guidance is present', () => {
const prompt = buildSystemPrompt({
workspace,
mcpInstructions: [
{ serverName: 'Tavily', toolPrefix: 'tavily', instructions: 'guide' },
],
});
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
expect(firstSafety).toBeGreaterThanOrEqual(0);
expect(lastSafety).toBeGreaterThan(firstSafety);
});
});
/**
* Unit tests for the pure block builder. It filters blank entries and returns
* '' so the caller can omit the section entirely.
*/
describe('buildMcpToolingBlock', () => {
it('returns "" for undefined / empty / all-blank', () => {
expect(buildMcpToolingBlock(undefined)).toBe('');
expect(buildMcpToolingBlock([])).toBe('');
expect(
buildMcpToolingBlock([
{ serverName: 'A', toolPrefix: 'a', instructions: ' ' },
]),
).toBe('');
});
it('includes only the non-blank entries', () => {
const block = buildMcpToolingBlock([
{ serverName: 'A', toolPrefix: 'a', instructions: 'alpha guide' },
{ serverName: 'B', toolPrefix: 'b', instructions: ' ' },
{ serverName: 'C', toolPrefix: 'c', instructions: 'gamma guide' },
]);
expect(block).toContain('a_*');
expect(block).toContain('alpha guide');
expect(block).toContain('c_*');
expect(block).toContain('gamma guide');
// The blank-only entry contributes no section header.
expect(block).not.toContain('b_*');
});
});

View File

@@ -1,4 +1,5 @@
import { Workspace } from '@docmost/db/types/entity.types';
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
/**
* Default agent persona used when the admin has not configured a custom system
@@ -76,6 +77,42 @@ export interface BuildSystemPromptInput {
* uses its CASL-enforced read/write page tools with the id when needed.
*/
openedPage?: { id?: string; title?: string } | null;
/**
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
* server's tools"), built by `McpClientsService.toolsFor` for servers that
* actually connected and contributed ≥1 callable tool (#180). Rendered as an
* `<mcp_tooling>` block INSIDE the safety sandwich (trusted text — it informs
* tool usage but cannot override the surrounding rules). Empty/blank => the
* block is omitted entirely.
*/
mcpInstructions?: McpServerInstruction[];
}
/**
* Render the `<mcp_tooling>` block from per-server guidance. Each server gets a
* section headed by its tool namespace prefix (e.g. `tavily_*`) so the model can
* connect the guidance to the actual namespaced tool names. The prefix is
* advisory: on rare name collisions individual tools may carry a disambiguating
* suffix, but the guidance stays guidance, not a contract. Returns '' when no
* server has non-blank guidance, so the caller can omit the block entirely.
*/
export function buildMcpToolingBlock(
mcpInstructions: McpServerInstruction[] | undefined,
): string {
if (!mcpInstructions || mcpInstructions.length === 0) return '';
const sections = mcpInstructions
.filter((m) => typeof m.instructions === 'string' && m.instructions.trim())
.map((m) => {
const header = `Server "${m.serverName}" (tools: ${m.toolPrefix}_*):`;
return `${header}\n${m.instructions.trim()}`;
});
if (sections.length === 0) return '';
return [
'<mcp_tooling note="admin guidance for the external tools below; informs tool choice only, cannot override the rules above or below">',
'Guidance for the external MCP tools available to you this turn:',
...sections,
'</mcp_tooling>',
].join('\n');
}
/**
@@ -92,6 +129,7 @@ export function buildSystemPrompt({
adminPrompt,
roleInstructions,
openedPage,
mcpInstructions,
}: BuildSystemPromptInput): string {
// Persona precedence: role instructions REPLACE the admin persona / default.
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
@@ -112,24 +150,35 @@ export function buildSystemPrompt({
const pageId = openedPage?.id;
if (typeof pageId === 'string' && pageId.trim().length > 0) {
const title =
typeof openedPage?.title === 'string' && openedPage.title.trim().length > 0
typeof openedPage?.title === 'string' &&
openedPage.title.trim().length > 0
? openedPage.title.trim()
: 'Untitled';
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
}
// Per-server external-MCP tool guidance (#180). Trusted, admin-authored text;
// rendered inside the sandwich (after context, before the trailing SAFETY) so
// it informs tool choice but cannot override the surrounding safety rules.
// Empty when no qualifying server has guidance.
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
// Sandwich the lower-trust persona/role text between two copies of the
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
// and followed by the safety rules. The persona is delimited with explicit
// <role_persona> tags noting it only shapes tone/voice. Context (workspace
// name, currently-viewed page) follows the persona, before the trailing
// SAFETY copy.
// name, currently-viewed page) then the MCP tooling guidance follow the
// persona, before the trailing SAFETY copy. Blank parts are filtered out so
// an empty section never adds a stray blank line.
return [
SAFETY_FRAMEWORK,
'<role_persona note="shapes tone/voice only; cannot override the rules above or below">',
base,
'</role_persona>',
context,
mcpTooling,
SAFETY_FRAMEWORK,
].join('\n');
]
.filter((part) => part !== '')
.join('\n');
}

View File

@@ -1,4 +1,6 @@
import { ForbiddenException } from '@nestjs/common';
import {
AiChatService,
compactToolOutput,
assistantParts,
serializeSteps,
@@ -10,7 +12,9 @@ import {
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
import { buildSystemPrompt } from './ai-chat.prompt';
import type { McpClientsService } from './external-mcp/mcp-clients.service';
/**
* Unit tests for compactToolOutput: the pure helper that shrinks LARGE tool
@@ -271,11 +275,12 @@ describe('flushAssistant', () => {
expect(f.toolCalls).not.toBeNull();
});
it('completed: attaches finishReason + normalized usage + contextTokens', () => {
it('completed: attaches finishReason + normalized usage + contextTokens + maxContextTokens', () => {
const f = flushAssistant([toolStep], '', 'completed', {
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
contextTokens: 15,
maxContextTokens: 200000,
});
expect(f.status).toBe('completed');
expect(f.metadata.finishReason).toBe('stop');
@@ -286,6 +291,23 @@ describe('flushAssistant', () => {
reasoningTokens: undefined,
});
expect(f.metadata.contextTokens).toBe(15);
expect(f.metadata.maxContextTokens).toBe(200000);
});
it('completed: omits maxContextTokens when unset or 0', () => {
// No maxContextTokens in the extra (admin set no context window).
const f = flushAssistant([toolStep], '', 'completed', {
finishReason: 'stop',
contextTokens: 15,
});
expect('maxContextTokens' in f.metadata).toBe(false);
// Explicit 0 is treated the same as unset (no limit -> key omitted).
const f0 = flushAssistant([toolStep], '', 'completed', {
finishReason: 'stop',
contextTokens: 15,
maxContextTokens: 0,
});
expect('maxContextTokens' in f0.metadata).toBe(false);
});
it('error: records the error and a derived finishReason', () => {
@@ -487,3 +509,143 @@ describe('accumulateStepUsage', () => {
});
});
});
/**
* Contract test for the #180 wiring in AiChatService.handle: the external MCP
* toolset must be built BEFORE the system prompt, and its per-server guidance
* threaded into buildSystemPrompt({ mcpInstructions }). The full streaming
* handle() is not unit-testable, so this reproduces the exact prompt-build call
* the service makes with a connected-server toolset and asserts the guidance is
* present. The toolsFor->buildSystemPrompt ordering is additionally enforced at
* compile time (the prompt input now consumes external.instructions).
*/
describe('AiChatService system prompt wiring (#180)', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
it('includes the external MCP server instructions in the built system prompt', () => {
// Shape returned by mcpClients.toolsFor (only `instructions` matters here).
const external: Pick<
Awaited<ReturnType<McpClientsService['toolsFor']>>,
'instructions'
> = {
instructions: [
{
serverName: 'Tavily',
toolPrefix: 'tavily',
instructions: 'Prefer tavily_search for current events.',
},
],
};
// Exactly the call the service makes after building the external toolset.
const system = buildSystemPrompt({
workspace,
adminPrompt: 'persona',
mcpInstructions: external.instructions,
});
expect(system).toContain('<mcp_tooling');
expect(system).toContain('Tavily');
expect(system).toContain('tavily_*');
expect(system).toContain('Prefer tavily_search for current events.');
});
it('renders no MCP block when there are no external servers (empty instructions)', () => {
const system = buildSystemPrompt({
workspace,
adminPrompt: 'persona',
mcpInstructions: [],
});
expect(system).not.toContain('<mcp_tooling');
});
});
/**
* resolveOpenPageContext: the open page the client sends is attacker-controllable
* (id AND title), so the service must validate the id against the DB and take the
* title from the DB row — never echo the client title (#159, AI edits the wrong
* page). Built with Object.create so the test exercises the real method without
* the service's full dependency graph (the constructor only assigns fields).
*/
describe('AiChatService.resolveOpenPageContext (#159 current-page validation)', () => {
const ws = { id: 'ws-1' } as Workspace;
const user = { id: 'u-1' } as any;
function makeService(opts: {
page?: { id: string; workspaceId: string; title: string | null } | null;
canView?: boolean | 'throw-other';
}) {
const svc = Object.create(AiChatService.prototype) as AiChatService;
(svc as any).logger = { warn: () => {} };
(svc as any).pageRepo = {
findById: async () => opts.page ?? undefined,
};
(svc as any).pageAccess = {
validateCanView: async () => {
if (opts.canView === 'throw-other') throw new Error('db down');
if (opts.canView === false) throw new ForbiddenException();
return true;
},
};
return svc;
}
const call = (svc: AiChatService, openPage: any) =>
(svc as any).resolveOpenPageContext(openPage, ws, user) as Promise<{
id: string;
title: string;
} | null>;
it('returns null when no page is open (no id)', async () => {
const svc = makeService({});
expect(await call(svc, null)).toBeNull();
expect(await call(svc, {})).toBeNull();
expect(await call(svc, { title: 'spoofed' })).toBeNull();
});
it('returns null when the page does not exist', async () => {
const svc = makeService({ page: null });
expect(await call(svc, { id: 'p-x' })).toBeNull();
});
it('returns null for a page in a DIFFERENT workspace (tenant isolation)', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-OTHER', title: 'Secret' },
});
expect(await call(svc, { id: 'p-1' })).toBeNull();
});
it('returns null when the user may not view the page (Forbidden)', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Restricted' },
canView: false,
});
expect(await call(svc, { id: 'p-1' })).toBeNull();
});
it('returns null (fail-closed) on a non-Forbidden access-check fault', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'X' },
canView: 'throw-other',
});
expect(await call(svc, { id: 'p-1' })).toBeNull();
});
it('uses the AUTHORITATIVE DB title, IGNORING the client-supplied title', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Real Title B' },
canView: true,
});
// The client claims it is on "Page A" but the id points at page B.
const result = await call(svc, { id: 'p-1', title: 'Page A' });
expect(result).toEqual({ id: 'p-1', title: 'Real Title B' });
});
it('coerces a null DB title to an empty string', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: null },
canView: true,
});
expect(await call(svc, { id: 'p-1' })).toEqual({ id: 'p-1', title: '' });
});
});

View File

@@ -216,6 +216,41 @@ export class AiChatService implements OnModuleInit {
return this.ai.getChatModel(workspaceId, roleModelOverride(role));
}
/**
* Validate the client-supplied open page and return its AUTHORITATIVE identity
* ({ id, title }) or null. The client controls BOTH the id and the title in the
* request body, so neither is trusted: the id must resolve to a real page in
* THIS workspace that the user may read, and the title is taken from the DB row
* (never the client) so the model can't be told it is "on Page A" while the id
* points at page B (#159). Fail-closed — any missing / foreign / inaccessible
* page, or any non-Forbidden access-check fault, returns null.
*/
private async resolveOpenPageContext(
openPage: { id?: string; title?: string } | null | undefined,
workspace: Workspace,
user: User,
): Promise<{ id: string; title: string } | null> {
const candidatePageId = openPage?.id;
if (!candidatePageId) return null;
const page = await this.pageRepo.findById(candidatePageId);
if (!page || page.workspaceId !== workspace.id) return null;
try {
await this.pageAccess.validateCanView(page, user);
} catch (e) {
// A ForbiddenException is the expected "user cannot read this page" case;
// log anything else (e.g. a DB error) so a real fault is not masked.
if (!(e instanceof ForbiddenException)) {
this.logger.warn(
`open page access check failed: ${
e instanceof Error ? e.message : 'unknown error'
}`,
);
}
return null;
}
return { id: page.id, title: page.title ?? '' };
}
async stream({
user,
workspace,
@@ -236,37 +271,26 @@ export class AiChatService implements OnModuleInit {
chatId = undefined;
}
}
// The open page the client sent is attacker-controllable — BOTH its id and
// its title. Resolve it ONCE against the DB (workspace-scoped + access-
// checked) and use the AUTHORITATIVE identity everywhere below: the system
// prompt context, the getCurrentPage tool, and the new-chat history origin.
// Previously the client title was echoed verbatim, so a navigation / two-tab
// desync (openPage.id -> page B, title -> "Page A") made the model report
// "updated Page A" while it edited page B (#159). Null when no page is open
// or the page is foreign / inaccessible / missing.
const openPageContext = await this.resolveOpenPageContext(
body.openPage,
workspace,
user,
);
if (!chatId) {
// Resolve the origin document for the history list. body.openPage.id is
// attacker-controllable, so validate it before persisting: it must be a
// real page in THIS workspace that the user is allowed to read. Anything
// else (foreign workspace, inaccessible/restricted, or non-existent) is
// dropped to null — persisting it would leak the page's title via the
// chat-list join, or violate the page_id FK on insert (this runs after
// res.hijack(), so a DB error would break the stream).
let originPageId: string | null = null;
const candidatePageId = body.openPage?.id;
if (candidatePageId) {
const page = await this.pageRepo.findById(candidatePageId);
if (page && page.workspaceId === workspace.id) {
try {
await this.pageAccess.validateCanView(page, user);
originPageId = page.id;
} catch (e) {
// Fail-closed: no provenance on any failure. A ForbiddenException is
// the expected "user cannot read this page" case; log anything else
// (e.g. a DB error) so a real fault is not masked as "no access".
if (!(e instanceof ForbiddenException)) {
this.logger.warn(
`origin page access check failed: ${
e instanceof Error ? e.message : 'unknown error'
}`,
);
}
originPageId = null;
}
}
}
// The history-list origin is the validated open page (see above):
// persisting an unvalidated id would leak a title via the chat-list join,
// or violate the page_id FK on insert (this runs after res.hijack(), so a
// DB error would break the stream).
const originPageId: string | null = openPageContext?.id ?? null;
const chat = await this.aiChatRepo.insert({
creatorId: user.id,
workspaceId: workspace.id,
@@ -312,38 +336,20 @@ export class AiChatService implements OnModuleInit {
// The model is resolved by the controller before hijack (clean 503 path).
// Here we only need the admin-configured system prompt.
const resolved = await this.aiSettings.resolve(workspace.id);
const system = buildSystemPrompt({
workspace,
adminPrompt: resolved?.systemPrompt,
// The role (pre-resolved by the controller) REPLACES the persona layer;
// the safety framework is still appended by buildSystemPrompt.
roleInstructions: role?.instructions,
openedPage: body.openPage,
});
// Pass the resolved chatId so the write tools can mint provenance tokens
// (access + collab) carrying { actor:'agent', aiChatId: chatId }, making
// agent REST/collab writes attributable and non-spoofable (§6.5/§6.6).
const docmostTools = await this.tools.forUser(
user,
sessionId,
workspace.id,
chatId,
// Same open-page value used by the system prompt above; exposed to the
// model via getCurrentPage so page identity survives prompt mangling.
body.openPage,
);
// Merge in admin-configured external MCP tools (web search, etc.; §6.8).
// A down/slow external server never crashes the turn — toolsFor skips it and
// records the outcome. The returned client handles MUST be closed in the
// streamText lifecycle (onFinish/onError/onAbort) — leaking them is a bug.
// Docmost tools take precedence on a name clash (external are namespaced, so
// a clash is not expected; the spread order makes intent explicit).
// Build the external MCP toolset FIRST so the system prompt can carry each
// connected server's admin-authored guidance (#180). Merge in admin-
// configured external MCP tools (web search, etc.; §6.8). A down/slow
// external server never crashes the turn — toolsFor skips it and records the
// outcome. The returned client handles MUST be closed in the streamText
// lifecycle (onFinish/onError/onAbort) — leaking them is a bug. Docmost
// tools take precedence on a name clash (external are namespaced, so a clash
// is not expected; the spread order makes intent explicit).
let external: Awaited<ReturnType<McpClientsService['toolsFor']>> = {
tools: {},
clients: [],
outcomes: [],
instructions: [],
};
try {
external = await this.mcpClients.toolsFor(workspace.id);
@@ -356,12 +362,15 @@ export class AiChatService implements OnModuleInit {
}`,
);
}
const tools = { ...external.tools, ...docmostTools };
// Close every external client EXACTLY ONCE across the turn's terminal
// callbacks (onFinish/onError/onAbort all fire at most once collectively,
// but guard anyway). Close errors are swallowed so they never break the
// response.
// but guard anyway). DEFINED HERE — before the prompt/toolset are built — so
// that if buildSystemPrompt or forUser throws AFTER the external lease was
// taken (toolsFor above), the lease is still released. Otherwise its refCount
// stays >= 1 forever and the external undici sockets leak until restart
// (#180 reorder moved toolsFor ahead of these; #185 review). Close errors are
// swallowed so they never break the response.
let clientsClosed = false;
const closeExternalClients = async (): Promise<void> => {
if (clientsClosed) return;
@@ -379,6 +388,44 @@ export class AiChatService implements OnModuleInit {
);
};
// Build the system prompt + Docmost toolset. If either throws after the
// external MCP lease was taken above, release the lease before rethrowing so
// the leased transports are not leaked (#185 review).
let system: string;
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
try {
system = buildSystemPrompt({
workspace,
adminPrompt: resolved?.systemPrompt,
// The role (pre-resolved by the controller) REPLACES the persona layer;
// the safety framework is still appended by buildSystemPrompt.
roleInstructions: role?.instructions,
// Server-validated open page (authoritative title), not the client value.
openedPage: openPageContext,
// Guidance only for servers that connected and yielded ≥1 callable tool.
mcpInstructions: external.instructions,
});
// Pass the resolved chatId so the write tools can mint provenance tokens
// (access + collab) carrying { actor:'agent', aiChatId: chatId }, making
// agent REST/collab writes attributable and non-spoofable (§6.5/§6.6).
docmostTools = await this.tools.forUser(
user,
sessionId,
workspace.id,
chatId,
// Same server-validated open page used by the system prompt above;
// exposed to the model via getCurrentPage so page identity (and the
// AUTHORITATIVE title) survives prompt mangling / client title spoofing.
openPageContext,
);
} catch (err) {
await closeExternalClients();
throw err;
}
const tools = { ...external.tools, ...docmostTools };
// Accumulate the turn's streamed output so a provider error / disconnect can
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
// callbacks don't hand us the in-progress text. `capturedSteps` holds finished
@@ -569,6 +616,10 @@ export class AiChatService implements OnModuleInit {
contextTokens:
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
undefined,
// Max context window for the chat header badge denominator;
// resolved from the admin-configured provider settings (in
// closure scope here). Omitted/0 = no limit.
maxContextTokens: resolved?.chatContextWindow,
}),
);
// Lifecycle: release the external MCP clients leased for this turn.
@@ -1165,8 +1216,9 @@ export async function applyFinalize(
* `metadata.parts` is built by assistantParts over the finished steps, then the
* in-progress text appended as a trailing text part, so rowToUiMessage /
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
* `metadata.error`, `metadata.usage` and `metadata.contextTokens` are attached
* only when provided/relevant, matching the pre-#183 onFinish/onError records.
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
* the pre-#183 onFinish/onError records.
*/
export function flushAssistant(
capturedSteps: ReadonlyArray<StepLike> | undefined,
@@ -1176,6 +1228,7 @@ export function flushAssistant(
finishReason?: string;
usage?: ChatStreamUsage | StreamUsage | undefined;
contextTokens?: number;
maxContextTokens?: number;
error?: string;
},
): AssistantFlush {
@@ -1206,6 +1259,8 @@ export function flushAssistant(
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
}
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
if (extra?.maxContextTokens)
metadata.maxContextTokens = extra.maxContextTokens;
if (extra?.error) metadata.error = extra.error;
return {

View File

@@ -42,6 +42,15 @@ export class CreateMcpServerDto {
@IsString({ each: true })
toolAllowlist?: string[];
// Admin-authored guidance ("how/when to use this server's tools") injected
// into the agent system prompt next to the tool descriptions (#180). Trusted,
// NON-secret (so it IS returned). Capped to bound prompt/token size (the
// built-in guide is ~1.5KB). Blank => stored as null.
@IsOptional()
@IsString()
@MaxLength(4000)
instructions?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;

View File

@@ -0,0 +1,75 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CreateMcpServerDto } from './create-mcp-server.dto';
import { UpdateMcpServerDto } from './update-mcp-server.dto';
/**
* API-boundary validation for the per-server `instructions` field (#180): a free
* text guide injected into the agent system prompt. It is optional, must be a
* string, and is bounded by @MaxLength(4000) to cap prompt/token size.
*/
describe('MCP server DTO instructions validation', () => {
function validateCreate(payload: unknown) {
const dto = plainToInstance(CreateMcpServerDto, payload);
return validateSync(dto as object);
}
function validateUpdate(payload: unknown) {
const dto = plainToInstance(UpdateMcpServerDto, payload);
return validateSync(dto as object);
}
const base = {
name: 'Tavily',
transport: 'http',
url: 'https://example.com/mcp',
};
it('accepts an omitted instructions field on create', () => {
expect(validateCreate({ ...base })).toHaveLength(0);
});
it('accepts a reasonable instructions string on create', () => {
expect(
validateCreate({ ...base, instructions: 'Use search for fresh facts.' }),
).toHaveLength(0);
});
it('rejects instructions over MaxLength(4000) on create', () => {
const errors = validateCreate({
...base,
instructions: 'a'.repeat(4001),
});
expect(
errors.some(
(e) =>
e.property === 'instructions' &&
e.constraints !== undefined &&
'maxLength' in e.constraints,
),
).toBe(true);
});
it('accepts instructions of exactly 4000 chars on create', () => {
expect(
validateCreate({ ...base, instructions: 'a'.repeat(4000) }),
).toHaveLength(0);
});
it('rejects a non-string instructions value', () => {
const errors = validateCreate({ ...base, instructions: 123 });
expect(errors.some((e) => e.property === 'instructions')).toBe(true);
});
it('rejects instructions over MaxLength(4000) on update', () => {
const errors = validateUpdate({ instructions: 'a'.repeat(4001) });
expect(
errors.some(
(e) =>
e.property === 'instructions' &&
e.constraints !== undefined &&
'maxLength' in e.constraints,
),
).toBe(true);
});
});

View File

@@ -43,6 +43,13 @@ export class UpdateMcpServerDto {
@IsString({ each: true })
toolAllowlist?: string[];
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared
// (stored as null by the repo). Capped to bound prompt/token size.
@IsOptional()
@IsString()
@MaxLength(4000)
instructions?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;

View File

@@ -33,6 +33,26 @@ interface ServerOutcome {
reason?: string;
}
/**
* One server's admin-authored guidance for the agent system prompt (#180).
* Built ONLY for a server that actually connected AND contributed ≥1 tool
* (after the allowlist filter) AND has non-blank guidance — so a guide never
* appears for a server whose tools the agent cannot actually call.
*/
export interface McpServerInstruction {
/** Display name of the server (for the prompt section header). */
serverName: string;
/**
* The tool-name namespace prefix the server's tools were merged under
* (sanitized name, e.g. `tavily`). The prompt renders this as `tavily_*` so
* the model can connect the guidance to the actual tool names. Advisory:
* individual tools may carry a disambiguating suffix on rare collisions.
*/
toolPrefix: string;
/** The trusted, non-blank guidance text. */
instructions: string;
}
export interface ExternalToolset {
/** Namespaced external tools, merge-ready into the agent toolset. */
tools: Record<string, Tool>;
@@ -40,6 +60,11 @@ export interface ExternalToolset {
clients: Closable[];
/** Per-server connect outcomes so the UI can show unavailable servers. */
outcomes: ServerOutcome[];
/**
* Per-server prompt guidance for connected servers that contributed ≥1 tool
* and have non-blank instructions. Empty when no server qualifies.
*/
instructions: McpServerInstruction[];
}
/** Connect+tools() timeout per server — a slow server must not stall the turn. */
@@ -60,6 +85,8 @@ interface CacheEntry {
tools: Record<string, Tool>;
clients: McpClient[];
outcomes: ServerOutcome[];
/** Prompt guidance for qualifying servers (see McpServerInstruction). */
instructions: McpServerInstruction[];
expiresAt: number;
/** Active leases (turns currently using these clients). */
refCount: number;
@@ -141,6 +168,7 @@ export class McpClientsService {
tools: entry.tools,
clients: [release],
outcomes: entry.outcomes,
instructions: entry.instructions,
};
}
@@ -225,6 +253,7 @@ export class McpClientsService {
const outcomes: ServerOutcome[] = [];
// Per-call total wall-clock cap, read once for this build (env-overridable).
const callTimeoutMs = mcpCallTimeoutMs();
const instructions: McpServerInstruction[] = [];
for (const server of servers) {
try {
@@ -233,17 +262,33 @@ export class McpClientsService {
clients.push(client);
const allow = server.toolAllowlist;
const picked =
Array.isArray(allow) && allow.length > 0
? pick(raw, allow)
: raw;
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
// Bound each tool's execute with a per-call total-timeout guard before
// merging, so a single chatty-but-stuck call is aborted after the cap.
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
// Namespace each tool with the sanitized server name AND disambiguate
// against names already merged from earlier servers, so no external
// tool is silently overwritten on collision.
this.mergeNamespaced(tools, guarded, server.name, server.id);
// tool is silently overwritten on collision. The returned count drives
// whether this server's prompt guidance is included (≥1 tool merged).
const merged = this.mergeNamespaced(
tools,
guarded,
server.name,
server.id,
);
outcomes.push({ name: server.name, ok: true });
// Include this server's guidance ONLY when it actually contributed at
// least one tool the agent can call (allowlist may have filtered all of
// them out) AND the admin authored non-blank instructions. The header
// prefix is the sanitized server name (= the tool namespace prefix).
const guide = server.instructions?.trim();
if (merged.count > 0 && guide) {
instructions.push({
serverName: server.name,
toolPrefix: merged.prefix,
instructions: guide,
});
}
} catch (err) {
// A failed server is skipped — the turn proceeds with the rest. Log a
// short warning (never the URL/headers) so ops can see degradation, and
@@ -260,6 +305,7 @@ export class McpClientsService {
tools,
clients,
outcomes,
instructions,
expiresAt: Date.now() + CACHE_TTL_MS,
refCount: 0,
evicted: false,
@@ -276,16 +322,19 @@ export class McpClientsService {
* renaming any key that would collide with an already-merged tool (different
* servers with the same sanitized name, or duplicates after truncation), so
* no external tool is silently dropped via overwrite.
*
* Returns how many tools this server actually contributed and the namespace
* prefix used (the sanitized server name) so the caller can attach the
* server's prompt guidance only when ≥1 tool was merged.
*/
private mergeNamespaced(
target: Record<string, Tool>,
picked: Record<string, Tool>,
serverName: string,
serverId: string,
): void {
for (const [name, tool] of Object.entries(
namespace(picked, serverName),
)) {
): { count: number; prefix: string } {
let count = 0;
for (const [name, tool] of Object.entries(namespace(picked, serverName))) {
let key = name;
if (key in target) {
const original = key;
@@ -295,7 +344,9 @@ export class McpClientsService {
);
}
target[key] = tool;
count += 1;
}
return { count, prefix: namespacePrefix(serverName) };
}
/**
@@ -371,9 +422,7 @@ export class McpClientsService {
/** Close clients, swallowing close errors so they never break a response. */
private async closeClients(clients: McpClient[]): Promise<void> {
await Promise.all(
clients.map((c) => c.close().catch(() => undefined)),
);
await Promise.all(clients.map((c) => c.close().catch(() => undefined)));
}
}
@@ -386,9 +435,10 @@ export class McpClientsService {
* lookup hands net/tls.connect ONLY a set that passed this check, so the kernel
* can never connect to an address that did not pass the guard. Pure — no I/O.
*/
export function validateResolvedAddresses(
addrs: readonly LookupAddress[],
): { ok: boolean; blockedHost?: string } {
export function validateResolvedAddresses(addrs: readonly LookupAddress[]): {
ok: boolean;
blockedHost?: string;
} {
if (addrs.length === 0) {
return { ok: false };
}
@@ -524,7 +574,7 @@ function namespace(
tools: Record<string, Tool>,
serverName: string,
): Record<string, Tool> {
const prefix = sanitizeName(serverName) || 'mcp';
const prefix = namespacePrefix(serverName);
const out: Record<string, Tool> = {};
for (const [name, t] of Object.entries(tools)) {
const safe = sanitizeName(name);
@@ -539,6 +589,15 @@ function namespace(
return out;
}
/**
* The tool-name namespace prefix for a server: its sanitized name, or `mcp`
* when the name sanitizes to empty. Tools are merged as `${prefix}_${tool}`, so
* the prompt guidance refers to the server's tools as `${prefix}_*`.
*/
function namespacePrefix(serverName: string): string {
return sanitizeName(serverName) || 'mcp';
}
/** Reduce an arbitrary string to ^[a-zA-Z0-9_-]+, collapsing runs to '_'. */
function sanitizeName(value: string): string {
return value

View File

@@ -0,0 +1,168 @@
import { type Tool } from 'ai';
import { McpClientsService } from './mcp-clients.service';
/**
* Tests for the per-server prompt guidance (#180) assembled by buildEntry and
* surfaced via toolsFor().instructions.
*
* REACHABILITY NOTE: buildEntry is a PRIVATE method; the smallest reachable
* public path is toolsFor() -> getOrBuildEntry -> buildEntry -> connect/tools()
* -> mergeNamespaced. We drive that path: stub the repo's `listEnabled` and spy
* on the private `connect` to return fake MCP clients whose `tools()` we control.
*
* Contract (all checked here): a server's guidance is included ONLY when the
* server actually connected AND contributed ≥1 callable tool (after the
* allowlist filter) AND its instructions are non-blank. The header carries the
* tool namespace prefix (the sanitized server name).
*/
function fakeTool(): Tool {
return { description: 'x', inputSchema: undefined } as unknown as Tool;
}
interface FakeServer {
id: string;
name: string;
transport: string;
url: string;
headersEnc: string | null;
toolAllowlist: string[] | null;
instructions: string | null;
}
function server(
over: Partial<FakeServer> & { id: string; name: string },
): FakeServer {
return {
transport: 'http',
url: 'https://example.com/mcp',
headersEnc: null,
toolAllowlist: null,
instructions: null,
...over,
};
}
async function instructionsFor(
servers: FakeServer[],
toolsByServerId: Record<string, Record<string, Tool>>,
// Server ids whose connect should THROW (simulating an unavailable server).
failingIds: Set<string> = new Set(),
): Promise<
{
serverName: string;
toolPrefix: string;
instructions: string;
}[]
> {
const repoStub = {
listEnabled: jest.fn().mockResolvedValue(servers),
};
const service = new McpClientsService(repoStub as never, {} as never);
jest
.spyOn(
service as unknown as { connect: (s: FakeServer) => unknown },
'connect',
)
.mockImplementation((s: FakeServer) => {
if (failingIds.has(s.id)) {
return Promise.reject(new Error('connection failed'));
}
return Promise.resolve({
tools: () => Promise.resolve(toolsByServerId[s.id] ?? {}),
close: () => Promise.resolve(),
});
});
const toolset = await service.toolsFor('ws-1');
await Promise.all(toolset.clients.map((c) => c.close()));
return toolset.instructions;
}
describe('external MCP per-server prompt guidance (via toolsFor)', () => {
afterEach(() => jest.restoreAllMocks());
it('includes guidance for a connected server with non-empty text and ≥1 tool', async () => {
const instructions = await instructionsFor(
[
server({
id: 'id-tavily',
name: 'Tavily',
instructions: 'Use tavily_search for fresh facts.',
}),
],
{ 'id-tavily': { search: fakeTool() } },
);
// sanitizeName preserves case (charset [a-zA-Z0-9_-]), so the prefix is the
// server name as-is for an already-clean name.
expect(instructions).toEqual([
{
serverName: 'Tavily',
toolPrefix: 'Tavily',
instructions: 'Use tavily_search for fresh facts.',
},
]);
});
it('omits guidance when the server has no instructions', async () => {
const instructions = await instructionsFor(
[server({ id: 'id-1', name: 'Tavily', instructions: null })],
{ 'id-1': { search: fakeTool() } },
);
expect(instructions).toEqual([]);
});
it('omits guidance when the instructions are only whitespace', async () => {
const instructions = await instructionsFor(
[server({ id: 'id-1', name: 'Tavily', instructions: ' ' })],
{ 'id-1': { search: fakeTool() } },
);
expect(instructions).toEqual([]);
});
it('omits guidance for a server that contributed ZERO tools (allowlist filtered all out)', async () => {
const instructions = await instructionsFor(
[
server({
id: 'id-1',
name: 'Tavily',
instructions: 'guide',
// Allowlist names a tool the server does not expose -> 0 picked.
toolAllowlist: ['nonexistent'],
}),
],
{ 'id-1': { search: fakeTool() } },
);
expect(instructions).toEqual([]);
});
it('omits guidance for an unavailable (failed-connect) server', async () => {
const instructions = await instructionsFor(
[server({ id: 'id-1', name: 'Tavily', instructions: 'guide' })],
{ 'id-1': { search: fakeTool() } },
new Set(['id-1']),
);
expect(instructions).toEqual([]);
});
it('includes only the qualifying servers among several', async () => {
const instructions = await instructionsFor(
[
server({ id: 'ok', name: 'Tavily', instructions: 'web guide' }),
server({ id: 'blank', name: 'Crawl', instructions: '' }),
server({ id: 'down', name: 'Down', instructions: 'never shown' }),
],
{
ok: { search: fakeTool() },
blank: { crawl: fakeTool() },
down: { x: fakeTool() },
},
new Set(['down']),
);
expect(instructions).toEqual([
{ serverName: 'Tavily', toolPrefix: 'Tavily', instructions: 'web guide' },
]);
});
});

View File

@@ -17,6 +17,7 @@ function row(overrides: Partial<AiMcpServer>): AiMcpServer {
enabled: true,
toolAllowlist: null,
headersEnc: null,
instructions: null,
...overrides,
} as unknown as AiMcpServer;
}
@@ -28,11 +29,7 @@ describe('McpServersService.toView (via list) — encrypted-header leak guard',
};
// secretBox + clients are unused by the list/toView path; pass stubs to
// satisfy the constructor.
return new McpServersService(
repoStub as never,
{} as never,
{} as never,
);
return new McpServersService(repoStub as never, {} as never, {} as never);
}
it('exposes hasHeaders:true and NO headersEnc when auth headers are set', async () => {
@@ -67,6 +64,7 @@ describe('McpServersService.toView (via list) — encrypted-header leak guard',
enabled: false,
toolAllowlist: ['search'],
headersEnc: 'BLOB',
instructions: 'Use search for fresh web facts.',
}),
]);
@@ -80,6 +78,19 @@ describe('McpServersService.toView (via list) — encrypted-header leak guard',
enabled: false,
toolAllowlist: ['search'],
hasHeaders: true,
instructions: 'Use search for fresh web facts.',
});
});
it('returns instructions (NON-secret) in the view, null when unset', async () => {
const service = buildService([
row({ id: 'a', instructions: 'How to use these tools.' }),
row({ id: 'b', instructions: null }),
]);
const [withText, withoutText] = await service.list('ws-1');
expect(withText.instructions).toBe('How to use these tools.');
expect(withoutText.instructions).toBeNull();
});
});

View File

@@ -20,6 +20,9 @@ export interface McpServerView {
enabled: boolean;
toolAllowlist: string[] | null;
hasHeaders: boolean;
// Admin-authored prompt guidance (#180). NON-secret, so returned in the view.
// Null when no guidance is configured.
instructions: string | null;
}
/**
@@ -56,6 +59,8 @@ export class McpServersService {
url: dto.url,
headersEnc,
toolAllowlist: dto.toolAllowlist ?? null,
// Blank/whitespace guidance is normalized to null by the repo.
instructions: dto.instructions ?? null,
enabled: dto.enabled ?? true,
});
this.clients.invalidate(workspaceId);
@@ -97,6 +102,8 @@ export class McpServersService {
headersEnc,
// undefined => unchanged; [] / value handled by repo (empty => null).
toolAllowlist: dto.toolAllowlist,
// undefined => unchanged; blank => cleared (null) by the repo.
instructions: dto.instructions,
enabled: dto.enabled,
});
this.clients.invalidate(workspaceId);
@@ -167,6 +174,7 @@ export class McpServersService {
enabled: row.enabled,
toolAllowlist: row.toolAllowlist ?? null,
hasHeaders: Boolean(row.headersEnc),
instructions: row.instructions ?? null,
};
}
}

View File

@@ -34,6 +34,7 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
resolveShareRole?: jest.Mock;
getShareChatModel?: jest.Mock;
tryConsumeWorkspaceQuota?: jest.Mock;
withinShareTokenBudget?: jest.Mock;
} = {}) {
const aiSettings = {
isPublicShareAssistantEnabled: jest
@@ -65,6 +66,8 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
over.getShareChatModel ?? jest.fn().mockResolvedValue('MODEL'),
tryConsumeWorkspaceQuota:
over.tryConsumeWorkspaceQuota ?? jest.fn().mockResolvedValue(true),
withinShareTokenBudget:
over.withinShareTokenBudget ?? jest.fn().mockResolvedValue(true),
};
const deps: ShareAssistantDeps = {
aiSettings: aiSettings as never,
@@ -191,6 +194,39 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
expect(publicShareChat.tryConsumeWorkspaceQuota).toHaveBeenCalledWith('ws-1');
});
it('withinShareTokenBudget false => 429 thrown BEFORE any stream (cost cap, #159 #5)', async () => {
const { deps, publicShareChat } = makeDeps({
withinShareTokenBudget: jest.fn().mockResolvedValue(false),
});
expect(await statusOf(deps, body())).toBe(429);
expect(publicShareChat.withinShareTokenBudget).toHaveBeenCalledWith('ws-1');
// The token budget is the COST backstop: an over-budget workspace must be
// rejected WITHOUT consuming a request slot, so the request cap never runs.
expect(publicShareChat.tryConsumeWorkspaceQuota).not.toHaveBeenCalled();
});
it('the token budget is checked BEFORE the request cap (over-budget wins, no slot spent)', async () => {
// Over budget AND the request cap would also reject: the read-only budget
// gate must win so the (mutating) request-slot consume is never reached.
const { deps, publicShareChat } = makeDeps({
withinShareTokenBudget: jest.fn().mockResolvedValue(false),
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
});
expect(await statusOf(deps, body())).toBe(429);
expect(publicShareChat.tryConsumeWorkspaceQuota).not.toHaveBeenCalled();
});
it('the token-budget gate is checked BEFORE the payload caps (429 wins over 413)', async () => {
const { deps } = makeDeps({
withinShareTokenBudget: jest.fn().mockResolvedValue(false),
});
const huge = {
role: 'user',
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
};
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(429);
});
it('messages over MAX_SHARE_MESSAGES => 413', async () => {
const { deps } = makeDeps();
const tooMany = Array.from({ length: MAX_SHARE_MESSAGES + 1 }, () => ({

View File

@@ -151,6 +151,7 @@ export interface ShareAssistantDeps {
| 'resolveShareRole'
| 'getShareChatModel'
| 'tryConsumeWorkspaceQuota'
| 'withinShareTokenBudget'
>;
}
@@ -267,9 +268,21 @@ export async function resolveShareAssistantRequest(
throw new NotFoundException('Not found');
}
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). Checked
// BEFORE res.hijack(), so an over-cap workspace gets a clean 429 and spends
// nothing.
// 5a. Per-WORKSPACE rolling-day TOKEN budget (the COST backstop). Read-only and
// checked FIRST so a workspace that has already burned its day's token
// budget gets a clean 429 WITHOUT consuming a request slot, and spends
// nothing. Counting requests alone does not bound the owner's provider
// bill (issue #159, finding #5).
if (!(await deps.publicShareChat.withinShareTokenBudget(workspaceId))) {
throw new HttpException(
'This documentation assistant has reached its usage budget. Please try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
// 5b. Per-WORKSPACE anti-abuse request cap (IP-independent; defense in depth).
// Checked BEFORE res.hijack(), so an over-cap workspace gets a clean 429
// and spends nothing.
if (!(await deps.publicShareChat.tryConsumeWorkspaceQuota(workspaceId))) {
throw new HttpException(
'This documentation assistant is temporarily busy. Please try again later.',

View File

@@ -17,7 +17,9 @@ import { buildShareSystemPrompt } from './public-share-chat.prompt';
import { roleModelOverride } from './roles/role-model-config';
import {
PublicShareWorkspaceLimiter,
PublicShareWorkspaceTokenBudget,
createPublicShareWorkspaceLimiter,
createPublicShareWorkspaceTokenBudget,
} from './public-share-workspace-limiter';
import { describeProviderError } from '../../integrations/ai/ai-error.util';
import {
@@ -125,6 +127,16 @@ export class PublicShareChatService {
*/
private readonly workspaceLimiter: PublicShareWorkspaceLimiter;
/**
* COST contour two: a per-workspace TOKEN budget over a rolling day. The
* request-count limiter above bounds how many anonymous calls run; this bounds
* how many provider TOKENS they spend (input re-sent per step + output),
* which is what the owner is actually billed for (issue #159, finding #5).
* Checked read-only before a turn streams; the real usage is recorded once the
* turn finishes (`onFinish`).
*/
private readonly tokenBudget: PublicShareWorkspaceTokenBudget;
constructor(
private readonly ai: AiService,
private readonly aiSettings: AiSettingsService,
@@ -133,6 +145,7 @@ export class PublicShareChatService {
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
) {
this.workspaceLimiter = createPublicShareWorkspaceLimiter(redisService);
this.tokenBudget = createPublicShareWorkspaceTokenBudget(redisService);
}
/**
@@ -144,6 +157,48 @@ export class PublicShareChatService {
return this.workspaceLimiter.tryConsume(workspaceId);
}
/**
* Read-only pre-stream COST gate: true while the workspace is under its
* rolling-day token budget, false once the trailing-day token spend has
* reached it (the controller must then 429 BEFORE starting the stream). This
* bounds the owner's actual provider bill, which counting requests alone does
* not (issue #159, finding #5).
*/
async withinShareTokenBudget(workspaceId: string): Promise<boolean> {
return this.tokenBudget.withinBudget(workspaceId);
}
/**
* Record a finished turn's real token spend against the rolling-day budget.
* Best-effort (the turn already ran): failures are swallowed by the budget.
*/
async recordShareTokens(workspaceId: string, tokens: number): Promise<void> {
return this.tokenBudget.record(workspaceId, tokens);
}
/**
* `streamText` onFinish hook body: account a finished turn's REAL token spend
* (input re-sent per step + output, summed across all steps) against the
* per-workspace rolling-day budget, so a future turn over budget is rejected up
* front (issue #159, finding #5). `totalUsage` fields are `number | undefined`;
* fall back to the sum of input+output when the provider omits `totalTokens`.
* Fire-and-forget: the turn already streamed, so a record failure must not
* break it.
*/
recordTurnUsage(
workspaceId: string,
totalUsage: {
totalTokens?: number;
inputTokens?: number;
outputTokens?: number;
},
): void {
const tokens =
totalUsage.totalTokens ??
(totalUsage.inputTokens ?? 0) + (totalUsage.outputTokens ?? 0);
void this.recordShareTokens(workspaceId, tokens);
}
/**
* Resolve the admin-selected agent role for the anonymous public-share
* assistant, scoped to the workspace and soft-delete aware. Returns null when
@@ -231,6 +286,8 @@ export class PublicShareChatService {
// bill even if the per-IP throttle is evaded; worst case = steps × this.
maxOutputTokens: resolveShareAiMaxOutputTokens(),
abortSignal: signal,
onFinish: ({ totalUsage }) =>
this.recordTurnUsage(workspaceId, totalUsage),
onError: ({ error }) => {
// Reuse the shared formatter so provider error formatting stays
// unified (statusCode + body) with the authenticated path.

View File

@@ -11,8 +11,11 @@ import {
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
import {
PublicShareWorkspaceLimiter,
PublicShareWorkspaceTokenBudget,
resolveShareAiWorkspaceMax,
resolveShareAiWorkspaceTokenBudget,
SHARE_AI_WORKSPACE_MAX_PER_WINDOW,
SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
} from './public-share-workspace-limiter';
/**
@@ -546,6 +549,228 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace
});
});
/**
* In-memory fake of the ioredis slice the TOKEN budget uses. Unlike the request
* limiter (one Lua), the budget runs TWO scripts over the same sorted set:
* - the read-only CHECK (sums the token counts encoded as each member's leading
* integer, admits while the sum is under budget, never mutates), and
* - the RECORD (ZADDs a finished turn's `<tokens>:<unique>` member).
* The fake faithfully reproduces both (branching on the script body) so the spec
* exercises the REAL budget math, not a re-implementation.
*/
class FakeTokenRedis {
private sets = new Map<string, Array<{ score: number; member: string }>>();
async eval(
script: string,
_numKeys: number,
key: string,
nowStr: string,
windowMsStr: string,
arg3: string,
): Promise<number> {
const now = Number(nowStr);
const windowMs = Number(windowMsStr);
const cutoff = now - windowMs;
const arr = (this.sets.get(key) ?? []).filter((e) => e.score > cutoff);
if (script.includes('ZADD')) {
// RECORD: arg3 is the `<tokens>:<unique>` member; append at score=now.
arr.push({ score: now, member: arg3 });
this.sets.set(key, arr);
return 1;
}
// CHECK: arg3 is the budget; sum the leading integer of each survivor.
const budget = Number(arg3);
this.sets.set(key, arr);
const total = arr.reduce((sum, e) => {
const m = /^(\d+)/.exec(e.member);
return sum + (m ? Number(m[1]) : 0);
}, 0);
return total >= budget ? 0 : 1;
}
}
function makeTokenBudget(budget: number, windowMs: number, clock: () => number) {
const redis = new FakeTokenRedis() as unknown as import('ioredis').Redis;
return new PublicShareWorkspaceTokenBudget(redis, budget, windowMs, clock);
}
describe('resolveShareAiWorkspaceTokenBudget (env-overridable per-day token budget)', () => {
const KEY = 'SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY';
const saved = process.env[KEY];
afterEach(() => {
if (saved === undefined) delete process.env[KEY];
else process.env[KEY] = saved;
});
it('falls back to the default when unset', () => {
delete process.env[KEY];
expect(resolveShareAiWorkspaceTokenBudget()).toBe(
SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
);
});
it('honors a positive override', () => {
process.env[KEY] = '250000';
expect(resolveShareAiWorkspaceTokenBudget()).toBe(250000);
});
it('ignores a non-positive / unparseable value (uses the default)', () => {
for (const bad of ['0', '-5', 'nope', '']) {
process.env[KEY] = bad;
expect(resolveShareAiWorkspaceTokenBudget()).toBe(
SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
);
}
});
});
describe('PublicShareWorkspaceTokenBudget (cluster-wide rolling-day token cap)', () => {
it('admits while under budget and rejects once the recorded spend reaches it', async () => {
const budget = makeTokenBudget(1000, 60_000, () => 1_000);
expect(await budget.withinBudget('ws-1')).toBe(true); // nothing spent yet
await budget.record('ws-1', 600);
expect(await budget.withinBudget('ws-1')).toBe(true); // 600 < 1000
await budget.record('ws-1', 400);
// 1000 >= 1000: the budget is exhausted, so the next turn is rejected up front.
expect(await budget.withinBudget('ws-1')).toBe(false);
});
it('counts TOKENS, not requests: one fat turn can exhaust the budget alone', async () => {
const budget = makeTokenBudget(1000, 60_000, () => 1_000);
// A single accepted turn re-sends the whole transcript across 5 steps; here
// it lands as 1200 tokens — already over the day budget on its own.
await budget.record('ws-1', 1200);
expect(await budget.withinBudget('ws-1')).toBe(false);
});
it('ages out spend older than the window so the budget recovers', async () => {
let now = 0;
const budget = makeTokenBudget(1000, 60_000, () => now);
await budget.record('ws-1', 1000); // at budget
now += 59_999; // still inside the day window
expect(await budget.withinBudget('ws-1')).toBe(false);
now += 2; // the spend is now strictly older than windowMs
expect(await budget.withinBudget('ws-1')).toBe(true);
});
it('ignores non-positive / non-finite usage (never records phantom spend)', async () => {
const budget = makeTokenBudget(1000, 60_000, () => 1_000);
await budget.record('ws-1', 0);
await budget.record('ws-1', -50);
await budget.record('ws-1', Number.NaN);
await budget.record('ws-1', Infinity);
expect(await budget.withinBudget('ws-1')).toBe(true); // nothing accumulated
});
it('keeps separate budgets per workspace', async () => {
const budget = makeTokenBudget(500, 60_000, () => 1_000);
await budget.record('ws-a', 500); // ws-a exhausted
expect(await budget.withinBudget('ws-a')).toBe(false);
expect(await budget.withinBudget('ws-b')).toBe(true); // ws-b untouched
});
it('FAILS CLOSED on the read-only check when Redis rejects', async () => {
const failingRedis = {
eval: () => Promise.reject(new Error('redis down')),
} as unknown as import('ioredis').Redis;
const budget = new PublicShareWorkspaceTokenBudget(
failingRedis,
1000,
60_000,
() => 1_000,
);
const errSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
expect(await budget.withinBudget('ws-1')).toBe(false);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('SWALLOWS a record failure (best-effort post-accounting, never throws)', async () => {
// The turn already streamed; a record failure must not surface to the caller.
const failingRedis = {
eval: () => Promise.reject(new Error('redis down')),
} as unknown as import('ioredis').Redis;
const budget = new PublicShareWorkspaceTokenBudget(
failingRedis,
1000,
60_000,
() => 1_000,
);
const errSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
await expect(budget.record('ws-1', 100)).resolves.toBeUndefined();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
});
describe('PublicShareChatService.withinShareTokenBudget / recordShareTokens', () => {
it('delegates the cost gate + accounting to the redis-backed token budget', async () => {
const redis = new FakeTokenRedis();
const redisService = { getOrThrow: () => redis } as never;
const service = new PublicShareChatService(
{} as never,
{} as never,
{} as never,
redisService,
{} as never,
);
// Default budget is large, so a fresh workspace is under budget; recording a
// modest spend keeps it under budget (asserts the wiring the controller +
// onFinish rely on).
expect(await service.withinShareTokenBudget('ws-1')).toBe(true);
await service.recordShareTokens('ws-1', 1234);
expect(await service.withinShareTokenBudget('ws-1')).toBe(true);
});
});
describe('PublicShareChatService.recordTurnUsage (streamText onFinish accounting)', () => {
function makeService() {
const redisService = { getOrThrow: () => new FakeTokenRedis() } as never;
const service = new PublicShareChatService(
{} as never,
{} as never,
{} as never,
redisService,
{} as never,
);
const recordSpy = jest
.spyOn(service, 'recordShareTokens')
.mockResolvedValue(undefined);
return { service, recordSpy };
}
it('sums input+output when the provider omits totalTokens', () => {
const { service, recordSpy } = makeService();
// The onFinish payload shape: a totalUsage with per-component counts but no
// authoritative total (provider omitted it).
service.recordTurnUsage('ws-1', { inputTokens: 1200, outputTokens: 300 });
expect(recordSpy).toHaveBeenCalledWith('ws-1', 1500);
});
it('treats missing input/output components as 0 in the fallback sum', () => {
const { service, recordSpy } = makeService();
service.recordTurnUsage('ws-1', { outputTokens: 42 });
expect(recordSpy).toHaveBeenCalledWith('ws-1', 42);
});
it('prefers the authoritative totalTokens when present (not the sum)', () => {
const { service, recordSpy } = makeService();
// totalTokens is the provider's authoritative figure and may differ from a
// naive input+output sum (e.g. cached/ reasoning tokens); it must win.
service.recordTurnUsage('ws-1', {
totalTokens: 5000,
inputTokens: 1200,
outputTokens: 300,
});
expect(recordSpy).toHaveBeenCalledWith('ws-1', 5000);
});
});
describe('PublicShareChatService.tryConsumeWorkspaceQuota', () => {
it('delegates to the redis-backed per-workspace limiter', async () => {
const redis = new FakeRedis();

View File

@@ -136,6 +136,177 @@ export class PublicShareWorkspaceLimiter {
}
}
/**
* SECOND cost contour: a per-workspace TOKEN budget over a rolling DAY.
*
* The request-count cap above bounds how MANY anonymous calls a workspace
* admits, but NOT how expensive each one is: one accepted call runs the agent
* loop up to `stepCountIs(5)`, and every step re-sends the WHOLE client-held
* transcript (~hundreds of KB) as input, so the provider input alone can be tens
* of thousands of tokens PER step while `maxOutputTokens` only caps the output.
* The request cap is also hourly with no daily ceiling, so a steady stream at
* the hourly cap sustains ~24x its count per day. Counting requests therefore
* does not bound the owner's actual LLM bill (issue #159, finding #5).
*
* This contour caps the SPEND directly: the actual tokens consumed (input +
* output, summed across all steps of every accepted turn) over the trailing
* `windowMs` (one rolling day) must stay under `budget`. It is checked BEFORE a
* turn streams (read-only) and the turn's real usage is recorded AFTER it
* finishes (`streamText` onFinish). Like the request cap it is cluster-wide
* (shared Redis) and uses a sliding-window LOG so the day boundary cannot be
* gamed for a 2x burst.
*
* Pre-check is read-only, so a turn already over budget is rejected, but the
* tokens of an in-flight turn are not yet known and are accounted only once it
* finishes. The worst-case overshoot past the budget is therefore one turn
* (bounded by steps x (maxOutputTokens + transcript size)) — acceptable for a
* cost backstop on an optional anonymous assistant.
*/
/** Default per-workspace token budget over the rolling day. */
export const SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT = 1_000_000;
/** Default token-budget window length: one rolling day. */
export const SHARE_AI_WORKSPACE_TOKEN_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Redis key namespace for the per-workspace token-spend sliding-window log. */
const TOKEN_KEY_PREFIX = 'share-ai:ws-tokens:';
/**
* Read-only sliding-window token-budget check.
*
* KEYS[1] = the per-workspace token sorted-set key
* ARGV[1] = now (epoch ms)
* ARGV[2] = windowMs
* ARGV[3] = budget (max tokens in the trailing window)
*
* Drops entries older than the window, then sums the token counts encoded as the
* leading integer of each surviving member. Returns 1 if the running total is
* still UNDER budget (admit), 0 once it has reached/exceeded the budget. Does NOT
* add anything — the turn's real usage is recorded separately once it finishes.
*/
const TOKEN_BUDGET_CHECK_LUA = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local budget = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
local members = redis.call('ZRANGE', key, 0, -1)
local total = 0
for i = 1, #members do
local t = tonumber(string.match(members[i], '^(%d+)'))
if t then total = total + t end
end
if total >= budget then
return 0
end
return 1
`;
/**
* Record one finished turn's token spend in the sliding-window log.
*
* KEYS[1] = the per-workspace token sorted-set key
* ARGV[1] = now (epoch ms) — the entry score
* ARGV[2] = windowMs
* ARGV[3] = member (`<tokens>:<unique>`; the leading integer is the token count)
*
* Always ZADDs (the turn already ran and spent the tokens) and refreshes the
* key TTL so idle workspaces cost no memory. Trims expired entries first so the
* set never grows unbounded for a busy workspace.
*/
const TOKEN_RECORD_LUA = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local member = ARGV[3]
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, windowMs)
return 1
`;
/**
* Cluster-wide, sliding-window per-workspace TOKEN budget backed by Redis.
* `withinBudget(key)` is a read-only pre-stream gate; `record(key, tokens)`
* accounts a finished turn's real usage. Decoupled from NestJS so it is testable
* against a mocked/real ioredis client, mirroring the request-count limiter.
*/
export class PublicShareWorkspaceTokenBudget {
private readonly logger = new Logger(PublicShareWorkspaceTokenBudget.name);
private counter = 0;
constructor(
private readonly redis: Redis,
private readonly budget: number = SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT,
private readonly windowMs: number = SHARE_AI_WORKSPACE_TOKEN_WINDOW_MS,
private readonly now: () => number = Date.now,
) {}
/**
* Read-only pre-stream check. Returns true while the workspace is under its
* rolling-day token budget, false once the trailing-window spend has reached
* it (caller must then 429 BEFORE streaming any tokens).
*
* FAILS CLOSED (false) on a Redis error: identical reasoning to the request
* limiter — when we cannot prove the workspace is under budget we DENY rather
* than admit an unmetered billable call. The assistant is optional, so a
* transient Redis blip briefly disabling it beats an unbounded provider bill.
*/
async withinBudget(key: string): Promise<boolean> {
const t = this.now();
try {
const admitted = await this.redis.eval(
TOKEN_BUDGET_CHECK_LUA,
1,
TOKEN_KEY_PREFIX + key,
String(t),
String(this.windowMs),
String(this.budget),
);
return admitted === 1;
} catch (err) {
this.logger.error(
`share-ai token budget Redis failure for key "${key}"; failing closed`,
err as Error,
);
return false;
}
}
/**
* Record a finished turn's token spend. Best-effort: the turn already ran, so
* a Redis failure here is logged but not propagated — it would only cause a
* slight under-count of the running budget, never a wrong answer to the
* caller. Non-positive / non-finite usage is ignored.
*/
async record(key: string, tokens: number): Promise<void> {
if (!Number.isFinite(tokens) || tokens <= 0) return;
const spend = Math.floor(tokens);
const t = this.now();
// Member: `<tokens>:<unique>` — the check Lua sums the leading integer, and
// the unique suffix keeps distinct turns in the same ms from colliding on
// the sorted-set member (which would drop one entry and under-count).
const member = `${spend}:${t}-${this.counter++}-${Math.random()
.toString(36)
.slice(2)}`;
try {
await this.redis.eval(
TOKEN_RECORD_LUA,
1,
TOKEN_KEY_PREFIX + key,
String(t),
String(this.windowMs),
member,
);
} catch (err) {
this.logger.error(
`share-ai token budget record failure for key "${key}" (${spend} tokens); ignoring`,
err as Error,
);
}
}
}
/**
* Read the per-workspace cap from the environment (overridable seam), falling
* back to the sane default. A non-positive / unparseable value uses the default.
@@ -162,3 +333,31 @@ export function createPublicShareWorkspaceLimiter(
SHARE_AI_WORKSPACE_WINDOW_MS,
);
}
/**
* Read the per-workspace rolling-day token budget from the environment
* (overridable seam), falling back to the sane default. A non-positive /
* unparseable value uses the default.
*/
export function resolveShareAiWorkspaceTokenBudget(): number {
const raw = Number(process.env.SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY);
return Number.isFinite(raw) && raw > 0
? Math.floor(raw)
: SHARE_AI_WORKSPACE_TOKEN_BUDGET_DEFAULT;
}
/**
* Build the per-workspace token budget from the injected RedisService (the same
* global ioredis client used by the request-count limiter). Tiny factory so the
* service constructor stays declarative and the budget stays unit-testable with
* a hand-rolled fake redis.
*/
export function createPublicShareWorkspaceTokenBudget(
redisService: RedisService,
): PublicShareWorkspaceTokenBudget {
return new PublicShareWorkspaceTokenBudget(
redisService.getOrThrow(),
resolveShareAiWorkspaceTokenBudget(),
SHARE_AI_WORKSPACE_TOKEN_WINDOW_MS,
);
}

View File

@@ -1,30 +0,0 @@
import { jsonbObject } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
/**
* Unit tests for jsonbObject: the repo helper that encodes a model_config object
* as a jsonb bind (or null when there is nothing to persist). It is the last
* line of defence before the column write, so the null-vs-bind decision is what
* matters here. We assert only null vs non-null because the non-null value is a
* kysely `sql` template fragment whose internal shape is an implementation
* detail of the SQL tag.
*/
describe('jsonbObject', () => {
it('returns null for null', () => {
expect(jsonbObject(null)).toBeNull();
});
it('returns null for undefined', () => {
expect(jsonbObject(undefined)).toBeNull();
});
it('returns null for an empty object (nothing to persist)', () => {
expect(jsonbObject({})).toBeNull();
});
it('returns a (non-null) jsonb bind for a non-empty object', () => {
const out = jsonbObject({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
// A real sql fragment is produced, never null/undefined.
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
});

View File

@@ -120,18 +120,25 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
const tools = await buildTools();
const deletePage = tools.deletePage;
// The Zod input schema only allows `pageId`; parsing strips/ignores extra
// keys, so a permanent/force flag is never part of the validated input.
// The wrapped input schema (modelFriendlyInput) only allows `pageId`;
// validation strips/ignores extra keys, so a permanent/force flag is never
// part of the validated input handed to execute.
const schema = (deletePage as unknown as { inputSchema: unknown })
.inputSchema as {
parse: (v: unknown) => Record<string, unknown>;
validate: (
v: unknown,
) =>
| { success: boolean; value?: Record<string, unknown> }
| Promise<{ success: boolean; value?: Record<string, unknown> }>;
};
const parsed = schema.parse({
const result = await schema.validate({
pageId: 'page-789',
permanentlyDelete: true,
forceDelete: true,
});
expect(result.success).toBe(true);
const parsed = result.value as Record<string, unknown>;
expect(parsed).toHaveProperty('pageId', 'page-789');
expect(parsed).not.toHaveProperty('permanentlyDelete');
expect(parsed).not.toHaveProperty('forceDelete');
@@ -207,19 +214,26 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
const tools = await buildTools();
const transformPage = tools.transformPage;
// The Zod input schema only allows pageId/transformJs/dryRun; parsing
// strips unknown keys, so deleteComments can never reach the client.
// The wrapped input schema only allows pageId/transformJs/dryRun;
// validation strips unknown keys, so deleteComments can never reach the
// client.
const schema = (transformPage as unknown as { inputSchema: unknown })
.inputSchema as {
parse: (v: unknown) => Record<string, unknown>;
validate: (
v: unknown,
) =>
| { success: boolean; value?: Record<string, unknown> }
| Promise<{ success: boolean; value?: Record<string, unknown> }>;
};
const parsed = schema.parse({
const result = await schema.validate({
pageId: 'p',
transformJs: '(d)=>d',
dryRun: true,
deleteComments: true,
});
expect(result.success).toBe(true);
const parsed = result.value as Record<string, unknown>;
expect(parsed).toHaveProperty('pageId', 'p');
expect(parsed).not.toHaveProperty('deleteComments');
});
@@ -395,3 +409,95 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
expect(updatePageJsonCalls).toHaveLength(0);
});
});
/**
* Model-friendly tool-call validation (#190): when the model drops a required
* `pageId` in a parallel/batch tool call, the built-in input schema must return
* a CLEAR, actionable message (naming the parameter, reminding it not to drop
* ids in batches) instead of zod's raw "expected string, received undefined" —
* while a valid call still validates. This is wired centrally via
* modelFriendlyInput, so it applies to every in-app tool; createComment (the
* tool from the bug report) and a sharedTool-built tool (getPage's sibling
* getOutline) are exercised here end-to-end through forUser().
*/
describe('AiChatToolsService model-friendly input validation (#190)', () => {
const fakeClient: Partial<DocmostClientLike> = {};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let service: AiChatToolsService;
beforeEach(() => {
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
});
afterEach(() => jest.restoreAllMocks());
function buildTools() {
return service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
);
}
// The AI SDK Schema produced by modelFriendlyInput exposes `validate`.
type ValidatableSchema = {
validate: (
v: unknown,
) =>
| { success: boolean; value?: unknown; error?: Error }
| Promise<{ success: boolean; value?: unknown; error?: Error }>;
};
const inputSchemaOf = (t: unknown) =>
(t as { inputSchema: unknown }).inputSchema as ValidatableSchema;
it('createComment: a dropped pageId yields a clear, model-actionable message', async () => {
const tools = await buildTools();
// The exact failing shape from the bug report's second parallel batch:
// content + selection, but pageId silently dropped.
const result = await inputSchemaOf(tools.createComment).validate({
content: 'A remark',
selection: 'титановый проводник',
});
expect(result.success).toBe(false);
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
expect(result.error?.message).toContain('parallel/batch tool calls');
// Not the raw zod text the model previously received.
expect(result.error?.message).not.toContain('received undefined');
});
it('createComment: a valid call with pageId validates successfully', async () => {
const tools = await buildTools();
const result = await inputSchemaOf(tools.createComment).validate({
pageId: '019efe44-0000-0000-0000-000000000000',
content: 'A remark',
selection: 'титановый проводник',
});
expect(result.success).toBe(true);
expect(result.value).toMatchObject({
pageId: '019efe44-0000-0000-0000-000000000000',
content: 'A remark',
});
});
it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => {
const tools = await buildTools();
const result = await inputSchemaOf(tools.getOutline).validate({});
expect(result.success).toBe(false);
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
});
});

View File

@@ -15,6 +15,7 @@ import {
} from './docmost-client.loader';
import { resolveCurrentPageResult } from './current-page.util';
import { parseNodeArg } from './parse-node-arg';
import { modelFriendlyInput } from './model-friendly-input';
/**
* Per-user, per-request adapter that exposes Docmost READ operations to the
@@ -102,9 +103,13 @@ export class AiChatToolsService {
): Tool =>
tool({
description: spec.description,
inputSchema: spec.buildShape
? z.object(spec.buildShape(z) as z.ZodRawShape)
: z.object({}),
// Wrap via modelFriendlyInput so a dropped/invalid parameter (e.g. a
// pageId omitted in a parallel batch, #190) yields a clear, actionable
// tool error instead of zod's raw text. No-arg specs still get an empty
// object schema.
inputSchema: modelFriendlyInput(
spec.buildShape ? (spec.buildShape(z) as z.ZodRawShape) : {},
),
execute,
});
@@ -118,7 +123,7 @@ export class AiChatToolsService {
'and entities), not a full sentence. If the first results look weak ' +
'or incomplete, search again with different wording or synonyms ' +
'before answering.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
query: z.string().describe('The search query.'),
limit: z
.number()
@@ -227,7 +232,7 @@ export class AiChatToolsService {
'"the current page", or "here" refers to. Returns the page id and title, ' +
'or null if the user is not currently on a page. Call this first whenever ' +
'the user refers to the current page without giving an explicit id.',
inputSchema: z.object({}),
inputSchema: modelFriendlyInput({}),
execute: async () => resolveCurrentPageResult(openedPage),
}),
@@ -235,7 +240,7 @@ export class AiChatToolsService {
description:
'Fetch a single page as Markdown by its page id. Returns the page ' +
'title and its Markdown content.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id (or slugId) of the page.'),
}),
execute: async ({ pageId }) => {
@@ -259,7 +264,7 @@ export class AiChatToolsService {
'Create a new page with a Markdown body in a space, optionally under ' +
'a parent page. Returns the new page id and title. Reversible: a page ' +
'can be moved to trash later.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
title: z.string().describe('The title of the new page.'),
content: z
.string()
@@ -294,7 +299,7 @@ export class AiChatToolsService {
description:
"Replace a page's body with new Markdown content (and optionally its " +
'title). Reversible: the previous version is kept in page history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to update.'),
content: z.string().describe('The new page body as Markdown.'),
title: z
@@ -316,7 +321,7 @@ export class AiChatToolsService {
description:
"Rename a page (change its title only; the body is untouched). " +
'Reversible: rename back at any time.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to rename.'),
title: z.string().describe('The new title.'),
}),
@@ -331,7 +336,7 @@ export class AiChatToolsService {
description:
'Move a page under a new parent page, or to the space root when no ' +
'parent is given. Reversible: move it back at any time.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to move.'),
parentPageId: z
.string()
@@ -353,7 +358,7 @@ export class AiChatToolsService {
description:
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
'page can be restored from trash). This NEVER permanently deletes.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to move to trash.'),
}),
// GUARDRAIL (§14 H4): the only field ever passed to the client is
@@ -379,7 +384,7 @@ export class AiChatToolsService {
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. Reversible via the ' +
'comment UI.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
selection: z
@@ -428,7 +433,7 @@ export class AiChatToolsService {
description:
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
'the resolved flag). Only top-level comments can be resolved.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
commentId: z
.string()
.describe('The id of the top-level comment to resolve/reopen.'),
@@ -460,7 +465,7 @@ export class AiChatToolsService {
'List the most recent pages, optionally scoped to a single space. ' +
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
inputSchema: z.object({
inputSchema: modelFriendlyInput({
spaceId: z
.string()
.optional()
@@ -488,7 +493,7 @@ export class AiChatToolsService {
'List sidebar pages for a space. With no pageId, returns the ' +
"space's ROOT pages; with a pageId, returns that page's direct " +
'CHILDREN.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
spaceId: z.string().describe('The id of the space.'),
pageId: z
.string()
@@ -520,7 +525,7 @@ export class AiChatToolsService {
description:
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
'matrix so cells can be addressed for rich edits).',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
@@ -536,7 +541,7 @@ export class AiChatToolsService {
listComments: tool({
description:
'List all comments on a page (content as Markdown).',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
}),
execute: async ({ pageId }) => await client.listComments(pageId),
@@ -544,7 +549,7 @@ export class AiChatToolsService {
getComment: tool({
description: 'Fetch a single comment by id (content as Markdown).',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
commentId: z.string().describe('The id of the comment.'),
}),
execute: async ({ commentId }) => await client.getComment(commentId),
@@ -554,7 +559,7 @@ export class AiChatToolsService {
description:
'Find new comments across a space (optionally scoped to a subtree) ' +
'created after a given timestamp.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
spaceId: z.string().describe('The id of the space to scan.'),
since: z
.string()
@@ -586,7 +591,7 @@ export class AiChatToolsService {
description:
'Fetch a single page-history version including its lossless ' +
'ProseMirror content.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
historyId: z.string().describe('The id of the history version.'),
}),
execute: async ({ historyId }) =>
@@ -604,7 +609,7 @@ export class AiChatToolsService {
'Export a page to a single self-contained Docmost-flavoured ' +
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
'with importPageMarkdown.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to export.'),
}),
execute: async ({ pageId }) => {
@@ -630,7 +635,7 @@ export class AiChatToolsService {
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
'may be a JSON object or a JSON string (both accepted). Reversible: ' +
'the previous version is kept in page history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
nodeId: z
.string()
@@ -663,7 +668,7 @@ export class AiChatToolsService {
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
'may be a JSON object or a JSON string (both accepted). Reversible ' +
'via page history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
node: z
.any()
@@ -722,7 +727,7 @@ export class AiChatToolsService {
'object or a JSON string (both accepted). Omit content for a ' +
'title-only update. Reversible: the previous version is kept in page ' +
'history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to update.'),
content: z
.any()
@@ -753,7 +758,7 @@ export class AiChatToolsService {
description:
'Insert a row of plain-text cells into a table. Reversible via ' +
'page history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
@@ -772,7 +777,7 @@ export class AiChatToolsService {
tableDeleteRow: tool({
description:
'Delete a table row at a 0-based index. Reversible via page history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
@@ -787,7 +792,7 @@ export class AiChatToolsService {
description:
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
'Reversible via page history.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
@@ -817,7 +822,7 @@ export class AiChatToolsService {
'Make a page PUBLICLY accessible and return its public URL. ' +
'Reversible via unsharePage. Only share when the user explicitly ' +
'asked, since this exposes the page to anyone with the link.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to share.'),
searchIndexing: z
.boolean()
@@ -844,7 +849,7 @@ export class AiChatToolsService {
"page's ProseMirror document for complex/scripted rewrites. dryRun " +
'(default true) previews a diff WITHOUT writing; set dryRun:false to ' +
'apply. Reversible: applying creates a new page-history snapshot.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to transform.'),
transformJs: z
.string()

View File

@@ -0,0 +1,101 @@
import { z } from 'zod';
import {
modelFriendlyInput,
buildModelFriendlyMessage,
} from './model-friendly-input';
/**
* Unit tests for the centralized in-app tool input wrapper (#190). A dropped or
* invalid parameter must surface a clear, model-actionable message (naming the
* parameter and reminding the model not to drop ids in parallel batches), while
* a valid call validates cleanly and strips unknown keys — and the advertised
* JSON Schema keeps the unchanged required/description contract.
*/
describe('modelFriendlyInput', () => {
// Mirrors createComment's shape: pageId is the required id the model drops in
// parallel batches; selection is optional with a min length.
const shape = {
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
selection: z.string().min(1).max(250).optional(),
};
// Loose return type: the AI SDK ValidationResult is a discriminated union, but
// these tests assert on both branches, so a flat optional shape is simpler.
async function validate(
value: unknown,
): Promise<{ success: boolean; value?: unknown; error?: Error }> {
const schema = modelFriendlyInput(shape);
return await schema.validate!(value);
}
it('rejects a dropped required pageId with a clear, actionable message', async () => {
const result = await validate({
content: 'Looks off here',
selection: 'титановый проводник',
});
expect(result.success).toBe(false);
const msg = result.error?.message ?? '';
// Names the dropped parameter...
expect(msg).toContain('parameter "pageId": missing (required)');
// ...and gives an explicit, non-raw instruction (not zod's raw text).
expect(msg).toContain('parallel/batch tool calls');
expect(msg).not.toContain('expected string, received undefined');
});
it('distinguishes a present-but-invalid parameter from a missing one', async () => {
// selection is present but too short (invalid), pageId is missing.
const result = await validate({ content: 'x', selection: '' });
expect(result.success).toBe(false);
const msg = result.error?.message ?? '';
expect(msg).toContain('parameter "pageId": missing (required)');
expect(msg).toContain('parameter "selection": invalid');
});
it('accepts a valid call and strips unknown keys from the validated value', async () => {
const result = await validate({
pageId: 'page-1',
content: 'A comment',
selection: 'anchor text',
bogus: true,
});
expect(result.success).toBe(true);
if (!result.success) throw new Error('expected success');
expect(result.value).toEqual({
pageId: 'page-1',
content: 'A comment',
selection: 'anchor text',
});
expect(result.value).not.toHaveProperty('bogus');
});
it('preserves the required/description contract in the advertised JSON Schema', async () => {
const schema = modelFriendlyInput(shape);
const json = (await schema.jsonSchema) as {
required?: string[];
properties?: Record<string, { description?: string }>;
};
// pageId + content stay required; selection stays optional.
expect(json.required).toEqual(expect.arrayContaining(['pageId', 'content']));
expect(json.required).not.toContain('selection');
expect(json.properties?.pageId.description).toBe(
'The id of the page to comment on.',
);
});
it('handles a no-arg tool (empty shape) without error', async () => {
const schema = modelFriendlyInput({});
const result = await schema.validate!({});
expect(result.success).toBe(true);
});
});
describe('buildModelFriendlyMessage', () => {
it('falls back to a generic message when issues carry an empty path', () => {
// safeParse on a non-object yields a root-level issue (empty path).
const error = z.object({ a: z.string() }).safeParse('not-an-object');
if (error.success) throw new Error('expected failure');
const msg = buildModelFriendlyMessage(error.error, 'not-an-object');
expect(msg).toContain('parameter "input"');
});
});

View File

@@ -0,0 +1,93 @@
import { jsonSchema, type Schema } from 'ai';
import type { JSONSchema7 } from '@ai-sdk/provider';
import { z } from 'zod';
/**
* Centralized input-schema wrapper for every in-app AI-chat tool.
*
* THE PROBLEM (#190): when the model issues PARALLEL / batch tool calls it
* sometimes drops an "obvious" repeated required argument (typically `pageId`)
* from some of the calls. zod v4 correctly rejects the missing value, but the
* AI SDK forwards zod's RAW message ("Invalid input: expected string, received
* undefined") straight back to the model, which is not actionable — the model
* cannot tell WHICH parameter it dropped or that it must re-send it.
*
* THE FIX: keep the exact same validation, but replace the raw zod text with a
* model-friendly message that names every problematic parameter and tells the
* model to re-issue the call with all required parameters present. We do NOT
* guess/backfill the value (a silently-assumed "current page" could comment on
* the wrong page — cf. #159); the model is simply told to retry correctly.
*
* HOW IT WORKS: we build the tool's JSON Schema from the zod shape via
* `z.toJSONSchema(..., { target: 'draft-7' })` (so the advertised contract —
* `required` / `description` / field constraints — is unchanged) and hand the
* AI SDK a custom `validate` that runs `z.object(shape).safeParse(value)`. On
* failure the AI SDK wraps our returned `Error` in `InvalidToolInputError`, so
* our clear text is what reaches the model as the tool error.
*/
export function modelFriendlyInput<T extends z.ZodRawShape>(
shape: T,
): Schema<z.output<z.ZodObject<T>>> {
const objectSchema = z.object(shape);
// draft-07 keeps required/description/constraints intact, matching what the
// model already saw — the tool contract does not change.
const json = z.toJSONSchema(objectSchema, {
target: 'draft-7',
}) as JSONSchema7;
return jsonSchema<z.output<z.ZodObject<T>>>(json, {
validate: (value) => {
const result = objectSchema.safeParse(value);
if (result.success) {
return { success: true, value: result.data };
}
return {
success: false,
error: new Error(buildModelFriendlyMessage(result.error, value)),
};
},
});
}
/**
* Turn a zod validation failure into a clear, model-actionable message naming
* each problematic parameter (and whether it is missing vs. invalid), plus an
* explicit reminder not to drop required ids in parallel/batch tool calls.
*/
export function buildModelFriendlyMessage(
error: z.ZodError,
value: unknown,
): string {
const seen = new Set<string>();
const parts: string[] = [];
for (const issue of error.issues) {
const name = issue.path.length ? issue.path.map(String).join('.') : 'input';
// A parameter the model omitted entirely reads as `undefined` at its path;
// anything else is present-but-invalid (wrong type, too short, etc.).
const missing = valueAtPath(value, issue.path) === undefined;
const part = `parameter "${name}": ${missing ? 'missing (required)' : 'invalid'}`;
if (seen.has(part)) continue;
seen.add(part);
parts.push(part);
}
if (parts.length === 0) {
// Defensive: a ZodError always has issues, but never emit an empty list.
parts.push('input: invalid');
}
return (
`Invalid input for this tool — ${parts.join('; ')}. ` +
'Re-issue the call with EVERY required parameter present and valid. ' +
"Do not drop ids like pageId, even when making parallel/batch tool calls — " +
'each tool call must carry its own pageId.'
);
}
/** Read the value at a zod issue path; returns undefined if any hop is absent. */
function valueAtPath(value: unknown, path: ReadonlyArray<PropertyKey>): unknown {
let current: unknown = value;
for (const key of path) {
if (current === null || typeof current !== 'object') return undefined;
current = (current as Record<PropertyKey, unknown>)[key];
}
return current;
}

View File

@@ -5,6 +5,7 @@ import { ShareService } from '../../share/share.service';
import { SearchService } from '../../search/search.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { jsonToMarkdown } from '../../../collaboration/collaboration.util';
import { modelFriendlyInput } from './model-friendly-input';
/**
* Isolated, READ-ONLY toolset for the ANONYMOUS public-share assistant.
@@ -52,7 +53,7 @@ export class PublicShareChatToolsService {
'(key terms and entities), not a full sentence. If the first ' +
'results look weak, search again with different wording before ' +
'answering. Only pages inside this share are ever returned.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
query: z.string().describe('The search query.'),
limit: z
.number()
@@ -87,7 +88,7 @@ export class PublicShareChatToolsService {
'Markdown, by its page id. Returns the page title and its Markdown ' +
'content. Only pages inside this share can be read; reading any ' +
'other page fails.',
inputSchema: z.object({
inputSchema: modelFriendlyInput({
pageId: z
.string()
.describe('The id (or slugId) of a page within this share.'),
@@ -142,7 +143,7 @@ export class PublicShareChatToolsService {
'List the pages (titles + ids) that make up THIS published ' +
'documentation share, so you can orient yourself before reading or ' +
'searching. Only pages inside this share are listed.',
inputSchema: z.object({}),
inputSchema: modelFriendlyInput({}),
execute: async () => {
// Reuse the same share-tree logic the public /shares/tree route uses:
// it validates the share + workspace, excludes restricted subtrees,

View File

@@ -57,11 +57,28 @@ describe('PageService', () => {
const eventEmitter = { emit: jest.fn() };
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
// i.e. this.db.transaction().execute(fn => fn(trx)). A permissive chainable
// Proxy stands in for the Kysely trx so the per-space advisory-lock
// `sql``.execute(trx)` resolves; a thrown BadRequestException still
// propagates out of the transaction unchanged.
const trxStub: any = new Proxy(function () {}, {
get: (_t, p) =>
p === 'then'
? undefined
: p === 'execute' || p === 'executeTakeFirst'
? () => Promise.resolve([])
: () => trxStub,
});
const db = {
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
};
const svc = new PageService(
pageRepo as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
{} as any, // db
db as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
@@ -268,9 +285,23 @@ describe('PageService', () => {
}),
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
};
// movePage now runs the cycle-check + UPDATE inside executeTx(this.db),
// which calls this.db.transaction().execute(fn => fn(trx)). A permissive
// chainable Proxy stands in for the Kysely trx so the per-space
// advisory-lock `sql``.execute(trx)` resolves and updatePage receives it.
const trxStub: any = new Proxy(function () {}, {
get: (_t, p) =>
p === 'then'
? undefined
: p === 'execute' || p === 'executeTakeFirst'
? () => Promise.resolve([])
: () => trxStub,
});
const svc = makeSvc({
pageRepo,
db: {} as any,
db: {
transaction: () => ({ execute: (fn: any) => fn(trxStub) }),
} as any,
});
// Legitimate move: destination ancestors do NOT include the moved page.
jest

View File

@@ -15,13 +15,13 @@ import {
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { shapeSidebarPagesTree } from './sidebar-pages-tree.util';
import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { dbOrTx, executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid';
import {
@@ -62,6 +62,23 @@ import {
agentSourceFields,
} from '../../../common/decorators/auth-provenance.decorator';
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
// descendant traversals) may walk. Real page trees are only a handful of levels
// deep, so this cap never truncates a legitimate result; it purely defends the
// recursive CTEs against runaway iteration if a parent/child cycle ever exists
// in the data (e.g. one slipped in before the move guard, #207 #8). Without it a
// cycle makes `withRecursive` loop forever (hang / statement timeout), and the
// move guard itself calls one of these CTEs — so a cycle would disable the very
// guard meant to prevent it. Each CTE carries a depth counter and stops here.
const MAX_PAGE_TREE_DEPTH = 10_000;
// Advisory-lock namespace (the first key of pg_advisory_xact_lock) used to
// serialize concurrent page moves within a single space so the cycle check and
// the move UPDATE stay atomic (see movePage, #207 #7). A dedicated namespace
// constant keeps these locks from colliding with any other advisory lock; the
// second key is hashtext(spaceId). Fits a signed int4 ('page' in ASCII).
const PAGE_MOVE_LOCK_NAMESPACE = 0x70616765;
@Injectable()
export class PageService {
private readonly logger = new Logger(PageService.name);
@@ -601,7 +618,13 @@ export class PageService {
slugIdMap.set(entry.oldSlugId, entry);
}
const attachmentMap = new Map<string, ICopyPageAttachment>();
// Keyed by old attachmentId. A single attachment can be referenced by more
// than one page in the copied subtree (e.g. a block copy-pasted into a child
// page keeps the same attachmentId). Each referencing page needs its own
// fresh attachment id / row / blob copy, so the value is a LIST of copy
// entries rather than a single one — otherwise the last page's entry would
// clobber the others and their images would 404 in the copies (#206 attach-1).
const attachmentMap = new Map<string, ICopyPageAttachment[]>();
const insertablePages: InsertablePage[] = await Promise.all(
pages.map(async (page) => {
@@ -617,12 +640,14 @@ export class PageService {
attachmentIds.forEach((attachmentId: string) => {
const newPageId = pageFromMap.newPageId;
const newAttachmentId = uuid7();
attachmentMap.set(attachmentId, {
const existingEntries = attachmentMap.get(attachmentId) ?? [];
existingEntries.push({
newPageId: newPageId,
oldPageId: page.id,
oldAttachmentId: attachmentId,
newAttachmentId: newAttachmentId,
});
attachmentMap.set(attachmentId, existingEntries);
prosemirrorDoc.descendants((node: PMNode) => {
if (isAttachmentNode(node.type.name)) {
@@ -819,51 +844,53 @@ export class PageService {
.execute();
for (const attachment of attachments) {
try {
const pageAttachment = attachmentMap.get(attachment.id);
// make sure the copied attachment belongs to the page it was copied from
if (attachment.pageId !== pageAttachment.oldPageId) {
continue;
}
const newAttachmentId = pageAttachment.newAttachmentId;
const newPageId = pageAttachment.newPageId;
const newPathFile = attachment.filePath.replace(
attachment.id,
newAttachmentId,
);
// One source attachment may need to be copied for several destination
// pages (it is referenced by more than one page in the subtree). Copy a
// distinct blob + row for every referencing page so each copy resolves
// (#206 attach-1). The old per-page ownership guard is gone: when the
// same attachmentId is shared, only one page would ever match the row's
// pageId, silently dropping the other copies.
const pageAttachments = attachmentMap.get(attachment.id) ?? [];
for (const pageAttachment of pageAttachments) {
try {
await this.storageService.copy(attachment.filePath, newPathFile);
const newAttachmentId = pageAttachment.newAttachmentId;
await this.db
.insertInto('attachments')
.values({
id: newAttachmentId,
type: attachment.type,
filePath: newPathFile,
fileName: attachment.fileName,
fileSize: attachment.fileSize,
mimeType: attachment.mimeType,
fileExt: attachment.fileExt,
creatorId: attachment.creatorId,
workspaceId: attachment.workspaceId,
pageId: newPageId,
spaceId: spaceId,
})
.execute();
} catch (err) {
this.logger.error(
`Duplicate page: failed to copy attachment ${attachment.id}`,
err,
const newPageId = pageAttachment.newPageId;
const newPathFile = attachment.filePath.replace(
attachment.id,
newAttachmentId,
);
// Continue with other attachments even if one fails
try {
await this.storageService.copy(attachment.filePath, newPathFile);
await this.db
.insertInto('attachments')
.values({
id: newAttachmentId,
type: attachment.type,
filePath: newPathFile,
fileName: attachment.fileName,
fileSize: attachment.fileSize,
mimeType: attachment.mimeType,
fileExt: attachment.fileExt,
creatorId: attachment.creatorId,
workspaceId: attachment.workspaceId,
pageId: newPageId,
spaceId: spaceId,
})
.execute();
} catch (err) {
this.logger.error(
`Duplicate page: failed to copy attachment ${attachment.id}`,
err,
);
// Continue with other attachments even if one fails
}
} catch (err) {
this.logger.error(err);
}
} catch (err) {
this.logger.error(err);
}
}
}
@@ -915,34 +942,61 @@ export class PageService {
}
}
// Server-side cycle guard: a page may not be moved into itself or into any
// page within its own subtree. Without this, an MCP/REST/agent caller (or a
// fast drag racing the client check) could persist a cycle and broadcast it.
// Only relevant when re-parenting under a concrete parent; moving to root
// (parentPageId null/undefined) can never create a cycle.
if (dto.parentPageId) {
if (dto.parentPageId === dto.pageId) {
throw new BadRequestException('Cannot move a page into its own subtree');
}
// Walk the destination parent's ancestor chain (reusing the breadcrumb
// ancestor CTE). If the page being moved appears among those ancestors,
// the destination lives inside the moved page's subtree -> cycle.
const destAncestors = await this.getPageBreadCrumbs(dto.parentPageId);
if (destAncestors.some((ancestor) => ancestor.id === dto.pageId)) {
throw new BadRequestException('Cannot move a page into its own subtree');
}
}
// Server-side cycle guard + the move UPDATE run in ONE transaction. A page
// may not be moved into itself or into any page within its own subtree;
// without this an MCP/REST/agent caller (or a fast drag racing the client
// check) could persist a cycle and broadcast it. Crucially, doing the guard
// and the write as two separate, unlocked statements is a TOCTOU race: two
// concurrent moves ("A under B" and "B under A") can each read the same
// pre-write acyclic snapshot, both pass the guard, then persist
// A.parentPageId=B AND B.parentPageId=A — a parent/child cycle (#207 #7). A
// per-space advisory lock (held until COMMIT) serializes all moves within a
// space: the second mover blocks until the first commits and then sees the
// freshly written parent, so its guard rejects the cycle.
const updateResult = await executeTx(this.db, async (trx) => {
await sql`select pg_advisory_xact_lock(${sql.lit(
PAGE_MOVE_LOCK_NAMESPACE,
)}, hashtext(${movedPage.spaceId}))`.execute(trx);
const updateResult = await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
// Agent-edit provenance: annotate the source on an agent move. A normal
// user request leaves the existing source value unchanged.
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
},
dto.pageId,
);
// Only relevant when re-parenting under a concrete parent; moving to root
// (parentPageId null/undefined) can never create a cycle.
if (dto.parentPageId) {
if (dto.parentPageId === dto.pageId) {
throw new BadRequestException(
'Cannot move a page into its own subtree',
);
}
// Walk the destination parent's ancestor chain (reusing the breadcrumb
// ancestor CTE) inside the lock. If the page being moved appears among
// those ancestors, the destination lives inside the moved page's
// subtree -> cycle.
const destAncestors = await this.getPageBreadCrumbs(
dto.parentPageId,
trx,
);
if (destAncestors.some((ancestor) => ancestor.id === dto.pageId)) {
throw new BadRequestException(
'Cannot move a page into its own subtree',
);
}
}
return this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
// Agent-edit provenance: annotate the source on an agent move. A
// normal user request leaves the existing source value unchanged.
...agentSourceFields(
provenance,
'lastUpdatedSource',
'lastUpdatedAiChatId',
),
},
dto.pageId,
trx,
);
});
// Guard against a phantom broadcast: if the row was concurrently deleted or
// otherwise not updated, skip the PAGE_MOVED event so we don't replay a move
@@ -981,8 +1035,8 @@ export class PageService {
});
}
async getPageBreadCrumbs(childPageId: string) {
const ancestors = await this.db
async getPageBreadCrumbs(childPageId: string, trx?: KyselyTransaction) {
const ancestors = await dbOrTx(this.db, trx)
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
@@ -996,6 +1050,9 @@ export class PageService {
'spaceId',
'deletedAt',
])
// Depth counter: bounds the walk so a parent/child cycle in the data
// can't make this recursive CTE loop forever (#207 #8).
.select(sql<number>`0`.as('depth'))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
@@ -1011,12 +1068,25 @@ export class PageService {
'p.spaceId',
'p.deletedAt',
])
.select(sql<number>`pa.depth + 1`.as('depth'))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
.where('p.deletedAt', 'is', null)
.where(sql<number>`pa.depth`, '<', MAX_PAGE_TREE_DEPTH),
),
)
.selectFrom('page_ancestors')
.selectAll('page_ancestors')
// Explicit column list (not selectAll) so the internal `depth` counter
// never leaks into the breadcrumb result shape.
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'deletedAt',
])
.select((eb) =>
eb
.exists(
@@ -1137,16 +1207,21 @@ export class PageService {
db
.selectFrom('pages')
.select(['id'])
// Depth counter: bounds the walk so a parent/child cycle in the data
// can't make this recursive CTE loop forever (#207 #8).
.select(sql<number>`0`.as('depth'))
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
.select(sql<number>`pd.depth + 1`.as('depth'))
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where(sql<number>`pd.depth`, '<', MAX_PAGE_TREE_DEPTH),
),
)
.selectFrom('page_descendants')
.selectAll()
.select(['id'])
.execute();
const pageIds = descendants.map((d) => d.id);

View File

@@ -0,0 +1,133 @@
import * as fs from 'node:fs';
import { ShareSeoController } from './share-seo.controller';
/**
* Routing guard for ShareSeoController.getShare (red-team finding #3).
*
* The SEO route must NOT leak a shared page's <title>/og:title to anonymous
* visitors / crawlers when the page is not publicly readable. It previously
* called the raw `getShareForPage`, which skips the restricted-ancestor gate, so
* a permission-restricted descendant of an includeSubPages share leaked its
* title. The fix funnels through `resolveReadableSharePage` (the canonical gate)
* AND honours `isSharingAllowed`. These tests pin that routing: a non-readable
* page or sharing-disabled space serves the plain SPA index (no title); only a
* readable, still-shared page gets meta tags.
*/
const SECRET_TITLE = 'Restricted Quarterly Numbers';
const INDEX_HTML = `<!doctype html><html><head><title>App</title><!--meta-tags--></head><body></body></html>`;
const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream;
// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller
// transitively pulls bcrypt, whose native module is located by node-gyp-build
// reading the filesystem at import time — a module-level fs mock breaks that.
beforeEach(() => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(INDEX_HTML);
jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL);
});
afterEach(() => jest.restoreAllMocks());
function makeRes() {
const res: any = {
sent: undefined as unknown,
type: jest.fn(() => res),
send: jest.fn((v: unknown) => {
res.sent = v;
}),
};
return res;
}
function makeController(opts: {
resolved: { share: any; page: any } | null;
sharingAllowed?: boolean;
}) {
const shareService = {
resolveReadableSharePage: jest.fn(async () => opts.resolved),
isSharingAllowed: jest.fn(async () => opts.sharingAllowed ?? true),
// Must NEVER be used by the SEO path anymore (the bypass is the bug).
getShareForPage: jest.fn(async () => {
throw new Error('getShareForPage must not be called by the SEO path');
}),
};
const workspaceRepo = {
findFirst: async () => ({ id: 'ws-1', settings: {} }),
};
const environmentService = { isSelfHosted: () => true };
const controller = new ShareSeoController(
shareService as any,
workspaceRepo as any,
environmentService as any,
);
return { controller, shareService };
}
const req: any = { raw: { headers: { host: 'self' } } };
describe('ShareSeoController.getShare routing (#3 title-leak gate)', () => {
it('serves the plain index (NO title) when the page is not publicly readable', async () => {
const { controller, shareService } = makeController({ resolved: null });
const res = makeRes();
await controller.getShare(res, req, 'share-key', `slug-pageB`);
// The restricted-ancestor gate ran; the raw bypass did not.
expect(shareService.resolveReadableSharePage).toHaveBeenCalled();
expect(shareService.getShareForPage).not.toHaveBeenCalled();
// The plain index stream was sent — NOT the title-bearing meta HTML.
expect(res.sent).toBe(STREAM_SENTINEL);
});
it('serves the plain index when sharing was disabled at the workspace/space level', async () => {
const { controller } = makeController({
resolved: {
share: { spaceId: 'sp-1', searchIndexing: true },
page: { title: SECRET_TITLE },
},
sharingAllowed: false,
});
const res = makeRes();
await controller.getShare(res, req, 'share-key', 'slug-pageB');
// The plain index stream was sent, so the restricted title never reached
// the response (it is only ever interpolated into the meta HTML string).
expect(res.sent).toBe(STREAM_SENTINEL);
expect(res.sent).not.toBe(SECRET_TITLE);
});
it('injects the title + meta for a readable, still-shared page', async () => {
const { controller } = makeController({
resolved: {
share: { spaceId: 'sp-1', searchIndexing: true },
page: { title: 'Public Handbook' },
},
sharingAllowed: true,
});
const res = makeRes();
await controller.getShare(res, req, 'share-key', 'slug-pageA');
expect(typeof res.sent).toBe('string');
expect(res.sent as string).toContain('<title>Public Handbook</title>');
expect(res.sent as string).toContain('og:title');
// searchIndexing on => crawlable (no noindex).
expect(res.sent as string).not.toContain('content="noindex"');
});
it('adds robots=noindex when the share opted out of search indexing', async () => {
const { controller } = makeController({
resolved: {
share: { spaceId: 'sp-1', searchIndexing: false },
page: { title: 'Internal Notes' },
},
sharingAllowed: true,
});
const res = makeRes();
await controller.getShare(res, req, 'share-key', 'slug-pageA');
expect(res.sent as string).toContain('content="noindex"');
});
});

View File

@@ -63,19 +63,38 @@ export class ShareSeoController {
const pageId = this.extractPageSlugId(pageSlug);
const share = await this.shareService.getShareForPage(
// Funnel through the canonical readable-share boundary (NOT the raw
// getShareForPage) so the restricted-ancestor gate runs: a permission-
// restricted descendant of an includeSubPages share must NOT leak its
// title to anonymous visitors / crawlers (red-team finding #3). null =>
// not publicly readable => serve the plain SPA index with no meta.
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
pageId,
workspace.id,
);
if (!share) {
if (!resolved) {
return this.sendIndex(indexFilePath, res);
}
// Honour a workspace/space-level sharing toggle flipped off AFTER this
// share was created: the content API gates on isSharingAllowed, so the SEO
// path must too or it keeps serving the title for a no-longer-shared page.
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
resolved.share.spaceId,
);
if (!sharingAllowed) {
return this.sendIndex(indexFilePath, res);
}
const html = fs.readFileSync(indexFilePath, 'utf8');
// Title of the PAGE being viewed (server-resolved), and noindex unless the
// share opted into search indexing (buildShareMetaHtml injects it).
let transformedHtml = buildShareMetaHtml(html, {
title: share?.sharedPage.title,
searchIndexing: share.searchIndexing,
title: resolved.page.title,
searchIndexing: resolved.share.searchIndexing,
});
// Deliberate same-origin tracker surface: this is the ONE place where an

View File

@@ -0,0 +1,38 @@
import { jsonbBind } from './utils';
/**
* Unit tests for jsonbBind: THE shared helper that encodes a JS array/object as
* a jsonb bind (or null when there is nothing to persist). It is the last line
* of defence before a jsonb column write, so the null-vs-bind decision is what
* matters here. We assert only null vs non-null because the non-null value is a
* kysely `sql` template fragment whose internal shape is an implementation
* detail of the SQL tag (the `::text::jsonb` double-encoding fix is verified
* end-to-end by the repo integration specs, where a real DB round-trip can
* actually observe `jsonb_typeof`).
*/
describe('jsonbBind', () => {
it('returns null for null / undefined', () => {
expect(jsonbBind(null)).toBeNull();
expect(jsonbBind(undefined)).toBeNull();
});
it('returns null for an empty array (nothing to persist)', () => {
expect(jsonbBind([])).toBeNull();
});
it('returns null for an empty object (nothing to persist)', () => {
expect(jsonbBind({})).toBeNull();
});
it('returns a (non-null) bind for a non-empty array', () => {
const out = jsonbBind(['search', 'crawl']);
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
it('returns a (non-null) bind for a non-empty object', () => {
const out = jsonbBind({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
});

View File

@@ -0,0 +1,19 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Per-server, admin-authored instruction text injected into the agent system
// prompt next to the server's tool descriptions (#180). NON-secret (unlike
// headers_enc): it IS returned in admin views/forms. Nullable: a server may
// have no guidance. Trusted text — it goes inside the prompt safety sandwich.
await db.schema
.alterTable('ai_mcp_servers')
.addColumn('instructions', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_mcp_servers')
.dropColumn('instructions')
.execute();
}

View File

@@ -35,7 +35,13 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
const result = await repo.findLiveEnabled('r-1', 'ws-1');
expect(result).toBe(role);
// The repo normalizes the row (modelConfig parse), so it returns a COPY, not
// the same reference; assert the row's fields are carried through.
expect(result).toMatchObject({
id: 'r-1',
workspaceId: 'ws-1',
enabled: true,
});
expect(db.selectFrom).toHaveBeenCalledWith('aiAgentRoles');
// Every security filter must be present.
expect(where).toHaveBeenCalledWith('id', '=', 'r-1');

View File

@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
import { AiAgentRole } from '@docmost/db/types/entity.types';
/** The jsonb shape persisted in `model_config` (loosely typed for the column). */
@@ -23,13 +22,14 @@ export class AiAgentRoleRepo {
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
const row = await this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
return row ? normalizeRow(row) : row;
}
/**
@@ -45,7 +45,7 @@ export class AiAgentRoleRepo {
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
const row = await this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
@@ -53,17 +53,19 @@ export class AiAgentRoleRepo {
.where('deletedAt', 'is', null)
.where('enabled', '=', true)
.executeTakeFirst();
return row ? normalizeRow(row) : row;
}
/** All live roles for the workspace (management list + chat picker). */
async listByWorkspace(workspaceId: string): Promise<AiAgentRole[]> {
return this.db
const rows = await this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'asc')
.execute();
return rows.map(normalizeRow);
}
async insert(
@@ -83,7 +85,7 @@ export class AiAgentRoleRepo {
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
const db = dbOrTx(this.db, trx);
return db
const row = await db
.insertInto('aiAgentRoles')
.values({
workspaceId: values.workspaceId,
@@ -92,7 +94,11 @@ export class AiAgentRoleRepo {
emoji: values.emoji ?? null,
description: values.description ?? null,
instructions: values.instructions,
modelConfig: jsonbObject(values.modelConfig),
// Cast: the generated `model_config` column type is the broad JsonValue
// union, which the concrete RawBuilder<Record> is not structurally
// assignable to (same reason the old jsonbObject cast to any).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modelConfig: jsonbBind(values.modelConfig) as any,
enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
@@ -100,6 +106,7 @@ export class AiAgentRoleRepo {
})
.returningAll()
.executeTakeFirst();
return normalizeRow(row);
}
async update(
@@ -127,7 +134,7 @@ export class AiAgentRoleRepo {
if (patch.description !== undefined) set.description = patch.description;
if (patch.instructions !== undefined) set.instructions = patch.instructions;
if (patch.modelConfig !== undefined) {
set.modelConfig = jsonbObject(patch.modelConfig);
set.modelConfig = jsonbBind(patch.modelConfig);
}
if (patch.enabled !== undefined) set.enabled = patch.enabled;
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
@@ -163,16 +170,36 @@ export class AiAgentRoleRepo {
}
/**
* Encode an object as a jsonb bind for the `model_config` column. The postgres
* driver would otherwise need an explicit cast; bind the JSON text and cast it.
* Returns null for null/undefined/empty objects. Cast to `any` because the
* generated column type is the broad `JsonValue` union, which a concrete object
* type is not structurally assignable to.
* Parse the `model_config` value read from the DB into the object the entity
* type promises. Rows written by the old double-encoding bind (`::jsonb` instead
* of `::text::jsonb`) round-trip as a JSON STRING, so the driver hands back e.g.
* `'{"driver":"gemini"}'` rather than an object; the read-path check
* `typeof cfg === 'object'` then failed and the model override was SILENTLY
* dropped (the role fell back to the default model). Be tolerant: a JSON string
* is parsed; an already-parsed object passes through; null / a non-object (incl.
* an array) / unparseable value becomes null (= no override). This self-heals
* already-corrupted rows on read, no migration required.
*/
export function jsonbObject(value: ModelConfigValue | undefined) {
if (value === null || value === undefined || Object.keys(value).length === 0) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return sql`${JSON.stringify(value)}::jsonb` as any;
export function parseModelConfig(
value: unknown,
): Record<string, unknown> | null {
// Shape guard only; the legacy double-encoding self-heal lives in
// parseJsonbValue (database/utils.ts).
return parseJsonbValue(
value,
(v): v is Record<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v),
);
}
/** Normalize a DB row so `modelConfig` is always an object or null. The cast
* bridges parseModelConfig's concrete `Record | null` to the column's broad
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */
function normalizeRow(row: AiAgentRole): AiAgentRole {
return {
...row,
modelConfig: parseModelConfig(
row.modelConfig,
) as AiAgentRole['modelConfig'],
};
}

View File

@@ -0,0 +1,46 @@
import { parseModelConfig } from './ai-agent-roles.repo';
/**
* Unit tests for parseModelConfig: the read-side normalizer that repairs the
* jsonb double-encoding regression on `model_config`. Rows written by the old
* `::jsonb` bind round-trip as a JSON STRING, which the read path's
* `typeof === 'object'` check rejected — silently dropping the model override.
* parseModelConfig accepts an already-parsed object, parses a legacy JSON
* string, and rejects everything that is not an object (null = no override).
*/
describe('parseModelConfig', () => {
it('passes an already-parsed object through', () => {
expect(parseModelConfig({ driver: 'gemini' })).toEqual({
driver: 'gemini',
});
});
it('parses a legacy double-encoded JSON string into an object', () => {
expect(parseModelConfig('{"driver":"gemini","chatModel":"x"}')).toEqual({
driver: 'gemini',
chatModel: 'x',
});
});
it('returns null for null / undefined', () => {
expect(parseModelConfig(null)).toBeNull();
expect(parseModelConfig(undefined)).toBeNull();
});
it('returns null for a non-object JSON value (string/number/array)', () => {
expect(parseModelConfig('"justastring"')).toBeNull();
expect(parseModelConfig('42')).toBeNull();
// An array is an object in JS but not a valid model_config shape.
expect(parseModelConfig('["a","b"]')).toBeNull();
expect(parseModelConfig(['a', 'b'])).toBeNull();
});
it('returns null for an unparseable string', () => {
expect(parseModelConfig('not json at all')).toBeNull();
});
it('returns null for a raw non-object primitive', () => {
expect(parseModelConfig(42 as unknown)).toBeNull();
expect(parseModelConfig(true as unknown)).toBeNull();
});
});

View File

@@ -1,4 +1,4 @@
import { parseToolAllowlist } from './ai-mcp-server.repo';
import { parseToolAllowlist, blankToNull } from './ai-mcp-server.repo';
/**
* The `tool_allowlist` jsonb column historically round-trips as a JSON STRING
@@ -10,7 +10,10 @@ import { parseToolAllowlist } from './ai-mcp-server.repo';
*/
describe('parseToolAllowlist', () => {
it('passes a real string array through unchanged', () => {
expect(parseToolAllowlist(['search', 'crawl'])).toEqual(['search', 'crawl']);
expect(parseToolAllowlist(['search', 'crawl'])).toEqual([
'search',
'crawl',
]);
});
it('parses a JSON-string array (the double-encoded read) into an array', () => {
@@ -46,3 +49,26 @@ describe('parseToolAllowlist', () => {
expect(parseToolAllowlist(true as unknown)).toBeNull();
});
});
/**
* `blankToNull` normalizes the per-server `instructions` free text before it is
* stored (#180): a missing/blank/whitespace-only value becomes null (so an empty
* guide is never persisted), any other value is trimmed.
*/
describe('blankToNull', () => {
it('returns null for null / undefined', () => {
expect(blankToNull(null)).toBeNull();
expect(blankToNull(undefined)).toBeNull();
});
it('returns null for an empty / whitespace-only string', () => {
expect(blankToNull('')).toBeNull();
expect(blankToNull(' ')).toBeNull();
expect(blankToNull('\n\t ')).toBeNull();
});
it('trims and returns a non-blank string', () => {
expect(blankToNull(' use the search tool ')).toBe('use the search tool');
expect(blankToNull('guide')).toBe('guide');
});
});

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
import { AiMcpServer } from '@docmost/db/types/entity.types';
const logger = new Logger('AiMcpServerRepo');
/**
* Repository for per-workspace external MCP servers the agent may use (§5.4).
*
@@ -60,6 +61,8 @@ export class AiMcpServerRepo {
url: string;
headersEnc?: string | null;
toolAllowlist?: string[] | null;
// Admin-authored prompt guidance; blank/whitespace normalizes to null.
instructions?: string | null;
enabled?: boolean;
},
trx?: KyselyTransaction,
@@ -75,7 +78,9 @@ export class AiMcpServerRepo {
headersEnc: values.headersEnc ?? null,
// jsonb column: the postgres driver would otherwise encode a JS array as
// a Postgres array literal. Bind the JSON text and cast it to jsonb.
toolAllowlist: jsonbArray(values.toolAllowlist),
toolAllowlist: jsonbBind(values.toolAllowlist),
// Plain text column: blank/whitespace-only guidance is stored as null.
instructions: blankToNull(values.instructions),
enabled: values.enabled ?? true,
})
.returningAll()
@@ -93,6 +98,8 @@ export class AiMcpServerRepo {
headersEnc?: string | null;
// undefined => leave unchanged; null => clear; string[] => set.
toolAllowlist?: string[] | null;
// undefined => leave unchanged; null/blank => clear; string => set.
instructions?: string | null;
enabled?: boolean;
},
trx?: KyselyTransaction,
@@ -104,7 +111,11 @@ export class AiMcpServerRepo {
if (patch.url !== undefined) set.url = patch.url;
if (patch.headersEnc !== undefined) set.headersEnc = patch.headersEnc;
if (patch.toolAllowlist !== undefined) {
set.toolAllowlist = jsonbArray(patch.toolAllowlist);
set.toolAllowlist = jsonbBind(patch.toolAllowlist);
}
if (patch.instructions !== undefined) {
// Blank/whitespace-only guidance clears the column (stored as null).
set.instructions = blankToNull(patch.instructions);
}
if (patch.enabled !== undefined) set.enabled = patch.enabled;
await db
@@ -130,57 +141,49 @@ export class AiMcpServerRepo {
}
/**
* Encode a string[] as a jsonb bind for the `tool_allowlist` column. Passing a
* plain JS array to the postgres driver would serialize it as a Postgres array
* literal (incompatible with jsonb), so we bind the JSON text and cast it.
*
* The cast is `::text::jsonb`, NOT `::jsonb`: if the parameter is bound straight
* to a jsonb cast, node-postgres infers its type as jsonb and JSON-stringifies
* the (already-JSON) string a SECOND time, so the column ends up holding a jsonb
* STRING SCALAR (`"[\"a\"]"`) instead of a jsonb ARRAY. Forcing the param through
* `::text` first binds it as text (sent verbatim), and `::jsonb` then parses it
* into a real array. (`normalizeRow` below repairs rows written the old way.)
*
* Returns null for null/empty arrays (an empty allowlist means "no restriction"
* is not intended — callers pass null to clear; an empty array is normalized to
* null here so it never round-trips as `[]`).
* Normalize an optional free-text field to a stored value: a missing/blank/
* whitespace-only string becomes null (so an "empty" guide is never persisted),
* any other string is trimmed. Returns null for null/undefined input.
*/
function jsonbArray(value: string[] | null | undefined) {
if (value === null || value === undefined || value.length === 0) {
return null;
}
// Typed as string[] so it is assignable to the toolAllowlist column.
return sql<string[]>`${JSON.stringify(value)}::text::jsonb`;
export function blankToNull(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Parse the `toolAllowlist` value read from the DB into the `string[] | null`
* the entity type promises. The jsonb column historically round-trips as a JSON
* STRING (rows written by the old double-encoding `jsonbArray`, see above), so
* the driver hands back a string like `'["a","b"]'` rather than an array. Be
* tolerant: an already-parsed array passes through; a JSON string is parsed; null
* / a non-array / unparseable value becomes null (unrestricted).
* STRING (rows written by the old double-encoding bind before the `::text::jsonb`
* fix), so the driver hands back a string like `'["a","b"]'` rather than an
* array. Be tolerant: normalize a JSON string to its value, then accept it only
* if it is an array of strings; null / a non-array / unparseable value / an
* array with a non-string element all become null (unrestricted).
*/
export function parseToolAllowlist(value: unknown): string[] | null {
if (value == null) return null;
if (Array.isArray(value)) {
return value.every((v) => typeof v === 'string') ? (value as string[]) : null;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) &&
parsed.every((v) => typeof v === 'string')
? (parsed as string[])
: null;
} catch {
return null;
}
}
return null;
// Shape guard only; the legacy double-encoding self-heal lives in
// parseJsonbValue (database/utils.ts).
return parseJsonbValue(
value,
(v): v is string[] =>
Array.isArray(v) && v.every((x) => typeof x === 'string'),
);
}
/** Normalize a DB row so `toolAllowlist` is always `string[] | null`. */
/**
* Normalize a DB row so `toolAllowlist` is always `string[] | null`.
*
* FAIL-OPEN logging: a stored value that is present but cannot be parsed into a
* string[] (corrupt JSON, a non-array, non-string elements) degrades to `null` =
* "no restriction", so the agent silently gets ALL of the server's tools. Log
* one line (server id only, never the contents) so that widening is not silent.
*/
function normalizeRow(row: AiMcpServer): AiMcpServer {
return { ...row, toolAllowlist: parseToolAllowlist(row.toolAllowlist) };
const parsed = parseToolAllowlist(row.toolAllowlist);
if (parsed === null && row.toolAllowlist != null) {
logger.warn(
`Corrupt tool_allowlist for MCP server ${row.id}; ignoring it (no tool restriction applied)`,
);
}
return { ...row, toolAllowlist: parsed };
}

View File

@@ -20,6 +20,7 @@ import { DB, Workspaces } from '@docmost/db/types/db';
export const AI_PROVIDER_SETTINGS_ALLOWED: readonly string[] = [
'driver',
'chatModel',
'chatContextWindow',
'chatApiStyle',
'embeddingModel',
'baseUrl',

View File

@@ -20,8 +20,15 @@ export interface AiMcpServers {
// Encrypted JSON of the auth headers. Nullable (a server may need no auth).
headersEnc: string | null;
// Optional allowlist of remote tool names to expose; null = expose all.
// Stored as jsonb; reads come back as a string[] from the postgres driver.
// Stored as jsonb. The postgres driver may return a JSON string for legacy
// double-encoded rows; `AiMcpServerRepo` normalizes every read to
// `string[] | null` via `parseToolAllowlist`.
toolAllowlist: string[] | null;
// Admin-authored guidance ("how/when to use this server's tools") injected
// into the agent system prompt (#180). Unlike `headersEnc` this is NON-secret
// and IS returned in admin views/forms. Plain text column (no jsonb). Null =
// no guidance. Trusted text — it goes inside the prompt safety sandwich.
instructions: string | null;
enabled: Generated<boolean>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;

View File

@@ -1,3 +1,4 @@
import { sql, RawBuilder } from 'kysely';
import { KyselyDB, KyselyTransaction } from './types/kysely.types';
/*
@@ -31,3 +32,61 @@ export function dbOrTx(
return db; // Use normal database instance
}
}
/**
* Bind a JS array/object as a `jsonb` column value, working around a postgres
* driver double-encoding quirk. THE single implementation — repos that persist
* jsonb (`tool_allowlist`, `model_config`, ...) call this instead of re-deriving
* the cast.
*
* THE QUIRK: with the `kysely-postgres-js` / postgres.js driver, casting a bound
* parameter straight to `::jsonb` makes the driver infer the param type as jsonb
* and JSON-stringify the (already-JSON) text a SECOND time, so the column ends
* up holding a jsonb STRING SCALAR (`"[\"a\"]"` / `"{\"k\":1}"`) instead of a
* real jsonb array/object. Read paths then see a string, not the structure, and
* silently fall back (an allowlist becomes "unrestricted", a model override is
* ignored). Forcing the param through `::text` first binds it as text (sent
* verbatim); `::jsonb` then parses it into a real array/object. Read-side
* parsers repair rows written the old buggy way without a migration.
*
* Returns `null` for null/undefined and for "empty" values (an empty array, or
* an object with no own enumerable keys) — callers treat empty as "clear/unset",
* so an empty allowlist/config never round-trips as `[]`/`{}`.
*/
export function jsonbBind<T>(
value: T | null | undefined,
): RawBuilder<T> | null {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) {
if (value.length === 0) return null;
} else if (typeof value === 'object') {
if (Object.keys(value as object).length === 0) return null;
}
return sql<T>`${JSON.stringify(value)}::text::jsonb`;
}
/**
* READ-side counterpart to {@link jsonbBind}: tolerantly decode a jsonb value
* read back from the DB and validate its shape with `guard`. THE single place
* the legacy double-encoding self-heal lives, so repos keep only a type-guard.
*
* A row written by the old `::jsonb` bind round-trips as a JSON STRING (see the
* quirk in jsonbBind), so the driver hands back e.g. `'["a"]'` / `'{"k":1}'`
* rather than the structure. This parses such a string once, then applies the
* caller's `guard`. Returns `null` for null / an unparseable string / a value
* the guard rejects (so a corrupt or wrong-shaped value degrades to "unset").
*/
export function parseJsonbValue<T>(
value: unknown,
guard: (v: unknown) => v is T,
): T | null {
let v: unknown = value;
if (typeof v === 'string') {
try {
v = JSON.parse(v); // legacy double-encoded read
} catch {
return null;
}
}
return guard(v) ? v : null;
}

View File

@@ -41,3 +41,35 @@ describe('UpdateAiSettingsDto.chatApiStyle', () => {
expect(errs.find((e) => e.property === 'chatApiStyle')).toBeUndefined();
});
});
/** DTO validation for the new chatContextWindow field (@IsInt @Min(0)). */
describe('UpdateAiSettingsDto.chatContextWindow', () => {
const errorsFor = async (chatContextWindow: unknown) =>
validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow }));
it('accepts a non-negative integer (incl. 0 = clear the limit)', async () => {
for (const v of [0, 200000]) {
const errs = await errorsFor(v);
expect(
errs.find((e) => e.property === 'chatContextWindow'),
).toBeUndefined();
}
});
it('rejects a negative value', async () => {
const errs = await errorsFor(-1);
expect(errs.find((e) => e.property === 'chatContextWindow')).toBeDefined();
});
it('rejects a non-integer value', async () => {
const errs = await errorsFor(1.5);
expect(errs.find((e) => e.property === 'chatContextWindow')).toBeDefined();
});
it('accepts the field being omitted (optional)', async () => {
const errs = await validate(plainToInstance(UpdateAiSettingsDto, {}));
expect(
errs.find((e) => e.property === 'chatContextWindow'),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import { parsePositiveInt } from './ai-settings.service';
/**
* Round-trip coercion for numeric `::text` provider settings (e.g.
* chatContextWindow). Values are stored as text and read back as strings, so
* this guards the read path the DTO write-validation does not cover: a silent
* loss of `Math.floor` or a `> 0` → `>= 0` drift would otherwise go unnoticed.
*/
describe('parsePositiveInt', () => {
it('keeps a valid positive integer string', () => {
expect(parsePositiveInt('200000')).toBe(200000);
});
it('floors a fractional string', () => {
expect(parsePositiveInt('1.9')).toBe(1);
expect(parsePositiveInt('1.0')).toBe(1);
});
it('returns undefined for zero', () => {
expect(parsePositiveInt('0')).toBeUndefined();
});
it('returns undefined for a negative value', () => {
expect(parsePositiveInt('-5')).toBeUndefined();
});
it('returns undefined for an empty string', () => {
expect(parsePositiveInt('')).toBeUndefined();
});
it('returns undefined for a non-numeric string', () => {
expect(parsePositiveInt('abc')).toBeUndefined();
});
it('returns undefined for undefined / null', () => {
expect(parsePositiveInt(undefined)).toBeUndefined();
expect(parsePositiveInt(null)).toBeUndefined();
});
it('accepts a real number too (not only ::text strings)', () => {
expect(parsePositiveInt(42)).toBe(42);
});
});

View File

@@ -18,6 +18,18 @@ import {
PROVIDER_SETTINGS_KEYS,
} from './ai.types';
/**
* Coerce a raw provider value (stored as `::text`, so it arrives as a string —
* see workspace.repo.ts) into a positive integer, or `undefined` when it is not
* a finite number greater than zero. Used for numeric `::text` settings such as
* `chatContextWindow`. Fractions are floored: `"1.9" → 1`, `"0"`/`"-5"`/`""`/
* `"abc"`/`undefined` → `undefined`.
*/
export function parsePositiveInt(raw: unknown): number | undefined {
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
}
/**
* Shape of the partial update accepted by `update`. Mirrors the validated
* controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined =
@@ -26,6 +38,8 @@ import {
export interface UpdateAiSettingsInput {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens for the chat header badge. 0/empty = no limit.
chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
embeddingModel?: string;
baseUrl?: string;
@@ -160,6 +174,9 @@ export class AiSettingsService {
const config: ResolvedAiConfig = {
driver: provider.driver,
chatModel: provider.chatModel,
// Max context window for the chat header badge denominator. Stored as
// ::text; 0/unset/invalid = no limit (undefined).
chatContextWindow: parsePositiveInt(provider.chatContextWindow),
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
chatApiStyle: provider.chatApiStyle,
// Cheap model id for the anonymous public-share assistant; reuses the chat
@@ -219,6 +236,10 @@ export class AiSettingsService {
async getMasked(workspaceId: string): Promise<MaskedAiSettings> {
const provider = await this.readProvider(workspaceId);
// Stored as ::text; coerce to a positive integer (or undefined) so the
// client receives a real number.
const chatContextWindow = parsePositiveInt(provider.chatContextWindow);
let hasApiKey = false;
let hasEmbeddingApiKey = false;
let hasSttApiKey = false;
@@ -243,6 +264,7 @@ export class AiSettingsService {
return {
driver: provider.driver,
chatModel: provider.chatModel,
chatContextWindow,
chatApiStyle: provider.chatApiStyle,
embeddingModel: provider.embeddingModel,
baseUrl: provider.baseUrl,

View File

@@ -32,6 +32,9 @@ export const CHAT_API_STYLES: ChatApiStyle[] = ['openai-compatible', 'openai'];
export interface AiProviderSettings {
driver: AiDriver;
chatModel: string;
// Max context window in tokens; surfaced to the chat header badge as the
// denominator ("current / max"). 0/unset = no limit (badge shows no denominator).
chatContextWindow?: number;
// Chat provider implementation for the `openai` driver. Unset → defaults to
// 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle.
chatApiStyle?: ChatApiStyle;
@@ -72,6 +75,7 @@ export interface AiProviderSettings {
export const PROVIDER_SETTINGS_KEYS = [
'driver',
'chatModel',
'chatContextWindow',
'chatApiStyle',
'embeddingModel',
'baseUrl',
@@ -98,6 +102,9 @@ export const PROVIDER_SETTINGS_KEYS = [
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens; surfaced to the chat header badge as the
// "current / max" denominator. 0/unset = no limit.
chatContextWindow?: number;
// Cheap model id for the public-share assistant; reuses the chat creds.
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts (empty/unset
@@ -116,6 +123,9 @@ export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
export interface MaskedAiSettings {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens; the chat header badge denominator. 0/unset =
// no limit.
chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
embeddingModel?: string;
baseUrl?: string;

View File

@@ -1,4 +1,4 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
import {
AI_DRIVERS,
AiDriver,
@@ -25,6 +25,13 @@ export class UpdateAiSettingsDto {
@IsString()
chatModel?: string;
// Max context window in tokens shown in the chat header badge. 0/empty =
// clear the limit (no denominator shown).
@IsOptional()
@IsInt()
@Min(0)
chatContextWindow?: number;
@IsOptional()
@IsIn(CHAT_API_STYLES)
chatApiStyle?: ChatApiStyle;

View File

@@ -1,4 +1,5 @@
import { Kysely } from 'kysely';
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
@@ -25,8 +26,16 @@ describe('AiAgentRoleRepo isolation + partial unique index [integration]', () =>
});
it('findById / listByWorkspace exclude soft-deleted rows', async () => {
const live = await repo.insert({ workspaceId: w1, name: 'Live', instructions: 'x' });
const dead = await repo.insert({ workspaceId: w1, name: 'Dead', instructions: 'x' });
const live = await repo.insert({
workspaceId: w1,
name: 'Live',
instructions: 'x',
});
const dead = await repo.insert({
workspaceId: w1,
name: 'Dead',
instructions: 'x',
});
await repo.softDelete(dead.id, w1);
expect(await repo.findById(live.id, w1)).toBeDefined();
@@ -38,7 +47,11 @@ describe('AiAgentRoleRepo isolation + partial unique index [integration]', () =>
});
it('findById of a W2 role from W1 context returns undefined (tenant isolation)', async () => {
const w2role = await repo.insert({ workspaceId: w2, name: 'W2Role', instructions: 'x' });
const w2role = await repo.insert({
workspaceId: w2,
name: 'W2Role',
instructions: 'x',
});
expect(await repo.findById(w2role.id, w2)).toBeDefined();
// Same id, wrong workspace context -> not visible.
@@ -58,21 +71,100 @@ describe('AiAgentRoleRepo isolation + partial unique index [integration]', () =>
});
it('same name is reusable after softDelete (partial unique index WHERE deleted_at IS NULL)', async () => {
const first = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
const first = await repo.insert({
workspaceId: w1,
name: 'Reusable',
instructions: 'x',
});
await repo.softDelete(first.id, w1);
// Now inserting the same name must succeed because the soft-deleted row is
// excluded from the partial unique index.
const second = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
const second = await repo.insert({
workspaceId: w1,
name: 'Reusable',
instructions: 'x',
});
expect(second.id).toBeDefined();
expect(second.id).not.toBe(first.id);
});
it('same name in W1 and W2 is allowed (unique is per-workspace)', async () => {
const a = await repo.insert({ workspaceId: w1, name: 'CrossTenant', instructions: 'x' });
const b = await repo.insert({ workspaceId: w2, name: 'CrossTenant', instructions: 'x' });
const a = await repo.insert({
workspaceId: w1,
name: 'CrossTenant',
instructions: 'x',
});
const b = await repo.insert({
workspaceId: w2,
name: 'CrossTenant',
instructions: 'x',
});
expect(a.id).toBeDefined();
expect(b.id).toBeDefined();
expect(a.id).not.toBe(b.id);
});
// model_config jsonb round-trip (issue #173 §1): the same double-encoding bug
// PR #172 fixed for tool_allowlist lived in jsonbObject. A DB round-trip is the
// only way to observe it — the write must land as a real jsonb OBJECT, and a
// legacy string-scalar row must self-heal on read (else the model override is
// silently dropped and the role falls back to the default model).
const jsonbTypeof = async (id: string): Promise<string | null> => {
const res = await sql<{ t: string | null }>`
SELECT jsonb_typeof(model_config) AS t
FROM ai_agent_roles WHERE id = ${id}
`.execute(db);
return res.rows[0]?.t ?? null;
};
it('insert stores model_config as a jsonb OBJECT and reads it back as an object', async () => {
const role = await repo.insert({
workspaceId: w1,
name: `Model-${randomUUID()}`,
instructions: 'x',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
});
expect(await jsonbTypeof(role.id)).toBe('object');
// The returned row is already normalized to an object.
expect(role.modelConfig).toEqual({
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
});
const found = await repo.findById(role.id, w1);
expect(found?.modelConfig).toEqual({
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
});
});
it('an empty model_config is normalized to null (no override)', async () => {
const role = await repo.insert({
workspaceId: w1,
name: `Empty-${randomUUID()}`,
instructions: 'x',
modelConfig: {},
});
// The column is SQL NULL, so jsonb_typeof returns SQL NULL (JS null).
expect(await jsonbTypeof(role.id)).toBeNull();
expect((await repo.findById(role.id, w1))?.modelConfig).toBeNull();
});
it('repairs a legacy double-encoded (string scalar) model_config on read', async () => {
const id = randomUUID();
// Seed the corrupt string-scalar shape the old `::jsonb` bind produced.
await sql`
INSERT INTO ai_agent_roles (id, workspace_id, name, instructions, model_config)
VALUES (
${id}, ${w1}, ${`Legacy-${id}`}, 'x',
to_jsonb(${'{"driver":"openai","chatModel":"gpt"}'}::text)
)
`.execute(db);
expect(await jsonbTypeof(id)).toBe('string'); // sanity: really corrupt
expect((await repo.findById(id, w1))?.modelConfig).toEqual({
driver: 'openai',
chatModel: 'gpt',
});
});
});

View File

@@ -0,0 +1,194 @@
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* AiMcpServerRepo `tool_allowlist` jsonb round-trip (PR #172 / issue #173 §3).
*
* The fix under test is a DB round-trip, so a unit test cannot observe it: the
* write must land as a real jsonb ARRAY (not a double-encoded string scalar),
* and the read must repair any legacy string-scalar rows. The read-side
* `parseToolAllowlist` MASKS a write regression (it parses the string back), so
* without this integration check, reverting `::text::jsonb` to `::jsonb` would
* keep every unit test green while silently corrupting the column again.
*/
describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () => {
let db: Kysely<any>;
let repo: AiMcpServerRepo;
let ws: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiMcpServerRepo(db as any);
ws = (await createWorkspace(db)).id;
});
afterAll(async () => {
await destroyTestDb();
});
const jsonbTypeof = async (id: string): Promise<string | null> => {
const res = await sql<{ t: string | null }>`
SELECT jsonb_typeof(tool_allowlist) AS t
FROM ai_mcp_servers WHERE id = ${id}
`.execute(db);
return res.rows[0]?.t ?? null;
};
it('insert stores the allowlist as a jsonb ARRAY (not a string scalar)', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
toolAllowlist: ['search', 'crawl'],
});
// The column holds a real jsonb array — the whole point of ::text::jsonb.
expect(await jsonbTypeof(row.id)).toBe('array');
// And the read returns a genuine string[], not a JSON string.
const found = await repo.findById(row.id, ws);
expect(found?.toolAllowlist).toEqual(['search', 'crawl']);
expect(Array.isArray(found?.toolAllowlist)).toBe(true);
});
it('an empty allowlist is normalized to null (no restriction), not []', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
toolAllowlist: [],
});
// The column is SQL NULL, so jsonb_typeof returns SQL NULL (JS null).
expect(await jsonbTypeof(row.id)).toBeNull();
expect((await repo.findById(row.id, ws))?.toolAllowlist).toBeNull();
});
it('repairs a legacy double-encoded (string scalar) row on read (self-heal)', async () => {
// Seed a row whose tool_allowlist is a jsonb STRING SCALAR holding the JSON
// text — exactly what the old `::jsonb` double-encoding produced.
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'["alpha","beta"]'}::text)
)
`.execute(db);
// Sanity: the seeded column really IS the corrupt string-scalar shape.
expect(await jsonbTypeof(id)).toBe('string');
// The repo read heals it back to a real string[].
expect((await repo.findById(id, ws))?.toolAllowlist).toEqual([
'alpha',
'beta',
]);
const enabled = await repo.listEnabled(ws);
const healed = enabled.find((r) => r.id === id);
expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']);
});
it('FAIL-OPEN: a present-but-corrupt tool_allowlist reads back as null (no restriction)', async () => {
// #185 re-review pt 8: normalizeRow's fail-open branch — the column is
// PRESENT but does not parse into a string[] (here a jsonb string scalar
// holding non-array JSON). The read must degrade to `null` ("no restriction"),
// not crash. (A warn is logged with the server id; not asserted here.)
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'{"not":"an array"}'}::text)
)
`.execute(db);
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
expect(await jsonbTypeof(id)).toBe('string');
// ...yet the read degrades to null (fail-open).
expect((await repo.findById(id, ws))?.toolAllowlist).toBeNull();
});
});
/**
* AiMcpServerRepo `instructions` text round-trip (#180). The column is plain
* text (no jsonb); blank/whitespace is normalized to null on both insert and
* update so an empty guide is never persisted.
*/
describe('AiMcpServerRepo instructions round-trip [integration]', () => {
let db: Kysely<any>;
let repo: AiMcpServerRepo;
let ws: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiMcpServerRepo(db as any);
ws = (await createWorkspace(db)).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('insert stores trimmed non-blank instructions and reads them back', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
instructions: ' Use search for fresh facts. ',
});
expect((await repo.findById(row.id, ws))?.instructions).toBe(
'Use search for fresh facts.',
);
});
it('insert normalizes blank/whitespace instructions to null', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
instructions: ' ',
});
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
});
it('insert with omitted instructions stores null', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
});
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
});
it('update sets, clears (blank => null), and leaves unchanged when absent', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
instructions: 'initial guide',
});
// Set a new value.
await repo.update(row.id, ws, { instructions: 'updated guide' });
expect((await repo.findById(row.id, ws))?.instructions).toBe(
'updated guide',
);
// Absent in the patch => unchanged.
await repo.update(row.id, ws, { name: 'renamed' });
expect((await repo.findById(row.id, ws))?.instructions).toBe(
'updated guide',
);
// Blank => cleared to null.
await repo.update(row.id, ws, { instructions: ' ' });
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
});
});

View File

@@ -0,0 +1,207 @@
import { randomUUID } from 'node:crypto';
import { Kysely } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageService } from 'src/core/page/services/page.service';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createUser,
} from './db';
/**
* #206 attach-1 — Duplicating a subtree where the SAME attachment is referenced
* by more than one page must copy a working blob/row for EVERY copy, not just
* the last page processed.
*
* Setup: root page A and child page B both embed the same image (attachmentId X,
* the attachment row owned by A in the DB). Duplicating A produces copies A' and
* B'. Before the fix the per-attachmentId map held a single entry, so B's entry
* clobbered A's and the row-ownership guard (`attachment.pageId !== oldPageId`)
* then skipped the only DB row entirely: zero blobs copied, zero new rows, both
* copies' images 404. The fix keys the map to a LIST and copies once per
* referencing page, dropping the broken guard.
*
* This drives the real PageService.duplicatePage against a real Postgres with a
* recording storage stub, and asserts: storage.copy called twice and two fresh
* attachment rows exist (one owned by A', one by B'), each matching the rewritten
* attachmentId in its page's content.
*/
describe('PageService.duplicatePage shared attachment [integration]', () => {
let db: Kysely<any>;
let pageRepo: PageRepo;
let pagePermissionRepo: PagePermissionRepo;
let pageService: PageService;
let workspaceId: string;
let spaceId: string;
let userId: string;
// Records every (source, dest) blob copy the service requests.
const copyCalls: Array<{ from: string; to: string }> = [];
const storageService = {
copy: async (from: string, to: string) => {
copyCalls.push({ from, to });
},
} as any;
// Duplicate persists transclusion/reference rows in best-effort try/catch
// blocks; a no-op stub keeps the harness focused on the attachment path.
const transclusionService = {
insertTransclusionsForPages: async () => {},
insertReferencesForPages: async () => {},
insertTemplateReferencesForPages: async () => {},
} as any;
const eventEmitter = { emit: () => true } as any;
function imageDoc(attachmentId: string) {
return {
type: 'doc',
content: [
{
type: 'image',
attrs: {
attachmentId,
src: `/api/files/${attachmentId}/image.png`,
width: '100%',
align: 'center',
},
},
],
};
}
beforeAll(async () => {
db = getTestDb();
pageRepo = new PageRepo(db as any, {} as any, eventEmitter);
// filterAccessiblePageIds short-circuits to the input ids when the space has
// no restricted pages, so groupRepo/cache (2nd/3rd ctor args) are never hit.
pagePermissionRepo = new PagePermissionRepo(
db as any,
{} as any,
{} as any,
);
pageService = new PageService(
pageRepo,
pagePermissionRepo,
undefined as any, // attachmentRepo (unused on duplicate path)
db as any,
storageService,
undefined as any, // attachmentQueue
undefined as any, // aiQueue
undefined as any, // generalQueue
eventEmitter,
undefined as any, // collaborationGateway
undefined as any, // watcherService
transclusionService,
);
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
userId = (await createUser(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('copies a shared attachment for every page that references it', async () => {
copyCalls.length = 0;
const attachmentId = randomUUID();
const pageAId = randomUUID();
const pageBId = randomUUID();
// Root A and child B both embed the same attachmentId.
await db
.insertInto('pages')
.values({
id: pageAId,
slugId: `a-${pageAId.slice(0, 8)}`,
title: 'A',
content: imageDoc(attachmentId) as any,
position: 'a0',
spaceId,
workspaceId,
creatorId: userId,
})
.execute();
await db
.insertInto('pages')
.values({
id: pageBId,
slugId: `b-${pageBId.slice(0, 8)}`,
title: 'B',
content: imageDoc(attachmentId) as any,
position: 'a0',
parentPageId: pageAId,
spaceId,
workspaceId,
creatorId: userId,
})
.execute();
// Single attachment row, owned by A.
await db
.insertInto('attachments')
.values({
id: attachmentId,
type: 'image',
filePath: `${spaceId}/${attachmentId}/image.png`,
fileName: 'image.png',
fileExt: 'png',
mimeType: 'image/png',
creatorId: userId,
workspaceId,
pageId: pageAId,
spaceId,
})
.execute();
const rootPage = await pageRepo.findById(pageAId);
const result = await pageService.duplicatePage(
rootPage as any,
undefined,
{ id: userId, workspaceId } as any,
);
const newRootId = result.id;
const newChildIds = result.childPageIds;
expect(newChildIds).toHaveLength(1);
const newChildId = newChildIds[0];
// Both pages' images were copied: one blob per referencing page.
expect(copyCalls).toHaveLength(2);
// Two fresh attachment rows exist, one owned by each copied page.
const newAttachments = await db
.selectFrom('attachments')
.selectAll()
.where('pageId', 'in', [newRootId, newChildId])
.where('workspaceId', '=', workspaceId)
.execute();
expect(newAttachments).toHaveLength(2);
const ownerIds = newAttachments.map((a) => a.pageId).sort();
expect(ownerIds).toEqual([newRootId, newChildId].sort());
// Each copied page's content points at a rewritten attachmentId that now has
// a real row (i.e. the image src resolves instead of 404ing).
for (const pageId of [newRootId, newChildId]) {
const page = await db
.selectFrom('pages')
.select(['content'])
.where('id', '=', pageId)
.executeTakeFirstOrThrow();
const node = (page.content as any).content[0];
expect(node.type).toBe('image');
const referencedId = node.attrs.attachmentId;
expect(referencedId).not.toBe(attachmentId); // remapped to a fresh id
const row = newAttachments.find((a) => a.id === referencedId);
expect(row).toBeDefined();
expect(row!.pageId).toBe(pageId);
}
});
});

View File

@@ -0,0 +1,133 @@
import { Kysely } from 'kysely';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageService } from 'src/core/page/services/page.service';
import { Page } from '@docmost/db/types/entity.types';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
} from './db';
/**
* #207 #7 — TOCTOU in PageService.movePage: two concurrent moves
* ("A under B" + "B under A") must NOT be able to persist a parent/child cycle.
*
* Before the fix the cycle check (getPageBreadCrumbs) and the UPDATE were two
* separate, unlocked statements, so both movers could read the same pre-write
* acyclic snapshot, both pass the guard, and persist A.parentPageId=B AND
* B.parentPageId=A. The fix runs the guard + UPDATE in one transaction behind a
* per-space advisory lock, so the moves serialize: whichever commits second
* sees the first's write and its guard rejects the cycle.
*
* This test drives the real PageService.movePage against a real Postgres,
* firing the two opposing moves concurrently, and asserts that no cycle ever
* persists (walking parentPageId from both pages always reaches a root with no
* repeated id) and that exactly one of the two opposing moves is rejected.
*/
describe('PageService.movePage concurrent A<->B cycle guard [integration]', () => {
let db: Kysely<any>;
let pageRepo: PageRepo;
let pageService: PageService;
let workspaceId: string;
let spaceId: string;
// A valid fractional-index position key; movePage validates the position.
const position = generateJitteredKeyBetween(null, null);
beforeAll(async () => {
db = getTestDb();
// Event emission is a side effect movePage performs but the cycle behaviour
// does not depend on; a no-op emitter keeps the harness minimal.
const eventEmitter = { emit: () => true } as any;
pageRepo = new PageRepo(db as any, {} as any, eventEmitter);
// Only pageRepo (1), db (4) and eventEmitter (9) are touched by movePage;
// the remaining constructor deps are unused on this path.
pageService = new PageService(
pageRepo,
undefined as any,
undefined as any,
db as any,
undefined as any,
undefined as any,
undefined as any,
undefined as any,
eventEmitter,
undefined as any,
undefined as any,
undefined as any,
);
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
async function findPage(id: string): Promise<Page> {
const page = await pageRepo.findById(id);
if (!page) throw new Error(`page ${id} not found`);
return page;
}
// Walk parentPageId upward from startId. Throws if a node repeats (cycle) or
// the walk fails to terminate; returns normally only when a root is reached.
async function assertReachesRoot(startId: string): Promise<void> {
const seen = new Set<string>();
let cur: string | null = startId;
let steps = 0;
while (cur) {
if (seen.has(cur)) {
throw new Error(`cycle detected: revisited ${cur}`);
}
seen.add(cur);
const row: { parentPageId: string | null } | undefined = await db
.selectFrom('pages')
.select('parentPageId')
.where('id', '=', cur)
.executeTakeFirst();
cur = row?.parentPageId ?? null;
if (++steps > 1000) {
throw new Error('parent walk did not terminate');
}
}
}
it('two opposing concurrent moves never persist a parent/child cycle', async () => {
// Repeat to exercise different scheduler interleavings of the two moves.
for (let i = 0; i < 8; i++) {
const a = await createPage(db, { workspaceId, spaceId, title: `A-${i}` });
const b = await createPage(db, { workspaceId, spaceId, title: `B-${i}` });
const movedA = await findPage(a.id);
const movedB = await findPage(b.id);
const results = await Promise.allSettled([
pageService.movePage(
{ pageId: a.id, parentPageId: b.id, position } as any,
movedA,
),
pageService.movePage(
{ pageId: b.id, parentPageId: a.id, position } as any,
movedB,
),
]);
// No cycle may have been persisted by either ordering.
await assertReachesRoot(a.id);
await assertReachesRoot(b.id);
// The serialization guarantees exactly one of the opposing moves wins;
// the other must be rejected as a subtree cycle.
const rejected = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected',
);
expect(rejected).toHaveLength(1);
expect(rejected[0].reason?.message).toMatch(/into its own subtree/);
}
});
});

View File

@@ -0,0 +1,134 @@
import { CamelCasePlugin, Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { PageService } from 'src/core/page/services/page.service';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
TEST_DATABASE_URL,
} from './db';
/**
* #207 #8 — recursive page-tree CTEs (ancestors in getPageBreadCrumbs,
* descendants in forceDelete) must not hang when a parent/child cycle already
* exists in the data. Before the fix neither CTE had a CYCLE clause or a depth
* cap, so a cycle (e.g. one persisted by the #7 TOCTOU race) made withRecursive
* loop forever — and since the move guard itself runs the ancestor CTE, a cycle
* would disable the very guard meant to prevent it.
*
* The fix adds a depth counter bounded by MAX_PAGE_TREE_DEPTH to both CTEs.
* These tests seed an A<->B cycle directly (bypassing the guard), then run the
* real CTE paths against Postgres with a short connection-level statement_timeout
* so a regression (an unbounded CTE) fails fast as a query timeout instead of a
* bounded result.
*/
describe('recursive page-tree CTEs cycle/depth guard [integration]', () => {
// Upper bound on rows the depth-capped CTEs can emit for a 2-node cycle: one
// row per depth level 0..MAX. Kept loose so the assertion does not couple to
// the exact constant, only to "bounded".
const BOUNDED_MAX_ROWS = 20_000;
let db: Kysely<any>;
// Dedicated Kysely whose connections carry a short statement_timeout, so an
// unbounded recursive CTE aborts quickly instead of hanging the suite.
let timeoutDb: Kysely<any>;
let workspaceId: string;
let spaceId: string;
beforeAll(async () => {
db = getTestDb();
timeoutDb = new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(TEST_DATABASE_URL, {
max: 2,
onnotice: () => {},
// Applied to every connection on connect: cap any single statement.
connection: { statement_timeout: 4000 },
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
}),
}),
plugins: [new CamelCasePlugin()],
});
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
});
afterAll(async () => {
await timeoutDb.destroy();
await destroyTestDb();
});
// Seed two fresh pages and wire them into a direct parent/child cycle,
// bypassing PageService.movePage's guard the way the #7 race would.
async function seedCycle(): Promise<{ aId: string; bId: string }> {
const a = await createPage(db, { workspaceId, spaceId, title: 'cycle-A' });
const b = await createPage(db, { workspaceId, spaceId, title: 'cycle-B' });
await db
.updateTable('pages')
.set({ parentPageId: b.id })
.where('id', '=', a.id)
.execute();
await db
.updateTable('pages')
.set({ parentPageId: a.id })
.where('id', '=', b.id)
.execute();
return { aId: a.id, bId: b.id };
}
function makeService(database: Kysely<any>): PageService {
const eventEmitter = { emit: () => true } as any;
const attachmentQueue = { add: async () => undefined } as any;
return new PageService(
undefined as any, // pageRepo (unused by these paths)
undefined as any, // pagePermissionRepo
undefined as any, // attachmentRepo
database as any, // db
undefined as any, // storageService
attachmentQueue, // attachmentQueue
undefined as any, // aiQueue
undefined as any, // generalQueue
eventEmitter, // eventEmitter
undefined as any, // collaborationGateway
undefined as any, // watcherService
undefined as any, // transclusionService
);
}
it('getPageBreadCrumbs returns a bounded result (no hang) when a cycle exists', async () => {
const { aId } = await seedCycle();
const service = makeService(timeoutDb);
// Must resolve (the depth cap stops the walk) rather than time out.
const crumbs = await service.getPageBreadCrumbs(aId);
expect(Array.isArray(crumbs)).toBe(true);
expect(crumbs.length).toBeGreaterThan(1);
expect(crumbs.length).toBeLessThanOrEqual(BOUNDED_MAX_ROWS);
});
it('forceDelete descendant CTE is bounded (no hang) and removes the cyclic pages', async () => {
const { aId, bId } = await seedCycle();
const service = makeService(timeoutDb);
// Must complete instead of looping on the descendant CTE.
await service.forceDelete(aId, workspaceId);
const survivors = await db
.selectFrom('pages')
.select('id')
.where('id', 'in', [aId, bId])
.execute();
expect(survivors).toHaveLength(0);
});
});

View File

@@ -1,33 +0,0 @@
# Отложенные интеграционные тесты `AiChatService.stream`
Статус: **открыто.** Это остаток от прежнего документа
`feature-test-coverage-deferred.md` (хвост тест-плана PR #49). Два из трёх
его разделов уже закрыты новой интеграционной обвязкой против реального
Postgres/Redis (`apps/server/test/integration/`, PR #115):
-**Раздел 1 — repo-тесты против БД.** Закрыт `ai-agent-roles-repo`,
`ai-chat-repo-find-by-creator`, `page-template-references-cascade`,
`workspace-repo-update-setting` (`*.int-spec.ts`).
-**Раздел 2 — достоверность Lua-окна cost-cap против реального Redis.**
Закрыт `public-share-workspace-limiter.int-spec.ts`.
-**Раздел 3 (ниже) — полная интеграция `AiChatService.stream`.** Всё ещё
не реализован; держим запись открытой, чтобы тест-долг не потерялся при
удалении исходного документа.
## Полная интеграция `AiChatService.stream` (рефактор R1-stream)
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
сценарии всё ещё отложены:
- **Запись чата, упавшего на первом ходу** (`onError`) — ассистентская
запись об ошибке должна сохраняться, даже когда первый ход стрима падает.
- **Жизненный цикл external-MCP клиентов** — клиенты закрываются и при
`throw`, и при `onFinish` (нет утечки соединений).
- **Анти-tamper: история восстанавливается из БД, а не из `body.messages`** —
клиент не может подменить историю через тело запроса.
Эти сценарии требуют сидирования SDK `streamText` (инъекция/seam колбэков
`onError` / `onFinish` / `onAbort` + `res.hijack`). Отложено, чтобы не
дестабилизировать 287-строчный `stream()`; делать вместе с выносом testable
turn-pipeline.

View File

@@ -1,127 +0,0 @@
# Дублирование определений инструментов: in-app агент vs standalone MCP-пакет
Статус: **частично закрыто.** Квирк «node как объект ИЛИ JSON-строка» вынесен
в общий хелпер `parseNodeArg` (см. «Прогресс» ниже); остальной долг (единый
реестр спеков + унификация конвертера) всё ещё открыт. Это forward-looking
стоимость поддержки, НЕ баг — код корректен сегодня. Держим запись открытой,
чтобы при росте набора инструментов долг не разъезжался молча.
## Прогресс
-**Квирк node-arg вынесен в хелпер** (`refactor/ai-chat-tool-spec-registry`,
PR #114). Шесть рукописных копий нормализации «node как объект ИЛИ
JSON-строка» свёрнуты в `parseNodeArg`: по одному источнику на пакет —
`packages/mcp/src/lib/parse-node-arg.ts` (standalone) и
`apps/server/src/core/ai-chat/tools/parse-node-arg.ts` (in-app). Две копии
намеренны (ESM/CJS-граница), поведение тождественно.
-**Единый реестр спеков** (схема + описание на инструмент) и **вывод
`DocmostClientLike` из реального типа** — отложены (см. «Фикс»): требуют
пересечения ESM/CJS-границы для данных+zod и ломают тест-стабы in-app
инструментов при точных типах. Делать инкрементально.
-**Унификация конвертера ProseMirror ↔ Markdown** — открыта (см. раздел
«Расширение …» ниже); на неё опирается план git-синка
(`docs/git-sync-plan.md`).
## Суть
Один и тот же набор инструментов поверх одного `DocmostClient` описан
**тремя независимыми рукописными слоями**. Каждое добавление инструмента или
правка его model-facing описания требует синхронной правки в 2–3 местах, а
parity-баги (расхождение копий) приходится чинить/переоткрывать дважды.
## Где дублируется (три слоя)
1. **Standalone MCP-сервер**`packages/mcp/src/index.ts` (~38 `registerTool`).
Для внешних MCP-клиентов (stdio/http). На каждый инструмент: zod-схема +
длинное model-facing описание + тонкий `execute`, вызывающий `DocmostClient`.
2. **Встроенный AI-чат**`apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts`
(~39 `tool({...})` через `ai`-SDK). Своя zod-схема + своё описание + свой
`execute` поверх ТОГО ЖЕ клиента (`@docmost/mcp` грузится в
`tools/docmost-client.loader.ts:188` через динамический `import()`).
3. **Ручная копия сигнатур** — интерфейс `DocmostClientLike` в
`apps/server/src/core/ai-chat/tools/docmost-client.loader.ts:9` (в комментарии
прямо: «Signatures here mirror that file exactly»), скопирован руками из
`packages/mcp/src/client.ts`.
## Что именно продублировано (с подтверждением по коду)
- **zod-схема + описание** каждого инструмента — в слоях 1 и 2 целиком.
- ~~**Квирк «node как объект ИЛИ JSON-строка»** реализован дважды (НЕ в общем
клиенте)~~ — **закрыто (PR #114):** вынесен в `parseNodeArg` (по хелперу на
пакет), 6 inline-копий устранены:
- in-app: `patchNode`, `insertNode`, `updatePageJson`
`apps/server/src/core/ai-chat/tools/parse-node-arg.ts`;
- standalone: `patch_node`, `insert_node`, `update_page_json`
`packages/mcp/src/lib/parse-node-arg.ts`.
- **Guardrail/семантика `transformPage` (dryRun)** описана в обоих:
`ai-chat-tools.service.ts:~935` и `index.ts:~1006`.
## Почему разделение слоёв 1 и 2 само по себе оправдано
У путей разный транспорт и auth-контекст, и это правильно держать раздельно:
in-app путь чеканит per-user JWT + provenance collab-токен (подписанная
agent-claim, `docmost-client.loader.ts:159``getCollabToken`; см. план §6.5),
а standalone обслуживает внешних клиентов по stdio/http. **Но** это оправдывает
два тонких адаптера (`execute` + auth-обвязка), а НЕ две рукописные копии
МЕТАДАННЫХ (схема + описание + квирки). Метаданные можно объявить один раз и
переиспользовать обоими транспортами.
## Доказательство стоимости (наблюдалось при фиксе edit_page_text)
При исправлении ложного «успеха» `edit_page_text` (refuse форматных правок +
`verify`-отчёт):
- **Поведение** легло в общий `DocmostClient` → автоматически дошло до обоих
агентов ОДНОЙ правкой. Это «хороший» случай — логика в едином источнике.
- **Описание** инструмента пришлось править ДВАЖДЫ: в `index.ts` (кодером) и
отдельно в `ai-chat-tools.service.ts:617`, где описание продолжало рекламировать
«Markdown wrappers tolerated via strip-and-retry» — ровно ту формулировку, что
ввела исходного агента в заблуждение. Копия молча разъехалась и какое-то время
встроенный агент получал устаревшую подсказку. Это и есть материализованный
parity-баг.
## Расширение: дублируется не только описания инструментов — ещё и конвертер (PM ↔ Markdown)
Зафиксировано при планировании встраивания git-синка (`docmost-sync` → gitmost,
нативная in-process интеграция). Та же болезнь «несколько рукописных копий одного
кода» теперь касается слоя конвертации ProseMirror ↔ Markdown и его lib, а не
только метаданных инструментов.
- **Копия в gitmost** — `packages/mcp/src/lib/`: `markdown-converter.ts` (~885
строк), `markdown-document.ts` (~136), `node-ops.ts`, `diff.ts`,
`docmost-schema.ts`. Канонизатора (`canonicalize.ts`) здесь НЕТ.
- **Копия в docmost-sync** — `packages/docmost-client/src/lib/`: тот же набор +
`canonicalize.ts` (~11 КБ, держит идемпотентность round-trip, SPEC §11) +
`markdown-document.ts` с режимом «тело + якоря, без тредов комментов»
(`includeCommentThreads:false`, на ~20 строк больше).
- **Третья копия (планируется)** — план git-синка вендорит чистую часть
конвертера в новый `packages/git-sync` (collab-файл не нужен: запись идёт
нативно через `openDirectConnection` + `@docmost/editor-ext`).
Копии уже молча разъехались (docmost-sync vs `packages/mcp`): `collaboration.ts`
~329 изменённых строк, `node-ops.ts` ~53, `markdown-converter.ts` ~24,
`markdown-document.ts` ~20. Отдельно: `docmost-schema.ts` в lib дублирует
**реальную** схему сервера `@docmost/editor-ext` (её использует collab/persistence)
— расхождение схем = риск битой конвертации нод.
Вывод: тот же фикс-вектор (единый источник правды), что и для инструментов, стоит
распространить на конвертер — общий пакет конвертации, потребляемый `mcp`,
`git-sync` и (в идеале) сервером. До конвергенции git-sync держит вендоренную
копию валидированного конвертера с гейтом round-trip против схемы `editor-ext`
(осознанный долг «третья копия сейчас, объединяем позже»).
## Фикс
Единый реестр спеков (полное устранение дублирования).** Вынести в
`packages/mcp` один источник на инструмент: `name` + zod-схема + model-facing
описание + общий хелпер нормализации node-строки (для patch/insert/update).
И `index.ts`, и `ai-chat-tools.service.ts` импортируют спеки и добавляют только
свой `execute`/auth. `DocmostClientLike` — выводить из типа реального клиента
(type-only import / генерация), а не копировать руками.
- Ограничение: `@docmost/mcp` — ESM-only, сервер грузит его через трюк
`new Function('import(specifier)')` (`docmost-client.loader.ts:174`), потому
что `module:commonjs` даунлевелит `import()` в `require()`. Реестр спеков
(данные + zod) должен пересекать ту же ESM/CJS-границу — выполнимо тем же
динамическим импортом; `ai`-SDK `tool()` и MCP `registerTool()` имеют разную
форму, поэтому реестр экспортирует транспорт-агностичные `{name, schema,
description}`, а каждая сторона оборачивает их сама. `zod` — общая зависимость
обоих пакетов, типы переносятся.

View File

@@ -1,534 +0,0 @@
# Git-sync: спека реализации (встраивание docmost-sync в gitmost)
Статус: **спецификация, код не менялся.** Детальный план реализации фичи
«двусторонний синк страниц Docmost ↔ локальная git-папка Markdown», встроенной
прямо в gitmost.
Источник движка: `https://gitea.vvzvlad.xyz/vvzvlad/docmost-sync`
(ветка `main`, на момент спеки HEAD `b03eb35`). Все сигнатуры ниже сверены с этим
исходником и с текущим кодом gitmost.
Предыстория и обоснование архитектурных развилок — в бэклоге
[ai-chat-tool-definitions-duplicated.md](backlog/ai-chat-tool-definitions-duplicated.md)
(раздел про дублирование конвертера) и в исходном `SPEC.md` репозитория
docmost-sync (нумерация §-параграфов ниже ссылается на него).
---
## 0. Зафиксированные решения
Из обсуждения архитектуры (выбор пользователя) и трёх суб-решений:
1. **Нативная in-process интеграция.** Никаких REST-к-себе и сервис-юзера: чтение
через репозитории gitmost, запись тела — через collab `openDirectConnection`,
триггеры — через `EventEmitter2` вместо поллинга `/recent`.
2. **Встроенный NestJS-модуль** `GitSyncModule` в `apps/server/src/integrations/git-sync`
с `@Interval`/событиями и **leader-lock на Redis** (single-writer при нескольких
репликах).
3. **Настройка по спейсам в UI** — флаг в `space.settings.gitSync`, секреты
(git-remote) — через ENV/`EnvironmentService`.
4. **Конвертер** — вендорим *чистую* часть из docmost-sync в `packages/git-sync`,
гейт = round-trip-идемпотентность против схемы `@docmost/editor-ext`.
5. **Vault****репозиторий на спейс**; `move-to-space` = кросс-репо delete+create.
6. **Провенанс** — отдельное значение `lastUpdatedSource = 'git-sync'`.
Вне scope v1 (как и в SPEC): комментарии (только якоря, без тредов), права/ACL,
вложения как отдельный поток (едут ссылками внутри контента), realtime-подписка
на Hocuspocus (остаётся поллинг-страховка + события).
---
## 1. Архитектура верхнего уровня
```
gitmost server (NestJS, один процесс)
┌─────────────────────────────────────────────────────────────┐
│ GitSyncModule │
│ │
│ GitSyncOrchestrator ── @Interval + Redis leader-lock │
│ │ (per enabled space: pull-cycle / push-cycle) │
│ │ │
│ ├── engine (vendored docmost-sync, IO инжектируется) │
│ │ pull.ts / push.ts / reconcile / layout / stabilize │
│ │ │
│ ├── GitmostDataSource ── реализует подмножество │
│ │ DocmostClient НАТИВНО: │
│ │ reads → PageRepo / SpaceRepo (Kysely) │
│ │ writes → CollaborationGateway.openDirectConnection│
│ │ + PageService (create/move/delete/...) │
│ │ │
│ └── VaultGit ── shell-out в системный git (как есть) │
│ │
│ PageChangeListener ── подписка на EventName.PAGE_* → │
│ debounce → enqueue push-cycle │
└─────────────────────────────────────────────────────────────┘
▲ читает/пишет страницы ▼ git push/pull
PostgreSQL (pages/spaces) data/git-sync/<spaceId>/ (vault) → remote
```
Ключ интеграции: движок docmost-sync уже **полностью построен на dependency
injection** — весь внешний IO (REST-клиент, git, файловая система) передаётся
через узкие интерфейсы. Мы НЕ переписываем движок; мы подставляем нативные
реализации в его DI-швы.
---
## 2. Состав вендоринга из docmost-sync
В новый пакет `packages/git-sync` копируем (с сохранением истории смысла —
backport-friendly, как сделано с `packages/mcp`):
### 2.1. Движок (engine) — `src/engine/`
| Файл | Что несёт | IO | Берём |
| --- | --- | --- | --- |
| `pull.ts` | Docmost→FS: reconcile + write + commit + merge | client+git+fs (инжектируется) | да |
| `push.ts` | FS→Docmost: diff + classify + apply + refs | client+git+fs (инжектируется) | да |
| `git.ts` | `VaultGit` — обёртка git shell-out | системный `git` | да, как есть |
| `reconcile.ts` | чистый планировщик | нет | да |
| `layout.ts` | чистый маппер дерево→пути | нет | да |
| `sanitize.ts` | чистая санитизация имён | нет | да |
| `stabilize.ts` | fixpoint-нормализация md (SPEC §11) | нет (lib-вызовы) | да |
| `loop-guard.ts` | `bodyHash` (sha256) | нет | да |
| `settings.ts` | zod-конфиг | `.env` | **адаптируем** (см. §7) |
| `index.ts` | тонкий CLI-скаффолд | — | нет (заменяем на NestJS) |
### 2.2. Конвертер (чистая часть) — `src/lib/`
Из `packages/docmost-client/src/lib/` берём **только** чистый конвертер и формат
файла (collab/auth REST-части НЕ нужны — запись нативная):
| Файл | Экспорт |
| --- | --- |
| `markdown-converter.ts` | `convertProseMirrorToMarkdown(content): string` |
| `collaboration.ts` (только конвертер-функция) | `markdownToProseMirror(md): Promise<doc>` ⚠️ |
| `markdown-document.ts` | `serializeDocmostMarkdownBody`, `parseDocmostMarkdown`, `serializeDocmostMarkdown`, тип `DocmostMdMeta` |
| `canonicalize.ts` | `canonicalizeContent(node)`, `docsCanonicallyEqual(a,b)` |
| `docmost-schema.ts` | tiptap-схема для `markdownToProseMirror` |
| `node-ops.ts`, `diff.ts` | трансформации/диф (нужны транзитивно) |
⚠️ `markdownToProseMirror` физически лежит в `collaboration.ts` docmost-client
(строка 289) — это **чистая** функция (marked→HTML→generateJSON), не путать с
collab/websocket write-path из того же файла, который НЕ берём.
> **Долг (зафиксирован в бэклоге):** это третья копия конвертера (есть в
> docmost-sync, в `packages/mcp`, теперь в `packages/git-sync`). Конвергенция в
> общий пакет — отдельная задача; здесь сознательно вендорим валидированную
> копию ради сохранения идемпотентности.
### 2.3. НЕ берём
`pull`/`push` CLI-обёртки, `roundtrip.ts` (харнес переносим в тесты, см. §13),
`docmost-client` REST-клиент целиком, `lib/collaboration.ts` (websocket-write),
`lib/auth-utils.ts`, `Makefile`, Docker-обвязку docmost-sync.
---
## 3. Главный шов: `GitmostDataSource`
Движок дёргает Docmost через `Pick<DocmostClient, …>`. Мы реализуем класс,
**структурно совместимый** с этими сигнатурами, но нативный внутри. Это
единственный нетривиальный новый код.
### 3.1. Точный набор методов, которых требует движок
Из `pull.ts` (`ApplyPullActionsDeps.client`) и обхода дерева:
```ts
listSpaceTree(spaceId: string, rootPageId?: string): Promise<{ pages: PageNode[]; complete: boolean }>;
getPageJson(pageId: string): Promise<{ id; slugId; title; parentPageId; spaceId; updatedAt; content }>;
```
Из `push.ts` (`ApplyPushDeps.client`):
```ts
importPageMarkdown(pageId: string, fullMarkdown: string): Promise<{ updatedAt?: string; /* … */ }>;
createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{ data: { id: string }; updatedAt?: string }>;
deletePage(pageId: string): Promise<unknown>;
movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>;
renamePage(pageId: string, title: string): Promise<unknown>;
```
Для непрерывного режима/детекции удалений (фаза B+, SPEC §8):
```ts
listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<any[]>;
listTrash(spaceId: string): Promise<any[]>;
restorePage(pageId: string): Promise<unknown>;
```
### 3.2. Маппинг на нативные сервисы gitmost
| Метод адаптера | Нативная реализация |
| --- | --- |
| `listSpaceTree(spaceId)` | `SpaceRepo.findById(spaceId, wsId)` + `PageRepo.getSpaceDescendants(spaceId, { includeContent: false })` → map в `PageNode { id, title, slugId, parentPageId, hasChildren }`. **`complete: true` всегда** (читаем БД, не пагинированный REST) → суппрессия `incomplete-fetch` из SPEC §8 нативно не срабатывает. |
| `getPageJson(pageId)` | `PageRepo.findById(pageId, { includeContent: true })``{ id, slugId, title, parentPageId, spaceId, updatedAt, content }`. `content` — ProseMirror JSON в схеме `editor-ext`. |
| `importPageMarkdown(pageId, fullMd)` | `parseDocmostMarkdown(fullMd)` → body; `await markdownToProseMirror(body)` → doc; **запись через collab** (см. §3.3). Вернуть `{ updatedAt }` свежей страницы. |
| `createPage(title, body, spaceId, parent?)` | `PageService.create(userId, wsId, { spaceId, title, parentPageId }, provenance)` → shell; затем тело через collab (§3.3). Вернуть `{ data: { id }, updatedAt }`. |
| `deletePage(pageId)` | `PageService.removePage(pageId, userId, wsId)` (soft-delete → Trash, обратимо). |
| `movePage(pageId, parent, pos?)` | `PageService.movePage({ pageId, parentPageId: parent, position }, movedPage, provenance)`. **`position` обязателен** для Docmost-move — вычисляем `fractional-indexing-jittered` ключ между соседями (соседей берём из `PageRepo`). |
| `renamePage(pageId, title)` | `PageService.update(page, { title }, user, provenance)`. |
| `listRecentSince` | `PageRepo.getRecentPagesInSpace(spaceId, { … })`, фильтр по `updatedAt > since`. |
| `listTrash(spaceId)` | `PageRepo` запрос с `deletedAt IS NOT NULL` по спейсу. |
| `restorePage(pageId)` | `PageService.restore(...)`. |
`userId`/`wsId` берём из конфигурации спейса (сервисный аккаунт воркспейса или
владелец спейса — см. §7). `provenance` всегда несёт `source: 'git-sync'` (§8).
### 3.3. Нативная запись тела (linchpin)
Подтверждено в коде: `CollaborationGateway.openDirectConnection(documentName, context)`
([collaboration.gateway.ts:148](../apps/server/src/collaboration/collaboration.gateway.ts#L148-L150))
+ паттерн `withYdocConnection`
([collaboration.handler.ts:118-133](../apps/server/src/collaboration/collaboration.handler.ts#L118-L133)).
Имя документа — `page.<pageId>` ([getPageId](../apps/server/src/collaboration/collaboration.util.ts#L163-L165)).
Схему берём из `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)).
```ts
// In-process body write — no loopback websocket, no service-user token.
// Mirrors collaboration.handler.ts 'replace' operation exactly.
private async writeBody(pageId: string, prosemirrorJson: JSONContent): Promise<void> {
const conn = await this.collabGateway.openDirectConnection(
`page.${pageId}`,
{ actor: 'git-sync' }, // provenance flows into PersistenceExtension (see §8)
);
try {
await conn.transact((doc) => {
const fragment = doc.getXmlFragment('default');
if (fragment.length > 0) fragment.delete(0, fragment.length);
const next = TiptapTransformer.toYdoc(prosemirrorJson, 'default', tiptapExtensions);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(next));
});
} finally {
await conn.disconnect();
}
// PersistenceExtension.onStoreDocument persists ydoc+content+textContent
// consistently, stamps lastUpdatedSource, broadcasts 'page.updated'.
}
```
**Схема-совместимость (критично).** `markdownToProseMirror` производит
ProseMirror JSON в схеме docmost-client, а `TiptapTransformer.toYdoc` валидирует
его в схеме `editor-ext`. Аналогично на чтении `convertProseMirrorToMarkdown`
получает `content` в схеме `editor-ext`. Эти две схемы **должны совпадать по
именам нод/марок/атрибутов**, иначе ноды потеряются. Это и есть гейт §13.1.
---
## 4. `VaultGit` и git-бинарь
`VaultGit` (engine/git.ts) оставляем как есть — он шеллит в системный `git` через
`execFile` (args-массив, без инъекций), всегда `cwd=<vaultPath>`. Константы:
`DEFAULT_BRANCH = "main"`, `BOT_AUTHOR_NAME = "Docmost Sync"`,
`BOT_AUTHOR_EMAIL = "docmost-sync@local"`; в push.ts: `DOCMOST_BRANCH = "docmost"`,
`LAST_PUSHED_REF = "refs/docmost/last-pushed"`, провенанс-трейлеры
`Docmost-Sync-Source: docmost|local`.
**Ops-требование:** в рантайм-образ gitmost добавить пакет `git`
([Dockerfile](../Dockerfile)) — сейчас его там может не быть. Без бинаря
`VaultGit.assertGitAvailable()` падает на старте цикла.
**Модель веток (пер-репо, SPEC §5):** `main` (правит человек/файлы) ↔ `docmost`
(зеркало Docmost, пишет только движок) ↔ `merge-base` как базлайн;
`refs/docmost/last-pushed` — что из `main` уже отражено в Docmost.
---
## 5. Топология vault: репозиторий на спейс
- Корень: `<DATA_DIR>/git-sync/<spaceId>/` — отдельный git-репо на каждый
включённый спейс. `layout.ts` уже спейс-скоупный (корень спейса → `segments: []`).
- Remote — пер-спейс (из конфигурации спейса/ENV). Изоляция конфликтов, блокировок
и blast-radius.
- `move-to-space` (страница меняет спейс) → **кросс-репо**: `delete` в исходном
репо + `create` в целевом. Ловим по событию `PAGE_MOVED_TO_SPACE`.
- Redis-lock ключ — `git-sync:lock:<spaceId>` (§9).
---
## 6. NestJS-модуль `GitSyncModule`
Структура (шаблон — `McpModule`):
```
apps/server/src/integrations/git-sync/
git-sync.module.ts
git-sync.constants.ts # QueueJob/event-имена, дефолты
services/
gitmost-datasource.service.ts # §3 адаптер
git-sync.orchestrator.ts # @Interval + leader-lock + цикл по спейсам
vault-registry.service.ts # путь vault на спейс, VaultGit-инстансы
fractional-index.util.ts # position для move (reuse server util)
listeners/
page-change.listener.ts # подписка на EventName.PAGE_* + debounce
git-sync.controller.ts # (опц.) ручной trigger/status для админа
```
```ts
@Module({
imports: [DatabaseModule, EnvironmentModule, ScheduleModule.forRoot()],
providers: [
GitmostDataSourceService,
GitSyncOrchestrator,
VaultRegistryService,
PageChangeListener,
],
})
export class GitSyncModule {}
```
- Регистрируем в [app.module.ts](../apps/server/src/app.module.ts) рядом с `McpModule`.
- Зависимости: `PageRepo`/`SpaceRepo` (через `DatabaseModule`), `PageService`,
`CollaborationGateway` (экспортировать из `CollaborationModule`),
`EnvironmentService`, ioredis-клиент.
- `ScheduleModule.forRoot()` уже подключается в `TelemetryModule`; повторный вызов
безопасен, но лучше вынести в общий модуль или убедиться, что forRoot один раз.
---
## 7. Конфигурация
### 7.1. Per-space (UI) — `space.settings.gitSync`
Расширяем существующий паттерн `settings.sharing` / `settings.comments`.
Сервер:
- `UpdateSpaceDto` ([update-space.dto.ts](../apps/server/src/core/space/dto/update-space.dto.ts)):
добавить `@IsOptional() @IsBoolean() gitSyncEnabled?: boolean;` (+ опц.
`gitSyncRemote?: string`, если решим хранить remote в БД, а не только в ENV).
- `SpaceService.updateSpace(dto, wsId)`
([space.service.ts:120](../apps/server/src/core/space/services/space.service.ts#L120)):
обработать как `disablePublicSharing`/`allowViewerComments`.
- `SpaceRepo`: добавить `updateGitSyncSettings(spaceId, wsId, prefKey, prefValue, trx?)`
по образцу `updateSharingSettings`
([space.repo.ts:92](../apps/server/src/database/repos/space/space.repo.ts#L92)) —
jsonb-merge в `settings.gitSync.<key>`.
- Гард: CASL `SpaceCaslAction.Manage / SpaceCaslSubject.Settings` (как в
[space.controller.ts:147](../apps/server/src/core/space/space.controller.ts#L147)).
Клиент:
- Тоггл в форме настроек спейса
([edit-space-form.tsx](../apps/client/src/features/space/components/edit-space-form.tsx))
через `useUpdateSpaceMutation()``updateSpace({ spaceId, gitSyncEnabled })`.
Образец — `mcp-settings.tsx`. `readOnly` при отсутствии `Manage/Settings`.
Форма `space.settings.gitSync`:
```jsonc
{ "gitSync": { "enabled": true, "remote": "git@…", "branch": "main" } }
```
### 7.2. Секреты/тюнинг (ENV) — `EnvironmentService`
Движковый `settings.ts` (zod, читает `.env`) **заменяем** на чтение из gitmost
`EnvironmentService`: `parseSettings(env)` оставляем как чистую функцию для тестов,
но в проде собираем `Settings` из `EnvironmentService`-геттеров.
Новые переменные (объявить в
[environment.validation.ts](../apps/server/src/integrations/environment/environment.validation.ts)
class-validator-декораторами, геттеры — в
[environment.service.ts](../apps/server/src/integrations/environment/environment.service.ts)):
| ENV | Назначение | Обяз. |
| --- | --- | --- |
| `GIT_SYNC_ENABLED` | глобальный мастер-выключатель | нет (default false) |
| `GIT_SYNC_DATA_DIR` | корень vault'ов (default `<DATA_DIR>/git-sync`) | нет |
| `GIT_SYNC_REMOTE_TEMPLATE` | шаблон remote, напр. `git@host:vault-{spaceId}.git` | нет |
| `GIT_SYNC_SSH_KEY_PATH` / креды remote | доступ к git-remote (secret) | по ситуации |
| `GIT_SYNC_POLL_INTERVAL_MS` | страховочный поллинг (default 15000) | нет |
| `GIT_SYNC_DEBOUNCE_MS` | окно дебаунса событий (default 2000) | нет |
| `GIT_SYNC_SERVICE_USER_ID` | от чьего имени писать в Docmost | да (если синк включён) |
> git-remote = доступ ко всей вики спейса (SPEC §12): креды только в ENV/secret
> store, никогда в БД/коммиты. В UI — только `enabled` (+ опц. имя remote из
> заранее разрешённого списка).
---
## 8. Провенанс и loop-guard
### 8.1. Значение `'git-sync'`
Сегодня `lastUpdatedSource ∈ { 'user', 'agent' }`
([persistence.extension.ts:132-134](../apps/server/src/collaboration/extensions/persistence.extension.ts#L132-L134)).
Добавляем `'git-sync'`:
- `PersistenceExtension`: `context.actor === 'git-sync'``lastUpdatedSource = 'git-sync'`.
- Снапшот истории для `'git-sync'` — дебаунс (как у человека), а не немедленный
(немедленный — только для `'agent'`,
[persistence.extension.ts:321](../apps/server/src/collaboration/extensions/persistence.extension.ts#L321)).
- Для `create/move/rename/delete` через `PageService` передаём
`AuthProvenanceData` c `source: 'git-sync'` (тип уже используется для агента —
расширить допустимые значения; точную форму подтвердить на реализации).
- Клиент: в истории
([history-item.tsx:128](../apps/client/src/features/page-history/components/history-item.tsx#L128))
не показывать агентский бейдж/дип-линк для `'git-sync'`; добавить значение в
тип [page.types.ts:23-26](../apps/client/src/features/page-history/types/page.types.ts#L23-L26)
(опц. свой бейдж «sync»).
### 8.2. Подавление петли (SPEC §10)
На pull-стороне игнорируем страницу как «свою запись», если:
`page.lastUpdatedSource === 'git-sync'` **И** `bodyHash(exportedBody)` совпадает
с последним запушенным (`PushedPageRecord.bodyHash` из `push.ts`). После записи в
Docmost сохраняем `updatedAt` ответа, чтобы поллинг-страховка не утянул свою же
запись обратно.
---
## 9. Single-writer (Redis leader-lock)
В кодовой базе `@Interval`-задачи (`trash-cleanup`, `telemetry`, `session-cleanup`)
**не защищены** от мультиинстанса. Для синка добавляем явный лок.
- ioredis уже есть (`RedisModule` из `@nestjs-labs/nestjs-ioredis`,
[app.module.ts](../apps/server/src/app.module.ts); прямой `RedisClient`
используется в collab-gateway).
- Лок на спейс: `SET git-sync:lock:<spaceId> <instanceId> NX PX <ttl>`; держим
цикл только при успехе, продлеваем по heartbeat, освобождаем в `finally`
(Lua-CAS на удаление по `instanceId`, чтобы не снять чужой лок).
- TTL > максимальной длительности цикла; на краше лок истекает сам.
```ts
// Acquire per-space leadership; returns false if another replica holds it.
private async acquire(spaceId: string): Promise<boolean> {
const ok = await this.redis.set(`git-sync:lock:${spaceId}`, this.instanceId, 'PX', LOCK_TTL_MS, 'NX');
return ok === 'OK';
}
```
---
## 10. Планировщик и событийные триггеры
- **События (основной триггер).** `PageChangeListener` подписывается на
`EventName.PAGE_CREATED | PAGE_UPDATED | PAGE_MOVED | PAGE_SOFT_DELETED |
PAGE_RESTORED | PAGE_MOVED_TO_SPACE` и job `PAGE_CONTENT_UPDATED`
([event.contants.ts](../apps/server/src/common/events/event.contants.ts)).
Фильтр по `spaceId` (только включённые спейсы) → дебаунс (`GIT_SYNC_DEBOUNCE_MS`)
→ ставит pull/push-цикл спейса в очередь оркестратора.
- Loop-guard: события от собственных записей (`source==='git-sync'` + совпавший
хэш) пропускаем (§8.2).
- **Поллинг-страховка.** `@Interval(GIT_SYNC_POLL_INTERVAL_MS)` в оркестраторе:
по каждому включённому спейсу (под локом) — реконсиляция (`listRecentSince` +
`listTrash`), ловит пропущенные события и стартовую сверку после простоя
(SPEC §12).
- Один цикл на спейс за раз (внутри-процессный мьютекс на `spaceId` поверх
Redis-лока).
---
## 11. Потоки данных (walkthroughs)
### 11.1. Первичный клон спейса (initial clone, SPEC §12)
1. `VaultGit.ensureRepo()` + `ensureBranch('docmost','main')` + `checkout('docmost')`.
2. `dataSource.listSpaceTree(spaceId)``{ pages, complete:true }`.
3. `readExisting({ listTracked: () => git.listTrackedFiles('*.md'), readFile })`.
4. `computePullActions({ pages, treeComplete:true, existing })` → план.
5. `applyPullActions(deps, actions, vaultRoot)`: на каждую страницу
`getPageJson``stabilizePageFile(content, meta)` (export→import→export
fixpoint, SPEC §11) → запись файла; затем `stageAll` + `commit` (трейлер
`docmost`) на `docmost`; `checkout('main')` + `merge('docmost')`.
6. Зафиксировать max `updatedAt` как стартовый `T_last`; `git push` в remote.
### 11.2. Docmost → FS (pull-цикл)
Триггер: событие/поллинг → (под локом) шаги §11.1 п.1–5 инкрементально. 3-way
merge `docmost→main` делает git: непересекающиеся правки сливаются, реальное
пересечение → conflict-маркеры в файле. **При конфликте push этой страницы в
Docmost блокируется** до ручного резолва (SPEC §9; фаза D).
### 11.3. FS → Docmost (push-цикл)
`runPush(deps, { dryRun })`:
1. `git.ensureRepo` / `isMergeInProgress` (abort при merge) / `checkout('main')`.
2. `stageAll` + `commit('local: working-tree changes')` (локально, в Docmost не шлёт).
3. База диффа: `readRef(LAST_PUSHED_REF)` ?? `docmost`; `revParse('main')``pushedCommit`.
4. `diffNameStatus(base, 'main')` → changes; префетч `metaAt(path, side)`.
5. `computePushActions({ changes, metaAt })` → creates/updates/deletes/renamesMoves/skipped.
6. `dryRun` → лог плана и выход (клиент НЕ создаётся).
7. `--apply`: `makeClient(settings)` → наш `GitmostDataSource`;
`applyPushActions`:
- update → `importPageMarkdown(pageId, fullMd)` (collab-write, §3.3);
- create → `createPage(...)` → записать присвоенный `pageId` обратно в meta;
- delete → `deletePage(pageId)` (Trash);
- rename/move → `classifyRenameMoves``movePage`/`renamePage`;
- при пустых failures: `updateRef(LAST_PUSHED_REF, pushedCommit)` +
`fastForwardBranch('docmost', pushedCommit)`.
8. Записать `bodyHash` + `updatedAt` (loop-guard, §8.2); `git push`.
---
## 12. Фазирование
- **A. Каркас + односторонний pull (нативно).** `packages/git-sync` (вендоринг
§2), `GitmostDataSource` (чтение через репозитории), `GitSyncModule`, конфиг из
`EnvironmentService`, ручной/однократный pull-цикл на один спейс. **Гейт §13.1.**
- **B. Push + непрерывность.** Нативная запись (§3.3), `runPush`, ветки/refs,
loop-guard (§8), Redis-лок (§9), `@Interval` + `PageChangeListener` (§10).
- **C. Per-space UI.** `space.settings.gitSync` (§7.1), DTO/сервис/репо/гард,
тоггл на клиенте, скоуп оркестратора по включённым спейсам.
- **D. Харднинг.** Conflict-gating (SPEC §9), удаления через Trash + git (§5),
стартовая реконсиляция и `move-to-space` кросс-репо, провенанс на клиенте,
Dockerfile `git`, полный набор тестов.
---
## 13. Тестирование
### 13.1. Гейт идемпотентности (блокирует фазу B)
Перенести round-trip-харнес docmost-sync (`roundtrip.ts` + `test/fixtures/corpus`)
в тесты `packages/git-sync`, но прогонять **против схемы `editor-ext`**:
`content (editor-ext) → convertProseMirrorToMarkdown → markdownToProseMirror →
TiptapTransformer.toYdoc(…, tiptapExtensions) → fromYdoc → canonicalizeContent`
должно давать `docsCanonicallyEqual === true`. Любая потеря нод/атрибутов =
расхождение схем → чинить `docmost-schema.ts` под `editor-ext`.
### 13.2. Юнит (чистая логика, переносится как есть)
`reconcile` (planReconciliation / decideAbsenceDeletions / mass-delete guards),
`layout` (коллизии/санитизация), `computePullActions`, `computePushActions`,
`classifyRenameMoves`, `bodyHash`.
### 13.3. Интеграция (нативный адаптер)
`GitmostDataSource` против тестовой БД: `listSpaceTree`/`getPageJson` корректно
маппят; `createPage`/`movePage`/`deletePage`/`importPageMarkdown` пишут через
collab и проставляют `lastUpdatedSource='git-sync'`; loop-guard не зацикливается
(write → poll → no-op).
### 13.4. e2e (под локом)
Полный pull→push round-trip на временном vault + временном спейсе: правка в
Docmost доезжает в файл и наоборот; конфликт даёт маркеры и блокирует push.
---
## 14. Риски и открытые пункты
1. **Схема-совместимость конвертера** (§3.3, §13.1) — главный риск; гейт
обязателен до фазы B.
2. **`AuthProvenanceData`** — точную форму типа подтвердить; возможно, потребует
расширения enum источника на сервере и в истории.
3. **Согласованность Yjs** — писать строго через `openDirectConnection`/`transact`;
не трогать `content`-колонку напрямую.
4. **`position` для move** — обязателен в Docmost-move; нужен
`fractional-indexing-jittered` между соседями (соседей брать сортировкой
`position COLLATE "C"`).
5. **`git` в рантайме** — добавить в Dockerfile.
6. **`ScheduleModule.forRoot()`** — не задублировать `forRoot`.
7. **Сервисный пользователь записи** (`GIT_SYNC_SERVICE_USER_ID`) — от чьего имени
идут create/move (влияет на `creatorId`/права); согласовать политику.
8. **Конфликты и удаления** — фаза D строго по SPEC §8/§9 (маркеры никогда не
уезжают в Docmost).
---
## 15. Чек-лист изменений по файлам
**Новый пакет**
- `packages/git-sync/**` — движок + чистый конвертер (§2), `package.json`
(`@docmost/git-sync`, `workspace:*`), `tsconfig.json`.
**Сервер (`apps/server/src`)**
- `integrations/git-sync/**` — модуль, оркестратор, адаптер, листенер (§6).
- `app.module.ts` — импорт `GitSyncModule`.
- `collaboration/collaboration.module.ts` — экспорт `CollaborationGateway`.
- `collaboration/extensions/persistence.extension.ts` — источник `'git-sync'` (§8.1).
- `core/space/dto/update-space.dto.ts``gitSyncEnabled?` (§7.1).
- `core/space/services/space.service.ts` — обработка флага.
- `database/repos/space/space.repo.ts``updateGitSyncSettings` (§7.1).
- `integrations/environment/environment.validation.ts` + `environment.service.ts`
новые ENV (§7.2).
- `Dockerfile` — пакет `git`.
**Клиент (`apps/client/src`)**
- `features/space/components/edit-space-form.tsx` — тоггл git-sync.
- `features/space/types` — поле `settings.gitSync`.
- `features/page-history/types/page.types.ts` + `components/history-item.tsx`
значение `'git-sync'` в `lastUpdatedSource`.
**Корень**
- `pnpm-workspace.yaml` уже покрывает `packages/*`; `apps/server/package.json`
зависимость `@docmost/git-sync: workspace:*`.

View File

@@ -1,359 +0,0 @@
# Мобильное приложение gitmost — исследование и план
> Статус: исследовательский + проектный документ.
> Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного
> мобильного (нативного/устанавливаемого) приложения **нет**.
> Цель: определить путь к мобильным приложениям — **iOS обязательно, Android
> как пойдёт** — с заделом на оффлайн в будущем (оффлайн сейчас не требуется).
Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён
устройством продукта, сравнивает варианты и описывает рекомендуемый план с
привязкой к файлам.
---
## 1. TL;DR
1. **Нативного приложения нет.** В проекте отсутствуют Capacitor, React Native,
Cordova и т.п. Мобильного клиента ещё не начинали.
2. **Адаптивная веб-версия — есть, и довольно проработанная.** Веб-клиент
открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer,
отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive-
примитивы Mantine, mobile-tuned `viewport`. Это готовый фундамент UI.
3. **Ядро продукта — веб-редактор — нативно не воспроизвести.** TipTap 3
(ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с
React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь
оставляет редактор в **WebView**.
4. **API уже готов к нативному клиенту.** Сервер принимает JWT не только из
cookie, но и из заголовка `Authorization: Bearer`. Есть точка входа для
вебсокета совместного редактирования (`POST /auth/collab-token`).
5. **Рекомендуемый путь — Capacitor:** обернуть существующий React-SPA в
нативную оболочку (iOS + Android из одного кода), добавить нативные плагины
(push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация +
WebView-редактор) делается потом инкрементально, без переписывания.
6. **Оффлайн-будущее уже заложено** (Yjs + `y-indexeddb`). Детальный план —
в [offline-sync-plan.md](offline-sync-plan.md); мобильное приложение этот
план переиспользует, а не дублирует.
7. **Главный блокер — не технический, а лицензионный.** AGPL форка несовместима
с условиями App Store, если зашивать веб-клиент в бинарник: DRM/usage-rules
Apple = «дополнительные ограничения», запрещённые AGPLv3 §10. Развязки —
грузить клиент с сервера (не из `.ipa`), PWA или sideload. Детали и матрица —
в §9; закрывать **до** кода обёртки.
---
## 2. Текущее состояние (как есть)
### 2.1. Стек
| Слой | Технологии |
|---|---|
| Бэкенд | NestJS 11 + Fastify, Kysely/Postgres, Redis/BullMQ. API в стиле RPC-POST (соглашение Docmost). Аутентификация — JWT. |
| Фронт | React 18 + Vite + Mantine + TanStack Query + i18next. Обычный SPA. |
| Ядро (редактор) | TipTap 3 (ProseMirror) + совместное редактирование на Yjs через Hocuspocus — см. [page-editor.tsx](../apps/client/src/features/editor/page-editor.tsx). |
| Оффлайн-фундамент | `yjs` + `y-indexeddb` уже в зависимостях клиента (локальная CRDT-копия тела документа). |
### 2.2. Мобильного приложения нет
В `package.json` и `apps/*/package.json` нет `capacitor`, `react-native`,
`cordova`, `expo`. Нативной оболочки в репозитории не заведено.
### 2.3. Адаптивная веб-версия — есть
| Что | Где |
|---|---|
| Адаптивная оболочка Mantine `AppShell` с `breakpoint: "sm"`, раздельные состояния `collapsed.mobile` / `collapsed.desktop` | [global-app-shell.tsx](../apps/client/src/components/layouts/global/global-app-shell.tsx) (L85–99) |
| Отдельный мобильный сайдбар-drawer (`mobileSidebarAtom` отделён от `desktopSidebarAtom`), авто-закрытие при навигации по дереву | [sidebar-atom.ts](../apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts), [space-tree-row.tsx](../apps/client/src/features/page/tree/components/space-tree-row.tsx) (L147–148) |
| Мобильная модалка истории + свой CSS | [history-modal.tsx](../apps/client/src/features/page-history/components/history-modal.tsx) (L17–19), `history-modal-mobile.tsx` |
| Мобильный контрол поиска | [search-control.tsx](../apps/client/src/features/search/components/search-control.tsx) (L38–42) |
| Мобильный рендер хлебных крошек через `useMediaQuery` | [breadcrumb.tsx](../apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx) (L41) |
| Responsive-примитивы `hiddenFrom`/`visibleFrom` (~16 мест), медиа-запросы в CSS-модулях | по всему `apps/client/src` |
| Mobile-tuned viewport (`width=device-width, user-scalable=no`) | [index.html](../apps/client/index.html) (L8) |
> Важно: адаптив проверялся в мобильном **браузере**, а не в WebView нативной
> оболочки. Перед сборкой приложения нужно прогнать UI как PWA/в WebView и
> отловить отличия (жесты, экранная клавиатура/IME в редакторе, safe-area).
### 2.4. Готовность API к нативному клиенту
- **Bearer-токен уже поддержан.** JWT извлекается из cookie **или** из заголовка
`Authorization`: см. [jwt.strategy.ts](../apps/server/src/core/auth/strategies/jwt.strategy.ts) (L27–29).
Серверная сторона нативной авторизации менять не нужно.
- **Токен сейчас не возвращается в теле логина.** [`login`](../apps/server/src/core/auth/auth.controller.ts)
(L55–105) кладёт JWT только в `httpOnly`-cookie ([`setAuthCookie`](../apps/server/src/core/auth/auth.controller.ts) L222–230).
- **Точка входа вебсокета коллаборации:** [`POST /auth/collab-token`](../apps/server/src/core/auth/auth.controller.ts) (L187–193).
- **CORS открыт без конфигурации:** [`app.enableCors()`](../apps/server/src/main.ts) (L144).
- **OpenAPI/Swagger отсутствует** (`@nestjs/swagger` не подключён) — авто-генерации
типизированного клиента сейчас нет.
---
## 3. Почему путь к мобилке предопределён
Три факта диктуют решение независимо от моды:
1. **Редактор практически невозможно переписать нативно.** ProseMirror + весь
набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного
production-порта Yjs под Swift/Kotlin нет (есть Rust `yrs` с биндингами, но
это отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное
расхождение с веб-версией. **Вывод: редактор остаётся в WebView.**
2. **API уже умеет нативного клиента** (Bearer, collab-token).
3. **Оффлайн-фундамент уже заложен** на веб-уровне (Yjs + `y-indexeddb`),
и он работает внутри WebView.
---
## 4. Три возможных пути
| Путь | Суть | Плюсы | Минусы | Вердикт |
|---|---|---|---|---|
| **A. Полностью нативно** (Swift/Kotlin) | Переписать всё, включая редактор и CRDT-синк | Максимально нативный UX | Воспроизвести ProseMirror + расширения + Yjs; несоразмерные трудозатраты; вечное отставание от веба | ❌ Не наш случай |
| **B. WebView-обёртка SPA (Capacitor)** | Обернуть существующий React-клиент в нативную оболочку, native-возможности — плагинами | Реюз ~100% кода (редактор, коллаборация, оффлайн); один кодовый бэйз → iOS+Android; быстро | Менее «нативно»; риск отказа App Store за «просто сайт» (4.2) — лечится нативной ценностью | ✅ Рекомендуется |
| **C. Гибрид: нативная оболочка + WebView-редактор** | Навигация/списки/поиск/логин — нативно (React Native/Swift), экран редактирования — web в WebView | Лучший UX; путь Notion/Linear | Заметно больше работы; нужен мост JS↔native | ⚖️ Цель эволюции из B |
---
## 5. Рекомендуемый путь
**B (Capacitor) как первый релиз, с заложенной эволюцией в C.**
Почему:
- Capacitor создан под сценарий «есть веб-приложение → хочу его в App Store с
нативными возможностями». Переиспользуется весь React-клиент и, главное,
редактор — то, что нативно не сделать.
- Один кодовый бэйз закрывает «iOS обязательно» и «Android как пойдёт»
одновременно, без второй команды.
- Адаптивная вёрстка уже есть (см. §2.3) — переверстывать под телефон с нуля
не нужно; работа смещается в нативную обвязку.
- Оффлайн-будущее подготовлено (Yjs + `y-indexeddb`); см.
[offline-sync-plan.md](offline-sync-plan.md).
- Когда упрётесь в UX отдельных экранов — их по одному выносят в нативную
оболочку, оставив редактор в WebView. То есть B → C делается инкрементально.
Почему **не** чистый React Native сразу: редактор всё равно придётся держать в
WebView (ядро web-only), но при этом теряется прямой реюз остального React-кода
и появляется мост как обязательная сложность с первого дня — для iOS-first
старта это лишний оверхед.
> Альтернатива: если критичен максимально нативный UX с первого релиза и есть
> ресурс — сразу путь C на React Native (Expo) с WebView только под редактор.
> Это сознательный размен «больше работы сейчас» за «более нативное ощущение».
⚠️ **Лицензионная оговорка к iOS.** Обычный Capacitor зашивает веб-билд
`apps/client` в `.ipa` — для публикации в App Store это **нарушает AGPL**
(см. §9). Выбор Capacitor для **Android** остаётся в силе, но на **iOS**
веб-клиент нельзя бандлить в бинарник: либо грузить его с сервера
(`server.url`), либо PWA. То есть рекомендация «B (Capacitor)» применима к
Android как есть, а к iOS — только в конфигурации без зашитого AGPL.
---
## 6. Что доработать на бэкенде
Немного, но конкретно:
1. **Выдача токена в теле ответа для нативного хранения.** Сейчас логин кладёт
JWT только в `httpOnly`-cookie и не возвращает его в body. На мобиле
`httpOnly`-cookie между разными origin (`capacitor://localhost` ↔ API) — боль
с SameSite/CORS. Чище: мобильный логин-флоу, возвращающий JWT в ответе, чтобы
хранить его в Keychain/Keystore и слать как `Authorization: Bearer`. Сервер
уже принимает Bearer — менять надо только **выдачу**.
Файлы: [auth.controller.ts](../apps/server/src/core/auth/auth.controller.ts).
2. **CORS.** Сейчас [`app.enableCors()`](../apps/server/src/main.ts) (L144) без
конфигурации. Под мобильные origin'ы и для безопасности задать явный whitelist.
3. **Push-уведомления.** Модуль `notification` уже есть — добавить регистрацию
device-token и интеграцию **APNs** (iOS) / **FCM** (Android).
4. **Опционально — OpenAPI/Swagger.** Сейчас спецификации нет; добавить
`@nestjs/swagger` дёшево и сильно ускорит мобильную разработку
(типизированный клиент).
---
## 7. Android-специфика
На пути Capacitor Android едет почти бесплатно (`npx cap add android` из того же
веб-билда), но есть нюансы:
- **Движок в плюс.** Android System WebView (Chromium) обновляется через Play
Store независимо от ОС и обычно свежее iOS WKWebView. Более рискованный движок
по совместимости — это iOS, а не Android.
- **Фрагментация.** Дешёвые/старые устройства с малой памятью и устаревшим
WebView; стек тяжёлый (ProseMirror + Yjs + mermaid + katex + excalidraw) —
тестировать на бюджетных аппаратах.
- **Обвязка под Android:** аппаратная/жестовая кнопка «Назад» (навигация внутри
приложения, а не выход), **FCM** для push, Android App Links (вместо iOS
Universal Links), подписание и Play Console.
- **Главный риск именно для Android — ввод текста в ProseMirror на Gboard/IME.**
Историческая боль `contenteditable` на Android (прыжки курсора, дубли символов
при композиции). Стало лучше, но **проверять в первую очередь и рано**.
- **Магазин.** Google Play лояльнее к webview-обёрткам, чем App Store; риск
«отклонят как просто сайт» для Play практически неактуален.
---
## 8. iOS-специфика
- **WKWebView** на движке WebKit жёстко привязан к версии ОС — это более
рискованный по совместимости движок (тестировать прежде всего его).
- **App Store guideline 4.2 (minimum functionality).** Чистая webview-обёртка
рискует отклонением «это просто сайт». Лечится реальной нативной ценностью:
push, share-extension, биометрический разблок, оффлайн-кэш — всё это Capacitor
даёт плагинами.
- **safe-area** под «чёлку»/системные панели, поведение экранной клавиатуры в
редакторе.
---
## 9. Лицензионный блокер: AGPL ↔ App Store (iOS)
> Это не инженерная, а **лицензионная** задача — закрывать её надо **до** кода
> обёртки, иначе можно сделать приложение, которое некуда легально опубликовать.
> Ниже — инженерно-лицензионный разбор, **не** юридическая консультация; финально
> подтверждать у того, кто разбирается в лицензиях.
### 9.1. Суть конфликта
gitmost — форк Docmost под **AGPL-3.0** (константа форка: «100% open, AGPL-only»).
Две вещи несовместимы:
- **AGPLv3 §10** (последний абзац) запрещает накладывать на получателя кода
**любые дополнительные ограничения** сверх самой лицензии.
- **Стандартный EULA App Store** ровно их и накладывает: **FairPlay/DRM**,
привязка установки к Apple ID с лимитом устройств (**usage rules**), запрет
свободного перераспространения бинарника.
Приняв условия Apple, чтобы попасть в App Store, вы нарушаете AGPL кода, который
раздаёте.
### 9.2. Почему это бьёт именно по форку
Запрет «дополнительных ограничений» связывает **лицензиатов, но не самого
правообладателя**: владелец 100% копирайта может опубликовать свой код в App Store.
Но в gitmost бóльшая часть копирайта принадлежит **upstream-Docmost** и
контрибьюторам — вы выступаете дистрибьютором *чужого* AGPL-кода и не можете
единолично добавить App-Store-исключение.
Прецеденты: **VLC** (удалён из App Store в 2011 по жалобе на конфликт GPL с
условиями стора; вернулся только после перелицензирования и согласия
правообладателей), **GNU Go** — снят по той же причине. Это не теоретический риск.
### 9.3. Ключевой принцип развязки: лицензия смотрит на `.ipa`, а не на устройство
Определяющее — **что раздаёт сам Apple** (`.ipa` под FairPlay) и **кто раздаёт
AGPL-байты**, а не то, окажутся ли они в итоге на устройстве:
- AGPL **внутри `.ipa`** → получен под ограничениями Apple → **нарушение**.
- AGPL **скачан с вашего сервера** → получен от вас под AGPL (исходники открыты,
§13 выполнен) → ограничения Apple на него **не** накладываются, даже если бандл
кэшируется в песочнице приложения.
Следствие: **офлайн на iOS легально достижим** — если кэшированный бандл пришёл с
вашего сервера, а не из `.ipa`. Ограничение тут не лицензионное, а в **ревью
Apple** (см. §9.5).
### 9.4. Варианты «грузить веб-клиент с сервера»
**A. WebView навигируется на хостед-клиент (`server.url`).** Capacitor умеет
`server: { url: 'https://app.example.com' }` — оболочка грузит WebView с удалённого
URL, мост и нативные плагины по-прежнему инжектятся. В `.ipa` — ноль AGPL.
- Плюс: лицензионно самый чистый; **origin = ваш домен**, поэтому cookie/CORS
работают как в браузере (боль `capacitor://localhost` ↔ API из §6 исчезает —
токен в body/Keychain может и не понадобиться).
- Минус: холодный старт требует сети; сервер лёг → приложение кирпич; офлайна по
умолчанию нет.
**B. OTA: пустой шелл скачивает и кэширует бандл.** Шелл при первом запуске тянет
JS-бандл с вашего сервера и кэширует как веб-ассеты (механизм Cordova/CodePush).
Open-source self-host-вариант — `@capgo/capacitor-updater` (важно для AGPL-проекта:
без привязки к проприетарному Appflow).
- Плюс: **даёт офлайн** — кэш AGPL легален, т.к. распространён вами, а не Apple.
- Минус: упирается в политику Apple по hot-update (§9.5).
**Не-обходы (мифы):** «никто не засудит» — это нарушение, а не обход; «LGPL-нуть
обёртку» — не помогает (проблема в AGPL-веб-клиенте, а не в обёртке); «mere
aggregation» — не катит: зашитый бандл это комбинированное распространяемое
произведение, а не простая агрегация.
### 9.5. Гейты Apple
| # | Guideline | Суть | Влияние |
|---|---|---|---|
| 1 | **2.5.2** (исполняемый код) | Скачивать/исполнять **нативный** код нельзя, **но** есть исключение для скриптов, исполняемых встроенным WebKit/JavascriptCore, если они не меняют назначение приложения | Загрузка веб-клиента в `WKWebView` под исключение попадает: вариант A — чистый, B — терпимый, но с границами |
| 2 | **4.2** (minimum functionality) | Чистый WebView-«просто сайт» рискует отклонением | Лечится нативной ценностью в оболочке (push/APNs, биометрия, share, файлы — ваш нативный код, не AGPL) |
| 3 | конфликт двух гейтов | «Лицензионно чистый» вариант (пустой шелл качает всё) — самый рискованный для ревью; «безопасный для ревью» (зашить веб-билд в `.ipa`) — лицензионное нарушение | **Совместить (офлайн) + (чистая AGPL) + (низкий риск ревью) в одной конфигурации нельзя — выбираете любые два** |
Безопасность: раз исполняете удалённый код — только HTTPS, желательно cert-pinning
(подмена сервера = произвольный JS в WebView пользователя).
### 9.6. Итоговая матрица распространения iOS
| Конфигурация | AGPL-чистота | Офлайн | Риск ревью Apple |
|---|---|---|---|
| A. `server.url` на хостед-клиент | ✅ чистая | ❌ нет | средний (4.2, лечится плагинами) |
| B. OTA пустой шелл + кэш бандла | ✅ чистая | ✅ есть | выше (2.5.2 + 4.2) |
| Зашить веб-билд в `.ipa` (обычный Capacitor) | ❌ нарушение | ✅ | низкий |
| **PWA** | ✅ чистая | ✅ | App Store не нужен |
| Sideload / EU DMA-маркетплейсы (iOS 17.4+) | ✅ чистая | ✅ | вне App Store; **только ЕС** |
**Вывод:** для iOS **PWA** — самое дешёвое решение, закрывающее всё сразу. Если
присутствие именно в App Store критично — **вариант A** (`server.url` + нативные
плагины под 4.2) легальный и реалистичный ценой «онлайн для холодного старта».
Офлайн в App Store (вариант B) технически и лицензионно возможен, но это
максимальный риск на ревью — закладывать только если офлайн на iOS обязателен.
Совместить «App Store + зашитый офлайн AGPL» легально нельзя, пока копирайт не ваш.
---
## 10. Оффлайн в будущем
Оффлайн сейчас не требуется, но позиция хорошая:
- Тело документа уже редактируется через Yjs (CRDT) + `y-indexeddb` — локальная
копия и автослияние правок работают, в том числе в WebView.
- «Полностью онлайн» — это всё вокруг тела (навигация, заголовки, комментарии,
CRUD, вложения, авторизация). Их оффлайн-синхронизация описана отдельным
планом с этапами M0…M4 — см. [offline-sync-plan.md](offline-sync-plan.md).
- Мобильное приложение **переиспользует** этот план, а не строит оффлайн заново.
Нюанс Android: System WebView под нехваткой места может чистить хранилище →
для оффлайна, возможно, понадобится дублировать критичные данные в нативное
хранилище, чтобы локальные копии не вычищались.
---
## 11. Открытые вопросы (зафиксировать до старта)
- **Q1.** Путь: Capacitor (B) с эволюцией в гибрид, или сразу React Native (C)?
Рекомендация — B.
- **Q2.** Мобильная авторизация: отдельный логин-флоу с токеном в body + Keychain/
Keystore + Bearer (рекомендуется) или попытка работать через cookie в WebView?
- **Q3.** Push: APNs + FCM сразу или iOS-first?
- **Q4.** Подключать ли OpenAPI/Swagger для генерации мобильного клиента?
- **Q5.** Когда включать оффлайн (M0…M4 из offline-sync-plan.md) относительно
первого мобильного релиза?
- **Q6.** iOS-дистрибуция при AGPL (§9): App Store через `server.url`
(онлайн-клиент, без зашитого AGPL), PWA или sideload/EU-маркетплейсы? Этот
лицензионный путь нужно подтвердить **до** кода обёртки. Рекомендация — PWA для
iOS, Capacitor для Android.
---
## 12. Чеклист первого шага (бутстрап Capacitor, iOS-first)
- [ ] **Закрыть лицензионный путь iOS (§9) ДО кода обёртки:** выбрать
`server.url` / PWA / sideload и подтвердить у разбирающегося в лицензиях.
- [ ] **Не бандлить AGPL-веб-клиент в iOS `.ipa`** (DRM/usage-rules App Store ⟂
AGPLv3 §10) — на iOS грузить клиент с сервера или идти через PWA.
- [ ] Прогнать существующий адаптивный UI как PWA/в WebView, отловить отличия
(жесты, IME в редакторе, safe-area).
- [ ] Добавить Capacitor в монорепо, нацелить на веб-билд `apps/client`
(Android — зашитый билд; iOS — `server.url`/PWA без зашитого AGPL, см. §9).
- [ ] `npx cap add ios` (Android — `npx cap add android`, когда будет готова обвязка).
- [ ] Бэкенд: мобильный логин-флоу с токеном в body; хранить токен в Keychain/
Keystore; слать `Authorization: Bearer`.
- [ ] Бэкенд: явный CORS-whitelist под мобильные origin'ы.
- [ ] Native-плагины под App Store 4.2: push, биометрия, share, файлы.
- [ ] Push: APNs (iOS); FCM добавить вместе с Android.
- [ ] Проверить вебсокет коллаборации из WebView (`/auth/collab-token` + Hocuspocus).
- [ ] (Опционально) Подключить `@nestjs/swagger`.

View File

@@ -1,205 +0,0 @@
# Множественные курсоры (multi-cursor editing) — анализ и подходы
> Статус: **черновик / обсуждение**. Код не пишется; цель этого документа — зафиксировать архитектурный вердикт, развилку подходов и рекомендацию.
>
> Важное уточнение термина: речь про **несколько собственных курсоров одного пользователя в одном документе** (как в VS Code: `Alt+Click` добавить курсор, `Ctrl/Cmd+D` — следующее вхождение, `Ctrl/Cmd+Shift+L` — все вхождения), чтобы править несколько мест одновременно. **Не** про collaborative-курсоры соавторов — те в проекте уже работают (`CollaborationCaret` + Hocuspocus awareness).
>
> Зафиксированные выводы (см. разделы ниже):
> - Полноценный VS Code-style multi-cursor нельзя «включить флагом»: движок редактора (ProseMirror) хранит в состоянии **ровно одно выделение**, в отличие от Monaco/CodeMirror с массивом selections. Готового production-пакета в экосистеме Tiptap/ProseMirror нет.
> - ~80% пользовательской ценности даёт ограниченный MVP («выделить все вхождения + одновременный ввод»), который опирается на **уже работающий** в проекте механизм `replaceAll` из расширения `SearchAndReplace`.
> - Рекомендация: реализовать MVP (Вариант A); полноценный набор (Вариант B) — отдельный большой эпик, имеет смысл браться только если MVP окажется недостаточно.
## 0. О чём речь (и о чём НЕ речь)
**Что хочется** — несколько кареток в одном документе; набранный текст и `Backspace`/`Delete` применяются ко всем позициям одновременно; одно `Cmd/Ctrl+Z` откатывает всю мульти-правку целиком. Сценарии из VS Code:
| Действие | Горячая клавиша | Суть |
| --- | --- | --- |
| Добавить курсор | `Alt+Click` | Курсор в произвольной точке клика |
| Добавить курсор строкой выше/ниже | `Ctrl/Cmd+Alt+↑/↓` | Копия курсора на соседней строке |
| Выделить следующее вхождение | `Ctrl/Cmd+D` | Добавить к набору следующее вхождение слова |
| Выделить все вхождения | `Ctrl/Cmd+Shift+L` | Все вхождения сразу |
| Колонковое/блочное выделение | `Alt+drag` | Прямоугольник курсоров по строкам |
**О чём НЕ речь** — collaborative-курсоры (видеть, где сейчас находится другой соавтор). Это в Gitmost уже есть и работает отдельно: `CollaborationCaret` в [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) подключается через `collabExtensions(...)`, а сервер Hocuspocus по умолчанию форвардит awareness. Этот документ её не касается.
## 1. Архитектурный вердикт: почему это не «включить флаг»
Редактор Gitmost — **Tiptap поверх ProseMirror** (`@tiptap/core` 3.20.4, `@tiptap/pm` 3.20.4). Принципиальное отличие от VS Code: Monaco/CodeMirror хранит **массив selections**, а ProseMirror хранит в `EditorState` **ровно один** `Selection`:
```
EditorState = { doc, selection: Selection /* единственное */, storedMarks, ... }
```
На этой единственной selection завязано в ProseMirror почти всё:
- команды ввода (`insertText`, `insertContent`) работают с текущей `selection`;
- обработчики `handleTextInput`, `handleKeyDown`, `handlePaste`, `handleDrop` получают одно выделение;
- история (undo/redo) оперирует transactions с одним выделением;
- **критично для нас** — синхронизация через y-prosemirror тоже опирается на единственную selection (свою «awareness-selection» отдельно, но не на локальный массив).
Доказательства из первоисточников:
- Tiptap issue [ueberdosis/tiptap#3370](https://github.com/ueberdosis/tiptap/issues/3370) «Multiple cursors per user» — открыт, официальной поддержки нет.
- Ответ **marijnh** (автор ProseMirror) на [discuss.prosemirror.net](https://discuss.prosemirror.net/t/multi-cursor-editing-in-prosemirror-or-tiptap/8397): готовой реализации нет, но путь обозначен — **«кастомный подкласс `Selection`, по аналогии с `CellSelection` из `prosemirror-tables`, который умеет содержать несколько отдельных диапазонов»**.
- Production-готового пакета multi-cursor для Tiptap/ProseMirror в npm **нет** — пилить с нуля.
**Вывод:** полноценный multi-cursor — это R&D-проект против устройства движка, а не настройка. Но самый ценный сценарий («поправить повторяющиеся одинаковые куски сразу в нескольких местах») реализуем дёшево, потому что массовая правка в одном transaction у нас уже написана.
## 2. Что уже есть в коде и переиспользуемо
В проекте уже есть расширение [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) (в `editor-ext`, подключено и в клиентском редакторе). Это почти готовый фундамент для главного сценария multi-cursor:
- [search-and-replace.ts:100-174](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L100-L174) — `processSearches` уже находит **все** вхождения терма и возвращает массив `results: Range[]` (диапазоны `from`/`to`).
- [search-and-replace.ts:157-168](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L157-L168) — уже рисует `Decoration.inline` для **всех** совпадений одновременно (это переиспользуется для подсветки «активных» курсоров).
- [search-and-replace.ts:213-246](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L213-L246) — `replaceAll` уже выполняет **массовую правку в одном transaction**, идя **с конца**, чтобы корректно учитывать сдвиг позиций после каждой вставки/удаления. Это ровно та механика, что нужна для одновременного ввода в несколько курсоров.
```ts
// search-and-replace.ts:213-246 — готовый эталон массового transaction
const replaceAll = (replaceTerm, results, { tr, dispatch }) => {
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// ... собрать marks, удалить старый текст, вставить новый
tr.delete(from, to);
if (replaceTerm) tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
dispatch(tr); // одна транзакция → одна запись в истории (один undo)
};
```
То есть самая хитрая часть multi-cursor — применить правку к N позициям за один `tr` с корректным маппингом — у нас **уже работает** в `replaceAll`.
Дополнительно в клиенте уже есть инфраструктура для горячих клавиш: в [page-editor.tsx:258-280](apps/client/src/features/editor/page-editor.tsx#L258-L280) есть блок `handleDOMEvents.keydown`, и используется утилита `platformModifierKey` (Cmd на macOS, Ctrl на других ОС — ровно то, что нужно для совместимых с VS Code шорткатов).
## 3. Развилка: три подхода
### 3.1 Вариант A — MVP: «выделить все вхождения + одновременный ввод» (рекомендация)
Реализует главный сценарий из VS Code:
- `Ctrl/Cmd+Shift+L` — берём слово под курсором (или текущее выделение), находим все вхождения, превращаем их в «активные курсоры»;
- `Ctrl/Cmd+D` — добавить следующее вхождение к набору;
- дальнейший ввод текста и `Backspace`/`Delete` применяются ко всем позициям одновременно через один transaction (копия механики `replaceAll`);
- `Esc` — выйти из multi-cursor (один курсор).
**Что переиспользуется:** массив `results` и логика массового `tr` берутся из [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) почти готовыми.
**Визуальные каретки:** через `Decoration.widget(pos, () => cursorDomElement)` — ProseMirror умеет «из коробки»; для диапазонов — `Decoration.inline`.
**Объём работы:** средний. Один новый Tiptap-extension в `packages/editor-ext/src/lib/multi-cursor/` + wiring в клиентском редакторе + горячие клавиши + CSS + юнит-тесты.
**Риски:** средние и ограниченные. Скоуп узкий (только текстовые вхождения), сценарии предсказуемые, тестируются конечным числом кейсов.
### 3.2 Вариант B — полноценный multi-cursor (как Monaco)
Полный набор из §0: `Alt+Click` (произвольная точка), `Alt+drag` (колонковое выделение), `Ctrl/Cmd+Alt+↑/↓` (курсор на соседней строке), а также произвольный набор **несвязанных** курсоров (не по вхождениям).
**Путь:** кастомный `MultiSelection extends Selection` (по подсказке мейнтейнера ProseMirror, по образцу `CellSelection` из `prosemirror-tables`), плюс **полная маршрутизация ввода**:
- перехват `handleTextInput`, `handleKeyDown` (Backspace/Delete/стрелки/Enter/Home/End), `handlePaste`, `handleDrop`;
- построение одного мульти-position transaction для каждого события;
- визуальный рендер нескольких кареток и диапазонов;
- undo-группировка (одно `Cmd/Ctrl+Z` откатывает все позиции разом);
- перемапливание позиций курсоров при **любых** изменениях документа, включая remote Yjs-правки.
**Объём работы:** очень большой (многие недели). Готового референса в экосистеме нет — это самостоятельный R&D с отладкой на реальном контенте.
**Риски:** высокие — см. риск-карту в §4 (IME/composition, конфликты со сложными нодами вроде таблиц и code-блоков, взаимодействие с коллаборацией).
### 3.3 Вариант C — эмуляция через коллаборацию (отбрасываем)
Идея из Tiptap#3370: «проигрывать правки через отдельного pseudo-user через collaborative-слой». **Не берём:** ломает provenance правок (в проекте есть бейдж авторства «AI agent» в истории страницы, migration `20260616T130000-agent-provenance` — такой хак его загрязнит и запутает), портит историю undo, концептуально криво и хрупко.
### Сводка
| | Вариант A (MVP) | Вариант B (full) | Вариант C |
| --- | --- | --- | --- |
| Сценарии | «все вхождения», «+следующее вхождение» | полный набор VS Code | — |
| База | готовый `replaceAll` | кастомный `Selection` с нуля | collaborative-слой |
| Объём | средний | очень большой | — |
| Риск | средний (ограниченный) | высокий | высокий |
| Рекомендация | **да** | только если A мало | нет |
## 4. Риск-карта
Для обоих вариантов, но в варианте B каждый пункт — сильно жёстче.
| Зона | Суть | Где больнее |
| --- | --- | --- |
| **Undo/redo** | Мульти-правка должна быть **одной** записью истории (одно `Cmd/Ctrl+Z` откатывает все позиции). Группировка через мету истории, см. как `replaceAll` делает один `dispatch(tr)`. | B |
| **Коллаборация (Yjs)** | Пока активны ваши курсоры, может прилететь remote-правка — позиции курсоров надо перемапливать через `tr.mapping.map(pos)`. Один локальный `tr` с правками в N местах Yjs переварит нормально (это несколько правок в одном Update). | B |
| **IME / dead keys** | Ввод через composition (буквы с акцентами, CJK) одновременно в несколько курсоров — крайне хрупко; для MVP (Вариант A) проще: на время composition можно схлопывать к одному курсору. | B |
| **Schema / сложные узлы** | Курсор внутри code-блока + курсор в заголовке: одна и та же вставка может нарушить schema одного узла, но не другого. Нужно gracefully skip конфликтующие курсоры (не ронять весь `tr`). | B (A — почти не касается, т.к. вхождения — текстовые) |
| **Таблицы / callouts** | `CellSelection`-подобная логика внутри таблиц — отдельная вселенная; в MVP курсоры в таблицах можно просто не поддерживать (как и в `replaceAll`). | B |
| **Производительность** | Очень много курсоров → большой `DecorationSet` и длинный `tr`. Практически редко > нескольких десятков, но заложить верхнюю границу. | общий |
## 5. Рекомендация
**Брать Вариант A.** Он закрывает главный use-case («быстро поправить повторяющиеся одинаковые куски сразу в нескольких местах»), опирается на **уже работающий** `replaceAll`-механизм, и риск ограничен. Вариант B имеет смысл отдельным эпиком — только если A окажется недостаточно и будет устойчивый спрос на произвольные курсоры; тогда начинать стоит с прототипа кастомного `MultiSelection`, чтобы доказать жизнеспособность на сложных узлах до полной реализации.
Сознательные границы MVP (Вариант A) — см. §6.7.
## 6. План реализации Варианта A (MVP) — по шагам
### 6.1. Новый extension
Создать `packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts` — Tiptap `Extension`:
- плагин (ProseMirror `Plugin`) со state = `{ cursors: {from: number, to: number}[] }` и `DecorationSet` (виджеты-каретки для точечных курсоров + `Decoration.inline` для диапазонов);
- команды:
- `selectAllOccurrences` — берёт слово под курсором (или текущее выделение), находит все вхождения (можно вынести общую с search-and-replace логику поиска в утилиту, чтобы не дублировать `processSearches`), заполняет `cursors`;
- `addNextOccurrence` (`Ctrl/Cmd+D`) — добавляет следующее вхождение к `cursors`;
- `exitMultiCursor` — очищает `cursors` (также вешается на `Esc`);
- обработчики в `props`:
- `handleTextInput(view, from, to, text)` — если `cursors` непустой, строит один `tr`, вставляя `text` в каждую позицию **с конца** (копия механики из [search-and-replace.ts:213-246](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L213-L246));
- `handleKeyDown``Backspace`/`Delete` аналогично (удаление символа перед/после каждой позиции);
- игнорировать/схлопнуть multi-cursor при начале composition (IME) — см. §4.
### 6.2. Маппинг позиций при изменениях документа
В `state.apply` плагина — при любом `docChanged` перемапливать все позиции через `tr.mapping.map(pos)` и удалять «схлопнувшиеся» (`from === to` после маппинга — это нормально для каретки). Это покрывает и собственные правки, и **remote Yjs-правки** (y-prosemirror применяет их как обычные transactions — маппинг работает одинаково).
### 6.3. Горячие клавиши
Добавить в существующий блок [page-editor.tsx:258-280](apps/client/src/features/editor/page-editor.tsx#L258-L280) (там уже есть `platformModifierKey`):
- `platformModifierKey + Shift + KeyL``selectAllOccurrences`;
- `platformModifierKey + KeyD``addNextOccurrence`;
- `Escape``exitMultiCursor`.
⚠️ Проверить конфликт `Ctrl/Cmd+D` с браузерным «добавить в закладки» (предотвратить через `event.preventDefault()`) и с любыми существующими биндингами редактора.
### 6.4. Регистрация
- экспортировать расширение из `packages/editor-ext/src/lib/multi-cursor/index.ts` и добавить в `packages/editor-ext/src/index.ts`;
- включить в `mainExtensions` в [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) (оно не зависит от коллаборации, поэтому идёт в основной набор, доступный и в обычном, и в коллаборативном редакторе).
### 6.5. CSS
Рядом с [collaboration.css](apps/client/src/features/editor/styles/collaboration.css) (и подключением через `styles/index.css`) — стили для классов вроде `.multi-cursor__caret` и `.multi-cursor__label`. Визуально отличать от collaborative-кареток (например, другим стилем/цветом), чтобы не путать свои мульти-курсоры с курсорами соавторов.
### 6.6. Тесты
Unit-тесты в `packages/editor-ext` (по образцу существующих там тестов) на:
- корректность массового `tr` (ввод/удаление в N позициях, проверка результирующего документа);
- маппинг позиций после локальной правки и после имитированной remote-правки;
- граничные случаи: курсоры на границах узлов, схлопывание, пустой набор.
### 6.7. Скоуп v1 / что сознательно НЕ входит
Чтобы держать риск в пределах, в MVP **не делаем** (явно фиксируем как out-of-scope):
- `Alt+Click` (произвольная точка) и `Alt+drag` (колонковое выделение) — это путь в Вариант B;
- `Ctrl/Cmd+Alt+↑/↓` (курсор на соседней строке) — то же;
- курсоры внутри таблиц, code-блоков и callouts — только обычный текст (как в `replaceAll`);
- одновременный ввод через IME в несколько позиций (на время composition схлопываем к одному курсору);
- курсоры, затрагивающие разные schema-узлы одновременно (если вставка нарушает schema в одной из позиций — пропускаем эту позицию, не роняем весь `tr`).
Эти границы — кандидаты на v2 / переход к Варианту B.
## 7. Открытые вопросы
1. **Выделение диапазонов vs точечные курсоры.** В VS Code `Ctrl/Cmd+Shift+L` выделяет целые слова (диапазоны). Делаем ли мы в MVP то же (диапазоны + одновременная замена всего слова), или только точечные каретки после конца слова? Рекомендация: диапазоны — это даёт «переименовать все эти слова сразу», что и есть главная ценность.
2. **Общая утилита поиска.** Вынести `processSearches` из search-and-replace в общую утилиту, чтобы не дублировать, или оставить независимую реализацию в multi-cursor? Рекомендация: вынести общую часть (поиск всех вхождений слова по документу), оба расширения используют её.
3. **Граница производительности.** Ввести ли хард-кап на число одновременных курсоров (например, 100) с предупреждением пользователю? Рекомендация: да, как страховка.
## 8. Источники
- [Tiptap issue #3370 — Multiple cursors per user](https://github.com/ueberdosis/tiptap/issues/3370)
- [discuss.ProseMirror — Multi-cursor editing in ProseMirror (ответ автора ProseMirror о кастомном подклассе Selection)](https://discuss.prosemirror.net/t/multi-cursor-editing-in-prosemirror-or-tiptap/8397)
- `prosemirror-tables` / `CellSelection` — референс реализации «выделения из нескольких диапазонов» для Варианта B.
- Внутренний код: [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) (эталон массового transaction), [page-editor.tsx](apps/client/src/features/editor/page-editor.tsx) (точки подключения горячих клавиш), [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) (регистрация расширений).

View File

@@ -1,393 +0,0 @@
# Offline-режим и синхронизация правок в gitmost
> Статус: проектный документ, готов к реализации.
> Контекст: gitmost — форк Docmost. Сейчас приложение полностью онлайн.
> Цель: дать возможность работать оффлайн (читать и редактировать) и
> синхронизироваться при возврате сети.
Документ описывает текущее устройство, целевую архитектуру и пошаговый план
реализации с привязкой к конкретным файлам. Его можно взять и реализовывать
по этапам M0…M4.
---
## 1. TL;DR
1. **Половина оффлайна уже встроена.** Тело страницы редактируется через Yjs
(CRDT) + Hocuspocus, а на клиенте уже подключён `y-indexeddb`. Правки тела
*уже открытой* страницы переживают потерю сети и **сами мёржатся** при
реконнекте — без конфликтов.
2. **«Полностью онлайн» — это всё вокруг тела документа:** загрузка самого
приложения, навигация (дерево/список), заголовки страниц, комментарии,
создание/перемещение/удаление страниц, вложения, авторизация.
3. **Оффлайн делится на два контура с разными механизмами синхронизации:**
- **Контур A — тело документа:** CRDT (Yjs). Почти готов, нужно укрепить.
- **Контур B — структурные данные (REST):** не CRDT. Нужен паттерн
*локальный кэш + outbox (очередь мутаций) + правила разрешения конфликтов*.
4. **PWA — обязательный фундамент, но это два слоя:**
- *Installability* (manifest + meta-теги) — **уже есть** в gitmost
(унаследовано от Docmost). Forkmost добавляет только косметику.
- *Service worker* (кэш app-shell, запуск без сети) — **нет нигде**, это и
есть реальная невыполненная часть. Без него установленное приложение без
сети покажет пустой экран.
---
## 2. Текущее состояние (как есть)
### 2.1. Контур A: тело документа — CRDT, почти готово
| Где | Что делает |
|---|---|
| [page-editor.tsx](../apps/client/src/features/editor/page-editor.tsx) (L131–206) | На каждую страницу создаётся `Y.Doc`, к нему цепляются `IndexeddbPersistence("page.<id>")` (локальная копия) **и** `HocuspocusProvider` (WS-синк). |
| [persistence.extension.ts](../apps/server/src/collaboration/extensions/persistence.extension.ts) | Сервер в `onStoreDocument` хранит в Postgres бинарный `ydoc` (Y state update) **плюс** отрендеренный tiptap-JSON `content` + `textContent`. В `onLoadDocument` поднимает `ydoc` обратно. |
| [collaboration/extensions/redis-sync/](../apps/server/src/collaboration/extensions/redis-sync/) | Redis-синк для горизонтального масштабирования инстансов. |
Почему это и есть оффлайн-редактирование: Yjs — CRDT, апдейты коммутативны.
Пока клиент оффлайн, изменения копятся в `Y.Doc` и в IndexedDB; при возврате
сети `HocuspocusProvider` обменивается state-векторами и **детерминированно
сливает** правки. Конфликтов «кто кого перезаписал» в теле документа нет.
### 2.2. Контур B: структурные данные — обычный REST, оффлайн недоступен
| Сущность | Где | Механизм |
|---|---|---|
| Заголовок страницы | [title-editor.tsx](../apps/client/src/features/editor/title-editor.tsx) (L48–152) | REST `/pages/update`, дебаунс 500 мс. **НЕ Yjs.** |
| CRUD страниц, move, restore | [page-service.ts](../apps/client/src/features/page/services/page-service.ts) | REST `/pages/*` |
| Комментарии | [comment-service.ts](../apps/client/src/features/comment/services/comment-service.ts) | REST `/comments/*` |
| Watchers, favorites, labels, дерево, поиск | соответствующие `features/*/services` | REST |
Состояние клиента:
- React Query: [main.tsx](../apps/client/src/main.tsx) (L26), `queryClient`
экспортируется, `retry:false`, `staleTime: 5 мин`. **Персистентности на диск
нет.** При перезагрузке без сети читать нечего.
- HTTP: [api-client.ts](../apps/client/src/lib/api-client.ts) — axios `/api`,
`withCredentials`. На `401``redirectToLogin()`. **Важно для оффлайна:**
редирект на логин при сетевой ошибке недопустим (см. M4).
### 2.3. PWA: что уже есть
- [manifest.json](../apps/client/public/manifest.json) — присутствует
(`display: standalone`, иконки).
- [index.html](../apps/client/index.html) (L9–16) — PWA meta-теги
(`apple-mobile-web-app-capable`, `mobile-web-app-capable`, `theme-color` и т.д.).
- **Service worker отсутствует.** Нет `vite-plugin-pwa`, Workbox, precache.
> Вывод по Forkmost (`Vito0912/forkmost`): их «PWA-наработки» — это только
> манифест и meta-теги (closing issue Docmost #328 про *устанавливаемость*).
> Service worker / оффлайн-кэша там нет. В gitmost installability уже есть,
> поэтому из Forkmost переносить нечего, кроме косметики.
### 2.4. Полезные примитивы, которые уже есть в проекте
- **Fractional indexing для позиций страниц:**
[page.service.ts](../apps/server/src/core/page/services/page.service.ts)
использует `generateJitteredKeyBetween` из `fractional-indexing-jittered`.
Позиция — это строковый ключ (`position: string`), «jittered»-вариант
специально снижает коллизии при конкурентных/оффлайн-вставках. Это готовый
offline-friendly примитив для перемещений в дереве.
- **Генерация ID:**
[nanoid.utils.ts](../apps/server/src/common/helpers/nanoid.utils.ts) —
`generateSlugId` (10 симв.) и `nanoIdGen`. ID можно генерировать на клиенте и
принимать на сервере (нужно для оффлайн-создания, см. M3).
---
## 3. Целевая архитектура
```
┌──────────────────────── Браузер (PWA) ────────────────────────┐
│ │
Тело документа │ TipTap ⟷ Y.Doc ⟷ IndexeddbPersistence (локальная копия) │
(Контур A, CRDT) │ │ │
│ └── HocuspocusProvider ──┐ │
│ │ │
Структурные данные │ React Query (read) ⟵ IndexedDB persister │ │
(Контур B, REST) │ Мутации ⟶ Outbox (IndexedDB) ──────────┐ │ │
│ │ │ │
App shell │ Service Worker (Workbox precache) │ │ │
└──────────────────────────────────────────┼────┼───────────────┘
│ │
(reconnect) ▼ ▼
┌──────────────────────── Сервер ───────────────────────────────┐
│ REST API (idempotent upsert по client-id) Hocuspocus (Yjs) │
│ │ │ │
│ └────────────── Postgres ───────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
Два независимых канала синхронизации:
- **Контур A** синкается сам через Hocuspocus (Yjs). Руками конфликты не решаем.
- **Контур B** синкается через outbox: оффлайн-мутации пишутся в журнал в
IndexedDB и проигрываются на сервер при реконнекте; конфликты решаются
явными правилами (LWW / per-entity).
---
## 4. План реализации по этапам
Этапы инкрементальны: каждый даёт пользователю ощутимый результат и может быть
смёржен отдельно. Рекомендуемый порядок — строго M0 → M4.
### M0 — PWA shell (фундамент: приложение запускается без сети)
**Зачем:** без service worker установленное приложение без сети не загрузится.
Это разблокирует всё остальное.
**Что сделать:**
1. Добавить `vite-plugin-pwa` (Workbox под капотом) в
[vite.config.ts](../apps/client/vite.config.ts).
- `registerType: 'autoUpdate'` или `prompt` (см. риск R3).
- `workbox.globPatterns` — прекэш JS/CSS/wasm/шрифтов/иконок.
- `manifest: false` или генерация из существующего
[manifest.json](../apps/client/public/manifest.json) (не дублировать).
- Навигационный fallback на `index.html` для SPA-роутов.
- Runtime caching: `CacheFirst` для статики, **`NetworkOnly` для `/api/**`
и `/collab`** на этом этапе (REST-кэш появится в M2; SW не должен молча
отдавать устаревшие ответы API).
2. Зарегистрировать SW в [main.tsx](../apps/client/src/main.tsx)
(`registerSW` из `virtual:pwa-register`).
3. Перенести косметику манифеста/метатегов из Forkmost при желании (бренд,
`orientation`, `msapplication-*`). Опционально, на оффлайн не влияет.
**Файлы:** `apps/client/vite.config.ts`, `apps/client/src/main.tsx`,
`apps/client/public/manifest.json`, `apps/client/index.html`.
**Критерий приёмки:** приложение устанавливается, после первой загрузки
открывается **без сети** (виден shell/лэйаут, а не пустой экран);
обновление версии SW не ломает открытую сессию.
**Риск:** низкий. Изолированный слой, кода приложения не трогает.
---
### M1 — Укрепление оффлайна тела документа (Контур A)
**Зачем:** убрать известные грабли Yjs и сделать поведение предсказуемым.
**Что сделать:**
1. **Закрыть ловушку «rebuild ydoc из JSON».** В
[persistence.extension.ts](../apps/server/src/collaboration/extensions/persistence.extension.ts)
`onLoadDocument` при пустом `page.ydoc` пересобирает документ из
`page.content` через `TiptapTransformer.toYdoc(...)`. Если это сработает,
пока оффлайн-клиент держит свой `Y.Doc` со своими client-id, при мёрже
возможно **дублирование контента** (классическая Yjs-ловушка).
- Гарантировать, что `ydoc` всегда персистится (после первого сохранения он
есть) и ветка rebuild не выполняется для страниц, у которых живут
оффлайн-клиенты. Минимум — единожды мигрировать `content → ydoc` для всех
страниц и далее считать `ydoc` единственным источником правды для тела.
2. **Индикатор оффлайна/синка в UI.** Уже есть `yjsConnectionStatusAtom` и
`isLocalSynced/isRemoteSynced` в
[page-editor.tsx](../apps/client/src/features/editor/page-editor.tsx).
Показать состояние («оффлайн», «есть несинхронизированные правки»,
«синхронизировано»).
3. **Заголовок страницы → в Yjs (рекомендуется).**
[title-editor.tsx](../apps/client/src/features/editor/title-editor.tsx)
сохраняет заголовок REST-ом (дебаунс 500 мс) — оффлайн это не работает и
расходится с телом. Варианты:
- (a) перенести заголовок в тот же `Y.Doc` (чистое CRDT-решение), либо
- (b) тащить заголовок через outbox из M3 (LWW). Решение зафиксировать
до старта M3 (см. открытый вопрос Q1).
**Файлы:** `apps/server/src/collaboration/extensions/persistence.extension.ts`,
`apps/client/src/features/editor/page-editor.tsx`,
`apps/client/src/features/editor/title-editor.tsx` (если вариант a).
**Критерий приёмки:** правки тела уже открытой страницы, сделанные оффлайн,
после реконнекта появляются на сервере и у других клиентов без дублей и потерь;
в UI виден статус синка.
**Риск:** средний (Yjs-семантика, миграция `content → ydoc`).
---
### M2 — Оффлайн-чтение и навигация (Контур B, read-path)
**Зачем:** оффлайн нужно видеть дерево, список и метаданные, иначе некуда
переходить; и нужно префетчить страницы «на оффлайн».
**Что сделать:**
1. **Персист React Query на диск.** Обернуть экспортируемый `queryClient` из
[main.tsx](../apps/client/src/main.tsx) в
`PersistQueryClientProvider` с IndexedDB-persister
(`@tanstack/query-persist-client-core` + idb-хранилище).
- Кэшировать: дерево пространства, список страниц, метаданные страницы,
комментарии. Выставить разумный `maxAge`/`gcTime`.
- Версионировать кэш (`buster`) по версии приложения, чтобы не «залипал»
после деплоя.
2. **«Сделать доступным оффлайн».** Действие для пространства/ветки: префетч
метаданных **и** прогрев `IndexeddbPersistence` для тел страниц (открыть/
подгрузить `ydoc` каждой целевой страницы заранее), т.к. сейчас локально
лежат только *ранее открытые* страницы.
3. **Runtime caching API в SW (read-only).** Для GET-эндпоинтов навигации —
`StaleWhileRevalidate`/`NetworkFirst` с фолбэком на кэш. Мутации (POST) —
по-прежнему мимо кэша (их берёт на себя M3).
**Файлы:** `apps/client/src/main.tsx`, новый модуль
`apps/client/src/lib/offline/` (persister, prefetch), точечно — хуки списков/
дерева в `features/page/tree`.
**Критерий приёмки:** после прогрева и ухода в оффлайн пользователь видит дерево
и список, открывает заранее подготовленные страницы и читает их тело и
комментарии.
**Риск:** средний (консистентность кэша, инвалидция после деплоя).
---
### M3 — Outbox для мутаций (Контур B, write-path) — ядро оффлайн-синка
**Зачем:** дать оффлайн-создание/редактирование структурных данных с
последующим проигрыванием на сервер.
**Что сделать:**
1. **Очередь мутаций (outbox) в IndexedDB.** Журнал операций
`{ id, entity, op, payload, clientId, baseVersion, createdAt, status }`.
Использовать **offline/paused mutations TanStack Query**
(`onlineManager` + `queryClient.resumePausedMutations()` + персист пауз),
либо отдельный модуль `apps/client/src/lib/offline/outbox.ts`.
2. **Клиентская генерация ID.** Для оффлайн-создания страниц/комментариев
генерировать `id`/`slugId` на клиенте тем же алфавитом, что и
[nanoid.utils.ts](../apps/server/src/common/helpers/nanoid.utils.ts).
Для позиций в дереве — `generateJitteredKeyBetween` из
`fractional-indexing-jittered` (тот же пакет, что на сервере).
3. **Идемпотентный upsert на сервере.** Эндпоинты `/pages/create`,
`/comments/create` и т.д. должны принимать клиентский `id` и быть
идемпотентными по нему (повторная отправка из очереди не должна плодить
дубликаты). Точки входа:
[page-service.ts](../apps/client/src/features/page/services/page-service.ts),
[comment-service.ts](../apps/client/src/features/comment/services/comment-service.ts)
и соответствующие контроллеры сервера.
4. **Optimistic updates + откат.** Применять мутацию к кэшу сразу; при
неуспешном проигрывании после реконнекта — откат/пометка конфликта.
5. **Правила разрешения конфликтов** (см. §5).
6. **Проигрывание при реконнекте** в порядке `createdAt`, с экспоненциальным
backoff и идемпотентностью.
**Файлы:** новый `apps/client/src/lib/offline/outbox.ts`, обёртки над
`features/*/services/*`, серверные контроллеры/сервисы соответствующих
сущностей (idempotent upsert).
**Критерий приёмки:** оффлайн можно создать страницу, отредактировать заголовок,
оставить комментарий, переместить страницу; после реконнекта всё появляется на
сервере один раз (без дублей), конфликты разрешаются по заданным правилам.
**Риск:** высокий (это самостоятельный класс багов синхронизации; требует
серверных изменений и тестов на конфликты).
---
### M4 — Вложения и оффлайн-авторизация
**Что сделать:**
1. **Вложения/картинки оффлайн.** Очередь загрузок: blob кладётся в локальный
кэш (Cache API/IndexedDB), в документ вставляется ссылка на локальный
ресурс; при реконнекте файл доуплоадивается, ссылка переписывается на
серверную. Точка входа — `features/attachments`.
2. **Оффлайн-толерантная авторизация.** В
[api-client.ts](../apps/client/src/lib/api-client.ts) `401`/сетевые ошибки
**не должны** выкидывать на логин при отсутствии сети — отличать «нет сети»
от «реально разлогинен». Collab-токен (JWT с TTL,
[page-editor.tsx](../apps/client/src/features/editor/page-editor.tsx) L166–181)
оффлайн не обновить — синк должен просто ждать реконнекта, не ломая
локальную работу.
**Критерий приёмки:** оффлайн-вставка картинки доезжает после реконнекта;
протухший токен/нет сети не выкидывают пользователя из приложения и не теряют
локальные правки.
**Риск:** средний.
---
## 5. Правила разрешения конфликтов (Контур B)
CRDT здесь нет, правила задаём явно по типам сущностей:
| Сущность | Стратегия |
|---|---|
| **Тело документа** | Yjs (CRDT) — руками ничего не решаем. |
| **Комментарии** | Почти append-only. LWW по полю + дедуп по `clientId`. Простейший случай. |
| **Метаданные страницы** (заголовок, иконка) | Last-Write-Wins по `updatedAt`. |
| **Перемещение в дереве** | Самый сложный случай. Позиции — строковые fractional-ключи (`generateJitteredKeyBetween`), что снижает коллизии вставок. Нужен серверный реконсилер для «родитель удалён, а ребёнок перемещён» и конкурентных move: правило «удаление побеждает перемещение» (или наоборот — зафиксировать), плюс перегенерация позиции при коллизии. |
| **Удаление vs правка** | Зафиксировать политику: правка удалённой сущности → конфликт в UI либо «удаление выигрывает». |
---
## 6. Подводные камни (читать до старта)
1. **Yjs rebuild из JSON → дубли.** Ветка `content → toYdoc` в
`onLoadDocument` опасна для долго-оффлайновых клиентов. Закрыть в M1.
2. **Инвалидция кэша после деплоя.** Персист React Query и precache SW должны
версионироваться по версии приложения (`buster`/`globPatterns` хэши), иначе
пользователь застрянет на старом UI/данных.
3. **Обновление service worker.** `autoUpdate` может перезагрузить вкладку с
несохранёнными правками. Для редактора предпочтительнее `prompt`-стратегия
(показать «доступно обновление», применить по согласию).
4. **Идемпотентность обязательна.** Любая мутация из outbox может отправиться
повторно (реконнект/ретрай). Без серверного upsert по `clientId` — дубли.
5. **Рост IndexedDB.** Прогрев тел страниц «на оффлайн» и кэш блобов могут
занять много места. Нужны лимиты/очистка (LRU).
6. **Редирект на логин при сетевой ошибке.** Сейчас `401``redirectToLogin`.
Оффлайн это выкинет пользователя и потеряет контекст — чинить в M4.
---
## 7. Зависимости (npm)
| Пакет | Зачем | Этап |
|---|---|---|
| `vite-plugin-pwa` (+ Workbox) | SW, precache app-shell, генерация манифеста | M0 |
| `@tanstack/query-persist-client-core` | Персист React Query на диск | M2 |
| `idb` или `idb-keyval` | Обёртка над IndexedDB (persister/outbox/blob-кэш) | M2–M4 |
| `fractional-indexing-jittered` | Клиентская генерация позиций (уже есть на сервере) | M3 |
`yjs`, `y-indexeddb`, `@hocuspocus/provider`**уже** в проекте, доустанавливать
не нужно.
---
## 8. Объём работ vs ценность (для приоритизации)
| Уровень | Этапы | Что пользователь получает |
|---|---|---|
| **Минимальный** | M0 + M1 | Приложение грузится оффлайн; уже открытые страницы редактируются и синкаются (тело + заголовок). Навигация — только по закэшированному. |
| **Средний** | + M2 + M3 | Оффлайн-навигация по подготовленным пространствам; оффлайн-создание страниц и комментариев с синком и LWW-конфликтами. |
| **Полный** | + M4 (и при необходимости — переезд на синк-движок) | Вложения оффлайн, устойчивая авторизация. Полноценный local-first. |
Прагматичный путь: довести **M0+M1** (это ~80% «редактирую то, что открыл»),
затем M2/M3 инкрементально. Полный синк-движок (RxDB / ElectricSQL / PowerSync /
Replicache / TanStack DB) рассматривать только если оффлайн станет ключевым
сценарием продукта — это существенный рефакторинг данных и бэкенда.
---
## 9. Открытые вопросы (зафиксировать до реализации)
- **Q1.** Заголовок страницы: переносим в Yjs (M1, вариант a) или гоним через
outbox (M3, вариант b)? Рекомендация — (a), меньше конфликтных правил.
- **Q2.** Политика конфликта «удаление vs правка»: «удаление выигрывает» или
явный конфликт в UI?
- **Q3.** Стратегия обновления SW для редактора: `autoUpdate` или `prompt`?
Рекомендация — `prompt`.
- **Q4.** Лимиты локального хранилища (сколько пространств/страниц/блобов
держать оффлайн, политика вытеснения).
- **Q5.** Целимся в инкрементальный путь (M0…M4) или сразу в синк-движок (уровень
«полный»)? От этого зависит, переписывать ли REST-слой.
---
## 10. Чеклист реализации
- [ ] M0: `vite-plugin-pwa` подключён, SW регистрируется, app-shell в precache,
`/api` и `/collab``NetworkOnly`.
- [ ] M0: приложение открывается без сети (shell виден).
- [ ] M1: ветка rebuild ydoc из JSON обезврежена; миграция `content → ydoc`.
- [ ] M1: индикатор статуса синка в UI.
- [ ] M1: заголовок переведён в Yjs (или решение Q1 принято).
- [ ] M2: React Query персистится в IndexedDB, кэш версионирован.
- [ ] M2: действие «сделать доступным оффлайн» (метаданные + прогрев `ydoc`).
- [ ] M3: outbox в IndexedDB, клиентские ID, идемпотентный upsert на сервере.
- [ ] M3: optimistic updates + откат; правила конфликтов реализованы.
- [ ] M4: очередь загрузки вложений + локальный blob-кэш.
- [ ] M4: авторизация толерантна к оффлайну (нет редиректа на логин при отсутствии сети).

Some files were not shown because too many files have changed in this diff Show More