Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8bc655eb | |||
| b8aac9635b | |||
| d72e101216 | |||
| 89fcc60782 | |||
| 917c406489 | |||
| 6bf9358387 | |||
| ccfbf3343b | |||
| b86c747245 | |||
| dc96736d44 | |||
| ef1a2b3960 | |||
| 0be44e307e | |||
| 9e99309096 | |||
| 433bd822b6 | |||
| ece84924f3 | |||
| a14174a726 | |||
| d822ceeaf7 | |||
| 4ac014def2 | |||
| 799dcfe7c5 | |||
| 14c864f5c6 | |||
| e292ed5117 | |||
| 6d7dba970c | |||
| 512bcba5f3 | |||
| 5c1ab9c7b5 | |||
| 14d7b21df0 | |||
| 363f20ab75 | |||
| 3411bda2d1 | |||
| e670f7498a | |||
| 9a8671c3af | |||
| bec2156e96 | |||
| acc705de19 | |||
| a49872444e | |||
| ffc38ea2ca | |||
| 2a951df096 | |||
| 5dc7a2703f | |||
| bfb4c8d8d0 | |||
| f794ac6d6c | |||
| e4e788f151 | |||
| 4be4a75fa3 | |||
| 2f23cf4b65 | |||
| d287c15db4 |
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
#
|
||||
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
||||
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
||||
# go over the collaboration websocket and are serialized PER PAGE by an
|
||||
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
||||
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
||||
#
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
||||
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
||||
# two replicas can each "hold" the lock for the same page at the same time and
|
||||
# serialization is silently lost (concurrent full-document writes race on the
|
||||
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
||||
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
||||
# same constraint applies to the RAM-only stash_page blob store above. There is
|
||||
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
||||
# CONSCIOUS documented constraint, not an oversight (#449).
|
||||
#
|
||||
# To reduce connect-storms the write path caches ONE live collab session per
|
||||
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
||||
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
||||
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
||||
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
||||
#
|
||||
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
||||
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
||||
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
||||
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
||||
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
||||
# the session is torn down and the next write re-auths with a fresh token, picking
|
||||
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
||||
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
||||
# stale-rights window. There is intentionally no push-based cache invalidation on
|
||||
# a rights change — this bounded window is the accepted trade-off (#449).
|
||||
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
||||
#
|
||||
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
||||
# content + images to an external consumer WITHOUT bloating the model context or
|
||||
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
||||
@@ -301,6 +335,20 @@ MCP_DOCMOST_PASSWORD=
|
||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||
# METRICS_PORT=9464
|
||||
#
|
||||
# METRICS_BIND — interface the /metrics listener binds to. DEFAULT 127.0.0.1
|
||||
# (loopback only), so the unauthenticated endpoint is NOT exposed on all
|
||||
# interfaces. If the scraper runs in a SEPARATE container and reaches this as
|
||||
# docmost:9464, set METRICS_BIND=0.0.0.0 — but then also set METRICS_TOKEN
|
||||
# and/or keep the port on a private network, since /metrics is otherwise open.
|
||||
# METRICS_BIND=127.0.0.1
|
||||
#
|
||||
# METRICS_TOKEN — optional Bearer token guarding /metrics. When set, every
|
||||
# scrape MUST send `Authorization: Bearer <token>` (others get 401). Configure
|
||||
# the scraper with the same bearer token (e.g. VictoriaMetrics/vmagent
|
||||
# `bearer_token`, Prometheus `authorization.credentials`). Leave unset only
|
||||
# when the endpoint is bound to loopback or an otherwise-trusted network.
|
||||
# METRICS_TOKEN=
|
||||
#
|
||||
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||
|
||||
@@ -157,6 +157,12 @@ jobs:
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
|
||||
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
|
||||
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
|
||||
@@ -5,6 +5,139 @@ repository. It has two layers: **how to run a task end-to-end** (the
|
||||
sections below), and **how the codebase is built** (the technical sections
|
||||
further down, formerly in `CLAUDE.md`).
|
||||
|
||||
## ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE
|
||||
|
||||
THE TEN RULES BELOW ARE HARD CONSTRAINTS. Each one was paid for with a real
|
||||
production incident or a multi-PR bug chain in THIS repository (cited inline).
|
||||
They override convenience, deadlines and "it's just a small feature". A PR that
|
||||
violates any of them MUST be rejected in review regardless of how good the rest
|
||||
of it is. If a task genuinely seems to require breaking one — STOP and raise it
|
||||
with the owner; do not code around it.
|
||||
|
||||
### 1. EVERY BUFFER, CACHE, HISTORY AND PAYLOAD HAS AN EXPLICIT SIZE BUDGET
|
||||
|
||||
Nothing accumulates unboundedly. A row/item cap is NOT a byte cap. Anything
|
||||
replayed to a model, buffered in memory, persisted per step, or refetched by a
|
||||
poll must state its budget in bytes/tokens and enforce it. Rewriting a growing
|
||||
structure in full on every increment is FORBIDDEN — append or diff instead;
|
||||
O(n²) write/serialize patterns do not pass review.
|
||||
(Paid for by: full-row rewrite on every agent step — hundreds of MB of Postgres
|
||||
writes per 50-step run, with every tool output serialized twice; unbounded
|
||||
history replay killing long chats on the provider context window; 32 MB replay
|
||||
buffers per active run.)
|
||||
|
||||
### 2. EVERYTHING LONG-RUNNING TERMINATES BY CONSTRUCTION
|
||||
|
||||
Every run / row / session / lease / subscriber / queue entry must define AT
|
||||
DESIGN TIME: its owner; every terminal state; who writes the terminal state on
|
||||
EVERY path (success, error, abort, disconnect in each phase, process restart);
|
||||
retries for the terminal write; and a periodic sweeper that does not depend on
|
||||
a reboot. A best-effort terminal write with no retry and no sweep is FORBIDDEN.
|
||||
(Paid for by: assistant rows stuck 'streaming' forever; runs stuck 'running'
|
||||
409-locking their chat until a restart — the #183/#184 follow-up chain.)
|
||||
|
||||
### 3. EVERY AWAIT IS CANCELLABLE AND DEADLINED; NEVER BLOCK THE EVENT LOOP
|
||||
|
||||
Every async step inside a request or agent turn honors the turn's AbortSignal
|
||||
AND a wall-clock deadline — including in-app tools, lock queues and pagination
|
||||
loops, not just external calls. Synchronous CPU work beyond ~50 ms goes to a
|
||||
worker_thread. Promise.race DOES NOT cancel synchronous work — using it as a
|
||||
"timeout" for sync computation is forbidden (the timer only fires after the
|
||||
event loop is free again, i.e. after the damage is done).
|
||||
(Paid for by: in-app tools ignoring abortSignal and writing pages AFTER Stop;
|
||||
the synchronous ELK layout freezing every SSE stream in the process; the
|
||||
step-0 MCP handshake hang — #397.)
|
||||
|
||||
### 4. ONE SOURCE OF TRUTH; EVERYTHING ELSE IS A REBUILDABLE CACHE
|
||||
|
||||
Postgres is the authoritative state. Every in-memory structure (registries,
|
||||
caches, client stores) must be reconstructible from the DB and treated as
|
||||
lossy. The client renders SERVER-DECLARED state — "a run is active" is a server
|
||||
fact delivered as data, never inferred from side signals (204 vs 2xx, the
|
||||
flavor of a disconnect). A new feature must name the owner of each piece of
|
||||
state before implementation starts.
|
||||
(Paid for by: the strip/restore resume machinery, silently frozen UIs and
|
||||
ghost sends after unmount — the #381→#432→#456 chain.)
|
||||
|
||||
### 5. STATE MACHINES ARE EXPLICIT — ONE-SHOT FLAGS ARE FORBIDDEN
|
||||
|
||||
A complex lifecycle (chat thread, resume/reconnect, run) lives in a named-state
|
||||
automaton (reducer / enum) where every state has an owner and a rendered
|
||||
representation — including the failure states. Adding a boolean ref that one
|
||||
callback arms and another reads-and-clears is FORBIDDEN in the AI-chat client.
|
||||
New behavior = a new named state + explicit transitions, and the interruption
|
||||
matrix (disconnect in each phase × restart × stop × supersede) is enumerated at
|
||||
design time, not discovered one incident at a time.
|
||||
(Paid for by: 26 one-shot useRef flags in chat-thread.tsx and the drip of
|
||||
"one more missing transition" across #381→#386/#389→#432→#456.)
|
||||
|
||||
### 6. NO NEW MODE FORKS; A FLAG IS FOR ROLLOUT, THEN IT DIES
|
||||
|
||||
A behavior flag that forks a code path must ship with a written sunset
|
||||
condition; stacking a new flag onto the existing matrix without deleting or
|
||||
scheduling an old one is forbidden. While a temporary fork exists, BOTH sides
|
||||
must share identical lifecycle handling (abort semantics, error listeners,
|
||||
concurrency gates) — asymmetric forks are outlawed.
|
||||
(Paid for by: legacy vs autonomous divergence — the one-active-run gate and
|
||||
the socket 'error' listener each existing on only ONE side; 2^4 flag
|
||||
combinations each with different abort semantics.)
|
||||
|
||||
### 7. NO HAND-SYNCED MIRRORS — CODEGEN OR A CI PARITY TEST, NOTHING LESS
|
||||
|
||||
Two copies of the same knowledge (schema, tool registry, glyph map, probe
|
||||
body, hash/normalize algorithm, label list) require either generation from a
|
||||
single source or a CI test that FAILS on drift. A "mirror this change over
|
||||
there" comment is NOT a guard and does not pass review.
|
||||
(Paid for by: #293 — three drifting converter copies losing data; #447 —
|
||||
REGISTRY_STAMP covering only one of the mirrored files; ~10 still-unguarded
|
||||
mirrors across the MCP layer.)
|
||||
|
||||
### 8. CACHES, HEADERS, BUFFERS AND FSM TRANSITIONS GET AN INTEGRATION TEST OF THE OBSERVABLE PROPERTY
|
||||
|
||||
A unit test of a pure helper DOES NOT COUNT for these. Test the real header on
|
||||
the real HTTP response, the real cache hit under real token sources, the real
|
||||
transition under a really-killed socket. If the observable property cannot be
|
||||
tested, the design is wrong — fix the design, not the test.
|
||||
(Paid for by: #431→#439 — a cache keyed on a fresh-per-call JWT, so it NEVER
|
||||
hit and became prod incident #435 while its unit tests stayed green; and by
|
||||
the #352→#455 immutable-cache header silently overwritten by a framework
|
||||
default AFTER the unit-tested code ran.)
|
||||
|
||||
### 9. CLIENT INPUT IS HOSTILE UNTIL VALIDATED — ALSO BEFORE PERSISTENCE
|
||||
|
||||
Anything from the browser (message parts, ids, titles, selections, flags) is
|
||||
validated/sanitized BEFORE it is persisted into a row that will later be
|
||||
replayed into a prompt, a converter or another subsystem. A poisoned row must
|
||||
never be able to permanently brick a chat or a page on every subsequent read.
|
||||
(Paid for by: unvalidated UIMessage parts persisted verbatim — one bad row
|
||||
500s the chat on every later turn; #159 client-spoofed page titles; #388
|
||||
selection re-sanitized server-side for the same reason.)
|
||||
|
||||
### 10. FAILURES ARE LOUD AND SPECIFIC; SILENT DEGRADATION IS FORBIDDEN
|
||||
|
||||
Extends the error convention below: a fire-and-forget write is allowed ONLY
|
||||
with a metric or a greppable ERROR log; a degraded mode (dead cached MCP
|
||||
client, stopped poll, exhausted retries, evicted buffer) must be VISIBLE to
|
||||
the user or the operator. A feature that can quietly stop working — a frozen
|
||||
"streaming…" UI, a poll that silently gives up, a cache serving corpses — does
|
||||
not pass review.
|
||||
(Paid for by: the degraded poll's silent 10-minute death leaving a forever-
|
||||
"streaming" answer; dead MCP clients served from cache while every external
|
||||
tool call failed; #435 being caught in minutes ONLY because metrics — #403 —
|
||||
existed.)
|
||||
|
||||
## Default skill for feature design
|
||||
|
||||
For any feature-design request — the user hands over a raw feature idea, asks
|
||||
to design or think through a feature, or to draft an issue («спроектируй»,
|
||||
«продумай фичу», «составь ишью», "design X", "write an issue for X") — invoke
|
||||
the `orchestrator-feature-designer` skill (Skill tool) BEFORE any other work.
|
||||
It is the default operating mode for design work in this repository: research
|
||||
→ design checklist (R1–R10) → forks resolved with the human → adversarial
|
||||
self-attack → filed PR-sized issues. Do not design features or write issues
|
||||
ad-hoc while this skill is available. This does not apply to non-design work
|
||||
(bug fixes, reviews, retrospectives, refactors already specified by an issue).
|
||||
|
||||
## Task lifecycle
|
||||
|
||||
### 1. Start: sync with develop
|
||||
@@ -337,7 +470,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||
|
||||
## CI / release
|
||||
|
||||
@@ -115,6 +115,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
||||
breaking window so external configs break exactly once. (#413)
|
||||
|
||||
- **The Prometheus `/metrics` listener now binds to `127.0.0.1` (loopback) by
|
||||
default instead of `0.0.0.0` (all interfaces).** This closes an unauthenticated
|
||||
endpoint that was previously reachable on every interface. **DEPLOY MIGRATION —
|
||||
cross-container scraping breaks silently otherwise:** if your scraper runs in a
|
||||
SEPARATE container and reaches the app as `docmost:9464` (the exact topology the
|
||||
old `0.0.0.0` hardcode served), you MUST now set `METRICS_BIND=0.0.0.0` — and,
|
||||
because that re-exposes the endpoint, also set `METRICS_TOKEN=<secret>` and
|
||||
configure the scraper with a matching Bearer token. Without `METRICS_BIND`, the
|
||||
scraper can no longer connect and metrics go dark with no error. See the
|
||||
`METRICS_BIND` / `METRICS_TOKEN` block in `.env.example` for the migration.
|
||||
Same-host (loopback) scrapers need no change. (#486)
|
||||
|
||||
### Added
|
||||
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
@@ -251,6 +263,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
by physical key position and matched against the commands; genuine Cyrillic
|
||||
search terms keep priority over remapped candidates, and short wrong-layout
|
||||
prefixes match by command title. (#283, #285, #287)
|
||||
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
|
||||
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
|
||||
substrings of page titles and body text — so technical tokens the full-text
|
||||
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
|
||||
even when the FTS query is empty. It returns a location `path`, a windowed
|
||||
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
|
||||
`parentPageId` subtree scope, and applies the page-level permission filter
|
||||
before the limit. The web UI never sets `substring`, so its full-text search
|
||||
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
|
||||
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
|
||||
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
|
||||
a sequential scan. (#443)
|
||||
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
|
||||
`search` response shape changes for the agent surface: each hit now carries
|
||||
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
|
||||
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -282,6 +310,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||
|
||||
- **The server no longer leaks a hung stream pipe on every mid-run client
|
||||
disconnect.** The same `ai@6.0.134` pnpm patch now also fixes the SDK's
|
||||
`writeToServerResponse`, which awaited only a `"drain"` event under
|
||||
backpressure: when a client disconnected mid-write the socket never drained, so
|
||||
the write loop parked forever, `response.end()` was unreachable, and the stream
|
||||
reader plus buffered chunks were pinned until process restart (every mid-run
|
||||
disconnect in autonomous mode leaked one). The patch races `"drain"` against
|
||||
`"close"`/`"error"`, cancels the reader and ends the response on disconnect, and
|
||||
swallows the fire-and-forget read rejection instead of crashing on an
|
||||
unhandledRejection. (#486)
|
||||
|
||||
- **A failed autonomous agent-run start no longer becomes an unstoppable ghost
|
||||
run.** When `beginRun` failed for a transient reason (e.g. a DB-pool blip),
|
||||
the turn previously continued with NO run row — invisible to `/stop`, not
|
||||
aborted on disconnect, and able to slip a second run past the one-run-per-chat
|
||||
gate, leaving an unstoppable run until restart. The turn now fails fast with an
|
||||
honest `503 A_RUN_BEGIN_FAILED` before the first byte (no orphan state), and the
|
||||
client shows a "temporary — please try again" message instead of a misleading
|
||||
"provider not configured". (#486)
|
||||
|
||||
- **A pathological draw.io graph can no longer wedge the whole server.** The ELK
|
||||
auto-layout (`layout:"elk"`) ran elkjs synchronously on the main event loop, so
|
||||
a graph at the node/edge cap blocked ALL HTTP/SSE/loopback traffic while it
|
||||
churned — and the old `setTimeout` "timeout" could never fire because the same
|
||||
thread was blocked. Layout now runs in a worker thread with the timeout enforced
|
||||
by `worker.terminate()`; the main loop stays responsive. (#486)
|
||||
|
||||
- **The `/health` Redis probe no longer leaks a client on every tick while Redis
|
||||
is down.** It built a new `ioredis` client per probe and disconnected it only on
|
||||
success, so during an outage each health tick added another forever-reconnecting
|
||||
client (an unbounded handle leak). A single long-lived probe client is now
|
||||
reused and closed on shutdown. (#486)
|
||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||
link whose target page name had no file extension (e.g. a bare title) was
|
||||
collapsed to empty text during export, producing an unclickable, label-less
|
||||
@@ -358,6 +419,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
share); any other value now returns the generic "not found" instead of
|
||||
serving the page. (#218)
|
||||
|
||||
- **Tool and provider error text no longer leaks to anonymous readers in the
|
||||
public-share AI chat.** A failing tool's raw error (which could carry an
|
||||
internal page title or a stack fragment) and a provider error (which bundles the
|
||||
provider `statusCode` and response body — potentially the internal baseUrl or
|
||||
model name) were streamed verbatim to the anonymous reader over SSE. Errors are
|
||||
now sanitized at the source: the share toolset collapses any unclassified tool
|
||||
error to a safe generic string (safe, classified tool messages still pass
|
||||
through for the model's self-correction), and the anonymous stream `onError`
|
||||
maps provider failures to a fixed set of neutral strings — the full detail goes
|
||||
only to the server log. A UI render gate is layered on top. (closes #394)
|
||||
|
||||
- **The Prometheus `/metrics` endpoint can now require Bearer authentication and
|
||||
is loopback-bound by default.** Previously it listened on all interfaces with no
|
||||
auth. Setting `METRICS_TOKEN` requires every scrape to present
|
||||
`Authorization: Bearer <token>` (compared in constant time), and the listener
|
||||
defaults to `127.0.0.1` (see the Breaking Changes entry for the cross-container
|
||||
migration). (#486)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
|
||||
+15
@@ -45,6 +45,11 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# The mcp package reads its data files (drawio-presets.json, drawio-shape-index.json.gz)
|
||||
# at runtime via `new URL("../../data/…", import.meta.url)` relative to build/lib/*.js,
|
||||
# i.e. from packages/mcp/data/. tsc emits only build/, so ship data/ explicitly or
|
||||
# drawioFromGraph and the shape catalog die with ENOENT on packages/mcp/data/*.
|
||||
COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||
@@ -81,4 +86,14 @@ VOLUME ["/app/data/storage"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
|
||||
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
|
||||
# stash_page blob store + cached collab sessions are RAM-only and process-local.
|
||||
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
|
||||
# sessions silently breaks per-page write serialization (two replicas can lock
|
||||
# the same page at once) and makes stash_page blobs unreachable across replicas.
|
||||
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
|
||||
# sessions / consistent hashing on page id). There is deliberately no
|
||||
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
|
||||
# collaboration write path" block) and packages/mcp/README.md for details.
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
@@ -203,6 +203,52 @@ describe("ChatThread — send now (#198)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #486: the final onFinish -> flushNext() must be gated on the live-mount flag.
|
||||
// A clean onFinish can land AFTER the thread unmounts (New-chat / chat-switch
|
||||
// mid-stream — the async attach/resume settles late); flushing then dequeues and
|
||||
// re-POSTs a queued message from an abandoned thread (a "ghost" send).
|
||||
describe("ChatThread — onFinish flush gated on mount (#486)", () => {
|
||||
beforeEach(resetState);
|
||||
afterEach(cleanup);
|
||||
|
||||
it("a clean onFinish WHILE MOUNTED flushes the queued message (control)", () => {
|
||||
renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text"
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// Mounted: the queue flushes normally.
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("a clean onFinish AFTER unmount does NOT flush (no ghost send)", () => {
|
||||
const { unmount } = renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn")); // enqueue "queued text"
|
||||
h.state.sendMessage.mockClear();
|
||||
|
||||
// Chat switched away mid-stream: the streamer unmounts...
|
||||
unmount();
|
||||
// ...and a late, clean onFinish lands on the abandoned thread.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// Gated on mountedRef: NOTHING is sent from the dead thread.
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #396: in autonomous mode a live sendNow must additionally request the
|
||||
// AUTHORITATIVE server stop of the detached run (a local abort is only a client
|
||||
// disconnect the server ignores) and arm a bounded 409 retry so the re-POST
|
||||
|
||||
@@ -659,7 +659,13 @@ export default function ChatThread({
|
||||
return;
|
||||
}
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
// Gate the final flush on the live-mount flag (#486): a clean onFinish can
|
||||
// land AFTER this thread unmounted (a New-chat / chat-switch mid-stream —
|
||||
// the async attach/resume settles late). Flushing then dequeues and POSTs a
|
||||
// queued message from an abandoned thread — a "ghost" send / ghost chat.
|
||||
// Every other queue side effect already guards on mountedRef; this last one
|
||||
// was the gap.
|
||||
if (mountedRef.current) flushNext();
|
||||
},
|
||||
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
|
||||
// Log the raw failure here for devtools; the UI shows a friendly classified
|
||||
|
||||
@@ -47,6 +47,13 @@ interface MessageItemProps {
|
||||
* agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether a failed tool card renders its raw
|
||||
* errorText. Defaults to true (internal chat). The public share passes false so
|
||||
* internal detail in a tool error is never painted (belt to the server-side
|
||||
* byte sanitization).
|
||||
*/
|
||||
showErrors?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
@@ -125,6 +132,7 @@ function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
@@ -219,6 +227,7 @@ function MessageItem({
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
showErrors={showErrors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +293,7 @@ export function arePropsEqual(
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.showInput === next.showInput &&
|
||||
prev.showErrors === next.showErrors &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName &&
|
||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||
|
||||
@@ -32,6 +32,12 @@ interface MessageListProps {
|
||||
* doesn't see the agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders
|
||||
* its raw errorText. Defaults to true (internal chat). The public share passes
|
||||
* false so internal detail in a tool error is never painted.
|
||||
*/
|
||||
showErrors?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
@@ -127,6 +133,7 @@ export default function MessageList({
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
@@ -217,6 +224,7 @@ export default function MessageList({
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
showErrors={showErrors}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||
|
||||
@@ -30,6 +30,16 @@ interface ToolCallCardProps {
|
||||
* the extra summary line, leaving the card (the action log) intact.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Whether to render the tool's raw errorText on a failed call. Defaults to true
|
||||
* (the internal chat, where the operator may debug). The public share passes
|
||||
* false: a tool error string can carry internal detail (an internal page title,
|
||||
* a stack fragment, a provider message). This is the RENDER gate only — the
|
||||
* authoritative fix also sanitizes the bytes server-side (see
|
||||
* PublicShareChatToolsService.forShare), so a share reader never receives raw
|
||||
* error text over the wire, not just never sees it painted (#394).
|
||||
*/
|
||||
showErrors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +51,7 @@ export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
showErrors = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
@@ -74,7 +85,7 @@ export default function ToolCallCard({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state === "error" && part.errorText && (
|
||||
{state === "error" && showErrors && part.errorText && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{part.errorText}
|
||||
</Text>
|
||||
|
||||
@@ -23,6 +23,25 @@ describe("describeChatError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an A_RUN_BEGIN_FAILED 503 as a temporary run-start failure, NOT provider-not-configured (#486)", () => {
|
||||
// The FULL real body the server writes for a beginRun failure: a
|
||||
// ServiceUnavailableException(object) whose response is serialized verbatim
|
||||
// onto the raw socket, self-describing statusCode 503 + the run-start code.
|
||||
const body =
|
||||
'{"message":"Could not start the agent run. This is usually temporary — please try again.","code":"A_RUN_BEGIN_FAILED","statusCode":503}';
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Could not start the run",
|
||||
detail:
|
||||
"The agent run could not be started. This is usually temporary — please try again.",
|
||||
});
|
||||
// ORDER GUARD: even though the body ALSO carries statusCode 503 (which the
|
||||
// generic branch matches), the A_RUN_BEGIN_FAILED branch runs first, so it is
|
||||
// never mislabeled "AI provider not configured".
|
||||
expect(describeChatError(body, t).title).not.toBe(
|
||||
"AI provider not configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
|
||||
@@ -24,6 +24,21 @@ export function describeChatError(
|
||||
): ChatErrorView {
|
||||
const msg = message ?? "";
|
||||
|
||||
// Our own "could not start the run" gate (A_RUN_BEGIN_FAILED, #486): a 503
|
||||
// whose body carries this code is a TEMPORARY server-side failure while
|
||||
// starting the run (e.g. a DB-pool blip), NOT an unconfigured provider. It MUST
|
||||
// be matched STRICTLY BEFORE the generic 503 branch below, which would
|
||||
// otherwise mislabel it "The AI provider is not configured" and tell the user
|
||||
// to call an admin instead of just retrying.
|
||||
if (/"code"\s*:\s*"A_RUN_BEGIN_FAILED"/.test(msg)) {
|
||||
return {
|
||||
title: t("Could not start the run"),
|
||||
detail: t(
|
||||
"The agent run could not be started. This is usually temporary — please try again.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||
return {
|
||||
title: t("AI chat is disabled"),
|
||||
|
||||
@@ -168,6 +168,10 @@ export default function ShareAiWidget({
|
||||
// Anonymous reader: suppress the tool-argument summary line so the
|
||||
// agent's raw query/argument text isn't shown on the public share.
|
||||
showInput={false}
|
||||
// Anonymous reader: never paint a tool's raw errorText (it can carry
|
||||
// internal detail). This is the render gate; the bytes are also
|
||||
// sanitized server-side in PublicShareChatToolsService.forShare (#394).
|
||||
showErrors={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
// leak as clickable links (external http(s) links are kept).
|
||||
|
||||
@@ -43,6 +43,9 @@ function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
||||
workspaceId: v.workspaceId,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||
// #487: terminal finalize now goes through the CONDITIONAL write. Default
|
||||
// returns a truthy row (the run WAS active -> this call wrote it).
|
||||
finalizeIfActive: jest.fn(async () => ({ id: 'run-1', status: 'succeeded' })),
|
||||
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||
findActiveByChat: jest.fn(async () => undefined),
|
||||
findLatestByChat: jest.fn(async () => undefined),
|
||||
@@ -336,14 +339,12 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
// #487: the terminal write is CONDITIONAL (finalizeIfActive); finishedAt is
|
||||
// stamped inside the repo method, so the service passes just status + error.
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
error: 'provider blew up',
|
||||
finishedAt: expect.any(Date),
|
||||
}),
|
||||
expect.objectContaining({ status: 'failed', error: 'provider blew up' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -366,8 +367,8 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||
@@ -389,8 +390,8 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
const updateGate = new Promise((res) => {
|
||||
resolveUpdate = res;
|
||||
});
|
||||
const update = jest.fn(() => updateGate);
|
||||
const repo = makeRepo({ update });
|
||||
const finalizeIfActive = jest.fn(() => updateGate);
|
||||
const repo = makeRepo({ finalizeIfActive });
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
@@ -399,23 +400,23 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
});
|
||||
|
||||
// Fire both before the (pending) update resolves. The first synchronously
|
||||
// claims the entry (active.delete) and awaits update; the second, started in
|
||||
// the same macrotask, finds the entry already gone and returns at the claim
|
||||
// WITHOUT ever calling update.
|
||||
// claims the entry (active.delete) and awaits the write; the second, started
|
||||
// in the same macrotask, finds the entry already gone and returns at the claim
|
||||
// WITHOUT ever writing.
|
||||
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||
|
||||
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Let the single in-flight update land; both calls resolve cleanly.
|
||||
resolveUpdate({ id: 'run-1' });
|
||||
resolveUpdate({ id: 'run-1', status: 'succeeded' });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||
// 'error' settle never wrote, so it could not clobber the real status.
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect(finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
@@ -431,10 +432,10 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
// 409s until a restart. The fix updates FIRST and retries.
|
||||
let calls = 0;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('deadlock detected');
|
||||
return { id: 'run-1' };
|
||||
return { id: 'run-1', status: 'succeeded' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
@@ -447,26 +448,29 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
|
||||
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||
// The retry landed the terminal write: the entry is dropped (slot freed), no
|
||||
// zombie left, and the row carries the real terminal status.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
expect(svc.hasZombie('run-1')).toBe(false);
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(2);
|
||||
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
||||
it('#487 give-up: if the terminal write keeps failing, finalizeRun leaves a ZOMBIE (does NOT restore the entry) and settleZombie re-drives it', async () => {
|
||||
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||
// #487 changes the give-up behaviour: the entry is NOT restored (a restored
|
||||
// entry is indistinguishable from a live run). Instead a ZOMBIE record holds
|
||||
// the intended terminal status, and a re-drive (settleZombie — called by the
|
||||
// reconcile / supersede / opportunistic paths) applies it later.
|
||||
let healthy = false;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
if (!healthy) throw new Error('pool exhausted');
|
||||
return { id: 'run-1' };
|
||||
return { id: 'run-1', status: 'succeeded' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
@@ -480,35 +484,83 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||
// First settle: every bounded attempt fails -> ZOMBIE, entry NOT restored.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||
// blip — distinct from the per-attempt warns.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false); // NOT a live entry
|
||||
expect(svc.hasZombie('run-1')).toBe(true);
|
||||
expect(svc.zombieRunIds()).toContain('run-1');
|
||||
// The give-up emits ONE explicit, greppable ERROR mentioning the zombie.
|
||||
const gaveUp = errorSpy.mock.calls.some(
|
||||
(c) =>
|
||||
/NON-TERMINAL/.test(String(c[0])) &&
|
||||
/ZOMBIE/.test(String(c[0])) &&
|
||||
/run-1/.test(String(c[0])) &&
|
||||
/chat-1/.test(String(c[0])),
|
||||
);
|
||||
expect(gaveUp).toBe(true);
|
||||
// The settle notifier resolved as terminalWriteFailed (a subscriber learns the
|
||||
// slot still needs the intended status applied).
|
||||
const outcome = await svc.peekSettled('run-1');
|
||||
expect(outcome).toEqual({
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
terminalWriteFailed: true,
|
||||
});
|
||||
|
||||
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||
// The DB recovers; a re-drive settles the zombie via the conditional UPDATE.
|
||||
healthy = true;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
const redriven = await svc.settleZombie('run-1');
|
||||
expect(redriven).toBe(true);
|
||||
expect(svc.hasZombie('run-1')).toBe(false);
|
||||
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
|
||||
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||
// written), so a double-settle can never clobber the real status.
|
||||
const callsBefore = repo.update.mock.calls.length;
|
||||
// A later finalizeRun is idempotent (row already terminal): it no-ops at the
|
||||
// once-gate, never re-writing.
|
||||
const callsBefore = repo.finalizeIfActive.mock.calls.length;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(callsBefore);
|
||||
});
|
||||
|
||||
it('#487 double-settle collapses to a benign no-op (conditional write; notifier resolves once)', async () => {
|
||||
// A second concurrent settle is stopped at the synchronous active.delete
|
||||
// claim, so the terminal write runs exactly once and the notifier resolves
|
||||
// exactly once with the FIRST settler's outcome.
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'aborted');
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late'); // no-op
|
||||
|
||||
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
const outcome = await svc.peekSettled('run-1');
|
||||
// peekSettled after resolve+delete falls through (notifier dropped, no zombie)
|
||||
// -> undefined; the FIRST settler already resolved any earlier subscriber.
|
||||
expect(outcome).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#487 late settledPromise subscriber gets the resolved outcome', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
||||
|
||||
// Subscribe BEFORE settle: hold the promise reference (as supersede does).
|
||||
const early = svc.peekSettled('run-1');
|
||||
expect(early).toBeDefined();
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
|
||||
// The reference grabbed before settle resolves with the written outcome, even
|
||||
// though the notifier was dropped from the map on resolve (bounded).
|
||||
await expect(early).resolves.toEqual({
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
terminalWriteFailed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||
@@ -525,3 +577,197 @@ describe('AiChatRunService run lifecycle', () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#487 AiChatRunService.supersede (CAS)', () => {
|
||||
const chat = 'chat-1';
|
||||
const ws = 'ws-1';
|
||||
|
||||
it('degrade: no active run on the chat -> caller sends a normal turn', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => undefined),
|
||||
findActiveByChat: jest.fn(async () => undefined),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'degrade' });
|
||||
});
|
||||
|
||||
it('invalid: the target run belongs to a DIFFERENT chat -> 400', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'run-x',
|
||||
chatId: 'other-chat',
|
||||
workspaceId: ws,
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'invalid' });
|
||||
});
|
||||
|
||||
it('mismatch: a DIFFERENT run is active than the one targeted -> current runId', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({ id: 'run-x', chatId: chat, workspaceId: ws })),
|
||||
findActiveByChat: jest.fn(async () => ({
|
||||
id: 'run-live',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({
|
||||
kind: 'mismatch',
|
||||
activeRunId: 'run-live',
|
||||
});
|
||||
});
|
||||
|
||||
it('ready: the target IS active -> stop it, await its (fast) settle, free the slot', async () => {
|
||||
// Simulate a live long TOOL (NOT a slow UPDATE): the run stays active until an
|
||||
// explicit Stop unwinds it; commit-1's race makes that settle land quickly.
|
||||
// The abort listener stands in for streamText's onAbort -> finalizeRun.
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'aborted',
|
||||
error: null,
|
||||
})),
|
||||
findActiveByChat: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
handle.signal.addEventListener('abort', () => {
|
||||
void svc.finalizeRun('run-1', ws, 'aborted');
|
||||
});
|
||||
|
||||
// supersede: getRun -> getActiveByChat(==target) -> requestStop -> the abort
|
||||
// listener settles the run -> awaitSettled resolves -> ready.
|
||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||
kind: 'ready',
|
||||
});
|
||||
expect(handle.signal.aborted).toBe(true); // Stop reached the run
|
||||
});
|
||||
|
||||
it('timeout: the target never settles within W -> 409 SUPERSEDE_TIMEOUT (nothing persisted)', async () => {
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
||||
findActiveByChat: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
})),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
// Do NOT settle the run: a tiny W elapses -> timeout.
|
||||
const result = await svc.supersede(chat, 'run-1', ws, 30);
|
||||
expect(result).toEqual({ kind: 'timeout' });
|
||||
});
|
||||
|
||||
it('ready then a DUPLICATE supersede POST degrades (the run is already gone)', async () => {
|
||||
let active: unknown = {
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
};
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'aborted',
|
||||
error: null,
|
||||
})),
|
||||
findActiveByChat: jest.fn(async () => active),
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
active = undefined; // settling frees the active slot
|
||||
return { id: 'run-1', status: 'aborted' };
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
handle.signal.addEventListener('abort', () => {
|
||||
void svc.finalizeRun('run-1', ws, 'aborted');
|
||||
});
|
||||
|
||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||
kind: 'ready',
|
||||
});
|
||||
// The duplicate POST for the same target now finds no active run -> degrade.
|
||||
expect(await svc.supersede(chat, 'run-1', ws)).toEqual({ kind: 'degrade' });
|
||||
});
|
||||
|
||||
it('reconcileStaleRuns: aborts a stale run with NO entry/zombie; NEVER touches a live entry', async () => {
|
||||
const finalizeIfActive = jest.fn(async () => ({ id: 'x', status: 'aborted' }));
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async (v: any) => ({
|
||||
id: 'live-1',
|
||||
status: 'running',
|
||||
chatId: v.chatId,
|
||||
workspaceId: v.workspaceId,
|
||||
})),
|
||||
finalizeIfActive,
|
||||
findStaleActive: jest.fn(async () => [
|
||||
{ id: 'orphan-1', workspaceId: ws, chatId: 'c-orphan' },
|
||||
{ id: 'live-1', workspaceId: ws, chatId: 'c-live' },
|
||||
]),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
// A LIVE run this replica owns (in the `active` map).
|
||||
await svc.beginRun({ chatId: 'c-live', workspaceId: ws, userId: 'u1' });
|
||||
expect(svc.isLocallyActive('live-1')).toBe(true);
|
||||
|
||||
const aborted = await svc.reconcileStaleRuns(15 * 60 * 1000);
|
||||
expect(aborted).toBe(1);
|
||||
// The orphan (no entry) was aborted; the live entry was NEVER passed to the DB.
|
||||
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
expect(finalizeIfActive).toHaveBeenCalledWith(
|
||||
'orphan-1',
|
||||
ws,
|
||||
expect.objectContaining({ status: 'aborted' }),
|
||||
);
|
||||
expect(svc.isLocallyActive('live-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('gave-up zombie: supersede applies the intended status (settleZombie) then is ready', async () => {
|
||||
let healthy = false;
|
||||
let active: unknown = {
|
||||
id: 'run-1',
|
||||
chatId: chat,
|
||||
workspaceId: ws,
|
||||
status: 'running',
|
||||
};
|
||||
const repo = makeRepo({
|
||||
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
||||
findActiveByChat: jest.fn(async () => active),
|
||||
finalizeIfActive: jest.fn(async () => {
|
||||
if (!healthy) throw new Error('db down');
|
||||
active = undefined;
|
||||
return { id: 'run-1', status: 'aborted' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||
|
||||
// The run's terminal write gives up -> zombie (row still 'running').
|
||||
await svc.finalizeRun('run-1', ws, 'aborted');
|
||||
expect(svc.hasZombie('run-1')).toBe(true);
|
||||
|
||||
// The DB recovers; supersede awaits the (already-resolved, terminalWriteFailed)
|
||||
// settle, then settleZombie applies the intended status -> ready.
|
||||
healthy = true;
|
||||
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||
kind: 'ready',
|
||||
});
|
||||
expect(svc.hasZombie('run-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,88 @@ export class RunAlreadyActiveError extends Error {
|
||||
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||
|
||||
/** The terminal run statuses — the row is done once it reads one of these. */
|
||||
export const RUN_TERMINAL_STATUSES: readonly RunTerminalStatus[] = [
|
||||
'succeeded',
|
||||
'failed',
|
||||
'aborted',
|
||||
];
|
||||
|
||||
/** Whether a persisted run status is terminal (settled). */
|
||||
export function isRunTerminal(status: string | null | undefined): boolean {
|
||||
return (
|
||||
status === 'succeeded' || status === 'failed' || status === 'aborted'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the outcome a run's {@link AiChatRunService.finalizeRun} settled with.
|
||||
* `terminalWriteFailed` = the terminal write GAVE UP after the bounded retry, so
|
||||
* the row is still non-terminal ('running') and a ZOMBIE record holds the
|
||||
* `intended` status for a later re-drive (reconcile / supersede / boot sweep). A
|
||||
* subscriber (supersede, #487 commit 3) uses this to decide whether the slot is
|
||||
* genuinely free or must first have the intended status applied.
|
||||
*/
|
||||
export interface RunSettleOutcome {
|
||||
status: RunTerminalStatus;
|
||||
error: string | null;
|
||||
terminalWriteFailed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: how long a supersede waits for the target run to settle after Stop before
|
||||
* it degrades to `SUPERSEDE_TIMEOUT`. W=10s is generous under a HEALTHY DB: commit
|
||||
* 1's race-on-abort makes an in-app tool abort->settle in ms/hundreds of ms, so a
|
||||
* live run releases its slot well within the window. Under a DB brownout the
|
||||
* timeout is normal (the write cannot land); W must NOT be raised to paper
|
||||
* over a slow DB — a SUPERSEDE_TIMEOUT is the honest signal (nothing persisted,
|
||||
* the composer keeps the user's text). Env-tunable for ops, default 10s.
|
||||
*/
|
||||
export const SUPERSEDE_SETTLE_TIMEOUT_MS = (() => {
|
||||
const raw = Number(process.env.AI_CHAT_SUPERSEDE_TIMEOUT_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
|
||||
})();
|
||||
|
||||
/**
|
||||
* #487: the result of the supersede CAS ({@link AiChatRunService.supersede}).
|
||||
* - `degrade` : no active run on the chat (it ended between click and POST) —
|
||||
* the caller sends a NORMAL turn (NOT a mismatch);
|
||||
* - `invalid` : the target runId belongs to a DIFFERENT chat (malformed CAS 400);
|
||||
* - `mismatch` : a DIFFERENT run is active than the one the client targeted —
|
||||
* 409 SUPERSEDE_TARGET_MISMATCH carrying the current `activeRunId`
|
||||
* (the client does NOT auto-retry);
|
||||
* - `timeout` : the target did not settle within W — 409 SUPERSEDE_TIMEOUT,
|
||||
* nothing persisted;
|
||||
* - `ready` : the target was stopped AND settled (or its zombie's intended was
|
||||
* applied) — the slot is free; the caller may beginRun the new run.
|
||||
*/
|
||||
export type SupersedeResult =
|
||||
| { kind: 'degrade' }
|
||||
| { kind: 'invalid' }
|
||||
| { kind: 'mismatch'; activeRunId: string }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'ready' };
|
||||
|
||||
/** A one-shot settle notifier (#487): `resolve` is called EXACTLY ONCE. */
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: a run whose terminal write GAVE UP (every bounded attempt failed). The
|
||||
* row is stranded non-terminal ('running'); this record is the ONLY thing that
|
||||
* distinguishes it from a live run, and carries the `intended` terminal status so
|
||||
* a re-drive can apply it via the conditional UPDATE. Process-local (phase-1
|
||||
* single-process assumption): a restart drops it, and the boot sweep then writes
|
||||
* 'aborted' over the intended — a documented loss (see finalizeRun).
|
||||
*/
|
||||
interface ZombieRun {
|
||||
workspaceId: string;
|
||||
chatId: string;
|
||||
intended: { status: RunTerminalStatus; error: string | null };
|
||||
}
|
||||
|
||||
export function mapTurnStatusToRun(
|
||||
status: TurnTerminalStatus,
|
||||
): RunTerminalStatus {
|
||||
@@ -101,6 +183,22 @@ export class AiChatRunService implements OnModuleInit {
|
||||
// uptime — negligible in phase 1's single process.
|
||||
private readonly settled = new Set<string>();
|
||||
|
||||
// #487 runId -> one-shot settle notifier. Kept in a SEPARATE map from `active`
|
||||
// ON PURPOSE: it must OUTLIVE the `active.delete` claim inside finalizeRun (the
|
||||
// claim frees the slot the instant finalize starts), so a subscriber can still
|
||||
// await the outcome after the entry is gone. Created in beginRun, resolved
|
||||
// EXACTLY ONCE in finalizeRun, then removed (bounded). Absence => this replica
|
||||
// has no live notifier: a subscriber falls back to the zombie map, then to the
|
||||
// row (see peekSettled). Process-local (phase-1 single-process assumption).
|
||||
private readonly settledPromises = new Map<string, Deferred<RunSettleOutcome>>();
|
||||
|
||||
// #487 runId -> ZOMBIE record: a run whose terminal write gave up (row stranded
|
||||
// non-terminal). BOUNDED — an entry is added only on give-up and removed on a
|
||||
// successful re-drive (settleZombie) or when the row is found already terminal;
|
||||
// a process restart clears it (and the boot sweep settles the stranded row).
|
||||
// Process-local (phase-1 single-process assumption).
|
||||
private readonly zombies = new Map<string, ZombieRun>();
|
||||
|
||||
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||
// brief connection blip). Riding out that blip in-place matters because the
|
||||
@@ -224,6 +322,10 @@ export class AiChatRunService implements OnModuleInit {
|
||||
chatId: args.chatId,
|
||||
workspaceId: args.workspaceId,
|
||||
});
|
||||
// #487: arm the one-shot settle notifier BEFORE returning, so a subscriber
|
||||
// that races in immediately after begin always finds a promise to await. It
|
||||
// is resolved exactly once when the run settles (or gives up).
|
||||
this.settledPromises.set(run.id, this.makeDeferred<RunSettleOutcome>());
|
||||
return { runId: run.id, signal: controller.signal };
|
||||
}
|
||||
|
||||
@@ -263,47 +365,43 @@ export class AiChatRunService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||
* Finalize a run to its terminal status (succeeded / failed / aborted) via a
|
||||
* CONDITIONAL UPDATE, stamping finishedAt + any error. Atomically safe against a
|
||||
* concurrent settle AND robust against a transient terminal-write failure.
|
||||
*
|
||||
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||
* so two callers can both see `false` and both write the row (last-write-wins
|
||||
* clobbers the real terminal status, and the bounded retry only widens that
|
||||
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
||||
* check-and-clear with NO await between the gate and the entry removal: the
|
||||
* second concurrent caller finds the entry already gone and returns in the same
|
||||
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
||||
* finalizing" is thus a single atomic step.
|
||||
* claim happens via `active.delete`, a SYNCHRONOUS check-and-clear with NO await
|
||||
* between the gate and the entry removal: the second concurrent caller finds the
|
||||
* entry already gone and returns in the same tick, before any UPDATE.
|
||||
*
|
||||
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
||||
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
||||
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
||||
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
||||
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
||||
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
||||
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
||||
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
||||
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
||||
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
||||
* and harmless meanwhile.
|
||||
* ALL TERMINAL WRITES ARE CONDITIONAL (#487): `finalizeIfActive` only flips a
|
||||
* row still in pending|running (mirror of the assistant message's
|
||||
* `onlyIfStreaming`). So even a settle that DID reach the UPDATE (e.g. a
|
||||
* reconcile stamp racing an owner finalize) can never clobber a terminal status
|
||||
* — the loser matches nothing and is a benign no-op. `active.delete` is the
|
||||
* fast, in-process gate; the conditional WHERE is the authoritative one.
|
||||
*
|
||||
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||
* double-settle collapses to a single write and a late settle can never clobber
|
||||
* the real terminal status or double-write the row.
|
||||
* ZOMBIE ON GIVE-UP (#487): if every bounded attempt THROWS (the DB is down for
|
||||
* the whole finalize), we do NOT restore the entry. The row is stranded
|
||||
* non-terminal ('running'); we record a ZOMBIE `{ terminalWriteFailed, intended
|
||||
* }` (the ONLY thing distinguishing this dead run from a live one) and resolve
|
||||
* the settle notifier with `terminalWriteFailed: true`. A restore would make the
|
||||
* zombie indistinguishable from a live run to every reader; instead a re-drive
|
||||
* (settleZombie, called by the periodic reconcile / supersede / opportunistic
|
||||
* paths) applies the intended status later via the same conditional UPDATE.
|
||||
*
|
||||
* DOCUMENTED LOSS (#487, single-process phase 1): if the process RESTARTS before
|
||||
* a zombie is re-driven, the in-memory zombie map is gone and the boot sweep
|
||||
* (unconditional) writes 'aborted' over the ACTUAL intended status. This is
|
||||
* unavoidable while the run lifecycle is single-process — there is no durable
|
||||
* record of `intended`; a cross-process durable intent is deferred to phase 2.
|
||||
*
|
||||
* IDEMPOTENT: the settle notifier resolves EXACTLY ONCE; a second settle is
|
||||
* stopped at `settled.has` or the `active.delete` claim, so a double-settle
|
||||
* collapses to a single write and can never double-resolve or clobber the row.
|
||||
*/
|
||||
async finalizeRun(
|
||||
runId: string,
|
||||
@@ -314,13 +412,17 @@ export class AiChatRunService implements OnModuleInit {
|
||||
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||
// Already terminally written -> idempotent no-op.
|
||||
if (this.settled.has(runId)) return;
|
||||
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||
// Capture the entry BEFORE the delete for the give-up log context.
|
||||
const entry = this.active.get(runId);
|
||||
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||
// the same tick, before any await — so it can never reach the UPDATE.
|
||||
if (!this.active.delete(runId)) return;
|
||||
|
||||
const status = mapTurnStatusToRun(turnStatus);
|
||||
const err = error ?? null;
|
||||
const chatId = entry?.chatId ?? 'unknown';
|
||||
|
||||
let lastError: unknown;
|
||||
for (
|
||||
let attempt = 1;
|
||||
@@ -328,47 +430,294 @@ export class AiChatRunService implements OnModuleInit {
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, {
|
||||
status: mapTurnStatusToRun(turnStatus),
|
||||
finishedAt: new Date(),
|
||||
error: error ?? null,
|
||||
const row = await this.runRepo.finalizeIfActive(runId, workspaceId, {
|
||||
status,
|
||||
error: err,
|
||||
});
|
||||
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||
// (claimed above); we do NOT restore it. The slot is now free.
|
||||
// No throw => the row is now terminal (we wrote it, or it was ALREADY
|
||||
// terminal — another writer won the conditional UPDATE, a benign no-op).
|
||||
this.settled.add(runId);
|
||||
this.zombies.delete(runId);
|
||||
// Resolve with the persisted outcome: our status when WE wrote it, else
|
||||
// the row's real terminal status (re-read on the already-terminal path so
|
||||
// a subscriber never sees a status we did not actually persist).
|
||||
const outcome: RunSettleOutcome = row
|
||||
? { status, error: err, terminalWriteFailed: false }
|
||||
: await this.readTerminalOutcome(runId, workspaceId, status, err);
|
||||
this.resolveSettled(runId, outcome);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
} catch (err2) {
|
||||
lastError = err2;
|
||||
this.logger.warn(
|
||||
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||
}): ${err2 instanceof Error ? err2.message : 'unknown error'}`,
|
||||
);
|
||||
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||
// Every attempt threw: GIVE UP. The row is stranded non-terminal ('running').
|
||||
// Do NOT restore the entry (a restored entry is indistinguishable from a live
|
||||
// run); leave a ZOMBIE record instead, and resolve the notifier as
|
||||
// terminalWriteFailed so a subscriber knows the slot still needs the intended
|
||||
// status applied. One explicit, greppable ERROR so an operator can tell a
|
||||
// give-up from a per-attempt blip.
|
||||
this.logger.error(
|
||||
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||
`('running'): terminal write failed after ${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||
`boot sweep`,
|
||||
`Run ${runId} (chat ${chatId}) left NON-TERMINAL ('running'): terminal ` +
|
||||
`write failed after ${AiChatRunService.FINALIZE_MAX_ATTEMPTS} attempts; ` +
|
||||
`ZOMBIE recorded (intended '${status}'), recovery deferred to reconcile / ` +
|
||||
`supersede / boot sweep`,
|
||||
lastError,
|
||||
);
|
||||
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||
// window above is consumed at the `active.delete` claim, and the no-streamText
|
||||
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
||||
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||
// cleared on restart.
|
||||
if (entry) this.active.set(runId, entry);
|
||||
this.zombies.set(runId, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
intended: { status, error: err },
|
||||
});
|
||||
this.resolveSettled(runId, { status, error: err, terminalWriteFailed: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: re-drive a zombie run's intended terminal write (the conditional
|
||||
* UPDATE). Called by the periodic reconcile (commit 4), an opportunistic
|
||||
* single-chat reconcile, and supersede (commit 3). On success — the row is now
|
||||
* terminal (written OR found already terminal) — the zombie is cleared and the
|
||||
* once-gate armed; on another failure the zombie is kept for a later retry.
|
||||
* Returns true when the row is now terminal. Best-effort; never throws.
|
||||
*/
|
||||
async settleZombie(runId: string): Promise<boolean> {
|
||||
const z = this.zombies.get(runId);
|
||||
if (!z) return false;
|
||||
try {
|
||||
await this.runRepo.finalizeIfActive(runId, z.workspaceId, {
|
||||
status: z.intended.status,
|
||||
error: z.intended.error,
|
||||
});
|
||||
this.zombies.delete(runId);
|
||||
this.settled.add(runId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Re-drive of zombie run ${runId} (chat ${z.chatId}) failed; will retry ` +
|
||||
`later: ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #487 reconcile clause (c): abort runs the DB still shows active (pending|
|
||||
* running) but that this replica does NOT own — NO live entry AND NO zombie —
|
||||
* and that have been UNTOUCHED past `staleMs` (from last-progress `updated_at`,
|
||||
* NOT startedAt, so a legit long marathon is never a candidate). "No entry" is
|
||||
* the PRIMARY gate: a live entry (an actively-executing run on this replica) is
|
||||
* NEVER aborted, whatever its age. Returns the number aborted. Best-effort —
|
||||
* never throws (a periodic-job failure must not crash the process).
|
||||
*/
|
||||
async reconcileStaleRuns(staleMs: number): Promise<number> {
|
||||
let candidates: Array<{ id: string; workspaceId: string; chatId: string }>;
|
||||
try {
|
||||
candidates = await this.runRepo.findStaleActive(staleMs);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile (stale runs) query failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
let aborted = 0;
|
||||
for (const c of candidates) {
|
||||
// PRIMARY gate: never touch a live entry, and never race a zombie we are
|
||||
// already re-driving (settleZombie owns those).
|
||||
if (this.active.has(c.id) || this.zombies.has(c.id)) continue;
|
||||
try {
|
||||
const row = await this.runRepo.finalizeIfActive(c.id, c.workspaceId, {
|
||||
status: 'aborted',
|
||||
error: 'Run aborted by reconcile: no live runner (stale).',
|
||||
});
|
||||
if (row) {
|
||||
aborted += 1;
|
||||
this.settled.add(c.id);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile abort of stale run ${c.id} failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return aborted;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the run's settle outcome as seen by THIS replica, or undefined when it
|
||||
* has no record (the caller then reads the row — the DB is the source of truth).
|
||||
* A LIVE deferred (still settling, or resolved-but-not-yet-consumed) wins; a
|
||||
* ZOMBIE synthesizes the give-up outcome. A subscriber (supersede) races this
|
||||
* against a timeout.
|
||||
*/
|
||||
peekSettled(runId: string): Promise<RunSettleOutcome> | undefined {
|
||||
const d = this.settledPromises.get(runId);
|
||||
if (d) return d.promise;
|
||||
const z = this.zombies.get(runId);
|
||||
if (z) {
|
||||
return Promise.resolve({
|
||||
status: z.intended.status,
|
||||
error: z.intended.error,
|
||||
terminalWriteFailed: true,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: await a run's settle outcome, bounded by `timeoutMs`. Returns the
|
||||
* outcome on settle, or undefined on TIMEOUT (or when this replica has no record
|
||||
* of the run and its row is not terminal). Uses the LIVE settle notifier / the
|
||||
* zombie synth when present; else reads the row (the DB is the source of truth
|
||||
* once the in-memory record is gone). The subscriber (supersede) grabs this
|
||||
* right after Stop; commit 1's race makes the settle land in ms on a healthy DB.
|
||||
*/
|
||||
async awaitSettled(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<RunSettleOutcome | undefined> {
|
||||
const pending = this.peekSettled(runId);
|
||||
if (pending) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
return await Promise.race([pending, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
// No live notifier and no zombie: read the row (already settled-and-written,
|
||||
// or unknown here). A terminal row is an outcome; anything else -> undefined.
|
||||
const row = await this.runRepo.findById(runId, workspaceId);
|
||||
if (row && isRunTerminal(row.status)) {
|
||||
return {
|
||||
status: row.status as RunTerminalStatus,
|
||||
error: row.error ?? null,
|
||||
terminalWriteFailed: false,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the SERVER supersede CAS for `POST /stream { supersede: { runId: X } }`.
|
||||
* Atomically transitions "X is the chat's active run" -> "X is stopped, settled,
|
||||
* slot free" so the caller can start a replacement run. See {@link
|
||||
* SupersedeResult} for the branch semantics.
|
||||
*
|
||||
* On a `ready` result the caller MUST still go through the normal beginRun gate
|
||||
* (the partial unique index) — between the slot freeing here and beginRun a
|
||||
* neighbouring tab's ordinary POST can win the slot (documented SLOT-THEFT: the
|
||||
* loser then gets a MISMATCH carrying the NEW runId). There is also NO side-
|
||||
* effect quiescence: an in-flight write of the stopped run may still land AFTER
|
||||
* the new run starts (commit 1 stops the NEXT call, not one already committing),
|
||||
* so the caller adds a prompt note to the new run.
|
||||
*/
|
||||
async supersede(
|
||||
chatId: string,
|
||||
targetRunId: string,
|
||||
workspaceId: string,
|
||||
timeoutMs: number = SUPERSEDE_SETTLE_TIMEOUT_MS,
|
||||
): Promise<SupersedeResult> {
|
||||
// Validate the target belongs to THIS chat (a CAS targeting another chat's run
|
||||
// is malformed -> 400). A missing row is NOT invalid: the run may have ended
|
||||
// and been pruned; the active-run check below decides degrade vs mismatch.
|
||||
const target = await this.getRun(targetRunId, workspaceId);
|
||||
if (target && target.chatId !== chatId) return { kind: 'invalid' };
|
||||
|
||||
const active = await this.getActiveForChat(chatId, workspaceId);
|
||||
// No active run: it ended between the client's click and this POST — this is a
|
||||
// DEGRADE to a normal send, NOT a mismatch (the user's intent still holds).
|
||||
if (!active) return { kind: 'degrade' };
|
||||
// A DIFFERENT run is active than the one the client saw -> mismatch. The
|
||||
// client does not auto-retry; it surfaces the new runId.
|
||||
if (active.id !== targetRunId) {
|
||||
return { kind: 'mismatch', activeRunId: active.id };
|
||||
}
|
||||
|
||||
// The target IS active: stop it, then await its settle within W.
|
||||
await this.requestStop(targetRunId, workspaceId);
|
||||
const outcome = await this.awaitSettled(targetRunId, workspaceId, timeoutMs);
|
||||
if (!outcome) return { kind: 'timeout' };
|
||||
// Gave up (terminal write failed): apply the intended status via the
|
||||
// conditional UPDATE so the slot actually frees. If that ALSO fails, the row
|
||||
// is still stranded -> treat as a timeout (nothing persisted for the new run).
|
||||
if (outcome.terminalWriteFailed) {
|
||||
const settled = await this.settleZombie(targetRunId);
|
||||
if (!settled) return { kind: 'timeout' };
|
||||
}
|
||||
return { kind: 'ready' };
|
||||
}
|
||||
|
||||
/** #487 test/diagnostic seam: whether a give-up zombie is held for this run. */
|
||||
hasZombie(runId: string): boolean {
|
||||
return this.zombies.has(runId);
|
||||
}
|
||||
|
||||
/** #487: every zombie runId held on this replica (reconcile clause a, commit 4). */
|
||||
zombieRunIds(): string[] {
|
||||
return [...this.zombies.keys()];
|
||||
}
|
||||
|
||||
/** #487: create a one-shot deferred (resolve captured for a later single call). */
|
||||
private makeDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** #487: resolve a run's settle notifier EXACTLY ONCE, then drop it (bounded).
|
||||
* A subscriber that already grabbed the promise still resolves; a later one
|
||||
* falls back to the zombie map / the row (see peekSettled). */
|
||||
private resolveSettled(runId: string, outcome: RunSettleOutcome): void {
|
||||
const d = this.settledPromises.get(runId);
|
||||
if (!d) return;
|
||||
this.settledPromises.delete(runId);
|
||||
d.resolve(outcome);
|
||||
}
|
||||
|
||||
/** #487: read the persisted terminal outcome when the conditional finalize was a
|
||||
* no-op (the row was already terminal). Falls back to the intended status when
|
||||
* the read fails or the row is unexpectedly missing/non-terminal. */
|
||||
private async readTerminalOutcome(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
fallbackStatus: RunTerminalStatus,
|
||||
fallbackError: string | null,
|
||||
): Promise<RunSettleOutcome> {
|
||||
try {
|
||||
const row = await this.runRepo.findById(runId, workspaceId);
|
||||
if (row && isRunTerminal(row.status)) {
|
||||
return {
|
||||
status: row.status as RunTerminalStatus,
|
||||
error: row.error ?? null,
|
||||
terminalWriteFailed: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the intended status — best-effort only.
|
||||
}
|
||||
return {
|
||||
status: fallbackStatus,
|
||||
error: fallbackError,
|
||||
terminalWriteFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||
|
||||
/**
|
||||
* Fail-fast guard for beginRun failures (#486, commit 4).
|
||||
*
|
||||
* When runHooks.begin() rejects for a reason OTHER than RunAlreadyActiveError
|
||||
* (e.g. a DB-pool blip), the turn must NOT continue untracked. The old code
|
||||
* logged and streamed anyway, leaving a run with NO run-row: in autonomous mode
|
||||
* nobody could abort it (/stop can't see it, disconnect doesn't abort it, and the
|
||||
* one-run gate would admit a SECOND run) — an unstoppable invisible run until
|
||||
* restart. The fix throws A_RUN_BEGIN_FAILED (503) BEFORE the first byte and
|
||||
* before the user row is persisted.
|
||||
*
|
||||
* We drive `stream()` directly on a prototype instance wired with only the
|
||||
* collaborators it touches before the throw, so the assertion is on the REAL
|
||||
* control flow, not a mock of it.
|
||||
*/
|
||||
describe('AiChatService beginRun failure (#486)', () => {
|
||||
function makeService(insertSpy: jest.Mock): AiChatService {
|
||||
// Bypass the (heavy) DI constructor: exercise the real stream() method on a
|
||||
// bare prototype instance with just the fields reached before the throw.
|
||||
// `any` because the private `logger` field makes a typed intersection collapse.
|
||||
const svc = Object.create(AiChatService.prototype);
|
||||
svc.aiChatRepo = {
|
||||
// Existing chat -> no insert path; chatId is kept as-is.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'chat1' }),
|
||||
};
|
||||
svc.aiChatMessageRepo = { insert: insertSpy };
|
||||
svc.logger = new Logger('test');
|
||||
return svc as AiChatService;
|
||||
}
|
||||
|
||||
const baseArgs = () => {
|
||||
const write = jest.fn();
|
||||
const res = {
|
||||
raw: { write, writableEnded: false, headersSent: false },
|
||||
};
|
||||
return {
|
||||
user: { id: 'u1' } as never,
|
||||
workspace: { id: 'w1' } as never,
|
||||
sessionId: 's1',
|
||||
// openPage undefined -> resolveOpenPageContext returns null without any DB
|
||||
// call; chatId present -> the existing-chat path.
|
||||
body: { chatId: 'chat1', messages: [] } as never,
|
||||
res: res as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
write,
|
||||
};
|
||||
};
|
||||
|
||||
it('throws A_RUN_BEGIN_FAILED (503) before the first byte and before persisting the user turn', async () => {
|
||||
const insertSpy = jest.fn();
|
||||
const svc = makeService(insertSpy);
|
||||
const { write, ...args } = baseArgs();
|
||||
|
||||
const runHooks = {
|
||||
begin: jest.fn().mockRejectedValue(new Error('DB pool exhausted')),
|
||||
} as never;
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.stream({ ...args, runHooks });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ServiceUnavailableException);
|
||||
const http = caught as ServiceUnavailableException;
|
||||
expect(http.getStatus()).toBe(503);
|
||||
expect(http.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
|
||||
|
||||
// Fail-fast: nothing was written to the socket and NO user message row was
|
||||
// persisted, so the turn left no orphan state to clean up.
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still maps a lost-the-race RunAlreadyActiveError to a 409, not A_RUN_BEGIN_FAILED', async () => {
|
||||
const insertSpy = jest.fn();
|
||||
const svc = makeService(insertSpy);
|
||||
const { write, ...args } = baseArgs();
|
||||
|
||||
const runHooks = {
|
||||
begin: jest.fn().mockRejectedValue(new RunAlreadyActiveError('chat1')),
|
||||
} as never;
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.stream({ ...args, runHooks });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ConflictException);
|
||||
expect((caught as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'A_RUN_ALREADY_ACTIVE',
|
||||
});
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
||||
|
||||
// Drive the SAME applyFinalize the service calls (no duplicated logic).
|
||||
async function dispatchFinalize(
|
||||
repo: { insert: jest.Mock; update: jest.Mock },
|
||||
repo: { insert: jest.Mock; finalizeOwner: jest.Mock },
|
||||
assistantId: string | undefined,
|
||||
flushed: AssistantFlush,
|
||||
): Promise<void> {
|
||||
@@ -135,21 +135,22 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
||||
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
|
||||
});
|
||||
|
||||
it('(a) upfront insert succeeded -> finalize UPDATEs the row by id', async () => {
|
||||
const repo = { insert: jest.fn(), update: jest.fn() };
|
||||
it('(a) upfront insert succeeded -> finalize CONDITIONALLY updates the row by id (#487 owner-write)', async () => {
|
||||
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
||||
const flushed = flushAssistant([], 'final answer', 'completed', {
|
||||
finishReason: 'stop',
|
||||
});
|
||||
await dispatchFinalize(repo, 'a1', flushed);
|
||||
expect(repo.update).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
||||
// #487: the owner write is the CONDITIONAL finalizeOwner, not a raw update.
|
||||
expect(repo.finalizeOwner).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
|
||||
const repo = { insert: jest.fn(), update: jest.fn() };
|
||||
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
||||
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
|
||||
await dispatchFinalize(repo, undefined, flushed);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
expect(repo.finalizeOwner).not.toHaveBeenCalled();
|
||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||
const arg = repo.insert.mock.calls[0][0];
|
||||
// The fallback insert carries the terminal content/status/metadata.
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* #487 commit 3 — the single concurrency GATE (both modes) + the server supersede
|
||||
* CAS, at the controller boundary. The gate + CAS run BEFORE res.hijack(), so a
|
||||
* rejected concurrent start / a CAS branch returns clean JSON (an HttpException
|
||||
* the controller's post-hijack catch re-serializes). These assert the OBSERVABLE
|
||||
* HTTP contract against the real controller + a stubbed run service.
|
||||
*/
|
||||
describe('#487 AiChatController.stream — gate + supersede', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
|
||||
function wsWith(autonomousRuns: boolean): Workspace {
|
||||
return {
|
||||
id: 'ws1',
|
||||
settings: { ai: { chat: true, autonomousRuns } },
|
||||
} as unknown as Workspace;
|
||||
}
|
||||
|
||||
function makeReqRes(body: Record<string, unknown>) {
|
||||
const req = {
|
||||
raw: { sessionId: 'sess', once: jest.fn(), destroyed: false },
|
||||
body,
|
||||
};
|
||||
const res = {
|
||||
raw: {
|
||||
writableEnded: false,
|
||||
headersSent: false,
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
end: jest.fn(),
|
||||
statusCode: 200,
|
||||
flushHeaders: jest.fn(),
|
||||
},
|
||||
hijack: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
function makeController(runServiceOverrides: Record<string, jest.Mock>) {
|
||||
const aiChatService = {
|
||||
resolveRoleForRequest: jest.fn().mockResolvedValue(null),
|
||||
getChatModel: jest.fn().mockResolvedValue({}),
|
||||
stream: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const aiChatRunService = {
|
||||
getActiveForChat: jest.fn().mockResolvedValue(undefined),
|
||||
supersede: jest.fn(),
|
||||
beginRun: jest.fn().mockResolvedValue({
|
||||
runId: 'run-new',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
linkAssistantMessage: jest.fn(),
|
||||
recordStep: jest.fn(),
|
||||
finalizeRun: jest.fn(),
|
||||
requestStop: jest.fn(),
|
||||
...runServiceOverrides,
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
aiChatRunService as never,
|
||||
{} as never, // aiChatRepo
|
||||
{} as never, // aiChatMessageRepo
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
);
|
||||
return { controller, aiChatService, aiChatRunService };
|
||||
}
|
||||
|
||||
const codeOf = (err: unknown) =>
|
||||
(((err as HttpException).getResponse() as Record<string, unknown>) ?? {})
|
||||
.code;
|
||||
|
||||
describe('single concurrency gate — BOTH modes reject the second tab with 409', () => {
|
||||
for (const autonomousRuns of [true, false]) {
|
||||
it(`rejects a concurrent start with 409 A_RUN_ALREADY_ACTIVE (autonomousRuns=${autonomousRuns})`, async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
getActiveForChat: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'run-live', chatId: 'c1' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({ chatId: 'c1' });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(
|
||||
req as never,
|
||||
res as never,
|
||||
user,
|
||||
wsWith(autonomousRuns),
|
||||
);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ConflictException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(409);
|
||||
expect(codeOf(thrown)).toBe('A_RUN_ALREADY_ACTIVE');
|
||||
// Rejected BEFORE committing to the stream (no hijack, no service.stream).
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('supersede MISMATCH -> 409 SUPERSEDE_TARGET_MISMATCH carrying the current runId', async () => {
|
||||
const { controller } = makeController({
|
||||
supersede: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ kind: 'mismatch', activeRunId: 'run-other' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ConflictException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_TARGET_MISMATCH');
|
||||
expect(
|
||||
((thrown as HttpException).getResponse() as Record<string, unknown>)
|
||||
.activeRunId,
|
||||
).toBe('run-other');
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersede TIMEOUT -> 409 SUPERSEDE_TIMEOUT, nothing streamed', async () => {
|
||||
const { controller } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'timeout' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(false));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ConflictException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_TIMEOUT');
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersede INVALID (target on another chat) -> 400 SUPERSEDE_INVALID', async () => {
|
||||
const { controller } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'invalid' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
|
||||
});
|
||||
|
||||
it('supersede without chatId -> 400 SUPERSEDE_INVALID', async () => {
|
||||
const { controller, aiChatRunService } = makeController({});
|
||||
const { req, res } = makeReqRes({ supersede: { runId: 'run-x' } });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
|
||||
expect(aiChatRunService.supersede).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersede READY -> proceeds to stream with superseded=true', async () => {
|
||||
const { controller, aiChatService } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'ready' }),
|
||||
getActiveForChat: jest.fn().mockResolvedValue(undefined), // slot free after CAS
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||
expect(res.hijack).toHaveBeenCalled();
|
||||
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
|
||||
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(true);
|
||||
// The run hooks are always present now (both modes).
|
||||
expect(aiChatService.stream.mock.calls[0][0].runHooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('supersede DEGRADE -> proceeds to a normal send (superseded=false)', async () => {
|
||||
const { controller, aiChatService } = makeController({
|
||||
supersede: jest.fn().mockResolvedValue({ kind: 'degrade' }),
|
||||
});
|
||||
const { req, res } = makeReqRes({
|
||||
chatId: 'c1',
|
||||
supersede: { runId: 'run-x' },
|
||||
});
|
||||
await controller.stream(req as never, res as never, user, wsWith(false));
|
||||
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
|
||||
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -432,12 +432,66 @@ export class AiChatController {
|
||||
// HttpException) instead of breaking mid-stream.
|
||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
||||
|
||||
// #184: one active run per chat. For an EXISTING chat reject a concurrent
|
||||
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
|
||||
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
|
||||
// (no chatId) cannot have a prior run, and the DB partial unique index is the
|
||||
// backstop against any race that slips past this check.
|
||||
if (autonomousRuns && body.chatId) {
|
||||
// #487: server-side supersede CAS ("interrupt and send now"). When the client
|
||||
// asks to replace a live run, atomically STOP it and wait for it to settle
|
||||
// before this turn claims the slot. Runs BEFORE hijack so every branch returns
|
||||
// clean JSON (the client keeps the composer text on a 409). See
|
||||
// AiChatRunService.supersede for the branch semantics.
|
||||
let superseded = false;
|
||||
const supersedeRunId = body.supersede?.runId;
|
||||
if (supersedeRunId) {
|
||||
if (!body.chatId) {
|
||||
throw new BadRequestException({
|
||||
message: 'supersede requires chatId',
|
||||
code: 'SUPERSEDE_INVALID',
|
||||
});
|
||||
}
|
||||
const result = await this.aiChatRunService.supersede(
|
||||
body.chatId,
|
||||
supersedeRunId,
|
||||
workspace.id,
|
||||
);
|
||||
switch (result.kind) {
|
||||
case 'invalid':
|
||||
throw new BadRequestException({
|
||||
message: 'The run to supersede does not belong to this chat',
|
||||
code: 'SUPERSEDE_INVALID',
|
||||
});
|
||||
case 'mismatch':
|
||||
// A DIFFERENT run is active than the one the client targeted. Surface
|
||||
// the CURRENT runId; the client does NOT auto-retry (a stale CAS).
|
||||
throw new ConflictException({
|
||||
message: 'A different agent run is now active on this chat',
|
||||
code: 'SUPERSEDE_TARGET_MISMATCH',
|
||||
activeRunId: result.activeRunId,
|
||||
});
|
||||
case 'timeout':
|
||||
// The target did not settle within W — nothing was persisted, the
|
||||
// composer keeps the text. NOT a rollback: the stop is already issued.
|
||||
throw new ConflictException({
|
||||
message:
|
||||
'The previous run did not stop in time; nothing was sent — please try again',
|
||||
code: 'SUPERSEDE_TIMEOUT',
|
||||
});
|
||||
case 'ready':
|
||||
// The target stopped and settled: the slot is free. Prompt the new run
|
||||
// that the old run's last operations may still be applying.
|
||||
superseded = true;
|
||||
break;
|
||||
case 'degrade':
|
||||
// The run already ended between click and POST — send normally.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// #487: one active run per chat — ENFORCED IN BOTH MODES now (legacy mode used
|
||||
// to have NO gate, so two tabs streamed two parallel turns on one chat, which
|
||||
// interleaved history and crashed convertToModelMessages). Reject a concurrent
|
||||
// start with a clean pre-hijack 409 (double-submit / second-tab). A brand-new
|
||||
// chat (no chatId) cannot have a prior run, and the DB partial unique index in
|
||||
// beginRun is the authoritative backstop for any race that slips past here
|
||||
// (including a slot stolen between a supersede release and beginRun).
|
||||
if (body.chatId) {
|
||||
const active = await this.aiChatRunService.getActiveForChat(
|
||||
body.chatId,
|
||||
workspace.id,
|
||||
@@ -446,107 +500,94 @@ export class AiChatController {
|
||||
throw new ConflictException({
|
||||
message: 'An agent run is already in progress for this chat',
|
||||
code: 'A_RUN_ALREADY_ACTIVE',
|
||||
activeRunId: active.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
|
||||
// a durable run whose abort is governed by the run (explicit stop), persist
|
||||
// its progress, and settle its terminal status — see AiChatRunService.
|
||||
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
||||
? {
|
||||
begin: async (chatId) => {
|
||||
const handle = await this.aiChatRunService.beginRun({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
trigger: 'user',
|
||||
});
|
||||
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
|
||||
// frame) so a tab that attaches in the begin->seed window finds an
|
||||
// entry to wait on. Gated on AI_CHAT_RESUMABLE_STREAM: with the flag
|
||||
// off nothing is registered and attach always 204s.
|
||||
if (
|
||||
handle?.runId &&
|
||||
this.environment?.isAiChatResumableStreamEnabled?.()
|
||||
) {
|
||||
this.streamRegistry?.open(chatId, handle.runId);
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
onAssistantSeeded: (runId, messageId) =>
|
||||
this.aiChatRunService.linkAssistantMessage(
|
||||
runId,
|
||||
workspace.id,
|
||||
messageId,
|
||||
),
|
||||
onStep: (runId, stepCount) =>
|
||||
void this.aiChatRunService.recordStep(
|
||||
runId,
|
||||
workspace.id,
|
||||
stepCount,
|
||||
),
|
||||
onSettled: (runId, status, error) =>
|
||||
this.aiChatRunService.finalizeRun(
|
||||
runId,
|
||||
workspace.id,
|
||||
status,
|
||||
error,
|
||||
),
|
||||
// #487: the turn is ALWAYS a first-class RUN now (both modes). The mode
|
||||
// difference is only the abort semantics on a browser disconnect (onClose
|
||||
// below). currentRunId is captured at begin so a legacy disconnect can stop
|
||||
// the run through its stop lever.
|
||||
let currentRunId: string | undefined;
|
||||
const runHooks: AiChatRunHooks = {
|
||||
begin: async (chatId) => {
|
||||
const handle = await this.aiChatRunService.beginRun({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
trigger: 'user',
|
||||
});
|
||||
currentRunId = handle?.runId;
|
||||
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
|
||||
// frame) so a tab that attaches in the begin->seed window finds an entry
|
||||
// to wait on. Gated on AI_CHAT_RESUMABLE_STREAM.
|
||||
if (
|
||||
handle?.runId &&
|
||||
this.environment?.isAiChatResumableStreamEnabled?.()
|
||||
) {
|
||||
this.streamRegistry?.open(chatId, handle.runId);
|
||||
}
|
||||
: undefined;
|
||||
return handle;
|
||||
},
|
||||
onAssistantSeeded: (runId, messageId) =>
|
||||
this.aiChatRunService.linkAssistantMessage(
|
||||
runId,
|
||||
workspace.id,
|
||||
messageId,
|
||||
),
|
||||
onStep: (runId, stepCount) =>
|
||||
void this.aiChatRunService.recordStep(runId, workspace.id, stepCount),
|
||||
onSettled: (runId, status, error) =>
|
||||
this.aiChatRunService.finalizeRun(runId, workspace.id, status, error),
|
||||
};
|
||||
|
||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||
// normal completion, so only abort when the response has not finished
|
||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
||||
// we also drop it on response `finish` so it never lingers after the stream
|
||||
// completes normally (the AI SDK pipes the response fire-and-forget, so we
|
||||
// cannot simply remove it once `stream()` returns).
|
||||
// Handle a client disconnect. `close` also fires on normal completion, so only
|
||||
// act when the response has not finished writing (a genuine disconnect). `once`
|
||||
// fires at most once and self-removes; we also drop it on response `finish`.
|
||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: wall-clock at
|
||||
// which a Safari disconnect is observed, measured from request receipt.
|
||||
const reqStartedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const onClose = (): void => {
|
||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||
// so log it here.
|
||||
if (!res.raw.writableEnded) {
|
||||
if (autonomousRuns) {
|
||||
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
||||
// the run keeps executing and persisting server-side; the client
|
||||
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||
// #184: a DETACHED run — a disconnect must NOT stop it. The run keeps
|
||||
// executing and persisting server-side; the client reconnects via
|
||||
// /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||
this.logger.log(
|
||||
`AI chat stream: client disconnected; run continues server-side ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
} else {
|
||||
// #487: legacy — a disconnect ENDS the turn, but the turn is now a RUN,
|
||||
// so stop it through the run's stop lever (requestStop). streamText no
|
||||
// longer consumes the socket signal (effectiveSignal is the run signal),
|
||||
// so aborting `controller` would do nothing; requestStop aborts the run.
|
||||
this.logger.warn(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
`AI chat stream: client disconnected before completion; stopping the ` +
|
||||
`run (elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
if (currentRunId) {
|
||||
void this.aiChatRunService.requestStop(currentRunId, workspace.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
req.raw.once('close', onClose);
|
||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||
|
||||
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
|
||||
// pipe keeps writing to a socket the client may have dropped — for the rest of
|
||||
// the (continuing) run. A write to the dead socket can emit an 'error' on the
|
||||
// raw response; without a listener that surfaces as an unhandled error event.
|
||||
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
|
||||
// disconnect, so it does not need this and keeps its exact prior behavior.
|
||||
if (autonomousRuns) {
|
||||
res.raw.on('error', (err) => {
|
||||
this.logger.debug(
|
||||
`AI chat detached stream: post-disconnect socket error swallowed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
// #184/#487: the run/pipe can outlive the socket in BOTH modes now (autonomous
|
||||
// keeps going; legacy keeps going until requestStop's abort unwinds the turn).
|
||||
// The SDK's pipe may then write to a dropped socket and emit an 'error' on the
|
||||
// raw response — swallow it so it never surfaces as an unhandled error event.
|
||||
res.raw.on('error', (err) => {
|
||||
this.logger.debug(
|
||||
`AI chat stream: post-disconnect socket error swallowed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Commit to streaming: hijack so Fastify stops managing the response and
|
||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||
@@ -562,8 +603,10 @@ export class AiChatController {
|
||||
signal: controller.signal,
|
||||
model,
|
||||
role,
|
||||
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||
// #487: the turn is always run-wrapped now (both modes).
|
||||
runHooks,
|
||||
// #487: warn the new run that a superseded run's last ops may still apply.
|
||||
superseded,
|
||||
});
|
||||
} catch (err) {
|
||||
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||
|
||||
@@ -101,6 +101,22 @@ const INTERRUPT_NOTE =
|
||||
'assume your previous response was complete, and do not silently restart the ' +
|
||||
'partial work — build on it or follow the new instruction.';
|
||||
|
||||
/**
|
||||
* #487: injected on a turn started by SUPERSEDING a previous run (the user hit
|
||||
* "interrupt and send now" while a run was live). The previous run was Stopped,
|
||||
* but there is NO side-effect quiescence — a write it had already committed, or
|
||||
* one committing at the moment of Stop, may land with a small delay AFTER this new
|
||||
* run starts. So the model is told its picture of the page/state may be a beat
|
||||
* stale and to re-read before assuming an edit did or did not apply.
|
||||
*/
|
||||
const SUPERSEDE_NOTE =
|
||||
'NOTE: A previous agent run in this conversation was just interrupted so this ' +
|
||||
'new turn could start. That run was stopped, but any operation it had already ' +
|
||||
'begun (e.g. a page edit) may still be applied with a short delay. Do not ' +
|
||||
'assume the document/state is exactly as the interrupted run left it — if you ' +
|
||||
'need to rely on the current content, RE-READ it with the page tools before ' +
|
||||
'acting rather than trusting a cached view.';
|
||||
|
||||
/**
|
||||
* Injected on a turn where the open page was hand-edited by the user (or anyone
|
||||
* else) AFTER the agent's previous response ended (#274). The server takes a
|
||||
@@ -203,6 +219,14 @@ export interface BuildSystemPromptInput {
|
||||
* (partial) answer was cut off by the user's new message.
|
||||
*/
|
||||
interrupted?: boolean;
|
||||
/**
|
||||
* #487: true when THIS turn was started by superseding a still-live previous run
|
||||
* ("interrupt and send now"). Adds SUPERSEDE_NOTE so the model knows the previous
|
||||
* run's last operations may still be applying and to re-read state it depends on.
|
||||
* Distinct from `interrupted` (which is about a PARTIAL prior answer in history);
|
||||
* both can be set together. Self-clears — set only for the superseding turn.
|
||||
*/
|
||||
superseded?: boolean;
|
||||
/**
|
||||
* Set only when the open page was edited by the user AFTER the agent's previous
|
||||
* turn ended (#274), confirmed server-side by diffing the current page against
|
||||
@@ -311,6 +335,7 @@ export function buildSystemPrompt({
|
||||
openedPage,
|
||||
mcpInstructions,
|
||||
interrupted,
|
||||
superseded,
|
||||
pageChanged,
|
||||
deferredToolsEnabled,
|
||||
toolCatalog,
|
||||
@@ -360,6 +385,13 @@ export function buildSystemPrompt({
|
||||
context += `\n${INTERRUPT_NOTE}`;
|
||||
}
|
||||
|
||||
// Supersede note (#487): present only for a turn that stopped and replaced a
|
||||
// still-live previous run — warns the model the previous run's last operations
|
||||
// may still be applying (no side-effect quiescence).
|
||||
if (superseded) {
|
||||
context += `\n${SUPERSEDE_NOTE}`;
|
||||
}
|
||||
|
||||
// Per-turn page-change note (#274). Added to the context section (inside the
|
||||
// safety sandwich), present only when the server detected that the open page
|
||||
// was edited by the user since the agent's last turn ended. The diff content is
|
||||
|
||||
@@ -89,6 +89,11 @@ describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
||||
const runRepo = {
|
||||
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||
// #487: the terminal settle now goes through the CONDITIONAL write.
|
||||
finalizeIfActive: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'run-1', status: 'failed' }),
|
||||
findById: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
||||
|
||||
@@ -148,9 +153,10 @@ describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
||||
|
||||
// The run was begun...
|
||||
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
||||
// ...then settled to a terminal FAILED status by the safety net...
|
||||
expect(runRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(runRepo.update).toHaveBeenCalledWith(
|
||||
// ...then settled to a terminal FAILED status by the safety net (via the
|
||||
// #487 conditional write)...
|
||||
expect(runRepo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||
expect(runRepo.finalizeIfActive).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws1',
|
||||
expect.objectContaining({ status: 'failed' }),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
import {
|
||||
ConflictException,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
||||
// about to reject. The race rejection happens at runHooks.begin(), long before
|
||||
@@ -151,6 +155,8 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
@@ -328,7 +334,13 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
usage: {},
|
||||
steps: [],
|
||||
});
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
|
||||
// #487: onFinish passes the (undefined) error slot so a message-finalize
|
||||
// failure could error-mark the run; on the success path it is undefined.
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'completed',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('F9: onAbort settles the run "aborted"', async () => {
|
||||
@@ -360,22 +372,22 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* F14 — the begin-failure RESILIENCE branch (the `else` of the run-race guard).
|
||||
* F14 — the begin-failure branch (the `else` of the run-race guard).
|
||||
*
|
||||
* stream() wraps runHooks.begin in try/catch with TWO branches:
|
||||
* - RunAlreadyActiveError -> 409 ConflictException (pinned above).
|
||||
* - ANY OTHER begin failure -> SWALLOW + continue UNTRACKED on the socket signal
|
||||
* (legacy fallback): it logs "...streaming without run tracking", leaves
|
||||
* `effectiveSignal = signal` (runId undefined) and serves the turn anyway.
|
||||
* - ANY OTHER begin failure -> throw ServiceUnavailableException(A_RUN_BEGIN_FAILED)
|
||||
* BEFORE the first byte (#486, commit 4).
|
||||
*
|
||||
* The contract: a transient beginRun failure (e.g. a non-unique DB error inserting
|
||||
* the run row) must STILL serve the user's turn — it must NOT re-throw and must NOT
|
||||
* be misclassified as a 409. A regression that re-threw here would break EVERY turn
|
||||
* on a begin failure with nothing to catch it. This branch is otherwise undriven by
|
||||
* any spec, so it is pinned here SEPARATELY from the 409 path: a plain begin error
|
||||
* proceeds to streamText with the SOCKET signal and still persists the user turn.
|
||||
* POLICY CHANGE (#486): the OLD contract here was "SWALLOW + stream the turn
|
||||
* UNTRACKED on the socket signal". That was reversed: an untracked run is
|
||||
* invisible to /stop, is not aborted on disconnect, and slips past the one-run
|
||||
* gate — an unstoppable ghost run in autonomous mode. Now a plain begin failure
|
||||
* FAILS the turn fast with a 503 A_RUN_BEGIN_FAILED, before any user row is
|
||||
* persisted and before streamText runs. This case is INVERTED (not deleted) so
|
||||
* the "plain begin failure" path stays explicitly pinned under the new policy.
|
||||
*/
|
||||
describe('AiChatService.stream — begin-failure resilience / legacy fallback (#184 F14)', () => {
|
||||
describe('AiChatService.stream — begin-failure fails the turn (#184 F14 / #486)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
|
||||
function makeStreamResult() {
|
||||
@@ -411,6 +423,8 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
@@ -455,7 +469,7 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) does NOT 409 — it swallows, logs, and streams the turn UNTRACKED on the socket signal', async () => {
|
||||
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) FAILS the turn with a 503 A_RUN_BEGIN_FAILED before the first byte — NO untracked stream (#486)', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
@@ -487,28 +501,26 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
|
||||
} as never,
|
||||
});
|
||||
|
||||
// The turn proceeds: NO throw at all (in particular NOT a 409).
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
// NEW POLICY: the turn is REJECTED with a 503 A_RUN_BEGIN_FAILED (not a 409,
|
||||
// and NOT swallowed into an untracked stream).
|
||||
await expect(promise).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
const err = (await promise.catch(
|
||||
(e) => e,
|
||||
)) as ServiceUnavailableException;
|
||||
expect(err.getStatus()).toBe(503);
|
||||
expect(err.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
|
||||
|
||||
expect(begin).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The resilience branch logged the legacy-fallback warning.
|
||||
// It logged the fail-the-turn line.
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('streaming without run tracking'),
|
||||
expect.stringContaining('failing the turn'),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// The turn really streamed: the user message was persisted and streamText ran.
|
||||
expect(aiChatMessageRepo.insert).toHaveBeenCalled();
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The decisive wiring: with no run handle, the fallback uses the SOCKET signal
|
||||
// (effectiveSignal = signal, runId undefined) — not a run-bound signal. #444:
|
||||
// the signal is unioned with the degeneration controller via AbortSignal.any,
|
||||
// so assert the socket abort still reaches the turn rather than identity.
|
||||
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
|
||||
expect(passed.aborted).toBe(false);
|
||||
socketController.abort();
|
||||
expect(passed.aborted).toBe(true);
|
||||
// Fail-fast: the turn NEVER streamed — no user row persisted, no streamText
|
||||
// call, so no orphan/untracked run was left behind.
|
||||
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
|
||||
expect(streamTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1315,8 +1315,12 @@ describe('AiChatService page-change lifecycle (#274)', () => {
|
||||
describe('isInterruptResume', () => {
|
||||
// history tail is the just-inserted user row; [len-2] is the previous turn.
|
||||
const withPrev = (
|
||||
prev: { role: string; status?: string | null } | null,
|
||||
): Array<{ role: string; status?: string | null }> =>
|
||||
prev: {
|
||||
role: string;
|
||||
status?: string | null;
|
||||
metadata?: unknown;
|
||||
} | null,
|
||||
): Array<{ role: string; status?: string | null; metadata?: unknown }> =>
|
||||
prev
|
||||
? [prev, { role: 'user', status: null }]
|
||||
: [{ role: 'user', status: null }];
|
||||
@@ -1357,6 +1361,33 @@ describe('isInterruptResume', () => {
|
||||
it('false when there is no preceding turn (only the new user row)', () => {
|
||||
expect(isInterruptResume(withPrev(null), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('#487 EXCLUDES a reconcile stamp (finalizeFailed) — not a genuine interruption', () => {
|
||||
// A row a reconcile settled to 'aborted' carries metadata.finalizeFailed. It
|
||||
// must NOT be treated as an interrupt-resume (that would inject a false
|
||||
// "you were interrupted" note), even though its status is 'aborted'.
|
||||
expect(
|
||||
isInterruptResume(
|
||||
withPrev({
|
||||
role: 'assistant',
|
||||
status: 'aborted',
|
||||
metadata: { finalizeFailed: true },
|
||||
}),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
// A genuine abort (no finalizeFailed) still counts.
|
||||
expect(
|
||||
isInterruptResume(
|
||||
withPrev({
|
||||
role: 'assistant',
|
||||
status: 'aborted',
|
||||
metadata: { parts: [] },
|
||||
}),
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1419,6 +1450,9 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
// #487: the terminal owner-write + the opportunistic reconcile query.
|
||||
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
@@ -1623,6 +1657,19 @@ describe('AiChatService.stream — token-degeneration reaction (#444)', () => {
|
||||
return { id };
|
||||
},
|
||||
),
|
||||
// #487: the terminal owner-write records into the SAME `updated` recorder so
|
||||
// assertions on the terminal 'completed'/'error'/'aborted' write still hold.
|
||||
finalizeOwner: jest.fn(
|
||||
async (
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
patch: Record<string, unknown>,
|
||||
) => {
|
||||
updated.push({ id, workspaceId, patch });
|
||||
return { id };
|
||||
},
|
||||
),
|
||||
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import {
|
||||
@@ -41,7 +43,11 @@ import {
|
||||
makeLoadToolsTool,
|
||||
buildExternalToolCatalog,
|
||||
} from './tools/tool-tiers';
|
||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||
import {
|
||||
RunAlreadyActiveError,
|
||||
AiChatRunService,
|
||||
} from './ai-chat-run.service';
|
||||
import { inAppToolCallCapMs } from './tools/ai-chat-tools.service';
|
||||
import { computePageChange } from './page-change/page-change.util';
|
||||
import {
|
||||
sanitizeSelection,
|
||||
@@ -55,6 +61,7 @@ import {
|
||||
import {
|
||||
isDegenerateOutput,
|
||||
truncateDegeneratedTail,
|
||||
shouldCheckDegeneration,
|
||||
} from './output-degeneration';
|
||||
|
||||
// Max agent steps per turn. One step = one model generation; a step that calls
|
||||
@@ -248,15 +255,23 @@ export function cleanGeneratedTitle(text: string): string {
|
||||
* partial output is already in history thanks to the step-granular write path).
|
||||
*/
|
||||
export function isInterruptResume(
|
||||
history: Array<{ role: string; status?: string | null }>,
|
||||
history: Array<{
|
||||
role: string;
|
||||
status?: string | null;
|
||||
metadata?: unknown;
|
||||
}>,
|
||||
clientInterrupted: boolean | undefined,
|
||||
): boolean {
|
||||
if (clientInterrupted !== true) return false;
|
||||
const prev = history[history.length - 2];
|
||||
return (
|
||||
prev?.role === 'assistant' &&
|
||||
(prev.status === 'aborted' || prev.status === 'streaming')
|
||||
);
|
||||
if (prev?.role !== 'assistant') return false;
|
||||
// #487: a reconcile STAMP (metadata.finalizeFailed) is NOT a genuine user
|
||||
// interruption — the previous turn's process died and a reconcile settled the
|
||||
// row as 'aborted'. Treating it as an interrupt-resume would inject a false
|
||||
// "you were interrupted" note. Exclude any finalizeFailed row.
|
||||
const meta = prev.metadata as { finalizeFailed?: unknown } | null | undefined;
|
||||
if (meta && meta.finalizeFailed === true) return false;
|
||||
return prev.status === 'aborted' || prev.status === 'streaming';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,6 +392,14 @@ export interface AiChatStreamBody {
|
||||
// it against persisted history (`isInterruptResume`) before injecting the
|
||||
// interrupt note, so a spoofed/stale flag on an ordinary turn is ignored.
|
||||
interrupted?: boolean;
|
||||
// #487: server-side supersede CAS. When present, this POST asks the server to
|
||||
// STOP the run `supersede.runId` (which the client saw as the chat's active run)
|
||||
// and, once it has settled, start THIS turn in its place. The server validates
|
||||
// the target against the chat and answers 400 (wrong chat) / 409
|
||||
// SUPERSEDE_TARGET_MISMATCH / 409 SUPERSEDE_TIMEOUT, or proceeds normally
|
||||
// (degrade / ready). Absent => an ordinary send (rejected with 409
|
||||
// A_RUN_ALREADY_ACTIVE if a run is already active on the chat).
|
||||
supersede?: { runId?: string } | null;
|
||||
// useChat sends the full UIMessage list; the last one is the new user turn.
|
||||
messages?: UIMessage[];
|
||||
}
|
||||
@@ -426,6 +449,11 @@ export interface AiChatStreamArgs {
|
||||
// chat row (existing chat) or the request body (new chat). null => universal
|
||||
// assistant. Carried here so the turn never re-loads it.
|
||||
role: AiAgentRole | null;
|
||||
// #487: true when this turn was started by SUPERSEDING a still-live previous run
|
||||
// (the controller ran the supersede CAS to a `ready` result). Adds the
|
||||
// SUPERSEDE_NOTE to the system prompt (the previous run's last ops may still be
|
||||
// applying — no side-effect quiescence). Absent on an ordinary send.
|
||||
superseded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,7 +470,7 @@ export interface AiChatStreamArgs {
|
||||
* can be rebuilt for `convertToModelMessages`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiChatService implements OnModuleInit {
|
||||
export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AiChatService.name);
|
||||
|
||||
constructor(
|
||||
@@ -463,8 +491,17 @@ export class AiChatService implements OnModuleInit {
|
||||
// constructions (int-specs) compile unchanged; Nest always injects the real
|
||||
// provider in production. Only ever touched on the run-wrapped + flag-on path.
|
||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
||||
// #487: the run lifecycle service, for the periodic + opportunistic reconcile
|
||||
// (zombie re-drive + stale-run abort). OPTIONAL so positional test
|
||||
// constructions compile unchanged; Nest always injects the real singleton, so
|
||||
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
|
||||
private readonly aiChatRunService?: AiChatRunService,
|
||||
) {}
|
||||
|
||||
// #487: periodic reconcile timer (single-process phase 1). Started in
|
||||
// onModuleInit, cleared in onModuleDestroy.
|
||||
private reconcileTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* Crash-recovery sweep on server start (#183): any assistant row left in the
|
||||
* 'streaming' state is the relic of a turn whose process died before it
|
||||
@@ -489,6 +526,158 @@ export class AiChatService implements OnModuleInit {
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// #487: start the PERIODIC reconcile (was boot-only). It heals both directions
|
||||
// of the run<->message lifecycle asymmetry that a boot sweep alone left to the
|
||||
// NEXT restart. Single-process phase 1: the in-memory active/zombie maps are
|
||||
// authoritative, so "no live entry" is a safe primary gate.
|
||||
const staleMs = this.reconcileStalenessMs();
|
||||
// boot-warn if the per-call cap is configured so high the derived staleness is
|
||||
// unusually long (a stale run then lingers longer before reconcile aborts it).
|
||||
if (staleMs > 30 * 60 * 1000) {
|
||||
this.logger.warn(
|
||||
`#487 reconcile staleness is ${Math.round(staleMs / 60000)}min ` +
|
||||
`(derived from max(2 x per-call cap, 15min)); a per-call cap this high ` +
|
||||
`delays stale-run recovery. Review AI_CHAT_INAPP_TOOL_CALL_CAP_MS.`,
|
||||
);
|
||||
}
|
||||
const intervalMs = this.reconcileIntervalMs();
|
||||
this.reconcileTimer = setInterval(() => {
|
||||
void this.reconcile().catch((err) => {
|
||||
this.logger.warn(
|
||||
`Periodic reconcile failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}, intervalMs);
|
||||
this.reconcileTimer.unref?.();
|
||||
}
|
||||
|
||||
/** #487: stop the periodic reconcile timer on shutdown. */
|
||||
onModuleDestroy(): void {
|
||||
if (this.reconcileTimer) {
|
||||
clearInterval(this.reconcileTimer);
|
||||
this.reconcileTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: reconcile staleness threshold X — a run/message is only a "no live
|
||||
* runner" abort candidate once UNTOUCHED past this. Derived as
|
||||
* max(2 x per-call cap, 15min): 2x the longest legitimate single tool call plus
|
||||
* a floor, so a marathon turn making steady progress (updatedAt bumped each
|
||||
* step) is never swept.
|
||||
*/
|
||||
private reconcileStalenessMs(): number {
|
||||
return Math.max(2 * inAppToolCallCapMs(), 15 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** #487: how often the periodic reconcile runs (env-tunable, default 2min). */
|
||||
private reconcileIntervalMs(): number {
|
||||
const raw = Number(process.env.AI_CHAT_RECONCILE_INTERVAL_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 2 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: the periodic BIDIRECTIONAL reconcile. Runs the clauses IN ORDER; each is
|
||||
* best-effort (a failure of one never blocks the others). Single-process phase 1
|
||||
* — the run service's in-memory maps are authoritative for "live entry".
|
||||
*
|
||||
* (a) re-drive ZOMBIE runs (a terminal write that gave up) — apply the intended
|
||||
* status via the conditional UPDATE;
|
||||
* (b) message 'streaming' + its RUN terminal -> stamp the message by the run's
|
||||
* status (succeeded-run + stuck row -> 'aborted'+finalizeFailed, NOT
|
||||
* 'completed' with empty parts — the final text lived only in the dead
|
||||
* process's memory, a documented loss);
|
||||
* (c) run active + NO live entry + NO zombie + stale -> aborted (the run
|
||||
* service applies the "no entry" primary gate + last-progress staleness);
|
||||
* (d) message 'streaming' + age>X + NO active run on the chat -> aborted
|
||||
* (historical-row safety, double-gated).
|
||||
*/
|
||||
async reconcile(): Promise<void> {
|
||||
const staleMs = this.reconcileStalenessMs();
|
||||
|
||||
// (a) zombie re-drive.
|
||||
if (this.aiChatRunService) {
|
||||
for (const runId of this.aiChatRunService.zombieRunIds()) {
|
||||
try {
|
||||
await this.aiChatRunService.settleZombie(runId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile (a) zombie ${runId} re-drive failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) message streaming + run terminal -> stamp message by run status.
|
||||
try {
|
||||
const stuck = await this.aiChatMessageRepo.findStreamingWithTerminalRun();
|
||||
for (const s of stuck) {
|
||||
// succeeded-run -> 'aborted' (NOT 'completed'-empty); failed -> 'error';
|
||||
// aborted -> 'aborted'. All via the finalizeFailed stamp.
|
||||
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
|
||||
await this.aiChatMessageRepo.stampTerminalIfStreaming(
|
||||
s.messageId,
|
||||
s.workspaceId,
|
||||
status,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile (b) message<-run failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// (c) stale active run with no live runner -> aborted.
|
||||
if (this.aiChatRunService) {
|
||||
try {
|
||||
await this.aiChatRunService.reconcileStaleRuns(staleMs);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile (c) stale-run abort failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// (d) historical streaming row, no active run on the chat, stale -> aborted.
|
||||
try {
|
||||
await this.aiChatMessageRepo.sweepStreamingWithoutActiveRun(staleMs);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Reconcile (d) historical-row sweep failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: OPPORTUNISTIC single-chat reconcile at the start of a turn (beginRun /
|
||||
* supersede path), so a user who returns to a chat with a stuck streaming row
|
||||
* (its run already terminal) sees it settled WITHOUT waiting for the periodic
|
||||
* job. Best-effort — a failure NEVER fails the turn (swallowed by the caller).
|
||||
*/
|
||||
async reconcileChat(chatId: string, workspaceId: string): Promise<void> {
|
||||
const stuck = await this.aiChatMessageRepo.findStreamingWithTerminalRun(50, {
|
||||
chatId,
|
||||
workspaceId,
|
||||
});
|
||||
for (const s of stuck) {
|
||||
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
|
||||
await this.aiChatMessageRepo.stampTerminalIfStreaming(
|
||||
s.messageId,
|
||||
s.workspaceId,
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -725,6 +914,7 @@ export class AiChatService implements OnModuleInit {
|
||||
model,
|
||||
role,
|
||||
runHooks,
|
||||
superseded,
|
||||
}: AiChatStreamArgs): Promise<void> {
|
||||
// Resolve / create the chat. A new chat is created when no valid chatId is
|
||||
// supplied or the supplied one does not belong to this workspace.
|
||||
@@ -797,15 +987,49 @@ export class AiChatService implements OnModuleInit {
|
||||
code: 'A_RUN_ALREADY_ACTIVE',
|
||||
});
|
||||
}
|
||||
// Any OTHER run-start failure must not break the turn — fall back to the
|
||||
// socket signal (legacy behavior) and stream anyway.
|
||||
// Any OTHER run-start failure (e.g. a DB-pool blip) must FAIL THE TURN,
|
||||
// not silently stream without a run-row. The old fallback let the turn
|
||||
// continue untracked: in autonomous mode nobody could then abort it —
|
||||
// /stop can't see a run that doesn't exist, a client disconnect doesn't
|
||||
// abort it, and the one-run-per-chat gate would let a SECOND run in. That
|
||||
// is an unstoppable, invisible run until process restart. Reject NOW,
|
||||
// BEFORE the first byte (nothing is written yet, no user row inserted, no
|
||||
// MCP lease taken), so the controller's post-hijack catch turns this
|
||||
// HttpException into an honest 503 on the raw socket. Same policy for BOTH
|
||||
// modes — #487 inherits it (no mode-branching here).
|
||||
this.logger.error(
|
||||
`Failed to begin agent run (chat ${chatId}); streaming without run tracking`,
|
||||
`Failed to begin agent run (chat ${chatId}); failing the turn`,
|
||||
err as Error,
|
||||
);
|
||||
throw new ServiceUnavailableException({
|
||||
message:
|
||||
'Could not start the agent run. This is usually temporary — please try again.',
|
||||
code: 'A_RUN_BEGIN_FAILED',
|
||||
// Self-describe the status in the body: the controller's post-hijack
|
||||
// catch writes getResponse() verbatim onto the raw socket, and an
|
||||
// object-arg HttpException does NOT inject statusCode. Without it the
|
||||
// client's 503 classifier (which reads the body JSON) could not see the
|
||||
// status. With it present, the client's A_RUN_BEGIN_FAILED branch (which
|
||||
// runs strictly before the generic-503 branch) shows "temporary, retry".
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #487: opportunistic single-chat reconcile — settle any streaming row on this
|
||||
// chat whose run is already terminal BEFORE this turn's history load, so the
|
||||
// user never waits on the periodic job and the new turn's model history is not
|
||||
// polluted by a stuck 'streaming' row. Best-effort: it must NEVER fail the turn.
|
||||
try {
|
||||
await this.reconcileChat(chatId, workspace.id);
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`Opportunistic reconcile for chat ${chatId} failed (ignored): ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract the incoming user turn (the last user message from useChat).
|
||||
const incoming = lastUserMessage(body.messages);
|
||||
@@ -1011,6 +1235,9 @@ export class AiChatService implements OnModuleInit {
|
||||
// History-confirmed interrupt-resume flag (#198): adds the interrupt note
|
||||
// so the model treats the partial answer above as cut off, not finished.
|
||||
interrupted,
|
||||
// #487: this turn superseded a still-live run — warn the model the
|
||||
// previous run's last ops may still be applying (no quiescence).
|
||||
superseded,
|
||||
// Detected between-turns human edit to the open page (#274): adds the
|
||||
// page_changed note + unified diff so the agent doesn't overwrite it.
|
||||
pageChanged,
|
||||
@@ -1080,7 +1307,6 @@ export class AiChatService implements OnModuleInit {
|
||||
const degenerationController = new AbortController();
|
||||
let degenerationDetected = false;
|
||||
let lastDegenerationCheckLen = 0;
|
||||
const DEGENERATION_CHECK_STEP = 2000;
|
||||
|
||||
// Step-granular durability (#183): create the assistant row UPFRONT in the
|
||||
// 'streaming' state (before any token), then UPDATE it as each step finishes
|
||||
@@ -1166,29 +1392,59 @@ export class AiChatService implements OnModuleInit {
|
||||
// callbacks — mirroring the pre-#183 persist-at-most-once guard for the
|
||||
// TERMINAL status (the row may be updated many times with 'streaming' before
|
||||
// this fires once).
|
||||
// #487: the once-gate closes ONLY AFTER a successful write, and the write is
|
||||
// BOUNDED-RETRIED. Previously `finalized` was set BEFORE the write and never
|
||||
// retried, so a single failed UPDATE stranded the row 'streaming' forever
|
||||
// (the boot-only sweep was the only recovery). Now a transient blip is ridden
|
||||
// out in place; a total give-up leaves the gate OPEN and logs, and the
|
||||
// periodic reconcile (clauses b/d) later settles the row. Returns whether the
|
||||
// terminal write LANDED, so the caller can error-mark the RUN on a message
|
||||
// failure (the run is finalized regardless — never gated on the message).
|
||||
let finalized = false;
|
||||
const FINALIZE_MSG_MAX_ATTEMPTS = 3;
|
||||
const finalizeAssistant = async (
|
||||
flushed: AssistantFlush,
|
||||
): Promise<void> => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
): Promise<boolean> => {
|
||||
if (finalized) return true;
|
||||
const plan = planFinalizeAssistant(assistantId);
|
||||
try {
|
||||
// Shared dispatch (see applyFinalize): UPDATE the upfront row, or — when
|
||||
// the upfront insert failed (kind 'insert') — INSERT the terminal row as
|
||||
// the only safety against losing the turn entirely.
|
||||
await applyFinalize(
|
||||
this.aiChatMessageRepo,
|
||||
plan,
|
||||
{ chatId, workspaceId: workspace.id, userId: user.id },
|
||||
flushed,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to finalize assistant message (kind=${plan.kind})`,
|
||||
err as Error,
|
||||
);
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= FINALIZE_MSG_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
// Shared dispatch (see applyFinalize): conditionally UPDATE the upfront
|
||||
// row (owner-write priority), or — when the upfront insert failed (kind
|
||||
// 'insert') — INSERT the terminal row as the only safety against losing
|
||||
// the turn entirely.
|
||||
await applyFinalize(
|
||||
this.aiChatMessageRepo,
|
||||
plan,
|
||||
{ chatId, workspaceId: workspace.id, userId: user.id },
|
||||
flushed,
|
||||
);
|
||||
finalized = true; // gate closes ONLY after a successful write
|
||||
return true;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
this.logger.warn(
|
||||
`Assistant message finalize attempt ${attempt}/${FINALIZE_MSG_MAX_ATTEMPTS} ` +
|
||||
`failed (kind=${plan.kind}): ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
if (attempt < FINALIZE_MSG_MAX_ATTEMPTS) {
|
||||
await new Promise((r) => setTimeout(r, 50 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Gave up: leave the gate OPEN (no in-process second settler exists — the
|
||||
// terminal callbacks are mutually exclusive) and log. The periodic reconcile
|
||||
// settles the stranded row; a late owner-write is impossible for this turn,
|
||||
// so the reconcile stamp (aborted+finalizeFailed) is the final state.
|
||||
this.logger.error(
|
||||
`Assistant message finalize GAVE UP after ${FINALIZE_MSG_MAX_ATTEMPTS} ` +
|
||||
`attempts (row left 'streaming', chat ${chatId}); reconcile will settle it`,
|
||||
lastError as Error,
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Measure
|
||||
@@ -1254,8 +1510,10 @@ export class AiChatService implements OnModuleInit {
|
||||
// trigger, abort the run ONCE with a distinguishable reason.
|
||||
if (
|
||||
!degenerationDetected &&
|
||||
inProgressText.length - lastDegenerationCheckLen >=
|
||||
DEGENERATION_CHECK_STEP
|
||||
shouldCheckDegeneration(
|
||||
inProgressText.length,
|
||||
lastDegenerationCheckLen,
|
||||
)
|
||||
) {
|
||||
lastDegenerationCheckLen = inProgressText.length;
|
||||
if (isDegenerateOutput(inProgressText)) {
|
||||
@@ -1275,6 +1533,13 @@ export class AiChatService implements OnModuleInit {
|
||||
// the in-progress accumulator for the next step.
|
||||
capturedSteps.push(step as StepLike);
|
||||
inProgressText = '';
|
||||
// Reset the degeneration-check watermark too (#486): it tracks a byte
|
||||
// offset INTO inProgressText, so once that resets to '' a stale (large)
|
||||
// mark makes `inProgressText.length - lastDegenerationCheckLen` go
|
||||
// negative and the throttled detector stays silent until a later step's
|
||||
// text re-grows past the old offset — a whole degenerate step could slip
|
||||
// through undetected. Zeroing it re-arms the check from the next byte.
|
||||
lastDegenerationCheckLen = 0;
|
||||
// Step-granular durability (#183): persist this finished step (its text +
|
||||
// tool calls + tool RESULTS) the moment it ends, so a process death after
|
||||
// this point still recovers the step. Not awaited here (never block the
|
||||
@@ -1324,7 +1589,7 @@ export class AiChatService implements OnModuleInit {
|
||||
const stepExhausted = steps.length >= MAX_AGENT_STEPS;
|
||||
const emptyTurnMarker =
|
||||
!producedText && stepExhausted ? STEP_LIMIT_NO_ANSWER_MARKER : '';
|
||||
await finalizeAssistant(
|
||||
const msgOk = await finalizeAssistant(
|
||||
flushAssistant(steps as StepLike[], emptyTurnMarker, 'completed', {
|
||||
finishReason: finishReason as string,
|
||||
usage: totalUsage as StreamUsage,
|
||||
@@ -1338,9 +1603,19 @@ export class AiChatService implements OnModuleInit {
|
||||
pageChanged,
|
||||
}),
|
||||
);
|
||||
// #184: settle the RUN as succeeded (best-effort, after the projection
|
||||
// is finalized above).
|
||||
if (runId) await runHooks?.onSettled?.(runId, 'completed');
|
||||
// #184/#487: the RUN is finalized ALWAYS (never gated on the message).
|
||||
// If the message finalize GAVE UP, error-mark the run so the asymmetry
|
||||
// "run succeeded / message streaming forever" cannot arise; the
|
||||
// periodic reconcile then settles the stuck message from this run.
|
||||
if (runId) {
|
||||
await runHooks?.onSettled?.(
|
||||
runId,
|
||||
msgOk ? 'completed' : 'error',
|
||||
msgOk
|
||||
? undefined
|
||||
: 'Assistant message could not be persisted (finalize failed).',
|
||||
);
|
||||
}
|
||||
// Lifecycle: release the external MCP clients leased for this turn.
|
||||
await closeExternalClients();
|
||||
|
||||
@@ -2081,7 +2356,10 @@ export function planFinalizeAssistant(
|
||||
* a test mock both satisfy it). */
|
||||
export interface FinalizeRepo {
|
||||
insert(insertable: Record<string, unknown>): Promise<unknown>;
|
||||
update(
|
||||
// #487: the OWNER terminal write is CONDITIONAL (status='streaming' OR
|
||||
// metadata.finalizeFailed) so the owner overwrites a reconcile stamp but never
|
||||
// an already-proper terminal row (owner-write priority).
|
||||
finalizeOwner(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
patch: AssistantFlush,
|
||||
@@ -2090,10 +2368,11 @@ export interface FinalizeRepo {
|
||||
|
||||
/**
|
||||
* Apply a finalize `plan` to the repo with the terminal `flushed` payload (#183):
|
||||
* UPDATE the upfront row, or INSERT a fresh terminal row as the fallback when the
|
||||
* upfront insert failed. The SINGLE dispatch shared by the service's
|
||||
* finalizeAssistant and its test, so the test exercises the real path instead of
|
||||
* a copy (#186 review). Pure of error handling — the caller wraps it.
|
||||
* conditionally UPDATE the upfront row (owner-write priority, #487), or INSERT a
|
||||
* fresh terminal row as the fallback when the upfront insert failed. The SINGLE
|
||||
* dispatch shared by the service's finalizeAssistant and its test, so the test
|
||||
* exercises the real path instead of a copy (#186 review). Pure of error
|
||||
* handling — the caller wraps it (and RETRIES it, #487).
|
||||
*/
|
||||
export async function applyFinalize(
|
||||
repo: FinalizeRepo,
|
||||
@@ -2102,7 +2381,7 @@ export async function applyFinalize(
|
||||
flushed: AssistantFlush,
|
||||
): Promise<void> {
|
||||
if (plan.kind === 'update') {
|
||||
await repo.update(plan.id, base.workspaceId, flushed);
|
||||
await repo.finalizeOwner(plan.id, base.workspaceId, flushed);
|
||||
return;
|
||||
}
|
||||
await repo.insert({
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { streamText } from 'ai';
|
||||
import {
|
||||
hasRepeatedLineRun,
|
||||
hasPeriodicTail,
|
||||
isDegenerateOutput,
|
||||
truncateDegeneratedTail,
|
||||
shouldCheckDegeneration,
|
||||
DEGENERATION_CHECK_STEP,
|
||||
REPEATED_LINES_THRESHOLD,
|
||||
MIN_PERIOD_REPEATS,
|
||||
} from './output-degeneration';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
|
||||
// Mock ONLY streamText so we can capture the onChunk/onStepFinish callbacks the
|
||||
// service registers and drive them by hand; every other `ai` export the service
|
||||
// uses (convertToModelMessages, stepCountIs, …) stays real.
|
||||
jest.mock('ai', () => {
|
||||
const actual = jest.requireActual('ai');
|
||||
return { ...actual, streamText: jest.fn() };
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for the token-degeneration detector (#444) — the sole anti-babble
|
||||
@@ -180,3 +193,188 @@ describe('truncateDegeneratedTail', () => {
|
||||
expect(truncateDegeneratedTail(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Throttle + step-boundary reset (#486). The stream keeps a watermark
|
||||
* (`lastDegenerationCheckLen`) that is an OFFSET into the accumulated step text.
|
||||
* On a step boundary the accumulator resets to '', so the watermark MUST reset to
|
||||
* 0 too — otherwise the throttle goes silent for the whole next step. These tests
|
||||
* pin the pure decision AND the reset property that ai-chat.service.onStepFinish
|
||||
* now enforces.
|
||||
*/
|
||||
describe('shouldCheckDegeneration (throttle) + step-boundary reset (#486)', () => {
|
||||
it('fires once the text grows a full DEGENERATION_CHECK_STEP past the mark', () => {
|
||||
expect(shouldCheckDegeneration(DEGENERATION_CHECK_STEP, 0)).toBe(true);
|
||||
expect(shouldCheckDegeneration(DEGENERATION_CHECK_STEP - 1, 0)).toBe(false);
|
||||
expect(shouldCheckDegeneration(5000, 3000)).toBe(true); // grew 2000 since mark
|
||||
expect(shouldCheckDegeneration(4000, 3000)).toBe(false); // grew only 1000
|
||||
});
|
||||
|
||||
it('BUG (no reset): a stale large watermark silences the next step', () => {
|
||||
// End of a long step: the watermark sits at 5000. The step ends and the
|
||||
// accumulator resets to '' — but if the watermark is NOT reset, a fresh short
|
||||
// degenerate burst (length 2000) never triggers a check: 2000 - 5000 < STEP.
|
||||
const staleWatermark = 5000;
|
||||
const nextStepLen = DEGENERATION_CHECK_STEP; // a fresh 2KB burst
|
||||
expect(shouldCheckDegeneration(nextStepLen, staleWatermark)).toBe(false);
|
||||
});
|
||||
|
||||
it('FIX (reset to 0): the same short degenerate burst IS checked and detected', () => {
|
||||
// onStepFinish now zeroes the watermark, so the fresh burst re-arms the check.
|
||||
const resetWatermark = 0;
|
||||
const degenerateBurst = 'loadTools.\n'.repeat(300); // real degeneration
|
||||
expect(degenerateBurst.length).toBeGreaterThanOrEqual(DEGENERATION_CHECK_STEP);
|
||||
// The throttle now fires...
|
||||
expect(
|
||||
shouldCheckDegeneration(degenerateBurst.length, resetWatermark),
|
||||
).toBe(true);
|
||||
// ...and the detector catches the loop that would otherwise stream unchecked.
|
||||
expect(isDegenerateOutput(degenerateBurst)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* BEHAVIOR guard for the ACTUAL fix (#486, ai-chat.service.onStepFinish resets
|
||||
* lastDegenerationCheckLen to 0). The pure tests above use a hard-coded
|
||||
* resetWatermark, so a REVERT of the real `lastDegenerationCheckLen = 0` line
|
||||
* would not redden any of them. This drives the REAL onChunk/onStepFinish
|
||||
* closures from stream() end to end and asserts the run is aborted when a fresh
|
||||
* degenerate burst arrives in the step AFTER a long clean step — which only
|
||||
* happens if the watermark was actually zeroed on the step boundary.
|
||||
*/
|
||||
describe('AiChatService: onStepFinish re-arms the degeneration watermark (#486)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
raw: {
|
||||
writeHead: jest.fn(),
|
||||
write: jest.fn(),
|
||||
once: jest.fn(),
|
||||
on: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
const mcpClients = {
|
||||
toolsFor: jest.fn(async () => ({
|
||||
tools: {},
|
||||
clients: [],
|
||||
outcomes: [],
|
||||
instructions: [],
|
||||
})),
|
||||
};
|
||||
return new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiChatPageSnapshotRepo
|
||||
aiSettings as never,
|
||||
tools as never,
|
||||
mcpClients as never,
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{
|
||||
isAiChatDeferredToolsEnabled: () => false,
|
||||
// Lockdown OFF -> the degeneration guard is the active anti-babble path.
|
||||
isAiChatFinalStepLockdownEnabled: () => false,
|
||||
} as never, // environment
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('aborts on a fresh degenerate burst in the NEXT step (reverting the reset line reddens this)', async () => {
|
||||
let captured:
|
||||
| {
|
||||
onChunk?: (e: { chunk: { type: string; text: string } }) => void;
|
||||
onStepFinish?: (step: unknown) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
| undefined;
|
||||
streamTextMock.mockImplementation((opts: never) => {
|
||||
captured = opts;
|
||||
return {
|
||||
consumeStream: jest.fn(),
|
||||
pipeUIMessageStreamToResponse: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const svc = makeService();
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
} as never,
|
||||
res: makeRes() as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
// No runHooks -> legacy path (socket signal), degeneration guard active.
|
||||
});
|
||||
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
const onChunk = captured!.onChunk!;
|
||||
const onStepFinish = captured!.onStepFinish!;
|
||||
const abortSignal = captured!.abortSignal!;
|
||||
expect(abortSignal.aborted).toBe(false);
|
||||
|
||||
// STEP 1: a LONG, non-degenerate first step. Distinct lines never trip the
|
||||
// detector, but they advance the throttle watermark far past the burst size
|
||||
// that follows (to ~5x the step). This is the stale watermark that, WITHOUT
|
||||
// the reset, would silence step 2.
|
||||
let counter = 0;
|
||||
let accumulated = 0;
|
||||
while (accumulated < DEGENERATION_CHECK_STEP * 5) {
|
||||
const line = `unique clean line number ${counter++} with distinct words\n`;
|
||||
accumulated += line.length;
|
||||
onChunk({ chunk: { type: 'text-delta', text: line } });
|
||||
}
|
||||
expect(abortSignal.aborted).toBe(false); // clean step must not abort
|
||||
|
||||
// STEP BOUNDARY: the real onStepFinish resets inProgressText AND (the fix)
|
||||
// zeroes lastDegenerationCheckLen.
|
||||
onStepFinish({ text: 'a clean first step', toolCalls: [], toolResults: [] });
|
||||
|
||||
// STEP 2: a FRESH, short degenerate burst (~3.3KB). Its length is far below
|
||||
// the step-1 stale watermark (~10KB), so WITHOUT the reset the throttle stays
|
||||
// silent and this streams unchecked. WITH the reset (watermark 0) it re-arms,
|
||||
// the detector fires, and the run aborts.
|
||||
const burst = 'loadTools.\n'.repeat(300);
|
||||
expect(burst.length).toBeGreaterThanOrEqual(DEGENERATION_CHECK_STEP);
|
||||
expect(burst.length).toBeLessThan(DEGENERATION_CHECK_STEP * 5);
|
||||
onChunk({ chunk: { type: 'text-delta', text: burst } });
|
||||
|
||||
// The decisive assertion: the composed abortSignal (unioned with the
|
||||
// degeneration controller) is now aborted. Reverting `lastDegenerationCheckLen
|
||||
// = 0` in onStepFinish makes this stay false.
|
||||
expect(abortSignal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,32 @@ export function isDegenerateOutput(text: string): boolean {
|
||||
return hasRepeatedLineRun(text) || hasPeriodicTail(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many bytes the in-progress text must grow before the (amortized) tail
|
||||
* heuristics are re-run. Shared with ai-chat.service so the throttle the stream
|
||||
* applies is the SAME one the unit test drives.
|
||||
*/
|
||||
export const DEGENERATION_CHECK_STEP = 2000;
|
||||
|
||||
/**
|
||||
* Throttle decision for the degeneration guard (#444/#486). Returns true when
|
||||
* the accumulated text has grown at least DEGENERATION_CHECK_STEP bytes past the
|
||||
* last-checked offset, so the pure rules only fire every ~2KB. Pure; the caller
|
||||
* updates its watermark to `textLen` when this returns true.
|
||||
*
|
||||
* The watermark is an offset INTO the accumulator, so when the accumulator is
|
||||
* reset to '' on a step boundary the caller MUST reset the watermark to 0 too
|
||||
* (#486). Otherwise `textLen - lastCheckLen` goes negative after the reset and
|
||||
* this returns false until a later step re-grows past the stale offset — a whole
|
||||
* degenerate step could stream unchecked.
|
||||
*/
|
||||
export function shouldCheckDegeneration(
|
||||
textLen: number,
|
||||
lastCheckLen: number,
|
||||
): boolean {
|
||||
return textLen - lastCheckLen >= DEGENERATION_CHECK_STEP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a degenerated tail before persist so hundreds of KB of garbage never
|
||||
* reach the DB / replay (#444). Keeps everything up to and including the FIRST
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Break the editor-ext import chain (share.service -> collaboration.util ->
|
||||
// @docmost/editor-ext -> @tiptap/core) that is unresolvable in this jest env and
|
||||
// pre-existingly breaks these specs. jsonToMarkdown is never reached in these
|
||||
// tests (the tools fail before rendering markdown).
|
||||
jest.mock('../../collaboration/collaboration.util', () => ({
|
||||
jsonToMarkdown: () => '',
|
||||
}));
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
|
||||
import { PublicShareChatService } from './public-share-chat.service';
|
||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||
|
||||
/**
|
||||
* SECURITY integration guard for #394 (commit 5): a tool's or the provider's raw
|
||||
* error text must NOT leak to an anonymous public-share reader.
|
||||
*
|
||||
* The render gate (ToolCallCard showErrors=false) hides the text in the DOM but
|
||||
* NOT on the wire, so this test asserts on the RAW SSE BYTES the server writes —
|
||||
* exactly the channel the render gate masks. We drive the real
|
||||
* PublicShareChatService.stream() with a real share toolset (its underlying
|
||||
* services mocked to fail) and a mock model, then inspect every byte piped to the
|
||||
* fake socket.
|
||||
*/
|
||||
|
||||
// A minimal ServerResponse stand-in that records every written chunk.
|
||||
class FakeSocket {
|
||||
chunks: string[] = [];
|
||||
statusCode = 200;
|
||||
writableEnded = false;
|
||||
destroyed = false;
|
||||
headersSent = false;
|
||||
writeHead(): this {
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
}
|
||||
setHeader(): void {}
|
||||
removeHeader(): void {}
|
||||
getHeader(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
flushHeaders(): void {}
|
||||
write(chunk: unknown): boolean {
|
||||
this.chunks.push(
|
||||
typeof chunk === 'string' ? chunk : Buffer.from(chunk as never).toString('utf8'),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
end(chunk?: unknown): void {
|
||||
if (chunk) this.write(chunk);
|
||||
this.writableEnded = true;
|
||||
}
|
||||
on(): this {
|
||||
return this;
|
||||
}
|
||||
once(): this {
|
||||
return this;
|
||||
}
|
||||
get body(): string {
|
||||
return this.chunks.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock model that issues one getSharePage tool call, then finishes with text. */
|
||||
function toolCallingModel(): MockLanguageModelV3 {
|
||||
let call = 0;
|
||||
return new MockLanguageModelV3({
|
||||
doStream: async () => {
|
||||
call++;
|
||||
if (call === 1) {
|
||||
return {
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'stream-start' as const, warnings: [] },
|
||||
{ type: 'tool-input-start' as const, id: 't1', toolName: 'getSharePage' },
|
||||
{ type: 'tool-input-end' as const, id: 't1' },
|
||||
{
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: 't1',
|
||||
toolName: 'getSharePage',
|
||||
input: '{"pageId":"secret-page"}',
|
||||
},
|
||||
{
|
||||
type: 'finish' as const,
|
||||
finishReason: { unified: 'tool-calls' as const, raw: 'tool_calls' },
|
||||
usage: {
|
||||
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'stream-start' as const, warnings: [] },
|
||||
{ type: 'text-start' as const, id: '1' },
|
||||
{ type: 'text-delta' as const, id: '1', delta: 'Sorry.' },
|
||||
{ type: 'text-end' as const, id: '1' },
|
||||
{
|
||||
type: 'finish' as const,
|
||||
finishReason: { unified: 'stop' as const, raw: 'stop' },
|
||||
usage: {
|
||||
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mock model whose stream emits a provider error carrying an internal secret. */
|
||||
function providerErrorModel(secret: string): MockLanguageModelV3 {
|
||||
return new MockLanguageModelV3({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'stream-start' as const, warnings: [] },
|
||||
{
|
||||
type: 'error' as const,
|
||||
error: {
|
||||
statusCode: 503,
|
||||
message: 'Service Unavailable',
|
||||
responseBody: `upstream ${secret} model=internal-gpt`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function makeService(toolsService: PublicShareChatToolsService): {
|
||||
svc: PublicShareChatService;
|
||||
logSpy: jest.SpyInstance;
|
||||
} {
|
||||
const svc = Object.create(PublicShareChatService.prototype);
|
||||
const logger = new Logger('test');
|
||||
const logSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||
svc.tools = toolsService;
|
||||
svc.logger = logger;
|
||||
svc.tokenBudget = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
return { svc, logSpy };
|
||||
}
|
||||
|
||||
async function runStream(
|
||||
svc: PublicShareChatService,
|
||||
model: MockLanguageModelV3,
|
||||
): Promise<FakeSocket> {
|
||||
const socket = new FakeSocket();
|
||||
await svc.stream({
|
||||
workspaceId: 'ws1',
|
||||
shareId: 'share1',
|
||||
share: { id: 'share1', pageId: 'p1', sharedPage: { id: 'p1', title: 'Docs' } },
|
||||
openedPage: null,
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'read the page' }] } as never,
|
||||
],
|
||||
res: { raw: socket } as never,
|
||||
signal: new AbortController().signal,
|
||||
model: model as never,
|
||||
role: null,
|
||||
});
|
||||
// Let the piped stream drain fully.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
return socket;
|
||||
}
|
||||
|
||||
describe('public share chat error leak (#394)', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('does NOT leak a tool\'s raw internal error to the SSE bytes (generic classified string instead)', async () => {
|
||||
const SECRET = 'INTERNAL_baseUrl_http://provider.internal:8080/v1';
|
||||
const shareService = {
|
||||
// The canonical boundary throws a RAW internal error (with a secret).
|
||||
resolveReadableSharePage: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error(`db failed at ${SECRET} stack@line42`)),
|
||||
};
|
||||
const tools = new PublicShareChatToolsService(
|
||||
shareService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const { svc } = makeService(tools);
|
||||
|
||||
const socket = await runStream(svc, toolCallingModel());
|
||||
|
||||
// The tool-output-error frame is present on the wire...
|
||||
expect(socket.body).toContain('tool-output-error');
|
||||
// ...but it carries ONLY the generic classified string — never the secret,
|
||||
// the raw driver message, or a stack fragment.
|
||||
expect(socket.body).toContain('The tool could not complete the request.');
|
||||
expect(socket.body).not.toContain(SECRET);
|
||||
expect(socket.body).not.toContain('stack@line42');
|
||||
expect(socket.body).not.toContain('db failed');
|
||||
});
|
||||
|
||||
it('passes a SAFE ShareToolError message (page not available) through to the bytes', async () => {
|
||||
const shareService = {
|
||||
// Not found in this share -> the tool throws the classified SAFE message.
|
||||
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const tools = new PublicShareChatToolsService(
|
||||
shareService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const { svc } = makeService(tools);
|
||||
|
||||
const socket = await runStream(svc, toolCallingModel());
|
||||
expect(socket.body).toContain('tool-output-error');
|
||||
expect(socket.body).toContain('not available in this share');
|
||||
});
|
||||
|
||||
it('does NOT leak a provider error (statusCode + response body) to the SSE bytes', async () => {
|
||||
const SECRET = 'http://provider.internal:8080';
|
||||
const tools = new PublicShareChatToolsService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const { svc, logSpy } = makeService(tools);
|
||||
|
||||
const socket = await runStream(svc, providerErrorModel(SECRET));
|
||||
|
||||
// The anon sees a fixed classified string, not the provider body/baseUrl/model.
|
||||
expect(socket.body).toContain('temporarily unavailable');
|
||||
expect(socket.body).not.toContain(SECRET);
|
||||
expect(socket.body).not.toContain('internal-gpt');
|
||||
// The FULL provider detail is logged server-side only.
|
||||
const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(logged).toContain(SECRET);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@ import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles
|
||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
import { AiService } from '../../integrations/ai/ai.service';
|
||||
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||
import {
|
||||
PublicShareChatToolsService,
|
||||
ShareToolError,
|
||||
} from './tools/public-share-chat-tools.service';
|
||||
import { buildShareSystemPrompt } from './public-share-chat.prompt';
|
||||
import { roleModelOverride } from './roles/role-model-config';
|
||||
import {
|
||||
@@ -102,6 +105,30 @@ export function filterShareTranscript(messages: UIMessage[]): UIMessage[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed, classified strings an ANONYMOUS share reader may see when the assistant
|
||||
* stream fails (#394). These reveal NOTHING about the internal provider, its
|
||||
* baseUrl, the model name, or the raw response body — unlike describeProviderError
|
||||
* (which is for the server log / the authenticated operator only). We classify by
|
||||
* HTTP status where available so the reader still gets a useful hint (retry vs.
|
||||
* give up) without any internal detail.
|
||||
*/
|
||||
export function classifyAnonStreamError(error: unknown): string {
|
||||
const status =
|
||||
typeof error === 'object' && error !== null
|
||||
? (error as { statusCode?: number }).statusCode
|
||||
: undefined;
|
||||
if (status === 429) {
|
||||
return 'The assistant is receiving too many requests right now. Please try again shortly.';
|
||||
}
|
||||
if (typeof status === 'number' && status >= 500) {
|
||||
return 'The assistant is temporarily unavailable. Please try again.';
|
||||
}
|
||||
// Any other failure (including a bare connection error with no status): a
|
||||
// single neutral line. No provider identity, no config, no response body.
|
||||
return 'The assistant could not complete your request. Please try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymous, read-only AI assistant for a single PUBLIC share tree.
|
||||
*
|
||||
@@ -318,11 +345,28 @@ export class PublicShareChatService {
|
||||
result.pipeUIMessageStreamToResponse(res.raw, {
|
||||
headers: { 'X-Accel-Buffering': 'no' },
|
||||
onError: (error: unknown) => {
|
||||
// Reuse the shared formatter so provider error formatting stays
|
||||
// unified between the log line and the streamed error message — a
|
||||
// share reader sees 402/429/503 causes consistently with the
|
||||
// authenticated path.
|
||||
return describeProviderError(error, 'AI stream error');
|
||||
// SECURITY (#394): the string this returns is written verbatim into the
|
||||
// SSE error frame delivered to an ANONYMOUS reader (for a tool failure
|
||||
// it becomes the atomic `tool-output-error` frame's errorText; for a
|
||||
// stream/provider failure, the terminal error frame).
|
||||
//
|
||||
// A ShareToolError is already a classified, safe tool message (see
|
||||
// PublicShareChatToolsService.wrapToolErrors) — pass it through so the
|
||||
// reader still gets the useful "page not available in this share" hint.
|
||||
if (error instanceof ShareToolError) {
|
||||
return error.message;
|
||||
}
|
||||
// Anything else is a provider/stream error. describeProviderError
|
||||
// bundles the provider statusCode AND response body, which can carry the
|
||||
// internal baseUrl or model name — NEVER expose that to the public. Log
|
||||
// the full detail server-side only and return a fixed classified string.
|
||||
this.logger.error(
|
||||
`Public share chat pipe error: ${describeProviderError(
|
||||
error,
|
||||
'AI stream error',
|
||||
)}`,
|
||||
);
|
||||
return classifyAnonStreamError(error);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
wrapInAppToolWithCap,
|
||||
inAppToolCallCapMs,
|
||||
type ToolAbortSignalSink,
|
||||
} from './ai-chat-tools.service';
|
||||
import type { Tool, ToolCallOptions } from 'ai';
|
||||
|
||||
/**
|
||||
* #487 commit 1 — in-app tool race-on-abort + safe-points + per-call cap.
|
||||
*
|
||||
* Tests assert the HONEST observable property the spec names — "after Stop, NO
|
||||
* new HTTP/WS call STARTS; an already-started single call may take either
|
||||
* outcome" — against the REAL wrapper mechanism (the composite abort signal it
|
||||
* publishes on the client + the RACE it runs), NOT a timing-dependent proxy like
|
||||
* "the write didn't land".
|
||||
*/
|
||||
|
||||
// A minimal stand-in for the client's `toolAbortSignal` field. In production the
|
||||
// wrapper publishes the composite here and the client's paginateAll /
|
||||
// mutatePageContent safe-points read it; the fake "tool" below reads it the same
|
||||
// way, so this exercises the real contract without a live DB / collab socket.
|
||||
class FakeClient implements ToolAbortSignalSink {
|
||||
private signal: AbortSignal | null = null;
|
||||
setToolAbortSignal(signal: AbortSignal | null): void {
|
||||
this.signal = signal;
|
||||
}
|
||||
getToolAbortSignal(): AbortSignal | null {
|
||||
return this.signal;
|
||||
}
|
||||
}
|
||||
|
||||
// A ToolCallOptions with just the field the wrapper reads (abortSignal). The AI
|
||||
// SDK passes a fuller object; the wrapper only spreads it and reads abortSignal.
|
||||
const opts = (abortSignal?: AbortSignal): ToolCallOptions =>
|
||||
({ toolCallId: 't1', messages: [], abortSignal }) as unknown as ToolCallOptions;
|
||||
|
||||
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('#487 wrapInAppToolWithCap — race-on-abort + safe-points', () => {
|
||||
it('after Stop, no NEW simulated call starts (multi-call tool)', async () => {
|
||||
const client = new FakeClient();
|
||||
const started: number[] = [];
|
||||
// A multi-call tool that mirrors paginateAll: it consults the client signal
|
||||
// at a safe-point BEFORE starting each simulated network call.
|
||||
const multiCall: Tool = {
|
||||
execute: (async (_args: unknown) => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// Safe-point: exactly what paginateAll / mutatePageContent do.
|
||||
client.getToolAbortSignal()?.throwIfAborted();
|
||||
started.push(i);
|
||||
await tick(10);
|
||||
}
|
||||
return 'done';
|
||||
}) as unknown as Tool['execute'],
|
||||
} as Tool;
|
||||
|
||||
const wrapped = wrapInAppToolWithCap(multiCall, client, 10_000);
|
||||
const ac = new AbortController();
|
||||
const call = (
|
||||
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
|
||||
)({}, opts(ac.signal));
|
||||
|
||||
// Let one or two calls start, then Stop.
|
||||
await tick(12);
|
||||
ac.abort(new Error('user stop'));
|
||||
|
||||
await expect(call).rejects.toThrow(); // wrapper rejects promptly
|
||||
const startedAtStop = started.length;
|
||||
|
||||
// Give the abandoned loser ample time; its next safe-point must throw because
|
||||
// the (aborted) composite is still published on the client.
|
||||
await tick(60);
|
||||
expect(started.length).toBe(startedAtStop);
|
||||
// It must NOT have run the whole sequence (that would mean Stop did nothing).
|
||||
expect(started.length).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it('rejects immediately on Stop even if the call never settles (discard loser)', async () => {
|
||||
const client = new FakeClient();
|
||||
let settled = false;
|
||||
const hang: Tool = {
|
||||
execute: (async () => {
|
||||
await new Promise(() => undefined); // never resolves
|
||||
settled = true;
|
||||
}) as unknown as Tool['execute'],
|
||||
} as Tool;
|
||||
const wrapped = wrapInAppToolWithCap(hang, client, 10_000);
|
||||
const ac = new AbortController();
|
||||
const call = (
|
||||
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
|
||||
)({}, opts(ac.signal));
|
||||
await tick(5);
|
||||
ac.abort();
|
||||
await expect(call).rejects.toThrow();
|
||||
expect(settled).toBe(false);
|
||||
});
|
||||
|
||||
it('per-call cap rejects a hung call with no Stop signal', async () => {
|
||||
const client = new FakeClient();
|
||||
const hang: Tool = {
|
||||
execute: (async () => {
|
||||
await new Promise(() => undefined);
|
||||
}) as unknown as Tool['execute'],
|
||||
} as Tool;
|
||||
// Tiny cap; no options.abortSignal at all (composite = cap only).
|
||||
const wrapped = wrapInAppToolWithCap(hang, client, 20);
|
||||
const start = Date.now();
|
||||
await expect(
|
||||
(wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>)(
|
||||
{},
|
||||
opts(undefined),
|
||||
),
|
||||
).rejects.toThrow(/per-call cap/);
|
||||
expect(Date.now() - start).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('publishes a composite signal on the client for the duration of the call', async () => {
|
||||
const client = new FakeClient();
|
||||
let seenDuringCall: AbortSignal | null = null;
|
||||
const probe: Tool = {
|
||||
execute: (async () => {
|
||||
seenDuringCall = client.getToolAbortSignal();
|
||||
return 'ok';
|
||||
}) as unknown as Tool['execute'],
|
||||
} as Tool;
|
||||
const wrapped = wrapInAppToolWithCap(probe, client, 10_000);
|
||||
const ac = new AbortController();
|
||||
await (
|
||||
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
|
||||
)({}, opts(ac.signal));
|
||||
expect(seenDuringCall).not.toBeNull();
|
||||
// The published composite must reflect the turn's Stop signal.
|
||||
ac.abort();
|
||||
expect((seenDuringCall as unknown as AbortSignal).aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('a completed call returns its raw result unchanged', async () => {
|
||||
const client = new FakeClient();
|
||||
const ok: Tool = {
|
||||
execute: (async () => ({ items: [1, 2, 3] })) as unknown as Tool['execute'],
|
||||
} as Tool;
|
||||
const wrapped = wrapInAppToolWithCap(ok, client, 10_000);
|
||||
const res = await (
|
||||
wrapped.execute as (a: unknown, o: ToolCallOptions) => Promise<unknown>
|
||||
)({}, opts(new AbortController().signal));
|
||||
expect(res).toEqual({ items: [1, 2, 3] });
|
||||
});
|
||||
|
||||
it('cap is env-tunable with a 2-minute default', () => {
|
||||
const prev = process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS;
|
||||
delete process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS;
|
||||
expect(inAppToolCallCapMs()).toBe(120_000);
|
||||
process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = '5000';
|
||||
expect(inAppToolCallCapMs()).toBe(5000);
|
||||
process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = 'not-a-number';
|
||||
expect(inAppToolCallCapMs()).toBe(120_000);
|
||||
if (prev === undefined) delete process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS;
|
||||
else process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS = prev;
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { tool, type Tool } from 'ai';
|
||||
import { tool, type Tool, type ToolCallOptions } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { TokenService } from '../../auth/services/token.service';
|
||||
@@ -59,6 +59,8 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
void client.getWorkspace();
|
||||
void client.getSpaces();
|
||||
void client.listPages(s, n, true);
|
||||
void client.getTree(s, s, n);
|
||||
void client.getPageContext(s);
|
||||
void client.listSidebarPages(s, s);
|
||||
void client.getOutline(s);
|
||||
void client.getPageJson(s);
|
||||
@@ -121,6 +123,23 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
void client.drawioGet(s, s, 'xml');
|
||||
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk');
|
||||
void client.drawioUpdate(s, s, s, s, 'elk');
|
||||
// --- draw.io high-level semantic tools (#425 stage 3) ---
|
||||
void client.drawioEditCells(s, s, [{ op: 'delete', cellId: s }], s);
|
||||
void client.drawioFromGraph(
|
||||
s,
|
||||
{ position: 'append', anchorNodeId: s },
|
||||
{ nodes: [{ id: s, label: s }] },
|
||||
'LR',
|
||||
s,
|
||||
'full',
|
||||
s,
|
||||
);
|
||||
void client.drawioFromMermaid(
|
||||
s,
|
||||
{ position: 'append', anchorNodeId: s },
|
||||
s,
|
||||
s,
|
||||
);
|
||||
// --- write (comment) ---
|
||||
void client.createComment(s, s, 'inline', s, s, s);
|
||||
void client.resolveComment(s, true);
|
||||
@@ -140,6 +159,129 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
* existing service-account `/mcp` path already calls loopback successfully, so
|
||||
* this works for single-workspace self-host.
|
||||
*/
|
||||
/**
|
||||
* #487: wall-clock cap for a SINGLE in-app tool call, env-tunable via
|
||||
* `AI_CHAT_INAPP_TOOL_CALL_CAP_MS`. Bounds a read tool that would otherwise
|
||||
* paginate for minutes and a content write whose collab commit hangs, and is the
|
||||
* per-call CAP half of the composite abort signal every in-app tool is wrapped
|
||||
* with (the other half is the turn's Stop signal). Default 2 minutes: generous
|
||||
* for a legitimate long read/write, tight enough that a stuck call cannot pin the
|
||||
* turn. The reconcile staleness floor (#487 commit 4) is derived as
|
||||
* max(2 x this cap, 15 min), so keep this well under that.
|
||||
*/
|
||||
export function inAppToolCallCapMs(): number {
|
||||
const raw = Number(process.env.AI_CHAT_INAPP_TOOL_CALL_CAP_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 120_000;
|
||||
}
|
||||
|
||||
/** #487: the composite signal's reason as an Error (informative thrown value). */
|
||||
function inAppAbortReason(signal: AbortSignal): Error {
|
||||
const r = signal.reason;
|
||||
return r instanceof Error
|
||||
? r
|
||||
: new Error(typeof r === 'string' ? r : 'In-app tool call aborted');
|
||||
}
|
||||
|
||||
/**
|
||||
* The client surface {@link wrapInAppToolWithCap} drives (#487). Both methods are
|
||||
* OPTIONAL: the real loopback DocmostClient implements them (so a Stop/cap reaches
|
||||
* its pagination / pre-commit safe-points), but a client that omits them still
|
||||
* gets the OUTER guarantee — the race rejects on abort regardless. This keeps the
|
||||
* wrapper decoupled from the exact client shape (unit-test doubles need not stub
|
||||
* the plumbing).
|
||||
*/
|
||||
export interface ToolAbortSignalSink {
|
||||
setToolAbortSignal?(signal: AbortSignal | null): void;
|
||||
getToolAbortSignal?(): AbortSignal | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: wrap an in-app tool so a Stop (the turn's `options.abortSignal`) OR the
|
||||
* per-call wall-clock cap REJECTS the call immediately, and so that SAME
|
||||
* composite signal reaches the client's pagination / pre-commit safe-points (via
|
||||
* `client.setToolAbortSignal`) — making a Stop stop the NEXT HTTP/WS call from
|
||||
* starting.
|
||||
*
|
||||
* Reuses the RACE pattern of `wrapToolWithCallTimeout` (mcp-clients.service.ts):
|
||||
* the call is raced against the composite signal, so on abort we reject in the
|
||||
* SAME tick and DISCARD the loser promise. Its network / collab teardown latency
|
||||
* therefore never blocks the turn — the supersede timeout W=10s (#487 commit 3)
|
||||
* relies on this abort->settle latency being milliseconds, not a socket teardown.
|
||||
* Awaiting the client's own signal-into-write path alone would NOT satisfy this
|
||||
* (the loser could still be tearing down a collab socket).
|
||||
*
|
||||
* The composite is SET on the client at entry and deliberately NOT restored on
|
||||
* unwind: after this wrapper rejects on abort, the ABANDONED loser promise keeps
|
||||
* running, and its safe-points read the client field — leaving the (aborted)
|
||||
* composite there is exactly what makes the loser's NEXT call throw and stop. The
|
||||
* next in-app tool call overwrites the field with its own fresh composite before
|
||||
* any of its safe-points run, so a stale settled signal never leaks forward.
|
||||
* SINGLE-WRITER by phase-1 assumption — see DocmostClientContext.toolAbortSignal
|
||||
* for the parallel-call caveat (#487).
|
||||
*
|
||||
* KNOWN LIMITATION (#487): a write tool that issues SEVERAL sequential collab
|
||||
* commits can be aborted BETWEEN commits, leaving a partially-applied operation.
|
||||
* Cancel guarantees "no NEW call starts", NOT "the write didn't land".
|
||||
*/
|
||||
export function wrapInAppToolWithCap(
|
||||
toolDef: Tool,
|
||||
client: ToolAbortSignalSink,
|
||||
capMs: number,
|
||||
): Tool {
|
||||
const original = toolDef.execute;
|
||||
if (typeof original !== 'function') return toolDef;
|
||||
const execute = async (args: unknown, options: ToolCallOptions) => {
|
||||
const capController = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
capController.abort(
|
||||
new Error(`In-app tool call exceeded the ${capMs}ms per-call cap`),
|
||||
);
|
||||
}, capMs);
|
||||
timer.unref?.();
|
||||
const composite = options?.abortSignal
|
||||
? AbortSignal.any([options.abortSignal, capController.signal])
|
||||
: capController.signal;
|
||||
// Reject the MOMENT the composite fires, independent of whether `original`
|
||||
// ever settles (a hung collab write / read would otherwise pin the turn). The
|
||||
// losing `original` is left pending; Promise.race attaches a rejection
|
||||
// handler to both inputs so a late rejection is never unhandled.
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
const fail = () => reject(inAppAbortReason(composite));
|
||||
if (composite.aborted) fail();
|
||||
else composite.addEventListener('abort', fail, { once: true });
|
||||
});
|
||||
// Publish the composite so the client's pagination / pre-commit safe-points
|
||||
// observe it (see the "not restored on unwind" rationale above). Guarded: a
|
||||
// client without the plumbing still gets the OUTER race guarantee below.
|
||||
client.setToolAbortSignal?.(composite);
|
||||
try {
|
||||
return await Promise.race([
|
||||
(original as (a: unknown, o: ToolCallOptions) => Promise<unknown>)(
|
||||
args,
|
||||
{ ...options, abortSignal: composite },
|
||||
),
|
||||
aborted,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
return { ...toolDef, execute } as unknown as Tool;
|
||||
}
|
||||
|
||||
/** #487: apply {@link wrapInAppToolWithCap} to every tool in a set. */
|
||||
export function wrapInAppToolsWithCap(
|
||||
tools: Record<string, Tool>,
|
||||
client: ToolAbortSignalSink,
|
||||
capMs: number,
|
||||
): Record<string, Tool> {
|
||||
const out: Record<string, Tool> = {};
|
||||
for (const [name, t] of Object.entries(tools)) {
|
||||
out[name] = wrapInAppToolWithCap(t, client, capMs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiChatToolsService {
|
||||
private readonly logger = new Logger(AiChatToolsService.name);
|
||||
@@ -167,7 +309,12 @@ export class AiChatToolsService {
|
||||
sessionId: string,
|
||||
workspaceId: string,
|
||||
aiChatId: string,
|
||||
): Promise<DocmostClientLike> {
|
||||
// #487: the returned client also carries the tool-cancellation plumbing
|
||||
// (setToolAbortSignal/getToolAbortSignal). These are host plumbing, NOT part
|
||||
// of the tool-execute surface (DocmostClientMethod), so they are surfaced here
|
||||
// as an intersection rather than by widening that Pick — keeping the
|
||||
// positional-call drift-guard (#446) scoped to the actual tool methods.
|
||||
): Promise<DocmostClientLike & ToolAbortSignalSink> {
|
||||
const apiUrl =
|
||||
process.env.MCP_DOCMOST_API_URL ||
|
||||
`http://127.0.0.1:${process.env.PORT || 3000}/api`;
|
||||
@@ -611,7 +758,15 @@ export class AiChatToolsService {
|
||||
// dependency and reuses the CASL enforcement already on `client`. When the
|
||||
// loaded package predates #417 (factory undefined) or the loader is mocked in
|
||||
// a unit test, signalling is a pure no-op and results are byte-identical.
|
||||
if (!createCommentSignalTracker) return tools;
|
||||
// #487: wrap every in-app tool with the race-on-abort + per-call cap guard so
|
||||
// a Stop / cap rejects immediately AND reaches the client's write/pagination
|
||||
// safe-points. Applied as the OUTERMOST wrapper (over the comment-signal
|
||||
// wrapper below) so the race governs the whole call. The client carries the
|
||||
// per-call composite signal via setToolAbortSignal.
|
||||
const capMs = inAppToolCallCapMs();
|
||||
if (!createCommentSignalTracker) {
|
||||
return wrapInAppToolsWithCap(tools, client, capMs);
|
||||
}
|
||||
|
||||
const tracker = createCommentSignalTracker({
|
||||
probe: async (pageId: string, sinceMs: number) => {
|
||||
@@ -640,7 +795,11 @@ export class AiChatToolsService {
|
||||
},
|
||||
});
|
||||
|
||||
return wrapToolsWithCommentSignal(tools, tracker);
|
||||
return wrapInAppToolsWithCap(
|
||||
wrapToolsWithCommentSignal(tools, tracker),
|
||||
client,
|
||||
capMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
|
||||
import { computeSrcRegistryStamp } from './docmost-client.loader';
|
||||
|
||||
@@ -30,10 +38,14 @@ function assertStaleGuard(
|
||||
}
|
||||
}
|
||||
|
||||
// Build a throwaway `<pkg>/build/index.js` + optional `<pkg>/src/tool-specs.ts`
|
||||
// layout so `computeSrcRegistryStamp(<pkg>/build/index.js)` resolves src the same
|
||||
// way the loader does (dirname(dirname(entry))/src/tool-specs.ts).
|
||||
function makeFakePackage(toolSpecsSource: string | null): {
|
||||
// Build a throwaway `<pkg>/build/index.js` + optional `<pkg>/src/` tree so
|
||||
// `computeSrcRegistryStamp(<pkg>/build/index.js)` resolves src the same way the
|
||||
// loader does (dirname(dirname(entry))/src). Since #486 the stamp hashes the WHOLE
|
||||
// src tree, so a fixture is a { relPath: content } map. A bare string is sugar for
|
||||
// a single `tool-specs.ts`; `null` means "no src tree" (the prod no-op path).
|
||||
function makeFakePackage(
|
||||
src: string | Record<string, string> | null,
|
||||
): {
|
||||
entry: string;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
@@ -42,10 +54,15 @@ function makeFakePackage(toolSpecsSource: string | null): {
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
const entry = join(buildDir, 'index.js');
|
||||
writeFileSync(entry, '// fake @docmost/mcp build entry\n', 'utf8');
|
||||
if (toolSpecsSource !== null) {
|
||||
if (src !== null) {
|
||||
const files =
|
||||
typeof src === 'string' ? { 'tool-specs.ts': src } : src;
|
||||
const srcDir = join(root, 'src');
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
writeFileSync(join(srcDir, 'tool-specs.ts'), toolSpecsSource, 'utf8');
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(srcDir, rel);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, content, 'utf8');
|
||||
}
|
||||
}
|
||||
return { entry, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
@@ -93,34 +110,109 @@ describe('computeSrcRegistryStamp (#447 stale-build guard)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// CROSS-IMPL EQUALITY (covers reviewer suggestion 2). The SAME fixed input and
|
||||
// #486 CORE (negative): an edit to a NON-tool-specs src file (client.ts) with a
|
||||
// rebuild NOT run must move the src stamp away from the built REGISTRY_STAMP, so
|
||||
// the loader's stale-check refuses. Under the old tool-specs.ts-only hash this
|
||||
// edit was invisible and a stale build/ served the old client.ts silently.
|
||||
it('a client.ts edit (no rebuild) moves the src stamp -> loader refuses (#486)', () => {
|
||||
// "Built" state: the package as it was compiled.
|
||||
const built = makeFakePackage({
|
||||
'tool-specs.ts': 'export const SPECS = 1;\n',
|
||||
'client.ts': "export const impl = 'v1';\n",
|
||||
});
|
||||
// "Dev edited src, forgot to rebuild": client.ts changed, tool-specs.ts not.
|
||||
const edited = makeFakePackage({
|
||||
'tool-specs.ts': 'export const SPECS = 1;\n',
|
||||
'client.ts': "export const impl = 'v2';\n",
|
||||
});
|
||||
try {
|
||||
const builtStamp = computeSrcRegistryStamp(built.entry);
|
||||
const editedStamp = computeSrcRegistryStamp(edited.entry);
|
||||
expect(builtStamp).not.toBeNull();
|
||||
expect(editedStamp).not.toBe(builtStamp);
|
||||
// build/ still carries builtStamp; src now hashes to editedStamp -> refuse.
|
||||
expect(() => assertStaleGuard(editedStamp, builtStamp as string)).toThrow(
|
||||
STALE_BUILD_MESSAGE,
|
||||
);
|
||||
} finally {
|
||||
built.cleanup();
|
||||
edited.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// *.generated.ts is excluded (the codegen's own output — a fixed-point cycle
|
||||
// otherwise): its presence/content must not move the stamp.
|
||||
it('excludes *.generated.ts from the stamp', () => {
|
||||
const without = makeFakePackage({ 'tool-specs.ts': 'x\n' });
|
||||
const withGen = makeFakePackage({
|
||||
'tool-specs.ts': 'x\n',
|
||||
'registry-stamp.generated.ts': 'export const REGISTRY_STAMP = "abc";\n',
|
||||
});
|
||||
try {
|
||||
expect(computeSrcRegistryStamp(withGen.entry)).toBe(
|
||||
computeSrcRegistryStamp(without.entry),
|
||||
);
|
||||
} finally {
|
||||
without.cleanup();
|
||||
withGen.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// CROSS-IMPL EQUALITY (covers reviewer suggestion 2). The SAME fixed tree and
|
||||
// EXPECTED hash are asserted in the mcp-side node test
|
||||
// (packages/mcp/test/unit/registry-stamp.test.mjs) against the codegen's
|
||||
// `computeRegistryStamp`. Asserting the SAME pair here against the loader's
|
||||
// `computeSrcRegistryStamp` proves both implementations normalize+hash
|
||||
// `computeSrcRegistryStamp` proves both implementations enumerate+normalize+hash
|
||||
// identically; a divergence in EITHER side reddens one of the two tests.
|
||||
it('matches the documented cross-impl hash for a fixed input', () => {
|
||||
const FIXED_INPUT = 'line1\r\nline2\n';
|
||||
const EXPECTED =
|
||||
'683376e290829b482c2655745caffa7a1dccfa10afaa62dac2b42dd6c68d0f83';
|
||||
const { entry, cleanup } = makeFakePackage(FIXED_INPUT);
|
||||
const CROSS_IMPL_TREE = {
|
||||
'tool-specs.ts': 'line1\r\nline2\n',
|
||||
'client/read.ts': 'export const R = 1;\n',
|
||||
'registry-stamp.generated.ts': 'export const REGISTRY_STAMP="ignored";\n',
|
||||
};
|
||||
const CROSS_IMPL_EXPECTED =
|
||||
'131c1b9e4e2f5a7d6cef91ca8df619822b442f52bc45ebd09474a4c1d6728616';
|
||||
|
||||
it('matches the documented cross-impl hash for a fixed tree', () => {
|
||||
const { entry, cleanup } = makeFakePackage(CROSS_IMPL_TREE);
|
||||
try {
|
||||
expect(computeSrcRegistryStamp(entry)).toBe(EXPECTED);
|
||||
expect(computeSrcRegistryStamp(entry)).toBe(CROSS_IMPL_EXPECTED);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('the documented EXPECTED is the normalize+sha256 of the fixed input', () => {
|
||||
// Proves EXPECTED is not a magic constant but the documented computation.
|
||||
const FIXED_INPUT = 'line1\r\nline2\n';
|
||||
const normalized = FIXED_INPUT.replace(/\r\n/g, '\n').replace(/\n$/, '');
|
||||
const expected = createHash('sha256')
|
||||
.update(normalized, 'utf8')
|
||||
.digest('hex');
|
||||
const { entry, cleanup } = makeFakePackage(FIXED_INPUT);
|
||||
it('the documented EXPECTED is the enumerate+normalize+sha256 of the tree', () => {
|
||||
// Proves EXPECTED is not a magic constant but the documented computation — a
|
||||
// local re-implementation of the loader's tree walk.
|
||||
const { entry, cleanup } = makeFakePackage(CROSS_IMPL_TREE);
|
||||
try {
|
||||
expect(computeSrcRegistryStamp(entry)).toBe(expected);
|
||||
const srcDir = join(dirname(dirname(entry)), 'src');
|
||||
const collect = (dir: string): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const e of readdirSync(dir)) {
|
||||
const f = join(dir, e);
|
||||
if (statSync(f).isDirectory()) out.push(...collect(f));
|
||||
else if (e.endsWith('.ts') && !e.endsWith('.generated.ts'))
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const files = collect(srcDir)
|
||||
.map((abs) => ({ rel: relative(srcDir, abs).split(sep).join('/'), abs }))
|
||||
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
||||
const h = createHash('sha256');
|
||||
for (const { rel, abs } of files) {
|
||||
const n = readFileSync(abs, 'utf8')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n$/, '');
|
||||
h.update(rel, 'utf8');
|
||||
h.update('\0', 'utf8');
|
||||
h.update(n, 'utf8');
|
||||
h.update('\0', 'utf8');
|
||||
}
|
||||
const localHash = h.digest('hex');
|
||||
expect(computeSrcRegistryStamp(entry)).toBe(localHash);
|
||||
expect(localHash).toBe(CROSS_IMPL_EXPECTED);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type { DocmostClient, SharedToolSpec } from '@docmost/mcp';
|
||||
|
||||
@@ -23,6 +23,8 @@ type DocmostClientMethod =
|
||||
| 'getWorkspace'
|
||||
| 'getSpaces'
|
||||
| 'listPages'
|
||||
| 'getTree'
|
||||
| 'getPageContext'
|
||||
| 'listSidebarPages'
|
||||
| 'getOutline'
|
||||
| 'getPageJson'
|
||||
@@ -69,6 +71,10 @@ type DocmostClientMethod =
|
||||
| 'drawioGet'
|
||||
| 'drawioCreate'
|
||||
| 'drawioUpdate'
|
||||
// --- draw.io high-level semantic tools (#425 stage 3) ---
|
||||
| 'drawioEditCells'
|
||||
| 'drawioFromGraph'
|
||||
| 'drawioFromMermaid'
|
||||
// --- write (comment) ---
|
||||
| 'createComment'
|
||||
| 'resolveComment';
|
||||
@@ -185,33 +191,52 @@ interface DocmostMcpModule {
|
||||
* present. Returns the stamp string, or `null` when the source is absent (a prod
|
||||
* image ships only build/, no src/). MUST stay byte-for-byte identical to
|
||||
* packages/mcp/scripts/gen-registry-stamp.mjs's `computeRegistryStamp` so the
|
||||
* build-time and src-time hashes agree: same input file (src/tool-specs.ts), same
|
||||
* normalization (CRLF -> LF, strip a single trailing newline), same sha256.
|
||||
* build-time and src-time hashes agree: same file set (every src/**\/*.ts except
|
||||
* *.generated.ts), same POSIX-relative sort, same per-file normalization (CRLF ->
|
||||
* LF, strip a single trailing newline) with the same path+content framing, same
|
||||
* sha256. Hashing the WHOLE src tree (not just tool-specs.ts) is #486: an edit to
|
||||
* client.ts / a client/* module / comment-signal / drawio-* without a rebuild
|
||||
* must also be caught, otherwise build/ silently serves the old code.
|
||||
*
|
||||
* DEV vs PROD detection is by FILE EXISTENCE, not NODE_ENV: we resolve the
|
||||
* package's own directory from `require.resolve('@docmost/mcp')` (which points at
|
||||
* build/index.js) and look for ../src/tool-specs.ts next to it. In a dev/test
|
||||
* worktree that file exists; in a prod image (build/ only, src/ stripped) it does
|
||||
* not, so this returns null and the caller skips the check. Any error (ENOENT, a
|
||||
* bad resolve) is swallowed to null — the stale-check must NEVER break startup.
|
||||
* build/index.js) and look for ../src next to it. In a dev/test worktree that
|
||||
* directory exists; in a prod image (build/ only, src/ stripped) it does not, so
|
||||
* this returns null and the caller skips the check. Any error (ENOENT, a bad
|
||||
* resolve) is swallowed to null — the stale-check must NEVER break startup.
|
||||
*
|
||||
* Exported for unit testing (docmost-client.loader.spec.ts): the export keyword
|
||||
* is behaviourally a no-op — the module-internal caller `loadDocmostMcp` is
|
||||
* unaffected. The test drives the null (no-src) path and asserts this
|
||||
* normalize+sha256 stays identical to the codegen's `computeRegistryStamp`.
|
||||
* enumerate+normalize+sha256 stays identical to the codegen's
|
||||
* `computeRegistryStamp`.
|
||||
*/
|
||||
export function computeSrcRegistryStamp(packageEntry: string): string | null {
|
||||
try {
|
||||
// packageEntry is <pkg>/build/index.js; the source lives at <pkg>/src/.
|
||||
const toolSpecsPath = join(
|
||||
dirname(dirname(packageEntry)),
|
||||
'src',
|
||||
'tool-specs.ts',
|
||||
);
|
||||
if (!existsSync(toolSpecsPath)) return null; // prod: no src tree -> skip.
|
||||
const source = readFileSync(toolSpecsPath, 'utf8');
|
||||
const normalized = source.replace(/\r\n/g, '\n').replace(/\n$/, '');
|
||||
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
||||
const srcDir = join(dirname(dirname(packageEntry)), 'src');
|
||||
if (!existsSync(srcDir)) return null; // prod: no src tree -> skip.
|
||||
// Enumerate every src/**\/*.ts except the codegen's own *.generated.ts
|
||||
// output (including it would be a fixed-point cycle). Sort by POSIX-relative
|
||||
// path so ordering is platform-independent, then fold each file's relative
|
||||
// path + normalized content into one hash — identical to the codegen.
|
||||
const files = collectStampFiles(srcDir)
|
||||
.map((abs) => ({
|
||||
rel: relative(srcDir, abs).split(sep).join('/'),
|
||||
abs,
|
||||
}))
|
||||
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
||||
const hash = createHash('sha256');
|
||||
for (const { rel, abs } of files) {
|
||||
const normalized = readFileSync(abs, 'utf8')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n$/, '');
|
||||
hash.update(rel, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(normalized, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
} catch {
|
||||
// Never let a resolution/read hiccup break server startup — treat as "no
|
||||
// src available" and skip the check (identical to the prod no-op path).
|
||||
@@ -219,6 +244,24 @@ export function computeSrcRegistryStamp(packageEntry: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively enumerate every `*.ts` under `dir`, EXCLUDING `*.generated.ts`.
|
||||
* Mirror of the codegen's `collectStampFiles` (packages/mcp/scripts/
|
||||
* gen-registry-stamp.mjs) — keep the two walk/filter rules identical.
|
||||
*/
|
||||
function collectStampFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...collectStampFiles(full));
|
||||
} else if (entry.endsWith('.ts') && !entry.endsWith('.generated.ts')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// TS with module:commonjs downlevels a literal `import()` to `require()`, which
|
||||
// cannot load the ESM-only `@docmost/mcp` package. Indirect through Function so
|
||||
// the real dynamic `import()` survives compilation and can load ESM from
|
||||
|
||||
@@ -7,6 +7,22 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { jsonToMarkdown } from '../../../collaboration/collaboration.util';
|
||||
import { modelFriendlyInput } from './model-friendly-input';
|
||||
|
||||
/**
|
||||
* A tool error whose message is DELIBERATELY safe to expose to an anonymous
|
||||
* share reader (and to the model, for self-correction). Every OTHER thrown error
|
||||
* is treated as internal and replaced with a generic string by `wrapToolErrors`,
|
||||
* so a raw exception message — an internal page title, a DB/stack fragment, a
|
||||
* driver detail — never rides the public UI stream (#394).
|
||||
*/
|
||||
export class ShareToolError extends Error {}
|
||||
|
||||
// The only two classified strings an anonymous reader may ever see from a tool
|
||||
// failure. The specific one keeps the model's self-correction useful ("try a
|
||||
// different page"); the generic one reveals nothing about the internal fault.
|
||||
const SHARE_TOOL_ERROR_NOT_AVAILABLE =
|
||||
'The requested page is not available in this share.';
|
||||
const SHARE_TOOL_ERROR_GENERIC = 'The tool could not complete the request.';
|
||||
|
||||
/**
|
||||
* Isolated, READ-ONLY toolset for the ANONYMOUS public-share assistant.
|
||||
*
|
||||
@@ -44,7 +60,7 @@ export class PublicShareChatToolsService {
|
||||
* are NO write tools, NO comments/history, NO cross-space or external tools.
|
||||
*/
|
||||
forShare(shareId: string, workspaceId: string): Record<string, Tool> {
|
||||
return {
|
||||
return this.wrapToolErrors({
|
||||
searchSharePages: tool({
|
||||
description:
|
||||
'Search the pages of THIS published documentation share for a ' +
|
||||
@@ -96,7 +112,7 @@ export class PublicShareChatToolsService {
|
||||
execute: async ({ pageId }) => {
|
||||
const id = (pageId ?? '').trim();
|
||||
if (!id) {
|
||||
throw new Error('A pageId is required.');
|
||||
throw new ShareToolError('A pageId is required.');
|
||||
}
|
||||
// Resolve via the SINGLE canonical share-access boundary: confirms the
|
||||
// page resolves to THIS share (recursive CTE up the tree, honouring
|
||||
@@ -112,7 +128,7 @@ export class PublicShareChatToolsService {
|
||||
workspaceId,
|
||||
);
|
||||
if (!resolved) {
|
||||
throw new Error('That page is not part of this published share.');
|
||||
throw new ShareToolError(SHARE_TOOL_ERROR_NOT_AVAILABLE);
|
||||
}
|
||||
const { page } = resolved;
|
||||
|
||||
@@ -193,6 +209,57 @@ export class PublicShareChatToolsService {
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap every tool's `execute` so a THROWN error is sanitized in ONE place —
|
||||
* closing the byte leak, the render, and the model context at once (#394).
|
||||
*
|
||||
* The AI SDK surfaces a tool-execution throw as an atomic `tool-output-error`
|
||||
* frame on the v6 UI stream whose `errorText` is the thrown message; on the
|
||||
* public share that frame goes straight to an anonymous reader. Unwrapped, a
|
||||
* raw exception (an internal page title, a DB/stack fragment, a driver detail)
|
||||
* would ride that frame verbatim. Here we catch it, LOG the full detail
|
||||
* server-side only, and re-throw a CLASSIFIED, safe error: the tool's own
|
||||
* intentional ShareToolError messages pass through (they keep the model's
|
||||
* self-correction useful), everything else collapses to a generic string.
|
||||
*/
|
||||
private wrapToolErrors(
|
||||
tools: Record<string, Tool>,
|
||||
): Record<string, Tool> {
|
||||
const wrapped: Record<string, Tool> = {};
|
||||
for (const [name, t] of Object.entries(tools)) {
|
||||
const original = t.execute;
|
||||
if (typeof original !== 'function') {
|
||||
wrapped[name] = t;
|
||||
continue;
|
||||
}
|
||||
wrapped[name] = {
|
||||
...t,
|
||||
execute: async (args: unknown, options: unknown) => {
|
||||
try {
|
||||
return await (
|
||||
original as (a: unknown, o: unknown) => Promise<unknown>
|
||||
)(args, options);
|
||||
} catch (err) {
|
||||
const safe =
|
||||
err instanceof ShareToolError
|
||||
? err.message
|
||||
: SHARE_TOOL_ERROR_GENERIC;
|
||||
// Full detail to the server log ONLY — never to the anon.
|
||||
this.logger.warn(
|
||||
`Public share tool "${name}" failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
// This safe string is ALL that rides the tool-output-error frame,
|
||||
// becomes model context, and could be rendered — one choke point.
|
||||
throw new ShareToolError(safe);
|
||||
}
|
||||
},
|
||||
} as Tool;
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ import type { DocmostClientLike } from './docmost-client.loader';
|
||||
*/
|
||||
|
||||
describe('tool tier metadata (#332)', () => {
|
||||
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => {
|
||||
expect(CORE_TOOL_KEYS).toHaveLength(15);
|
||||
it('core set is the documented 13 + searchInPage + insertFootnote + getTree + getPageContext (17, #443)', () => {
|
||||
expect(CORE_TOOL_KEYS).toHaveLength(17);
|
||||
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
|
||||
expect(CORE_TOOL_SET.has('getTree')).toBe(true); // #443, promoted to core
|
||||
expect(CORE_TOOL_SET.has('getPageContext')).toBe(true); // #443, promoted to core
|
||||
// loadTools is a meta-tool, not a normal core key.
|
||||
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,12 +39,14 @@ export interface ToolCatalogEntry {
|
||||
|
||||
/**
|
||||
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage`
|
||||
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent
|
||||
* for the editorial roles this feature targets; `insertFootnote` is core so the
|
||||
* footnote tool is NOT hidden while its natural sibling `editPageText` is always
|
||||
* active (that asymmetry is exactly what pushed the agent to write literal
|
||||
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added
|
||||
* to activeTools separately).
|
||||
* (#330) + `insertFootnote` (#410) + `getTree`/`getPageContext` (#443).
|
||||
* `searchInPage` is core because it is frequent for the editorial roles this
|
||||
* feature targets; `insertFootnote` is core so the footnote tool is NOT hidden
|
||||
* while its natural sibling `editPageText` is always active (that asymmetry is
|
||||
* exactly what pushed the agent to write literal `^[...]`). `getTree` and
|
||||
* `getPageContext` are the single-call navigation/lookup tools — core so the
|
||||
* agent never has to loadTools just to orient itself. `loadTools` is active too
|
||||
* but is not a normal tool key (it is added to activeTools separately).
|
||||
*/
|
||||
export const CORE_TOOL_KEYS = [
|
||||
'searchPages',
|
||||
@@ -66,6 +68,11 @@ export const CORE_TOOL_KEYS = [
|
||||
// #410 insertFootnote — core so pinpoint citations to already-written text
|
||||
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
||||
'insertFootnote',
|
||||
// #443 getTree + getPageContext — cheap single-call navigation/lookup tools
|
||||
// (the core listPages even points to getTree); core so the agent never has
|
||||
// to loadTools just to orient itself.
|
||||
'getTree',
|
||||
'getPageContext',
|
||||
] as const;
|
||||
|
||||
/** O(1) membership test for the core tier. */
|
||||
|
||||
@@ -120,3 +120,102 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
expect(req.raw.actor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486).
|
||||
*
|
||||
* The access-token path stamped provenance; the API-key path returned early
|
||||
* WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker.
|
||||
* The API-key payload carries no signed claim, so provenance is resolved from the
|
||||
* SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent',
|
||||
* otherwise 'user'; aiChatId is always null (an API key has no ai_chats row).
|
||||
*
|
||||
* The enterprise ApiKeyService is not bundled in the OSS build, so the strategy
|
||||
* loads it through an overridable `resolveApiKeyService` seam that we stub here.
|
||||
*/
|
||||
describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise<any>) {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const workspaceRepo: any = { findById: jest.fn() };
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
workspaceRepo,
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
);
|
||||
// Stub the EE ApiKeyService seam (the real module is not in the OSS build).
|
||||
const validateApiKey = jest.fn(validateApiKeyImpl);
|
||||
jest
|
||||
.spyOn(strategy as any, 'resolveApiKeyService')
|
||||
.mockReturnValue({ validateApiKey });
|
||||
return { strategy, validateApiKey };
|
||||
}
|
||||
|
||||
const makeReq = () => ({ raw: {} as Record<string, any> });
|
||||
const apiKeyPayload = () => ({
|
||||
sub: 'svc-1',
|
||||
workspaceId: 'ws-1',
|
||||
apiKeyId: 'key-1',
|
||||
type: JwtType.API_KEY,
|
||||
});
|
||||
|
||||
it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => {
|
||||
const validated = {
|
||||
user: { id: 'svc-1', isAgent: true },
|
||||
workspace: { id: 'ws-1' },
|
||||
};
|
||||
const { strategy, validateApiKey } = makeApiKeyStrategy(
|
||||
async () => validated,
|
||||
);
|
||||
const req = makeReq();
|
||||
|
||||
const result = await strategy.validate(req, apiKeyPayload() as any);
|
||||
|
||||
expect(validateApiKey).toHaveBeenCalledTimes(1);
|
||||
expect(req.raw.actor).toBe('agent');
|
||||
// API keys carry no internal ai_chats row -> null.
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
// The validated auth object is returned unchanged (req.user shape preserved).
|
||||
expect(result).toBe(validated);
|
||||
});
|
||||
|
||||
it("stamps actor='user' for an ordinary (non-agent) API key", async () => {
|
||||
const { strategy } = makeApiKeyStrategy(async () => ({
|
||||
user: { id: 'u-1', isAgent: false },
|
||||
workspace: { id: 'ws-1' },
|
||||
}));
|
||||
const req = makeReq();
|
||||
|
||||
await strategy.validate(req, apiKeyPayload() as any);
|
||||
|
||||
expect(req.raw.actor).toBe('user');
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
{ findById: jest.fn() } as any,
|
||||
{ findActiveById: jest.fn() } as any,
|
||||
{ trackActivity: jest.fn() } as any,
|
||||
{ getAppSecret: () => 'test-secret' } as any,
|
||||
{} as any,
|
||||
);
|
||||
// EE not bundled: the seam returns null.
|
||||
jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null);
|
||||
const req = makeReq();
|
||||
|
||||
await expect(
|
||||
strategy.validate(req, apiKeyPayload() as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(req.raw.actor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,28 +102,49 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
let ApiKeyModule: any;
|
||||
let isApiKeyModuleReady = false;
|
||||
const apiKeyService = this.resolveApiKeyService();
|
||||
if (!apiKeyService) {
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
|
||||
const result = await apiKeyService.validateApiKey(payload);
|
||||
|
||||
// Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the
|
||||
// access-token path above, it CANNOT be resolved before this point: the
|
||||
// API-key payload carries no signed actor/aiChatId claim, and the user (with
|
||||
// its isAgent flag) is unknown until the key is validated. Claim semantics for
|
||||
// API keys: an is_agent API key (an agent service account) stamps 'agent' on
|
||||
// every REST write; an ordinary API key resolves to 'user'. An API key has no
|
||||
// internal ai_chats row, so aiChatId is always null. Derived from the
|
||||
// SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable
|
||||
// — mirroring the access-token path. Passing `null` for the claim means the
|
||||
// actor is decided solely by user.isAgent.
|
||||
const provenance = resolveProvenance((result as any)?.user, null);
|
||||
req.raw.actor = provenance.actor;
|
||||
req.raw.aiChatId = provenance.aiChatId;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the enterprise ApiKeyService, or `null` when the EE module is not
|
||||
* bundled in this build (community build). Extracted as an overridable seam so
|
||||
* the API-key provenance stamping can be unit-tested without the EE package
|
||||
* present (docmost is OSS + a separate EE bundle; `require` of the EE path
|
||||
* throws here). Any load/resolve error is treated as "module missing".
|
||||
*/
|
||||
protected resolveApiKeyService(): {
|
||||
validateApiKey: (payload: JwtApiKeyPayload) => Promise<unknown>;
|
||||
} | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
isApiKeyModuleReady = true;
|
||||
const ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'API Key module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
isApiKeyModuleReady = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isApiKeyModuleReady) {
|
||||
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
return ApiKeyService.validateApiKey(payload);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,22 @@ export class SearchResponseDto {
|
||||
updatedAt: Date;
|
||||
space: Partial<Space>;
|
||||
}
|
||||
|
||||
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
|
||||
// Additive to the FTS response: carries the location (`path`), a windowed
|
||||
// `snippet` around the first match and a per-response sort `score`. The MCP
|
||||
// layer maps `id → pageId`; `slugId` is never exposed.
|
||||
export class SearchLookupResponseDto {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
parentPageId: string | null;
|
||||
// Ancestor titles from the space root down to the direct parent; [] for a
|
||||
// root page.
|
||||
path: string[];
|
||||
// ~300–500 chars around the first match (or a leading text window / extended
|
||||
// ts_headline fallback).
|
||||
snippet: string;
|
||||
// 0..1 float, meaningful ONLY for sorting within one response.
|
||||
score: number;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,31 @@ export class SearchDTO {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
offset?: number;
|
||||
|
||||
// --- Opt-in agent-lookup mode (#443). ------------------------------------
|
||||
// These fields are ADDITIVE and default-off: a web client that sends none of
|
||||
// them gets byte-identical FTS behaviour and result shape. They are only read
|
||||
// by the substring/path/snippet code path in SearchService.searchPage.
|
||||
//
|
||||
// NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO
|
||||
// with `whitelist: true`, so an older server silently strips these unknown
|
||||
// fields and the request degrades gracefully to the plain FTS behaviour.
|
||||
|
||||
// Enables the hybrid substring branch (title + text_content LIKE) merged with
|
||||
// the existing FTS branch, plus tiered ranking, path and windowed snippet.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
substring?: boolean;
|
||||
|
||||
// Restrict the search to a page and all of its descendants (inclusive).
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentPageId?: string;
|
||||
|
||||
// Match titles only; do not scan text_content.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
titleOnly?: boolean;
|
||||
}
|
||||
|
||||
export class SearchShareDTO extends SearchDTO {
|
||||
|
||||
@@ -60,6 +60,12 @@ export class SearchController {
|
||||
}
|
||||
}
|
||||
|
||||
// #443 graceful degradation: on EE/Typesense instances the request routes to
|
||||
// the Typesense backend, which does NOT implement the opt-in agent-lookup
|
||||
// mode. The `substring`/`parentPageId`/`titleOnly` fields are silently ignored
|
||||
// and the response carries no `path`/`snippet`/`score` and no substring/tier
|
||||
// ranking — it degrades to plain Typesense FTS. The native lookup mode below
|
||||
// is Postgres-search-driver only.
|
||||
if (this.environmentService.getSearchDriver() === 'typesense') {
|
||||
return this.searchTypesense(searchDto, {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
computeLookupScore,
|
||||
escapeLikePattern,
|
||||
SearchLookupTier,
|
||||
} from './search.service';
|
||||
|
||||
/**
|
||||
* Pure-function coverage for the #443 agent-lookup helpers:
|
||||
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
|
||||
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
|
||||
* everything);
|
||||
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
|
||||
* always outranks a weaker one regardless of the in-tier secondary signal.
|
||||
*
|
||||
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
|
||||
* covered by the integration spec against the real schema.
|
||||
*/
|
||||
describe('escapeLikePattern', () => {
|
||||
it('escapes the LIKE metacharacters % _ and \\', () => {
|
||||
expect(escapeLikePattern('%')).toBe('\\%');
|
||||
expect(escapeLikePattern('_')).toBe('\\_');
|
||||
expect(escapeLikePattern('\\')).toBe('\\\\');
|
||||
});
|
||||
|
||||
it('escapes the backslash FIRST so it does not double-escape %/_', () => {
|
||||
// Input `\%` must become `\\` + `\%` = `\\\%`, not `\\%`.
|
||||
expect(escapeLikePattern('\\%')).toBe('\\\\\\%');
|
||||
});
|
||||
|
||||
it('leaves ordinary technical chars (. - / digits) untouched', () => {
|
||||
expect(escapeLikePattern('backup-srv.local')).toBe('backup-srv.local');
|
||||
expect(escapeLikePattern('10.0.12')).toBe('10.0.12');
|
||||
expect(escapeLikePattern('WB-MGE-30D86B')).toBe('WB-MGE-30D86B');
|
||||
expect(escapeLikePattern('a/b')).toBe('a/b');
|
||||
});
|
||||
|
||||
it('escapes only the metacharacters in a mixed string', () => {
|
||||
expect(escapeLikePattern('50%_off.zip')).toBe('50\\%\\_off.zip');
|
||||
});
|
||||
|
||||
it('is null/undefined-safe', () => {
|
||||
expect(escapeLikePattern(undefined as any)).toBe('');
|
||||
expect(escapeLikePattern(null as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLookupScore', () => {
|
||||
it('keeps every score within (0, 1]', () => {
|
||||
for (const tier of [
|
||||
SearchLookupTier.TITLE_EXACT,
|
||||
SearchLookupTier.TITLE_SUBSTRING,
|
||||
SearchLookupTier.TEXT,
|
||||
]) {
|
||||
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
|
||||
const s = computeLookupScore({ tier, secondary });
|
||||
expect(s).toBeGreaterThan(0);
|
||||
expect(s).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
|
||||
// Weak tier with a huge secondary must still lose to a strong tier with a
|
||||
// tiny secondary — tiers dominate.
|
||||
const strongLowSecondary = computeLookupScore({
|
||||
tier: SearchLookupTier.TITLE_EXACT,
|
||||
secondary: 0,
|
||||
});
|
||||
const weakHighSecondary = computeLookupScore({
|
||||
tier: SearchLookupTier.TEXT,
|
||||
secondary: 1e9,
|
||||
});
|
||||
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
|
||||
});
|
||||
|
||||
it('within a tier a larger secondary sorts higher', () => {
|
||||
const lo = computeLookupScore({
|
||||
tier: SearchLookupTier.TEXT,
|
||||
secondary: 0.1,
|
||||
});
|
||||
const hi = computeLookupScore({
|
||||
tier: SearchLookupTier.TEXT,
|
||||
secondary: 5,
|
||||
});
|
||||
expect(hi).toBeGreaterThan(lo);
|
||||
});
|
||||
|
||||
it('treats a negative/absent secondary as 0', () => {
|
||||
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
|
||||
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
|
||||
expect(
|
||||
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
|
||||
).toBe(zero);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
||||
import { SearchResponseDto } from './dto/search-response.dto';
|
||||
import {
|
||||
SearchLookupResponseDto,
|
||||
SearchResponseDto,
|
||||
} from './dto/search-response.dto';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { sql } from 'kysely';
|
||||
@@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string {
|
||||
return tsquery(cleaned + '*');
|
||||
}
|
||||
|
||||
// Escape the LIKE metacharacters (`%`, `_`, `\`) in a raw user query so every
|
||||
// character — including `.`, `-`, `_`, `%`, `/` — is matched LITERALLY by a
|
||||
// `col LIKE '%' || q || '%'` predicate. Without this, a query of `%` or `_`
|
||||
// would match every row (see the #443 acceptance table). The backslash is the
|
||||
// escape char (Postgres LIKE default), so it must be escaped first.
|
||||
export function escapeLikePattern(raw: string): string {
|
||||
return (raw ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/%/g, '\\%')
|
||||
.replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
// Ranking tiers for the agent-lookup mode (#443), highest first. A hit's tier
|
||||
// is the strongest way it matched; ties inside a tier break on a secondary
|
||||
// signal (FTS rank, or first-match position). The numeric `score` returned to
|
||||
// the caller is derived from (tier, secondary) and is meaningful ONLY for
|
||||
// ordering within a single response.
|
||||
export enum SearchLookupTier {
|
||||
// Title equals the query, case-insensitively.
|
||||
TITLE_EXACT = 3,
|
||||
// Query is a substring of the title.
|
||||
TITLE_SUBSTRING = 2,
|
||||
// Query matched in the text (substring or FTS).
|
||||
TEXT = 1,
|
||||
}
|
||||
|
||||
export interface RankableHit {
|
||||
tier: SearchLookupTier;
|
||||
// Secondary in-tier signal, higher = better (e.g. ts_rank, or a
|
||||
// position-derived closeness score). Defaults to 0.
|
||||
secondary?: number;
|
||||
}
|
||||
|
||||
// Map (tier, secondary) → a 0..1 float used ONLY to sort one response.
|
||||
//
|
||||
// Formula: score = (tier + squash(secondary)) / (maxTier + 1), where
|
||||
// squash(x) = x / (1 + x) maps any non-negative secondary into [0, 1)
|
||||
// so a stronger tier ALWAYS outranks a weaker one regardless of the secondary
|
||||
// value, and within a tier a larger secondary sorts higher. maxTier is the top
|
||||
// enum value (TITLE_EXACT = 3), so the divisor keeps the result in (0, 1].
|
||||
export function computeLookupScore(hit: RankableHit): number {
|
||||
const maxTier = SearchLookupTier.TITLE_EXACT;
|
||||
const secondary = Math.max(0, hit.secondary ?? 0);
|
||||
const squashed = secondary / (1 + secondary);
|
||||
return (hit.tier + squashed) / (maxTier + 1);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@@ -50,12 +100,19 @@ export class SearchService {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<{ items: SearchResponseDto[] }> {
|
||||
): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> {
|
||||
const { query } = searchParams;
|
||||
|
||||
if (query.length < 1) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
// Opt-in agent-lookup mode (#443). Guarded by the `substring` flag so the
|
||||
// web-UI (which never sets it) keeps byte-identical FTS behaviour below.
|
||||
if (searchParams.substring) {
|
||||
return this.searchPageLookup(searchParams, opts);
|
||||
}
|
||||
|
||||
const searchQuery = buildTsQuery(query);
|
||||
|
||||
let queryResults = this.db
|
||||
@@ -175,6 +232,348 @@ export class SearchService {
|
||||
return { items: searchResults };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-lookup search (#443, opt-in via `SearchDTO.substring`).
|
||||
*
|
||||
* ADDITIVE to the FTS path: runs a substring branch (title + optionally
|
||||
* text_content, LIKE with metacharacters escaped) MERGED with the existing
|
||||
* FTS branch, so technical tokens that the `english` tokenizer mangles
|
||||
* (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are still found — even
|
||||
* when `buildTsQuery()` returns '' for a dotted/numeric query. Results carry a
|
||||
* location (`path`), a windowed `snippet` and a per-response `score`.
|
||||
*
|
||||
* The whole method is only reached when `substring: true`; the web-UI never
|
||||
* sets it, so its behaviour is unchanged.
|
||||
*/
|
||||
private async searchPageLookup(
|
||||
searchParams: SearchDTO,
|
||||
opts: { userId?: string; workspaceId: string },
|
||||
): Promise<{ items: SearchLookupResponseDto[] }> {
|
||||
const rawQuery = searchParams.query.trim();
|
||||
if (!rawQuery) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(searchParams.limit || 10, 1), 50);
|
||||
|
||||
// Normalize the query the same way as the FTS / suggest path: f_unaccent +
|
||||
// lower, done in SQL. `q` is the escaped LIKE pattern body (literal chars).
|
||||
const likeBody = escapeLikePattern(rawQuery);
|
||||
// Compare against `LOWER(f_unaccent(col))`; unaccent+lower the needle too.
|
||||
const needle = sql<string>`LOWER(f_unaccent(${rawQuery}))`;
|
||||
const likePattern = sql<string>`LOWER(f_unaccent(${'%' + likeBody + '%'}))`;
|
||||
const tsQuery = buildTsQuery(rawQuery);
|
||||
const hasTsQuery = tsQuery.length > 0;
|
||||
|
||||
// --- Resolve the space scope. ---------------------------------------------
|
||||
// Mirrors searchPage: explicit spaceId, else the authenticated user's member
|
||||
// spaces. The share path is not exposed to this opt-in mode.
|
||||
let spaceIds: string[] = [];
|
||||
if (searchParams.spaceId) {
|
||||
spaceIds = [searchParams.spaceId];
|
||||
} else if (opts.userId) {
|
||||
spaceIds = await this.spaceMemberRepo.getUserSpaceIds(opts.userId);
|
||||
} else {
|
||||
return { items: [] };
|
||||
}
|
||||
if (spaceIds.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
// --- Optional parentPageId subtree scope (inclusive). ---------------------
|
||||
// Reuse the same recursive-descendants pattern used for share-scope.
|
||||
let descendantIds: string[] | null = null;
|
||||
if (searchParams.parentPageId) {
|
||||
const descendants = await this.pageRepo.getPageAndDescendants(
|
||||
searchParams.parentPageId,
|
||||
{ includeContent: false },
|
||||
);
|
||||
descendantIds = descendants.map((p: any) => p.id);
|
||||
if (descendantIds.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Candidate query: substring (title + text) UNION FTS. -----------------
|
||||
// We compute everything the ranker needs in SQL and pull only small columns
|
||||
// (never the whole text_content) into Node:
|
||||
// - titleExact / titleSub: tier signals
|
||||
// - textMatchPos: 1-based position of the first text match (0 = none)
|
||||
// - ftsRank: ts_rank for the FTS secondary signal (0 when no tsquery)
|
||||
// - snippet: windowed ~500 chars around the first text match, or a leading
|
||||
// text window (title-only hit), or an extended ts_headline fallback.
|
||||
const N_BEFORE = 60; // chars of context before the first match
|
||||
const SNIPPET_LEN = 500;
|
||||
|
||||
let candidates = this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as id',
|
||||
'pages.slugId as slugId',
|
||||
'pages.title as title',
|
||||
'pages.parentPageId as parentPageId',
|
||||
// Tier signals.
|
||||
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}`.as(
|
||||
'titleExact',
|
||||
),
|
||||
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\'`.as(
|
||||
'titleSub',
|
||||
),
|
||||
// 1-based position of the first text match (0 = no text match).
|
||||
sql<number>`strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle})`.as(
|
||||
'textMatchPos',
|
||||
),
|
||||
// FTS secondary signal (0 when the tsquery is empty).
|
||||
hasTsQuery
|
||||
? sql<number>`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`.as(
|
||||
'ftsRank',
|
||||
)
|
||||
: sql<number>`0`.as('ftsRank'),
|
||||
// Windowed snippet, computed entirely in SQL. Priority:
|
||||
// 1. window around the first text match;
|
||||
// 2. otherwise (titleOnly: no snippet; else) a leading window of the
|
||||
// page text (title-only hit);
|
||||
// 3. otherwise an extended ts_headline for pure-FTS hits.
|
||||
//
|
||||
// #443 snippet-position fix: the match position (`strpos`) is computed in
|
||||
// the LOWER(f_unaccent(...)) space, but f_unaccent is NOT length-
|
||||
// preserving (ß→ss, æ→ae, …→..., ½→ 1/2, full-width forms), so slicing
|
||||
// the ORIGINAL text at that position was misaligned — a single expanding
|
||||
// char before the match shifted the window (or ran it past end → empty).
|
||||
// We now slice from the SAME LOWER(f_unaccent(...)) string so position
|
||||
// and slice share one coordinate space. DELIBERATE trade-off: the snippet
|
||||
// loses original case/diacritics — acceptable for an agent-facing snippet
|
||||
// (position accuracy over original-glyph fidelity). The ts_headline branch
|
||||
// matches over the ORIGINAL text itself, so it is unaffected and kept as-is.
|
||||
searchParams.titleOnly
|
||||
? sql<string>`''`.as('snippet')
|
||||
: sql<string>`
|
||||
coalesce(
|
||||
case
|
||||
when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) > 0
|
||||
then substring(
|
||||
LOWER(f_unaccent(coalesce(pages.text_content, '')))
|
||||
from greatest(1, strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) - ${N_BEFORE})
|
||||
for ${SNIPPET_LEN}
|
||||
)
|
||||
when coalesce(pages.text_content, '') <> ''
|
||||
then substring(LOWER(f_unaccent(pages.text_content)) from 1 for 300)
|
||||
${
|
||||
hasTsQuery
|
||||
? sql`else ts_headline('english', coalesce(pages.text_content, ''), to_tsquery('english', f_unaccent(${tsQuery})), 'MinWords=25, MaxWords=40, MaxFragments=3')`
|
||||
: sql``
|
||||
}
|
||||
end,
|
||||
''
|
||||
)
|
||||
`.as('snippet'),
|
||||
])
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', spaceIds);
|
||||
|
||||
if (descendantIds) {
|
||||
candidates = candidates.where('pages.id', 'in', descendantIds);
|
||||
}
|
||||
|
||||
// Match predicate: title substring OR (unless titleOnly) text substring OR
|
||||
// (unless titleOnly) FTS. The substring branch runs even when the tsquery is
|
||||
// empty — that is the dotted/numeric-token case the FTS path misses.
|
||||
//
|
||||
// #443 dead-index fix: these two LIKE predicates MUST match the GIN trgm
|
||||
// index expressions EXACTLY for Postgres to use them. The indexes are on the
|
||||
// coalesce-FREE expressions `LOWER(f_unaccent(title))` (#348's
|
||||
// idx_pages_title_trgm) and `LOWER(f_unaccent(text_content))` (this PR's
|
||||
// idx_pages_text_content_trgm). A `coalesce(col,'')` wrapper here would make
|
||||
// the query expression differ from the index expression and force a Seq Scan
|
||||
// on pages for every lookup. Dropping coalesce is SEMANTICALLY EQUIVALENT:
|
||||
// `NULL LIKE '%q%'` is NULL (falsy), so a NULL title/text simply doesn't
|
||||
// match — exactly as an empty string wouldn't match `%q%`.
|
||||
candidates = candidates.where((eb) => {
|
||||
const ors = [
|
||||
eb(
|
||||
sql`LOWER(f_unaccent(pages.title))`,
|
||||
'like',
|
||||
sql`${likePattern} ESCAPE '\\'`,
|
||||
),
|
||||
];
|
||||
if (!searchParams.titleOnly) {
|
||||
ors.push(
|
||||
eb(
|
||||
sql`LOWER(f_unaccent(pages.text_content))`,
|
||||
'like',
|
||||
sql`${likePattern} ESCAPE '\\'`,
|
||||
),
|
||||
);
|
||||
if (hasTsQuery) {
|
||||
ors.push(
|
||||
sql<boolean>`pages.tsv @@ to_tsquery('english', f_unaccent(${tsQuery}))` as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
return eb.or(ors);
|
||||
});
|
||||
|
||||
// Pull a generous candidate set (before permission filtering + limit).
|
||||
// Cap it so a pathological match set cannot blow up memory; 200 >> limit
|
||||
// (max 50) leaves ample headroom for the post-permission truncation.
|
||||
//
|
||||
// #443 cap-ordering fix: the 200-cap MUST be deterministic and relevance-
|
||||
// biased. Without an ORDER BY, Postgres returns an ARBITRARY 200 rows, so on
|
||||
// a broad match set (common word / short substring) a strong TITLE_EXACT hit
|
||||
// could be among the dropped rows while 200 low-tier TEXT hits fill the cap.
|
||||
// We order by the SAME SQL tier proxies the Node ranker uses — title-exact,
|
||||
// then title-substring, then fts-rank (nulls last), then earliest text-match
|
||||
// position — so the cap keeps the strongest candidates. The Node-side final
|
||||
// tier sort + slice(0, limit) below still runs and stays authoritative; this
|
||||
// ORDER BY only decides WHICH candidates survive the 200-cap.
|
||||
// NB: a BARE integer literal in ORDER BY is read by Postgres as an ordinal
|
||||
// column position (`ORDER BY 0` → "position 0 is not in select list"), so the
|
||||
// no-tsquery fallback is `0::float`, not `0`.
|
||||
const ftsRankExpr = hasTsQuery
|
||||
? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`
|
||||
: sql`0::float`;
|
||||
const candidatesCapped = candidates
|
||||
// Raw-SQL ORDER BY expressions: pass the full `<expr> <dir>` as ONE arg
|
||||
// (the two-arg form treats a raw-SQL second arg as an ORDER BY position).
|
||||
.orderBy(
|
||||
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}) desc`,
|
||||
)
|
||||
.orderBy(
|
||||
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\') desc`,
|
||||
)
|
||||
.orderBy(sql`${ftsRankExpr} desc nulls last`)
|
||||
// Earlier text match first; strpos returns 0 for "no match", which would
|
||||
// sort BEFORE a real (>=1) position under plain ASC, so push 0 to the end.
|
||||
.orderBy(
|
||||
sql`case when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) = 0 then 2147483647 else strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) end asc`,
|
||||
);
|
||||
|
||||
let rows: any[] = await candidatesCapped.limit(200).execute();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
// --- Permissions BEFORE limit. --------------------------------------------
|
||||
// Apply the existing page-level post-filter to the MERGED set, then rank and
|
||||
// only THEN truncate to `limit` — never lose the permission filter.
|
||||
if (opts.userId) {
|
||||
const accessibleIds =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: rows.map((r) => r.id),
|
||||
userId: opts.userId,
|
||||
spaceId: searchParams.spaceId,
|
||||
workspaceId: opts.workspaceId,
|
||||
});
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
rows = rows.filter((r) => accessibleSet.has(r.id));
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
// --- Tiered ranking + dedup. ----------------------------------------------
|
||||
// Rows are already unique by id (single pages scan), so no cross-branch
|
||||
// dedup is needed here; the tier captures the strongest match reason.
|
||||
const ranked = rows.map((r) => {
|
||||
let tier: SearchLookupTier;
|
||||
let secondary: number;
|
||||
if (r.titleExact) {
|
||||
tier = SearchLookupTier.TITLE_EXACT;
|
||||
secondary = Number(r.ftsRank) || 0;
|
||||
} else if (r.titleSub) {
|
||||
tier = SearchLookupTier.TITLE_SUBSTRING;
|
||||
secondary = Number(r.ftsRank) || 0;
|
||||
} else {
|
||||
tier = SearchLookupTier.TEXT;
|
||||
// Prefer earlier text matches; map position → closeness in (0, 1].
|
||||
const pos = Number(r.textMatchPos) || 0;
|
||||
secondary =
|
||||
pos > 0 ? 1 / (1 + (pos - 1) / 100) : Number(r.ftsRank) || 0;
|
||||
}
|
||||
return { row: r, tier, score: computeLookupScore({ tier, secondary }) };
|
||||
});
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
const top = ranked.slice(0, limit);
|
||||
|
||||
// --- Batch ancestor path (ONE recursive CTE, not N+1). --------------------
|
||||
const pathById = await this.buildAncestorPaths(top.map((t) => t.row.id));
|
||||
|
||||
const items: SearchLookupResponseDto[] = top.map((t) => ({
|
||||
id: t.row.id,
|
||||
slugId: t.row.slugId,
|
||||
title: t.row.title,
|
||||
parentPageId: t.row.parentPageId ?? null,
|
||||
path: pathById.get(t.row.id) ?? [],
|
||||
snippet: (t.row.snippet ?? '')
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
score: t.score,
|
||||
}));
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch ancestor-titles helper (#443): ONE recursive CTE seeded with ALL hit
|
||||
* ids, walking UP parentPageId. Returns a map hitId → ancestor titles ordered
|
||||
* root → direct parent (the hit's own title is excluded). Root pages map to
|
||||
* an empty array. Avoids the N+1 of a per-page breadcrumb call.
|
||||
*/
|
||||
private async buildAncestorPaths(
|
||||
hitIds: string[],
|
||||
): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (hitIds.length === 0) return result;
|
||||
|
||||
// ancestry(hit_id, page_id, title, parent_page_id, depth): seed one row per
|
||||
// hit at depth 0 (the hit itself), then walk to parents (increasing depth).
|
||||
const rows = await this.db
|
||||
.withRecursive('ancestry', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as hitId',
|
||||
'pages.id as pageId',
|
||||
'pages.title as title',
|
||||
'pages.parentPageId as parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
])
|
||||
.where('pages.id', 'in', hitIds)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('ancestry as a', 'p.id', 'a.parentPageId')
|
||||
.select([
|
||||
'a.hitId as hitId',
|
||||
'p.id as pageId',
|
||||
'p.title as title',
|
||||
'p.parentPageId as parentPageId',
|
||||
sql<number>`a.depth + 1`.as('depth'),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestry')
|
||||
.select(['hitId', 'title', 'depth'])
|
||||
// depth 0 is the hit itself — excluded from the path.
|
||||
.where('depth', '>', 0)
|
||||
.orderBy('hitId')
|
||||
// Larger depth = closer to the space root. Ordering DESC gives
|
||||
// root → parent once collected.
|
||||
.orderBy('depth', 'desc')
|
||||
.execute();
|
||||
|
||||
for (const r of rows as any[]) {
|
||||
const list = result.get(r.hitId) ?? [];
|
||||
list.push(r.title);
|
||||
result.set(r.hitId, list);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async searchSuggestions(
|
||||
suggestion: SearchSuggestionDTO,
|
||||
userId: string,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* #443 — trigram indexes for the opt-in agent-lookup search mode.
|
||||
*
|
||||
* The lookup mode adds a substring branch that runs leading-wildcard
|
||||
* `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and
|
||||
* pages.text_content. A leading wildcard cannot use a b-tree index, so without a
|
||||
* GIN trigram index each such predicate is a sequential scan.
|
||||
*
|
||||
* - TITLE: the lookup-mode title predicate is `LOWER(f_unaccent(title)) LIKE
|
||||
* '%q%'` (coalesce-free, so it can use a functional index), which is IDENTICAL
|
||||
* to the one added for /search/suggest (#348). #348's perf-indexes migration
|
||||
* already created `idx_pages_title_trgm` on `(LOWER(f_unaccent(title)))
|
||||
* gin_trgm_ops`, so the title predicate is already covered — we do NOT
|
||||
* re-create that index here (it would be redundant).
|
||||
*
|
||||
* - TEXT_CONTENT: NEW. The substring branch scans text_content when the query
|
||||
* is not titleOnly. text_content is the large column, so a GIN trigram index
|
||||
* on it is the meaningful acceleration for the lookup mode. The lookup search
|
||||
* is ALWAYS space-scoped (spaceId or the user's member spaces), so on small
|
||||
* instances a per-space sequential scan is tolerable — but the index turns the
|
||||
* `%q%` text predicate into a Bitmap Index Scan and removes the only
|
||||
* unbounded-per-space cost of the feature. We add it. The trade-off is disk +
|
||||
* write amplification on page edits (GIN trigram indexes are larger and slower
|
||||
* to update than b-trees); on the small instances this fork targets that cost
|
||||
* is acceptable and the read win on agent lookups is the priority.
|
||||
*
|
||||
* DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX — Kysely runs
|
||||
* each migration in a transaction, so CONCURRENTLY is impossible. The build takes
|
||||
* a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content
|
||||
* GIN build is the slow one and can take minutes on a large tenant. For large
|
||||
* installations, run this in a maintenance window or build the index out-of-band
|
||||
* with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops
|
||||
* here). Small/typical tenants are unaffected.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// The title predicate is served by #348's idx_pages_title_trgm — see header.
|
||||
// Only the text_content index is introduced here.
|
||||
|
||||
// text_content trigram index. Its expression is coalesce-free —
|
||||
// `LOWER(f_unaccent(text_content))` — to EXACTLY match the coalesce-free
|
||||
// lookup-mode text substring predicate in search.service.ts, so Postgres can
|
||||
// use it (a `coalesce(...)` mismatch would silently fall back to a Seq Scan).
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm
|
||||
ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops)
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
// Only drop the index this migration introduced. idx_pages_title_trgm is owned
|
||||
// by the #348 perf-indexes migration, so leave it for that migration's down().
|
||||
await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -188,6 +189,144 @@ export class AiChatMessageRepo {
|
||||
return query.returning(this.baseFields).executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* #487 OWNER terminal write — the streamText terminal callback's finalize. Like
|
||||
* `update` but CONDITIONAL on `status='streaming' OR metadata.finalizeFailed`:
|
||||
* the owner writes its real content EITHER when the row is still streaming (the
|
||||
* normal case) OR when a reconcile stamp already flipped it to a terminal status
|
||||
* but marked `finalizeFailed:true` — the owner's real content OVERWRITES that
|
||||
* placeholder stamp (owner-write priority, #487). A row that is properly terminal
|
||||
* (no finalizeFailed) is left untouched (undefined) — idempotent. The `patch`
|
||||
* carries the real metadata WITHOUT finalizeFailed, so a successful write CLEARS
|
||||
* the flag. Returns the updated row, or undefined when nothing matched.
|
||||
*/
|
||||
async finalizeOwner(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
patch: Partial<{
|
||||
content: string | null;
|
||||
toolCalls: unknown;
|
||||
metadata: unknown;
|
||||
status: string | null;
|
||||
}>,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiChatMessage | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('aiChatMessages')
|
||||
.set({ ...(patch as Record<string, unknown>), updatedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('status', '=', 'streaming'),
|
||||
eb(sql<string>`(metadata->>'finalizeFailed')`, '=', 'true'),
|
||||
]),
|
||||
)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* #487 RECONCILE status-only stamp — settle a stuck 'streaming' row to a
|
||||
* terminal status WITHOUT the owner's real content (which lived only in the
|
||||
* dead process's memory — a documented loss). CONDITIONAL on `status='streaming'`
|
||||
* (never touches an already-terminal row) AND it MERGES `finalizeFailed:true`
|
||||
* into metadata (preserving the partial `parts` already persisted) so a LATER
|
||||
* owner-write (finalizeOwner) can still OVERWRITE this placeholder with real
|
||||
* content, and so `isInterruptResume` can EXCLUDE this row (a reconcile stamp is
|
||||
* not a genuine user interruption). Returns the updated row, or undefined.
|
||||
*/
|
||||
async stampTerminalIfStreaming(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
status: 'aborted' | 'error' | 'completed',
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiChatMessage | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('aiChatMessages')
|
||||
.set({
|
||||
status,
|
||||
metadata: sql`coalesce(metadata, '{}'::jsonb) || jsonb_build_object('finalizeFailed', true)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('status', '=', 'streaming')
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* #487 reconcile clause (b): streaming assistant rows whose linked RUN has
|
||||
* already reached a terminal status — an asymmetry ("run settled / message
|
||||
* streaming forever") the periodic reconcile heals by stamping the message.
|
||||
* Returns the message id + its run's terminal status, bounded.
|
||||
*/
|
||||
async findStreamingWithTerminalRun(
|
||||
limit = 200,
|
||||
// #487: scope to ONE chat for the opportunistic per-turn reconcile (removes
|
||||
// reconcile latency from the user-visible path); omit for the periodic sweep.
|
||||
chat?: { chatId: string; workspaceId: string },
|
||||
): Promise<
|
||||
Array<{ messageId: string; workspaceId: string; runStatus: string }>
|
||||
> {
|
||||
let query = this.db
|
||||
.selectFrom('aiChatMessages as m')
|
||||
.innerJoin('aiChatRuns as r', 'r.assistantMessageId', 'm.id')
|
||||
.select([
|
||||
'm.id as messageId',
|
||||
'm.workspaceId as workspaceId',
|
||||
'r.status as runStatus',
|
||||
])
|
||||
.where('m.status', '=', 'streaming')
|
||||
.where('r.status', 'in', ['succeeded', 'failed', 'aborted']);
|
||||
if (chat) {
|
||||
query = query
|
||||
.where('m.chatId', '=', chat.chatId)
|
||||
.where('m.workspaceId', '=', chat.workspaceId);
|
||||
}
|
||||
return query.limit(limit).execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* #487 reconcile clause (d) — historical-row safety: streaming rows older than
|
||||
* `staleMs` whose chat has NO active run row (double-gated). Settle them to
|
||||
* 'aborted' + finalizeFailed (so a late owner-write could still overwrite).
|
||||
* Returns the count. Used ONLY by the periodic reconcile, never at boot.
|
||||
*/
|
||||
async sweepStreamingWithoutActiveRun(
|
||||
staleMs: number,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const staleBefore = new Date(Date.now() - staleMs);
|
||||
const rows = await db
|
||||
.updateTable('aiChatMessages as m')
|
||||
.set({
|
||||
status: 'aborted',
|
||||
metadata: sql`coalesce(m.metadata, '{}'::jsonb) || jsonb_build_object('finalizeFailed', true)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('m.status', '=', 'streaming')
|
||||
.where('m.updatedAt', '<', staleBefore)
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('aiChatRuns as r')
|
||||
.select('r.id')
|
||||
.whereRef('r.chatId', '=', 'm.chatId')
|
||||
.where('r.status', 'in', ['pending', 'running']),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning('m.id')
|
||||
.execute();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-recovery sweep (#183): flip every assistant row still left in the
|
||||
* 'streaming' state (a turn that died mid-write before reaching a terminal
|
||||
@@ -200,13 +339,20 @@ export class AiChatMessageRepo {
|
||||
* step, so an actively-streaming row never matches; this prevents a fresh
|
||||
* replica's boot-sweep from aborting a turn another replica is still streaming
|
||||
* in a multi-instance deploy.
|
||||
*
|
||||
* #487: the sweep now ALSO marks `finalizeFailed:true` so a late owner-write can
|
||||
* overwrite this placeholder with real content (owner-write priority).
|
||||
*/
|
||||
async sweepStreaming(trx?: KyselyTransaction): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const staleBefore = new Date(Date.now() - SWEEP_STREAMING_STALE_MS);
|
||||
const rows = await db
|
||||
.updateTable('aiChatMessages')
|
||||
.set({ status: 'aborted', updatedAt: new Date() })
|
||||
.set({
|
||||
status: 'aborted',
|
||||
metadata: sql`coalesce(metadata, '{}'::jsonb) || jsonb_build_object('finalizeFailed', true)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('status', '=', 'streaming')
|
||||
.where('updatedAt', '<', staleBefore)
|
||||
.returning('id')
|
||||
|
||||
@@ -143,6 +143,41 @@ export class AiChatRunRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* #487: CONDITIONAL terminal finalize — flip a run to a terminal status and
|
||||
* stamp `finished_at` ONLY while it is still active (pending|running), mirroring
|
||||
* the assistant message's `onlyIfStreaming` guard. A double-settle (a late or
|
||||
* second writer, a supersede applying a zombie's intended, a reconcile stamp)
|
||||
* matches NOTHING once the row is terminal and is a benign no-op — so a terminal
|
||||
* status can never be clobbered by a later writer (last-writer-wins is gone).
|
||||
*
|
||||
* Returns the updated row when it WAS active (this call wrote it), else
|
||||
* undefined (the row was already terminal — another writer won). The caller
|
||||
* distinguishes the two to resolve the correct settle outcome.
|
||||
*/
|
||||
async finalizeIfActive(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
patch: { status: string; error: string | null },
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiChatRun | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const now = new Date();
|
||||
return db
|
||||
.updateTable('aiChatRuns')
|
||||
.set({
|
||||
status: patch.status,
|
||||
error: patch.error,
|
||||
finishedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[])
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an EXPLICIT stop request on an active run (distinct from a browser
|
||||
* disconnect, which never stops a run). Stamps `stop_requested_at` ONLY while
|
||||
@@ -184,6 +219,31 @@ export class AiChatRunRepo {
|
||||
* sweeps only runs UNTOUCHED past the window. Phase 1 is single-process, so the
|
||||
* boot path supplies no window.
|
||||
*/
|
||||
/**
|
||||
* #487 reconcile clause (c): active (pending|running) runs UNTOUCHED past
|
||||
* `staleMs` — candidates for "no live runner" abort. Staleness is measured from
|
||||
* `updated_at` (the LAST-PROGRESS timestamp — recordStep bumps it), NOT
|
||||
* `started_at`, so a legitimate long-running marathon (11–25 min of steady
|
||||
* progress) is never a candidate. The caller filters these against its in-memory
|
||||
* `active` / zombie maps ("no entry" is the PRIMARY gate — a live entry is never
|
||||
* aborted) before settling any of them. Bounded.
|
||||
*/
|
||||
async findStaleActive(
|
||||
staleMs: number,
|
||||
limit = 200,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Array<{ id: string; workspaceId: string; chatId: string }>> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const staleBefore = new Date(Date.now() - staleMs);
|
||||
return db
|
||||
.selectFrom('aiChatRuns')
|
||||
.select(['id', 'workspaceId', 'chatId'])
|
||||
.where('status', 'in', ACTIVE_RUN_STATUSES as unknown as string[])
|
||||
.where('updatedAt', '<', staleBefore)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async sweepRunning(
|
||||
opts: { staleMs?: number } = {},
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { streamText } from 'ai';
|
||||
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
|
||||
|
||||
/**
|
||||
* Regression tests for the writeToServerResponse drain-hang fix in
|
||||
* patches/ai@6.0.134.patch (#486, commit 6).
|
||||
*
|
||||
* Unpatched ai@6.0.134's writeToServerResponse awaits ONLY `once("drain")` when
|
||||
* response.write() returns false (backpressure). If the client disconnects
|
||||
* mid-write the socket never drains, so that await never resolves: the read loop
|
||||
* parks FOREVER, its `finally { response.end() }` is unreachable, and the stream
|
||||
* reader + buffered chunks are pinned until process restart. In autonomous mode
|
||||
* the run keeps producing output after the disconnect, so EVERY mid-run
|
||||
* disconnect leaks a hung pipe. The patch races drain against close/error, and on
|
||||
* a terminal socket event cancels the reader and breaks so `finally` always runs.
|
||||
*
|
||||
* This drives the REAL patched writeToServerResponse through the public
|
||||
* pipeUIMessageStreamToResponse API with a response that never drains and closes
|
||||
* mid-write — exactly the leak scenario.
|
||||
*/
|
||||
|
||||
/** A ServerResponse-like emitter whose first write() stalls (returns false) and
|
||||
* then "closes" like a disconnecting client — never firing 'drain'. */
|
||||
class DisconnectingResponse extends EventEmitter {
|
||||
ended = false;
|
||||
writeCount = 0;
|
||||
statusCode = 200;
|
||||
writableEnded = false;
|
||||
destroyed = false;
|
||||
writeHead(): this {
|
||||
return this;
|
||||
}
|
||||
setHeader(): void {}
|
||||
flushHeaders(): void {}
|
||||
write(): boolean {
|
||||
this.writeCount++;
|
||||
if (this.writeCount === 1) {
|
||||
// Simulate the client vanishing mid-write: backpressure (false) and then a
|
||||
// 'close' on the next tick, and CRUCIALLY never a 'drain'. Unpatched, the
|
||||
// loop would await drain forever here.
|
||||
setImmediate(() => this.emit('close'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
end(): void {
|
||||
this.ended = true;
|
||||
this.writableEnded = true;
|
||||
this.emit('finish');
|
||||
}
|
||||
}
|
||||
|
||||
function makeModel() {
|
||||
return new MockLanguageModelV3({
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'stream-start' as const, warnings: [] },
|
||||
{ type: 'text-start' as const, id: '1' },
|
||||
{ type: 'text-delta' as const, id: '1', delta: 'hello ' },
|
||||
{ type: 'text-delta' as const, id: '1', delta: 'world' },
|
||||
{ type: 'text-end' as const, id: '1' },
|
||||
{
|
||||
type: 'finish' as const,
|
||||
finishReason: { unified: 'stop' as const, raw: 'stop' },
|
||||
usage: {
|
||||
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe('ai@6.0.134 pnpm patch: writeToServerResponse drain-hang (#486)', () => {
|
||||
it('ends the response (does NOT hang) when the socket closes mid-write without draining', async () => {
|
||||
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||
const res = new DisconnectingResponse();
|
||||
// Drain the SDK stream independently, like the production detached path.
|
||||
void result.consumeStream({ onError: () => undefined });
|
||||
result.pipeUIMessageStreamToResponse(res as never);
|
||||
|
||||
// TRIPWIRE: the patched loop exits on 'close' and runs finally -> end().
|
||||
// Unpatched, it awaits 'drain' forever and this never becomes true.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const poll = setInterval(() => {
|
||||
if (res.ended) {
|
||||
clearInterval(poll);
|
||||
resolve();
|
||||
} else if (Date.now() - started > 3000) {
|
||||
clearInterval(poll);
|
||||
reject(new Error('writeToServerResponse hung: response never ended'));
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
|
||||
expect(res.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('does not emit an unhandledRejection when the fire-and-forget read() throws', async () => {
|
||||
// The patch swallows read()'s rejection (fire-and-forget) with a log instead
|
||||
// of letting it surface as a process-killing unhandledRejection.
|
||||
const rejections: unknown[] = [];
|
||||
const onUnhandled = (e: unknown) => rejections.push(e);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
// Silence the patch's diagnostic console.error for the throwing read().
|
||||
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
try {
|
||||
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||
const res = new DisconnectingResponse();
|
||||
void result.consumeStream({ onError: () => undefined });
|
||||
result.pipeUIMessageStreamToResponse(res as never);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
expect(rejections).toEqual([]);
|
||||
});
|
||||
|
||||
it('both installed dist builds (CJS and ESM) carry the #486 patch marker', () => {
|
||||
const cjsPath = require.resolve('ai');
|
||||
const mjsPath = cjsPath.replace(/index\.js$/, 'index.mjs');
|
||||
expect(cjsPath).toMatch(/index\.js$/);
|
||||
expect(readFileSync(cjsPath, 'utf8')).toContain('PATCH(docmost #486)');
|
||||
expect(readFileSync(mjsPath, 'utf8')).toContain('PATCH(docmost #486)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { HealthIndicatorService } from '@nestjs/terminus';
|
||||
import type { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
/**
|
||||
* Integration guard for the /health Redis-probe handle leak (#486, commit 2).
|
||||
*
|
||||
* The bug: `pingCheck` built `new Redis(...)` per call and only disconnected on
|
||||
* the SUCCESS path, so when Redis is DOWN every probe tick added ANOTHER
|
||||
* forever-reconnecting client — an unbounded handle/client leak for the duration
|
||||
* of the outage. The fix reuses ONE long-lived probe client.
|
||||
*
|
||||
* This is an OBSERVABLE-property test, not an assertion on a mocked return value:
|
||||
* we point the indicator at a REAL, refused TCP endpoint (a dead port) so ioredis
|
||||
* genuinely fails to connect, run many probes, and assert the number of live
|
||||
* Redis CLIENTS created stays at exactly ONE. `ioredis` is delegated to its real
|
||||
* implementation (requireActual) — only the constructor is wrapped to COUNT the
|
||||
* real clients it creates, which is precisely the leaking resource.
|
||||
*/
|
||||
const mockLiveClients: Array<{ status: string; disconnect: () => void }> = [];
|
||||
|
||||
jest.mock('ioredis', () => {
|
||||
const actual = jest.requireActual('ioredis');
|
||||
const RealRedis = actual.Redis ?? actual.default ?? actual;
|
||||
class CountingRedis extends RealRedis {
|
||||
constructor(...args: unknown[]) {
|
||||
super(...(args as []));
|
||||
mockLiveClients.push(this as never);
|
||||
}
|
||||
}
|
||||
return { ...actual, Redis: CountingRedis, default: CountingRedis };
|
||||
});
|
||||
|
||||
// Import AFTER the mock is registered so the class picks up the counting client.
|
||||
import { RedisHealthIndicator } from './redis.health';
|
||||
|
||||
describe('RedisHealthIndicator handle leak (#486)', () => {
|
||||
const indicatorService = {
|
||||
check: (key: string) => ({
|
||||
up: () => ({ [key]: { status: 'up' } }),
|
||||
down: (message: string) => ({ [key]: { status: 'down', message } }),
|
||||
}),
|
||||
} as unknown as HealthIndicatorService;
|
||||
|
||||
// A port with (almost certainly) nothing listening -> connection refused fast.
|
||||
const environmentService = {
|
||||
getRedisUrl: () => 'redis://127.0.0.1:6399/0',
|
||||
} as unknown as EnvironmentService;
|
||||
|
||||
let indicator: RedisHealthIndicator;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLiveClients.length = 0;
|
||||
indicator = new RedisHealthIndicator(indicatorService, environmentService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
indicator.onModuleDestroy();
|
||||
// Belt-and-braces: tear down anything the test created so ioredis reconnect
|
||||
// timers do not keep the jest worker alive.
|
||||
for (const c of mockLiveClients) {
|
||||
try {
|
||||
c.disconnect();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('creates exactly ONE Redis client across many probes while Redis is DOWN', async () => {
|
||||
const N = 8;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const result = await indicator.pingCheck('redis');
|
||||
// Down endpoint -> every probe reports "down" (not an unhandled crash).
|
||||
expect(result.redis.status).toBe('down');
|
||||
}
|
||||
|
||||
// THE OBSERVABLE LEAK: on the buggy code this is N (a fresh, never-cleaned
|
||||
// reconnecting client per probe). The fix reuses one shared client.
|
||||
expect(mockLiveClients).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('onModuleDestroy releases the probe client (a later probe builds a fresh one)', async () => {
|
||||
await indicator.pingCheck('redis');
|
||||
expect(mockLiveClients).toHaveLength(1);
|
||||
|
||||
indicator.onModuleDestroy();
|
||||
// A second destroy is a safe no-op (probeClient was nulled).
|
||||
indicator.onModuleDestroy();
|
||||
|
||||
// After shutdown the indicator lazily builds a NEW client on the next probe,
|
||||
// proving the old one was truly released rather than reused.
|
||||
await indicator.pingCheck('redis');
|
||||
expect(mockLiveClients).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -2,33 +2,78 @@ import {
|
||||
HealthIndicatorResult,
|
||||
HealthIndicatorService,
|
||||
} from '@nestjs/terminus';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisHealthIndicator {
|
||||
export class RedisHealthIndicator implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisHealthIndicator.name);
|
||||
|
||||
/**
|
||||
* ONE long-lived probe connection, reused across every /health tick. The old
|
||||
* code built `new Redis(...)` per call and only `disconnect()`d on the SUCCESS
|
||||
* path, so while Redis was DOWN every probe added a fresh, forever-reconnecting
|
||||
* client — a handle leak that grew without bound for as long as the outage (and
|
||||
* the health checker keeps polling) lasted. A single shared client keeps at most
|
||||
* ONE background reconnect loop regardless of how many probes run.
|
||||
*/
|
||||
private probeClient: Redis | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly healthIndicatorService: HealthIndicatorService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
private getProbeClient(): Redis {
|
||||
if (!this.probeClient) {
|
||||
this.probeClient = new Redis(this.environmentService.getRedisUrl(), {
|
||||
// Constructing must never throw or eagerly connect; the first ping opens
|
||||
// the socket. This lets us build the client once and reuse it.
|
||||
lazyConnect: true,
|
||||
// A health probe must fail FAST, not queue behind a stuck reconnect: one
|
||||
// retry per request, and no offline queue so a ping while disconnected
|
||||
// rejects immediately instead of buffering commands that pile up in RAM.
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
});
|
||||
// ioredis emits 'error' on every failed (re)connect; with no listener that
|
||||
// surfaces as an unhandled 'error' event and can crash the process. Swallow
|
||||
// it here — pingCheck already reports health — and log at debug so a Redis
|
||||
// outage does not flood the logs.
|
||||
this.probeClient.on('error', (err) => {
|
||||
this.logger.debug(
|
||||
`Redis probe connection error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
return this.probeClient;
|
||||
}
|
||||
|
||||
async pingCheck(key: string): Promise<HealthIndicatorResult> {
|
||||
const indicator = this.healthIndicatorService.check(key);
|
||||
|
||||
try {
|
||||
const redis = new Redis(this.environmentService.getRedisUrl(), {
|
||||
maxRetriesPerRequest: 15,
|
||||
});
|
||||
|
||||
const redis = this.getProbeClient();
|
||||
await redis.ping();
|
||||
redis.disconnect();
|
||||
return indicator.up();
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
return indicator.down(`${key} is not available`);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.probeClient) {
|
||||
// disconnect() (not quit()) tears the socket + reconnect loop down
|
||||
// immediately without waiting on a round-trip to a possibly-down server.
|
||||
// Do NOT removeAllListeners() with no event name — that would also strip
|
||||
// ioredis' OWN internal listeners and break its teardown; our 'error'
|
||||
// listener is harmless and dies with the dropped client reference.
|
||||
this.probeClient.disconnect();
|
||||
this.probeClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './mcp-auth.helpers';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
|
||||
import { McpService } from './mcp.service';
|
||||
|
||||
// The /mcp per-user auth decision logic is tested through the framework-free
|
||||
// `resolveMcpSessionConfig` helper that McpService delegates to. McpService
|
||||
@@ -1179,3 +1180,46 @@ describe('mapAuthResultToResponse (handle status/body mapping, refactor R2)', ()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// #486: onModuleDestroy must ALSO tear down the live loopback CollabSessions, not
|
||||
// just clear the sweep timer — otherwise the embedded MCP's collab sockets keep
|
||||
// docs pinned open on the collab server past process exit. The teardown goes
|
||||
// through an overridable seam (destroyAllMcpSessions) so it can be spied without
|
||||
// loading the ESM-only @docmost/mcp package.
|
||||
describe('McpService.onModuleDestroy — CollabSession teardown (#486)', () => {
|
||||
function makeService(): McpService {
|
||||
// The constructor only stores its deps and starts the (unref'd) sweep timer,
|
||||
// so bare stubs suffice. onModuleDestroy clears that timer, so no leak.
|
||||
return new McpService(
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
}
|
||||
|
||||
it('destroys all sessions AND clears the sweep timer on shutdown', async () => {
|
||||
const svc = makeService();
|
||||
const destroy = jest.fn().mockResolvedValue(undefined);
|
||||
(svc as any).destroyAllMcpSessions = destroy;
|
||||
const clearSpy = jest.spyOn(global, 'clearInterval');
|
||||
|
||||
await svc.onModuleDestroy();
|
||||
|
||||
expect(destroy).toHaveBeenCalledTimes(1);
|
||||
expect(clearSpy).toHaveBeenCalledWith((svc as any).sweepTimer);
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('swallows a teardown failure so shutdown never throws', async () => {
|
||||
const svc = makeService();
|
||||
(svc as any).destroyAllMcpSessions = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('collab teardown boom'));
|
||||
|
||||
await expect(svc.onModuleDestroy()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,10 +117,42 @@ export class McpService implements OnModuleDestroy {
|
||||
this.sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
clearInterval(this.sweepTimer);
|
||||
// Tear down any live loopback CollabSession providers at shutdown (#486). The
|
||||
// embedded MCP (and the in-app AI agent) open Hocuspocus collab sockets against
|
||||
// THIS process; without an explicit teardown those sessions keep their docs
|
||||
// "open" on the collab server and hold providers/buffers until they idle out,
|
||||
// so a restart can race a doc still pinned by the dying worker. Best-effort:
|
||||
// any failure is logged, never allowed to break shutdown.
|
||||
try {
|
||||
await this.destroyAllMcpSessions();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'MCP CollabSession teardown on shutdown failed',
|
||||
err as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve @docmost/mcp's `destroyAllSessions` and invoke it (#486). The live
|
||||
* CollabSession registry is a module-level singleton in the ESM package, shared
|
||||
* by every entry (`.`/`./http`), so this tears down ALL sessions regardless of
|
||||
* which surface opened them. The module is already loaded whenever MCP was used;
|
||||
* if it was never loaded (or is absent) the import + no-op is harmless.
|
||||
*
|
||||
* Held as an overridable field so a unit test can spy the teardown without
|
||||
* loading the ESM-only package or standing up the DI graph.
|
||||
*/
|
||||
private destroyAllMcpSessions: () => Promise<void> = async () => {
|
||||
const entry = require.resolve('@docmost/mcp');
|
||||
const mod = (await esmImport(pathToFileURL(entry).href)) as {
|
||||
destroyAllSessions?: () => void;
|
||||
};
|
||||
mod.destroyAllSessions?.();
|
||||
};
|
||||
|
||||
// Service account the embedded MCP uses to talk back to this Docmost
|
||||
// instance over loopback REST + the collaboration WebSocket. Now OPTIONAL:
|
||||
// it is only a fallback when no per-user Basic/Bearer credentials are sent.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { get as httpGet } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
// Drive the metrics HTTP server without the load-time METRICS_PORT gate: mock the
|
||||
// registry so isMetricsEnabled()/getMetricsRegistry() are always satisfied. What
|
||||
// we assert is observed over a REAL socket (bind address, status codes), not on
|
||||
// the mock.
|
||||
jest.mock('./metrics.registry', () => ({
|
||||
isMetricsEnabled: () => true,
|
||||
getMetricsRegistry: () => ({
|
||||
metrics: async () => '# HELP up test\nup 1\n',
|
||||
contentType: 'text/plain; version=0.0.4',
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
startMetricsServer,
|
||||
closeMetricsServer,
|
||||
resolveMetricsBind,
|
||||
resolveMetricsToken,
|
||||
} from './metrics.server';
|
||||
|
||||
/** Find a free TCP port (the metrics server requires METRICS_PORT > 0). */
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const p = (s.address() as AddressInfo).port;
|
||||
s.close(() => resolve(p));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal GET against 127.0.0.1:port with optional Authorization header. */
|
||||
function req(
|
||||
port: number,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = httpGet(
|
||||
{ host: '127.0.0.1', port, path: '/metrics', headers },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => (body += c));
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode ?? 0, body }),
|
||||
);
|
||||
},
|
||||
);
|
||||
r.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('metrics server bind + auth (#486)', () => {
|
||||
const saved = {
|
||||
bind: process.env.METRICS_BIND,
|
||||
token: process.env.METRICS_TOKEN,
|
||||
port: process.env.METRICS_PORT,
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await closeMetricsServer();
|
||||
process.env.METRICS_BIND = saved.bind;
|
||||
process.env.METRICS_TOKEN = saved.token;
|
||||
process.env.METRICS_PORT = saved.port;
|
||||
delete process.env.METRICS_BIND;
|
||||
delete process.env.METRICS_TOKEN;
|
||||
});
|
||||
|
||||
describe('resolveMetricsBind', () => {
|
||||
it('defaults to loopback 127.0.0.1', () => {
|
||||
delete process.env.METRICS_BIND;
|
||||
expect(resolveMetricsBind()).toBe('127.0.0.1');
|
||||
});
|
||||
it('honours the METRICS_BIND override', () => {
|
||||
process.env.METRICS_BIND = '0.0.0.0';
|
||||
expect(resolveMetricsBind()).toBe('0.0.0.0');
|
||||
});
|
||||
it('treats a blank override as unset (loopback)', () => {
|
||||
process.env.METRICS_BIND = ' ';
|
||||
expect(resolveMetricsBind()).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMetricsToken', () => {
|
||||
it('is null when unset', () => {
|
||||
delete process.env.METRICS_TOKEN;
|
||||
expect(resolveMetricsToken()).toBeNull();
|
||||
});
|
||||
it('returns the trimmed token when set', () => {
|
||||
process.env.METRICS_TOKEN = ' s3cret ';
|
||||
expect(resolveMetricsToken()).toBe('s3cret');
|
||||
});
|
||||
});
|
||||
|
||||
it('binds to loopback by default and serves /metrics without auth when no token', async () => {
|
||||
delete process.env.METRICS_BIND;
|
||||
delete process.env.METRICS_TOKEN;
|
||||
const port = await freePort();
|
||||
process.env.METRICS_PORT = String(port);
|
||||
|
||||
const server = startMetricsServer();
|
||||
expect(server).not.toBeNull();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server!.listening) resolve();
|
||||
else server!.once('listening', () => resolve());
|
||||
});
|
||||
// OBSERVABLE: the listener bound to loopback, not 0.0.0.0.
|
||||
expect((server!.address() as AddressInfo).address).toBe('127.0.0.1');
|
||||
|
||||
const res = await req(port);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toContain('up 1');
|
||||
});
|
||||
|
||||
it('rejects unauthenticated scrapes with 401 and accepts the exact Bearer token', async () => {
|
||||
delete process.env.METRICS_BIND;
|
||||
process.env.METRICS_TOKEN = 'topsecret';
|
||||
const port = await freePort();
|
||||
process.env.METRICS_PORT = String(port);
|
||||
|
||||
const server = startMetricsServer();
|
||||
expect(server).not.toBeNull();
|
||||
|
||||
// No auth -> 401.
|
||||
const noAuth = await req(port);
|
||||
expect(noAuth.status).toBe(401);
|
||||
|
||||
// Wrong token, DIFFERENT length -> 401 (short-circuits on the length guard).
|
||||
const wrong = await req(port, { authorization: 'Bearer nope' });
|
||||
expect(wrong.status).toBe(401);
|
||||
|
||||
// Wrong token, SAME length -> 401. This drives the timingSafeEqual compare
|
||||
// itself (the length guard passes: 'Bearer topsecreX' has the same length as
|
||||
// 'Bearer topsecret'). Pins the constant-time compare: a regression that made
|
||||
// it return true would let this equal-length wrong token through — the
|
||||
// different-length case above would NOT catch that.
|
||||
const sameLen = await req(port, { authorization: 'Bearer topsecreX' });
|
||||
expect(sameLen.status).toBe(401);
|
||||
|
||||
// Correct token -> 200 with the metrics body.
|
||||
const ok = await req(port, { authorization: 'Bearer topsecret' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toContain('up 1');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,27 @@
|
||||
import { createServer, Server } from 'node:http';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
|
||||
|
||||
/**
|
||||
* Constant-time compare of the presented Authorization header against the
|
||||
* expected `Bearer <token>`. This is the ONLY auth layer for the metrics
|
||||
* endpoint, so a naive `!==` would leak the token byte-by-byte via timing.
|
||||
* timingSafeEqual requires equal-length buffers, so a length mismatch short-
|
||||
* circuits to "not equal" (its own length is not itself a useful oracle: the
|
||||
* expected string length is fixed by config, not secret-derived).
|
||||
*/
|
||||
function bearerMatches(
|
||||
presented: string | undefined,
|
||||
expected: string,
|
||||
): boolean {
|
||||
if (typeof presented !== 'string') return false;
|
||||
const a = Buffer.from(presented);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Prometheus scrape endpoint on a SEPARATE port, taken from
|
||||
* `METRICS_PORT`. There is NO default port: when `METRICS_PORT` is unset the
|
||||
@@ -16,6 +36,30 @@ import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
|
||||
*/
|
||||
let metricsServer: Server | null = null;
|
||||
|
||||
/**
|
||||
* Interface the metrics endpoint binds to. Defaults to LOOPBACK (127.0.0.1) so
|
||||
* the unauthenticated `/metrics` surface is NOT exposed on all interfaces by
|
||||
* default — the old `0.0.0.0` bind put an auth-less endpoint on every interface.
|
||||
* Deployments where the scraper runs in a SEPARATE container (and reaches this as
|
||||
* `docmost:9464`) set `METRICS_BIND=0.0.0.0`, ideally together with METRICS_TOKEN
|
||||
* and/or a private network so the port is not world-readable.
|
||||
*/
|
||||
export function resolveMetricsBind(): string {
|
||||
const raw = (process.env.METRICS_BIND ?? '').trim();
|
||||
return raw.length > 0 ? raw : '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional Bearer token guarding `/metrics`. When `METRICS_TOKEN` is set, every
|
||||
* scrape must present `Authorization: Bearer <token>`; unset (default) leaves the
|
||||
* endpoint open (safe when bound to loopback / a trusted network). Returns the
|
||||
* trimmed token or null when unset/blank.
|
||||
*/
|
||||
export function resolveMetricsToken(): string | null {
|
||||
const raw = (process.env.METRICS_TOKEN ?? '').trim();
|
||||
return raw.length > 0 ? raw : null;
|
||||
}
|
||||
|
||||
export function startMetricsServer(): Server | null {
|
||||
if (!isMetricsEnabled()) return null;
|
||||
|
||||
@@ -31,8 +75,22 @@ export function startMetricsServer(): Server | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bind = resolveMetricsBind();
|
||||
const token = resolveMetricsToken();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/metrics') {
|
||||
// Optional Bearer auth: reject scrapes without the exact token when one is
|
||||
// configured. This is the auth layer the old all-interfaces bind lacked.
|
||||
if (token) {
|
||||
const auth = req.headers['authorization'];
|
||||
if (!bearerMatches(auth, `Bearer ${token}`)) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader('WWW-Authenticate', 'Bearer');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = await register.metrics();
|
||||
res.setHeader('Content-Type', register.contentType);
|
||||
@@ -48,10 +106,14 @@ export function startMetricsServer(): Server | null {
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Bind on all interfaces: the scraper (VictoriaMetrics) reaches this from
|
||||
// another container as docmost:9464. The port is not published to the host.
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
logger.log(`Metrics endpoint listening on :${port}/metrics`);
|
||||
// Bind to loopback by default so the auth-less endpoint is not exposed on all
|
||||
// interfaces. Set METRICS_BIND=0.0.0.0 (ideally with METRICS_TOKEN) when the
|
||||
// scraper runs in a separate container and reaches this as docmost:9464.
|
||||
server.listen(port, bind, () => {
|
||||
logger.log(
|
||||
`Metrics endpoint listening on ${bind}:${port}/metrics` +
|
||||
(token ? ' (Bearer auth required)' : ''),
|
||||
);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
|
||||
@@ -31,9 +31,6 @@ export enum QueueJob {
|
||||
IMPORT_TASK = 'import-task',
|
||||
EXPORT_TASK = 'export-task',
|
||||
|
||||
SEARCH_REMOVE_PAGE = 'search-remove-page',
|
||||
SEARCH_REMOVE_ASSET = 'search-remove-attachment',
|
||||
SEARCH_REMOVE_FACE = 'search-remove-comment',
|
||||
TYPESENSE_FLUSH = 'typesense-flush',
|
||||
|
||||
PAGE_CREATED = 'page-created',
|
||||
|
||||
@@ -182,6 +182,7 @@ describe('AiChatService run-stream attach [integration]', () => {
|
||||
{
|
||||
isAiChatDeferredToolsEnabled: () => false,
|
||||
isAiChatResumableStreamEnabled: () => true,
|
||||
isAiChatFinalStepLockdownEnabled: () => false,
|
||||
} as any,
|
||||
registry,
|
||||
);
|
||||
@@ -499,6 +500,7 @@ describe('AiChatService run-stream attach [integration]', () => {
|
||||
{
|
||||
isAiChatDeferredToolsEnabled: () => false,
|
||||
isAiChatResumableStreamEnabled: () => true,
|
||||
isAiChatFinalStepLockdownEnabled: () => false,
|
||||
} as any,
|
||||
registry,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||
import { AiChatRunService } from '../../src/core/ai-chat/ai-chat-run.service';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createUser,
|
||||
createChat,
|
||||
createMessage,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* #487 commit 4 — bidirectional reconcile + owner-write priority, real SQL.
|
||||
*
|
||||
* Proves the OBSERVABLE recovery properties against docmost_test:
|
||||
* - the CONDITIONAL owner-write beats a reconcile stamp, and a stamp never
|
||||
* clobbers a proper terminal row;
|
||||
* - a LATE owner-finalize with real content OVERWRITES a reconcile 'aborted'
|
||||
* stamp (finalizeFailed);
|
||||
* - each reconcile clause (b message<-run, c stale-run, d historical row) settles
|
||||
* the stuck row/run, and a LIVE run entry is never touched;
|
||||
* - the "kill DB on finish" recovery: after the DB comes back, neither the
|
||||
* message row nor the run row stays stuck.
|
||||
*/
|
||||
describe('#487 reconcile + owner-write priority [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let messageRepo: AiChatMessageRepo;
|
||||
let runRepo: AiChatRunRepo;
|
||||
let runService: AiChatRunService;
|
||||
let workspaceId: string;
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
messageRepo = new AiChatMessageRepo(db as any);
|
||||
runRepo = new AiChatRunRepo(db as any);
|
||||
runService = new AiChatRunService(runRepo, { isCloud: () => false } as never);
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
userId = (await createUser(db, workspaceId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
const newChat = async () =>
|
||||
(await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||
|
||||
const metaOf = async (id: string): Promise<Record<string, unknown> | null> => {
|
||||
const row = await messageRepo.findById(id, workspaceId);
|
||||
return (row?.metadata as Record<string, unknown> | null) ?? null;
|
||||
};
|
||||
|
||||
it('owner finalizeOwner writes a streaming row and CLEARS finalizeFailed', async () => {
|
||||
const chatId = await newChat();
|
||||
const m = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
metadata: { parts: [] },
|
||||
});
|
||||
const wrote = await messageRepo.finalizeOwner(m.id, workspaceId, {
|
||||
content: 'final answer',
|
||||
status: 'completed',
|
||||
metadata: { parts: [{ type: 'text', text: 'final answer' }] },
|
||||
} as never);
|
||||
expect(wrote!.status).toBe('completed');
|
||||
expect((await metaOf(m.id))?.finalizeFailed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a reconcile stamp NEVER clobbers a proper terminal row (finalizeOwner is a no-op there)', async () => {
|
||||
const chatId = await newChat();
|
||||
const m = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: 'real',
|
||||
metadata: { parts: [] },
|
||||
});
|
||||
// The reconcile stamp is onlyIfStreaming -> no-op on a completed row.
|
||||
const stamped = await messageRepo.stampTerminalIfStreaming(
|
||||
m.id,
|
||||
workspaceId,
|
||||
'aborted',
|
||||
);
|
||||
expect(stamped).toBeUndefined();
|
||||
expect((await messageRepo.findById(m.id, workspaceId))!.status).toBe(
|
||||
'completed',
|
||||
);
|
||||
});
|
||||
|
||||
it('LATE owner-finalize with real content OVERWRITES a reconcile aborted stamp', async () => {
|
||||
const chatId = await newChat();
|
||||
const m = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
metadata: { parts: [{ type: 'text', text: 'partial' }] },
|
||||
});
|
||||
// Reconcile stamps it aborted + finalizeFailed (final text lived only in mem).
|
||||
const stamped = await messageRepo.stampTerminalIfStreaming(
|
||||
m.id,
|
||||
workspaceId,
|
||||
'aborted',
|
||||
);
|
||||
expect(stamped!.status).toBe('aborted');
|
||||
expect((await metaOf(m.id))?.finalizeFailed).toBe(true);
|
||||
|
||||
// A LATE owner-write (finalizeFailed=true satisfies the OR) overwrites it with
|
||||
// real content, clearing the flag — owner-write priority.
|
||||
const wrote = await messageRepo.finalizeOwner(m.id, workspaceId, {
|
||||
content: 'the real final answer',
|
||||
status: 'completed',
|
||||
metadata: { parts: [{ type: 'text', text: 'the real final answer' }] },
|
||||
} as never);
|
||||
expect(wrote!.status).toBe('completed');
|
||||
expect(wrote!.content).toBe('the real final answer');
|
||||
expect((await metaOf(m.id))?.finalizeFailed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clause (c): a stale active run with NO live entry -> aborted; a LIVE entry is untouched', async () => {
|
||||
// Stale run, NOT owned by this replica (no entry) -> reconcile aborts it.
|
||||
const staleChat = await newChat();
|
||||
const stale = await runRepo.insert({
|
||||
chatId: staleChat,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
status: 'running',
|
||||
});
|
||||
await db
|
||||
.updateTable('aiChatRuns')
|
||||
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
|
||||
.where('id', '=', stale.id)
|
||||
.execute();
|
||||
|
||||
// A live run OWNED by this replica (beginRun registers an in-memory entry),
|
||||
// ALSO backdated stale — the "no entry" primary gate must protect it.
|
||||
const liveChat = await newChat();
|
||||
const live = await runService.beginRun({
|
||||
chatId: liveChat,
|
||||
workspaceId,
|
||||
userId,
|
||||
});
|
||||
await db
|
||||
.updateTable('aiChatRuns')
|
||||
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
|
||||
.where('id', '=', live.runId)
|
||||
.execute();
|
||||
|
||||
const aborted = await runService.reconcileStaleRuns(15 * 60 * 1000);
|
||||
expect(aborted).toBeGreaterThanOrEqual(1);
|
||||
expect((await runRepo.findById(stale.id, workspaceId))!.status).toBe(
|
||||
'aborted',
|
||||
);
|
||||
// The live entry is NEVER aborted, however stale its row looks.
|
||||
expect((await runRepo.findById(live.runId, workspaceId))!.status).toBe(
|
||||
'running',
|
||||
);
|
||||
expect(runService.isLocallyActive(live.runId)).toBe(true);
|
||||
|
||||
// cleanup the live run
|
||||
await runService.finalizeRun(live.runId, workspaceId, 'aborted');
|
||||
});
|
||||
|
||||
it('clause (b): a streaming message whose RUN is terminal is stamped by run status (succeeded -> aborted, NOT completed-empty)', async () => {
|
||||
const chatId = await newChat();
|
||||
const msg = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
metadata: { parts: [] },
|
||||
});
|
||||
// A SUCCEEDED run linked to the still-streaming message (the asymmetry).
|
||||
const run = await runRepo.insert({
|
||||
chatId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
status: 'running',
|
||||
assistantMessageId: msg.id,
|
||||
});
|
||||
await runRepo.finalizeIfActive(run.id, workspaceId, {
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
});
|
||||
|
||||
const stuck = await messageRepo.findStreamingWithTerminalRun();
|
||||
const mine = stuck.find((s) => s.messageId === msg.id);
|
||||
expect(mine?.runStatus).toBe('succeeded');
|
||||
// Reconcile clause (b): succeeded run -> message 'aborted' (NOT 'completed'),
|
||||
// the final text lived only in memory (documented loss), +finalizeFailed.
|
||||
const status = mine!.runStatus === 'failed' ? 'error' : 'aborted';
|
||||
await messageRepo.stampTerminalIfStreaming(msg.id, workspaceId, status);
|
||||
const row = await messageRepo.findById(msg.id, workspaceId);
|
||||
expect(row!.status).toBe('aborted');
|
||||
expect((row!.metadata as Record<string, unknown>).finalizeFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('clause (d): a stale streaming row with NO active run on the chat -> aborted+finalizeFailed', async () => {
|
||||
const chatId = await newChat();
|
||||
const msg = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
metadata: { parts: [] },
|
||||
});
|
||||
await db
|
||||
.updateTable('aiChatMessages')
|
||||
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
|
||||
.where('id', '=', msg.id)
|
||||
.execute();
|
||||
|
||||
const swept = await messageRepo.sweepStreamingWithoutActiveRun(
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
expect(swept).toBeGreaterThanOrEqual(1);
|
||||
const row = await messageRepo.findById(msg.id, workspaceId);
|
||||
expect(row!.status).toBe('aborted');
|
||||
expect((row!.metadata as Record<string, unknown>).finalizeFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('clause (d) is DOUBLE-GATED: a stale streaming row WITH an active run on the chat is left alone', async () => {
|
||||
const chatId = await newChat();
|
||||
const msg = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
metadata: { parts: [] },
|
||||
});
|
||||
await db
|
||||
.updateTable('aiChatMessages')
|
||||
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
|
||||
.where('id', '=', msg.id)
|
||||
.execute();
|
||||
// An ACTIVE run on the same chat -> clause (d) must NOT touch the message.
|
||||
const run = await runRepo.insert({
|
||||
chatId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
await messageRepo.sweepStreamingWithoutActiveRun(15 * 60 * 1000);
|
||||
expect((await messageRepo.findById(msg.id, workspaceId))!.status).toBe(
|
||||
'streaming',
|
||||
);
|
||||
await runRepo.finalizeIfActive(run.id, workspaceId, {
|
||||
status: 'aborted',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('"kill DB on finish" recovery: after the DB is back, reconcile leaves NEITHER the row nor the run stuck', async () => {
|
||||
// Simulate a process that seeded the assistant row + run, then died before
|
||||
// finalizing EITHER (a mid-turn crash): a streaming message + a running run,
|
||||
// both stale, with no in-memory entry (fresh service = fresh maps).
|
||||
const chatId = await newChat();
|
||||
const msg = await createMessage(db, {
|
||||
workspaceId,
|
||||
chatId,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
metadata: { parts: [{ type: 'text', text: 'partial' }] },
|
||||
});
|
||||
const run = await runRepo.insert({
|
||||
chatId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
status: 'running',
|
||||
assistantMessageId: msg.id,
|
||||
});
|
||||
await db
|
||||
.updateTable('aiChatRuns')
|
||||
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
|
||||
.where('id', '=', run.id)
|
||||
.execute();
|
||||
await db
|
||||
.updateTable('aiChatMessages')
|
||||
.set({ updatedAt: new Date(Date.now() - 60 * 60 * 1000) })
|
||||
.where('id', '=', msg.id)
|
||||
.execute();
|
||||
|
||||
// Reconcile (as the periodic job would): (c) aborts the orphan run, then
|
||||
// (b) settles the message from the now-terminal run.
|
||||
await runService.reconcileStaleRuns(15 * 60 * 1000);
|
||||
const stuck = await messageRepo.findStreamingWithTerminalRun();
|
||||
for (const s of stuck) {
|
||||
const status = s.runStatus === 'failed' ? 'error' : 'aborted';
|
||||
await messageRepo.stampTerminalIfStreaming(s.messageId, s.workspaceId, status);
|
||||
}
|
||||
|
||||
// Neither is stuck: the run is terminal AND the message is terminal.
|
||||
expect((await runRepo.findById(run.id, workspaceId))!.status).toBe('aborted');
|
||||
const row = await messageRepo.findById(msg.id, workspaceId);
|
||||
expect(row!.status).toBe('aborted');
|
||||
expect((row!.metadata as Record<string, unknown>).finalizeFailed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -281,6 +281,52 @@ describe('AiChatRun durable lifecycle [integration]', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('#487 finalizeIfActive is CONDITIONAL: a late terminal write cannot clobber the settled status (real SQL)', async () => {
|
||||
const c = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||
const run = await runRepo.insert({
|
||||
chatId: c,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
// First terminal write: the run IS active, so it flips + returns the row.
|
||||
const first = await runRepo.finalizeIfActive(run.id, workspaceId, {
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
});
|
||||
expect(first!.status).toBe('succeeded');
|
||||
expect(first!.finishedAt).toBeTruthy();
|
||||
|
||||
// A late/second writer tries to flip it to 'aborted' — the WHERE status IN
|
||||
// ('pending','running') guard matches NOTHING now, so it is a benign no-op.
|
||||
const second = await runRepo.finalizeIfActive(run.id, workspaceId, {
|
||||
status: 'aborted',
|
||||
error: 'late clobber attempt',
|
||||
});
|
||||
expect(second).toBeUndefined();
|
||||
|
||||
// The persisted terminal status is UNCHANGED — last-writer-wins is gone.
|
||||
const row = await runRepo.findById(run.id, workspaceId);
|
||||
expect(row!.status).toBe('succeeded');
|
||||
expect(row!.error).toBeNull();
|
||||
});
|
||||
|
||||
it('#487 double-settle through the service collapses to one write at the SQL gate', async () => {
|
||||
const c = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||
const handle = await service.beginRun({ chatId: c, workspaceId, userId });
|
||||
|
||||
// First settle writes 'aborted' via the conditional write.
|
||||
await service.finalizeRun(handle.runId, workspaceId, 'aborted');
|
||||
// A late safety-net settle to 'error' is a no-op (row already terminal).
|
||||
await service.finalizeRun(handle.runId, workspaceId, 'error', 'late');
|
||||
|
||||
const row = await runRepo.findById(handle.runId, workspaceId);
|
||||
expect(row!.status).toBe('aborted');
|
||||
expect(service.isLocallyActive(handle.runId)).toBe(false);
|
||||
expect(service.hasZombie(handle.runId)).toBe(false);
|
||||
});
|
||||
|
||||
it('sweepRunning() with NO args (boot sweep / variant C) aborts even a FRESH running run', async () => {
|
||||
// F1/DECISION C at the SQL level: the unconditional boot sweep has NO
|
||||
// staleness window, so a run updated just now (a fast restart) is settled too
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('AiChatService.stream [integration]', () => {
|
||||
{} as any, // pageAccess (idem)
|
||||
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||
// harness so the toolset/behavior is exactly as before.
|
||||
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('AiChatService.stream [integration]', () => {
|
||||
{} as any,
|
||||
{} as any,
|
||||
// #332: deferred tool loading ON — the property under test.
|
||||
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||
{ isAiChatDeferredToolsEnabled: () => true, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* #443 dead-index guard — EXPLAIN on the REAL DB.
|
||||
*
|
||||
* The lookup mode's substring predicates run a leading-wildcard
|
||||
* `LOWER(f_unaccent(col)) LIKE '%q%'`. Those are only fast when Postgres uses
|
||||
* the GIN trigram indexes:
|
||||
* - idx_pages_title_trgm on (LOWER(f_unaccent(title))) [#348]
|
||||
* - idx_pages_text_content_trgm on (LOWER(f_unaccent(text_content))) [#443]
|
||||
*
|
||||
* Postgres uses a functional index ONLY when the query expression matches the
|
||||
* index expression EXACTLY. The original lookup query wrapped the columns in
|
||||
* `coalesce(col,'')`, which differs from the coalesce-FREE index expression and
|
||||
* silently forced a Seq Scan on pages for EVERY lookup (the MCP client always
|
||||
* sends substring:true). This test locks that in.
|
||||
*
|
||||
* Discriminator: `SET enable_seqscan = off` asks the planner "CAN this predicate
|
||||
* use the index at all?" — which is exactly what the coalesce bug breaks. With
|
||||
* seqscan disabled:
|
||||
* - the coalesce-FREE (fixed) predicate plans a Bitmap Index Scan on the trgm
|
||||
* index (no Seq Scan on pages);
|
||||
* - the coalesce-WRAPPED (buggy) predicate cannot use the index and falls back
|
||||
* to a Seq Scan on pages even though seqscan is disabled.
|
||||
* We assert both to prove the fix and to keep the regression from silently
|
||||
* returning.
|
||||
*/
|
||||
describe('SearchService agent-lookup EXPLAIN — trgm index is live [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let workspaceId: string;
|
||||
let spaceId: string;
|
||||
|
||||
async function insertPage(title: string, textContent: string): Promise<void> {
|
||||
const id = randomUUID();
|
||||
await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
id,
|
||||
slugId: `slug-${id.slice(0, 12)}`,
|
||||
title,
|
||||
textContent,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Run EXPLAIN (no ANALYZE — we only inspect the chosen plan) and return the
|
||||
// concatenated plan text.
|
||||
async function explain(query: string): Promise<string> {
|
||||
const rows = await sql<{ 'QUERY PLAN': string }>`EXPLAIN ${sql.raw(query)}`.execute(
|
||||
db,
|
||||
);
|
||||
return (rows.rows as any[]).map((r) => r['QUERY PLAN']).join('\n');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, workspaceId)).id;
|
||||
|
||||
// Seed enough rows that a trigram index is a plausible plan. The content is
|
||||
// varied so the '%needle%' pattern is selective.
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await insertPage(
|
||||
`seed-title-${i}`,
|
||||
`seed body content number ${i} lorem ipsum dolor sit amet ${i}`,
|
||||
);
|
||||
}
|
||||
await insertPage('backup-srv.local', 'the needle-token-xyz lives here');
|
||||
|
||||
// Keep the trgm indexes' stats fresh so the planner costs them correctly.
|
||||
await sql`ANALYZE pages`.execute(db);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
// Force the planner to answer "can the index be used?" rather than "is it
|
||||
// cheaper than a seq scan on this size?". Restored after each test.
|
||||
beforeEach(async () => {
|
||||
await sql`SET enable_seqscan = off`.execute(db);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await sql`RESET enable_seqscan`.execute(db);
|
||||
});
|
||||
|
||||
it('title predicate (coalesce-FREE, as fixed) uses idx_pages_title_trgm, not a Seq Scan', async () => {
|
||||
const plan = await explain(
|
||||
`SELECT id FROM pages WHERE LOWER(f_unaccent(title)) LIKE '%srv.local%'`,
|
||||
);
|
||||
expect(plan).toContain('idx_pages_title_trgm');
|
||||
expect(plan).not.toMatch(/Seq Scan on pages/i);
|
||||
});
|
||||
|
||||
it('text_content predicate (coalesce-FREE, as fixed) uses idx_pages_text_content_trgm, not a Seq Scan', async () => {
|
||||
const plan = await explain(
|
||||
`SELECT id FROM pages WHERE LOWER(f_unaccent(text_content)) LIKE '%needle-token%'`,
|
||||
);
|
||||
expect(plan).toContain('idx_pages_text_content_trgm');
|
||||
expect(plan).not.toMatch(/Seq Scan on pages/i);
|
||||
});
|
||||
|
||||
// Negative control: the OLD coalesce-wrapped predicate must NOT be able to use
|
||||
// the index — even with seqscan disabled it can only Seq Scan pages. If this
|
||||
// ever stops seq-scanning, the coalesce/index expressions have re-aligned and
|
||||
// the guard above is no longer meaningful.
|
||||
it('coalesce-WRAPPED text predicate (the bug) cannot use the index — falls to Seq Scan', async () => {
|
||||
const plan = await explain(
|
||||
`SELECT id FROM pages WHERE LOWER(f_unaccent(coalesce(text_content,''))) LIKE '%needle-token%'`,
|
||||
);
|
||||
expect(plan).not.toContain('idx_pages_text_content_trgm');
|
||||
expect(plan).toMatch(/Seq Scan on pages/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Kysely } from 'kysely';
|
||||
import { SearchService } from 'src/core/search/search.service';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* #443 — agent-lookup search mode, acceptance on the REAL DB schema.
|
||||
*
|
||||
* Exercises SearchService.searchPage(..., { substring: true }) against a
|
||||
* migrated Postgres: substring matching of technical tokens the FTS tokenizer
|
||||
* mangles (backup-srv.local, 10.0.12.5, WB-MGE-30D86B, "Теги: Docker"), the
|
||||
* populated path + snippet, parentPageId subtree scoping, titleOnly, the empty
|
||||
* result, LIKE-metacharacter escaping (`%`/`_` must NOT match everything), the
|
||||
* permission post-filter applied BEFORE the limit, and the web-UI path staying
|
||||
* on the legacy FTS shape when `substring` is absent.
|
||||
*
|
||||
* The tsv column is populated by the pages_tsvector_trigger on insert, so the
|
||||
* FTS branch is exercised too.
|
||||
*/
|
||||
describe('SearchService agent-lookup mode [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let service: SearchService;
|
||||
let workspaceId: string;
|
||||
let spaceId: string;
|
||||
|
||||
// Direct page insert (the shared createPage seeder omits text_content /
|
||||
// parent_page_id, both of which this mode depends on). Returns the id.
|
||||
async function insertPage(args: {
|
||||
title: string;
|
||||
textContent?: string;
|
||||
parentPageId?: string | null;
|
||||
spaceId?: string;
|
||||
}): Promise<string> {
|
||||
const id = randomUUID();
|
||||
await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
id,
|
||||
slugId: `slug-${id.slice(0, 12)}`,
|
||||
title: args.title,
|
||||
textContent: args.textContent ?? null,
|
||||
parentPageId: args.parentPageId ?? null,
|
||||
spaceId: args.spaceId ?? spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
// Build a SearchService wired to the real DB + a real PageRepo (only its
|
||||
// recursive-descendants method is used by this mode, and it needs only `db`),
|
||||
// with lightweight stubs for the space-membership and permission repos so a
|
||||
// test can drive scope + the permission post-filter explicitly.
|
||||
function buildService(opts?: {
|
||||
userSpaceIds?: string[];
|
||||
// ids to KEEP after the permission post-filter; undefined = keep all.
|
||||
accessibleIds?: string[];
|
||||
}): SearchService {
|
||||
const pageRepo = new PageRepo(db as any, null as any, null as any);
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIds: async () => opts?.userSpaceIds ?? [spaceId],
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
filterAccessiblePageIds: async ({ pageIds }: { pageIds: string[] }) =>
|
||||
opts?.accessibleIds
|
||||
? pageIds.filter((id) => opts.accessibleIds!.includes(id))
|
||||
: pageIds,
|
||||
};
|
||||
return new SearchService(
|
||||
db as any,
|
||||
pageRepo as any,
|
||||
{} as any, // shareRepo — unused by the lookup path
|
||||
spaceMemberRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, workspaceId)).id;
|
||||
service = buildService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
it('finds `backup-srv.local` by the fragment `srv.local`', async () => {
|
||||
const pageId = await insertPage({
|
||||
title: 'backup-srv.local',
|
||||
textContent: 'A backup server node.',
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: 'srv.local', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
expect(items.map((i: any) => i.id)).toContain(pageId);
|
||||
const hit = items.find((i: any) => i.id === pageId);
|
||||
expect(hit.title).toBe('backup-srv.local');
|
||||
// slugId must never be part of the server response shape.
|
||||
expect('slugId' in hit).toBe(true); // server carries it; MCP strips it
|
||||
});
|
||||
|
||||
it('finds a page whose TEXT contains `10.0.12.5` by the fragment `10.0.12` (empty-tsquery case)', async () => {
|
||||
const pageId = await insertPage({
|
||||
title: 'Server inventory',
|
||||
textContent: 'The backup box lives at IP: 10.0.12.5. Debian 12, backups.',
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: '10.0.12', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const hit = items.find((i: any) => i.id === pageId);
|
||||
expect(hit).toBeDefined();
|
||||
// The windowed snippet must include the matched text.
|
||||
expect(hit.snippet).toContain('10.0.12.5');
|
||||
});
|
||||
|
||||
it('finds `WB-MGE-30D86B` (alphanumeric token with dashes) by title', async () => {
|
||||
const pageId = await insertPage({
|
||||
title: 'WB-MGE-30D86B',
|
||||
textContent: 'Device page.',
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: 'WB-MGE-30D86B', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const hit = items.find((i: any) => i.id === pageId);
|
||||
expect(hit).toBeDefined();
|
||||
// Exact title match → top tier (TITLE_EXACT=3) → score in [0.75, 1].
|
||||
expect(hit.score).toBeGreaterThanOrEqual(0.75);
|
||||
// And it is the top-ranked hit of its own result set.
|
||||
expect(items[0].id).toBe(pageId);
|
||||
});
|
||||
|
||||
it('finds every page whose text literally contains `Теги: Docker`', async () => {
|
||||
const a = await insertPage({
|
||||
title: 'Container host A',
|
||||
textContent: 'Some notes.\nТеги: Docker, compose\nmore.',
|
||||
});
|
||||
const b = await insertPage({
|
||||
title: 'Container host B',
|
||||
textContent: 'Prelude.\nТеги: Docker\nepilogue.',
|
||||
});
|
||||
const noise = await insertPage({
|
||||
title: 'Unrelated',
|
||||
textContent: 'Теги: Kubernetes',
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: 'Теги: Docker', spaceId, substring: true, limit: 50 } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const ids = items.map((i: any) => i.id);
|
||||
expect(ids).toContain(a);
|
||||
expect(ids).toContain(b);
|
||||
expect(ids).not.toContain(noise);
|
||||
});
|
||||
|
||||
it('populates a non-empty `path` for a nested hit and `[]` for a root hit', async () => {
|
||||
const root = await insertPage({ title: 'Infrastructure' });
|
||||
const mid = await insertPage({ title: 'Datacenter A', parentPageId: root });
|
||||
const leaf = await insertPage({
|
||||
title: 'unique-nested-host',
|
||||
parentPageId: mid,
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: 'unique-nested-host', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const hit = items.find((i: any) => i.id === leaf);
|
||||
expect(hit.path).toEqual(['Infrastructure', 'Datacenter A']);
|
||||
|
||||
const rootHits = (await service.searchPage(
|
||||
{ query: 'Infrastructure', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
const rootHit = rootHits.items.find((i: any) => i.id === root);
|
||||
expect(rootHit.path).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes to a subtree with parentPageId (cutting off sibling branches)', async () => {
|
||||
const branchA = await insertPage({ title: 'BranchA-root' });
|
||||
const inA = await insertPage({
|
||||
title: 'scoped-target-xyz',
|
||||
parentPageId: branchA,
|
||||
});
|
||||
const branchB = await insertPage({ title: 'BranchB-root' });
|
||||
const inB = await insertPage({
|
||||
title: 'scoped-target-xyz',
|
||||
parentPageId: branchB,
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{
|
||||
query: 'scoped-target-xyz',
|
||||
spaceId,
|
||||
substring: true,
|
||||
parentPageId: branchA,
|
||||
} as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const ids = items.map((i: any) => i.id);
|
||||
expect(ids).toContain(inA);
|
||||
expect(ids).not.toContain(inB);
|
||||
});
|
||||
|
||||
it('includes the parent page itself in the parentPageId subtree', async () => {
|
||||
const parent = await insertPage({ title: 'self-included-parent' });
|
||||
await insertPage({ title: 'child-of-self', parentPageId: parent });
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{
|
||||
query: 'self-included-parent',
|
||||
spaceId,
|
||||
substring: true,
|
||||
parentPageId: parent,
|
||||
} as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
expect(items.map((i: any) => i.id)).toContain(parent);
|
||||
});
|
||||
|
||||
it('titleOnly does NOT match on text_content', async () => {
|
||||
const pageId = await insertPage({
|
||||
title: 'Plain title',
|
||||
textContent: 'body mentions the-secret-token here',
|
||||
});
|
||||
|
||||
const withText = (await service.searchPage(
|
||||
{ query: 'the-secret-token', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
expect(withText.items.map((i: any) => i.id)).toContain(pageId);
|
||||
|
||||
const titleOnly = (await service.searchPage(
|
||||
{
|
||||
query: 'the-secret-token',
|
||||
spaceId,
|
||||
substring: true,
|
||||
titleOnly: true,
|
||||
} as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
expect(titleOnly.items.map((i: any) => i.id)).not.toContain(pageId);
|
||||
});
|
||||
|
||||
// #443 Fix #1 regression: f_unaccent is NOT length-preserving, so an
|
||||
// expanding char (ß→ss, …→...) BEFORE the match shifted the strpos position
|
||||
// relative to the ORIGINAL text and the snippet slice ran past end → empty.
|
||||
// The position and the slice now share the LOWER(f_unaccent(...)) space, so
|
||||
// the window is aligned and always contains the matched (unaccented) token.
|
||||
it('returns a populated snippet when an unaccent-EXPANDING char precedes the match', async () => {
|
||||
// 300 × `ß` (each f_unaccent-expands to `ss`) before the needle. Under the
|
||||
// old code strpos returned a position ~593 in the expanded space but the
|
||||
// slice ran over the ORIGINAL (~360 char) text → empty snippet, match lost.
|
||||
const prefix = 'ß'.repeat(300);
|
||||
const pageId = await insertPage({
|
||||
title: 'Expanding-unaccent page',
|
||||
textContent: `${prefix} needle-token-xyz trailing.`,
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: 'needle-token-xyz', spaceId, substring: true } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const hit = items.find((i: any) => i.id === pageId);
|
||||
expect(hit).toBeDefined();
|
||||
// Snippet must be non-empty AND contain the matched token (unaccented form).
|
||||
expect(hit.snippet.length).toBeGreaterThan(0);
|
||||
expect(hit.snippet).toContain('needle-token-xyz');
|
||||
});
|
||||
|
||||
// #443 Fix #2 regression: >200 matching pages for a broad substring, with
|
||||
// exactly ONE exact-title hit. Without an ORDER BY on the 200-cap the exact
|
||||
// hit could be among the arbitrarily-dropped rows; the ORDER BY keeps the
|
||||
// strongest candidates so it must survive the cap and rank at the top.
|
||||
it('keeps an exact-title hit through the 200-cap on a >200-row match set', async () => {
|
||||
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||
|
||||
// 250 low-tier TEXT hits: the shared substring `capword` appears only in the
|
||||
// body, never the title, so each is a TEXT-tier match (weakest tier).
|
||||
for (let i = 0; i < 250; i++) {
|
||||
await insertPage({
|
||||
title: `filler-page-${i}`,
|
||||
textContent: `body contains capword here #${i}`,
|
||||
spaceId: isoSpace,
|
||||
});
|
||||
}
|
||||
// Exactly one EXACT-title hit for the same query token.
|
||||
const exact = await insertPage({
|
||||
title: 'capword',
|
||||
textContent: 'unrelated body text',
|
||||
spaceId: isoSpace,
|
||||
});
|
||||
|
||||
const { items } = (await svc.searchPage(
|
||||
{ query: 'capword', spaceId: isoSpace, substring: true, limit: 10 } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const ids = items.map((i: any) => i.id);
|
||||
// The exact-title hit must survive the 200-cap and appear in the top `limit`.
|
||||
expect(ids).toContain(exact);
|
||||
// And, being TITLE_EXACT, it must be the single strongest hit.
|
||||
expect(items[0].id).toBe(exact);
|
||||
});
|
||||
|
||||
// #443 Fix #3: titleOnly matches only the title, so it must not leak the page
|
||||
// body as the snippet (the old "first 300 chars of text_content" fallback).
|
||||
it('titleOnly does NOT return a text-body snippet', async () => {
|
||||
const pageId = await insertPage({
|
||||
title: 'titleonly-snippet-page',
|
||||
textContent: 'SECRET-BODY-CONTENT-NOT-IN-TITLE that must not leak.',
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{
|
||||
query: 'titleonly-snippet-page',
|
||||
spaceId,
|
||||
substring: true,
|
||||
titleOnly: true,
|
||||
} as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const hit = items.find((i: any) => i.id === pageId);
|
||||
expect(hit).toBeDefined();
|
||||
// The body text must not appear in the snippet; titleOnly → empty snippet.
|
||||
expect(hit.snippet).not.toContain('SECRET-BODY-CONTENT-NOT-IN-TITLE');
|
||||
expect(hit.snippet).toBe('');
|
||||
});
|
||||
|
||||
it('returns [] (not an error) for a query that matches nothing', async () => {
|
||||
const { items } = (await service.searchPage(
|
||||
{
|
||||
query: 'zzz-no-such-string-anywhere-42',
|
||||
spaceId,
|
||||
substring: true,
|
||||
} as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('a `%` query does NOT match everything (LIKE metacharacter escaped)', async () => {
|
||||
// Fresh space so we can assert on total counts without cross-test noise.
|
||||
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||
await insertPage({ title: 'alpha', spaceId: isoSpace });
|
||||
await insertPage({ title: 'beta', spaceId: isoSpace });
|
||||
const literal = await insertPage({
|
||||
title: '100%-coverage',
|
||||
spaceId: isoSpace,
|
||||
});
|
||||
|
||||
const { items } = (await svc.searchPage(
|
||||
{ query: '%', spaceId: isoSpace, substring: true, limit: 50 } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const ids = items.map((i: any) => i.id);
|
||||
// `%` is a literal → matches only the page that actually contains '%'.
|
||||
expect(ids).toContain(literal);
|
||||
expect(ids).not.toContain(
|
||||
items.find((i: any) => i.title === 'alpha')?.id,
|
||||
);
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
it('an `_` query does NOT match everything (LIKE metacharacter escaped)', async () => {
|
||||
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||
await insertPage({ title: 'gamma', spaceId: isoSpace });
|
||||
const literal = await insertPage({
|
||||
title: 'snake_case_name',
|
||||
spaceId: isoSpace,
|
||||
});
|
||||
|
||||
const { items } = (await svc.searchPage(
|
||||
{ query: '_', spaceId: isoSpace, substring: true, limit: 50 } as any,
|
||||
{ workspaceId },
|
||||
)) as any;
|
||||
|
||||
const ids = items.map((i: any) => i.id);
|
||||
expect(ids).toContain(literal);
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
it('applies the permission post-filter to the MERGED set BEFORE the limit', async () => {
|
||||
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||
const keep = await insertPage({
|
||||
title: 'perm-visible-target',
|
||||
spaceId: isoSpace,
|
||||
});
|
||||
const hidden = await insertPage({
|
||||
title: 'perm-hidden-target',
|
||||
spaceId: isoSpace,
|
||||
});
|
||||
|
||||
// Authenticated (userId set) so the permission filter runs; only `keep` is
|
||||
// accessible. limit 1 must NOT be able to select `hidden`.
|
||||
const svc = buildService({
|
||||
userSpaceIds: [isoSpace],
|
||||
accessibleIds: [keep],
|
||||
});
|
||||
const { items } = (await svc.searchPage(
|
||||
{
|
||||
query: 'perm-',
|
||||
spaceId: isoSpace,
|
||||
substring: true,
|
||||
limit: 1,
|
||||
} as any,
|
||||
{ userId: 'user-1', workspaceId },
|
||||
)) as any;
|
||||
|
||||
const ids = items.map((i: any) => i.id);
|
||||
expect(ids).toContain(keep);
|
||||
expect(ids).not.toContain(hidden);
|
||||
});
|
||||
|
||||
it('web-UI path (no `substring` flag) keeps the legacy FTS response shape', async () => {
|
||||
await insertPage({
|
||||
title: 'legacy shape page',
|
||||
textContent: 'searchable legacyword content',
|
||||
});
|
||||
|
||||
const { items } = (await service.searchPage(
|
||||
{ query: 'legacyword', spaceId } as any,
|
||||
{ userId: 'user-1', workspaceId },
|
||||
)) as any;
|
||||
|
||||
// Legacy hits carry rank + highlight + space, and NO path/snippet/score.
|
||||
const hit = items[0];
|
||||
expect(hit).toBeDefined();
|
||||
expect('rank' in hit).toBe(true);
|
||||
expect('highlight' in hit).toBe(true);
|
||||
expect('path' in hit).toBe(false);
|
||||
expect('snippet' in hit).toBe(false);
|
||||
expect('score' in hit).toBe(false);
|
||||
});
|
||||
});
|
||||
+40
-13
@@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
|
||||
| **Enterprise license required** | **No** | **Yes** | No | No | No |
|
||||
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
|
||||
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
|
||||
| **Lossless Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
|
||||
| **Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
|
||||
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
|
||||
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
|
||||
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
|
||||
@@ -115,8 +115,10 @@ All 41 tools, grouped by what you'd reach for them.
|
||||
- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
|
||||
max 100). Use `search` for lookups in large spaces.
|
||||
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
|
||||
- **`getPage`** — A page's content as clean **Markdown** (convenient, but a *lossy*
|
||||
view — block ids and exact table/callout structure are approximated).
|
||||
- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
|
||||
block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
|
||||
table spans/colwidth/background, indent, `callout.icon`, `orderedList.type`, and link
|
||||
`internal`/`target`/`rel`/`class`; use `getPageJson` when you need those).
|
||||
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
|
||||
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
|
||||
tools consume.
|
||||
@@ -186,10 +188,14 @@ All 41 tools, grouped by what you'd reach for them.
|
||||
|
||||
### Markdown round-trip
|
||||
|
||||
- **`exportPageMarkdown`** — Export a page to a single self-contained, **lossless
|
||||
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
||||
and diagrams, and a trailing comments-thread block. To replace a page's body from plain
|
||||
authoring Markdown, use `updatePageMarkdown`.
|
||||
- **`exportPageMarkdown`** — Export a page to a single self-contained
|
||||
**Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
||||
and diagrams, and a trailing comments-thread block. The download → edit → import
|
||||
round-trip regenerates block ids and **silently drops** the no-Markdown-representation
|
||||
attr set (table merge spans/colwidth/background, indent, `callout.icon`,
|
||||
`orderedList.type`, link `internal`/`target`/`rel`/`class`); keep those in ProseMirror
|
||||
JSON if they must survive. To replace a page's body from plain authoring Markdown, use
|
||||
`updatePageMarkdown`.
|
||||
|
||||
> **Removed in this release:** `importPageMarkdown` (the round-trip parser for an
|
||||
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
|
||||
@@ -287,21 +293,42 @@ so capable clients steer the model automatically.
|
||||
the debounced REST snapshot), then **reads → transforms → writes synchronously** in one
|
||||
tick so no remote update can interleave, and **waits for persistence acknowledgement**
|
||||
before returning.
|
||||
- **Per-page write serialization.** A per-`pageId` async mutex ensures two MCP writes to
|
||||
the same page never overlap; different pages never block each other.
|
||||
- **Per-page write serialization.** A per-`pageId` async mutex (keyed by the resolved
|
||||
page **UUID**, never a slugId) ensures two MCP writes to the same page never overlap;
|
||||
different pages never block each other. The lock helper fails fast if it is ever handed
|
||||
a non-UUID key, so a write path that forgot to resolve the id can never silently lock
|
||||
under a split key.
|
||||
|
||||
**Deploy requirement — single instance or sticky sessions.** This mutex is an
|
||||
in-process `Map`, and the cached collab sessions and the `stash_page` blob store are
|
||||
RAM-only and process-local. Behind a **multi-replica** load balancer **without sticky
|
||||
sessions**, two replicas can each "hold" the lock for the same page at once and per-page
|
||||
serialization is silently lost. Run the MCP/app as a **single instance**, or pin each
|
||||
page's traffic to one replica (sticky sessions / consistent hashing on the page id).
|
||||
There is deliberately no cross-process (e.g. Postgres advisory) lock yet — a conscious
|
||||
documented constraint. See the `Dockerfile` comment and the `MCP collaboration write
|
||||
path` block in `.env.example`.
|
||||
|
||||
**Rights-staleness window.** A cached collab session writes under the token captured at
|
||||
connect time (and the collab-token cache reuses a token for its TTL), so a **revoked**
|
||||
page access can lag by up to `MCP_COLLAB_SESSION_MAX_AGE_MS` (the hard session lifetime,
|
||||
default 10 min) before the next re-auth picks it up. Lower it to shorten the lag at the
|
||||
cost of more reconnects. This bounded window is an accepted trade-off; there is no
|
||||
push-based cache invalidation on a rights change.
|
||||
- **Transparent re-authentication.** Login uses email/password; expired tokens are
|
||||
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
||||
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
||||
triggers a single re-login.
|
||||
- **Lossless and lossy reads.** `getPageJson` returns the exact ProseMirror tree with
|
||||
block ids; `getPage` returns clean Markdown for convenience.
|
||||
- **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids;
|
||||
`getPage` returns canonical Markdown that drops only a fixed, documented attr set.
|
||||
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
|
||||
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
|
||||
highlights, sub/superscript and more, with defensive caps against pathological input.
|
||||
- **Structured tables & lossless Markdown round-trip.** Tables can be edited as a matrix
|
||||
- **Structured tables & Markdown round-trip.** Tables can be edited as a matrix
|
||||
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
|
||||
a page can be exported to and re-imported from a self-contained Docmost-flavoured
|
||||
Markdown file that preserves inline comment anchors and diagrams.
|
||||
Markdown file that preserves inline comment anchors and diagrams (block ids regenerate
|
||||
and a fixed no-Markdown-representation attr set is dropped — see `exportPageMarkdown`).
|
||||
- **Token-optimized responses.** API responses are filtered down to the fields agents
|
||||
actually need, and large collections (spaces, pages, comments, history) are paginated.
|
||||
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
|
||||
|
||||
+42
-14
@@ -43,7 +43,7 @@ Docmost-MCP не сочетают:
|
||||
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
|
||||
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
|
||||
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
|
||||
| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
||||
| **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
||||
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
|
||||
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
|
||||
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
|
||||
@@ -119,8 +119,11 @@ Docmost-MCP не сочетают:
|
||||
50, максимум 100). Для поиска в больших пространствах используйте `search`.
|
||||
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
|
||||
100).
|
||||
- **`getPage`** — Контент страницы как чистый **Markdown** (удобно, но это
|
||||
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются).
|
||||
- **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет
|
||||
лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
|
||||
markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
|
||||
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
|
||||
используйте `getPageJson`, когда они нужны).
|
||||
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
|
||||
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
|
||||
поблочного редактирования.
|
||||
@@ -191,10 +194,14 @@ Docmost-MCP не сочетают:
|
||||
|
||||
### Markdown: экспорт и импорт
|
||||
|
||||
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный, **lossless
|
||||
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
||||
диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из
|
||||
обычного авторского Markdown, используйте `updatePageMarkdown`.
|
||||
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный
|
||||
**Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
||||
диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать →
|
||||
импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без
|
||||
markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`,
|
||||
`orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror
|
||||
JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown,
|
||||
используйте `updatePageMarkdown`.
|
||||
|
||||
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
|
||||
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
|
||||
@@ -295,23 +302,44 @@ Docmost-MCP не сочетают:
|
||||
правки, которых ещё нет в дебаунс-снапшоте REST), затем **читает → трансформирует →
|
||||
пишет синхронно** в одном тике, чтобы никакое удалённое обновление не вклинилось, и
|
||||
**ждёт подтверждения сохранения** до возврата.
|
||||
- **Сериализация записи по странице.** Асинхронный мьютекс по `pageId` гарантирует, что
|
||||
две записи MCP в одну страницу никогда не пересекаются; разные страницы друг друга не
|
||||
блокируют.
|
||||
- **Сериализация записи по странице.** Асинхронный мьютекс по разрешённому **UUID**
|
||||
страницы (никогда не по slugId) гарантирует, что две записи MCP в одну страницу никогда
|
||||
не пересекаются; разные страницы друг друга не блокируют. Хелпер блокировки падает сразу
|
||||
(fail-fast), если ему передали не-UUID ключ, — путь записи, забывший разрезолвить id, не
|
||||
сможет молча взять лок под расщеплённым ключом.
|
||||
|
||||
**Требование к деплою — один инстанс или sticky-сессии.** Этот мьютекс — процесс-локальный
|
||||
`Map`, а кэш collab-сессий и хранилище `stashPage` живут только в RAM одного процесса. За
|
||||
**мультиреплика**-балансировщиком **без sticky-сессий** две реплики могут одновременно
|
||||
«держать» лок одной страницы, и сериализация по странице молча теряется. Запускайте
|
||||
MCP/приложение **одним инстансом** либо прибивайте трафик страницы к одной реплике
|
||||
(sticky-сессии / consistent hashing по id страницы). Кросс-процессной блокировки (например,
|
||||
Postgres advisory-lock) намеренно пока нет — осознанное задокументированное ограничение.
|
||||
См. комментарий в `Dockerfile` и блок `MCP collaboration write path` в `.env.example`.
|
||||
|
||||
**Окно устаревших прав.** Кэшированная collab-сессия пишет под токеном, захваченным в
|
||||
момент connect (а кэш collab-токена переиспользует токен в пределах своего TTL), поэтому
|
||||
**отозванный** доступ к странице может лагать до `MCP_COLLAB_SESSION_MAX_AGE_MS` (жёсткий
|
||||
срок жизни сессии, по умолчанию 10 мин), пока следующая переавторизация его не подхватит.
|
||||
Уменьшите значение, чтобы сократить лаг ценой большего числа переподключений. Это
|
||||
ограниченное окно — принятый trade-off; push-инвалидации кэша при смене прав нет.
|
||||
- **Прозрачная переавторизация.** Логин по email/паролю; истёкшие токены обновляются
|
||||
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
||||
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
||||
повторный логин.
|
||||
- **Lossless- и lossy-чтение.** `getPageJson` возвращает точное дерево ProseMirror с id
|
||||
блоков; `getPage` возвращает чистый Markdown для удобства.
|
||||
- **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков;
|
||||
`getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный
|
||||
набор атрибутов.
|
||||
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
|
||||
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
|
||||
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
|
||||
против патологического ввода.
|
||||
- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как
|
||||
- **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как
|
||||
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
|
||||
документа, а страницу — экспортировать и заново импортировать как самодостаточный
|
||||
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы.
|
||||
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы
|
||||
(id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления
|
||||
отбрасывается — см. `exportPageMarkdown`).
|
||||
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
|
||||
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
|
||||
пагинируются.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$comment": "Semantic palettes for drawioFromGraph (issue #425). DATA, not code: node `kind` -> fill/stroke slot, edge `kind` -> line-style props, per preset. The `default` node palette is the issue's base table; `dark` keeps the same hues on a dark canvas with lighter strokes/font; `colorblind-safe` maps every slot onto the Okabe-Ito qualitative palette (8 colours proven distinguishable for all common colour-vision deficiencies) so no two adjacent kinds collide. `fontColor`/`fillColor`/`strokeColor` are exact draw.io values. `edgeDefault` is the fallback line style; `group` is the (always-transparent) container stroke per preset.",
|
||||
"presets": {
|
||||
"default": {
|
||||
"canvasDark": false,
|
||||
"nodes": {
|
||||
"service": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf", "fontColor": "#000000" },
|
||||
"db": { "fillColor": "#d5e8d4", "strokeColor": "#82b366", "fontColor": "#000000" },
|
||||
"queue": { "fillColor": "#fff2cc", "strokeColor": "#d6b656", "fontColor": "#000000" },
|
||||
"gateway": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00", "fontColor": "#000000" },
|
||||
"error": { "fillColor": "#f8cecc", "strokeColor": "#b85450", "fontColor": "#000000" },
|
||||
"external": { "fillColor": "#f5f5f5", "strokeColor": "#666666", "fontColor": "#333333" },
|
||||
"security": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6", "fontColor": "#000000" }
|
||||
},
|
||||
"edges": {
|
||||
"sync": { "props": "" },
|
||||
"async": { "props": "dashed=1;" },
|
||||
"error": { "props": "dashed=1;strokeColor=#DD344C;" }
|
||||
},
|
||||
"edgeDefault": { "strokeColor": "#333333", "fontColor": "#333333" },
|
||||
"group": { "strokeColor": "#666666", "fontColor": "#333333" }
|
||||
},
|
||||
"dark": {
|
||||
"canvasDark": true,
|
||||
"nodes": {
|
||||
"service": { "fillColor": "#1a2a44", "strokeColor": "#7ea6e0", "fontColor": "#dae8fc" },
|
||||
"db": { "fillColor": "#1f331e", "strokeColor": "#97d077", "fontColor": "#d5e8d4" },
|
||||
"queue": { "fillColor": "#3a3218", "strokeColor": "#e5c15a", "fontColor": "#fff2cc" },
|
||||
"gateway": { "fillColor": "#3a2812", "strokeColor": "#ffb570", "fontColor": "#ffe6cc" },
|
||||
"error": { "fillColor": "#3a1c1b", "strokeColor": "#e08e8b", "fontColor": "#f8cecc" },
|
||||
"external": { "fillColor": "#2b2b2b", "strokeColor": "#999999", "fontColor": "#e0e0e0" },
|
||||
"security": { "fillColor": "#2c2338", "strokeColor": "#b39ddb", "fontColor": "#e1d5e7" }
|
||||
},
|
||||
"edges": {
|
||||
"sync": { "props": "" },
|
||||
"async": { "props": "dashed=1;" },
|
||||
"error": { "props": "dashed=1;strokeColor=#ff6b6b;" }
|
||||
},
|
||||
"edgeDefault": { "strokeColor": "#cccccc", "fontColor": "#e0e0e0" },
|
||||
"group": { "strokeColor": "#aaaaaa", "fontColor": "#e0e0e0" }
|
||||
},
|
||||
"colorblind-safe": {
|
||||
"canvasDark": false,
|
||||
"okabeIto": ["#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"],
|
||||
"nodes": {
|
||||
"service": { "fillColor": "#D6E9F5", "strokeColor": "#0072B2", "fontColor": "#000000" },
|
||||
"db": { "fillColor": "#D6EFE4", "strokeColor": "#009E73", "fontColor": "#000000" },
|
||||
"queue": { "fillColor": "#FCF8CC", "strokeColor": "#F0E442", "fontColor": "#000000" },
|
||||
"gateway": { "fillColor": "#FBEBD0", "strokeColor": "#E69F00", "fontColor": "#000000" },
|
||||
"error": { "fillColor": "#F7DDCC", "strokeColor": "#D55E00", "fontColor": "#000000" },
|
||||
"external": { "fillColor": "#EDEDED", "strokeColor": "#000000", "fontColor": "#000000" },
|
||||
"security": { "fillColor": "#F3DEEB", "strokeColor": "#CC79A7", "fontColor": "#000000" }
|
||||
},
|
||||
"edges": {
|
||||
"sync": { "props": "" },
|
||||
"async": { "props": "dashed=1;" },
|
||||
"error": { "props": "dashed=1;strokeColor=#D55E00;" }
|
||||
},
|
||||
"edgeDefault": { "strokeColor": "#000000", "fontColor": "#000000" },
|
||||
"group": { "strokeColor": "#000000", "fontColor": "#000000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,100 @@
|
||||
// Codegen: emit src/registry-stamp.generated.ts with a REGISTRY_STAMP hash of
|
||||
// the tool-specs REGISTRY CONTENT, so a build/ vs src/ skew (issue #447) is
|
||||
// detectable at runtime.
|
||||
// the ENTIRE src/ tree, so a build/ vs src/ skew (issue #447) is detectable at
|
||||
// runtime for ANY source file — not just tool-specs.ts.
|
||||
//
|
||||
// WHY hash the raw source text (not extracted structured data):
|
||||
// SHARED_TOOL_SPECS carries `buildShape` functions (the input SCHEMAS) which are
|
||||
// NOT serializable. The input schema is exactly one of the things that MUST stay
|
||||
// in sync between build/ and src/, so we cannot drop it from the hash. Rather
|
||||
// than probe zod with a fragile shim to reconstruct the schema shape, we hash the
|
||||
// STABLE, deterministic source TEXT of tool-specs.ts. That text fully captures
|
||||
// every field that must stay in sync — mcpName, inAppKey, description, tier,
|
||||
// catalogLine AND the buildShape bodies (input schemas) — with zero probing
|
||||
// fragility. Any edit to a spec (a renamed tool, a reworded description, a
|
||||
// changed schema field) changes the text and therefore the stamp.
|
||||
// WHY hash the whole src tree (not just tool-specs.ts): the runtime tools are
|
||||
// assembled from far more than the spec registry — client.ts, the client/*
|
||||
// domain modules, comment-signal.ts and the drawio-* helpers all ship in build/
|
||||
// and are loaded by the in-app server. Hashing ONLY tool-specs.ts meant an edit
|
||||
// to any of those (e.g. a behavioural fix in client.ts) left the stamp unchanged,
|
||||
// so a stale build/ served the OLD code silently (issue #486). Hashing every
|
||||
// src/**/*.ts closes that gap: any source edit changes the stamp.
|
||||
//
|
||||
// DETERMINISM: the hash is computed over the file bytes with line endings
|
||||
// normalized to LF and a single trailing newline stripped, so a CRLF checkout or
|
||||
// an editor's trailing-newline habit cannot make build/ and src/ disagree. No
|
||||
// Date.now / randomness. The loader's dev-only stale-check (docmost-client.loader.ts)
|
||||
// re-runs THIS SAME normalization + sha256 over src/tool-specs.ts and compares to
|
||||
// the built REGISTRY_STAMP; the two must compute identically.
|
||||
// WHY hash the raw source text (not extracted structured data): the tool input
|
||||
// SCHEMAS live as `buildShape` functions which are NOT serializable, so we cannot
|
||||
// reduce them to structured data without a fragile zod shim. Hashing the STABLE,
|
||||
// deterministic source TEXT captures every field that must stay in sync with zero
|
||||
// probing fragility. Any edit to any source file changes the text → the stamp.
|
||||
//
|
||||
// DETERMINISM: files are enumerated recursively, filtered to *.ts EXCLUDING
|
||||
// *.generated.ts (the codegen's OWN output — including it would create a
|
||||
// fixed-point cycle), and sorted by their POSIX-normalized path relative to src/
|
||||
// so the order is platform-independent. Each file contributes its relative path
|
||||
// AND its content with line endings normalized to LF and a single trailing
|
||||
// newline stripped, so a CRLF checkout or an editor's trailing-newline habit
|
||||
// cannot make build/ and src/ disagree. No Date.now / randomness. The loader's
|
||||
// dev-only stale-check (docmost-client.loader.ts) re-runs THIS SAME enumeration +
|
||||
// normalization + sha256 and compares to the built REGISTRY_STAMP; the two must
|
||||
// compute identically.
|
||||
//
|
||||
// This script runs from the `build` and `pretest` npm scripts BEFORE tsc, so
|
||||
// build/ always carries a stamp derived from the tool-specs.ts that was compiled.
|
||||
// build/ always carries a stamp derived from the src/ tree that was compiled.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC_DIR = join(__dirname, '..', 'src');
|
||||
const TOOL_SPECS_PATH = join(SRC_DIR, 'tool-specs.ts');
|
||||
const OUT_PATH = join(SRC_DIR, 'registry-stamp.generated.ts');
|
||||
|
||||
/**
|
||||
* Deterministic stamp of the tool-specs registry content. Kept as a plain
|
||||
* function (exported) so the algorithm has a single home; the loader duplicates
|
||||
* only the tiny normalize+sha256 steps because it lives in the CJS server build
|
||||
* and cannot import this ESM script. If you change the normalization here, mirror
|
||||
* it in apps/server/src/core/ai-chat/tools/docmost-client.loader.ts.
|
||||
* Recursively enumerate every `*.ts` file under `dir`, EXCLUDING the codegen's
|
||||
* own `*.generated.ts` output (a self-referential cycle otherwise). Returns
|
||||
* absolute paths, unsorted (the caller sorts by relative path for determinism).
|
||||
* Kept as a plain exported function so the algorithm has a single home; the
|
||||
* loader duplicates it because it lives in the CJS server build and cannot import
|
||||
* this ESM script. If you change the walk/filter here, mirror it in
|
||||
* apps/server/src/core/ai-chat/tools/docmost-client.loader.ts.
|
||||
*/
|
||||
export function computeRegistryStamp(toolSpecsSource) {
|
||||
const normalized = toolSpecsSource.replace(/\r\n/g, '\n').replace(/\n$/, '');
|
||||
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
||||
export function collectStampFiles(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...collectStampFiles(full));
|
||||
} else if (entry.endsWith('.ts') && !entry.endsWith('.generated.ts')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic stamp of the whole src/ tree. Enumerate + sort by POSIX-relative
|
||||
* path, then fold each file's relative path AND normalized content into one
|
||||
* sha256. MUST stay byte-for-byte identical to the loader's recompute.
|
||||
*/
|
||||
export function computeRegistryStamp(srcDir) {
|
||||
const files = collectStampFiles(srcDir)
|
||||
.map((abs) => ({
|
||||
rel: relative(srcDir, abs).split(sep).join('/'),
|
||||
abs,
|
||||
}))
|
||||
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
||||
const hash = createHash('sha256');
|
||||
for (const { rel, abs } of files) {
|
||||
const normalized = readFileSync(abs, 'utf8')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n$/, '');
|
||||
hash.update(rel, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(normalized, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const source = readFileSync(TOOL_SPECS_PATH, 'utf8');
|
||||
const stamp = computeRegistryStamp(source);
|
||||
const stamp = computeRegistryStamp(SRC_DIR);
|
||||
const out =
|
||||
'// AUTO-GENERATED by scripts/gen-registry-stamp.mjs — DO NOT EDIT BY HAND.\n' +
|
||||
'// A deterministic hash of src/tool-specs.ts content (tool names, descriptions,\n' +
|
||||
'// tiers, catalog lines and input schemas). Regenerated on every build/pretest\n' +
|
||||
'// so build/ always matches the compiled src. The in-app loader recomputes this\n' +
|
||||
'// from src and refuses to run on a mismatch (issue #447). This file is\n' +
|
||||
'// gitignored and produced by the build — see .gitignore.\n' +
|
||||
'// A deterministic hash of the whole src/ tree (every src/**/*.ts except\n' +
|
||||
'// *.generated.ts). Regenerated on every build/pretest so build/ always\n' +
|
||||
'// matches the compiled src. The in-app loader recomputes this from src and\n' +
|
||||
'// refuses to run on a mismatch (issue #447/#486). This file is gitignored\n' +
|
||||
'// and produced by the build — see .gitignore.\n' +
|
||||
`export const REGISTRY_STAMP = ${JSON.stringify(stamp)};\n`;
|
||||
writeFileSync(OUT_PATH, out, 'utf8');
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
+89
-4815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,704 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import { assertFullUuid } from "./errors.js";
|
||||
import {
|
||||
filterWorkspace,
|
||||
filterSpace,
|
||||
filterPage,
|
||||
filterComment,
|
||||
filterSearchResult,
|
||||
} from "../lib/filters.js";
|
||||
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import {
|
||||
applyAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
getAnchoredText,
|
||||
resolveAnchorSelection,
|
||||
normalizeForMatch,
|
||||
} from "../lib/comment-anchor.js";
|
||||
import { closestBlockHint } from "../lib/text-normalize.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
|
||||
// Public method surface of CommentsMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements ICommentsMixin` fails to compile on drift.
|
||||
export interface ICommentsMixin {
|
||||
listComments(pageId: string, includeResolved?: boolean): any;
|
||||
getComment(commentId: string): any;
|
||||
createComment(pageId: string, content: string, type?: "page" | "inline", selection?: string, parentCommentId?: string, suggestedText?: string): any;
|
||||
updateComment(commentId: string, content: string): any;
|
||||
deleteComment(commentId: string): any;
|
||||
resolveComment(commentId: string, resolved: boolean): any;
|
||||
checkNewComments(spaceId: string, since: string, parentPageId?: string): any;
|
||||
}
|
||||
|
||||
export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ICommentsMixin> & TBase {
|
||||
abstract class CommentsMixin extends Base implements ICommentsMixin {
|
||||
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
|
||||
|
||||
/**
|
||||
* Normalize a comment's `content` into a ProseMirror doc object before
|
||||
* markdown conversion. createComment/updateComment send content as a
|
||||
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
|
||||
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
|
||||
* string, so parse it first (guarded — fall back to the raw value on any
|
||||
* parse failure so a non-JSON legacy value is still handled gracefully).
|
||||
*/
|
||||
protected parseCommentContent(content: any): any {
|
||||
if (typeof content !== "string") return content;
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List comments on a page (cursor-paginated), content as markdown.
|
||||
*
|
||||
* DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the
|
||||
* agent sees only active discussions: a top-level comment with `resolvedAt`
|
||||
* set AND every reply under it (a reply of a closed thread is part of the
|
||||
* closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how
|
||||
* many resolved top-level threads were hidden so the agent can re-query with
|
||||
* `includeResolved: true` to see everything. Active threads always stay.
|
||||
*
|
||||
* Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) — callers that
|
||||
* need the full feed (lossless export, transformPage, checkNewComments) pass
|
||||
* `includeResolved: true` and read `.items`.
|
||||
*/
|
||||
async listComments(pageId: string, includeResolved = false) {
|
||||
await this.ensureAuthenticated();
|
||||
let allComments: any[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
// Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments
|
||||
// ever stops advancing the cursor (the exact #442 drift scenario) this loop
|
||||
// would otherwise spin forever accumulating duplicates.
|
||||
const MAX_PAGES = 50;
|
||||
let truncated = false;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const payload: Record<string, any> = { pageId, limit: 100 };
|
||||
if (cursor) payload.cursor = cursor;
|
||||
|
||||
const response = await this.client.post("/comments", payload);
|
||||
const data = response.data.data || response.data;
|
||||
const items = data.items || [];
|
||||
allComments = allComments.concat(items);
|
||||
|
||||
// Advance strictly via the server-issued cursor. A missing nextCursor or a
|
||||
// cursor identical to the one we just sent means the end (or a server that
|
||||
// ignores our pagination param) — stop instead of re-fetching page one.
|
||||
const next: string | null = data.meta?.nextCursor || null;
|
||||
if (!next || next === cursor) break;
|
||||
cursor = next;
|
||||
|
||||
// Reaching the ceiling with a still-advancing cursor means truncation.
|
||||
if (page === MAX_PAGES - 1) truncated = true;
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
console.warn(
|
||||
`listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||
);
|
||||
}
|
||||
|
||||
const mapped = allComments.map((comment: any) => {
|
||||
const markdown = comment.content
|
||||
? convertProseMirrorToMarkdown(
|
||||
this.parseCommentContent(comment.content),
|
||||
)
|
||||
: "";
|
||||
return filterComment(comment, markdown);
|
||||
});
|
||||
|
||||
if (includeResolved) {
|
||||
return { items: mapped, resolvedThreadsHidden: 0 };
|
||||
}
|
||||
|
||||
// Ids of RESOLVED top-level threads (a top-level comment has no
|
||||
// parentCommentId). A whole thread is hidden when its root is resolved.
|
||||
const resolvedRootIds = new Set(
|
||||
mapped
|
||||
.filter((c) => !c.parentCommentId && c.resolvedAt != null)
|
||||
.map((c) => c.id),
|
||||
);
|
||||
|
||||
const items = mapped.filter((c) => {
|
||||
// Hide the resolved root itself and every reply anchored to it. A reply's
|
||||
// own resolvedAt is irrelevant — its membership follows the parent thread.
|
||||
// ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId
|
||||
// always points at the thread ROOT (no reply-of-reply nesting), so a single
|
||||
// level of parent lookup covers a whole thread. If nested replies are ever
|
||||
// introduced, a deep reply of a resolved thread would need a root-walk here.
|
||||
if (!c.parentCommentId) return !resolvedRootIds.has(c.id);
|
||||
return !resolvedRootIds.has(c.parentCommentId);
|
||||
});
|
||||
|
||||
return { items, resolvedThreadsHidden: resolvedRootIds.size };
|
||||
}
|
||||
|
||||
|
||||
async getComment(commentId: string) {
|
||||
// Fail fast (#436): reject a truncated id before any network call.
|
||||
assertFullUuid("get_comment", "commentId", commentId);
|
||||
await this.ensureAuthenticated();
|
||||
const response = await this.client.post("/comments/info", { commentId });
|
||||
const comment = response.data.data || response.data;
|
||||
const markdown = comment.content
|
||||
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
|
||||
: "";
|
||||
return {
|
||||
data: filterComment(comment, markdown),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
|
||||
protected topLevelBlockTexts(doc: any): string[] {
|
||||
const content = doc && Array.isArray(doc.content) ? doc.content : [];
|
||||
return content
|
||||
.map((b: any) => blockPlainText(b))
|
||||
.filter((t: string) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when per-block anchoring failed but the (normalized) selection DOES
|
||||
* appear in the blocks' joined plain text — i.e. it straddles a block
|
||||
* boundary. Blocks are joined with a newline (collapsed to one space by
|
||||
* normalizeForMatch) so a selection whose parts are separated by a paragraph
|
||||
* break still matches. Callers only reach here after single-block anchoring
|
||||
* (incl. the markdown-strip fallback) has already failed.
|
||||
*/
|
||||
protected selectionSpansMultipleBlocks(
|
||||
blockTexts: string[],
|
||||
selection: string,
|
||||
): boolean {
|
||||
const normSel = normalizeForMatch(selection).norm.trim();
|
||||
if (normSel.length === 0) return false;
|
||||
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
|
||||
return joined.indexOf(normSel) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the actionable error for a createComment anchor MISS, porting
|
||||
* editPageText's self-correction affordances: an explicit "spans multiple
|
||||
* blocks" message when the selection straddles a block boundary, otherwise a
|
||||
* "closest block text" hint quoting the block that holds the selection's
|
||||
* longest token. `live` switches the wording between the pre-check (reading the
|
||||
* persisted page) and the post-create live-anchor failure (which rolls back).
|
||||
*/
|
||||
protected anchorNotFoundError(
|
||||
doc: any,
|
||||
selection: string,
|
||||
live: boolean,
|
||||
): Error {
|
||||
const blockTexts = this.topLevelBlockTexts(doc);
|
||||
const rolled = live ? " The comment was rolled back." : "";
|
||||
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
|
||||
return new Error(
|
||||
"createComment: the selection spans multiple blocks; anchor on a " +
|
||||
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
|
||||
rolled,
|
||||
);
|
||||
}
|
||||
const where = live ? "in the live document" : "in the page";
|
||||
return new Error(
|
||||
`createComment: could not find the selection text ${where} to anchor ` +
|
||||
"the comment. Provide the EXACT contiguous text from a single " +
|
||||
"paragraph/block (<=250 chars)." +
|
||||
closestBlockHint(blockTexts, selection) +
|
||||
rolled,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an inline comment anchored to its `selection` text, or a reply.
|
||||
*
|
||||
* Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a
|
||||
* `selection`: the `type` argument is kept for interface compatibility but the
|
||||
* effective type is coerced to "inline". The selection has to anchor in the
|
||||
* document; if it cannot, the comment is rolled back and an error is thrown so
|
||||
* the caller is forced to supply a proper inline selection rather than leaving
|
||||
* an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
|
||||
* their parent's anchor: they take NO selection and are not anchored.
|
||||
*/
|
||||
async createComment(
|
||||
pageId: string,
|
||||
content: string,
|
||||
type: "page" | "inline" = "page",
|
||||
selection?: string,
|
||||
parentCommentId?: string,
|
||||
suggestedText?: string,
|
||||
) {
|
||||
// Fail fast (#436): a provided parent id must be a full UUID before any
|
||||
// network call. Validate only when truthy — a falsy parentCommentId means
|
||||
// "top-level comment" (mirrors the isReply computation below), not a reply.
|
||||
if (parentCommentId) {
|
||||
assertFullUuid("createComment", "parentCommentId", parentCommentId);
|
||||
}
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const isReply = !!parentCommentId;
|
||||
const hasSuggestion =
|
||||
suggestedText !== undefined && suggestedText !== null;
|
||||
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
|
||||
// the exact anchored text, so it is only meaningful on a top-level inline
|
||||
// comment that carries a selection.
|
||||
if (hasSuggestion) {
|
||||
if (isReply) {
|
||||
throw new Error(
|
||||
"createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||
);
|
||||
}
|
||||
if (!selection || !selection.trim()) {
|
||||
throw new Error(
|
||||
"createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Only top-level comments are inline-anchored, so they are stored as
|
||||
// "inline". Replies carry no inline selection, so they keep the historical
|
||||
// general ("page") type — both backward-compatible and semantically correct.
|
||||
// The `type` argument is kept for interface compatibility; createComment
|
||||
// normalizes the effective type internally, so callers may pass "inline".
|
||||
const effectiveType: "page" | "inline" = isReply ? "page" : "inline";
|
||||
if (!isReply && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
"createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment",
|
||||
);
|
||||
}
|
||||
|
||||
// For a SUGGESTION, the value we store as the comment's `selection` must be
|
||||
// the RAW document substring the mark lands on (typographic quotes/dashes,
|
||||
// nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is
|
||||
// placed via normalization, so when the doc was auto-converted to
|
||||
// typographic the raw substring differs from the agent input; apply-time
|
||||
// compares the stored selection to the marked doc text STRICTLY, so storing
|
||||
// the raw substring is what makes "Apply" succeed instead of a spurious 409.
|
||||
// Captured in the pre-check below (which already reads the page) and used as
|
||||
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
||||
let anchoredSelection: string | null = null;
|
||||
// Set when the anchor matched only after stripping markdown from the
|
||||
// selection (the strip fallback); surfaced as a soft warning like
|
||||
// editPageText does, so a stale-markdown selection is flagged.
|
||||
let anchorNormalized = false;
|
||||
|
||||
// For a top-level comment, fail BEFORE creating anything when the selection
|
||||
// is not present in the persisted document — this avoids leaving an orphan
|
||||
// comment + notification behind. A read failure (network) is non-fatal: the
|
||||
// live anchor step below still enforces the anchoring invariant.
|
||||
if (!isReply && selection) {
|
||||
try {
|
||||
const page = await this.getPageJson(pageId);
|
||||
if (hasSuggestion) {
|
||||
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
|
||||
// exact anchored text, and ordinary anchoring silently takes the first
|
||||
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
|
||||
// rejected BEFORE creating the comment.
|
||||
const matches = countAnchorMatches(page.content, selection);
|
||||
if (matches === 0) {
|
||||
throw this.anchorNotFoundError(page.content, selection, false);
|
||||
}
|
||||
if (matches >= 2) {
|
||||
throw new Error(
|
||||
`createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
|
||||
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
|
||||
"(still <=250 chars) so it appears exactly once.",
|
||||
);
|
||||
}
|
||||
// Exactly one match: capture the RAW anchored substring to store as the
|
||||
// comment selection (so apply-time equality holds). If this returns
|
||||
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
||||
// the raw agent selection below rather than crash.
|
||||
anchoredSelection = getAnchoredText(page.content, selection);
|
||||
anchorNormalized = resolveAnchorSelection(
|
||||
page.content,
|
||||
selection,
|
||||
).normalized;
|
||||
} else {
|
||||
const resolved = resolveAnchorSelection(page.content, selection);
|
||||
if (!resolved.found) {
|
||||
throw this.anchorNotFoundError(page.content, selection, false);
|
||||
}
|
||||
anchorNormalized = resolved.normalized;
|
||||
}
|
||||
} catch (e) {
|
||||
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
|
||||
// swallow read/network errors so the live anchor step can still try (and
|
||||
// enforce) anchoring.
|
||||
if (
|
||||
e instanceof Error &&
|
||||
(e.message.startsWith("createComment: could not find the selection") ||
|
||||
e.message.startsWith(
|
||||
"createComment: the selection spans multiple blocks",
|
||||
) ||
|
||||
e.message.startsWith(
|
||||
"createComment: the suggestion's selection is ambiguous",
|
||||
))
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Pre-check getPageJson failed; deferring to live anchor step:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert through the full Docmost schema. Deliberately the NON-canonicalizing
|
||||
// variant: a comment body may carry a footnote definition with no matching
|
||||
// reference, and canonicalization would drop it (data loss). See
|
||||
// markdownToProseMirror vs markdownToProseMirrorCanonical.
|
||||
const jsonContent = await markdownToProseMirror(content);
|
||||
const payload: Record<string, any> = {
|
||||
pageId,
|
||||
content: JSON.stringify(jsonContent),
|
||||
type: effectiveType,
|
||||
};
|
||||
// For a suggestion, store the RAW anchored substring (anchoredSelection) so
|
||||
// the stored selection === the text under the mark === apply-time
|
||||
// expectedText. Ordinary comments (and the null fallback) keep the raw
|
||||
// agent selection — their selection is only display/anchor and never used
|
||||
// by apply, so their behavior is unchanged.
|
||||
if (!isReply && selection)
|
||||
payload.selection = anchoredSelection ?? selection;
|
||||
if (parentCommentId) payload.parentCommentId = parentCommentId;
|
||||
// Only a top-level inline comment (with a selection) may carry a suggestion.
|
||||
if (!isReply && selection && hasSuggestion) {
|
||||
payload.suggestedText = suggestedText;
|
||||
}
|
||||
|
||||
const response = await this.client.post("/comments/create", payload);
|
||||
const comment = response.data.data || response.data;
|
||||
const markdown = comment.content
|
||||
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
|
||||
: content;
|
||||
const result: any = {
|
||||
data: filterComment(comment, markdown),
|
||||
success: true,
|
||||
};
|
||||
|
||||
// Replies inherit the parent's anchor: no selection, no anchoring.
|
||||
if (isReply) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Anchor the comment in the document. The /comments/create API records the
|
||||
// comment + its `selection` text, but it does NOT insert the comment MARK
|
||||
// into the page content, so without this the inline comment has no
|
||||
// highlight/anchor and is not clickable. If anchoring fails the comment is
|
||||
// rolled back (deleted) and an error is thrown — never an orphan comment.
|
||||
const newCommentId: string = comment.id;
|
||||
// Guard: a create response without an id would mean writing a comment mark
|
||||
// with commentId: undefined and a later delete of a falsy id. We have no id
|
||||
// to roll back here (nothing was created with an id), so just fail loudly.
|
||||
if (!newCommentId) {
|
||||
throw new Error(
|
||||
"createComment: the server returned no comment id, so the comment could not be anchored",
|
||||
);
|
||||
}
|
||||
let anchored = false;
|
||||
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||
let ambiguousInLiveDoc = false;
|
||||
// Captured inside the transform on a not-found abort, so the rollback path
|
||||
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||
// LIVE document (the pre-check page is not in scope there).
|
||||
let liveNotFoundError: Error | null = null;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// /comments/create REST call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Route through the mutatePage seam (not the free function) so this
|
||||
// wrapper's uniqueness gate + rollback can be unit-tested without a live
|
||||
// Hocuspocus collab socket.
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (hasSuggestion) {
|
||||
// Authoritative uniqueness check against the LIVE document: a
|
||||
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
|
||||
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
|
||||
// no longer has exactly one occurrence (it changed since the
|
||||
// pre-check), abort so the just-created comment is rolled back
|
||||
// rather than mis-anchored to the first occurrence.
|
||||
const liveCount = countAnchorMatches(doc, selection as string);
|
||||
if (liveCount !== 1) {
|
||||
ambiguousInLiveDoc = liveCount >= 2;
|
||||
if (liveCount === 0) {
|
||||
liveNotFoundError = this.anchorNotFoundError(
|
||||
doc,
|
||||
selection as string,
|
||||
true,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
|
||||
anchored = true;
|
||||
return doc;
|
||||
}
|
||||
// Selection text not found in the LIVE document: abort the write. The
|
||||
// rollback + throw below turns this into a hard error.
|
||||
liveNotFoundError = this.anchorNotFoundError(
|
||||
doc,
|
||||
selection as string,
|
||||
true,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
result.verify = mutation.verify;
|
||||
} catch (e) {
|
||||
// The comment record already exists; roll it back so we never leave an
|
||||
// orphan, then rethrow the original anchoring error.
|
||||
await this.safeDeleteComment(newCommentId);
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!anchored) {
|
||||
// Mutation aborted because the selection was not found (or, for a
|
||||
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||
// and surface a hard error.
|
||||
await this.safeDeleteComment(newCommentId);
|
||||
if (ambiguousInLiveDoc) {
|
||||
throw new Error(
|
||||
"createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
|
||||
);
|
||||
}
|
||||
throw (
|
||||
liveNotFoundError ??
|
||||
new Error(
|
||||
"createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Soft warning (like editPageText): the selection only matched after
|
||||
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||
if (anchorNormalized) {
|
||||
result.warning =
|
||||
"The selection matched only after stripping markdown syntax; the comment " +
|
||||
"was anchored on the document's plain text. Copy the selection verbatim " +
|
||||
"from getPage / searchInPage output to avoid this.";
|
||||
}
|
||||
|
||||
result.anchored = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort rollback of a just-created comment. Swallows any delete failure
|
||||
* (logging under DEBUG) so a failed cleanup never masks the original error.
|
||||
*/
|
||||
protected async safeDeleteComment(commentId: string): Promise<void> {
|
||||
// Defense in depth: never call the delete API with a falsy id — there is
|
||||
// nothing to roll back, and deleteComment(undefined) would hit a bad route.
|
||||
if (!commentId) return;
|
||||
try {
|
||||
await this.deleteComment(commentId);
|
||||
} catch (delErr) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Failed to roll back comment after anchoring error:",
|
||||
delErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async updateComment(commentId: string, content: string) {
|
||||
// Fail fast (#436): reject a truncated id before any network call.
|
||||
assertFullUuid("updateComment", "commentId", commentId);
|
||||
await this.ensureAuthenticated();
|
||||
// NON-canonicalizing on purpose (comment body — see createComment).
|
||||
const jsonContent = await markdownToProseMirror(content);
|
||||
await this.client.post("/comments/update", {
|
||||
commentId,
|
||||
content: JSON.stringify(jsonContent),
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
commentId,
|
||||
message: "Comment updated successfully.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async deleteComment(commentId: string) {
|
||||
// Fail fast (#436): reject a truncated id before any network call.
|
||||
assertFullUuid("deleteComment", "commentId", commentId);
|
||||
await this.ensureAuthenticated();
|
||||
return this.client
|
||||
.post("/comments/delete", { commentId })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve or reopen a top-level comment thread (reversible — `resolved`
|
||||
* toggles the state). Only top-level comments can be resolved; the server
|
||||
* rejects resolving a reply. Hits POST /comments/resolve.
|
||||
*/
|
||||
async resolveComment(commentId: string, resolved: boolean) {
|
||||
// Fail fast (#436): reject a truncated id before any network call.
|
||||
assertFullUuid("resolveComment", "commentId", commentId);
|
||||
await this.ensureAuthenticated();
|
||||
const response = await this.client.post("/comments/resolve", {
|
||||
commentId,
|
||||
resolved,
|
||||
});
|
||||
const comment = response.data?.data ?? response.data;
|
||||
return {
|
||||
success: true,
|
||||
commentId,
|
||||
resolved,
|
||||
comment,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new comments across pages in a space (optionally scoped to a
|
||||
* subtree): pages updated after `since` are scanned and their comments
|
||||
* filtered by createdAt > since.
|
||||
*/
|
||||
async checkNewComments(
|
||||
spaceId: string,
|
||||
since: string,
|
||||
parentPageId?: string,
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const sinceDate = new Date(since);
|
||||
|
||||
// Reject an unparseable `since`: comparing against an Invalid Date silently
|
||||
// yields zero new comments (every `>` against NaN is false), which would
|
||||
// mask a malformed input as "nothing new" instead of erroring.
|
||||
if (Number.isNaN(sinceDate.getTime())) {
|
||||
throw new Error(
|
||||
`checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Enumerate the FULL set of pages in scope via the page tree (a complete
|
||||
// page index), NOT the bounded "/pages/recent" feed which caps at ~5000
|
||||
// recent items and silently misses comments on older pages.
|
||||
//
|
||||
// Subtree scope: when parentPageId is given, the scope is that page ITSELF
|
||||
// plus every descendant. Otherwise the scope is the whole space (all roots
|
||||
// and their descendants).
|
||||
//
|
||||
// NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not
|
||||
// bump it (verified on a live server), so such a filter silently misses
|
||||
// comments on pages that were not otherwise edited. The complete tree walk
|
||||
// already restricts the scope correctly, so no recent-feed allow-list is
|
||||
// needed any more.
|
||||
//
|
||||
// The subtree scope (parentPageId given) already INCLUDES the root node
|
||||
// itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so
|
||||
// no separate getPageRaw fetch for the parent is needed.
|
||||
const { pages: pagesInScope, truncated } = await this.enumerateSpacePages(
|
||||
spaceId,
|
||||
parentPageId,
|
||||
);
|
||||
|
||||
// 2. Fetch comments for each page, keep ones created after since
|
||||
const results: any[] = [];
|
||||
for (const page of pagesInScope) {
|
||||
try {
|
||||
// Full feed (incl. resolved): a "new comments since" scan reports all
|
||||
// recent activity; the active-only filter is scoped to listComments.
|
||||
const comments = (await this.listComments(page.id, true)).items;
|
||||
const newComments = comments.filter(
|
||||
(c: any) => new Date(c.createdAt) > sinceDate,
|
||||
);
|
||||
if (newComments.length > 0) {
|
||||
results.push({
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
comments: newComments,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Skip pages with errors (e.g. deleted between calls)
|
||||
}
|
||||
}
|
||||
|
||||
const totalNewComments = results.reduce(
|
||||
(sum, r) => sum + r.comments.length,
|
||||
0,
|
||||
);
|
||||
|
||||
// `truncated` is reported by enumerateSpacePages: it is true ONLY when the
|
||||
// stdio fallback BFS hit its node cap. The primary /pages/tree path is
|
||||
// uncapped, so a space with legitimately many pages is not falsely flagged.
|
||||
return {
|
||||
since,
|
||||
scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`,
|
||||
checkedPages: pagesInScope.length,
|
||||
pagesWithNewComments: results.length,
|
||||
totalNewComments,
|
||||
truncated,
|
||||
comments: results,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Image upload / embedding ---
|
||||
|
||||
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
|
||||
}
|
||||
return CommentsMixin;
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
// Shared client context + core seams (issue #450). The abstract base of the
|
||||
// DocmostClient mixin chain: it owns ALL shared instance state (the axios
|
||||
// client, apiUrl, auth tokens, the resolvePageId cache, the collab-token cache,
|
||||
// the sandbox/metrics sinks) and the core HTTP/auth/pagination/write seams every
|
||||
// domain module builds on. Domain modules are mixins layered on top; the final
|
||||
// DocmostClient (client.ts) assembles them. Extracted VERBATIM from the original
|
||||
// monolith — only field/seam visibility was widened from `private` to
|
||||
// `protected` so sibling mixins can reach the shared state through `this`, and
|
||||
// the cross-module methods that live in other mixins are declared `abstract`
|
||||
// here so `this.<method>` type-checks. No behaviour changed.
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import FormData from "form-data";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import {
|
||||
acquireCollabSession,
|
||||
isCollabAuthFailedError,
|
||||
} from "../lib/collab-session.js";
|
||||
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
|
||||
import { formatDocmostAxiosError } from "./errors.js";
|
||||
|
||||
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
|
||||
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
|
||||
// so the mixins compose into one prototype chain sharing this context.
|
||||
export type GConstructor<T = {}> = abstract new (...args: any[]) => T;
|
||||
|
||||
/**
|
||||
* Configuration for a DocmostClient / MCP server instance. A discriminated
|
||||
* union: either service-account credentials (email/password — the client calls
|
||||
* performLogin, powering the external /mcp HTTP endpoint and the stdio CLI) OR
|
||||
* a token getter (getToken — the client uses the returned BARE access JWT as
|
||||
* the Bearer and never calls performLogin; used for the internal per-user path).
|
||||
*
|
||||
* Both branches may ALSO carry an optional `getCollabToken` provider. When set,
|
||||
* content mutations (which go over the collaboration websocket) use the token it
|
||||
* returns INSTEAD of calling `POST /auth/collab-token`. The internal per-user
|
||||
* agent path uses this to hand the client a provenance collab token (signed
|
||||
* `actor:'agent'`+`aiChatId`), so agent content edits are attributed without a
|
||||
* spoofable client-side field. When absent the client keeps the original
|
||||
* `/auth/collab-token` path (service-account/stdio unchanged).
|
||||
*
|
||||
* Housed here (not in index.ts) so client.ts has no type dependency on index.ts;
|
||||
* index.ts re-exports it for the package's public surface.
|
||||
*/
|
||||
// Sink the stash tool writes blobs into. The host app binds this to its in-RAM
|
||||
// SandboxStore and composes the public `uri` (the package never sees the store
|
||||
// or any env). `put` returns the anonymous read URL plus integrity metadata.
|
||||
export type SandboxPut = (
|
||||
buf: Buffer,
|
||||
mime: string,
|
||||
) => { uri: string; sha256: string; size: number };
|
||||
|
||||
export type DocmostMcpConfig = { apiUrl: string } & (
|
||||
| { email: string; password: string }
|
||||
| { getToken: () => Promise<string> } // returns a BARE JWT; the client adds "Bearer "
|
||||
) & {
|
||||
// Optional collab-token provider (returns a ready collab JWT). Common to
|
||||
// both branches; see the type doc above.
|
||||
getCollabToken?: () => Promise<string>;
|
||||
// Optional blob sandbox sink. Present only where the stash tool is wired;
|
||||
// when absent, stashPage throws a clear "not configured" error. The
|
||||
// optional `has`/`evict` probes let stashPage keep its mirror counts honest
|
||||
// under the store's FIFO eviction (see stashPage); older sinks omit them.
|
||||
sandbox?: {
|
||||
put: SandboxPut;
|
||||
has?: (uri: string) => boolean;
|
||||
evict?: (uri: string) => void;
|
||||
};
|
||||
// Dependency-neutral metrics sink. When present, the client emits generic
|
||||
// (name, value, labels) samples; the HOST maps those names onto its own
|
||||
// metrics registry (the package never depends on prom-client or the server).
|
||||
// Absent in standalone/stdio mode → the client is a complete no-op here.
|
||||
onMetric?: (
|
||||
name: string,
|
||||
value: number,
|
||||
labels?: Record<string, string>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Collab-token cache TTL in milliseconds (issue #435). Read fresh from the
|
||||
* environment on every mint — like collab-session.ts readConfig — so tests and a
|
||||
* live rollback can change it without reloading the module.
|
||||
*
|
||||
* Why a cache at all: the live CollabSession registry (#400/#431) keys sessions
|
||||
* on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH
|
||||
* collab-token sources mint a FRESH token per mutation — the in-app provider
|
||||
* re-signs a JWT whose iat/exp (seconds) changes every second, and the external
|
||||
* MCP POSTs /auth/collab-token each call — so the token in the key changed on
|
||||
* every op and the session was almost never reused (connect-storms, 25s
|
||||
* timeouts, zombie sessions). Caching the token per-client keeps the key stable
|
||||
* across a burst of mutations so ONE session is reused.
|
||||
*
|
||||
* Default 5 min: well under the 24h collab-token lifetime AND <= the collab
|
||||
* session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the
|
||||
* permission-staleness window is not widened beyond what #431 already accepted.
|
||||
* The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the
|
||||
* cache — an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables
|
||||
* the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls
|
||||
* back to the 5-min default with the cache ON — parseInt yields NaN, which is
|
||||
* treated as "not configured", not as "disabled". So to turn the cache off you
|
||||
* must set the value to exactly 0, not to garbage.
|
||||
*/
|
||||
function readCollabTokenTtlMs(): number {
|
||||
const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10);
|
||||
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
export abstract class DocmostClientContext {
|
||||
protected client: AxiosInstance;
|
||||
protected token: string | null = null;
|
||||
protected apiUrl: string;
|
||||
// email/password are only set on the service-account (credentials) variant;
|
||||
// null on the getToken variant (where there are no credentials to log in with).
|
||||
protected email: string | null = null;
|
||||
protected password: string | null = null;
|
||||
// Per-user token provider. When set, login() calls it to obtain a BARE access
|
||||
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
|
||||
protected getTokenFn: (() => Promise<string>) | null = null;
|
||||
// Optional collab-token provider. When set, getCollabTokenWithReauth() returns
|
||||
// its token instead of calling POST /auth/collab-token; on a 401/403 it is
|
||||
// re-invoked once. Used by the internal agent to carry signed provenance.
|
||||
protected getCollabTokenFn: (() => Promise<string>) | null = null;
|
||||
// Optional blob-sandbox sink for the stash tool. Null when not configured.
|
||||
protected sandboxPut: SandboxPut | null = null;
|
||||
// Optional probes paired with the sink. `has` lets stashPage detect a blob
|
||||
// FIFO-evicted by a LATER put in the same stash; `evict` lets it free this
|
||||
// op's image blobs if the final doc put throws. Null when the sink omits them.
|
||||
protected sandboxHas: ((uri: string) => boolean) | null = null;
|
||||
protected sandboxEvict: ((uri: string) => void) | null = null;
|
||||
// Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric).
|
||||
// Null on the legacy positional form and whenever the host omits it → no-op.
|
||||
protected onMetricFn:
|
||||
| ((name: string, value: number, labels?: Record<string, string>) => void)
|
||||
| null = null;
|
||||
// In-flight login dedup: when the token expires, the 401 interceptor,
|
||||
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
|
||||
// can all call login() at once. Memoizing a single promise collapses that
|
||||
// thundering herd into ONE /auth/login request that everyone awaits.
|
||||
protected loginPromise: Promise<void> | null = null;
|
||||
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
|
||||
// page's canonical UUID, so repeated collab edits on the same page do not
|
||||
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
|
||||
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||
protected pageIdCache = new Map<string, string>();
|
||||
|
||||
// Collab-token cache (issue #435): the last minted collab token plus the
|
||||
// wall-clock time it was minted, so a burst of content mutations reuses ONE
|
||||
// token and therefore ONE live CollabSession (whose registry key includes the
|
||||
// token — #400 invariant 4). Per-instance: a DocmostClient is built per
|
||||
// user/per chat request, so a cached token can never leak across identities.
|
||||
// Reset whenever the client's identity changes (login() / this.token cleared);
|
||||
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
|
||||
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
|
||||
|
||||
// #487: an OPTIONAL abort signal the in-app tool host sets before each tool
|
||||
// call (a composite of the turn's Stop signal + a per-call wall-clock cap). It
|
||||
// is checked at safe-points BETWEEN the sequential HTTP calls of a paginated
|
||||
// read (paginateAll) and just before the atomic collab commit of a write (the
|
||||
// mutatePage/replacePage/mutateLiveContentUnlocked seams), so a Stop / cap
|
||||
// stops the NEXT network call from STARTING. An already-started single call may
|
||||
// still land — a documented limitation (#487).
|
||||
//
|
||||
// SINGLE-WRITER by phase-1 assumption: exactly one DocmostClient is built per
|
||||
// turn and shared by every tool call; the host sets this per call and does NOT
|
||||
// restore the prior value on unwind (set-and-leave) — a fresh client per turn
|
||||
// plus overwrite-by-the-next-call keeps it correct, and leaving a settled
|
||||
// call's signal in place is what makes a discarded race-loser throw on its
|
||||
// next safe-point. If the model emits PARALLEL in-app
|
||||
// tool calls they share this one field, so the per-call CAP of one call is not
|
||||
// guaranteed to bound another's in-flight pagination — but every composite the
|
||||
// host sets carries the SAME turn Stop signal, so a Stop still aborts whichever
|
||||
// signal is current. #487.
|
||||
protected toolAbortSignal: AbortSignal | null = null;
|
||||
|
||||
/**
|
||||
* #487: set (or clear with null) the in-app tool abort signal governing the
|
||||
* NEXT client call's safe-points. The host wraps each in-app tool call: it sets
|
||||
* the composite (Stop + per-call cap) here before invoking the tool and leaves
|
||||
* it in place afterwards (set-and-leave, NOT restored) — the next call
|
||||
* overwrites it, and a fresh client is built per turn. Public so the
|
||||
* server-side tool wrapper can reach it; harmless (a no-op) when never set.
|
||||
*/
|
||||
public setToolAbortSignal(signal: AbortSignal | null): void {
|
||||
this.toolAbortSignal = signal;
|
||||
}
|
||||
|
||||
/** #487: the abort signal currently governing this client's safe-points. */
|
||||
public getToolAbortSignal(): AbortSignal | null {
|
||||
return this.toolAbortSignal;
|
||||
}
|
||||
|
||||
// Two construction forms:
|
||||
// - new DocmostClient(config) // discriminated union (current)
|
||||
// - new DocmostClient(baseURL, email, password) // legacy positional creds
|
||||
// The positional form is retained so existing callers/tests keep working; it
|
||||
// is exactly equivalent to the credentials branch of the object form.
|
||||
constructor(config: DocmostMcpConfig);
|
||||
constructor(baseURL: string, email: string, password: string);
|
||||
constructor(
|
||||
configOrBaseURL: DocmostMcpConfig | string,
|
||||
email?: string,
|
||||
password?: string,
|
||||
) {
|
||||
// Normalize the legacy positional form into the object union.
|
||||
const config: DocmostMcpConfig =
|
||||
typeof configOrBaseURL === "string"
|
||||
? { apiUrl: configOrBaseURL, email: email!, password: password! }
|
||||
: configOrBaseURL;
|
||||
|
||||
this.apiUrl = config.apiUrl;
|
||||
if ("getToken" in config) {
|
||||
// Token variant: carry the user's JWT via getToken; no credentials, so
|
||||
// login() must never call performLogin (there is nothing to log in with).
|
||||
this.getTokenFn = config.getToken;
|
||||
} else {
|
||||
// Service-account variant: behaves exactly as before (performLogin).
|
||||
this.email = config.email;
|
||||
this.password = config.password;
|
||||
}
|
||||
// Optional, available to both variants. When present, content mutations get
|
||||
// their collab token from here instead of POST /auth/collab-token.
|
||||
if (config.getCollabToken) {
|
||||
this.getCollabTokenFn = config.getCollabToken;
|
||||
}
|
||||
if (config.sandbox) {
|
||||
this.sandboxPut = config.sandbox.put;
|
||||
this.sandboxHas = config.sandbox.has ?? null;
|
||||
this.sandboxEvict = config.sandbox.evict ?? null;
|
||||
}
|
||||
// Legacy positional form carries no onMetric → null (complete no-op).
|
||||
this.onMetricFn = config.onMetric ?? null;
|
||||
this.client = axios.create({
|
||||
baseURL: this.apiUrl,
|
||||
// Default request timeout so a hung connection cannot wedge a per-page
|
||||
// lock or block the server indefinitely. Multipart uploads override this
|
||||
// with a longer per-request timeout.
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Re-authenticate transparently on a 401/403 once: the JWT authToken can
|
||||
// expire while the server is long-running, after which every cached-token
|
||||
// request would otherwise fail until a manual restart. On such a response,
|
||||
// clear the stale token, perform a fresh login, and replay the original
|
||||
// request exactly once (guarded by config._retry to avoid infinite loops;
|
||||
// the login request itself is never retried).
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
const status = error.response?.status;
|
||||
const isAuthError = status === 401 || status === 403;
|
||||
const isLoginRequest =
|
||||
typeof config?.url === "string" && config.url.includes("/auth/login");
|
||||
|
||||
if (config && isAuthError && !config._retry && !isLoginRequest) {
|
||||
config._retry = true;
|
||||
// Drop the stale token + Authorization header before re-login. Also
|
||||
// clear the collab-token cache (#435): a new identity/login must not
|
||||
// keep serving a collab token minted under the old one.
|
||||
this.token = null;
|
||||
this.collabTokenCache = null;
|
||||
delete this.client.defaults.headers.common["Authorization"];
|
||||
try {
|
||||
await this.login();
|
||||
} catch (loginError) {
|
||||
// Re-login failed: surface the original error to the caller.
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// Re-issue the original request with the freshly minted Bearer token.
|
||||
// Read it from the default header that login() just set, not from
|
||||
// this.token, to avoid a theoretical "Bearer null" if this.token was
|
||||
// cleared between login() resolving and this point.
|
||||
config.headers = config.headers || {};
|
||||
config.headers["Authorization"] =
|
||||
this.client.defaults.headers.common["Authorization"];
|
||||
return this.client.request(config);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Diagnostics interceptor (issue #437). Registered AFTER the re-login
|
||||
// interceptor so a successful re-login retry (which resolves to a real
|
||||
// response) is never seen here as an error; only a genuine failure reaches
|
||||
// this rejection handler. It reformats error.message IN PLACE (see
|
||||
// formatDocmostAxiosError — kept as a mutation, not a custom Error class, so
|
||||
// the surrounding axios.isAxiosError / error.response?.status / config._retry
|
||||
// checks keep working) and re-rejects the SAME error. The _docmostFormatted
|
||||
// flag makes a re-processed retry-failure a no-op.
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
formatDocmostAxiosError(error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// --- Cross-module seams (issue #450) -----------------------------------
|
||||
// A method in one domain mixin sometimes calls a PROTECTED method owned by
|
||||
// another mixin (e.g. nodes-write -> validateDocUrls in doc-validate). Those
|
||||
// callees are `protected`, so they cannot be surfaced through the public
|
||||
// per-mixin interfaces. Declaring them here on the shared base lets `this.<m>`
|
||||
// type-check across modules. Each is a stub that is ALWAYS overridden by the
|
||||
// owning mixin (layered above this base in the chain), so the body never runs;
|
||||
// it throws only to make an impossible mis-wiring loud instead of silent.
|
||||
// (The PUBLIC cross-module callees — getPage, getPageJson, listComments,
|
||||
// deleteComment, listPageHistory — arrive via the mixins' public interfaces,
|
||||
// so they are not restated here.)
|
||||
protected enumerateSpacePages(
|
||||
_spaceId: string,
|
||||
_rootPageId?: string,
|
||||
): Promise<{ pages: any[]; truncated: boolean }> {
|
||||
throw new Error("enumerateSpacePages not wired (missing ReadMixin)");
|
||||
}
|
||||
protected validateDocUrls(_node: any, _depth?: number): void {
|
||||
throw new Error("validateDocUrls not wired (missing DocValidateMixin)");
|
||||
}
|
||||
protected validateDocStructure(_node: any, _depth?: number): void {
|
||||
throw new Error("validateDocStructure not wired (missing DocValidateMixin)");
|
||||
}
|
||||
protected assertValidNodeShape(_op: string, _node: any): void {
|
||||
throw new Error("assertValidNodeShape not wired (missing DocValidateMixin)");
|
||||
}
|
||||
protected fetchInternalFile(
|
||||
_src: string,
|
||||
): Promise<{ buffer: Buffer; mime: string }> {
|
||||
throw new Error("fetchInternalFile not wired (missing StashMixin)");
|
||||
}
|
||||
protected uploadAttachmentBuffer(
|
||||
_pageId: string,
|
||||
_buffer: Buffer,
|
||||
_fileName: string,
|
||||
_mime: string,
|
||||
): Promise<{ id: string; fileName: string; fileSize: number }> {
|
||||
throw new Error("uploadAttachmentBuffer not wired (missing MediaMixin)");
|
||||
}
|
||||
protected fetchAttachmentText(_src: string): Promise<string> {
|
||||
throw new Error("fetchAttachmentText not wired (missing MediaMixin)");
|
||||
}
|
||||
// PUBLIC cross-module callees. Declared here too (as always-overridden stubs)
|
||||
// so a mixin calling e.g. `this.getPageJson` type-checks against the base —
|
||||
// the mixin's own public interface only covers its own methods. The real
|
||||
// implementations live in ReadMixin / CommentsMixin / PagesMixin and shadow
|
||||
// these on the prototype chain.
|
||||
getPage(_pageId: string): Promise<any> {
|
||||
throw new Error("getPage not wired (missing ReadMixin)");
|
||||
}
|
||||
getPageJson(_pageId: string): Promise<any> {
|
||||
throw new Error("getPageJson not wired (missing ReadMixin)");
|
||||
}
|
||||
listComments(_pageId: string, _includeResolved?: boolean): Promise<any> {
|
||||
throw new Error("listComments not wired (missing CommentsMixin)");
|
||||
}
|
||||
deleteComment(_commentId: string): Promise<any> {
|
||||
throw new Error("deleteComment not wired (missing CommentsMixin)");
|
||||
}
|
||||
listPageHistory(_pageId: string, _cursor?: string): Promise<any> {
|
||||
throw new Error("listPageHistory not wired (missing PagesMixin)");
|
||||
}
|
||||
|
||||
/** Application base URL (API URL without the /api suffix). */
|
||||
get appUrl(): string {
|
||||
return this.apiUrl.replace(/\/api\/?$/, "");
|
||||
}
|
||||
|
||||
|
||||
async login() {
|
||||
// Reuse an in-flight login if one is already running so concurrent callers
|
||||
// share a single token fetch instead of each issuing their own.
|
||||
if (!this.loginPromise) {
|
||||
// Token variant: re-fetch a BARE JWT via getToken() (there are no
|
||||
// credentials to log in with — on a 401/403 the interceptor below calls
|
||||
// login() again, which re-invokes getToken()). Credentials variant:
|
||||
// performLogin against /auth/login exactly as before.
|
||||
const fetchToken = this.getTokenFn
|
||||
? this.getTokenFn()
|
||||
: performLogin(this.apiUrl, this.email!, this.password!);
|
||||
this.loginPromise = fetchToken
|
||||
.then((token) => {
|
||||
// Guard against an empty/invalid token (e.g. a getToken provider that
|
||||
// resolves to "" or null): without this an empty token would set a
|
||||
// literal "Authorization: Bearer null"/"Bearer " header and every
|
||||
// request would 401 with a confusing error. Fail loudly instead.
|
||||
if (typeof token !== "string" || token.length === 0) {
|
||||
throw new Error("getToken returned an empty token");
|
||||
}
|
||||
this.token = token;
|
||||
// Identity (re)established: drop any collab token minted under a
|
||||
// previous identity so the #435 cache can never outlive it.
|
||||
this.collabTokenCache = null;
|
||||
this.client.defaults.headers.common["Authorization"] =
|
||||
`Bearer ${token}`;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loginPromise = null;
|
||||
});
|
||||
}
|
||||
return this.loginPromise;
|
||||
}
|
||||
|
||||
|
||||
async ensureAuthenticated() {
|
||||
if (!this.token) {
|
||||
await this.login();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a collaboration token, transparently re-authenticating once on a
|
||||
* 401/403. getCollabToken() uses bare axios internally, so it is NOT covered
|
||||
* by this.client's response interceptor; this helper replicates that
|
||||
* behaviour for collab-token requests: ensure a token, try once, and on an
|
||||
* expired-token auth error perform a fresh login and retry exactly once.
|
||||
*
|
||||
* Collab-token cache (issue #435): both sources — the getCollabToken provider
|
||||
* (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) — mint
|
||||
* a FRESH token per call, whose string therefore changes every op. Since the
|
||||
* live CollabSession registry keys on the token string (#400/#431 invariant 4),
|
||||
* that churned the key and defeated session reuse. So we cache the last minted
|
||||
* token per-client for readCollabTokenTtlMs() and hand it back for a burst of
|
||||
* mutations, keeping the session key stable. `forceRefresh` bypasses the cache
|
||||
* (the 401/403 reauth retry uses it, so the retry cannot be handed the same
|
||||
* stale token that just failed — otherwise reauth would be a no-op). TTL 0
|
||||
* disables the cache: exact fetch-per-call legacy behaviour.
|
||||
*/
|
||||
protected async getCollabTokenWithReauth(
|
||||
forceRefresh = false,
|
||||
): Promise<string> {
|
||||
const ttl = readCollabTokenTtlMs();
|
||||
// Serve the cached collab token while it is still fresh (identity isolation
|
||||
// is preserved: the cache is a per-instance field on a client built per
|
||||
// user/per chat request, and it is cleared on every identity change).
|
||||
if (
|
||||
!forceRefresh &&
|
||||
ttl > 0 &&
|
||||
this.collabTokenCache &&
|
||||
Date.now() - this.collabTokenCache.mintedAt < ttl
|
||||
) {
|
||||
return this.collabTokenCache.token;
|
||||
}
|
||||
|
||||
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
|
||||
// (the internal agent's provenance collab token), use it instead of the
|
||||
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
|
||||
// signed token expired between content mutations in a long agent turn).
|
||||
if (this.getCollabTokenFn) {
|
||||
try {
|
||||
const token = await this.getCollabTokenFn();
|
||||
if (typeof token !== "string" || token.length === 0) {
|
||||
throw new Error("getCollabToken returned an empty token");
|
||||
}
|
||||
return this.rememberCollabToken(token, ttl);
|
||||
} catch (e) {
|
||||
// On an auth error retry EXACTLY once, forcing a refresh so the retry
|
||||
// re-invokes the provider (bypassing the cache) for a genuinely fresh
|
||||
// token. `!forceRefresh` bounds it to a single retry (no loop).
|
||||
if (this.isCollabAuthError(e) && !forceRefresh) {
|
||||
return this.getCollabTokenWithReauth(true);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await this.ensureAuthenticated();
|
||||
try {
|
||||
const token = await getCollabToken(this.apiUrl, this.token!);
|
||||
return this.rememberCollabToken(token, ttl);
|
||||
} catch (e) {
|
||||
// getCollabToken wraps the AxiosError in a plain Error but attaches the
|
||||
// HTTP status as `.status`, so isCollabAuthError detects an auth failure
|
||||
// via either the raw AxiosError shape OR the attached status.
|
||||
if (this.isCollabAuthError(e) && !forceRefresh) {
|
||||
// Fresh login (which clears this.token AND the collab-token cache), then
|
||||
// retry exactly once with the cache bypassed via forceRefresh.
|
||||
await this.login();
|
||||
return this.getCollabTokenWithReauth(true);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a freshly minted collab token in the per-client cache (issue #435) and
|
||||
* return it unchanged. No-op write when the cache is disabled (ttl<=0) or the
|
||||
* token is empty, so a disabled cache is exact fetch-per-call legacy behaviour
|
||||
* and a bad token is never cached.
|
||||
*/
|
||||
protected rememberCollabToken(token: string, ttl: number): string {
|
||||
if (ttl > 0 && typeof token === "string" && token.length > 0) {
|
||||
this.collabTokenCache = { token, mintedAt: Date.now() };
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error carries a 401/403 — either as a raw AxiosError
|
||||
* (`error.response.status`) or as the plain-Error `.status` that
|
||||
* lib/auth-utils.getCollabToken attaches after wrapping the AxiosError.
|
||||
*/
|
||||
protected isCollabAuthError(e: unknown): boolean {
|
||||
const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined;
|
||||
const attachedStatus = (e as any)?.status;
|
||||
return (
|
||||
axiosStatus === 401 ||
|
||||
axiosStatus === 403 ||
|
||||
attachedStatus === 401 ||
|
||||
attachedStatus === 403
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a collab write and, on a Hocuspocus HANDSHAKE auth failure, self-heal
|
||||
* once (#486). Symmetric to the HTTP-401 path in getCollabTokenWithReauth: the
|
||||
* REST interceptor and login() already drop the cached collab token on a 401/
|
||||
* 403, but a rejected WEBSOCKET handshake left the stale token in the cache, so
|
||||
* every subsequent mutation kept re-presenting the same bad token for up to the
|
||||
* collab-token TTL (minutes) with no self-heal. Here, when the write rejects
|
||||
* with the tagged collab-auth error, we invalidate the cached token and retry
|
||||
* the write EXACTLY once with a force-refreshed token. Not a loop: a second
|
||||
* failure (or any non-auth error) propagates unchanged.
|
||||
*
|
||||
* `write` receives the token to use, so the retry can hand it a genuinely fresh
|
||||
* one rather than re-running with the same stale string.
|
||||
*/
|
||||
protected async writeWithCollabAuthRetry<T>(
|
||||
collabToken: string,
|
||||
write: (token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await write(collabToken);
|
||||
} catch (e) {
|
||||
if (!isCollabAuthFailedError(e)) throw e;
|
||||
// The WS handshake rejected our token: drop it from the cache so it can't
|
||||
// be reused for the rest of the TTL, mint a fresh one (forceRefresh bypasses
|
||||
// the cache and re-invokes the provider/login), and retry the write once.
|
||||
this.collabTokenCache = null;
|
||||
const fresh = await this.getCollabTokenWithReauth(true);
|
||||
return await write(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the collaboration websocket, read the live doc, apply
|
||||
* `transform`, write the result, and wait for the server to persist it —
|
||||
* WITHOUT acquiring the per-page lock.
|
||||
*
|
||||
* This mirrors collaboration.mutatePageContent EXCEPT that it does not call
|
||||
* withPageLock. It exists solely so replaceImage can hold ONE withPageLock
|
||||
* across its scan -> upload -> write sequence: the per-page mutex is NOT
|
||||
* reentrant, so calling the normal (self-locking) mutatePageContent inside an
|
||||
* outer withPageLock for the same pageId would deadlock. The caller MUST hold
|
||||
* the page lock for the whole operation; this helper assumes that invariant.
|
||||
*
|
||||
* `transform` receives the live ProseMirror doc and returns the NEW full doc
|
||||
* to write, or `null` to abort with no write. Errors thrown by `transform`
|
||||
* propagate to the caller.
|
||||
*
|
||||
* Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so
|
||||
* every content mutator (including replaceImage) can return a verifiable
|
||||
* change report. The report is computed AFTER the atomic read->write and
|
||||
* never throws.
|
||||
*/
|
||||
protected async mutateLiveContentUnlocked(
|
||||
pageId: string,
|
||||
collabToken: string,
|
||||
transform: (liveDoc: any) => any | null,
|
||||
): Promise<MutationResult> {
|
||||
// Reuse a live CollabSession for the page (issue #400) instead of opening a
|
||||
// fresh provider per op. acquireCollabSession does NOT take the per-page
|
||||
// lock — the caller (replaceImage) already holds ONE withPageLock across its
|
||||
// scan -> upload -> write sequence, and the mutex is not reentrant, so
|
||||
// taking it here would deadlock. The synchronous read->write section and the
|
||||
// unsyncedChanges/connectionLost ack logic live in CollabSession.mutate,
|
||||
// preserved verbatim from the old inline machine (incl. the #152 structural
|
||||
// diff that keeps a live editor's cursor anchored).
|
||||
// Wrap in the collab-auth self-heal (#486): a rejected WS handshake drops the
|
||||
// cached collab token and retries once with a fresh one (the retry passes the
|
||||
// refreshed token down to acquireCollabSession via `token`).
|
||||
return this.writeWithCollabAuthRetry(collabToken, async (token) => {
|
||||
const session = await acquireCollabSession(pageId, token, this.apiUrl, {
|
||||
// Only the actual 25s collab connect timeout emits this — the connect-vs-
|
||||
// unload signal; the other failure paths must NOT emit it.
|
||||
onConnectTimeout: () =>
|
||||
this.onMetricFn?.("collab_connect_timeouts_total", 1),
|
||||
});
|
||||
try {
|
||||
// #487 PRE-COMMIT safe-point (reentrant twin of mutatePageContent): a
|
||||
// Stop/cap after acquiring the session but before the atomic write skips
|
||||
// this commit. Same limitation applies (stops the NEXT commit only).
|
||||
this.toolAbortSignal?.throwIfAborted();
|
||||
return await session.mutate(transform);
|
||||
} catch (e) {
|
||||
// Drop the session on any failure so the next call reconnects fresh.
|
||||
session.destroy("mutate failed");
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic pagination handler for Docmost API endpoints
|
||||
*/
|
||||
async paginateAll<T = any>(
|
||||
endpoint: string,
|
||||
basePayload: Record<string, any> = {},
|
||||
limit: number = 100,
|
||||
): Promise<T[]> {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const clampedLimit = Math.max(1, Math.min(100, limit));
|
||||
|
||||
// Hard ceiling on the number of pages to fetch: guards against a server
|
||||
// that returns a perpetually-true hasNextPage (which would otherwise loop
|
||||
// forever and accumulate duplicates).
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
let cursor: string | undefined;
|
||||
let allItems: T[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
// #487 safe-point: a Stop (or the in-app tool per-call cap) that fires
|
||||
// BETWEEN sequential page fetches must stop the NEXT request from starting
|
||||
// — a read tool that would otherwise paginate for minutes is interrupted
|
||||
// here. throwIfAborted() rejects with the signal's reason.
|
||||
this.toolAbortSignal?.throwIfAborted();
|
||||
const payload: Record<string, any> = {
|
||||
...basePayload,
|
||||
limit: clampedLimit,
|
||||
};
|
||||
if (cursor) payload.cursor = cursor;
|
||||
|
||||
const response = await this.client.post(endpoint, payload);
|
||||
|
||||
const data = response.data;
|
||||
const items = data.data?.items || data.items || [];
|
||||
const meta = data.data?.meta || data.meta;
|
||||
|
||||
allItems = allItems.concat(items);
|
||||
|
||||
// Advance strictly via the server-issued cursor. A missing nextCursor (or
|
||||
// hasNextPage false) means we reached the end. A cursor identical to the
|
||||
// one we just sent means the server did not understand our pagination
|
||||
// param — stop instead of re-fetching page one forever and duplicating.
|
||||
const next = meta?.hasNextPage ? meta?.nextCursor : null;
|
||||
if (!next || next === cursor) {
|
||||
// If the server still reports more pages but stopped issuing a usable
|
||||
// cursor at the ceiling, flag the result as truncated below.
|
||||
if (page === MAX_PAGES - 1 && meta?.hasNextPage) truncated = true;
|
||||
break;
|
||||
}
|
||||
cursor = next;
|
||||
|
||||
// Reaching the ceiling with more pages still available means the result
|
||||
// set is truncated.
|
||||
if (page === MAX_PAGES - 1) truncated = true;
|
||||
}
|
||||
|
||||
// If the loop stopped because it hit the MAX_PAGES ceiling while the server
|
||||
// still reported more results, the result set is truncated — warn so the
|
||||
// caller is not silently handed an incomplete list.
|
||||
if (truncated) {
|
||||
console.warn(
|
||||
`paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||
);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
|
||||
/** Raw page info including the ProseMirror JSON content and slugId. */
|
||||
async getPageRaw(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const response = await this.client.post("/pages/info", { pageId });
|
||||
return response.data?.data ?? response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
|
||||
* so every collaboration document the MCP opens is named `page.<uuid>` — the
|
||||
* SAME name the web editor always uses (`page.${page.id}`).
|
||||
*
|
||||
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
|
||||
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
|
||||
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
|
||||
* DB row that produced TWO independent Yjs documents whose debounced stores
|
||||
* clobbered each other — the agent's edit was silently lost (#260).
|
||||
*
|
||||
* A UUID input short-circuits with no network round-trip. A slugId is resolved
|
||||
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
|
||||
* repeated edits on the same page add no extra request.
|
||||
*/
|
||||
protected async resolvePageId(pageId: string): Promise<string> {
|
||||
if (isUuid(pageId)) return pageId;
|
||||
const cached = this.pageIdCache.get(pageId);
|
||||
if (cached) return cached;
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const uuid = data?.id;
|
||||
if (typeof uuid !== "string" || !uuid) {
|
||||
throw new Error(
|
||||
`Could not resolve a canonical page id for "${pageId}"`,
|
||||
);
|
||||
}
|
||||
this.pageIdCache.set(pageId, uuid);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Page-locked write seam over collaboration.mutatePageContent. Production just
|
||||
* delegates; it exists as an overridable method so the insertFootnote wrapper
|
||||
* (transform abort-on-not-found + response shaping) can be unit-tested without
|
||||
* standing up a live Hocuspocus collab socket.
|
||||
*
|
||||
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||
* lock"): every write must lock and key its CollabSession by the UUID, never a
|
||||
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
|
||||
* already resolved pays no extra round-trip; centralizing it here means a
|
||||
* caller that reaches this seam with a raw slugId still locks correctly instead
|
||||
* of silently splitting the mutex key. withPageLock also asserts the key is a
|
||||
* UUID as a hard backstop.
|
||||
*/
|
||||
protected async mutatePage(
|
||||
pageId: string,
|
||||
collabToken: string,
|
||||
apiUrl: string,
|
||||
transform: (doc: any) => any,
|
||||
): Promise<{ doc?: any; verify?: any }> {
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// #486: on a rejected collab-WS handshake, invalidate + refresh the token and
|
||||
// retry the write once (symmetric to the HTTP-401 reauth path).
|
||||
return this.writeWithCollabAuthRetry(collabToken, (token) =>
|
||||
// #487: thread the in-app tool signal to mutatePageContent's pre-commit
|
||||
// safe-point so a Stop/cap during the connect/lock window skips the write.
|
||||
mutatePageContent(
|
||||
pageUuid,
|
||||
token,
|
||||
apiUrl,
|
||||
transform,
|
||||
this.toolAbortSignal ?? undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-document write seam over collaboration.replacePageContent. Production
|
||||
* just delegates; it exists as an overridable method so the full-doc write
|
||||
* tools (updatePageJson, copyPageContent) can have their footnote-
|
||||
* canonicalization binding unit-tested without a live Hocuspocus collab socket.
|
||||
*
|
||||
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||
* lock") for the same reason as mutatePage above — the lock/CollabSession key
|
||||
* is guaranteed canonical here, not left to the caller's discipline.
|
||||
*/
|
||||
protected async replacePage(
|
||||
pageId: string,
|
||||
doc: any,
|
||||
collabToken: string,
|
||||
apiUrl: string,
|
||||
): Promise<{ doc?: any; verify?: any }> {
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// #486: on a rejected collab-WS handshake, invalidate + refresh the token and
|
||||
// retry the write once (symmetric to the HTTP-401 reauth path).
|
||||
return this.writeWithCollabAuthRetry(collabToken, (token) =>
|
||||
// #487: same pre-commit safe-point as mutatePage, for full-document writes.
|
||||
replacePageContent(
|
||||
pageUuid,
|
||||
doc,
|
||||
token,
|
||||
apiUrl,
|
||||
this.toolAbortSignal ?? undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a page to a single self-contained Docmost-flavoured markdown file:
|
||||
* meta block + body (with inline comment anchors + diagrams) + comment
|
||||
* threads. Lossless round-trip target; see importPageMarkdown for the inverse.
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
|
||||
// Public method surface of DocValidateMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements IDocValidateMixin` fails to compile on drift.
|
||||
export interface IDocValidateMixin {
|
||||
}
|
||||
|
||||
export function DocValidateMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDocValidateMixin> & TBase {
|
||||
abstract class DocValidateMixin extends Base implements IDocValidateMixin {
|
||||
/**
|
||||
* Validate a URL string against a scheme allowlist for a given context.
|
||||
*
|
||||
* The markdown link path enforces safe schemes via TipTap, but the raw
|
||||
* JSON path (updatePageJson) bypasses that — so this is the sanitization
|
||||
* choke point for ProseMirror JSON written directly by the caller.
|
||||
*
|
||||
* - "link": reject javascript:, vbscript:, data: (any scheme that can
|
||||
* execute or smuggle script when the href is clicked).
|
||||
* - "src": allow only http(s):, mailto:, /api/files paths, or a
|
||||
* scheme-less relative/absolute path; reject
|
||||
* javascript:/vbscript:/data:/file:.
|
||||
*/
|
||||
protected isSafeUrl(url: unknown, context: "link" | "src"): boolean {
|
||||
if (typeof url !== "string") return false;
|
||||
const trimmed = url.trim();
|
||||
if (trimmed === "") return true; // empty href/src is harmless
|
||||
|
||||
// Extract a leading "scheme:" if present. A scheme must start with a
|
||||
// letter and contain only letters/digits/+/-/. before the colon. Strip
|
||||
// whitespace and ASCII control chars first so a tab/newline embedded in
|
||||
// the scheme cannot smuggle a dangerous scheme past the check.
|
||||
const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, "");
|
||||
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
|
||||
const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null;
|
||||
|
||||
const dangerous = new Set(["javascript", "vbscript", "data", "file"]);
|
||||
|
||||
if (context === "link") {
|
||||
if (scheme === null) return true; // relative/anchor link is fine
|
||||
// For links, data: is also blocked (can carry script payloads).
|
||||
return !new Set(["javascript", "vbscript", "data"]).has(scheme);
|
||||
}
|
||||
|
||||
// context === "src"
|
||||
if (scheme === null) return true; // relative/absolute path (incl. /api/files)
|
||||
if (dangerous.has(scheme)) return false;
|
||||
return scheme === "http" || scheme === "https" || scheme === "mailto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk a ProseMirror doc and reject any unsafe URL on a link
|
||||
* mark href or on a media node's src/url. Media nodes covered: image,
|
||||
* attachment, video, plus embed (rendered as an iframe), youtube, drawio
|
||||
* and excalidraw — all of which carry a user-controlled URL that Docmost
|
||||
* renders. Throws a clear error on the first violation. A max-depth guard
|
||||
* turns an over-deep document into a clean error instead of a RangeError
|
||||
* stack overflow.
|
||||
*/
|
||||
protected validateDocUrls(node: any, depth: number = 0): void {
|
||||
const MAX_DEPTH = 200;
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new Error(
|
||||
`document nesting exceeds the maximum depth of ${MAX_DEPTH}`,
|
||||
);
|
||||
}
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
// Link marks on text nodes: validate the href.
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const mark of node.marks) {
|
||||
if (mark && mark.type === "link" && mark.attrs) {
|
||||
if (!this.isSafeUrl(mark.attrs.href, "link")) {
|
||||
throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Media nodes: validate src/url against the stricter src allowlist.
|
||||
// embed renders as an iframe (highest risk); youtube/drawio/excalidraw
|
||||
// likewise carry a user-controlled URL Docmost renders, so they get the
|
||||
// same scheme check as image/attachment/video.
|
||||
if (
|
||||
node.type === "image" ||
|
||||
node.type === "attachment" ||
|
||||
node.type === "video" ||
|
||||
node.type === "embed" ||
|
||||
node.type === "youtube" ||
|
||||
node.type === "drawio" ||
|
||||
node.type === "excalidraw" ||
|
||||
node.type === "audio" ||
|
||||
node.type === "pdf"
|
||||
) {
|
||||
const attrs = node.attrs || {};
|
||||
for (const key of ["src", "url"]) {
|
||||
if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) {
|
||||
throw new Error(
|
||||
`unsafe ${node.type} ${key} rejected: "${attrs[key]}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) {
|
||||
this.validateDocUrls(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively validate the STRUCTURE of a ProseMirror node (reuses the
|
||||
* recursion shape of validateDocUrls). Every node must be an object with a
|
||||
* string `type`; when present, `content` must be an array, `marks` must be
|
||||
* an array of objects each with a string `type`, and a text node's `text`
|
||||
* must be a string. Throws a clear "invalid ProseMirror document" error on
|
||||
* the first violation. A max-depth guard turns an over-deep document into a
|
||||
* clean error instead of a RangeError stack overflow.
|
||||
*/
|
||||
protected validateDocStructure(node: any, depth: number = 0): void {
|
||||
const MAX_DEPTH = 200;
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new Error(
|
||||
`invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`,
|
||||
);
|
||||
}
|
||||
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||
throw new Error(
|
||||
"invalid ProseMirror document: every node must be an object with a string `type`",
|
||||
);
|
||||
}
|
||||
if (
|
||||
"text" in node &&
|
||||
node.type === "text" &&
|
||||
typeof node.text !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"invalid ProseMirror document: a text node must have a string `text`",
|
||||
);
|
||||
}
|
||||
if (node.marks !== undefined) {
|
||||
if (!Array.isArray(node.marks)) {
|
||||
throw new Error(
|
||||
"invalid ProseMirror document: `marks` must be an array",
|
||||
);
|
||||
}
|
||||
for (const mark of node.marks) {
|
||||
if (
|
||||
!mark ||
|
||||
typeof mark !== "object" ||
|
||||
typeof mark.type !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
"invalid ProseMirror document: every mark must be an object with a string `type`",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.content !== undefined) {
|
||||
if (!Array.isArray(node.content)) {
|
||||
throw new Error(
|
||||
"invalid ProseMirror document: `content` must be an array when present",
|
||||
);
|
||||
}
|
||||
for (const child of node.content) {
|
||||
this.validateDocStructure(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-write SHAPE gate (#409). Walk the WHOLE node tree with the shared
|
||||
* `findInvalidNode` and throw a rich, path-anchored error the instant a nested
|
||||
* node has an absent/unknown `type` (or an unknown mark) — the exact shape that
|
||||
* otherwise surfaces DEEP in the Yjs encode as the cryptic
|
||||
* `Unknown node type: undefined`, but only AFTER a collab session was opened
|
||||
* and a page lock taken. Calling this BEFORE `getCollabTokenWithReauth` /
|
||||
* `mutatePageContent` fails fast: no collab connection, no lock, deterministic
|
||||
* message. `op` names the tool for the message prefix (e.g. "patchNode").
|
||||
*
|
||||
* `findInvalidNode` derives its "known type" set from the very same
|
||||
* `docmostExtensions` the encode path uses, so a node this gate accepts is one
|
||||
* the encoder will accept too.
|
||||
*/
|
||||
protected assertValidNodeShape(op: string, node: any): void {
|
||||
const bad = findInvalidNode(node);
|
||||
if (bad) {
|
||||
throw new Error(`${op}: invalid node — ${bad.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace page content with a raw ProseMirror JSON document (lossless) and/or
|
||||
* update its title. Both `doc` and `title` are optional, but at least one must
|
||||
* be supplied:
|
||||
* - `doc` provided -> validate + full-overwrite the body (and update the
|
||||
* title too when `title` is also given).
|
||||
* - `doc` omitted, `title` given -> title-only update; the body is NOT
|
||||
* touched/resent (no collab write happens).
|
||||
* - neither given -> throws (nothing to update).
|
||||
*/
|
||||
}
|
||||
return DocValidateMixin;
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import { parseCells as parseDrawioCells } from "../lib/drawio-xml.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import {
|
||||
prepareModel,
|
||||
decodeDrawioSvg,
|
||||
buildDrawioSvg,
|
||||
mxHash,
|
||||
normalizeXml,
|
||||
countUserCells,
|
||||
} from "../lib/drawio-xml.js";
|
||||
import { renderDiagramShapes } from "../lib/drawio-preview.js";
|
||||
import { applyElkLayout } from "../lib/drawio-layout.js";
|
||||
import {
|
||||
buildFromGraph,
|
||||
type Graph,
|
||||
type LayoutMode as GraphLayoutMode,
|
||||
} from "../lib/drawio-graph.js";
|
||||
import { applyCellOps, type CellOp } from "../lib/drawio-cell-ops.js";
|
||||
import { mermaidToGraph } from "../lib/drawio-mermaid.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
|
||||
// Public method surface of DrawioMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements IDrawioMixin` fails to compile on drift.
|
||||
export interface IDrawioMixin {
|
||||
drawioGet(pageId: string, node: string, format?: "xml" | "svg"): Promise<{ pageId: string; nodeId: string; format: "xml" | "svg"; content: string; meta: { attachmentId: string | null; title: string | null; width: number | null; height: number | null; cellCount: number; hash: string; }; }>;
|
||||
drawioCreate(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, xml: string, title?: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
|
||||
drawioUpdate(pageId: string, node: string, xml: string, baseHash: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
|
||||
drawioEditCells(pageId: string, node: string, operations: CellOp[], baseHash: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
|
||||
drawioFromGraph(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, graph: Graph, direction?: "LR" | "RL" | "TB" | "BT", preset?: string, layout?: GraphLayoutMode, node?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>;
|
||||
drawioFromMermaid(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, mermaid: string, preset?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>;
|
||||
}
|
||||
|
||||
export function DrawioMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDrawioMixin> & TBase {
|
||||
abstract class DrawioMixin extends Base implements IDrawioMixin {
|
||||
/**
|
||||
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||
* drawio node.
|
||||
*/
|
||||
protected async resolveDrawioNode(
|
||||
pageId: string,
|
||||
node: string,
|
||||
): Promise<{ node: any; ref: string }> {
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const hit = getNodeByRef(
|
||||
data.content ?? { type: "doc", content: [] },
|
||||
node,
|
||||
);
|
||||
if (!hit) {
|
||||
throw new Error(
|
||||
`drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#<index>" from getOutline)`,
|
||||
);
|
||||
}
|
||||
if (hit.type !== "drawio") {
|
||||
throw new Error(
|
||||
`drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`,
|
||||
);
|
||||
}
|
||||
return { node: hit.node, ref: node };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`.
|
||||
* Runs the decode chain (base64/entity content= → drawio file → nested XML or
|
||||
* pako-inflated compressed <diagram>). The returned `hash` is the
|
||||
* optimistic-lock key for drawioUpdate.
|
||||
*/
|
||||
async drawioGet(
|
||||
pageId: string,
|
||||
node: string,
|
||||
format: "xml" | "svg" = "xml",
|
||||
): Promise<{
|
||||
pageId: string;
|
||||
nodeId: string;
|
||||
format: "xml" | "svg";
|
||||
content: string;
|
||||
meta: {
|
||||
attachmentId: string | null;
|
||||
title: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
cellCount: number;
|
||||
hash: string;
|
||||
};
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
|
||||
const attrs = drawio.attrs || {};
|
||||
const src = attrs.src;
|
||||
if (!src) {
|
||||
throw new Error(
|
||||
`drawio: node "${node}" on page ${pageId} has no src to read`,
|
||||
);
|
||||
}
|
||||
const svg = await this.fetchAttachmentText(src);
|
||||
const modelXml = decodeDrawioSvg(svg);
|
||||
const meta = {
|
||||
attachmentId: attrs.attachmentId ?? null,
|
||||
title: attrs.title ?? null,
|
||||
width: attrs.width != null ? Number(attrs.width) : null,
|
||||
height: attrs.height != null ? Number(attrs.height) : null,
|
||||
cellCount: countUserCells(modelXml),
|
||||
hash: mxHash(modelXml),
|
||||
};
|
||||
return {
|
||||
pageId,
|
||||
nodeId: attrs.id ?? node,
|
||||
format,
|
||||
content: format === "svg" ? svg : normalizeXml(modelXml),
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a drawio diagram from mxGraph XML: lint → schematic SVG preview
|
||||
* (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the
|
||||
* attachment → insert a `drawio` node before/after an anchor or appended.
|
||||
* `xml` is a bare `<mxGraphModel>` or a list of `<mxCell>` (the server wraps
|
||||
* it and adds the id=0/id=1 sentinels).
|
||||
*/
|
||||
async drawioCreate(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
xml: string,
|
||||
title?: string,
|
||||
layout?: "elk",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
if (
|
||||
!where ||
|
||||
(where.position !== "before" &&
|
||||
where.position !== "after" &&
|
||||
where.position !== "append")
|
||||
) {
|
||||
throw new Error(
|
||||
'drawioCreate: `where.position` must be one of "before", "after", "append"',
|
||||
);
|
||||
}
|
||||
if (where.position === "before" || where.position === "after") {
|
||||
const hasId =
|
||||
typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0;
|
||||
const hasText =
|
||||
typeof where.anchorText === "string" && where.anchorText.length > 0;
|
||||
if (hasId === hasText) {
|
||||
throw new Error(
|
||||
`drawioCreate: position "${where.position}" requires exactly one of anchorNodeId or anchorText`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional server-side ELK auto-layout: the model declares structure with
|
||||
// rough coords, ELK computes the pixels (best-effort — returns the input
|
||||
// unchanged on any layout failure).
|
||||
const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml;
|
||||
// Pre-write pipeline (throws a structured DrawioLintError on any violation).
|
||||
const prepared = prepareModel(laidOutXml);
|
||||
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||
const diagramTitle = title || "Page-1";
|
||||
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||
|
||||
const att = await this.uploadAttachmentBuffer(
|
||||
pageId,
|
||||
Buffer.from(svg, "utf-8"),
|
||||
"diagram.drawio.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
|
||||
// NOTE: no `id` attribute is set here. The vendored `drawio` node schema
|
||||
// (diagramAttributes) declares no `id`, so any block id would be silently
|
||||
// dropped by PMNode.fromJSON on save and the returned handle would fail to
|
||||
// resolve. The addressable handle is the node's "#<index>" (like image/table
|
||||
// nodes), computed after the insert below.
|
||||
const drawioNode: any = {
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
src: `/api/files/${att.id}/${att.fileName}`,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
align: "center",
|
||||
},
|
||||
};
|
||||
if (title) drawioNode.attrs.title = title;
|
||||
// Reuse the existing URL trust boundary (rejects unsafe src schemes).
|
||||
this.validateDocUrls(drawioNode);
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let inserted = false;
|
||||
let insertedIndex = -1;
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
inserted = false;
|
||||
insertedIndex = -1;
|
||||
const { doc: nd, inserted: ins } = insertNodeRelative(
|
||||
liveDoc,
|
||||
drawioNode,
|
||||
where,
|
||||
);
|
||||
inserted = ins;
|
||||
if (!inserted) return null; // anchor not found -> skip the write
|
||||
// Locate the freshly-inserted node to derive its "#<index>" handle. The
|
||||
// just-uploaded attachmentId is unique, so it identifies our node.
|
||||
if (Array.isArray(nd.content)) {
|
||||
insertedIndex = nd.content.findIndex(
|
||||
(b: any) =>
|
||||
b &&
|
||||
b.type === "drawio" &&
|
||||
b.attrs &&
|
||||
b.attrs.attachmentId === att.id,
|
||||
);
|
||||
}
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
if (!inserted) {
|
||||
const anchorDesc = where.anchorNodeId
|
||||
? `anchorNodeId "${where.anchorNodeId}"`
|
||||
: `anchorText "${where.anchorText}"`;
|
||||
throw new Error(
|
||||
`drawioCreate: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (insertedIndex < 0) {
|
||||
// The node was inserted nested (e.g. inside a callout/table cell via an
|
||||
// anchor), where "#<index>" — which addresses only top-level blocks —
|
||||
// cannot reference it. drawio nodes carry no persisted id, so there is no
|
||||
// stable handle for a nested diagram.
|
||||
throw new Error(
|
||||
`drawioCreate: the diagram was inserted on page ${pageId} but not as a ` +
|
||||
`top-level block, so it has no addressable "#<index>" handle. Anchor ` +
|
||||
`on a top-level block (or append) so the diagram can be re-read.`,
|
||||
);
|
||||
}
|
||||
|
||||
// The returned handle is POSITIONAL ("#<index>"): valid for the immediate
|
||||
// create -> get/update flow, but re-resolve via getOutline if the document
|
||||
// structure changes (blocks added/removed before it shift the index).
|
||||
const nodeId = `#${insertedIndex}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: prepared.warnings,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is
|
||||
* compared against the hash of the diagram's CURRENT XML (from drawioGet);
|
||||
* any mismatch means a human or another agent edited the diagram after the
|
||||
* read, so the write is refused with a conflict error. On success the new
|
||||
* `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is
|
||||
* avoided — some Docmost versions corrupt an attachment on overwrite, exactly
|
||||
* as replaceImage documents) and the node is repointed with new dimensions.
|
||||
*/
|
||||
async drawioUpdate(
|
||||
pageId: string,
|
||||
node: string,
|
||||
xml: string,
|
||||
baseHash: string,
|
||||
layout?: "elk",
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
if (typeof baseHash !== "string" || baseHash.length === 0) {
|
||||
throw new Error(
|
||||
"drawioUpdate: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the node and read the CURRENT diagram to enforce the optimistic
|
||||
// lock before doing any write or upload.
|
||||
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
|
||||
const oldAttrs = drawio.attrs || {};
|
||||
const oldSrc = oldAttrs.src;
|
||||
// The returned handle is the caller-supplied reference. drawio nodes carry
|
||||
// no persisted id, so `ref` (an "#<index>" or a rare legacy attrs.id) is the
|
||||
// honest identifier to hand back.
|
||||
const nodeId = oldAttrs.id ?? ref;
|
||||
if (!oldSrc) {
|
||||
throw new Error(
|
||||
`drawioUpdate: node "${node}" on page ${pageId} has no src to compare against`,
|
||||
);
|
||||
}
|
||||
const currentSvg = await this.fetchAttachmentText(oldSrc);
|
||||
const currentHash = mxHash(decodeDrawioSvg(currentSvg));
|
||||
if (currentHash !== baseHash) {
|
||||
throw new Error(
|
||||
`drawioUpdate: conflict — the diagram changed since it was read ` +
|
||||
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Optional server-side ELK auto-layout (best-effort; see drawioCreate).
|
||||
const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml;
|
||||
// Pipeline for the new content (throws a structured DrawioLintError).
|
||||
const prepared = prepareModel(laidOutXml);
|
||||
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||
const diagramTitle = oldAttrs.title || "Page-1";
|
||||
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||
|
||||
const att = await this.uploadAttachmentBuffer(
|
||||
pageId,
|
||||
Buffer.from(svg, "utf-8"),
|
||||
"diagram.drawio.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
const newSrc = `/api/files/${att.id}/${att.fileName}`;
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let repointed = 0;
|
||||
const repoint = (n: any) => {
|
||||
n.attrs = {
|
||||
...n.attrs,
|
||||
src: newSrc,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
};
|
||||
repointed++;
|
||||
};
|
||||
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
repointed = 0;
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (!Array.isArray(doc.content)) doc.content = [];
|
||||
// Repoint ONLY the resolved node — never every node that happens to
|
||||
// share this attachmentId (a copied diagram is two nodes with one
|
||||
// attachmentId; keying on it would clobber both). Re-resolve the same
|
||||
// handle against the live doc and walk to its exact position.
|
||||
const hit = getNodeByRef(doc, ref);
|
||||
if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip
|
||||
let target: any = doc;
|
||||
for (const idx of hit.path) {
|
||||
if (!target || !Array.isArray(target.content)) {
|
||||
target = null;
|
||||
break;
|
||||
}
|
||||
target = target.content[idx];
|
||||
}
|
||||
if (!target || target.type !== "drawio") return null;
|
||||
repoint(target);
|
||||
if (repointed === 0) return null; // node vanished concurrently -> skip
|
||||
return doc;
|
||||
},
|
||||
);
|
||||
|
||||
if (repointed === 0) {
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: [
|
||||
...prepared.warnings,
|
||||
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
|
||||
],
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: prepared.warnings,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
// --- draw.io high-level semantic tools (issue #425) ---
|
||||
|
||||
/**
|
||||
* ID-based targeted edits of an existing drawio diagram (add / update / delete
|
||||
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
|
||||
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
|
||||
* the operations to the parsed model (a `delete` CASCADES to container children
|
||||
* and to every edge whose source/target is deleted), then runs the SAME #423
|
||||
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
|
||||
* repoint the node). Ids are stable so diffs stay meaningful across edits.
|
||||
*/
|
||||
|
||||
// --- draw.io high-level semantic tools (issue #425) ---
|
||||
|
||||
/**
|
||||
* ID-based targeted edits of an existing drawio diagram (add / update / delete
|
||||
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
|
||||
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
|
||||
* the operations to the parsed model (a `delete` CASCADES to container children
|
||||
* and to every edge whose source/target is deleted), then runs the SAME #423
|
||||
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
|
||||
* repoint the node). Ids are stable so diffs stay meaningful across edits.
|
||||
*/
|
||||
async drawioEditCells(
|
||||
pageId: string,
|
||||
node: string,
|
||||
operations: CellOp[],
|
||||
baseHash: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
if (typeof baseHash !== "string" || baseHash.length === 0) {
|
||||
throw new Error(
|
||||
"drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(operations) || operations.length === 0) {
|
||||
throw new Error(
|
||||
"drawioEditCells: operations must be a non-empty array of { op, ... }",
|
||||
);
|
||||
}
|
||||
|
||||
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
|
||||
const oldAttrs = drawio.attrs || {};
|
||||
const oldSrc = oldAttrs.src;
|
||||
const nodeId = oldAttrs.id ?? ref;
|
||||
if (!oldSrc) {
|
||||
throw new Error(
|
||||
`drawioEditCells: node "${node}" on page ${pageId} has no src to edit`,
|
||||
);
|
||||
}
|
||||
const currentSvg = await this.fetchAttachmentText(oldSrc);
|
||||
const currentModel = decodeDrawioSvg(currentSvg);
|
||||
const currentHash = mxHash(currentModel);
|
||||
if (currentHash !== baseHash) {
|
||||
throw new Error(
|
||||
`drawioEditCells: conflict — the diagram changed since it was read ` +
|
||||
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the operations to the parsed model, then run the standard pipeline.
|
||||
const editedModel = applyCellOps(currentModel, operations);
|
||||
const prepared = prepareModel(editedModel);
|
||||
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||
const diagramTitle = oldAttrs.title || "Page-1";
|
||||
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||
|
||||
const att = await this.uploadAttachmentBuffer(
|
||||
pageId,
|
||||
Buffer.from(svg, "utf-8"),
|
||||
"diagram.drawio.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
const newSrc = `/api/files/${att.id}/${att.fileName}`;
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let repointed = 0;
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
repointed = 0;
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] };
|
||||
if (!Array.isArray(doc.content)) doc.content = [];
|
||||
const hit = getNodeByRef(doc, ref);
|
||||
if (!hit || hit.type !== "drawio") return null;
|
||||
let target: any = doc;
|
||||
for (const idx of hit.path) {
|
||||
if (!target || !Array.isArray(target.content)) {
|
||||
target = null;
|
||||
break;
|
||||
}
|
||||
target = target.content[idx];
|
||||
}
|
||||
if (!target || target.type !== "drawio") return null;
|
||||
target.attrs = {
|
||||
...target.attrs,
|
||||
src: newSrc,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
};
|
||||
repointed++;
|
||||
return doc;
|
||||
},
|
||||
);
|
||||
|
||||
if (repointed === 0) {
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: [
|
||||
...prepared.warnings,
|
||||
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
|
||||
],
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: prepared.warnings,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The main high-level tool: build a diagram from a SEMANTIC graph (nodes with
|
||||
* a `kind`/`icon`, groups, edges) — the model never supplies coordinates or
|
||||
* style strings. The server resolves icons via the shape catalog (#424),
|
||||
* assigns palette colors from the preset, runs ELK layered layout (honouring
|
||||
* `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups),
|
||||
* and assembles linter-clean XML, then inserts it through the SAME create
|
||||
* pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a
|
||||
* target `node` is given (it preserves that diagram's existing coordinates and
|
||||
* places only new cells); on a fresh insert it behaves like "full".
|
||||
*/
|
||||
async drawioFromGraph(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
graph: Graph,
|
||||
direction?: "LR" | "RL" | "TB" | "BT",
|
||||
preset?: string,
|
||||
layout?: GraphLayoutMode,
|
||||
node?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
iconsResolved: number;
|
||||
iconsMissing: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
// Direction/preset supplied as separate params override the graph fields so
|
||||
// both the flat tool schema and an inline graph can set them.
|
||||
const merged: Graph = {
|
||||
...graph,
|
||||
direction: direction ?? graph.direction,
|
||||
preset: preset ?? graph.preset,
|
||||
};
|
||||
const mode: GraphLayoutMode = layout ?? "full";
|
||||
|
||||
// Incremental into an EXISTING node: read its coords so ELK preserves them,
|
||||
// and keep the full existing model so incremental MERGES (never drops) any
|
||||
// cell the new graph doesn't re-list.
|
||||
let existingCoords: Map<string, { x: number; y: number }> | undefined;
|
||||
let existingModelXml: string | undefined;
|
||||
let editExisting = false;
|
||||
let baseHash: string | undefined;
|
||||
if (node && (mode === "incremental" || mode === "none")) {
|
||||
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
|
||||
const src = (drawio.attrs || {}).src;
|
||||
if (src) {
|
||||
const svg = await this.fetchAttachmentText(src);
|
||||
const model = decodeDrawioSvg(svg);
|
||||
baseHash = mxHash(model);
|
||||
existingModelXml = model;
|
||||
existingCoords = new Map();
|
||||
for (const c of parseDrawioCells(model)) {
|
||||
if (c.vertex && c.geometry.x != null && c.geometry.y != null) {
|
||||
existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y });
|
||||
}
|
||||
}
|
||||
editExisting = true;
|
||||
}
|
||||
}
|
||||
|
||||
const built = await buildFromGraph(
|
||||
merged,
|
||||
mode,
|
||||
existingCoords,
|
||||
existingModelXml,
|
||||
);
|
||||
|
||||
if (editExisting && node && baseHash) {
|
||||
// Re-target the existing diagram: replace it with the assembled model.
|
||||
const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash);
|
||||
return {
|
||||
...res,
|
||||
iconsResolved: built.iconsResolved,
|
||||
iconsMissing: built.iconsMissing,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await this.drawioCreate(pageId, where, built.modelXml);
|
||||
return {
|
||||
...res,
|
||||
iconsResolved: built.iconsResolved,
|
||||
iconsMissing: built.iconsMissing,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE
|
||||
* parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the
|
||||
* drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most
|
||||
* common wiki case); other diagram types throw a clear error so the model can
|
||||
* fall back to drawioFromGraph.
|
||||
*/
|
||||
async drawioFromMermaid(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
mermaid: string,
|
||||
preset?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
iconsResolved: number;
|
||||
iconsMissing: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
const graph = mermaidToGraph(mermaid);
|
||||
if (preset) graph.preset = preset;
|
||||
return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset);
|
||||
}
|
||||
|
||||
// --- Page history / diff / transform ---
|
||||
|
||||
/**
|
||||
* List the saved versions (history snapshots) of a page, newest first.
|
||||
* Docmost auto-snapshots on every save. Returns one cursor-paginated page of
|
||||
* results: `{ items, nextCursor }`. The history record's id field is `id`.
|
||||
*/
|
||||
}
|
||||
return DrawioMixin;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Central REST error diagnostics (issues #437 + #450). SINGLE place that maps an
|
||||
// axios error to the model-facing message. Extracted verbatim from client.ts;
|
||||
// the constructor's response interceptor (see client/context.ts) routes every
|
||||
// REST call through formatDocmostAxiosError so the whole surface is uniform.
|
||||
import axios from "axios";
|
||||
|
||||
// --- Issue #437: central error diagnostics -------------------------------
|
||||
// The agent only ever sees the thrown exception's `error.message`, so a failed
|
||||
// tool must return an ACTIONABLE message (method, path, status, and the
|
||||
// server's own validation text) instead of the opaque "Request failed with
|
||||
// status code 400". These helpers + the response interceptor in the
|
||||
// constructor are the single authoritative place that text is composed.
|
||||
|
||||
// Overall cap on the composed diagnostic message so the model context stays
|
||||
// compact and a (whitelisted) server string can never blow up the text.
|
||||
const ERROR_MESSAGE_CAP = 300;
|
||||
// Only attempt to JSON.parse an arraybuffer body under this size: a larger
|
||||
// binary body is never a JSON error envelope, so parsing it just wastes memory
|
||||
// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch
|
||||
// carries the JSON error envelope as raw bytes here).
|
||||
const ERROR_BUFFER_PARSE_CAP = 4096;
|
||||
|
||||
// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant-
|
||||
// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the
|
||||
// canonical shape/length is enforced, not the version/variant nibble.
|
||||
const FULL_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Throw an actionable error BEFORE any network call when `value` is not a full
|
||||
* canonical UUID. Absorbs #436: a truncated/short comment id used to reach the
|
||||
* server and bounce back as an opaque 400/404 the agent could not self-correct;
|
||||
* failing fast here names the exact fix.
|
||||
*/
|
||||
export function assertFullUuid(
|
||||
tool: string,
|
||||
param: string,
|
||||
value: string,
|
||||
): void {
|
||||
if (typeof value !== "string" || !FULL_UUID_RE.test(value)) {
|
||||
throw new Error(
|
||||
`${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` +
|
||||
`019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` +
|
||||
`verbatim from listComments / createComment output.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep ONLY the pathname of a request (no host, no query string, no fragment)
|
||||
// so the message never leaks a host or query params. Resolves a relative
|
||||
// config.url against config.baseURL, then discards everything but the path.
|
||||
function requestPath(config: any): string {
|
||||
const rawUrl = typeof config?.url === "string" ? config.url : "";
|
||||
const base =
|
||||
typeof config?.baseURL === "string" ? config.baseURL : undefined;
|
||||
try {
|
||||
// A dummy base makes an absolute config.url parse too; its host is dropped.
|
||||
return new URL(rawUrl, base ?? "http://localhost").pathname;
|
||||
} catch {
|
||||
// Malformed url: still strip any query/fragment manually.
|
||||
return rawUrl.split(/[?#]/)[0] || rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the server-facing message from `error.response.data`, using ONLY the
|
||||
* whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the
|
||||
* raw response body, headers (Authorization!) and config are NEVER read here —
|
||||
* a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in
|
||||
* favour of the statusText.
|
||||
*/
|
||||
function extractServerMessage(data: any, statusText: string): string {
|
||||
// class-validator envelope: { message: string | string[], error?: string }.
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
!Buffer.isBuffer(data) &&
|
||||
!(data instanceof ArrayBuffer)
|
||||
) {
|
||||
const msg = (data as any).message;
|
||||
if (Array.isArray(msg)) {
|
||||
const joined = msg.filter((m) => typeof m === "string").join("; ");
|
||||
if (joined) return joined;
|
||||
} else if (typeof msg === "string" && msg) {
|
||||
return msg;
|
||||
}
|
||||
const err = (data as any).error;
|
||||
if (typeof err === "string" && err) return err;
|
||||
return statusText;
|
||||
}
|
||||
|
||||
// Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a
|
||||
// failed arraybuffer fetch still surfaces the server's validation text.
|
||||
if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) {
|
||||
try {
|
||||
return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText);
|
||||
} catch {
|
||||
return statusText;
|
||||
}
|
||||
}
|
||||
return statusText;
|
||||
}
|
||||
|
||||
// A raw string / HTML body is never surfaced (may echo server internals).
|
||||
return statusText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic:
|
||||
* `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`
|
||||
* or, when the request never got a response:
|
||||
* `<METHOD> <path> failed: <code> (no response from server)`.
|
||||
*
|
||||
* Mutates the SAME error object (never a custom subclass) so the live
|
||||
* axios.isAxiosError / error.response?.status / config._retry checks around the
|
||||
* client keep working, and sets `_docmostFormatted` as a double-processing
|
||||
* guard. A no-op on a non-axios or already-formatted error.
|
||||
*/
|
||||
export function formatDocmostAxiosError(error: any): void {
|
||||
if (!error || error._docmostFormatted) return;
|
||||
if (!axios.isAxiosError(error)) return;
|
||||
|
||||
const config: any = error.config ?? {};
|
||||
const method =
|
||||
typeof config.method === "string" ? config.method.toUpperCase() : "";
|
||||
const methodPath = `${method} ${requestPath(config)}`.trim();
|
||||
const response = error.response;
|
||||
|
||||
let message: string;
|
||||
if (response) {
|
||||
const statusText =
|
||||
typeof response.statusText === "string" ? response.statusText : "";
|
||||
const serverMessage = extractServerMessage(response.data, statusText);
|
||||
message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`;
|
||||
// Full body only to stderr under DEBUG (parity with downloadImage).
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Docmost request failed; response body:",
|
||||
JSON.stringify(response.data),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout).
|
||||
// Use ONLY error.code, never the raw error.message: axios network messages
|
||||
// embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo
|
||||
// ENOTFOUND host") and #437's invariant is that the host never reaches the
|
||||
// model-visible message. code is set for essentially every real no-response
|
||||
// error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full
|
||||
// native message still goes to stderr under DEBUG.
|
||||
const reason = error.code ?? "network error";
|
||||
message = `${methodPath} failed: ${reason} (no response from server)`;
|
||||
if (process.env.DEBUG) {
|
||||
console.error("Docmost request failed; no response:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.length > ERROR_MESSAGE_CAP) {
|
||||
message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…";
|
||||
}
|
||||
|
||||
error.message = message;
|
||||
(error as any)._docmostFormatted = true;
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import FormData from "form-data";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { basename, extname } from "path";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||
import { diffDocs, summarizeChange } from "../lib/diff.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
|
||||
// Supported image types, kept as two lookup tables so both a local file
|
||||
// extension and a remote Content-Type can be mapped to the same canonical set.
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
};
|
||||
|
||||
// Public method surface of MediaMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements IMediaMixin` fails to compile on drift.
|
||||
export interface IMediaMixin {
|
||||
uploadImage(pageId: string, url: string): any;
|
||||
insertImage(pageId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string; replaceText?: string; afterText?: string; }): any;
|
||||
replaceImage(pageId: string, oldAttachmentId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string }): any;
|
||||
}
|
||||
|
||||
export function MediaMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IMediaMixin> & TBase {
|
||||
abstract class MediaMixin extends Base implements IMediaMixin {
|
||||
// --- Image upload / embedding ---
|
||||
|
||||
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
|
||||
protected supportedImageMime(ct: string): string | null {
|
||||
return MIME_TO_EXT[ct] ? ct : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a remote image from a caller-supplied URL and resolve its bytes,
|
||||
* MIME and a filename.
|
||||
*
|
||||
* SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is
|
||||
* fetched BY THE SERVER, so it must be guarded before and after the request.
|
||||
* The guards mirror the local-file trust boundary in uploadImage:
|
||||
* - scheme allowlist (http/https only) — rejects file:, data:, ftp:, etc.,
|
||||
* so the caller cannot use this path to read local files or other schemes;
|
||||
* - a size cap enforced both via axios maxContentLength/maxBodyLength AND a
|
||||
* post-download buffer.length re-check (defends against a missing/lying
|
||||
* Content-Length), so a huge response cannot exhaust memory;
|
||||
* - a 30s timeout. The timeout matters because replaceImage holds the
|
||||
* per-page lock across this upload, so a hung download would wedge the
|
||||
* lock for that page.
|
||||
* We deliberately do NOT block private IP ranges: the MCP caller is already
|
||||
* trusted to read arbitrary host files via the filePath path, so the marginal
|
||||
* trust granted by fetching internal URLs is comparable, and blocking would
|
||||
* break legitimate internal-image use.
|
||||
*/
|
||||
protected async fetchRemoteImage(
|
||||
url: string,
|
||||
maxBytes: number,
|
||||
): Promise<{ buffer: Buffer; mime: string; fileName: string }> {
|
||||
// Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes
|
||||
// (file:, data:, ftp:, ...) before any network request is made.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (e: any) {
|
||||
throw new Error(`Invalid image URL "${url}": ${e.message}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`,
|
||||
);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 30000,
|
||||
maxContentLength: maxBytes,
|
||||
maxBodyLength: maxBytes,
|
||||
headers: { Accept: "image/*" },
|
||||
});
|
||||
} catch (error) {
|
||||
// Keep the thrown message free of the raw response body (it may echo
|
||||
// server internals); surface only status/statusText. The full body is
|
||||
// logged under DEBUG for diagnostics.
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Image download failed; response body:",
|
||||
JSON.stringify(error.response?.data),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim(),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// axios returns an ArrayBuffer for responseType: "arraybuffer".
|
||||
const buffer = Buffer.from(response.data);
|
||||
// Re-check the size: maxContentLength relies on Content-Length, which may be
|
||||
// absent or lie, so guard against the actual byte count too.
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`Empty image response from "${url}"`);
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(
|
||||
`Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve MIME: prefer the response Content-Type (strip any "; charset=..."
|
||||
// parameter, lowercase, trim) mapped through the supported set; if the
|
||||
// header is generic/missing/unsupported, fall back to the URL path
|
||||
// extension via the existing extension->MIME logic.
|
||||
const rawCt = response.headers?.["content-type"];
|
||||
let mime: string | null = null;
|
||||
if (typeof rawCt === "string" && rawCt.length > 0) {
|
||||
const ct = rawCt.split(";")[0].trim().toLowerCase();
|
||||
mime = this.supportedImageMime(ct);
|
||||
}
|
||||
if (!mime) {
|
||||
// Fall back to the URL path extension. Use the pathname so the query
|
||||
// string never contaminates the extension lookup.
|
||||
const ext = extname(parsed.pathname).toLowerCase();
|
||||
mime = EXT_TO_MIME[ext] ?? null;
|
||||
}
|
||||
if (!mime) {
|
||||
throw new Error(
|
||||
`cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build a filename from the URL path basename (ignore the query string),
|
||||
// defaulting to "image" when empty, and ensure it ends with the canonical
|
||||
// extension for the resolved MIME (append it when missing/mismatched).
|
||||
const canonicalExt = MIME_TO_EXT[mime];
|
||||
let fileName = basename(parsed.pathname) || "image";
|
||||
if (extname(fileName).toLowerCase() !== canonicalExt) {
|
||||
fileName += canonicalExt;
|
||||
}
|
||||
|
||||
return { buffer, mime, fileName };
|
||||
}
|
||||
|
||||
/** Build a Docmost ProseMirror image node from an uploaded attachment. */
|
||||
protected buildImageNode(
|
||||
att: { id: string; fileName: string; fileSize?: number },
|
||||
align?: "left" | "center" | "right",
|
||||
alt?: string,
|
||||
): any {
|
||||
// Clean file URL, matching Docmost's native behaviour. No cache-busting
|
||||
// query: the server serves the bare URL correctly, and replacement creates
|
||||
// a new attachment id (a new URL) which busts caches naturally.
|
||||
const src = `/api/files/${att.id}/${att.fileName}`;
|
||||
const node: any = {
|
||||
type: "image",
|
||||
attrs: {
|
||||
src,
|
||||
attachmentId: att.id,
|
||||
// Default to null when the server omits fileSize so the attr is never
|
||||
// undefined (undefined would be dropped on serialization / break the
|
||||
// ProseMirror image schema which expects size present).
|
||||
size: att.fileSize ?? null,
|
||||
align: align || "center",
|
||||
width: null,
|
||||
},
|
||||
};
|
||||
if (alt) node.attrs.alt = alt;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a remote image from an http(s) URL and upload it as an attachment
|
||||
* of a page, returning the attachment metadata plus a ready-to-insert
|
||||
* ProseMirror image node. Local file paths are intentionally not supported:
|
||||
* the MCP caller is a remote AI with no access to this server's filesystem.
|
||||
*/
|
||||
async uploadImage(pageId: string, url: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB
|
||||
|
||||
// Fetch + validate the remote image (scheme allowlist, size cap, timeout).
|
||||
// See fetchRemoteImage for the SSRF / resource trust boundary.
|
||||
const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES);
|
||||
const fileBuffer = fetched.buffer;
|
||||
const mime = fetched.mime;
|
||||
const fileName = fetched.fileName;
|
||||
|
||||
// Build a FRESH FormData for every send attempt. A FormData body is a
|
||||
// single-use stream that is CONSUMED on the first send, so it cannot be
|
||||
// replayed by this.client's response interceptor (replaying a consumed
|
||||
// stream fails with 'socket hang up'). Multipart re-auth is therefore done
|
||||
// here with bare axios and an explicit one-shot 401/403 retry that rebuilds
|
||||
// the body. Field order matters: text fields must precede the file part so
|
||||
// the server reads them; the server always generates a fresh attachment id.
|
||||
const buildForm = () => {
|
||||
const form = new FormData();
|
||||
form.append("pageId", pageId);
|
||||
form.append("file", fileBuffer, {
|
||||
filename: fileName,
|
||||
contentType: mime,
|
||||
});
|
||||
return form;
|
||||
};
|
||||
|
||||
// Local name distinct from the `url` parameter (the source image URL): this
|
||||
// is the /files/upload endpoint we POST the multipart body to.
|
||||
const uploadUrl = `${this.apiUrl}/files/upload`;
|
||||
let response;
|
||||
try {
|
||||
// Call buildForm() ONCE per attempt and reuse the instance for both
|
||||
// getHeaders() and the body so the Content-Type boundary matches the body.
|
||||
const form = buildForm();
|
||||
// Read the Authorization header from this.client's defaults (set by
|
||||
// login(), only ever deleted — never set to null) instead of building
|
||||
// `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
|
||||
// mid-flight, which would otherwise produce a literal "Bearer null".
|
||||
// ensureAuthenticated() above guarantees login() ran, so the default
|
||||
// header exists here. A 60s timeout keeps a hung upload from wedging the
|
||||
// per-page lock (replaceImage holds withPageLock across this call).
|
||||
response = await axios.post(uploadUrl, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} catch (error) {
|
||||
// On an expired-token auth error, re-login and retry exactly once with a
|
||||
// freshly-rebuilt FormData (the previous one was already consumed).
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await this.login();
|
||||
const form2 = buildForm();
|
||||
response = await axios.post(uploadUrl, form2, {
|
||||
headers: {
|
||||
...form2.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} else if (axios.isAxiosError(error)) {
|
||||
// Keep the thrown message free of the raw response body (it may echo
|
||||
// request data or server internals); surface only status/statusText.
|
||||
// The full body is logged under DEBUG for diagnostics.
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Image upload failed; response body:",
|
||||
JSON.stringify(error.response?.data),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Image upload failed: ${error.response?.status} ${error.response?.statusText}`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// The attachment may arrive bare or wrapped in a { data } envelope.
|
||||
const att = response.data?.data ?? response.data;
|
||||
if (!att?.id || !att?.fileName) {
|
||||
throw new Error(
|
||||
"Unexpected /files/upload response: " + JSON.stringify(response.data),
|
||||
);
|
||||
}
|
||||
|
||||
// Some Docmost versions omit fileSize from the upload response. Fall back
|
||||
// to the fetched byte length (the bytes we just uploaded) so callers never
|
||||
// get an undefined size.
|
||||
const resolvedSize = att.fileSize ?? fileBuffer.length;
|
||||
|
||||
return {
|
||||
attachmentId: att.id,
|
||||
fileName: att.fileName,
|
||||
fileSize: resolvedSize,
|
||||
src: `/api/files/${att.id}/${att.fileName}`,
|
||||
imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image from a web (http/https) URL and insert it into a page in
|
||||
* one step.
|
||||
* By default the image is appended at the end. With replaceText, the first
|
||||
* top-level block whose text contains the string is replaced; with afterText,
|
||||
* the image is inserted right after the first matching block. All other
|
||||
* block ids are preserved (only one top-level block is added or swapped).
|
||||
*/
|
||||
async insertImage(
|
||||
pageId: string,
|
||||
url: string,
|
||||
opts: {
|
||||
align?: "left" | "center" | "right";
|
||||
alt?: string;
|
||||
replaceText?: string;
|
||||
afterText?: string;
|
||||
} = {},
|
||||
) {
|
||||
const up = await this.uploadImage(pageId, url);
|
||||
// Reuse the node from uploadImage (clean /api/files/<id>/<file> src), then
|
||||
// apply align/alt onto a shallow attrs copy.
|
||||
const node: any = { ...up.imageNode, attrs: { ...up.imageNode.attrs } };
|
||||
if (opts.align) node.attrs.align = opts.align;
|
||||
if (opts.alt) node.attrs.alt = opts.alt;
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// uploadImage /files/upload call above keeps the agent-supplied id.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Recursively collect the plain text of a top-level block.
|
||||
const blockText = (n: any): string => {
|
||||
let out = "";
|
||||
if (n.type === "text") out += n.text || "";
|
||||
for (const child of n.content || []) out += blockText(child);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Insert into the LIVE synced document, not the debounced REST snapshot, so
|
||||
// concurrent edits/comments/images are preserved and parallel insertImage
|
||||
// calls (serialized by the per-page lock) each see the previous insertion.
|
||||
let placement: "replaced" | "after" | "appended" | undefined;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (!Array.isArray(doc.content)) doc.content = [];
|
||||
|
||||
if (opts.replaceText) {
|
||||
// Ambiguity guard (mirrors editPageText): count matching top-level
|
||||
// blocks first, so a non-unique fragment cannot silently replace the
|
||||
// wrong block (e.g. text that also appears inside a callout/table).
|
||||
const matches = doc.content.filter((b: any) =>
|
||||
blockText(b).includes(opts.replaceText!),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`replaceText not found: "${opts.replaceText}"`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`,
|
||||
);
|
||||
}
|
||||
const idx = doc.content.findIndex((b: any) =>
|
||||
blockText(b).includes(opts.replaceText!),
|
||||
);
|
||||
// Data-loss guard: replaceText swaps the WHOLE top-level block, so if
|
||||
// the fragment only appears nested inside a container (table, callout,
|
||||
// list, blockquote) the entire structure would be destroyed. Refuse
|
||||
// when the matched block is a container rather than a leaf
|
||||
// paragraph/heading and point the caller at a safer tool.
|
||||
const CONTAINER_TYPES = new Set([
|
||||
"table",
|
||||
"callout",
|
||||
"bulletList",
|
||||
"orderedList",
|
||||
"taskList",
|
||||
"blockquote",
|
||||
]);
|
||||
const matchedBlock = doc.content[idx];
|
||||
if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) {
|
||||
throw new Error(
|
||||
`replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` +
|
||||
`Use afterText to insert near it, or updatePageJson for surgical edits.`,
|
||||
);
|
||||
}
|
||||
doc.content.splice(idx, 1, node);
|
||||
placement = "replaced";
|
||||
} else if (opts.afterText) {
|
||||
// Ambiguity guard (mirrors editPageText): refuse a non-unique fragment.
|
||||
const matches = doc.content.filter((b: any) =>
|
||||
blockText(b).includes(opts.afterText!),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`afterText not found: "${opts.afterText}"`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`,
|
||||
);
|
||||
}
|
||||
const idx = doc.content.findIndex((b: any) =>
|
||||
blockText(b).includes(opts.afterText!),
|
||||
);
|
||||
doc.content.splice(idx + 1, 0, node);
|
||||
placement = "after";
|
||||
} else {
|
||||
doc.content.push(node);
|
||||
placement = "appended";
|
||||
}
|
||||
|
||||
return doc;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pageId,
|
||||
attachmentId: up.attachmentId,
|
||||
src: up.src,
|
||||
placement,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an existing image in a page with a new image fetched from a web
|
||||
* (http/https) URL. Uploads the new file as a brand-new attachment, which
|
||||
* yields a fresh clean URL that both renders correctly and busts browser
|
||||
* caches (the URL changed). Finds every image node
|
||||
* whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested
|
||||
* in callouts/tables) and repoints its src/attachmentId/size, preserving
|
||||
* comments, alignment and alt. Operates on the live collab document so comments
|
||||
* and concurrent edits are preserved. Throws if no matching image is found.
|
||||
*
|
||||
* The OLD attachment is left in place as an unreferenced orphan: Docmost
|
||||
* exposes NO HTTP API to delete a single content attachment (verified against
|
||||
* the attachment controller/service and by probing the live API — deletion
|
||||
* happens only by cascade when the page, space or user is removed). This is the
|
||||
* same outcome as Docmost's own editor when an image is removed/replaced.
|
||||
* In-place byte overwrite is deliberately NOT used because some Docmost
|
||||
* versions corrupt the attachment (HTTP 500) when its bytes are overwritten.
|
||||
*/
|
||||
async replaceImage(
|
||||
pageId: string,
|
||||
oldAttachmentId: string,
|
||||
url: string,
|
||||
opts: { align?: "left" | "center" | "right"; alt?: string } = {},
|
||||
) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// page lock must ALSO key on the UUID so this operation serializes against
|
||||
// other writes to the same page (mutatePageContent now locks by the resolved
|
||||
// UUID too); locking by the raw slugId here would desync the mutex key and
|
||||
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
|
||||
// keeps the agent-supplied id (it hits REST, not the collab doc).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
|
||||
// Previously the scan and the write were two separate mutatePageContent
|
||||
// calls, each acquiring + releasing the lock, with the upload happening in
|
||||
// the UNLOCKED gap between them. A concurrent op could interleave there: it
|
||||
// could remove the target image so the write pass matches nothing, leaving
|
||||
// the freshly-uploaded attachment as an un-deletable orphan (Docmost has no
|
||||
// API to delete a single content attachment). Acquiring the lock once and
|
||||
// using the non-locking collab helper inside (the per-page mutex is NOT
|
||||
// reentrant, so the self-locking mutatePageContent would deadlock here)
|
||||
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
|
||||
// and does not touch the page lock, so it is safe to call while held.
|
||||
return withPageLock(pageUuid, async () => {
|
||||
// STEP 1: read-only live check. Scan the live document for any image node
|
||||
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
|
||||
// throws without ever creating an orphan attachment.
|
||||
let matchFound = false;
|
||||
const scan = (nodes: any[]) => {
|
||||
for (const node of nodes) {
|
||||
if (!node) continue;
|
||||
if (
|
||||
node.type === "image" &&
|
||||
node.attrs &&
|
||||
node.attrs.attachmentId === oldAttachmentId
|
||||
) {
|
||||
matchFound = true;
|
||||
}
|
||||
if (Array.isArray(node.content)) scan(node.content);
|
||||
}
|
||||
};
|
||||
|
||||
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||
matchFound = false; // reset per-transform (collab may retry the read).
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (Array.isArray(doc.content)) scan(doc.content);
|
||||
return null; // read-only: never write on the check pass.
|
||||
});
|
||||
|
||||
if (!matchFound) {
|
||||
throw new Error(
|
||||
`replaceImage: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// STEP 2: a match exists — upload the new file as a FRESH attachment (new
|
||||
// id, new clean URL) and repoint every matching node in a second pass.
|
||||
// Still inside the SAME lock, so no other op can have changed the page
|
||||
// since the scan.
|
||||
const up = await this.uploadImage(pageId, url);
|
||||
|
||||
let replaced = 0;
|
||||
|
||||
// Swap the source of one image node, preserving align/alt/title/geometry.
|
||||
const repoint = (node: any) => {
|
||||
node.attrs = {
|
||||
...node.attrs,
|
||||
src: up.src,
|
||||
attachmentId: up.attachmentId,
|
||||
// Default to null when fileSize is unknown so the attr is never
|
||||
// undefined.
|
||||
size: up.fileSize ?? null,
|
||||
};
|
||||
if (opts.align) node.attrs.align = opts.align;
|
||||
if (opts.alt !== undefined) node.attrs.alt = opts.alt;
|
||||
replaced++;
|
||||
};
|
||||
|
||||
// Recursively repoint every image node (incl. ones nested in callouts/tables).
|
||||
const walk = (nodes: any[]) => {
|
||||
for (const node of nodes) {
|
||||
if (!node) continue;
|
||||
if (
|
||||
node.type === "image" &&
|
||||
node.attrs &&
|
||||
node.attrs.attachmentId === oldAttachmentId
|
||||
) {
|
||||
repoint(node);
|
||||
}
|
||||
if (Array.isArray(node.content)) walk(node.content);
|
||||
}
|
||||
};
|
||||
|
||||
const mutation = await this.mutateLiveContentUnlocked(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
(liveDoc) => {
|
||||
// Reset per-transform so collab retries recompute cleanly (no double-count).
|
||||
replaced = 0;
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (!Array.isArray(doc.content)) doc.content = [];
|
||||
walk(doc.content);
|
||||
if (replaced === 0) return null; // no match -> skip the write entirely
|
||||
return doc;
|
||||
},
|
||||
);
|
||||
// KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no
|
||||
// text/mark change) may still report verify.changed === false, because the
|
||||
// text+marks+integrity-count model in summarizeChange does not inspect
|
||||
// image `src`/attachmentId attributes. That is acceptable here — the
|
||||
// replace is confirmed by `replaced` below, and verify is supplementary.
|
||||
|
||||
if (replaced === 0) {
|
||||
// The pass-1 SCAN found the target (matchFound was true) and we already
|
||||
// uploaded the new attachment, but pass-2 matched nothing — a concurrent
|
||||
// editor must have removed the node between the two passes. Do NOT throw
|
||||
// here (that would leak the just-uploaded attachment AND report failure);
|
||||
// instead report success with the upload flagged as an unreferenced
|
||||
// orphan so the caller knows. (The early throw above still covers the
|
||||
// case where pass-1 finds nothing, before any upload happens.)
|
||||
return {
|
||||
success: true,
|
||||
replaced: 0,
|
||||
pageId,
|
||||
oldAttachmentId,
|
||||
newAttachmentId: up.attachmentId,
|
||||
src: up.src,
|
||||
orphanedAttachmentId: up.attachmentId,
|
||||
warning:
|
||||
"target image was removed concurrently; uploaded attachment is unreferenced",
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pageId,
|
||||
replaced,
|
||||
oldAttachmentId,
|
||||
newAttachmentId: up.attachmentId,
|
||||
src: up.src,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- draw.io diagrams (issue #423) ---
|
||||
|
||||
/**
|
||||
* Upload a ready-made byte buffer as a page attachment via the same
|
||||
* multipart /files/upload endpoint uploadImage uses. Split out as its own
|
||||
* (overridable) seam so drawioCreate/update can upload the generated
|
||||
* `.drawio.svg` without going through the URL-fetch path, and so tests can
|
||||
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
|
||||
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
|
||||
* attempt).
|
||||
*/
|
||||
|
||||
// --- draw.io diagrams (issue #423) ---
|
||||
|
||||
/**
|
||||
* Upload a ready-made byte buffer as a page attachment via the same
|
||||
* multipart /files/upload endpoint uploadImage uses. Split out as its own
|
||||
* (overridable) seam so drawioCreate/update can upload the generated
|
||||
* `.drawio.svg` without going through the URL-fetch path, and so tests can
|
||||
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
|
||||
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
|
||||
* attempt).
|
||||
*/
|
||||
protected async uploadAttachmentBuffer(
|
||||
pageId: string,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): Promise<{ id: string; fileName: string; fileSize: number }> {
|
||||
await this.ensureAuthenticated();
|
||||
const buildForm = () => {
|
||||
const form = new FormData();
|
||||
form.append("pageId", pageId);
|
||||
form.append("file", buffer, { filename: fileName, contentType: mime });
|
||||
return form;
|
||||
};
|
||||
const uploadUrl = `${this.apiUrl}/files/upload`;
|
||||
let response;
|
||||
try {
|
||||
const form = buildForm();
|
||||
response = await axios.post(uploadUrl, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await this.login();
|
||||
const form2 = buildForm();
|
||||
response = await axios.post(uploadUrl, form2, {
|
||||
headers: {
|
||||
...form2.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} else if (axios.isAxiosError(error)) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Attachment upload failed; response body:",
|
||||
JSON.stringify(error.response?.data),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const att = response.data?.data ?? response.data;
|
||||
if (!att?.id || !att?.fileName) {
|
||||
throw new Error(
|
||||
"Unexpected /files/upload response: " + JSON.stringify(response.data),
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: att.id,
|
||||
fileName: att.fileName,
|
||||
fileSize: att.fileSize ?? buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a stored `.drawio.svg` attachment as text. Overridable seam over
|
||||
* fetchInternalFile (the authed loopback fetch, which also rejects any
|
||||
* traversal/SSRF src) so drawioGet/update can read the current diagram and
|
||||
* tests can stub the bytes.
|
||||
*/
|
||||
protected async fetchAttachmentText(src: string): Promise<string> {
|
||||
const { buffer } = await this.fetchInternalFile(src);
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||
* drawio node.
|
||||
*/
|
||||
}
|
||||
return MediaMixin;
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import {
|
||||
importMarkdownFragment,
|
||||
canBeDocChild,
|
||||
findUnrepresentableTableAttrs,
|
||||
} from "../lib/markdown-fragment.js";
|
||||
import {
|
||||
applyTextEdits,
|
||||
TextEdit,
|
||||
TextEditResult,
|
||||
TextEditFailure,
|
||||
} from "../lib/json-edit.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
|
||||
|
||||
// Public method surface of NodesWriteMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements INodesWriteMixin` fails to compile on drift.
|
||||
export interface INodesWriteMixin {
|
||||
updatePageJson(pageId: string, doc?: any, title?: string): any;
|
||||
editPageText(pageId: string, edits: TextEdit[]): any;
|
||||
patchNode(pageId: string, nodeId: string, input: { markdown?: string; node?: any }): any;
|
||||
insertNode(pageId: string, input: { markdown?: string; node?: any }, opts: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }): any;
|
||||
deleteNode(pageId: string, nodeId: string): any;
|
||||
}
|
||||
|
||||
export function NodesWriteMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & INodesWriteMixin> & TBase {
|
||||
abstract class NodesWriteMixin extends Base implements INodesWriteMixin {
|
||||
/**
|
||||
* Replace page content with a raw ProseMirror JSON document (lossless) and/or
|
||||
* update its title. Both `doc` and `title` are optional, but at least one must
|
||||
* be supplied:
|
||||
* - `doc` provided -> validate + full-overwrite the body (and update the
|
||||
* title too when `title` is also given).
|
||||
* - `doc` omitted, `title` given -> title-only update; the body is NOT
|
||||
* touched/resent (no collab write happens).
|
||||
* - neither given -> throws (nothing to update).
|
||||
*/
|
||||
async updatePageJson(pageId: string, doc?: any, title?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Title-only / no-op handling: when no document is supplied, do NOT write
|
||||
// the body. Update the title if one was given; otherwise there is nothing
|
||||
// to do, so fail loudly rather than silently no-op.
|
||||
if (doc == null) {
|
||||
if (!title) {
|
||||
throw new Error(
|
||||
"updatePageJson: nothing to update (provide content and/or title)",
|
||||
);
|
||||
}
|
||||
await this.client.post("/pages/update", { pageId, title });
|
||||
return {
|
||||
success: true,
|
||||
modified: true,
|
||||
message: "Page title updated (content left unchanged).",
|
||||
pageId,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the document shape before a full overwrite: a malformed doc
|
||||
// would otherwise silently corrupt the page (full-overwrite is the
|
||||
// documented behaviour; no optimistic-concurrency is applied here).
|
||||
if (
|
||||
typeof doc !== "object" ||
|
||||
doc.type !== "doc" ||
|
||||
!Array.isArray(doc.content)
|
||||
) {
|
||||
throw new Error(
|
||||
'content must be a ProseMirror document ({"type":"doc","content":[...]}) ' +
|
||||
"where content is an array of nodes each having a string `type`",
|
||||
);
|
||||
}
|
||||
|
||||
// Recurse the WHOLE document so a malformed nested node (e.g. a node with a
|
||||
// non-string type, a non-array content/marks, or a text node missing its
|
||||
// string text) is rejected up front rather than silently corrupting the
|
||||
// page on overwrite.
|
||||
this.validateDocStructure(doc);
|
||||
|
||||
// #409: beyond the string-`type` check above, reject a nested node whose
|
||||
// `type` is a string but NOT a known Docmost schema node (a typo/unknown
|
||||
// block) — the same `Unknown node type` the encoder throws — with a rich,
|
||||
// path-anchored message, still BEFORE any collab connection.
|
||||
this.assertValidNodeShape("updatePageJson", doc);
|
||||
|
||||
// Sanitize URLs before writing. This closes the JSON-path bypass: unlike
|
||||
// the markdown link path (which TipTap sanitizes), raw JSON could otherwise
|
||||
// inject javascript:/data: link hrefs or media srcs straight into the doc.
|
||||
this.validateDocUrls(doc);
|
||||
|
||||
// Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot
|
||||
// leave footnotes out of order, orphaned, or in multiple lists — the bottom
|
||||
// list + numbering are always derived from reference order. No-op when the
|
||||
// footnotes are already canonical.
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
doc = normalizeAndMergeFootnotes(doc);
|
||||
doc = canonicalizeFootnotes(doc);
|
||||
|
||||
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||
// write (e.g. persist timeout) must not leave a new title over the old body.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await this.replacePage(
|
||||
pageUuid,
|
||||
doc,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
);
|
||||
|
||||
// Body persisted successfully — now it is safe to set the title.
|
||||
if (title) {
|
||||
await this.client.post("/pages/update", { pageId, title });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modified: true,
|
||||
message: "Page content replaced from ProseMirror JSON.",
|
||||
pageId,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTHOR-INLINE footnote insertion. The agent supplies only WHERE
|
||||
* (`anchorText`, a snippet of body text to attach the marker after) and WHAT
|
||||
* (`text`, the footnote content as markdown). Numbering and the bottom
|
||||
* `footnotesList` are derived deterministically server-side
|
||||
* (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees,
|
||||
* assigns, or edits a footnote number or the list, so it CANNOT desync.
|
||||
*
|
||||
* Content DEDUP: when an existing definition has the same content, its id is
|
||||
* reused (one number, one definition, several references). The write is atomic
|
||||
* via `mutatePageContent` (single-writer, page-locked); if the anchor text is
|
||||
* not found the transform aborts with a clear error and no write happens.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Surgical text edits: find/replace inside text nodes of the live
|
||||
* document. Preserves all block ids, marks, callouts and tables.
|
||||
*/
|
||||
async editPageText(pageId: string, edits: TextEdit[]) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Apply the edits against the LIVE synced document, not the debounced REST
|
||||
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
|
||||
// records per-edit match problems in `failed` instead of throwing, and
|
||||
// applies whatever it can; we abort the write only when nothing applied.
|
||||
let results: TextEditResult[] | undefined;
|
||||
let failed: TextEditFailure[] | undefined;
|
||||
// Whether we actually wrote new content. Set inside the transform: a
|
||||
// degenerate edit (e.g. find === replace, or a batch that nets to no change)
|
||||
// can "apply" yet leave the document byte-for-byte identical, in which case
|
||||
// we must NOT write (no spurious history version) and must not claim a write
|
||||
// happened.
|
||||
let wrote = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
wrote = false;
|
||||
const r = applyTextEdits(liveDoc, edits);
|
||||
results = r.results;
|
||||
failed = r.failed;
|
||||
// Nothing applied -> abort the write (mutatePageContent treats a null
|
||||
// return from the transform as "write nothing").
|
||||
if (r.results.length === 0) return null;
|
||||
// Edits "applied" but produced an identical document: skip the write so
|
||||
// no new history version is created. Stable structural comparison via
|
||||
// JSON.stringify (both docs come from the same deep-copied source, so
|
||||
// key order is stable).
|
||||
if (JSON.stringify(r.doc) === JSON.stringify(liveDoc)) return null;
|
||||
wrote = true;
|
||||
return r.doc;
|
||||
},
|
||||
);
|
||||
|
||||
if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) {
|
||||
// No edit applied: surface an aggregated, actionable error so the caller
|
||||
// does not mistake a no-op for a partial success.
|
||||
throw new Error(
|
||||
"editPageText: no edits were applied (nothing written). " +
|
||||
failed!.map((f) => `"${f.find}": ${f.reason}`).join("; "),
|
||||
);
|
||||
}
|
||||
|
||||
// Edits matched but produced no content change (identical document): report
|
||||
// a successful no-op — NOT a failure — and do not falsely claim a write.
|
||||
if (!wrote) {
|
||||
return {
|
||||
success: true,
|
||||
pageId,
|
||||
applied: results,
|
||||
failed,
|
||||
message: "No changes written (edits produced identical content).",
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
success: true,
|
||||
pageId,
|
||||
applied: results,
|
||||
failed,
|
||||
message:
|
||||
(failed?.length ?? 0)
|
||||
? `Applied ${results?.length ?? 0} edit(s); ${failed!.length} failed (see failed[]). Node ids and formatting preserved.`
|
||||
: "Text edits applied (node ids and formatting preserved).",
|
||||
verify: mutation.verify,
|
||||
};
|
||||
|
||||
// If any applied edit matched only after stripping markdown (the
|
||||
// normalized fallback), warn that editPageText preserved existing marks
|
||||
// and did NOT change formatting — so a caller who intended a formatting
|
||||
// change is pointed at patchNode.
|
||||
if (results?.some((r) => r.normalized === true)) {
|
||||
result.warning =
|
||||
"Some edits matched only after stripping markdown from your find string; " +
|
||||
"editPageText preserved existing marks (it did not change bold/strike/etc.). " +
|
||||
"If you intended a formatting change, use patchNode.";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the block whose attrs.id === nodeId. Operates on the LIVE collab
|
||||
* document so comments and concurrent edits are preserved.
|
||||
*
|
||||
* Exactly one of `input.markdown` / `input.node` (#413):
|
||||
* - `markdown` (RECOMMENDED): the block is rewritten from a canonical markdown
|
||||
* fragment. The fragment may import to N blocks (a "1 -> N" splice: rewrite a
|
||||
* whole section in one call). The FIRST resulting block INHERITS the target's
|
||||
* `attrs.id` (so an existing comment anchoring the block by id survives); the
|
||||
* rest get FRESH ids. `^[...]` footnotes in the fragment are first-class:
|
||||
* their definitions merge into the page's TAIL footnote list (content-key
|
||||
* dedup + canonicalize), same machinery insertFootnote uses. REJECTED when
|
||||
* the TARGET block carries a table-cell attribute markdown cannot represent
|
||||
* (colspan/rowspan/colwidth/background) — use the table tools or `node`.
|
||||
* - `node`: a raw ProseMirror node for precise attr/mark work. The replacement
|
||||
* keeps the target id (if `node.attrs.id` is missing it is set to nodeId).
|
||||
*
|
||||
* #159 ambiguous-id semantics are unchanged: 0 matches -> "no node"; >1 matches
|
||||
* -> "ambiguous, refused" (nothing written), on BOTH paths — the markdown path
|
||||
* runs a dry `replaceNodeById` count first, so a duplicated id never splices.
|
||||
*/
|
||||
async patchNode(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
input: { markdown?: string; node?: any },
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// XOR: exactly one of markdown / node. Both optional in the schema; the
|
||||
// runtime enforces the recommendation ("markdown for prose, node for fine
|
||||
// work") without letting an ambiguous both-or-neither call through.
|
||||
const hasMd =
|
||||
input != null &&
|
||||
typeof input.markdown === "string" &&
|
||||
input.markdown.trim() !== "";
|
||||
const hasNode = input != null && input.node != null;
|
||||
if (hasMd === hasNode) {
|
||||
throw new Error(
|
||||
"patchNode: provide exactly one of `markdown` (recommended, for prose) " +
|
||||
"or `node` (a raw ProseMirror node, for precise attr/mark work)",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMd) {
|
||||
return this.patchNodeMarkdown(pageId, nodeId, input.markdown as string);
|
||||
}
|
||||
return this.patchNodeJson(pageId, nodeId, input.node);
|
||||
}
|
||||
|
||||
/**
|
||||
* patchNode with a raw ProseMirror `node` (the pre-#413 behavior). Replaces
|
||||
* EVERY node whose attrs.id === nodeId; the swapped-in node keeps the target
|
||||
* id. #159 ambiguity refused. Split out so the markdown path can reuse the
|
||||
* shared collab/guard plumbing without a giant branch.
|
||||
*/
|
||||
protected async patchNodeJson(pageId: string, nodeId: string, node: any) {
|
||||
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||
throw new Error(
|
||||
"patchNode: `node` must be an object with a string `type`",
|
||||
);
|
||||
}
|
||||
// Preserve the block id WITHOUT mutating the caller's object: build a local
|
||||
// copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the
|
||||
// node it replaces).
|
||||
const target = {
|
||||
...node,
|
||||
attrs: {
|
||||
...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}),
|
||||
},
|
||||
};
|
||||
if (target.attrs.id == null) {
|
||||
target.attrs.id = nodeId;
|
||||
}
|
||||
|
||||
// #409: fail fast on a malformed node SHAPE (a nested child with an
|
||||
// absent/unknown `type`, e.g. a text leaf written as `{"text":"foo"}` with
|
||||
// no `"type":"text"`) BEFORE opening a collab session or taking the page
|
||||
// lock — the root-only `typeof node.type === "string"` check above never
|
||||
// sees nested children, and the encoder's `Unknown node type: undefined`
|
||||
// would otherwise only surface after the connection.
|
||||
this.assertValidNodeShape("patchNode", target);
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track the replacement count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let replaced = 0;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
replaced = 0;
|
||||
const { doc: nd, replaced: r } = replaceNodeById(
|
||||
liveDoc,
|
||||
nodeId,
|
||||
target,
|
||||
);
|
||||
replaced = r;
|
||||
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost
|
||||
// duplicates block ids on copy/paste (and copyPageContent writes them
|
||||
// verbatim), so replacing "the node with id X" would silently clobber
|
||||
// EVERY duplicate (#159). Refuse: skip the write and throw below so the
|
||||
// model re-targets with a more specific anchor instead of corrupting the
|
||||
// page. Only an unambiguous single match is written.
|
||||
if (replaced !== 1) return null;
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
|
||||
// the write for any count !== 1). Single shared guard (#159, #185 review).
|
||||
assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId);
|
||||
|
||||
return { success: true, replaced, nodeId, verify: mutation.verify };
|
||||
}
|
||||
|
||||
/**
|
||||
* patchNode with a MARKDOWN fragment (#413). Imports the fragment through the
|
||||
* canonical importer, then 1 -> N splices the resulting blocks in place of the
|
||||
* target block on the LIVE collab doc:
|
||||
* - the FIRST block inherits the target's id; the rest get FRESH ids (minted
|
||||
* by the importer/id-remap, so neighbour blocks are untouched);
|
||||
* - `^[...]` footnote definitions merge into the page's tail list;
|
||||
* - REJECTED when the target block carries a markdown-unrepresentable table
|
||||
* attr (colspan/rowspan/colwidth/background) — guarding against silent loss;
|
||||
* - #159 ambiguity is enforced by a dry `replaceNodeById` count BEFORE the
|
||||
* splice, so a duplicated id never writes.
|
||||
*/
|
||||
protected async patchNodeMarkdown(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
markdown: string,
|
||||
) {
|
||||
// Import the fragment up front (network-free, canonical) so a bad fragment
|
||||
// fails before any collab connection or page lock.
|
||||
const { blocks, definitions } = await importMarkdownFragment(markdown);
|
||||
|
||||
// The first imported block inherits the target id; the rest keep the fresh
|
||||
// ids the importer assigned. Build the thread now so it is stable across a
|
||||
// collab retry (the transform below is pure over its inputs).
|
||||
const threaded = blocks.map((b, i) => {
|
||||
if (i !== 0) return b;
|
||||
return {
|
||||
...b,
|
||||
attrs: {
|
||||
...(b && typeof b.attrs === "object" ? b.attrs : {}),
|
||||
id: nodeId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Shape-validate every imported block up front (parity with the JSON path):
|
||||
// the importer only emits schema nodes, but the check is cheap insurance and
|
||||
// yields the same rich #409 diagnostics if the schema ever drifts.
|
||||
for (const b of threaded) {
|
||||
this.assertValidNodeShape("patchNode", b);
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let replaced = 0;
|
||||
let guardAttrs: string | null = null;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
replaced = 0;
|
||||
guardAttrs = null;
|
||||
|
||||
// #159: count matches with the same recursive walk the JSON path uses;
|
||||
// only an UNAMBIGUOUS single match may write. A dry count keeps the
|
||||
// ambiguity semantics identical across both paths.
|
||||
const { replaced: count } = replaceNodeById(liveDoc, nodeId, {
|
||||
type: "paragraph",
|
||||
});
|
||||
replaced = count;
|
||||
if (count !== 1) return null;
|
||||
|
||||
// Guard against SILENT LOSS: if the target block carries a table-cell
|
||||
// attribute markdown cannot represent (colspan/rowspan/colwidth/
|
||||
// background), refuse the markdown rewrite so those attrs are not
|
||||
// dropped. Simple tables (no such attrs) rewrite fine.
|
||||
const hit = getNodeByRef(liveDoc, nodeId);
|
||||
guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null;
|
||||
if (guardAttrs != null) return null;
|
||||
|
||||
// Re-mint any minted block id that collides with an existing page id
|
||||
// (skip index 0: its id is intentionally the target nodeId, unique by
|
||||
// the #159 dry-count above), so the 1 -> N splice stays page-wide unique.
|
||||
reassignCollidingBlockIds(liveDoc, threaded, 0);
|
||||
|
||||
// 1 -> N splice, then merge any fragment footnote definitions into the
|
||||
// page's tail list and re-derive canonical footnote numbering.
|
||||
const { doc: spliced } = replaceNodeByIdWithMany(
|
||||
liveDoc,
|
||||
nodeId,
|
||||
threaded,
|
||||
);
|
||||
return mergeFootnoteDefinitions(spliced, definitions);
|
||||
},
|
||||
);
|
||||
|
||||
// Surface the guard rejection with an actionable message (nothing written).
|
||||
if (guardAttrs != null) {
|
||||
throw new Error(
|
||||
`patchNode: the target block has table-cell attributes markdown cannot ` +
|
||||
`represent (${guardAttrs}) — a markdown rewrite would drop them. Use ` +
|
||||
`the table tools (tableUpdateCell/tableInsertRow) or pass a raw ` +
|
||||
`ProseMirror \`node\` instead of \`markdown\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform skipped the write
|
||||
// for any count !== 1). Shared #159 guard, identical to the JSON path.
|
||||
assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
replaced,
|
||||
nodeId,
|
||||
blocks: threaded.length,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content relative to an anchor (or append it at the top level).
|
||||
* Operates on the LIVE collab document so comments and concurrent edits are
|
||||
* preserved.
|
||||
*
|
||||
* Exactly one of `input.markdown` / `input.node` (#413):
|
||||
* - `markdown` (RECOMMENDED): a canonical markdown fragment. It may import to
|
||||
* SEVERAL blocks — they are inserted IN ORDER at the anchor. `^[...]`
|
||||
* footnote definitions merge into the page's tail list (same machinery as
|
||||
* insertFootnote). Every inserted block gets a fresh id.
|
||||
* - `node`: a raw ProseMirror node for precise attr/mark work, or to insert
|
||||
* table structure (a bare tableRow/tableCell/tableHeader — NOT expressible in
|
||||
* markdown, so those stay JSON-only).
|
||||
*
|
||||
* opts.position:
|
||||
* - "append": push the content at the end of the top-level content.
|
||||
* - "before"/"after": insert as a sibling of the anchor, just before/after it.
|
||||
* Exactly one of anchorNodeId / anchorText must be given; anchorNodeId
|
||||
* locates a node anywhere by attrs.id, anchorText matches the first top-level
|
||||
* block whose plain text includes it.
|
||||
*
|
||||
* Throws if the anchor cannot be found.
|
||||
*/
|
||||
async insertNode(
|
||||
pageId: string,
|
||||
input: { markdown?: string; node?: any },
|
||||
opts: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// XOR: exactly one of markdown / node (both optional in the schema).
|
||||
const hasMd =
|
||||
input != null &&
|
||||
typeof input.markdown === "string" &&
|
||||
input.markdown.trim() !== "";
|
||||
const hasNode = input != null && input.node != null;
|
||||
if (hasMd === hasNode) {
|
||||
throw new Error(
|
||||
"insertNode: provide exactly one of `markdown` (recommended, for prose) " +
|
||||
"or `node` (a raw ProseMirror node, for precise attr/mark work or table structure)",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!opts ||
|
||||
(opts.position !== "before" &&
|
||||
opts.position !== "after" &&
|
||||
opts.position !== "append")
|
||||
) {
|
||||
throw new Error(
|
||||
'insertNode: `position` must be one of "before", "after", "append"',
|
||||
);
|
||||
}
|
||||
if (opts.position === "before" || opts.position === "after") {
|
||||
// before/after require EXACTLY ONE anchor (an id or a text fragment).
|
||||
const hasId =
|
||||
typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0;
|
||||
const hasText =
|
||||
typeof opts.anchorText === "string" && opts.anchorText.length > 0;
|
||||
if (hasId === hasText) {
|
||||
throw new Error(
|
||||
`insertNode: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the ordered list of blocks to insert plus any footnote definitions
|
||||
// to merge. The markdown path imports canonically (so an inserted block is
|
||||
// byte-identical to the same content in a full-page import); the node path is
|
||||
// a single block with no footnote merge (raw JSON `^[...]` is not touched).
|
||||
let blocks: any[];
|
||||
let definitions: any[] = [];
|
||||
if (hasMd) {
|
||||
const frag = await importMarkdownFragment(input.markdown as string);
|
||||
blocks = frag.blocks;
|
||||
definitions = frag.definitions;
|
||||
} else {
|
||||
const node = input.node;
|
||||
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||
throw new Error(
|
||||
"insertNode: `node` must be an object with a string `type`",
|
||||
);
|
||||
}
|
||||
blocks = [node];
|
||||
}
|
||||
|
||||
// #409: fail fast on a malformed node SHAPE (a nested child with an
|
||||
// absent/unknown `type`) BEFORE opening a collab session or taking the page
|
||||
// lock — the root-only check above never sees nested children.
|
||||
for (const b of blocks) {
|
||||
this.assertValidNodeShape("insertNode", b);
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
inserted = false;
|
||||
// Re-mint any minted block id that collides with an existing page id
|
||||
// (all inserted blocks are fresh, no skip) so the splice stays unique.
|
||||
if (hasMd) reassignCollidingBlockIds(liveDoc, blocks);
|
||||
// Single-block node path keeps `insertNodeRelative` (it owns the
|
||||
// structural table-node splicing); the markdown path uses the array
|
||||
// splice so N blocks land in order at one anchor.
|
||||
const res = hasMd
|
||||
? insertNodesRelative(liveDoc, blocks, opts)
|
||||
: insertNodeRelative(liveDoc, blocks[0], opts);
|
||||
inserted = res.inserted;
|
||||
if (!inserted) return null; // anchor not found -> skip the write entirely
|
||||
// Merge any fragment footnote definitions into the page tail list and
|
||||
// re-derive canonical numbering (no-op when there are none).
|
||||
return mergeFootnoteDefinitions(res.doc, definitions);
|
||||
},
|
||||
);
|
||||
|
||||
if (!inserted) {
|
||||
const anchorDesc = opts.anchorNodeId
|
||||
? `anchorNodeId "${opts.anchorNodeId}"`
|
||||
: `anchorText "${opts.anchorText}"`;
|
||||
// anchorText is matched against the block's literal RENDERED plain text;
|
||||
// markdown/emoji are tolerated only as a strip-and-retry fallback, so a
|
||||
// miss usually means the text differs from what's on the page.
|
||||
const hint = opts.anchorText
|
||||
? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from getPageJson is more reliable."
|
||||
: "";
|
||||
throw new Error(
|
||||
`insertNode: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inserted: true,
|
||||
position: opts.position,
|
||||
blocks: blocks.length,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove EVERY node whose attrs.id === nodeId (recursively, including nodes
|
||||
* nested in callouts/tables) from its parent content array. Operates on the
|
||||
* LIVE collab document so comments and concurrent edits are preserved.
|
||||
* Throws if no node matches.
|
||||
*/
|
||||
async deleteNode(pageId: string, nodeId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track the deletion count in an outer var, reset per-transform, so a
|
||||
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||
let deleted = 0;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
deleted = 0;
|
||||
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
||||
deleted = d;
|
||||
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block
|
||||
// ids are duplicated on copy/paste, #159): deleting "the node with id X"
|
||||
// would silently remove EVERY duplicate. Refuse: skip the write and throw
|
||||
// below so the model re-targets. Only an unambiguous single match is
|
||||
// deleted.
|
||||
if (deleted !== 1) return null;
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
|
||||
// the write for any count !== 1). Single shared guard (#159, #185 review).
|
||||
assertUnambiguousMatch("deleteNode", "delete", deleted, nodeId, pageId);
|
||||
|
||||
return { success: true, deleted, nodeId, verify: mutation.verify };
|
||||
}
|
||||
|
||||
/** Build the public share URL for a page. */
|
||||
}
|
||||
return NodesWriteMixin;
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import FormData from "form-data";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import { footnoteWarningsField } from "../lib/footnote-analyze.js";
|
||||
import {
|
||||
serializeDocmostMarkdown,
|
||||
parseDocmostMarkdown,
|
||||
} from "../lib/markdown-document.js";
|
||||
import { diffDocs, summarizeChange } from "../lib/diff.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Public method surface of PagesMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements IPagesMixin` fails to compile on drift.
|
||||
export interface IPagesMixin {
|
||||
createPage(title: string, content: string, spaceId: string, parentPageId?: string): any;
|
||||
updatePage(pageId: string, content: string, title?: string): any;
|
||||
renamePage(pageId: string, title: string): any;
|
||||
movePage(pageId: string, parentPageId: string | null, position?: string): any;
|
||||
deletePage(pageId: string): any;
|
||||
sharePage(pageId: string, searchIndexing?: boolean): any;
|
||||
listShares(): any;
|
||||
unsharePage(pageId: string): any;
|
||||
exportPageMarkdown(pageId: string): Promise<string>;
|
||||
importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any>;
|
||||
copyPageContent(sourcePageId: string, targetPageId: string): any;
|
||||
listPageHistory(pageId: string, cursor?: string): any;
|
||||
getPageHistory(historyId: string): any;
|
||||
restorePageVersion(historyId: string): any;
|
||||
diffPageVersions(pageId: string, from?: string, to?: string): any;
|
||||
}
|
||||
|
||||
export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IPagesMixin> & TBase {
|
||||
abstract class PagesMixin extends Base implements IPagesMixin {
|
||||
/**
|
||||
* Create a new page with title and content.
|
||||
* Uses the /pages/import workaround (the only endpoint accepting content),
|
||||
* then moves the page and restores the exact title: the import endpoint
|
||||
* derives the title from the FILENAME and replaces spaces with
|
||||
* underscores, so we explicitly re-set it via /pages/update afterwards.
|
||||
*/
|
||||
async createPage(
|
||||
title: string,
|
||||
content: string,
|
||||
spaceId: string,
|
||||
parentPageId?: string,
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
if (parentPageId) {
|
||||
try {
|
||||
await this.getPage(parentPageId);
|
||||
} catch (e) {
|
||||
throw new Error(`Parent page with ID ${parentPageId} not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Create content via Import (using multipart/form-data).
|
||||
// Build a FRESH FormData per send attempt: a FormData body is a single-use
|
||||
// stream consumed on the first send, so it cannot be replayed by
|
||||
// this.client's response interceptor (replay fails with 'socket hang up').
|
||||
// Multipart re-auth is therefore done here with bare axios and an explicit
|
||||
// one-shot 401/403 retry that rebuilds the body.
|
||||
const fileContent = Buffer.from(content, "utf-8");
|
||||
const buildForm = () => {
|
||||
const form = new FormData();
|
||||
form.append("spaceId", spaceId);
|
||||
form.append("file", fileContent, {
|
||||
filename: `${title || "import"}.md`,
|
||||
contentType: "text/markdown",
|
||||
});
|
||||
return form;
|
||||
};
|
||||
|
||||
const importUrl = `${this.apiUrl}/pages/import`;
|
||||
let response;
|
||||
try {
|
||||
// Call buildForm() ONCE per attempt and reuse the instance for both
|
||||
// getHeaders() and the body so the Content-Type boundary matches the body.
|
||||
const form = buildForm();
|
||||
// Read the Authorization header from this.client's defaults (set by
|
||||
// login(), only ever deleted — never set to null) instead of building
|
||||
// `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
|
||||
// mid-flight, which would otherwise produce a literal "Bearer null".
|
||||
// ensureAuthenticated() above guarantees login() ran, so the default
|
||||
// header exists here.
|
||||
response = await axios.post(importUrl, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} catch (error) {
|
||||
// On an expired-token auth error, re-login and retry exactly once with a
|
||||
// freshly-rebuilt FormData (the previous one was already consumed).
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await this.login();
|
||||
const form2 = buildForm();
|
||||
response = await axios.post(importUrl, form2, {
|
||||
headers: {
|
||||
...form2.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const newPageId = (response.data?.data ?? response.data).id;
|
||||
|
||||
// 2. Move to parent if needed
|
||||
if (parentPageId) {
|
||||
await this.movePage(newPageId, parentPageId);
|
||||
}
|
||||
|
||||
// 3. Restore the exact title (import mangles spaces into underscores)
|
||||
if (title) {
|
||||
await this.client.post("/pages/update", { pageId: newPageId, title });
|
||||
}
|
||||
|
||||
const page = await this.getPage(newPageId);
|
||||
// Surface non-fatal footnote problems (dangling refs, empty/duplicate
|
||||
// definitions, markers in tables) so the agent can fix its markup (#166).
|
||||
return { ...page, ...footnoteWarningsField(content) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a page's content from markdown and optionally its title.
|
||||
* NOTE: full re-import — block ids regenerate. For surgical changes
|
||||
* use editPageText / updatePageJson instead.
|
||||
*/
|
||||
async updatePage(pageId: string, content: string, title?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
// REST /pages/update title write below keeps the agent-supplied id (the
|
||||
// server resolves a slugId there).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Write the BODY first, then the title (#159 split-brain). If the collab
|
||||
// body write fails (e.g. a persist timeout), the title must be left
|
||||
// UNTOUCHED so the page never ends up with a new title over its old body.
|
||||
// A title write failing AFTER a successful body is rarer (REST is fast) and
|
||||
// leaves correct content under a stale title — the lesser inconsistency.
|
||||
let collabToken = "";
|
||||
let mutation;
|
||||
try {
|
||||
collabToken = await this.getCollabTokenWithReauth();
|
||||
mutation = await updatePageContentRealtime(
|
||||
pageUuid,
|
||||
content,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Verbose diagnostics (incl. anything that could expose a token prefix)
|
||||
// are gated behind DEBUG; the thrown Error below carries no token data.
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Failed to update page content via realtime collaboration:",
|
||||
error,
|
||||
);
|
||||
const tokenPreview = collabToken
|
||||
? collabToken.substring(0, 15) + "..."
|
||||
: "null";
|
||||
console.error(`Collab token preview: ${tokenPreview}`);
|
||||
}
|
||||
throw new Error(`Failed to update page content: ${error.message}`);
|
||||
}
|
||||
|
||||
// Body persisted successfully — now it is safe to set the title.
|
||||
if (title) {
|
||||
await this.client.post("/pages/update", { pageId, title });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modified: true,
|
||||
message: "Page updated successfully.",
|
||||
pageId: pageId,
|
||||
verify: mutation.verify,
|
||||
// Non-fatal footnote diagnostics (#166); omitted when there are none.
|
||||
...footnoteWarningsField(content),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL string against a scheme allowlist for a given context.
|
||||
*
|
||||
* The markdown link path enforces safe schemes via TipTap, but the raw
|
||||
* JSON path (updatePageJson) bypasses that — so this is the sanitization
|
||||
* choke point for ProseMirror JSON written directly by the caller.
|
||||
*
|
||||
* - "link": reject javascript:, vbscript:, data: (any scheme that can
|
||||
* execute or smuggle script when the href is clicked).
|
||||
* - "src": allow only http(s):, mailto:, /api/files paths, or a
|
||||
* scheme-less relative/absolute path; reject
|
||||
* javascript:/vbscript:/data:/file:.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rename a page (change its title only) without touching or resending its
|
||||
* content. The slug is derived from the page record, not the body, so it is
|
||||
* left intact too.
|
||||
*/
|
||||
async renamePage(pageId: string, title: string) {
|
||||
await this.ensureAuthenticated();
|
||||
await this.client.post("/pages/update", { pageId, title });
|
||||
return { success: true, pageId, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the WHOLE content of one page onto another, entirely server-side: the
|
||||
* source's ProseMirror document is read and written verbatim onto the target
|
||||
* via the live collab path, so the document never passes through the model.
|
||||
*
|
||||
* Only the target's BODY is replaced — its title and slug live on the page
|
||||
* record (not in the content), so they are untouched. The source page is not
|
||||
* modified at all.
|
||||
*/
|
||||
|
||||
async movePage(
|
||||
pageId: string,
|
||||
parentPageId: string | null,
|
||||
position?: string,
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
// Docmost requires position >= 5 chars.
|
||||
const validPosition = position || "a00000";
|
||||
|
||||
return this.client
|
||||
.post("/pages/move", {
|
||||
pageId,
|
||||
parentPageId,
|
||||
position: validPosition,
|
||||
})
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
|
||||
async deletePage(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
return this.client
|
||||
.post("/pages/delete", { pageId })
|
||||
.then((res) => res.data);
|
||||
}
|
||||
|
||||
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
|
||||
|
||||
/**
|
||||
* Normalize a comment's `content` into a ProseMirror doc object before
|
||||
* markdown conversion. createComment/updateComment send content as a
|
||||
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
|
||||
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
|
||||
* string, so parse it first (guarded — fall back to the raw value on any
|
||||
* parse failure so a non-JSON legacy value is still handled gracefully).
|
||||
*/
|
||||
|
||||
/** Share a page publicly (idempotent) and return the public URL. */
|
||||
async sharePage(pageId: string, searchIndexing: boolean = true) {
|
||||
await this.ensureAuthenticated();
|
||||
const response = await this.client.post("/shares/create", {
|
||||
pageId,
|
||||
includeSubPages: false,
|
||||
searchIndexing,
|
||||
});
|
||||
const share = response.data?.data ?? response.data;
|
||||
const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId;
|
||||
return {
|
||||
shareId: share.id,
|
||||
key: share.key,
|
||||
pageId: share.pageId,
|
||||
publicUrl: this.shareUrl(share.key, slugId),
|
||||
searchIndexing: share.searchIndexing,
|
||||
};
|
||||
}
|
||||
|
||||
/** List all public shares in the workspace with their URLs. */
|
||||
|
||||
/** Build the public share URL for a page. */
|
||||
protected shareUrl(shareKey: string, slugId: string): string {
|
||||
return `${this.appUrl}/share/${shareKey}/p/${slugId}`;
|
||||
}
|
||||
|
||||
/** Share a page publicly (idempotent) and return the public URL. */
|
||||
|
||||
/** List all public shares in the workspace with their URLs. */
|
||||
async listShares() {
|
||||
const shares = await this.paginateAll("/shares", {});
|
||||
return shares.map((s: any) => ({
|
||||
shareId: s.id,
|
||||
key: s.key,
|
||||
pageId: s.pageId,
|
||||
pageTitle: s.page?.title,
|
||||
publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null,
|
||||
searchIndexing: s.searchIndexing,
|
||||
createdAt: s.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Remove the public share of a page. */
|
||||
async unsharePage(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const shares = await this.listShares();
|
||||
const share = shares.find((s: any) => s.pageId === pageId);
|
||||
if (!share) {
|
||||
throw new Error(`Page ${pageId} is not shared.`);
|
||||
}
|
||||
await this.client.post("/shares/delete", { shareId: share.shareId });
|
||||
return { success: true, removedShareId: share.shareId, pageId };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Export a page to a single self-contained Docmost-flavoured markdown file:
|
||||
* meta block + body (with inline comment anchors + diagrams) + comment
|
||||
* threads. Lossless round-trip target; see importPageMarkdown for the inverse.
|
||||
*/
|
||||
async exportPageMarkdown(pageId: string): Promise<string> {
|
||||
await this.ensureAuthenticated();
|
||||
const page = await this.getPageRaw(pageId);
|
||||
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
|
||||
let comments: any[] = [];
|
||||
try {
|
||||
// Lossless export: include RESOLVED threads so the export -> import
|
||||
// round-trip preserves every comment. This is exactly why the active-only
|
||||
// filter is an opt-in (default false) on listComments.
|
||||
comments = (await this.listComments(pageId, true)).items;
|
||||
} catch (e) {
|
||||
// A comments fetch failure must not lose the body; export with [] and let
|
||||
// the caller see the (empty) comments block. Log under DEBUG only.
|
||||
if (process.env.DEBUG) console.error("export: listComments failed", e);
|
||||
}
|
||||
const meta = {
|
||||
version: 1,
|
||||
pageId: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
};
|
||||
return serializeDocmostMarkdown(meta, body, comments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a self-contained Docmost markdown file back into a page. Parses out
|
||||
* the meta + comments metadata blocks, converts the body to ProseMirror
|
||||
* (restoring comment marks + diagrams from their inline HTML), and replaces
|
||||
* the page content. Comment THREAD records are NOT written to the server in
|
||||
* this version — they are preserved in the file and the inline marks are
|
||||
* re-applied so the highlights survive; managing comment records stays with
|
||||
* the comment tools/UI.
|
||||
*/
|
||||
async importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any> {
|
||||
await this.ensureAuthenticated();
|
||||
const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown);
|
||||
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
|
||||
const doc = await markdownToProseMirrorCanonical(body);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await replacePageContent(
|
||||
pageUuid,
|
||||
doc,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
);
|
||||
// Collect distinct comment ids that actually became comment marks in the doc.
|
||||
const collectCommentIds = (node: any, acc: Set<string>): Set<string> => {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const mk of node.marks) {
|
||||
if (mk && mk.type === "comment" && mk.attrs?.commentId) {
|
||||
acc.add(mk.attrs.commentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) collectCommentIds(child, acc);
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
// Count reflects the comment marks present in the written document, so an id
|
||||
// that only appears as inert text (e.g. inside a fenced code block) is not
|
||||
// counted because it never becomes a comment mark.
|
||||
const anchoredIds = collectCommentIds(doc, new Set<string>());
|
||||
const result: any = {
|
||||
success: true,
|
||||
pageId,
|
||||
anchoredCommentCount: anchoredIds.size,
|
||||
commentsInFile: Array.isArray(comments) ? comments.length : 0,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
// Warn (non-fatal) if the file was exported from a DIFFERENT page.
|
||||
if (meta?.pageId && meta.pageId !== pageId) {
|
||||
result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`;
|
||||
}
|
||||
// Non-fatal footnote diagnostics (#166), analyzed on the BODY (the part after
|
||||
// the docmost:meta / docmost:comments blocks) — so a `[^x]`-like token inside
|
||||
// those JSON blocks never produces a false warning, while real markers in the
|
||||
// body do. `body` comes from parseDocmostMarkdown(fullMarkdown) above.
|
||||
Object.assign(result, footnoteWarningsField(body));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a page (change its title only) without touching or resending its
|
||||
* content. The slug is derived from the page record, not the body, so it is
|
||||
* left intact too.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy the WHOLE content of one page onto another, entirely server-side: the
|
||||
* source's ProseMirror document is read and written verbatim onto the target
|
||||
* via the live collab path, so the document never passes through the model.
|
||||
*
|
||||
* Only the target's BODY is replaced — its title and slug live on the page
|
||||
* record (not in the content), so they are untouched. The source page is not
|
||||
* modified at all.
|
||||
*/
|
||||
async copyPageContent(sourcePageId: string, targetPageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// A self-copy would be a no-op overwrite; reject it explicitly so a caller
|
||||
// mistake surfaces as a clear error rather than a silent round-trip.
|
||||
if (sourcePageId === targetPageId) {
|
||||
throw new Error(
|
||||
"copyPageContent: sourcePageId and targetPageId are the same page (no-op copy)",
|
||||
);
|
||||
}
|
||||
|
||||
const source = await this.getPageRaw(sourcePageId);
|
||||
const content = source?.content;
|
||||
if (
|
||||
!content ||
|
||||
typeof content !== "object" ||
|
||||
content.type !== "doc" ||
|
||||
!Array.isArray(content.content)
|
||||
) {
|
||||
throw new Error(
|
||||
`copyPageContent: source page ${sourcePageId} has no usable ProseMirror content to copy`,
|
||||
);
|
||||
}
|
||||
|
||||
// Defense-in-depth: run the same URL-scheme sanitizer the JSON write path
|
||||
// uses, so copying never lands a javascript:/data: href/src on the target
|
||||
// (parity with updatePageJson; harmless for already-stored source content).
|
||||
this.validateDocUrls(content);
|
||||
|
||||
// Defense-in-depth (#228): this is a FULL-document write, so canonicalize
|
||||
// footnotes before copying — a no-op on already-canonical source content, but
|
||||
// it guarantees a copy can never propagate a non-canonical footnote topology
|
||||
// to the target (parity with the other full-doc write paths).
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content));
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||
const targetUuid = await this.resolvePageId(targetPageId);
|
||||
const mutation = await this.replacePage(
|
||||
targetUuid,
|
||||
canonical,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourcePageId,
|
||||
targetPageId,
|
||||
copiedNodes: canonical.content.length,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surgical text edits: find/replace inside text nodes of the live
|
||||
* document. Preserves all block ids, marks, callouts and tables.
|
||||
*/
|
||||
|
||||
// --- Page history / diff / transform ---
|
||||
|
||||
/**
|
||||
* List the saved versions (history snapshots) of a page, newest first.
|
||||
* Docmost auto-snapshots on every save. Returns one cursor-paginated page of
|
||||
* results: `{ items, nextCursor }`. The history record's id field is `id`.
|
||||
*/
|
||||
async listPageHistory(pageId: string, cursor?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const payload: Record<string, any> = { pageId };
|
||||
if (cursor) payload.cursor = cursor;
|
||||
const response = await this.client.post("/pages/history", payload);
|
||||
const data = response.data?.data ?? response.data;
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
nextCursor: data?.meta?.nextCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single page-history version including its lossless ProseMirror
|
||||
* `content`. The version also carries pageId/title/createdAt.
|
||||
*/
|
||||
async getPageHistory(historyId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const response = await this.client.post("/pages/history/info", {
|
||||
historyId,
|
||||
});
|
||||
return response.data?.data ?? response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Restore" a version: Docmost has NO restore endpoint, so we take the
|
||||
* version's `content` and write it as the page's current content via the live
|
||||
* collab path (which itself creates a new history snapshot). Returns the
|
||||
* affected pageId and the source historyId.
|
||||
*/
|
||||
async restorePageVersion(historyId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const version = await this.getPageHistory(historyId);
|
||||
if (
|
||||
!version ||
|
||||
!version.pageId ||
|
||||
!version.content ||
|
||||
typeof version.content !== "object"
|
||||
) {
|
||||
throw new Error(
|
||||
`restorePageVersion: history ${historyId} has no usable content`,
|
||||
);
|
||||
}
|
||||
// Defense-in-depth: sanitize URLs in the restored content (parity with the
|
||||
// JSON write path) before writing it back.
|
||||
this.validateDocUrls(version.content);
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// version.pageId is the page entity id (already a UUID); resolvePageId
|
||||
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
|
||||
const pageUuid = await this.resolvePageId(version.pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
() => version.content,
|
||||
);
|
||||
return {
|
||||
pageId: version.pageId,
|
||||
restoredFrom: historyId,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two versions of a page and return a Docmost-equivalent change set.
|
||||
* `from`/`to` each resolve to a ProseMirror doc:
|
||||
* - null / undefined / "current" -> the page's CURRENT content;
|
||||
* - any other string -> that historyId's content.
|
||||
* Returns the diff plus the resolved version metadata for each side.
|
||||
*/
|
||||
async diffPageVersions(pageId: string, from?: string, to?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
const isCurrent = (v?: string) => v == null || v === "" || v === "current";
|
||||
|
||||
const resolveSide = async (
|
||||
v?: string,
|
||||
): Promise<{ doc: any; meta: any }> => {
|
||||
if (isCurrent(v)) {
|
||||
const raw = await this.getPageRaw(pageId);
|
||||
return {
|
||||
doc: raw.content || { type: "doc", content: [] },
|
||||
meta: {
|
||||
kind: "current",
|
||||
pageId,
|
||||
title: raw.title,
|
||||
updatedAt: raw.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
const version = await this.getPageHistory(v as string);
|
||||
return {
|
||||
doc: version.content || { type: "doc", content: [] },
|
||||
meta: {
|
||||
kind: "history",
|
||||
historyId: version.id,
|
||||
pageId: version.pageId,
|
||||
title: version.title,
|
||||
createdAt: version.createdAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const fromSide = await resolveSide(from);
|
||||
const toSide = await resolveSide(to);
|
||||
const diff = diffDocs(fromSide.doc, toSide.doc);
|
||||
return { from: fromSide.meta, to: toSide.meta, diff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a page by running an arbitrary user-supplied JS transform against the
|
||||
* live document, with a diff preview + page-history safety net.
|
||||
*
|
||||
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
|
||||
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
|
||||
* a 5s timeout, and NO access to require/process/fs/network. It must return a
|
||||
* `{ type: "doc" }` node, which is validated structurally before any write.
|
||||
*
|
||||
* `ctx` exposes:
|
||||
* - comments: the page's comments (fetched before the live read);
|
||||
* - log: an array the transform can push diagnostics to (via console.log);
|
||||
* - consume(id): mark a comment id as consumed (for deleteComments);
|
||||
* - helpers: the transforms.ts primitives + commentsToFootnotes.
|
||||
*
|
||||
* Footnote convention used by the helpers: footnote markers are plain "[N]"
|
||||
* text in the body, and the notes are an orderedList under a heading whose
|
||||
* text is "Примечания переводчика".
|
||||
*
|
||||
* dryRun (default true): read the page's current content, run the transform,
|
||||
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
|
||||
* Otherwise the transform runs atomically inside mutatePageContent, optionally
|
||||
* deletes consumed comments, and returns the new historyId + diff + log.
|
||||
*/
|
||||
}
|
||||
return PagesMixin;
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import {
|
||||
filterWorkspace,
|
||||
filterSpace,
|
||||
filterPage,
|
||||
filterComment,
|
||||
filterSearchResult,
|
||||
} from "../lib/filters.js";
|
||||
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
||||
import {
|
||||
collectInternalFileNodes,
|
||||
normalizeFileUrl,
|
||||
resolveInternalFilePath,
|
||||
} from "../lib/internal-file-urls.js";
|
||||
import { buildPageTree } from "../lib/tree.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import {
|
||||
importMarkdownFragment,
|
||||
canBeDocChild,
|
||||
findUnrepresentableTableAttrs,
|
||||
} from "../lib/markdown-fragment.js";
|
||||
import { searchInDoc, SearchOptions } from "../lib/page-search.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
|
||||
// Public method surface of ReadMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements IReadMixin` fails to compile on drift.
|
||||
export interface IReadMixin {
|
||||
getWorkspace(): any;
|
||||
getSpaces(): any;
|
||||
listPages(spaceId?: string, limit?: number, tree?: boolean): any;
|
||||
getTree(spaceId: string, rootPageId?: string, maxDepth?: number): any;
|
||||
getPageContext(pageId: string): any;
|
||||
listSidebarPages(spaceId: string, pageId?: string): any;
|
||||
getPage(pageId: string): any;
|
||||
getPageJson(pageId: string): any;
|
||||
getOutline(pageId: string): any;
|
||||
getNode(pageId: string, nodeId: string, format?: "markdown" | "json"): any;
|
||||
searchInPage(pageId: string, query: string, opts?: SearchOptions): any;
|
||||
getTable(pageId: string, tableRef: string): any;
|
||||
search(query: string, spaceId?: string, limit?: number, opts?: { parentPageId?: string; titleOnly?: boolean }): any;
|
||||
}
|
||||
|
||||
export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IReadMixin> & TBase {
|
||||
abstract class ReadMixin extends Base implements IReadMixin {
|
||||
async getWorkspace() {
|
||||
await this.ensureAuthenticated();
|
||||
const response = await this.client.post("/workspace/info", {});
|
||||
return {
|
||||
data: filterWorkspace(response.data?.data ?? response.data),
|
||||
success: response.data.success,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async getSpaces() {
|
||||
const spaces = await this.paginateAll("/spaces", {});
|
||||
return spaces.map((space) => filterSpace(space));
|
||||
}
|
||||
|
||||
/**
|
||||
* List pages in one of two modes.
|
||||
*
|
||||
* Default (`tree` false): most recent pages by updatedAt (descending),
|
||||
* bounded. Fetching the whole space can exceed MCP response/time limits on
|
||||
* large instances, so a single bounded page of results is returned (default
|
||||
* 50, max 100) via the `/pages/recent` feed.
|
||||
*
|
||||
* Tree (`tree` true): DEPRECATED — prefer `getTree`, which shares this exact
|
||||
* code path (a single `/pages/tree` request via `enumerateSpacePages` +
|
||||
* `buildPageTree`) but returns the compact `{pageId, title, children?,
|
||||
* hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is
|
||||
* kept for backward compatibility; it REQUIRES `spaceId` (a page tree is
|
||||
* scoped to one space) and IGNORES `limit` — the whole hierarchy is returned.
|
||||
* It fetches the tree via `enumerateSpacePages`, which on the fork server
|
||||
* resolves to a single `/pages/tree` request returning the whole
|
||||
* permission-filtered flat page set (soft-deleted pages excluded
|
||||
* server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for
|
||||
* stock upstream servers that lack `/pages/tree`.
|
||||
*/
|
||||
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
if (tree) {
|
||||
if (!spaceId) {
|
||||
throw new Error(
|
||||
"listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.",
|
||||
);
|
||||
}
|
||||
// #486: propagate `truncated` (same pattern as check_new_comments). The old
|
||||
// code dropped it, so a caller handed an INCOMPLETE tree (the stdio-fallback
|
||||
// BFS hit its node cap) had no way to know pages were missing. Return the
|
||||
// tree alongside the flag; the primary /pages/tree path is uncapped so this
|
||||
// is false there.
|
||||
const { pages, truncated } = await this.enumerateSpacePages(spaceId);
|
||||
return { tree: buildPageTree(pages), truncated };
|
||||
}
|
||||
|
||||
const clampedLimit = Math.max(1, Math.min(100, limit));
|
||||
const payload: Record<string, any> = { limit: clampedLimit, page: 1 };
|
||||
if (spaceId) payload.spaceId = spaceId;
|
||||
const response = await this.client.post("/pages/recent", payload);
|
||||
const data = response.data;
|
||||
const items = data.data?.items || data.items || [];
|
||||
return items.map((page: any) => filterPage(page));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE
|
||||
* request — the #443 `getTree` tool. Shares its whole code path with
|
||||
* `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree`
|
||||
* (with the cursor-BFS only as a fallback for stock upstream servers that lack
|
||||
* the endpoint), then `buildPageTree` nests the flat, permission-filtered,
|
||||
* position-ordered list. No second tree fetch, no per-node BFS.
|
||||
*
|
||||
* - `rootPageId` — restrict to that page's subtree; the server seeds the CTE
|
||||
* with the page itself, so the result is exactly ONE root (the page and its
|
||||
* descendants). Omit it for the whole space.
|
||||
* - `maxDepth` — trim the response to that many levels (roots = depth 1) to
|
||||
* save tokens; the server still returns everything in one request, the cut
|
||||
* is applied in `buildPageTree` AFTER the full tree is built. A node whose
|
||||
* children were cut carries `hasChildren: true` (source of truth = the flat
|
||||
* item's server `hasChildren`) so the caller can descend with a follow-up
|
||||
* `getTree(spaceId, rootPageId=that node)` call.
|
||||
*
|
||||
* Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID
|
||||
* `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId`
|
||||
* (a page tree is scoped to one space).
|
||||
*/
|
||||
async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) {
|
||||
await this.ensureAuthenticated();
|
||||
if (!spaceId) {
|
||||
throw new Error(
|
||||
"getTree: spaceId is required (a page tree is scoped to one space).",
|
||||
);
|
||||
}
|
||||
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
|
||||
return buildPageTree(pages, { shape: "getTree", maxDepth });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Where am I / what's around" for a single page — the #443 `getPageContext`
|
||||
* tool. Metadata only (no page content), using exactly TWO server requests:
|
||||
*
|
||||
* 1. `POST /pages/breadcrumbs` — a recursive CTE that walks UP from the page.
|
||||
* The server returns the chain root->page order (it `.reverse()`s the
|
||||
* child-first walk before responding), INCLUDING the page itself as the
|
||||
* LAST element. So the last element is the page and everything before it
|
||||
* is the ancestor chain root->parent. This carries the page's own title
|
||||
* and spaceId, so no extra page-info fetch is needed for a UUID input.
|
||||
* 2. `listSidebarPages(spaceId, pageId)` — the page's DIRECT children,
|
||||
* cursor-paginated (a page with >20 children returns ALL of them, no
|
||||
* dupes) and in sidebar `position` order, each carrying `hasChildren`.
|
||||
*
|
||||
* The input may be a slugId (agents copy them from URLs); it is run through
|
||||
* `resolvePageId` first, exactly like the other page tools. A UUID input adds
|
||||
* no request there (short-circuit), keeping the total at two; a slugId input
|
||||
* adds one unavoidable resolve round-trip.
|
||||
*
|
||||
* INVARIANT: only the UUID `pageId` is exposed anywhere — server `id` is
|
||||
* mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible
|
||||
* pageId makes the server 404/403, which propagates as a clear tool error
|
||||
* (never a hollow empty object).
|
||||
*/
|
||||
async getPageContext(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Resolve a possibly-slugId input to the canonical UUID (no round-trip for a
|
||||
// UUID). Errors here (bad/inaccessible id) propagate as a clear tool error.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Request 1: the ancestor chain, root->page, page included as the LAST item.
|
||||
const response = await this.client.post("/pages/breadcrumbs", {
|
||||
pageId: pageUuid,
|
||||
});
|
||||
const chain: any[] = (response.data?.data ?? response.data) ?? [];
|
||||
if (!Array.isArray(chain) || chain.length === 0) {
|
||||
// The endpoint always includes the page itself, so an empty chain means
|
||||
// the page is gone/inaccessible — surface a clear error, not {}.
|
||||
throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`);
|
||||
}
|
||||
|
||||
// Split: the last element is the page, the rest (root->parent) are the
|
||||
// breadcrumbs. A root page has no ancestors -> breadcrumbs is [].
|
||||
const self = chain[chain.length - 1];
|
||||
const ancestors = chain.slice(0, -1);
|
||||
|
||||
const page = {
|
||||
pageId: self.id,
|
||||
title: self.title,
|
||||
spaceId: self.spaceId,
|
||||
};
|
||||
const breadcrumbs = ancestors.map((n: any) => ({
|
||||
pageId: n.id,
|
||||
title: n.title,
|
||||
}));
|
||||
|
||||
// Request 2: direct children in sidebar order, each with hasChildren.
|
||||
const childItems = await this.listSidebarPages(self.spaceId, pageUuid);
|
||||
const children = childItems.map((c: any) => ({
|
||||
pageId: c.id,
|
||||
title: c.title,
|
||||
hasChildren: Boolean(c.hasChildren),
|
||||
}));
|
||||
|
||||
return { page, breadcrumbs, children };
|
||||
}
|
||||
|
||||
/**
|
||||
* List sidebar pages for a space. With no pageId the request returns the
|
||||
* space ROOT pages; with a pageId it returns the direct CHILDREN of that
|
||||
* page. pageId is therefore optional and is only included in the POST body
|
||||
* when provided (an empty/undefined pageId would otherwise change the
|
||||
* semantics on the server).
|
||||
*/
|
||||
async listSidebarPages(spaceId: string, pageId?: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Paginate via the server-issued cursor. The server switched from OFFSET
|
||||
// (`page`) to CURSOR (`cursor`/`nextCursor`) pagination, and the global
|
||||
// ValidationPipe(whitelist:true) SILENTLY STRIPS the obsolete `page` field
|
||||
// — so the old offset loop got the SAME first page every time (with
|
||||
// hasNextPage stuck true) and dropped every child beyond the first page.
|
||||
const MAX_PAGES = 50;
|
||||
let cursor: string | undefined;
|
||||
let allItems: any[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (let i = 0; i < MAX_PAGES; i++) {
|
||||
// limit: 100 is the server-side Max; cuts request count 5x vs the default 20.
|
||||
const payload: Record<string, any> = { spaceId, limit: 100 };
|
||||
// Only send pageId when scoping to a page's children; omit it for roots.
|
||||
if (pageId) payload.pageId = pageId;
|
||||
if (cursor) payload.cursor = cursor;
|
||||
|
||||
const data = (await this.client.post("/pages/sidebar-pages", payload)).data
|
||||
?.data;
|
||||
allItems = allItems.concat(data?.items ?? []);
|
||||
|
||||
// Advance strictly via the server-issued cursor; a missing/repeated cursor
|
||||
// means the protocol drifted again — stop instead of looping on page one.
|
||||
const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null;
|
||||
if (!next || next === cursor) break;
|
||||
cursor = next;
|
||||
|
||||
// Reaching the ceiling with more pages still available means the child
|
||||
// list is truncated (mirrors paginateAll).
|
||||
if (i === MAX_PAGES - 1) truncated = true;
|
||||
}
|
||||
|
||||
// Warn on real truncation (ceiling hit while the server still had pages) so
|
||||
// the caller is not silently handed an incomplete child list.
|
||||
if (truncated) {
|
||||
console.warn(
|
||||
`listSidebarPages: children of "${pageId ?? spaceId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||
);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate EVERY page in a space (or in a subtree, when rootPageId is given).
|
||||
*
|
||||
* Primary path (fork server): a SINGLE `POST /pages/tree` returns the whole
|
||||
* space (or a subtree) as a flat, permission-filtered list in one request, in
|
||||
* the exact node shape buildPageTree consumes. This replaces the old
|
||||
* per-node BFS, which issued N sidebar requests and — after the server moved
|
||||
* to cursor pagination — silently lost every child past the first sidebar
|
||||
* page (the obsolete `page` param was stripped by ValidationPipe).
|
||||
*
|
||||
* The subtree variant (rootPageId given) INCLUDES the root node itself
|
||||
* (getPageAndDescendants seeds with id = rootPageId), unlike the old BFS
|
||||
* which started from the root's children.
|
||||
*
|
||||
* Fallback path (stdio mode may target STOCK upstream Docmost, which lacks
|
||||
* `/pages/tree`): on a 404/405 it falls back to the cursor-based BFS below,
|
||||
* walking direct children via the fixed cursor listSidebarPages. Safeguards:
|
||||
* a `visited` Set of page ids prevents re-processing a node (cycles /
|
||||
* duplicate references), and a hard node cap bounds pathological trees so the
|
||||
* walk always terminates.
|
||||
*
|
||||
* Returns `{ pages, truncated }`. `truncated` is true ONLY when the fallback
|
||||
* BFS stopped at its MAX_NODES cap — the primary /pages/tree path is uncapped
|
||||
* and always returns the complete set, so it never reports truncation.
|
||||
*/
|
||||
protected async enumerateSpacePages(
|
||||
spaceId: string,
|
||||
rootPageId?: string,
|
||||
): Promise<{ pages: any[]; truncated: boolean }> {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Single request replaces the whole BFS: /pages/tree returns the full
|
||||
// permission-filtered flat page set of a space (or a subtree) at once. This
|
||||
// path is uncapped, so it is never truncated.
|
||||
const payload = rootPageId ? { pageId: rootPageId } : { spaceId };
|
||||
try {
|
||||
const response = await this.client.post("/pages/tree", payload);
|
||||
const pages = (response.data?.data ?? response.data)?.items ?? [];
|
||||
return { pages, truncated: false };
|
||||
} catch (e: any) {
|
||||
// Only fall back when the endpoint is absent (stock upstream Docmost);
|
||||
// any other error is a genuine failure and must propagate.
|
||||
if (
|
||||
!axios.isAxiosError(e) ||
|
||||
(e.response?.status !== 404 && e.response?.status !== 405)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: cursor-based breadth-first walk via listSidebarPages.
|
||||
const MAX_NODES = 10000;
|
||||
const result: any[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Seed with the root node itself when scoping to a subtree, so its own
|
||||
// comments aren't dropped: the primary /pages/tree seeds
|
||||
// getPageAndDescendants with id = rootPageId (root included), but
|
||||
// listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN.
|
||||
// The `visited` set below prevents a double-add if the root also appears
|
||||
// among the children. getPageRaw returns a page whose id/title/spaceId are
|
||||
// exactly what buildPageTree and checkNewComments consume.
|
||||
if (rootPageId) {
|
||||
try {
|
||||
const root = await this.getPageRaw(rootPageId);
|
||||
if (root?.id) {
|
||||
result.push(root);
|
||||
visited.add(root.id);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: if the root can't be read, fall through to children-only.
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the queue with the starting level (subtree children or roots).
|
||||
const queue: any[] = await this.listSidebarPages(spaceId, rootPageId);
|
||||
|
||||
while (queue.length > 0 && result.length < MAX_NODES) {
|
||||
const node = queue.shift();
|
||||
if (!node || typeof node !== "object" || !node.id) continue;
|
||||
|
||||
// Skip already-seen ids to guard against cycles / duplicate references.
|
||||
if (visited.has(node.id)) continue;
|
||||
visited.add(node.id);
|
||||
|
||||
result.push(node);
|
||||
|
||||
if (node.hasChildren) {
|
||||
try {
|
||||
const children = await this.listSidebarPages(spaceId, node.id);
|
||||
for (const child of children) queue.push(child);
|
||||
} catch (e: any) {
|
||||
// A failure fetching one node's children must not abort the whole
|
||||
// walk: skip this branch and keep enumerating the rest.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncated only when the cap was hit with the queue still non-empty (real
|
||||
// truncation, not a natural end at exactly MAX_NODES).
|
||||
return {
|
||||
pages: result,
|
||||
truncated: result.length >= MAX_NODES && queue.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Raw page info including the ProseMirror JSON content and slugId. */
|
||||
|
||||
async getPage(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
|
||||
// Agent read: hide resolved-comment anchors so the agent sees only active
|
||||
// discussions. Active anchors are kept. (The lossless exportPageMarkdown
|
||||
// round-trip deliberately does NOT pass this flag — resolved anchors there
|
||||
// must be preserved.)
|
||||
let content = resultData.content
|
||||
? convertProseMirrorToMarkdown(resultData.content, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
// Always fetch subpages to provide context to the agent
|
||||
let subpages: any[] = [];
|
||||
try {
|
||||
// `pageId` may be a slugId, but the sidebar-pages endpoint requires the
|
||||
// UUID; `resultData.id` holds the resolved UUID returned by getPageRaw.
|
||||
subpages = await this.listSidebarPages(resultData.spaceId, resultData.id);
|
||||
} catch (e: any) {
|
||||
console.warn("Failed to fetch subpages:", e);
|
||||
}
|
||||
|
||||
// Resolve subpages if the placeholder exists
|
||||
if (content && content.includes("{{SUBPAGES}}")) {
|
||||
if (subpages && subpages.length > 0) {
|
||||
const list = subpages
|
||||
.map((p: any) => `- [${p.title}](page:${p.id})`)
|
||||
.join("\n");
|
||||
content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
|
||||
} else {
|
||||
content = content.replace("{{SUBPAGES}}", "");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: filterPage(resultData, content, subpages),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Page info + raw ProseMirror JSON content (lossless representation). */
|
||||
async getPageJson(pageId: string) {
|
||||
const data = await this.getPageRaw(pageId);
|
||||
return {
|
||||
id: data.id,
|
||||
slugId: data.slugId,
|
||||
title: data.title,
|
||||
parentPageId: data.parentPageId,
|
||||
spaceId: data.spaceId,
|
||||
updatedAt: data.updatedAt,
|
||||
content: data.content || { type: "doc", content: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
|
||||
* `src` is normalized to `/api/files/<id>/<file>`; `this.client.baseURL`
|
||||
* already ends in `/api`, so we strip the leading `/api` and request the
|
||||
* relative path with the client's Authorization header. Returns the raw bytes
|
||||
* and the response Content-Type (mime), defaulting to octet-stream.
|
||||
*
|
||||
* The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory;
|
||||
* the authoritative per-blob cap is enforced by the sandbox `put`. The path is
|
||||
* resolved via resolveInternalFilePath, which REJECTS (throws) any traversal
|
||||
* or percent-encoded src that would let an attacker-controlled `attrs.src`
|
||||
* escape `/api/files/` and reach another internal endpoint (SSRF). That throw
|
||||
* happens before this.client.get, so a malicious src is counted as a failed
|
||||
* mirror — it never reaches the network.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compact outline of a page's top-level blocks (no full document body).
|
||||
* Cheap way to locate sections/tables and grab block ids before drilling in
|
||||
* with getNode / patchNode / insertNode.
|
||||
*/
|
||||
async getOutline(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
return {
|
||||
pageId,
|
||||
slugId: data.slugId,
|
||||
title: data.title,
|
||||
outline: buildOutline(data.content ?? { type: "doc", content: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single block for editing by reference: a block id (headings/
|
||||
* paragraphs/callouts/images), or `#<index>` to select a top-level block by its
|
||||
* outline index (the only way to reach tables/rows/cells, which carry no id).
|
||||
*
|
||||
* `format` (#413):
|
||||
* - `"markdown"` (DEFAULT): serialize the block via the canonical converter
|
||||
* (`{type:"doc",content:[node]}` -> `convertProseMirrorToMarkdown`) — a read
|
||||
* "for editing": pair it with `patchNode({markdown})` to rewrite the block.
|
||||
* Comment anchors (`<span data-comment-id>`, INCLUDING resolved ones) are
|
||||
* NOT stripped here (unlike getPage): losing them on write-back would
|
||||
* orphan the thread. Returns `{ ..., format:"markdown", markdown }`.
|
||||
* - `"json"`: return the raw ProseMirror subtree as-is (lossless; the previous
|
||||
* default). Returns `{ ..., format:"json", node }`.
|
||||
*
|
||||
* AUTO fallback: a type that cannot be a document top-level child
|
||||
* (tableRow/tableCell/tableHeader, addressed by `#<index>`) is NOT expressible
|
||||
* as a standalone markdown document, so a `"markdown"` request for such a node
|
||||
* transparently falls back to JSON with an explicit `format:"json"` field. The
|
||||
* check derives from the schema's `doc` contentMatch, so it tracks the schema.
|
||||
*/
|
||||
async getNode(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
format: "markdown" | "json" = "markdown",
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const hit = getNodeByRef(
|
||||
data.content ?? { type: "doc", content: [] },
|
||||
nodeId,
|
||||
);
|
||||
if (!hit) {
|
||||
throw new Error(
|
||||
`getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#<index>" for a top-level block such as a table)`,
|
||||
);
|
||||
}
|
||||
|
||||
// JSON requested (or a non-top-level type that markdown cannot represent as a
|
||||
// standalone document): return the subtree verbatim.
|
||||
if (format === "json" || !canBeDocChild(hit.type)) {
|
||||
return {
|
||||
pageId,
|
||||
ref: nodeId,
|
||||
path: hit.path,
|
||||
type: hit.type,
|
||||
format: "json" as const,
|
||||
node: hit.node,
|
||||
};
|
||||
}
|
||||
|
||||
// Markdown: wrap the node as a one-block doc and run the canonical converter.
|
||||
// Comment anchors are DELIBERATELY preserved (converter default) so a
|
||||
// getNode(markdown) -> edit -> patchNode(markdown) round trip does not orphan
|
||||
// a comment thread; this differs from getPage, which strips them.
|
||||
const markdown = convertProseMirrorToMarkdown({
|
||||
type: "doc",
|
||||
content: [hit.node],
|
||||
});
|
||||
return {
|
||||
pageId,
|
||||
ref: nodeId,
|
||||
path: hit.path,
|
||||
type: hit.type,
|
||||
format: "markdown" as const,
|
||||
markdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every occurrence of `query` on a page IN MEMORY, over the plain text of
|
||||
* each text container (reusing the same `getPageRaw` fetch as the other read
|
||||
* tools) — no server search endpoint, no whole-document round-trip through the
|
||||
* model. Returns `{ total, truncated, matches }`; each match carries a ref for
|
||||
* getNode/patchNode (the `#<index>` form resolves with getNode but NOT
|
||||
* patchNode — see SearchMatch.nodeId), plus the top-level block index and a
|
||||
* short context window used to build a unique text `selection` for
|
||||
* createComment (createComment has no nodeId param). The pure engine
|
||||
* (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine
|
||||
* and the empty-query / invalid-or-unsupported-regex errors.
|
||||
*/
|
||||
async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const result = searchInDoc(
|
||||
data.content ?? { type: "doc", content: [] },
|
||||
query,
|
||||
opts,
|
||||
);
|
||||
return { pageId, query, ...result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a table as a matrix. `tableRef` is `#<index>` (from getOutline) or a
|
||||
* block id of any node inside the table. Returns the cell texts plus a
|
||||
* parallel cellIds matrix (each cell's first paragraph id, or null) so a
|
||||
* caller can patchNode a cell for rich-formatted edits. Throws when no table
|
||||
* resolves for the reference.
|
||||
*/
|
||||
async getTable(pageId: string, tableRef: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef);
|
||||
if (!t) {
|
||||
throw new Error(
|
||||
`tableGet: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
pageId,
|
||||
table: tableRef,
|
||||
rows: t.rows,
|
||||
cols: t.cols,
|
||||
path: t.path,
|
||||
cells: t.cells,
|
||||
cellIds: t.cellIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row of plain-text cells into a table on the LIVE collab document.
|
||||
* `tableRef` is `#<index>` or a block id inside the target table. `cells` is
|
||||
* padded to the table's column count (more cells than columns throws); `index`
|
||||
* is a 0-based insert position (omit/out-of-range to append). Throws when no
|
||||
* table resolves for the reference.
|
||||
*/
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
spaceId?: string,
|
||||
limit?: number,
|
||||
opts: { parentPageId?: string; titleOnly?: boolean } = {},
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
// Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid
|
||||
// substring + FTS branch that returns path + snippet + score. A stock
|
||||
// upstream server strips these unknown DTO fields (whitelist:true) and
|
||||
// silently degrades to plain FTS — see the tool-registration comment.
|
||||
const payload: Record<string, any> = {
|
||||
query,
|
||||
spaceId,
|
||||
substring: true,
|
||||
};
|
||||
if (opts.parentPageId) payload.parentPageId = opts.parentPageId;
|
||||
if (opts.titleOnly) payload.titleOnly = true;
|
||||
// Clamp an optional caller-supplied limit into the lookup range (1..50)
|
||||
// before forwarding; omit it when not provided so the server default applies.
|
||||
if (limit !== undefined) {
|
||||
payload.limit = Math.max(1, Math.min(50, limit));
|
||||
}
|
||||
const response = await this.client.post("/search", payload);
|
||||
|
||||
// Normalize both response shapes: bare array and paginated { items: [...] }
|
||||
const data = response.data?.data;
|
||||
const items = Array.isArray(data) ? data : data?.items || [];
|
||||
const filteredItems = items.map((item: any) => filterSearchResult(item));
|
||||
|
||||
return {
|
||||
items: filteredItems,
|
||||
success: response.data?.success || false,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
return ReadMixin;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import {
|
||||
collectInternalFileNodes,
|
||||
normalizeFileUrl,
|
||||
resolveInternalFilePath,
|
||||
} from "../lib/internal-file-urls.js";
|
||||
|
||||
// Public method surface of StashMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements IStashMixin` fails to compile on drift.
|
||||
export interface IStashMixin {
|
||||
stashPage(pageId: string): Promise<{ uri: string; sha256: string; size: number; images: { mirrored: number; failed: number }; }>;
|
||||
}
|
||||
|
||||
export function StashMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IStashMixin> & TBase {
|
||||
abstract class StashMixin extends Base implements IStashMixin {
|
||||
/**
|
||||
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
|
||||
* `src` is normalized to `/api/files/<id>/<file>`; `this.client.baseURL`
|
||||
* already ends in `/api`, so we strip the leading `/api` and request the
|
||||
* relative path with the client's Authorization header. Returns the raw bytes
|
||||
* and the response Content-Type (mime), defaulting to octet-stream.
|
||||
*
|
||||
* The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory;
|
||||
* the authoritative per-blob cap is enforced by the sandbox `put`. The path is
|
||||
* resolved via resolveInternalFilePath, which REJECTS (throws) any traversal
|
||||
* or percent-encoded src that would let an attacker-controlled `attrs.src`
|
||||
* escape `/api/files/` and reach another internal endpoint (SSRF). That throw
|
||||
* happens before this.client.get, so a malicious src is counted as a failed
|
||||
* mirror — it never reaches the network.
|
||||
*/
|
||||
protected async fetchInternalFile(
|
||||
src: string,
|
||||
): Promise<{ buffer: Buffer; mime: string }> {
|
||||
const HARD_CEILING = 64 * 1024 * 1024; // 64 MiB memory guard
|
||||
const relPath = resolveInternalFilePath(src);
|
||||
const response = await this.client.get(relPath, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 30000,
|
||||
maxContentLength: HARD_CEILING,
|
||||
maxBodyLength: HARD_CEILING,
|
||||
});
|
||||
const buffer = Buffer.from(response.data);
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`Empty file response from "${src}"`);
|
||||
}
|
||||
const rawCt = response.headers?.["content-type"];
|
||||
const mime =
|
||||
typeof rawCt === "string" && rawCt.length > 0
|
||||
? rawCt.split(";")[0].trim().toLowerCase()
|
||||
: "application/octet-stream";
|
||||
return { buffer, mime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash a page's full content into the in-RAM blob sandbox and return ONLY a
|
||||
* short anonymous URL — the body never enters the model context (this is the
|
||||
* whole point: ~30KB+ ProseMirror docs blow the model context if passed as a
|
||||
* tool argument). Every INTERNAL file/image src (the type-agnostic criterion,
|
||||
* so drawio/excalidraw/video/file nodes are covered too) is mirrored into the
|
||||
* sandbox and its `src` rewritten to the sandbox URL, so an external consumer
|
||||
* can fetch the images anonymously. External http(s) srcs are left untouched.
|
||||
*
|
||||
* Blobs live in RAM with a short TTL and are cleared on restart — consume the
|
||||
* URLs within the TTL and one uptime. A failed image fetch never aborts the
|
||||
* doc: the original src is kept and the failure counted.
|
||||
*
|
||||
* Returns { uri, sha256, size, images:{mirrored, failed} }. `uri` and `sha256`
|
||||
* are for the document blob; `sha256` is also the blob's ETag (integrity).
|
||||
*/
|
||||
async stashPage(pageId: string): Promise<{
|
||||
uri: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
images: { mirrored: number; failed: number };
|
||||
}> {
|
||||
if (!this.sandboxPut) {
|
||||
throw new Error(
|
||||
"stashPage is unavailable: the blob sandbox is not configured on this server",
|
||||
);
|
||||
}
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Stash the SAME shape getPageJson returns (id/title/.../content), with a
|
||||
// deep clone so the rewrite never mutates anything shared.
|
||||
const pageJson = await this.getPageJson(pageId);
|
||||
const cloned: any = structuredClone(pageJson);
|
||||
|
||||
// Group internal-file nodes by normalized src so each unique resource is
|
||||
// fetched + stored ONCE (dedup), and every node sharing that src points at
|
||||
// the one sandbox blob. Capture each node's ORIGINAL raw src per-node:
|
||||
// dedup groups nodes whose normalized src is equal even when their raw srcs
|
||||
// differ (e.g. `/api/files/...` vs the bare `/files/...`), so on a revert we
|
||||
// must restore each node's own original value, not the group key.
|
||||
const bySrc = new Map<string, Array<{ node: any; origSrc: string }>>();
|
||||
for (const node of collectInternalFileNodes(cloned.content)) {
|
||||
const origSrc = String(node.attrs.src);
|
||||
const src = normalizeFileUrl(origSrc);
|
||||
const entry = { node, origSrc };
|
||||
const group = bySrc.get(src);
|
||||
if (group) group.push(entry);
|
||||
else bySrc.set(src, [entry]);
|
||||
}
|
||||
|
||||
let mirrored = 0;
|
||||
let failed = 0;
|
||||
// Record every successful mirror so it can be (a) reverted if its blob gets
|
||||
// FIFO-evicted by a LATER put in this same stash, and (b) freed if the final
|
||||
// doc put throws.
|
||||
const mirrors: Array<{
|
||||
uri: string;
|
||||
entries: Array<{ node: any; origSrc: string }>;
|
||||
}> = [];
|
||||
const MAX_CONCURRENCY = 5;
|
||||
const groups = [...bySrc.entries()];
|
||||
for (let i = 0; i < groups.length; i += MAX_CONCURRENCY) {
|
||||
const batch = groups.slice(i, i + MAX_CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async ([src, entries]) => {
|
||||
try {
|
||||
const { buffer, mime } = await this.fetchInternalFile(src);
|
||||
// put may throw if the blob exceeds the per-blob/total caps.
|
||||
const stored = this.sandboxPut!(buffer, mime);
|
||||
for (const entry of entries) entry.node.attrs.src = stored.uri;
|
||||
mirrors.push({ uri: stored.uri, entries });
|
||||
mirrored++;
|
||||
} catch (err) {
|
||||
// One bad/oversized image (or a rejected traversal src) must not
|
||||
// abort the document. Logged unconditionally (never the blob body),
|
||||
// matching the package's ungated console.warn convention.
|
||||
failed++;
|
||||
console.warn(
|
||||
`stashPage: failed to mirror "${src}": ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Revert one mirror's nodes to their original internal srcs and re-count it
|
||||
// as failed (its blob was FIFO-evicted before the doc could reference it
|
||||
// safely).
|
||||
const revertMirror = (mirror: {
|
||||
uri: string;
|
||||
entries: Array<{ node: any; origSrc: string }>;
|
||||
}) => {
|
||||
for (const entry of mirror.entries) entry.node.attrs.src = entry.origSrc;
|
||||
mirrored--;
|
||||
failed++;
|
||||
console.warn(
|
||||
`stashPage: mirrored blob ${mirror.uri} was evicted before the doc ` +
|
||||
`could safely reference it; reverted its src and counted it as failed`,
|
||||
);
|
||||
};
|
||||
|
||||
// Pre-put reconciliation: an image put earlier in THIS stash can FIFO-evict
|
||||
// an even-earlier image of the same stash. Drop those from the live set
|
||||
// first so the first serialized doc is already mostly correct.
|
||||
let liveMirrors = mirrors;
|
||||
if (this.sandboxHas) {
|
||||
liveMirrors = [];
|
||||
for (const mirror of mirrors) {
|
||||
if (this.sandboxHas(mirror.uri)) liveMirrors.push(mirror);
|
||||
else revertMirror(mirror);
|
||||
}
|
||||
}
|
||||
|
||||
// Put the document, then reconcile against eviction caused by the doc put
|
||||
// ITSELF (the doc is newest, FIFO drops oldest = this stash's images). Each
|
||||
// iteration reverts >=1 mirror, so the loop terminates (worst case: all
|
||||
// images reverted and the doc references no sandbox image URLs).
|
||||
let stored: { uri: string; sha256: string; size: number };
|
||||
for (;;) {
|
||||
const docBuf = Buffer.from(JSON.stringify(cloned), "utf8");
|
||||
let docStored: { uri: string; sha256: string; size: number };
|
||||
try {
|
||||
docStored = this.sandboxPut(docBuf, "application/json");
|
||||
} catch (err) {
|
||||
// The doc put failed (e.g. doc exceeds the cap). Free this op's image
|
||||
// blobs instead of leaking them in RAM for the whole TTL, then
|
||||
// re-throw.
|
||||
if (this.sandboxEvict) {
|
||||
for (const mirror of liveMirrors) this.sandboxEvict(mirror.uri);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!this.sandboxHas) {
|
||||
stored = docStored;
|
||||
break;
|
||||
}
|
||||
const evictedNow = liveMirrors.filter((m) => !this.sandboxHas!(m.uri));
|
||||
if (evictedNow.length === 0) {
|
||||
stored = docStored;
|
||||
break;
|
||||
}
|
||||
// The doc we just stored references now-dead blobs. Revert those nodes,
|
||||
// drop the stale doc blob, and loop to re-serialize + re-put the
|
||||
// corrected doc.
|
||||
for (const mirror of evictedNow) revertMirror(mirror);
|
||||
liveMirrors = liveMirrors.filter((m) => this.sandboxHas!(m.uri));
|
||||
if (this.sandboxEvict) this.sandboxEvict(docStored.uri);
|
||||
}
|
||||
return {
|
||||
uri: stored.uri,
|
||||
sha256: stored.sha256,
|
||||
size: stored.size,
|
||||
images: { mirrored, failed },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact outline of a page's top-level blocks (no full document body).
|
||||
* Cheap way to locate sections/tables and grab block ids before drilling in
|
||||
* with getNode / patchNode / insertNode.
|
||||
*/
|
||||
}
|
||||
return StashMixin;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
|
||||
// Public method surface of TablesMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements ITablesMixin` fails to compile on drift.
|
||||
export interface ITablesMixin {
|
||||
insertFootnote(pageId: string, anchorText: string, text: string): any;
|
||||
tableInsertRow(pageId: string, tableRef: string, cells: string[], index?: number): any;
|
||||
tableDeleteRow(pageId: string, tableRef: string, index: number): any;
|
||||
tableUpdateCell(pageId: string, tableRef: string, row: number, col: number, text: string): any;
|
||||
}
|
||||
|
||||
export function TablesMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITablesMixin> & TBase {
|
||||
abstract class TablesMixin extends Base implements ITablesMixin {
|
||||
/**
|
||||
* AUTHOR-INLINE footnote insertion. The agent supplies only WHERE
|
||||
* (`anchorText`, a snippet of body text to attach the marker after) and WHAT
|
||||
* (`text`, the footnote content as markdown). Numbering and the bottom
|
||||
* `footnotesList` are derived deterministically server-side
|
||||
* (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees,
|
||||
* assigns, or edits a footnote number or the list, so it CANNOT desync.
|
||||
*
|
||||
* Content DEDUP: when an existing definition has the same content, its id is
|
||||
* reused (one number, one definition, several references). The write is atomic
|
||||
* via `mutatePageContent` (single-writer, page-locked); if the anchor text is
|
||||
* not found the transform aborts with a clear error and no write happens.
|
||||
*/
|
||||
async insertFootnote(pageId: string, anchorText: string, text: string) {
|
||||
await this.ensureAuthenticated();
|
||||
if (!anchorText || !anchorText.trim()) {
|
||||
throw new Error("insertFootnote: anchorText is required");
|
||||
}
|
||||
if (text == null || `${text}`.trim() === "") {
|
||||
throw new Error("insertFootnote: text is required");
|
||||
}
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
let result: { footnoteId: string; reused: boolean } | null = null;
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc: any) => {
|
||||
const r = insertInlineFootnote(liveDoc, { anchorText, text });
|
||||
if (!r.inserted) {
|
||||
// Abort the page-locked write by throwing: mutatePageContent does not
|
||||
// persist when the transform throws, so a missing anchor leaves the
|
||||
// page untouched (no partial write).
|
||||
throw new Error(
|
||||
`insertFootnote: anchor text not found: ${JSON.stringify(
|
||||
anchorText.slice(0, 80),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
result = { footnoteId: r.footnoteId, reused: r.reused };
|
||||
return r.doc;
|
||||
},
|
||||
);
|
||||
// The not-found path throws inside the transform (aborting mutatePage), so by
|
||||
// here `result` is always set.
|
||||
const r = result!;
|
||||
return {
|
||||
success: true,
|
||||
modified: true,
|
||||
pageId,
|
||||
footnoteId: r.footnoteId,
|
||||
reused: r.reused,
|
||||
message: r.reused
|
||||
? "Footnote inserted (reused an existing same-content definition)."
|
||||
: "Footnote inserted.",
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-locked write seam over collaboration.mutatePageContent. Production just
|
||||
* delegates; it exists as an overridable method so the insertFootnote wrapper
|
||||
* (transform abort-on-not-found + response shaping) can be unit-tested without
|
||||
* standing up a live Hocuspocus collab socket.
|
||||
*
|
||||
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||
* lock"): every write must lock and key its CollabSession by the UUID, never a
|
||||
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
|
||||
* already resolved pays no extra round-trip; centralizing it here means a
|
||||
* caller that reaches this seam with a raw slugId still locks correctly instead
|
||||
* of silently splitting the mutex key. withPageLock also asserts the key is a
|
||||
* UUID as a hard backstop.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Insert a row of plain-text cells into a table on the LIVE collab document.
|
||||
* `tableRef` is `#<index>` or a block id inside the target table. `cells` is
|
||||
* padded to the table's column count (more cells than columns throws); `index`
|
||||
* is a 0-based insert position (omit/out-of-range to append). Throws when no
|
||||
* table resolves for the reference.
|
||||
*/
|
||||
async tableInsertRow(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
cells: string[],
|
||||
index?: number,
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||
// recomputes it cleanly (mirrors insertNode's pattern).
|
||||
let inserted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
inserted = false;
|
||||
const { doc: nd, inserted: ins } = insertTableRow(
|
||||
liveDoc,
|
||||
tableRef,
|
||||
cells,
|
||||
index,
|
||||
);
|
||||
inserted = ins;
|
||||
if (!inserted) return null; // table not found -> skip the write entirely
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
if (!inserted) {
|
||||
throw new Error(
|
||||
`tableInsertRow: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
table: tableRef,
|
||||
inserted: true,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the row at 0-based `index` from a table on the LIVE collab document.
|
||||
* `tableRef` is `#<index>` or a block id inside the target table. The helper's
|
||||
* out-of-range and last-row errors propagate; a missing table throws here.
|
||||
*/
|
||||
async tableDeleteRow(pageId: string, tableRef: string, index: number) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let deleted = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
deleted = false;
|
||||
const { doc: nd, deleted: del } = deleteTableRow(
|
||||
liveDoc,
|
||||
tableRef,
|
||||
index,
|
||||
);
|
||||
deleted = del;
|
||||
if (!deleted) return null; // table not found -> skip the write entirely
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(
|
||||
`tableDeleteRow: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
table: tableRef,
|
||||
deleted: true,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the plain-text content of cell `[row, col]` (0-based) in a table on the
|
||||
* LIVE collab document, replacing the cell's content with a single text
|
||||
* paragraph (the cell's first-paragraph id is preserved). `tableRef` is
|
||||
* `#<index>` or a block id inside the target table. The helper's out-of-range
|
||||
* error propagates; a missing table throws here.
|
||||
*/
|
||||
async tableUpdateCell(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
row: number,
|
||||
col: number,
|
||||
text: string,
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let updated = false;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
updated = false;
|
||||
const { doc: nd, updated: upd } = updateTableCell(
|
||||
liveDoc,
|
||||
tableRef,
|
||||
row,
|
||||
col,
|
||||
text,
|
||||
);
|
||||
updated = upd;
|
||||
if (!updated) return null; // table not found -> skip the write entirely
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`tableUpdateCell: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
table: tableRef,
|
||||
row,
|
||||
col,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new page with title and content.
|
||||
* Uses the /pages/import workaround (the only endpoint accepting content),
|
||||
* then moves the page and restores the exact title: the import endpoint
|
||||
* derives the title from the FILENAME and replaces spaces with
|
||||
* underscores, so we explicitly re-set it via /pages/update afterwards.
|
||||
*/
|
||||
}
|
||||
return TablesMixin;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||
import {
|
||||
updatePageContentRealtime,
|
||||
replacePageContent,
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
import { diffDocs, summarizeChange } from "../lib/diff.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "../lib/transforms.js";
|
||||
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Public method surface of TransformsMixin (issue #450) — a NAMED type so the factory
|
||||
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||
// Derived from the class below; `implements ITransformsMixin` fails to compile on drift.
|
||||
export interface ITransformsMixin {
|
||||
transformPage(pageId: string, transformJs: string, opts?: { dryRun?: boolean; deleteComments?: boolean }): any;
|
||||
}
|
||||
|
||||
export function TransformsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITransformsMixin> & TBase {
|
||||
abstract class TransformsMixin extends Base implements ITransformsMixin {
|
||||
/**
|
||||
* Edit a page by running an arbitrary user-supplied JS transform against the
|
||||
* live document, with a diff preview + page-history safety net.
|
||||
*
|
||||
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
|
||||
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
|
||||
* a 5s timeout, and NO access to require/process/fs/network. It must return a
|
||||
* `{ type: "doc" }` node, which is validated structurally before any write.
|
||||
*
|
||||
* `ctx` exposes:
|
||||
* - comments: the page's comments (fetched before the live read);
|
||||
* - log: an array the transform can push diagnostics to (via console.log);
|
||||
* - consume(id): mark a comment id as consumed (for deleteComments);
|
||||
* - helpers: the transforms.ts primitives + commentsToFootnotes.
|
||||
*
|
||||
* Footnote convention used by the helpers: footnote markers are plain "[N]"
|
||||
* text in the body, and the notes are an orderedList under a heading whose
|
||||
* text is "Примечания переводчика".
|
||||
*
|
||||
* dryRun (default true): read the page's current content, run the transform,
|
||||
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
|
||||
* Otherwise the transform runs atomically inside mutatePageContent, optionally
|
||||
* deletes consumed comments, and returns the new historyId + diff + log.
|
||||
*/
|
||||
async transformPage(
|
||||
pageId: string,
|
||||
transformJs: string,
|
||||
opts: { dryRun?: boolean; deleteComments?: boolean } = {},
|
||||
) {
|
||||
const dryRun = opts.dryRun ?? true;
|
||||
const deleteComments = opts.deleteComments ?? false;
|
||||
|
||||
await this.ensureAuthenticated();
|
||||
// Full feed (incl. resolved): a page transform (e.g. comments -> footnotes)
|
||||
// must operate on every comment, so it opts into the unfiltered feed.
|
||||
const comments = (await this.listComments(pageId, true)).items;
|
||||
|
||||
// ctx handed to the sandbox. consume() records ids; helpers are the pure
|
||||
// transform primitives. log is captured from console.log inside the sandbox.
|
||||
const ctx = {
|
||||
comments,
|
||||
log: [] as string[],
|
||||
consumed: new Set<string>(),
|
||||
consume(id: string) {
|
||||
this.consumed.add(id);
|
||||
},
|
||||
helpers: {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
},
|
||||
};
|
||||
|
||||
// Captured oldDoc / newDoc for the diff (set inside runTransform).
|
||||
let oldDoc: any;
|
||||
let newDoc: any;
|
||||
|
||||
// SYNCHRONOUS transform runner — safe to call inside mutatePageContent's
|
||||
// onSynced (no await between the live read and the write).
|
||||
const runTransform = (liveDoc: any): any => {
|
||||
oldDoc = structuredClone(liveDoc);
|
||||
const sandbox: Record<string, any> = {
|
||||
doc: structuredClone(liveDoc),
|
||||
ctx,
|
||||
structuredClone,
|
||||
console: {
|
||||
log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")),
|
||||
},
|
||||
};
|
||||
// Wrap the provided string in parentheses so both an expression-arrow
|
||||
// (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a
|
||||
// fresh context with no require/process/module so the transform cannot
|
||||
// touch fs/network/process. 5s wall-clock timeout.
|
||||
let fn: any;
|
||||
try {
|
||||
fn = vm.runInNewContext("(" + transformJs + ")", sandbox, {
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
throw new Error(`transform did not compile: ${e?.message ?? e}`);
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
throw new Error(
|
||||
"transform must evaluate to a function (doc, ctx) => doc",
|
||||
);
|
||||
}
|
||||
const raw = vm.runInNewContext(
|
||||
"f(d, c)",
|
||||
{ f: fn, d: sandbox.doc, c: ctx },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
if (
|
||||
!raw ||
|
||||
typeof raw !== "object" ||
|
||||
raw.type !== "doc" ||
|
||||
!Array.isArray(raw.content)
|
||||
) {
|
||||
throw new Error(
|
||||
'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })',
|
||||
);
|
||||
}
|
||||
// Validate the RAW transform output FIRST (structure — including the
|
||||
// MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer
|
||||
// recurses without a depth limiter, so validating after it would turn a
|
||||
// too-deep doc into an opaque "Maximum call stack size exceeded" instead of
|
||||
// the intended "nesting exceeds the maximum depth" error.
|
||||
this.validateDocStructure(raw);
|
||||
this.validateDocUrls(raw);
|
||||
// Auto-canonicalize footnotes after the transform (idempotent): no write
|
||||
// path can leave footnotes out of order / orphaned / in a raw `[^id]`
|
||||
// block. In a dryRun preview this may surface footnote edits the script
|
||||
// author did not write (the canonicalizer tidied them) — that is expected.
|
||||
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||
const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw));
|
||||
newDoc = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
// Preview only: run against the current REST snapshot, never open the
|
||||
// socket. oldDoc/newDoc are captured by runTransform.
|
||||
const raw = await this.getPageRaw(pageId);
|
||||
const current = raw.content || { type: "doc", content: [] };
|
||||
runTransform(current);
|
||||
// Run an independent Yjs-encodability check (same sanitize + schema as the
|
||||
// apply path), so the preview fails with the same descriptive error when
|
||||
// the doc is not encodable instead of returning a misleadingly-green diff.
|
||||
assertYjsEncodable(newDoc);
|
||||
return {
|
||||
pushed: false,
|
||||
diff: diffDocs(oldDoc, newDoc),
|
||||
log: ctx.log,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply atomically against the live doc.
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
runTransform,
|
||||
);
|
||||
|
||||
// Optionally delete consumed comments (best-effort; a delete failure must
|
||||
// not undo the successful write).
|
||||
const deletedComments: string[] = [];
|
||||
if (deleteComments) {
|
||||
for (const id of ctx.consumed) {
|
||||
try {
|
||||
await this.deleteComment(id);
|
||||
deletedComments.push(id);
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(`transform: failed to delete comment ${id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the newest historyId (Docmost snapshots on the write above).
|
||||
let historyId: string | null = null;
|
||||
try {
|
||||
const hist = await this.listPageHistory(pageId);
|
||||
historyId = hist.items?.[0]?.id ?? null;
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error("transform: failed to fetch history id:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pushed: true,
|
||||
historyId,
|
||||
diff: diffDocs(oldDoc, newDoc),
|
||||
deletedComments,
|
||||
log: ctx.log,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
}
|
||||
return TransformsMixin;
|
||||
}
|
||||
+44
-12
@@ -4,7 +4,6 @@ import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
||||
import { searchShapes } from "./lib/drawio-shapes.js";
|
||||
import { getGuideSection } from "./lib/drawio-guide.js";
|
||||
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||
@@ -435,30 +434,63 @@ server.registerTool(
|
||||
// Tool: search
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
||||
// different schema; this transport is the #443 agent-lookup search — a hybrid
|
||||
// substring + full-text search that also returns each hit's location (`path`)
|
||||
// and a windowed `snippet`, so one call answers "where is it and what's in it".
|
||||
// The in-app hybrid-RRF search is deliberately NOT touched. Different behaviour
|
||||
// AND schema, so kept per-layer.
|
||||
//
|
||||
// STANDALONE-vs-STOCK-UPSTREAM: the client sends the opt-in `substring`/
|
||||
// `parentPageId`/`titleOnly` DTO fields. A stock upstream server validates the
|
||||
// DTO with `whitelist: true` and silently strips these unknown fields, so the
|
||||
// request degrades gracefully to plain FTS (no path/snippet, current shape).
|
||||
//
|
||||
// EE/TYPESENSE DEGRADATION (#443): on an instance whose SEARCH_DRIVER is
|
||||
// `typesense`, the server routes this request to the Typesense backend, which
|
||||
// does NOT implement agent-lookup — the substring/path/snippet/tiering is
|
||||
// ignored and the response degrades to plain Typesense FTS. The rich lookup
|
||||
// shape is only produced by the native Postgres search driver.
|
||||
server.registerTool(
|
||||
"search",
|
||||
{
|
||||
description:
|
||||
"Full-text search for pages and content across the whole workspace. " +
|
||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
||||
"its own default).",
|
||||
"Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
|
||||
"like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
|
||||
"hit's location (`path`: ancestor titles root→parent) and a `snippet` " +
|
||||
"around the first match, so you rarely need a follow-up get_page. " +
|
||||
"Matches substrings literally (dots/dashes/digits are not tokenized) as " +
|
||||
"well as full-text. Returns `{ pageId, title, path, snippet, score }` " +
|
||||
"sorted by `score` (a per-response relevance float).",
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe("Search query"),
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Restrict the search to a single space"),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Restrict to a page and all its descendants (the page itself included)",
|
||||
),
|
||||
titleOnly: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Match page titles only; skip page text"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe("Max results to return (max 100)"),
|
||||
.describe("Max results to return (1-50, default 10)"),
|
||||
},
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
// The tool exposes no spaceId filter, so pass undefined for the client's
|
||||
// optional spaceId parameter and forward limit into its correct slot.
|
||||
const result = await docmostClient.search(query, undefined, limit);
|
||||
async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
|
||||
const result = await docmostClient.search(query, spaceId, limit, {
|
||||
parentPageId,
|
||||
titleOnly,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,6 +37,25 @@ const CONNECT_TIMEOUT_MS = 25000;
|
||||
/** Time we wait for the server to acknowledge our write before giving up. */
|
||||
const PERSIST_TIMEOUT_MS = 20000;
|
||||
|
||||
/**
|
||||
* Marker property set on the Error thrown when the Hocuspocus handshake REJECTS
|
||||
* our collab token (onAuthenticationFailed). The client wraps content writes so
|
||||
* that on this specific failure it invalidates its cached collab token and
|
||||
* retries once with a fresh one — symmetric to the HTTP-401 reauth path (#486).
|
||||
* A plain message-match would be brittle; a tagged property is unambiguous and
|
||||
* survives teardown (which rejects pending ops with this SAME error object).
|
||||
*/
|
||||
const COLLAB_AUTH_FAILED_MARKER = "collabAuthFailed";
|
||||
|
||||
/** True when `e` is the tagged collab-WS auth-failure error (see marker above). */
|
||||
export function isCollabAuthFailedError(e: unknown): boolean {
|
||||
return !!(
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
(e as Record<string, unknown>)[COLLAB_AUTH_FAILED_MARKER] === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tunables, read fresh from the environment on every acquire so tests (and a
|
||||
* live rollback) can change them without reloading the module. Mirrors how
|
||||
@@ -302,10 +321,13 @@ export class CollabSession {
|
||||
this.openResolve?.();
|
||||
},
|
||||
onAuthenticationFailed: () => {
|
||||
this.teardown(
|
||||
new Error("Authentication failed for collaboration connection"),
|
||||
true,
|
||||
);
|
||||
// Tag the error so the client can tell a REJECTED collab token apart
|
||||
// from a generic disconnect and invalidate + refresh it (#486).
|
||||
const err = new Error(
|
||||
"Authentication failed for collaboration connection",
|
||||
) as Error & { [COLLAB_AUTH_FAILED_MARKER]?: boolean };
|
||||
err[COLLAB_AUTH_FAILED_MARKER] = true;
|
||||
this.teardown(err, true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -440,9 +462,16 @@ export class CollabSession {
|
||||
// must stay synchronous (no await). While the JS event loop is not
|
||||
// yielded, no incoming remote update can interleave, so any already-synced
|
||||
// concurrent edits are preserved in liveDoc.
|
||||
//
|
||||
// INVARIANT 1 is machine-checked: the BEGIN/END markers below delimit the
|
||||
// no-await window, and test/unit/no-await-critical-window.test.mjs scans
|
||||
// this source and FAILS if any `await` (or `for await`/`yield`) appears
|
||||
// between them. Do NOT add an await inside this block — an accidental
|
||||
// async boundary here silently reopens the clobber-live-edits race (#152).
|
||||
let newDoc: any;
|
||||
let beforeDoc: any;
|
||||
try {
|
||||
// === MUTATE-CRITICAL-WINDOW: BEGIN (no await between here and END #449) ===
|
||||
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
|
||||
if (
|
||||
!liveDoc ||
|
||||
@@ -480,6 +509,7 @@ export class CollabSession {
|
||||
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
|
||||
// end of the document on every agent write.
|
||||
applyDocToFragment(this.ydoc, newDoc);
|
||||
// === MUTATE-CRITICAL-WINDOW: END (#449) ===
|
||||
} catch (e) {
|
||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
||||
// "text not found"): propagate them verbatim to the caller.
|
||||
|
||||
@@ -254,6 +254,12 @@ export async function mutatePageContent(
|
||||
collabToken: string,
|
||||
baseUrl: string,
|
||||
transform: (liveDoc: any) => any | null,
|
||||
// #487: optional abort signal carrying the turn's Stop + the in-app tool
|
||||
// per-call cap. Checked as the PRE-COMMIT safe-point below (after the session
|
||||
// is acquired, immediately before the atomic read->write), so a Stop that
|
||||
// arrives during the connect/lock window stops THIS write from landing. See the
|
||||
// limitation note at the check.
|
||||
signal?: AbortSignal,
|
||||
): Promise<MutationResult> {
|
||||
return withPageLock(pageId, async () => {
|
||||
if (process.env.DEBUG) {
|
||||
@@ -266,6 +272,13 @@ export async function mutatePageContent(
|
||||
|
||||
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
|
||||
try {
|
||||
// #487 PRE-COMMIT safe-point: if the turn was Stopped (or the in-app tool
|
||||
// per-call cap fired) after we acquired the collab session but before the
|
||||
// atomic write, throw NOW so this commit never runs. KNOWN LIMITATION
|
||||
// (#487): this only stops THIS commit — a write tool that already committed
|
||||
// an EARLIER call this turn leaves that op applied. Cancel guarantees "no
|
||||
// NEW commit starts", NOT "the write didn't land".
|
||||
signal?.throwIfAborted();
|
||||
return await session.mutate(transform);
|
||||
} catch (e) {
|
||||
// Drop the session on any failure so the next call reconnects fresh (this
|
||||
@@ -291,6 +304,8 @@ export async function replacePageContent(
|
||||
prosemirrorDoc: any,
|
||||
collabToken: string,
|
||||
baseUrl: string,
|
||||
// #487: threaded straight to mutatePageContent's pre-commit safe-point.
|
||||
signal?: AbortSignal,
|
||||
): Promise<MutationResult> {
|
||||
// Fail fast on a bad document instead of deferring the failure into the
|
||||
// collaboration write (where TiptapTransformer.toYdoc(undefined) used to
|
||||
@@ -307,6 +322,7 @@ export async function replacePageContent(
|
||||
collabToken,
|
||||
baseUrl,
|
||||
() => prosemirrorDoc,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+161
-51
@@ -14,7 +14,19 @@
|
||||
* signature.
|
||||
*
|
||||
* If recreateTransform / the changeset throws on a pathological document pair,
|
||||
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
||||
* OR the pair is too large to diff cheaply (see the size guard below), we fall
|
||||
* back to a coarse block-level text diff so the tool never hard-fails and never
|
||||
* pins the event loop.
|
||||
*
|
||||
* SIZE GUARD (issue #464 — prod CPU-DoS). recreateTransform computes its diff via
|
||||
* rfc6902.createPatch, whose array diff is O(n·m) Levenshtein per array pair and
|
||||
* whose per-run word diff is O(w²); on a large/heavily-changed doc this runs for
|
||||
* seconds-to-hours and starves the whole process (BullMQ, Redis lock renewals,
|
||||
* embeddings). It never THROWS — it just never finishes — so the try/catch below
|
||||
* cannot save us. Because diffDocs runs on EVERY in-app/MCP content edit's verify
|
||||
* report, we PRE-FLIGHT the doc size and route anything above a cheap cap straight
|
||||
* to the coarse fallback (the same shape the catch produces). Same cap+fallback
|
||||
* pattern as the ELK-layout DoS fix (#440 / c917dcc3).
|
||||
*/
|
||||
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
@@ -72,6 +84,56 @@ function countNodes(doc: any, pred: (node: any) => boolean): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
// --- Issue #464: pre-flight size guard for the precise diff ------------------
|
||||
// Defaults are BENCHMARK-derived on the recreateTransform(complexSteps:false,
|
||||
// wordDiffs:true, simplifyDiff:true) pipeline, chosen so the WORST case (a fully
|
||||
// re-written doc — the adversarial shape that drove the incident) keeps the
|
||||
// synchronous block under ~200ms REGARDLESS of input:
|
||||
// - 150 total nodes: worst-case pair ~176ms; the O(node²) array diff crosses
|
||||
// 200ms at ~170 nodes and then explodes super-linearly (400 nodes ~1.3s,
|
||||
// 800 ~5.5s), so cap just below the crossover.
|
||||
// - 12 KiB serialized JSON: an independent axis, because the per-run word diff
|
||||
// is O(words²) — a FEW nodes with very long text runs is dangerous even at a
|
||||
// low node count (17 nodes / ~11 KiB ~176ms, / ~14 KiB ~290ms). A node-light
|
||||
// but byte-heavy doc is still refused.
|
||||
// Either metric over its cap routes to the coarse fallback. Both are env-tunable
|
||||
// for operators who accept more CPU in exchange for exact diffs on larger docs.
|
||||
const DEFAULT_MAX_NODES = 150;
|
||||
const DEFAULT_MAX_BYTES = 12 * 1024;
|
||||
|
||||
/**
|
||||
* Read a positive-integer env override, falling back to `dflt`. Garbage / unset /
|
||||
* non-finite / non-positive all fall back (so the guard can never be accidentally
|
||||
* disabled by a malformed value). Read fresh on every call so a test / operator
|
||||
* can flip the knob without a restart.
|
||||
*/
|
||||
function readPositiveIntEnv(name: string, dflt: number): number {
|
||||
const raw = parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the pair is too large for the precise (recreateTransform) diff and
|
||||
* must degrade to the coarse fallback. Takes the MAX of the two docs on each
|
||||
* metric so an ASYMMETRIC pair (a small new doc vs a huge old doc, or vice
|
||||
* versa) — which still explodes rfc6902 — is caught. Cheap: one node walk +
|
||||
* one JSON.stringify per doc, both O(size).
|
||||
*/
|
||||
function exceedsDiffSizeGuard(oldDoc: any, newDoc: any): boolean {
|
||||
const maxNodes = readPositiveIntEnv("MCP_DIFF_MAX_NODES", DEFAULT_MAX_NODES);
|
||||
const maxBytes = readPositiveIntEnv("MCP_DIFF_MAX_BYTES", DEFAULT_MAX_BYTES);
|
||||
const nodes = Math.max(
|
||||
countNodes(oldDoc, () => true),
|
||||
countNodes(newDoc, () => true),
|
||||
);
|
||||
if (nodes > maxNodes) return true;
|
||||
const bytes = Math.max(
|
||||
JSON.stringify(oldDoc)?.length ?? 0,
|
||||
JSON.stringify(newDoc)?.length ?? 0,
|
||||
);
|
||||
return bytes > maxBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
|
||||
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
|
||||
@@ -226,6 +288,81 @@ function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Accumulated textual changes plus their derived char/block tallies. */
|
||||
interface DiffTally {
|
||||
changes: DiffChange[];
|
||||
inserted: number;
|
||||
deleted: number;
|
||||
changedBlocks: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the coarse-fallback tally for a pair. This is the SINGLE source of the
|
||||
* `fellBack:true` result shape, shared by BOTH degrade paths in diffDocs (the
|
||||
* pre-flight size guard and the recreateTransform catch) so they behave and
|
||||
* report identically.
|
||||
*/
|
||||
function coarseDiffTally(oldDoc: any, newDoc: any): DiffTally {
|
||||
const changes = coarseDiff(oldDoc, newDoc);
|
||||
let inserted = 0;
|
||||
let deleted = 0;
|
||||
const changedBlocks = new Set<string>();
|
||||
for (const c of changes) {
|
||||
if (c.op === "insert") inserted += c.text.length;
|
||||
else deleted += c.text.length;
|
||||
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
||||
}
|
||||
return { changes, inserted, deleted, changedBlocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the PRECISE tally via the recreateTransform pipeline. Callers MUST
|
||||
* gate this behind the size guard (it can block the event loop for a large pair)
|
||||
* and wrap it in try/catch (a pathological pair can throw); on either the guard
|
||||
* or a throw, use `coarseDiffTally` instead. Kept as a sibling of
|
||||
* `coarseDiffTally` so both produce the same `DiffTally` shape.
|
||||
*/
|
||||
function preciseDiffTally(oldDocJson: any, newDocJson: any): DiffTally {
|
||||
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||
const tr = recreateTransform(oldNode, newNode, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
|
||||
const simplified = simplifyChanges(changeSet.changes, newNode);
|
||||
|
||||
const changes: DiffChange[] = [];
|
||||
let inserted = 0;
|
||||
let deleted = 0;
|
||||
const changedBlocks = new Set<string>();
|
||||
|
||||
for (const change of simplified) {
|
||||
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
||||
if (change.toA > change.fromA) {
|
||||
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
deleted += text.length;
|
||||
const block = blockContextAt(oldNode, change.fromA);
|
||||
changes.push({ op: "delete", block, text });
|
||||
if (block) changedBlocks.add("d:" + block);
|
||||
}
|
||||
}
|
||||
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
||||
if (change.toB > change.fromB) {
|
||||
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
inserted += text.length;
|
||||
const block = blockContextAt(newNode, change.fromB);
|
||||
changes.push({ op: "insert", block, text });
|
||||
if (block) changedBlocks.add("i:" + block);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { changes, inserted, deleted, changedBlocks };
|
||||
}
|
||||
|
||||
/** Build the human-readable unified-ish markdown summary. */
|
||||
function renderMarkdown(
|
||||
result: Omit<DiffResult, "markdown">,
|
||||
@@ -276,66 +413,39 @@ export function diffDocs(
|
||||
newDocJson: any,
|
||||
notesHeading: string = "Примечания переводчика",
|
||||
): DiffResult {
|
||||
// computeIntegrity is cheap (linear node walks) and its counts are needed in
|
||||
// BOTH the precise and coarse paths, so it always runs first.
|
||||
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
||||
|
||||
let changes: DiffChange[] = [];
|
||||
let inserted = 0;
|
||||
let deleted = 0;
|
||||
let fellBack = false;
|
||||
const changedBlocks = new Set<string>();
|
||||
let tally: DiffTally;
|
||||
|
||||
try {
|
||||
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||
const tr = recreateTransform(oldNode, newNode, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
const changeSet = ChangeSet.create(oldNode).addSteps(
|
||||
tr.doc,
|
||||
tr.mapping.maps,
|
||||
[],
|
||||
);
|
||||
const simplified = simplifyChanges(changeSet.changes, newNode);
|
||||
|
||||
for (const change of simplified) {
|
||||
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
||||
if (change.toA > change.fromA) {
|
||||
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
deleted += text.length;
|
||||
const block = blockContextAt(oldNode, change.fromA);
|
||||
changes.push({ op: "delete", block, text });
|
||||
if (block) changedBlocks.add("d:" + block);
|
||||
}
|
||||
}
|
||||
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
||||
if (change.toB > change.fromB) {
|
||||
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
inserted += text.length;
|
||||
const block = blockContextAt(newNode, change.fromB);
|
||||
changes.push({ op: "insert", block, text });
|
||||
if (block) changedBlocks.add("i:" + block);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
||||
// Pre-flight size guard (#464): a too-large pair would make recreateTransform
|
||||
// block the event loop for seconds-to-hours WITHOUT throwing, so route it to
|
||||
// the coarse fallback BEFORE calling recreateTransform at all. Both this path
|
||||
// and the catch below go through coarseDiffTally for an identical `fellBack`
|
||||
// result shape.
|
||||
if (exceedsDiffSizeGuard(oldDocJson, newDocJson)) {
|
||||
fellBack = true;
|
||||
changes = coarseDiff(oldDocJson, newDocJson);
|
||||
for (const c of changes) {
|
||||
if (c.op === "insert") inserted += c.text.length;
|
||||
else deleted += c.text.length;
|
||||
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
||||
tally = coarseDiffTally(oldDocJson, newDocJson);
|
||||
} else {
|
||||
try {
|
||||
tally = preciseDiffTally(oldDocJson, newDocJson);
|
||||
} catch {
|
||||
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
||||
fellBack = true;
|
||||
tally = coarseDiffTally(oldDocJson, newDocJson);
|
||||
}
|
||||
}
|
||||
|
||||
const partial: Omit<DiffResult, "markdown"> = {
|
||||
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
|
||||
summary: {
|
||||
inserted: tally.inserted,
|
||||
deleted: tally.deleted,
|
||||
blocksChanged: tally.changedBlocks.size,
|
||||
},
|
||||
integrity,
|
||||
changes,
|
||||
changes: tally.changes,
|
||||
};
|
||||
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// ID-based cell operations for `drawioEditCells` (issue #425, stage 3).
|
||||
//
|
||||
// Instead of resending the whole XML (whose diff is fragile — draw.io reorders
|
||||
// attributes, and a {search,replace} text match breaks on it), the model sends
|
||||
// targeted operations keyed by cell id:
|
||||
//
|
||||
// { op: "add", xml: "<mxCell .../>" } // append a new cell
|
||||
// { op: "update", cellId: "n3", xml: "<mxCell .../>" } // replace that cell
|
||||
// { op: "delete", cellId: "n5" } // + CASCADE
|
||||
//
|
||||
// `delete` CASCADES: it removes the cell, every descendant cell whose parent
|
||||
// chain leads to it (container children), AND every edge whose source or target
|
||||
// is any deleted cell. Ids are STABLE across edits so diffs stay meaningful.
|
||||
//
|
||||
// Operations apply to the parsed DOM of the current model; the caller re-lints
|
||||
// and rebuilds the .drawio.svg through the existing #423 pipeline afterwards.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
let _window: any = null;
|
||||
function xmlWindow(): any {
|
||||
if (!_window) _window = new JSDOM("").window;
|
||||
return _window;
|
||||
}
|
||||
|
||||
export type CellOp =
|
||||
| { op: "add"; xml: string }
|
||||
| { op: "update"; cellId: string; xml: string }
|
||||
| { op: "delete"; cellId: string };
|
||||
|
||||
export class CellOpsError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`drawioEditCells: ${message}`);
|
||||
this.name = "CellOpsError";
|
||||
}
|
||||
}
|
||||
|
||||
// The mxGraph root sentinels. id="0" is the graph root; id="1" is the default
|
||||
// layer that parents every real cell. A delete targeting either would cascade
|
||||
// through the whole diagram body (every cell chains up to "1"), so such an op is
|
||||
// rejected outright.
|
||||
const SENTINEL_IDS = new Set(["0", "1"]);
|
||||
|
||||
/** Parse a single `<mxCell …>…</mxCell>` fragment into an element, or throw. */
|
||||
function parseCellFragment(xml: string): any {
|
||||
const parser = new (xmlWindow().DOMParser)();
|
||||
// Wrap so a self-closed or child-bearing single cell parses as one root.
|
||||
const doc = parser.parseFromString(`<root>${xml}</root>`, "application/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length > 0) {
|
||||
throw new CellOpsError(`operation xml is not well-formed: ${xml.slice(0, 120)}`);
|
||||
}
|
||||
const cells = doc.getElementsByTagName("mxCell");
|
||||
if (cells.length !== 1) {
|
||||
throw new CellOpsError(
|
||||
`each add/update op must carry exactly one <mxCell> (got ${cells.length})`,
|
||||
);
|
||||
}
|
||||
return cells[0];
|
||||
}
|
||||
|
||||
/** All ids reachable as descendants of `rootId` via the parent relation. */
|
||||
function collectDescendants(
|
||||
rootId: string,
|
||||
parentOf: Map<string, string | undefined>,
|
||||
): Set<string> {
|
||||
const doomed = new Set<string>([rootId]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const [id, parent] of parentOf) {
|
||||
if (!doomed.has(id) && parent != null && doomed.has(parent)) {
|
||||
doomed.add(id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return doomed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the operation list to a model XML string and return the new model XML.
|
||||
* Uses the DOM so attribute order / formatting is preserved for untouched cells.
|
||||
* Throws CellOpsError on an unknown target id or a malformed op fragment (so the
|
||||
* model gets a precise error and nothing is half-applied).
|
||||
*/
|
||||
export function applyCellOps(modelXml: string, ops: CellOp[]): string {
|
||||
if (!Array.isArray(ops) || ops.length === 0) {
|
||||
throw new CellOpsError("operations must be a non-empty array");
|
||||
}
|
||||
const parser = new (xmlWindow().DOMParser)();
|
||||
const doc = parser.parseFromString(modelXml, "application/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length > 0) {
|
||||
throw new CellOpsError("the current diagram XML is not well-formed");
|
||||
}
|
||||
const root = doc.getElementsByTagName("root")[0];
|
||||
if (!root) throw new CellOpsError("the current diagram has no <root> element");
|
||||
|
||||
const cellEls = () => Array.from(root.getElementsByTagName("mxCell")) as any[];
|
||||
const byId = () => {
|
||||
const m = new Map<string, any>();
|
||||
for (const el of cellEls()) m.set(el.getAttribute("id") ?? "", el);
|
||||
return m;
|
||||
};
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.op === "add") {
|
||||
const frag = parseCellFragment(op.xml);
|
||||
const id = frag.getAttribute("id");
|
||||
if (!id) throw new CellOpsError("an add op's <mxCell> is missing an id");
|
||||
if (byId().has(id))
|
||||
throw new CellOpsError(`add op id "${id}" already exists (use update)`);
|
||||
root.appendChild(doc.importNode(frag, true));
|
||||
} else if (op.op === "update") {
|
||||
const map = byId();
|
||||
const target = map.get(op.cellId);
|
||||
if (!target)
|
||||
throw new CellOpsError(`update target cell "${op.cellId}" does not exist`);
|
||||
const frag = parseCellFragment(op.xml);
|
||||
const newId = frag.getAttribute("id");
|
||||
if (newId && newId !== op.cellId)
|
||||
throw new CellOpsError(
|
||||
`update op cellId "${op.cellId}" != the <mxCell> id "${newId}" (ids are stable)`,
|
||||
);
|
||||
// Replace the element in place so surrounding cells are untouched.
|
||||
const imported = doc.importNode(frag, true);
|
||||
target.parentNode.replaceChild(imported, target);
|
||||
} else if (op.op === "delete") {
|
||||
// Reject a sentinel-targeted delete BEFORE collecting descendants: "0"/"1"
|
||||
// parent the entire diagram, so a cascade from either would wipe the whole
|
||||
// model body (doomed.delete("0"/"1") only spared the sentinel itself, not
|
||||
// its children).
|
||||
if (SENTINEL_IDS.has(op.cellId))
|
||||
throw new CellOpsError(
|
||||
`cannot delete sentinel cell "${op.cellId}" (the graph root/default layer)`,
|
||||
);
|
||||
const map = byId();
|
||||
if (!map.has(op.cellId))
|
||||
throw new CellOpsError(`delete target cell "${op.cellId}" does not exist`);
|
||||
// Build the parent relation over the CURRENT cells for the cascade.
|
||||
const parentOf = new Map<string, string | undefined>();
|
||||
for (const el of cellEls()) {
|
||||
parentOf.set(el.getAttribute("id") ?? "", el.getAttribute("parent") ?? undefined);
|
||||
}
|
||||
const doomed = collectDescendants(op.cellId, parentOf);
|
||||
// Cascade to edges whose source/target is any doomed cell.
|
||||
for (const el of cellEls()) {
|
||||
if (el.getAttribute("edge") !== "1") continue;
|
||||
const src = el.getAttribute("source");
|
||||
const tgt = el.getAttribute("target");
|
||||
if ((src && doomed.has(src)) || (tgt && doomed.has(tgt))) {
|
||||
doomed.add(el.getAttribute("id") ?? "");
|
||||
}
|
||||
}
|
||||
// Never delete the sentinels even if referenced by a malformed op.
|
||||
doomed.delete("0");
|
||||
doomed.delete("1");
|
||||
for (const el of cellEls()) {
|
||||
const id = el.getAttribute("id") ?? "";
|
||||
if (doomed.has(id)) el.parentNode.removeChild(el);
|
||||
}
|
||||
} else {
|
||||
throw new CellOpsError(`unknown op "${(op as any).op}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const ser = new (xmlWindow().XMLSerializer)();
|
||||
return ser.serializeToString(doc.documentElement);
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
// Semantic graph -> draw.io pipeline for `drawioFromGraph` (issue #425, stage 3).
|
||||
//
|
||||
// The model describes a diagram SEMANTICALLY — nodes with a `kind` and an
|
||||
// optional `icon`, groups (containers), edges with a `kind` — and NEVER sees a
|
||||
// coordinate or a style string. This module owns the whole server-side pipeline:
|
||||
//
|
||||
// 1. validateGraph — a hand-written validator (no zod dependency, so this
|
||||
// lib stays importable by client.ts without coupling to
|
||||
// a zod major) that rejects malformed graphs early.
|
||||
// 2. resolveNodeStyle — `icon` -> exact style via the shape catalog (#424);
|
||||
// an UNKNOWN icon degrades to a generic shape by `kind`
|
||||
// WITH the label (never an empty square). `kind` -> the
|
||||
// preset palette slot.
|
||||
// 3. graphToElk — graph -> ELK-JSON, honouring the layout hints
|
||||
// (`layer`/`sameLayerAs` -> layer constraints, `pinned`
|
||||
// -> a fixed node) and compound group nodes.
|
||||
// 4. assembleModel — graph + ELK coordinates -> a full mxGraphModel XML
|
||||
// that satisfies the #423 linter BY CONSTRUCTION
|
||||
// (sentinels, transparent containers, relative child
|
||||
// coords, cross-container edges parent="1", >=150px
|
||||
// gaps from ELK spacing, escaped labels).
|
||||
//
|
||||
// The `layout` mode: "full" re-lays everything; "incremental" fixes existing
|
||||
// coordinates (ELK interactive mode) and places only new nodes; "none" keeps the
|
||||
// caller-provided/prior coordinates untouched.
|
||||
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
searchShapes,
|
||||
awsServiceStyle,
|
||||
type ShapeResult,
|
||||
} from "./drawio-shapes.js";
|
||||
import {
|
||||
getPreset,
|
||||
genericNodeStyle,
|
||||
iconNodeStyle,
|
||||
edgeStyle,
|
||||
groupStyle,
|
||||
type PresetData,
|
||||
} from "./drawio-presets.js";
|
||||
import { MIN_SHAPE_GAP } from "./drawio-xml.js";
|
||||
|
||||
// --- graph schema (plain TS + a hand validator) ----------------------------
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
/** Icon reference, e.g. "aws:lambda" | "azure:cosmos" | "lambda". */
|
||||
icon?: string;
|
||||
/** Group (container) id this node belongs to. */
|
||||
group?: string;
|
||||
/** Layer hint (ELK layerChoiceConstraint): 0-based column/row index. */
|
||||
layer?: number;
|
||||
/** Put this node in the same layer as another node id. */
|
||||
sameLayerAs?: string;
|
||||
/** Fix this node at exact coordinates (an ELK fixed node). */
|
||||
pinned?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface GraphGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
/** Parent group id — lets a group nest inside another group (e.g. subnet in VPC). */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface Graph {
|
||||
nodes: GraphNode[];
|
||||
groups?: GraphGroup[];
|
||||
edges?: GraphEdge[];
|
||||
direction?: "LR" | "RL" | "TB" | "BT";
|
||||
preset?: string;
|
||||
}
|
||||
|
||||
export type LayoutMode = "none" | "full" | "incremental";
|
||||
|
||||
/** A structured validation error (mirrors the drawio linter's shape loosely). */
|
||||
export class GraphValidationError extends Error {
|
||||
issues: string[];
|
||||
constructor(issues: string[]) {
|
||||
super(`drawioFromGraph: invalid graph — ${issues.join("; ")}`);
|
||||
this.name = "GraphValidationError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_GRAPH_NODES = 500; // parity with drawio-layout's ELK_MAX_NODES.
|
||||
// Edge/group caps mirror drawio-layout's ELK_MAX_EDGES. Without an edge cap a
|
||||
// tiny node set with a huge edge list (e.g. 500 nodes / 200000 edges) passes
|
||||
// node validation, then graphToElk/runElk exhausts the heap SYNCHRONOUSLY inside
|
||||
// elk.bundled.js — before the 5s ELK timeout can fire and OUTSIDE it entirely
|
||||
// for the mapper/assembler — crashing the worker on LLM-authored input. Reject
|
||||
// the over-limit shape here, before any layout or assembly runs.
|
||||
export const MAX_GRAPH_EDGES = 1000; // parity with drawio-layout's ELK_MAX_EDGES.
|
||||
export const MAX_GRAPH_GROUPS = 500; // groups are compound ELK nodes; bound them too.
|
||||
|
||||
/**
|
||||
* Validate the graph structure BEFORE any layout/assembly so the model gets a
|
||||
* precise, actionable error instead of a corrupt diagram. Throws
|
||||
* GraphValidationError listing every problem.
|
||||
*/
|
||||
export function validateGraph(graph: Graph): void {
|
||||
const issues: string[] = [];
|
||||
if (!graph || typeof graph !== "object") {
|
||||
throw new GraphValidationError(["graph must be an object"]);
|
||||
}
|
||||
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
||||
throw new GraphValidationError(["graph.nodes must be a non-empty array"]);
|
||||
}
|
||||
// Size caps FIRST (fail fast, before touching per-element loops) so an
|
||||
// over-limit graph can never reach the layout engine and OOM the worker.
|
||||
if (graph.nodes.length > MAX_GRAPH_NODES) {
|
||||
throw new GraphValidationError([
|
||||
`graph has ${graph.nodes.length} nodes (max ${MAX_GRAPH_NODES})`,
|
||||
]);
|
||||
}
|
||||
if (Array.isArray(graph.edges) && graph.edges.length > MAX_GRAPH_EDGES) {
|
||||
throw new GraphValidationError([
|
||||
`graph has ${graph.edges.length} edges (max ${MAX_GRAPH_EDGES})`,
|
||||
]);
|
||||
}
|
||||
if (Array.isArray(graph.groups) && graph.groups.length > MAX_GRAPH_GROUPS) {
|
||||
throw new GraphValidationError([
|
||||
`graph has ${graph.groups.length} groups (max ${MAX_GRAPH_GROUPS})`,
|
||||
]);
|
||||
}
|
||||
|
||||
const nodeIds = new Set<string>();
|
||||
const groupIds = new Set<string>();
|
||||
for (const g of graph.groups ?? []) {
|
||||
if (!g.id) issues.push("a group is missing its id");
|
||||
else if (groupIds.has(g.id)) issues.push(`duplicate group id "${g.id}"`);
|
||||
groupIds.add(g.id);
|
||||
}
|
||||
for (const n of graph.nodes) {
|
||||
if (!n.id) issues.push("a node is missing its id");
|
||||
else if (nodeIds.has(n.id)) issues.push(`duplicate node id "${n.id}"`);
|
||||
else if (groupIds.has(n.id))
|
||||
issues.push(`node id "${n.id}" collides with a group id`);
|
||||
nodeIds.add(n.id);
|
||||
if (typeof n.label !== "string" || n.label === "")
|
||||
issues.push(`node "${n.id}" is missing a label`);
|
||||
if (n.group != null && !groupIds.has(n.group))
|
||||
issues.push(`node "${n.id}" references unknown group "${n.group}"`);
|
||||
if (n.pinned != null) {
|
||||
if (
|
||||
typeof n.pinned.x !== "number" ||
|
||||
typeof n.pinned.y !== "number" ||
|
||||
!Number.isFinite(n.pinned.x) ||
|
||||
!Number.isFinite(n.pinned.y)
|
||||
)
|
||||
issues.push(`node "${n.id}" has an invalid pinned {x,y}`);
|
||||
}
|
||||
if (n.layer != null && (!Number.isInteger(n.layer) || n.layer < 0))
|
||||
issues.push(`node "${n.id}" has an invalid layer (must be a >=0 integer)`);
|
||||
}
|
||||
// sameLayerAs must reference an existing node (checked after all ids known).
|
||||
for (const n of graph.nodes) {
|
||||
if (n.sameLayerAs != null && !nodeIds.has(n.sameLayerAs))
|
||||
issues.push(
|
||||
`node "${n.id}" sameLayerAs references unknown node "${n.sameLayerAs}"`,
|
||||
);
|
||||
}
|
||||
for (const e of graph.edges ?? []) {
|
||||
if (!e.from || !e.to) {
|
||||
issues.push("an edge is missing from/to");
|
||||
continue;
|
||||
}
|
||||
if (!nodeIds.has(e.from) && !groupIds.has(e.from))
|
||||
issues.push(`edge from "${e.from}" resolves to no node/group`);
|
||||
if (!nodeIds.has(e.to) && !groupIds.has(e.to))
|
||||
issues.push(`edge to "${e.to}" resolves to no node/group`);
|
||||
}
|
||||
if (issues.length > 0) throw new GraphValidationError(issues);
|
||||
}
|
||||
|
||||
// --- icon resolution -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a node's `icon` reference to a concrete style-string + size via the
|
||||
* shape catalog. Accepts "aws:lambda", "azure:cosmos", or a bare "lambda". An
|
||||
* AWS `resIcon` name is built directly (exact service-icon template). Anything
|
||||
* else goes through searchShapes. Returns null when nothing resolves — the
|
||||
* caller then falls back to a generic shape by kind (never an empty box).
|
||||
*/
|
||||
export function resolveIcon(icon: string): ShapeResult | null {
|
||||
const raw = icon.trim();
|
||||
if (raw === "") return null;
|
||||
let provider = "";
|
||||
let name = raw;
|
||||
const colon = raw.indexOf(":");
|
||||
if (colon !== -1) {
|
||||
provider = raw.slice(0, colon).trim().toLowerCase();
|
||||
name = raw.slice(colon + 1).trim();
|
||||
}
|
||||
|
||||
if (provider === "aws") {
|
||||
// Prefer an exact resIcon match from the catalog (carries the right size and
|
||||
// any rebrand/blocklist note); if the underscore/space name doesn't hit,
|
||||
// build the canonical service-icon style directly so it is never an empty box.
|
||||
const results = searchShapes(name.replace(/_/g, " "), { limit: 5 });
|
||||
const aws4 = results.find((r) => r.style.includes("mxgraph.aws4"));
|
||||
if (aws4) return aws4;
|
||||
return {
|
||||
style: awsServiceStyle(name.replace(/\s+/g, "_")),
|
||||
w: 78,
|
||||
h: 78,
|
||||
title: name,
|
||||
type: "vertex",
|
||||
};
|
||||
}
|
||||
|
||||
// Non-AWS or bare name: fuzzy search the catalog. Take the top vertex hit,
|
||||
// but REJECT a weak match (the fuzzy scorer can prefix-match an unrelated
|
||||
// stencil, e.g. "not..." -> "Notebook"); require the hit's title to actually
|
||||
// share a meaningful token with the query, otherwise degrade to generic-by-kind.
|
||||
const q = provider ? `${provider} ${name}` : name;
|
||||
const results = searchShapes(q, { limit: 8 });
|
||||
const hit = results.find((r) => r.type !== "edge") ?? results[0];
|
||||
if (!hit) return null;
|
||||
if (!isRelevantMatch(name, hit.title)) return null;
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a resolved stencil is a genuine match for the requested icon name (as
|
||||
* opposed to a loose prefix hit on an unrelated shape). True if any 3+ char
|
||||
* token of the query appears in the stencil title, or vice-versa.
|
||||
*/
|
||||
function isRelevantMatch(name: string, title: string): boolean {
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
const qTokens = norm(name).split(/\s+/).filter((t) => t.length >= 3);
|
||||
if (qTokens.length === 0) return true; // very short names: trust the scorer
|
||||
const t = norm(title);
|
||||
const tTokens = new Set(t.split(/\s+/));
|
||||
for (const qt of qTokens) {
|
||||
if (tTokens.has(qt)) return true;
|
||||
if (t.includes(qt)) return true;
|
||||
for (const tt of tTokens) if (tt.length >= 3 && qt.includes(tt)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the final style + size for a node. When `icon` resolves, use the icon
|
||||
* style (overlaid with a dark-preset font fix); otherwise a GENERIC shape by
|
||||
* `kind` carrying the label. `resolved` reports whether an icon was found (used
|
||||
* by the acceptance test that asserts no empty squares).
|
||||
*/
|
||||
export function resolveNodeStyle(
|
||||
preset: PresetData,
|
||||
node: GraphNode,
|
||||
): { style: string; w: number; h: number; iconResolved: boolean } {
|
||||
if (node.icon) {
|
||||
const shape = resolveIcon(node.icon);
|
||||
if (shape) {
|
||||
return {
|
||||
style: iconNodeStyle(preset, shape.style),
|
||||
w: shape.w,
|
||||
h: shape.h,
|
||||
iconResolved: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Generic shape by kind, sized to the label so a long label never overflows.
|
||||
const w = Math.max(120, estimateLabelWidth(node.label) + 32);
|
||||
return { style: genericNodeStyle(preset, node.kind), w, h: 60, iconResolved: false };
|
||||
}
|
||||
|
||||
/** Rough rendered width of the longest label line at 12px (~0.6em/glyph). */
|
||||
function estimateLabelWidth(label: string): number {
|
||||
const lines = label.split(/\r?\n|
|<br\s*\/?>/i);
|
||||
let longest = 0;
|
||||
for (const l of lines) longest = Math.max(longest, l.trim().length);
|
||||
return Math.ceil(longest * 12 * 0.6);
|
||||
}
|
||||
|
||||
// --- graph -> ELK-JSON -----------------------------------------------------
|
||||
|
||||
interface ElkNode {
|
||||
id: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
children?: ElkNode[];
|
||||
layoutOptions?: Record<string, string>;
|
||||
}
|
||||
interface ElkEdge {
|
||||
id: string;
|
||||
sources: string[];
|
||||
targets: string[];
|
||||
}
|
||||
interface ElkGraph extends ElkNode {
|
||||
edges?: ElkEdge[];
|
||||
}
|
||||
|
||||
const ELK_DIRECTION: Record<string, string> = {
|
||||
LR: "RIGHT",
|
||||
RL: "LEFT",
|
||||
TB: "DOWN",
|
||||
BT: "UP",
|
||||
};
|
||||
|
||||
/** Sizes resolved per node id (from resolveNodeStyle), fed to the ELK mapper. */
|
||||
export interface NodeSize {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ELK graph from the semantic graph + resolved node sizes. Compound
|
||||
* group nodes nest their members (a group may itself nest in another group).
|
||||
* `only` restricts the graph to a subset of node ids (used by the incremental
|
||||
* path to lay out ONLY the new nodes). Layout HINTS (`layer`/`sameLayerAs`/
|
||||
* `pinned`) are NOT encoded as ELK constraints here — ELK's constraint knobs are
|
||||
* unreliable across versions — they are enforced deterministically AFTER layout
|
||||
* by applyHints, which is exact and testable.
|
||||
*/
|
||||
export function graphToElk(
|
||||
graph: Graph,
|
||||
sizes: Map<string, NodeSize>,
|
||||
opts: { only?: Set<string> } = {},
|
||||
): ElkGraph {
|
||||
const direction = ELK_DIRECTION[graph.direction ?? "LR"] ?? "RIGHT";
|
||||
const only = opts.only;
|
||||
const include = (id: string) => !only || only.has(id);
|
||||
|
||||
const makeNode = (n: GraphNode): ElkNode => {
|
||||
const size = sizes.get(n.id) ?? { w: 140, h: 60 };
|
||||
return { id: n.id, width: size.w, height: size.h };
|
||||
};
|
||||
|
||||
// Group children nest under their group node; ungrouped nodes are roots.
|
||||
const groupNode = new Map<string, ElkNode>();
|
||||
const usedGroups = new Set<string>();
|
||||
for (const g of graph.groups ?? []) {
|
||||
const size = sizes.get(g.id) ?? { w: 200, h: 150 };
|
||||
groupNode.set(g.id, {
|
||||
id: g.id,
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
children: [],
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": direction,
|
||||
"elk.padding": "[top=40,left=30,bottom=30,right=30]",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
|
||||
"elk.spacing.nodeNode": "170",
|
||||
},
|
||||
});
|
||||
}
|
||||
const roots: ElkNode[] = [];
|
||||
for (const n of graph.nodes) {
|
||||
if (!include(n.id)) continue;
|
||||
const en = makeNode(n);
|
||||
if (n.group && groupNode.has(n.group)) {
|
||||
groupNode.get(n.group)!.children!.push(en);
|
||||
usedGroups.add(n.group);
|
||||
} else {
|
||||
roots.push(en);
|
||||
}
|
||||
}
|
||||
// Nest group nodes into their parent group (a subnet inside a VPC); groups
|
||||
// with no parent group become roots. Only groups that hold an included node.
|
||||
const groupIdSet = new Set((graph.groups ?? []).map((g) => g.id));
|
||||
for (const g of graph.groups ?? []) {
|
||||
if (only && !usedGroups.has(g.id)) continue;
|
||||
const en = groupNode.get(g.id)!;
|
||||
if (g.group && groupIdSet.has(g.group) && g.group !== g.id && (!only || usedGroups.has(g.group))) {
|
||||
groupNode.get(g.group)!.children!.push(en);
|
||||
} else {
|
||||
roots.push(en);
|
||||
}
|
||||
}
|
||||
|
||||
// Edges: endpoints may be nodes or groups; INCLUDE_CHILDREN spans the nesting.
|
||||
const validIds = new Set<string>([
|
||||
...graph.nodes.filter((n) => include(n.id)).map((n) => n.id),
|
||||
...(graph.groups ?? []).map((g) => g.id),
|
||||
]);
|
||||
const edges: ElkEdge[] = [];
|
||||
(graph.edges ?? []).forEach((e, i) => {
|
||||
if (!validIds.has(e.from) || !validIds.has(e.to)) return;
|
||||
edges.push({ id: `e${i}`, sources: [e.from], targets: [e.to] });
|
||||
});
|
||||
|
||||
const rootOptions: Record<string, string> = {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": direction,
|
||||
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
|
||||
"elk.spacing.nodeNode": "170",
|
||||
"elk.spacing.edgeNode": "40",
|
||||
"elk.spacing.edgeEdge": "30",
|
||||
"elk.padding": "[top=20,left=20,bottom=20,right=20]",
|
||||
};
|
||||
|
||||
return { id: "root", layoutOptions: rootOptions, children: roots, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the layout hints DETERMINISTICALLY on ELK's output (mutates `geo`):
|
||||
* - `sameLayerAs`: snap the dependent node's LAYER-AXIS coordinate to its
|
||||
* anchor's, so the pair lands in the same layer (x for LR/RL, y for TB/BT).
|
||||
* `layer` groups nodes with the same index onto the same anchor coordinate.
|
||||
* - `pinned`: override the node's coordinate with the exact pinned {x,y}.
|
||||
* Applied only to top-level (ungrouped) nodes, whose ELK coords are absolute.
|
||||
*/
|
||||
export function applyHints(
|
||||
graph: Graph,
|
||||
geo: Map<string, { x: number; y: number; w: number; h: number }>,
|
||||
): void {
|
||||
const dir = graph.direction ?? "LR";
|
||||
const layerAxis: "x" | "y" = dir === "TB" || dir === "BT" ? "y" : "x";
|
||||
// The perpendicular (cross-layer) axis: members snapped onto one layer must be
|
||||
// spread along THIS axis so they don't stack onto the same point.
|
||||
const crossAxis: "x" | "y" = layerAxis === "x" ? "y" : "x";
|
||||
const crossSize: "w" | "h" = crossAxis === "x" ? "w" : "h";
|
||||
const grouped = new Set(
|
||||
graph.nodes.filter((n) => n.group).map((n) => n.id),
|
||||
);
|
||||
|
||||
// sameLayerAs / layer: co-assign the layer-axis coordinate.
|
||||
// Build the effective layer key per node, then pick a representative coord.
|
||||
const layerKeyOf = new Map<string, string>();
|
||||
const explicitLayer = new Map<string, number>();
|
||||
for (const n of graph.nodes) if (n.layer != null) explicitLayer.set(n.id, n.layer);
|
||||
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
const resolveKey = (n: GraphNode): string | null => {
|
||||
if (explicitLayer.has(n.id)) return `L${explicitLayer.get(n.id)}`;
|
||||
const seen = new Set<string>([n.id]);
|
||||
let cur: GraphNode | undefined = n;
|
||||
while (cur && cur.sameLayerAs != null && !seen.has(cur.sameLayerAs)) {
|
||||
seen.add(cur.sameLayerAs);
|
||||
const t = byId.get(cur.sameLayerAs);
|
||||
if (!t) break;
|
||||
if (explicitLayer.has(t.id)) return `L${explicitLayer.get(t.id)}`;
|
||||
cur = t;
|
||||
}
|
||||
// A sameLayerAs chain with no explicit layer: key on the chain's root id.
|
||||
if (n.sameLayerAs != null) {
|
||||
let root = n.id;
|
||||
const s2 = new Set<string>([n.id]);
|
||||
let c: GraphNode | undefined = n;
|
||||
while (c && c.sameLayerAs != null && !s2.has(c.sameLayerAs)) {
|
||||
s2.add(c.sameLayerAs);
|
||||
root = c.sameLayerAs;
|
||||
c = byId.get(c.sameLayerAs);
|
||||
}
|
||||
return `C${root}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const n of graph.nodes) {
|
||||
if (grouped.has(n.id)) continue; // group children are relative — skip
|
||||
const key = resolveKey(n);
|
||||
if (key) layerKeyOf.set(n.id, key);
|
||||
}
|
||||
// Group members of each layer key so we can snap AND spread them together.
|
||||
const membersOf = new Map<string, string[]>();
|
||||
for (const n of graph.nodes) {
|
||||
const key = layerKeyOf.get(n.id);
|
||||
if (key == null) continue;
|
||||
if (!geo.has(n.id)) continue;
|
||||
(membersOf.get(key) ?? membersOf.set(key, []).get(key)!).push(n.id);
|
||||
}
|
||||
// For each layer key: snap every member to the FIRST member's layer-axis coord,
|
||||
// then SPREAD them along the perpendicular (cross-layer) axis with a >=
|
||||
// MIN_SHAPE_GAP gap. Without the spread, a sameLayerAs chain whose nodes ELK
|
||||
// happened to give the same cross-axis coordinate would collapse onto one point
|
||||
// -> shape-overlap + edge-through-shape quality warnings (breaking the
|
||||
// "0 warnings by construction" guarantee for these AUTO-positioned hints). We
|
||||
// start from the members' minimum cross-axis coord and stack them with a gap
|
||||
// of MIN_SHAPE_GAP beyond each shape's cross-axis size.
|
||||
for (const [key, members] of membersOf) {
|
||||
if (members.length === 0) continue;
|
||||
// Snap layer-axis coord to the first member.
|
||||
const repCoord = geo.get(members[0])![layerAxis];
|
||||
// Preserve the members' existing relative order along the cross axis so the
|
||||
// spread stays visually stable, then re-lay them contiguously.
|
||||
const sorted = [...members].sort(
|
||||
(a, b) => geo.get(a)![crossAxis] - geo.get(b)![crossAxis],
|
||||
);
|
||||
let cursor = geo.get(sorted[0])![crossAxis];
|
||||
for (const id of sorted) {
|
||||
const g = geo.get(id)!;
|
||||
g[layerAxis] = repCoord;
|
||||
g[crossAxis] = cursor;
|
||||
cursor += g[crossSize] + MIN_SHAPE_GAP;
|
||||
}
|
||||
void key;
|
||||
}
|
||||
|
||||
// pinned: exact override (wins over any layer snap). Explicit user coordinates
|
||||
// are user intent, but CLAMP to non-negative so an out-of-bounds pin (e.g.
|
||||
// x:-500) never renders off-canvas. Two user-pinned nodes at the same point is
|
||||
// user error the server can't silently relocate — the assembler docstring
|
||||
// documents that explicit pins are user-directed and MAY warn (see #423/#425
|
||||
// acceptance: the "0 quality-warnings by construction" guarantee is for
|
||||
// AUTO-LAYOUT, not for coordinates the user pinned by hand).
|
||||
for (const n of graph.nodes) {
|
||||
if (!n.pinned) continue;
|
||||
const px = Math.max(0, n.pinned.x);
|
||||
const py = Math.max(0, n.pinned.y);
|
||||
const g = geo.get(n.id);
|
||||
if (g) {
|
||||
g.x = px;
|
||||
g.y = py;
|
||||
} else {
|
||||
const sz = { w: 140, h: 60 };
|
||||
geo.set(n.id, { x: px, y: py, w: sz.w, h: sz.h });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental variant of applyHints: apply `pinned` only to NEW nodes (those
|
||||
* absent from `existing`); an existing node's coordinates are NEVER changed
|
||||
* (acceptance #3). sameLayerAs/layer snapping is intentionally skipped in the
|
||||
* incremental path — moving a new node's layer axis could still be desired, but
|
||||
* it must never move an existing cell, so we keep the incremental contract
|
||||
* simple: existing cells are frozen, new pinned nodes honour their pin.
|
||||
*/
|
||||
export function applyHintsForNew(
|
||||
graph: Graph,
|
||||
geo: Map<string, { x: number; y: number; w: number; h: number }>,
|
||||
existing: Map<string, { x: number; y: number }>,
|
||||
): void {
|
||||
for (const n of graph.nodes) {
|
||||
if (existing.has(n.id)) continue; // never move an existing cell
|
||||
if (!n.pinned) continue;
|
||||
const px = Math.max(0, n.pinned.x); // clamp out-of-bounds pins non-negative
|
||||
const py = Math.max(0, n.pinned.y);
|
||||
const g = geo.get(n.id);
|
||||
if (g) {
|
||||
g.x = px;
|
||||
g.y = py;
|
||||
} else {
|
||||
geo.set(n.id, { x: px, y: py, w: 140, h: 60 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- layout runner ---------------------------------------------------------
|
||||
|
||||
const ELK_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Run ELK over the mapped graph and return computed geometry per id (coords are
|
||||
* parent-relative, matching mxGraph's convention for container children). On any
|
||||
* ELK failure/timeout the returned map is empty and the caller falls back to a
|
||||
* deterministic grid placement (so the write never fails on a layout hiccup).
|
||||
*/
|
||||
export async function runElk(
|
||||
elk: ElkGraph,
|
||||
): Promise<Map<string, { x: number; y: number; w: number; h: number }>> {
|
||||
const geo = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const Ctor: any = (ELK as any).default ?? ELK;
|
||||
const inst = new Ctor();
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("ELK timed out")), ELK_TIMEOUT_MS);
|
||||
});
|
||||
const laid = (await Promise.race([inst.layout(elk as any), timeout])) as ElkGraph;
|
||||
const walk = (n: ElkNode) => {
|
||||
if (n.id !== "root") {
|
||||
geo.set(n.id, {
|
||||
x: Math.round(n.x ?? 0),
|
||||
y: Math.round(n.y ?? 0),
|
||||
w: Math.round(n.width ?? 140),
|
||||
h: Math.round(n.height ?? 60),
|
||||
});
|
||||
}
|
||||
for (const c of n.children ?? []) walk(c);
|
||||
};
|
||||
walk(laid);
|
||||
} catch {
|
||||
return new Map(); // best-effort: empty -> caller uses fallback grid.
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
return geo;
|
||||
}
|
||||
|
||||
// --- XML assembler ---------------------------------------------------------
|
||||
|
||||
/** Order groups so a parent group always precedes its nested children. */
|
||||
function topoSortGroups(groups: GraphGroup[], groupIds: Set<string>): GraphGroup[] {
|
||||
const byId = new Map(groups.map((g) => [g.id, g]));
|
||||
const out: GraphGroup[] = [];
|
||||
const done = new Set<string>();
|
||||
const visit = (g: GraphGroup, stack: Set<string>) => {
|
||||
if (done.has(g.id)) return;
|
||||
if (stack.has(g.id)) return; // cycle guard
|
||||
stack.add(g.id);
|
||||
if (g.group && groupIds.has(g.group) && g.group !== g.id) {
|
||||
const parent = byId.get(g.group);
|
||||
if (parent) visit(parent, stack);
|
||||
}
|
||||
stack.delete(g.id);
|
||||
if (!done.has(g.id)) {
|
||||
done.add(g.id);
|
||||
out.push(g);
|
||||
}
|
||||
};
|
||||
for (const g of groups) visit(g, new Set());
|
||||
return out;
|
||||
}
|
||||
|
||||
function xmlEscapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/\r\n|\r|\n/g, "
"); // literal newline -> the linter-approved entity
|
||||
}
|
||||
|
||||
export interface AssembleResult {
|
||||
modelXml: string;
|
||||
iconsResolved: number;
|
||||
iconsMissing: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the final mxGraphModel XML from the graph + resolved styles/coords.
|
||||
* Guarantees BY CONSTRUCTION that the #423 linter passes:
|
||||
* - id=0 and id=1(parent=0) sentinels;
|
||||
* - each node/group is vertex="1" (containers get container=1 via the style);
|
||||
* - each edge is edge="1" with a child <mxGeometry relative="1" as="geometry"/>;
|
||||
* - group children set parent=<groupId> and RELATIVE coords; an edge between
|
||||
* two different parents is parent="1";
|
||||
* - labels are XML-escaped and any newline is 
.
|
||||
* `geo` may be empty (ELK failed) — then a deterministic grid is used so the
|
||||
* output is still valid and non-overlapping (>=170px stride).
|
||||
*
|
||||
* QUALITY-WARNING GUARANTEE: the "0 quality-warnings by construction" promise
|
||||
* holds for AUTO-LAYOUT — ELK spacing plus applyHints' cross-axis spread for the
|
||||
* server-positioned `layer`/`sameLayerAs` hints keep shapes >=MIN_SHAPE_GAP
|
||||
* apart. It does NOT extend to explicit `pinned` coordinates: those are
|
||||
* user-directed, so two nodes the user pins to the same/overlapping point are
|
||||
* user error the server honours verbatim (only clamped non-negative) and MAY
|
||||
* therefore produce a quality warning.
|
||||
*/
|
||||
export function assembleModel(
|
||||
graph: Graph,
|
||||
opts: {
|
||||
preset: PresetData;
|
||||
styles: Map<string, { style: string; w: number; h: number; iconResolved: boolean }>;
|
||||
geo: Map<string, { x: number; y: number; w: number; h: number }>;
|
||||
},
|
||||
): AssembleResult {
|
||||
const { preset, styles, geo } = opts;
|
||||
const groupIds = new Set((graph.groups ?? []).map((g) => g.id));
|
||||
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
|
||||
// Fallback grid when ELK produced nothing: lay ungrouped nodes on a grid with
|
||||
// a 190px stride (>150 gap). Grouped nodes/groups are placed inside their group.
|
||||
const fallback = geo.size === 0;
|
||||
const gridPos = (i: number) => ({ x: 40 + (i % 5) * 200, y: 40 + Math.floor(i / 5) * 140 });
|
||||
|
||||
const cells: string[] = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];
|
||||
|
||||
// Groups first (they are parents of their members). A nested group sets
|
||||
// parent=<parentGroupId>; emit parents before children so parent-exists holds.
|
||||
let gi = 0;
|
||||
const groupGeo = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
const orderedGroups = topoSortGroups(graph.groups ?? [], groupIds);
|
||||
for (const g of orderedGroups) {
|
||||
const gg = geo.get(g.id) ?? { ...gridPos(gi++), w: 320, h: 220 };
|
||||
groupGeo.set(g.id, gg);
|
||||
const style = groupStyle(preset);
|
||||
const gParent = g.group && groupIds.has(g.group) && g.group !== g.id ? g.group : "1";
|
||||
cells.push(
|
||||
`<mxCell id="${xmlEscapeAttr(g.id)}" value="${xmlEscapeAttr(g.label)}" style="${style}" vertex="1" parent="${xmlEscapeAttr(gParent)}">` +
|
||||
`<mxGeometry x="${gg.x}" y="${gg.y}" width="${gg.w}" height="${gg.h}" as="geometry"/></mxCell>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Nodes. A grouped node's coords are RELATIVE to its group (ELK already
|
||||
// returns child coords relative to the parent; for the fallback grid we place
|
||||
// children on a small in-group grid).
|
||||
let ungrouped = (graph.groups?.length ?? 0);
|
||||
const inGroupIndex = new Map<string, number>();
|
||||
let iconsResolved = 0;
|
||||
const iconsMissing: string[] = [];
|
||||
for (const n of graph.nodes) {
|
||||
const st = styles.get(n.id)!;
|
||||
if (n.icon) {
|
||||
if (st.iconResolved) iconsResolved++;
|
||||
else iconsMissing.push(n.id);
|
||||
}
|
||||
let x: number;
|
||||
let y: number;
|
||||
const g = geo.get(n.id);
|
||||
if (g && !fallback) {
|
||||
x = g.x;
|
||||
y = g.y;
|
||||
} else if (n.group && groupIds.has(n.group)) {
|
||||
const k = inGroupIndex.get(n.group) ?? 0;
|
||||
inGroupIndex.set(n.group, k + 1);
|
||||
x = 30 + (k % 3) * 180;
|
||||
y = 40 + Math.floor(k / 3) * 120;
|
||||
} else {
|
||||
const p = gridPos(ungrouped++);
|
||||
x = p.x;
|
||||
y = p.y;
|
||||
}
|
||||
const parent = n.group && groupIds.has(n.group) ? n.group : "1";
|
||||
cells.push(
|
||||
`<mxCell id="${xmlEscapeAttr(n.id)}" value="${xmlEscapeAttr(n.label)}" style="${st.style}" vertex="1" parent="${xmlEscapeAttr(parent)}">` +
|
||||
`<mxGeometry x="${x}" y="${y}" width="${st.w}" height="${st.h}" as="geometry"/></mxCell>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Edges. parent="1" whenever the two endpoints have different container
|
||||
// parents (or either is a group); otherwise the shared group id.
|
||||
(graph.edges ?? []).forEach((e, i) => {
|
||||
const style = edgeStyle(preset, e.kind);
|
||||
const fromNode = nodeById.get(e.from);
|
||||
const toNode = nodeById.get(e.to);
|
||||
const fromParent = fromNode?.group && groupIds.has(fromNode.group) ? fromNode.group : "1";
|
||||
const toParent = toNode?.group && groupIds.has(toNode.group) ? toNode.group : "1";
|
||||
const parent = fromParent === toParent ? fromParent : "1";
|
||||
const label = e.label ? ` value="${xmlEscapeAttr(e.label)}"` : "";
|
||||
cells.push(
|
||||
`<mxCell id="ge${i}"${label} style="${style}" edge="1" parent="${xmlEscapeAttr(parent)}" ` +
|
||||
`source="${xmlEscapeAttr(e.from)}" target="${xmlEscapeAttr(e.to)}">` +
|
||||
`<mxGeometry relative="1" as="geometry"/></mxCell>`,
|
||||
);
|
||||
});
|
||||
|
||||
const modelAttrs =
|
||||
'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100" adaptiveColors="auto"';
|
||||
const modelXml = `<mxGraphModel ${modelAttrs}><root>${cells.join("")}</root></mxGraphModel>`;
|
||||
return { modelXml, iconsResolved, iconsMissing };
|
||||
}
|
||||
|
||||
// --- incremental merge -----------------------------------------------------
|
||||
|
||||
let _mergeWindow: any = null;
|
||||
function mergeWindow(): any {
|
||||
if (!_mergeWindow) _mergeWindow = new JSDOM("").window;
|
||||
return _mergeWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the freshly-assembled graph XML with the EXISTING diagram model so an
|
||||
* incremental "add a node" call never drops a hand-placed cell. `assembleModel`
|
||||
* emits ONLY the passed graph's cells; on its own it would replace the whole
|
||||
* model, wiping any existing cell the caller didn't re-list. This splices every
|
||||
* existing cell that the graph does NOT re-list (preserved verbatim: coords,
|
||||
* style, edges) into the assembled root:
|
||||
* - id in the graph -> the graph's (re-laid) cell wins (already assembled;
|
||||
* coords are frozen for existing ids via the incremental geo path);
|
||||
* - id NOT in the graph -> the existing cell is preserved verbatim;
|
||||
* - a graph node absent from the existing model -> added (offset clear).
|
||||
* The sentinels ("0"/"1") come from the assembled model and are never doubled.
|
||||
*/
|
||||
function mergeExistingCells(
|
||||
assembledXml: string,
|
||||
existingModelXml: string,
|
||||
graph: Graph,
|
||||
): string {
|
||||
const win = mergeWindow();
|
||||
const parser = new win.DOMParser();
|
||||
const existingDoc = parser.parseFromString(existingModelXml, "application/xml");
|
||||
if (existingDoc.getElementsByTagName("parsererror").length > 0) {
|
||||
// Existing model unreadable: fall back to the assembled model alone (still a
|
||||
// valid diagram — better than throwing on a corrupt prior file).
|
||||
return assembledXml;
|
||||
}
|
||||
const assembledDoc = parser.parseFromString(assembledXml, "application/xml");
|
||||
const root = assembledDoc.getElementsByTagName("root")[0];
|
||||
if (!root) return assembledXml;
|
||||
|
||||
// Ids the assembled model already emitted (graph nodes/groups/edges + sentinels).
|
||||
const assembledIds = new Set<string>();
|
||||
for (const el of Array.from(root.getElementsByTagName("mxCell")) as any[]) {
|
||||
const id = el.getAttribute("id");
|
||||
if (id) assembledIds.add(id);
|
||||
}
|
||||
// The graph's own ids: any existing cell with one of these is superseded by the
|
||||
// assembled version and must NOT be re-imported.
|
||||
const graphIds = new Set<string>([
|
||||
...graph.nodes.map((n) => n.id),
|
||||
...(graph.groups ?? []).map((g) => g.id),
|
||||
]);
|
||||
|
||||
const existingCells = Array.from(
|
||||
existingDoc.getElementsByTagName("mxCell"),
|
||||
) as any[];
|
||||
for (const el of existingCells) {
|
||||
const id = el.getAttribute("id") ?? "";
|
||||
if (id === "0" || id === "1") continue; // sentinels come from the assembled model
|
||||
if (graphIds.has(id)) continue; // graph re-lists it -> assembled version wins
|
||||
if (assembledIds.has(id)) continue; // id collision guard -> keep assembled
|
||||
root.appendChild(assembledDoc.importNode(el, true));
|
||||
assembledIds.add(id);
|
||||
}
|
||||
|
||||
const ser = new win.XMLSerializer();
|
||||
return ser.serializeToString(assembledDoc.documentElement);
|
||||
}
|
||||
|
||||
// --- top-level: graph -> mxGraphModel XML ----------------------------------
|
||||
|
||||
export interface BuildFromGraphResult {
|
||||
modelXml: string;
|
||||
iconsResolved: number;
|
||||
iconsMissing: string[];
|
||||
layout: LayoutMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full server-side pipeline: validate -> resolve styles/icons -> map to ELK
|
||||
* -> run ELK (or fall back) -> assemble linter-clean XML. `existingCoords` is
|
||||
* supplied for `layout:"incremental"` (the coordinates of the diagram's current
|
||||
* cells, so they are preserved and only new nodes are placed). `existingModelXml`
|
||||
* is the current diagram's full model XML — in incremental mode every existing
|
||||
* cell the graph does NOT re-list is MERGED back in verbatim so a hand-placed
|
||||
* cell is never dropped (WARNING #4). Pure — no network.
|
||||
*/
|
||||
export async function buildFromGraph(
|
||||
graph: Graph,
|
||||
layout: LayoutMode = "full",
|
||||
existingCoords?: Map<string, { x: number; y: number }>,
|
||||
existingModelXml?: string,
|
||||
): Promise<BuildFromGraphResult> {
|
||||
validateGraph(graph);
|
||||
const preset = getPreset(graph.preset);
|
||||
|
||||
// Resolve every node's style + size (icon or generic-by-kind).
|
||||
const styles = new Map<
|
||||
string,
|
||||
{ style: string; w: number; h: number; iconResolved: boolean }
|
||||
>();
|
||||
const sizes = new Map<string, NodeSize>();
|
||||
for (const n of graph.nodes) {
|
||||
const s = resolveNodeStyle(preset, n);
|
||||
styles.set(n.id, s);
|
||||
sizes.set(n.id, { w: s.w, h: s.h });
|
||||
}
|
||||
// Group sizes: seed a min box; ELK computes the real size when it lays out.
|
||||
for (const g of graph.groups ?? []) sizes.set(g.id, { w: 240, h: 180 });
|
||||
|
||||
let geo = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
if (layout === "incremental" && existingCoords && existingCoords.size > 0) {
|
||||
// INCREMENTAL: keep every existing cell's coords VERBATIM (acceptance #3 —
|
||||
// never move a hand-arranged cell) and lay out ONLY the new nodes, then
|
||||
// offset that block clear of the existing bbox so nothing overlaps.
|
||||
for (const [id, c] of existingCoords) {
|
||||
const sz = sizes.get(id) ?? { w: 140, h: 60 };
|
||||
geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h });
|
||||
}
|
||||
const newIds = new Set(
|
||||
graph.nodes.filter((n) => !existingCoords.has(n.id)).map((n) => n.id),
|
||||
);
|
||||
if (newIds.size > 0) {
|
||||
const elk = graphToElk(graph, sizes, { only: newIds });
|
||||
const laid = await runElk(elk);
|
||||
// Place the new block below the existing content (a clear >=170px gap).
|
||||
let maxY = 0;
|
||||
for (const c of existingCoords.values()) maxY = Math.max(maxY, c.y);
|
||||
const offsetY = maxY + 200;
|
||||
for (const [id, g] of laid) {
|
||||
if (newIds.has(id)) geo.set(id, { ...g, y: g.y + offsetY });
|
||||
else if (!geo.has(id)) geo.set(id, g); // a new group container
|
||||
}
|
||||
}
|
||||
// Hints still apply to NEW pinned nodes only (existing ones stay put).
|
||||
applyHintsForNew(graph, geo, existingCoords);
|
||||
} else if (layout !== "none") {
|
||||
const elk = graphToElk(graph, sizes);
|
||||
geo = await runElk(elk);
|
||||
applyHints(graph, geo);
|
||||
} else if (existingCoords) {
|
||||
// layout:"none" with prior coords -> keep them verbatim.
|
||||
for (const [id, c] of existingCoords) {
|
||||
const sz = sizes.get(id) ?? { w: 140, h: 60 };
|
||||
geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h });
|
||||
}
|
||||
}
|
||||
|
||||
const assembled = assembleModel(graph, { preset, styles, geo });
|
||||
// In incremental mode, splice back every existing cell the graph didn't
|
||||
// re-list so an "add one node" call preserves the user's manual layout.
|
||||
let modelXml = assembled.modelXml;
|
||||
if (
|
||||
layout === "incremental" &&
|
||||
existingModelXml &&
|
||||
existingCoords &&
|
||||
existingCoords.size > 0
|
||||
) {
|
||||
modelXml = mergeExistingCells(modelXml, existingModelXml, graph);
|
||||
}
|
||||
return {
|
||||
modelXml,
|
||||
iconsResolved: assembled.iconsResolved,
|
||||
iconsMissing: assembled.iconsMissing,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
// exactly mxGraph's convention for a child of a container, so they map across
|
||||
// directly. Container sizes are computed by ELK; leaf sizes are preserved.
|
||||
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { normalizeInput, parseCells, type DrawioCell } from "./drawio-xml.js";
|
||||
|
||||
@@ -18,22 +18,33 @@ import { normalizeInput, parseCells, type DrawioCell } from "./drawio-xml.js";
|
||||
const DEFAULT_W = 140;
|
||||
const DEFAULT_H = 60;
|
||||
|
||||
// DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied
|
||||
// (layout:"elk" in drawioCreate/drawioUpdate) and elkjs runs synchronously on
|
||||
// the MCP server's event loop, so an unbounded graph would block it for
|
||||
// seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry
|
||||
// thousands of nodes. We cap the graph size and race the layout against a
|
||||
// wall-clock timeout; on either bound we fall back to the ORIGINAL model, the
|
||||
// same best-effort contract the catch already honours.
|
||||
// DoS bounds for the ELK layout. The mxGraph XML is LLM-supplied (layout:"elk"
|
||||
// in drawioCreate/drawioUpdate). elkjs' layout() returns a Promise but runs the
|
||||
// crossing-minimisation SYNCHRONOUSLY — it blocks whatever thread it runs on for
|
||||
// the whole pass. A ~1MB XML (well under the stage-1 16MB cap) can carry
|
||||
// thousands of nodes. We (a) cap the graph size before ever calling ELK and
|
||||
// (b) run the layout in a WORKER THREAD so the main event loop stays free, with
|
||||
// the wall-clock timeout enforced by terminating that worker. On either bound we
|
||||
// fall back to the ORIGINAL model, the same best-effort contract the catch honours.
|
||||
// - 500 nodes lays out in well under a second; beyond that ELK cost climbs
|
||||
// steeply, so refuse and leave the (already-valid) model untouched.
|
||||
// - Edges dominate the layered-crossing cost, so allow a bit more headroom
|
||||
// (1000) than nodes but still bound them.
|
||||
// - 5s is generous for any graph within the caps yet short enough that a
|
||||
// pathological input can never wedge the server.
|
||||
// - The timeout is a HARD kill of the worker thread — the only way to interrupt
|
||||
// synchronous JS. The in-process setTimeout race we used before was an
|
||||
// illusion: the timer could never fire while the SAME thread was blocked
|
||||
// inside elkjs, so it "protected" nothing. Now the timer runs on the main
|
||||
// thread while ELK runs on the worker, so it can actually fire and terminate.
|
||||
const ELK_MAX_NODES = 500;
|
||||
const ELK_MAX_EDGES = 1000;
|
||||
const ELK_TIMEOUT_MS = 5000;
|
||||
// Wall-clock ceiling for a single layout pass. Overridable for tests (a tiny
|
||||
// value forces the terminate-on-timeout path deterministically); a non-positive
|
||||
// or unparseable override falls back to the default.
|
||||
const ELK_TIMEOUT_DEFAULT_MS = 5000;
|
||||
function resolveElkTimeoutMs(): number {
|
||||
const raw = Number(process.env.DRAWIO_ELK_TIMEOUT_MS);
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : ELK_TIMEOUT_DEFAULT_MS;
|
||||
}
|
||||
|
||||
// Spacing is set >=150px on purpose so an ELK layout never trips the linter's
|
||||
// "gap between adjacent shapes < 150px" quality warning (acceptance #3).
|
||||
@@ -78,13 +89,57 @@ interface ElkGraph extends ElkNode {
|
||||
edges?: ElkEdge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one ELK layered layout on a worker thread and resolve with the laid-out
|
||||
* graph. The timeout is enforced by `worker.terminate()` — a HARD kill, which is
|
||||
* the only way to interrupt elkjs' synchronous crossing-minimisation once it has
|
||||
* started. Rejects on timeout, worker error, or an early exit; the caller treats
|
||||
* any rejection as "keep the original model" (best-effort layout).
|
||||
*/
|
||||
function layoutInWorker(graph: ElkGraph, timeoutMs: number): Promise<ElkGraph> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL("./drawio-layout.worker.js", import.meta.url),
|
||||
{ workerData: { graph } },
|
||||
);
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
// Always tear the worker down: on the happy path so it does not linger,
|
||||
// on timeout so the blocked synchronous ELK run is actually interrupted.
|
||||
void worker.terminate();
|
||||
fn();
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error("ELK layout timed out"))),
|
||||
timeoutMs,
|
||||
);
|
||||
worker.once("message", (msg: { ok?: boolean; laid?: ElkGraph; error?: string }) => {
|
||||
finish(() =>
|
||||
msg?.ok
|
||||
? resolve(msg.laid as ElkGraph)
|
||||
: reject(new Error(msg?.error ?? "ELK layout failed")),
|
||||
);
|
||||
});
|
||||
worker.once("error", (err) => finish(() => reject(err)));
|
||||
worker.once("exit", (code) => {
|
||||
// A clean exit after we already settled is normal (terminate()); only an
|
||||
// unexpected early exit while still pending is a failure.
|
||||
if (settled) return;
|
||||
finish(() => reject(new Error(`ELK worker exited early (code ${code})`)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an ELK layered layout to a drawio input and return a full mxGraphModel
|
||||
* string with rewritten geometry. Accepts the same three input forms as
|
||||
* drawioCreate (a bare model, an <mxfile>, or a <mxCell> list). Async because
|
||||
* elkjs' layout() is promise-based. On any layout failure the ORIGINAL
|
||||
* (normalized) model is returned unchanged — layout is best-effort polish, never
|
||||
* a reason to fail the write.
|
||||
* the layout runs on a worker thread. On any layout failure (including a
|
||||
* terminate-on-timeout) the ORIGINAL (normalized) model is returned unchanged —
|
||||
* layout is best-effort polish, never a reason to fail the write.
|
||||
*/
|
||||
export async function applyElkLayout(inputXml: string): Promise<string> {
|
||||
const modelXml = normalizeInput(inputXml);
|
||||
@@ -150,26 +205,14 @@ export async function applyElkLayout(inputXml: string): Promise<string> {
|
||||
};
|
||||
|
||||
let laid: ElkGraph;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
// elkjs ships a CJS default export whose interop shape varies across
|
||||
// module systems; resolve the real constructor at runtime, then cast (the
|
||||
// runtime call is verified — see the layout unit test).
|
||||
const Ctor: any = (ELK as any).default ?? ELK;
|
||||
const elk = new Ctor();
|
||||
// Race the layout against a wall-clock timeout so a graph that is under the
|
||||
// node/edge caps but still pathologically slow can never wedge the server.
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("ELK layout timed out")),
|
||||
ELK_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
laid = (await Promise.race([elk.layout(graph as any), timeout])) as ElkGraph;
|
||||
// Run the (synchronous-under-the-hood) ELK pass on a worker thread so the
|
||||
// main event loop is never blocked, and enforce the wall-clock ceiling by
|
||||
// terminating that worker on timeout. A graph under the node/edge caps but
|
||||
// still pathologically slow is hard-killed instead of wedging anything.
|
||||
laid = await layoutInWorker(graph, resolveElkTimeoutMs());
|
||||
} catch {
|
||||
return modelXml; // best-effort: keep the model as-is on timeout or ELK failure
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Collect computed geometry per node id (coords are parent-relative already).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Worker-thread entry for the ELK layered layout (issue #486, commit 1).
|
||||
//
|
||||
// elkjs' layout() returns a Promise but runs the actual crossing-minimisation
|
||||
// SYNCHRONOUSLY — it blocks whatever thread it runs on for the whole pass. On
|
||||
// the in-app MCP host that thread used to be the main NestJS event loop, so a
|
||||
// pathological graph at the node/edge cap could wedge ALL HTTP/SSE/loopback
|
||||
// traffic while it churned. Running it HERE, on a dedicated worker thread, keeps
|
||||
// the main loop free; the parent enforces the wall-clock timeout by calling
|
||||
// `worker.terminate()` — the only way to interrupt synchronous JS — since the
|
||||
// in-process `setTimeout` race the parent used before could never fire while the
|
||||
// same thread was blocked inside elkjs.
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
|
||||
interface WorkerInput {
|
||||
graph: unknown;
|
||||
}
|
||||
|
||||
const { graph } = (workerData ?? {}) as WorkerInput;
|
||||
|
||||
// elkjs ships a CJS default export whose interop shape varies across module
|
||||
// systems; resolve the real constructor at runtime (same as the parent did).
|
||||
const Ctor: any = (ELK as any).default ?? ELK;
|
||||
const elk = new Ctor();
|
||||
|
||||
elk
|
||||
.layout(graph as any)
|
||||
.then((laid: unknown) => {
|
||||
parentPort?.postMessage({ ok: true, laid });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
parentPort?.postMessage({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// Pure Mermaid `flowchart` -> graph-JSON parser for `drawioFromMermaid` (issue
|
||||
// #425, stage 3, OPTIONAL). The escape clause in the issue: convert WITHOUT
|
||||
// Electron/draw.io-CLI, so a pure text parser only. It handles the common wiki
|
||||
// flowchart subset — node shapes, labelled/dashed edges, subgraphs (-> groups),
|
||||
// and the direction header — and emits a Graph the drawioFromGraph pipeline
|
||||
// renders as an EDITABLE draw.io diagram. Anything beyond flowchart (sequence /
|
||||
// class / state) throws a clear error so the model falls back to drawioFromGraph.
|
||||
//
|
||||
// DELIBERATELY NARROW: this is not a full Mermaid grammar (Mermaid's own parser
|
||||
// is a 100KB+ browser dependency). It covers `flowchart`/`graph` with the node
|
||||
// shapes and edge arrows that show up in practice; unusual syntax is skipped
|
||||
// rather than mis-parsed, and a diagram that yields no nodes throws.
|
||||
|
||||
import type { Graph, GraphNode, GraphEdge, GraphGroup } from "./drawio-graph.js";
|
||||
|
||||
export class MermaidParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`drawioFromMermaid: ${message}`);
|
||||
this.name = "MermaidParseError";
|
||||
}
|
||||
}
|
||||
|
||||
// Input-size bounds applied BEFORE parsing. Without them a pathological mermaid
|
||||
// string (e.g. 300000 connection lines, or 20000 nested `subgraph`s) builds a
|
||||
// huge intermediate node/edge/group structure that OOM-crashes the worker — the
|
||||
// downstream validateGraph caps in drawio-graph can't help because the parser
|
||||
// exhausts the heap constructing the intermediate FIRST. These caps reject the
|
||||
// over-limit input fast, before a single line is parsed.
|
||||
const MAX_MERMAID_CHARS = 200_000; // ~200 KB of source is far beyond any real diagram.
|
||||
const MAX_MERMAID_LINES = 20_000;
|
||||
const MAX_MERMAID_GROUPS = 500; // parity with drawio-graph's MAX_GRAPH_GROUPS.
|
||||
// Per connection line, the number of chained nodes we will expand (`A-->B-->C`).
|
||||
const MAX_CHAIN_NODES = 500;
|
||||
|
||||
const DIRECTIONS: Record<string, Graph["direction"]> = {
|
||||
LR: "LR",
|
||||
RL: "RL",
|
||||
TB: "TB",
|
||||
TD: "TB",
|
||||
BT: "BT",
|
||||
};
|
||||
|
||||
/**
|
||||
* Node-shape delimiters -> a semantic `kind`. Mermaid encodes shape in the
|
||||
* bracket style; we map the common ones to the palette kinds so the diagram is
|
||||
* colored meaningfully (a decision/diamond -> queue, a database cylinder -> db,
|
||||
* a rounded/stadium -> service, a subroutine/hexagon -> gateway, default rect ->
|
||||
* service). The label text lives between the delimiters.
|
||||
*/
|
||||
interface ShapeDef {
|
||||
open: string;
|
||||
close: string;
|
||||
kind: string;
|
||||
}
|
||||
// Order matters: longer/multi-char delimiters first so "([" beats "(".
|
||||
const SHAPES: ShapeDef[] = [
|
||||
{ open: "([", close: "])", kind: "service" }, // stadium
|
||||
{ open: "[[", close: "]]", kind: "gateway" }, // subroutine
|
||||
{ open: "[(", close: ")]", kind: "db" }, // cylinder-ish / database
|
||||
{ open: "((", close: "))", kind: "external" }, // circle
|
||||
{ open: "{{", close: "}}", kind: "gateway" }, // hexagon
|
||||
{ open: "[", close: "]", kind: "service" }, // rectangle
|
||||
{ open: "(", close: ")", kind: "service" }, // rounded
|
||||
{ open: "{", close: "}", kind: "queue" }, // rhombus / decision
|
||||
{ open: ">", close: "]", kind: "external" }, // asymmetric flag
|
||||
];
|
||||
|
||||
/** Strip Mermaid label quoting/escapes and normalise whitespace. */
|
||||
function cleanLabel(raw: string): string {
|
||||
let s = raw.trim();
|
||||
if (
|
||||
(s.startsWith('"') && s.endsWith('"')) ||
|
||||
(s.startsWith("'") && s.endsWith("'"))
|
||||
) {
|
||||
s = s.slice(1, -1);
|
||||
}
|
||||
return s.replace(/<br\s*\/?>/gi, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** A single edge-arrow spec: its regex and the resulting edge `kind`. */
|
||||
interface ArrowDef {
|
||||
re: RegExp;
|
||||
kind: string;
|
||||
}
|
||||
// Dotted arrows (`-.->`) -> async; thick (`==>`) stay sync; normal `-->`/`---`.
|
||||
// Each captures an optional `|label|` OR inline label between the two arrow
|
||||
// halves. Applied to the segment between two node tokens.
|
||||
const ARROWS: ArrowDef[] = [
|
||||
{ re: /-\.->|-\.-/, kind: "async" },
|
||||
{ re: /==>|===/, kind: "sync" },
|
||||
{ re: /-->|---/, kind: "sync" },
|
||||
];
|
||||
|
||||
interface ParsedRef {
|
||||
id: string;
|
||||
node?: GraphNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single node token like `A`, `A[Label]`, `db[(Orders)]`, `d{Choose}`.
|
||||
* Returns the id and, when the token declares a shape/label, a GraphNode.
|
||||
*/
|
||||
function parseNodeToken(token: string): ParsedRef | null {
|
||||
const t = token.trim();
|
||||
if (t === "") return null;
|
||||
for (const shape of SHAPES) {
|
||||
const oi = t.indexOf(shape.open);
|
||||
if (oi <= 0) continue;
|
||||
if (!t.endsWith(shape.close)) continue;
|
||||
const id = t.slice(0, oi).trim();
|
||||
const label = cleanLabel(t.slice(oi + shape.open.length, t.length - shape.close.length));
|
||||
if (!id) return null;
|
||||
return { id, node: { id, label: label || id, kind: shape.kind } };
|
||||
}
|
||||
// Bare id (no shape declared here — may be defined elsewhere).
|
||||
if (/^[A-Za-z0-9_.-]+$/.test(t)) return { id: t };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a connection line into [leftToken, arrowSegment, rightToken]. Returns
|
||||
* null if the line has no arrow. The arrow segment may embed a label as
|
||||
* `-->|text|` or `-- text -->`.
|
||||
*/
|
||||
function splitConnection(
|
||||
line: string,
|
||||
): { left: string; right: string; kind: string; label?: string } | null {
|
||||
for (const arrow of ARROWS) {
|
||||
// Find the arrow occurrence. Support a mid-arrow label: `A -- text --> B`.
|
||||
const m = arrow.re.exec(line);
|
||||
if (!m) continue;
|
||||
const idx = m.index;
|
||||
let left = line.slice(0, idx).trim();
|
||||
let rest = line.slice(idx + m[0].length).trim();
|
||||
let label: string | undefined;
|
||||
// Pipe label: `-->|HTTPS| B`.
|
||||
const pipe = /^\|([^|]*)\|\s*(.*)$/.exec(rest);
|
||||
if (pipe) {
|
||||
label = cleanLabel(pipe[1]);
|
||||
rest = pipe[2].trim();
|
||||
}
|
||||
// Mid-arrow label on the left side: `A -- text` before the arrow half.
|
||||
const midLeft = /^(.*?)\s*--\s*(.+)$/.exec(left);
|
||||
if (!label && midLeft && /-\.|--|==/.test(line.slice(0, idx))) {
|
||||
// Only treat as a label when there's clearly text after `--`.
|
||||
if (!/[\[\](){}]/.test(midLeft[2])) {
|
||||
left = midLeft[1].trim();
|
||||
label = cleanLabel(midLeft[2]);
|
||||
}
|
||||
}
|
||||
if (!left || !rest) return null;
|
||||
return { left, right: rest, kind: arrow.kind, label };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Mermaid flowchart text into a Graph. Handles the header
|
||||
* (`flowchart LR` / `graph TD`), `subgraph <id>[title] … end` blocks (-> groups),
|
||||
* node declarations, and connection lines. Throws MermaidParseError for a
|
||||
* non-flowchart diagram or when nothing parses.
|
||||
*/
|
||||
export function mermaidToGraph(mermaid: string): Graph {
|
||||
if (typeof mermaid !== "string" || mermaid.trim() === "") {
|
||||
throw new MermaidParseError("empty mermaid input");
|
||||
}
|
||||
// Size guards FIRST — bound the raw input before building any intermediate.
|
||||
if (mermaid.length > MAX_MERMAID_CHARS) {
|
||||
throw new MermaidParseError(
|
||||
`input is ${mermaid.length} chars (max ${MAX_MERMAID_CHARS}); split the diagram or use drawioFromGraph`,
|
||||
);
|
||||
}
|
||||
const rawLines = mermaid.split(/\r?\n/);
|
||||
if (rawLines.length > MAX_MERMAID_LINES) {
|
||||
throw new MermaidParseError(
|
||||
`input has ${rawLines.length} lines (max ${MAX_MERMAID_LINES}); split the diagram or use drawioFromGraph`,
|
||||
);
|
||||
}
|
||||
const nodes = new Map<string, GraphNode>();
|
||||
const groups: GraphGroup[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
let direction: Graph["direction"] = "LR";
|
||||
let sawHeader = false;
|
||||
|
||||
// Stack of active subgraph ids (nesting); the top is the current group.
|
||||
const groupStack: string[] = [];
|
||||
let anonGroup = 0;
|
||||
|
||||
const ensureNode = (ref: ParsedRef) => {
|
||||
const existing = nodes.get(ref.id);
|
||||
if (ref.node) {
|
||||
if (existing) {
|
||||
// Fill in a label/kind if this token declared a shape and the prior didn't.
|
||||
if (existing.label === existing.id && ref.node.label !== ref.node.id)
|
||||
existing.label = ref.node.label;
|
||||
if (!existing.kind) existing.kind = ref.node.kind;
|
||||
} else {
|
||||
nodes.set(ref.id, { ...ref.node });
|
||||
}
|
||||
} else if (!existing) {
|
||||
nodes.set(ref.id, { id: ref.id, label: ref.id, kind: "service" });
|
||||
}
|
||||
// Assign to the current subgraph if inside one and not yet grouped.
|
||||
const cur = groupStack[groupStack.length - 1];
|
||||
const n = nodes.get(ref.id)!;
|
||||
if (cur && n.group == null) n.group = cur;
|
||||
};
|
||||
|
||||
for (const raw of rawLines) {
|
||||
let line = raw.trim();
|
||||
if (line === "" || line.startsWith("%%")) continue; // blank / comment
|
||||
|
||||
// Header.
|
||||
const header = /^(flowchart|graph)\s+([A-Za-z]{2})\b/.exec(line);
|
||||
if (header) {
|
||||
sawHeader = true;
|
||||
const dir = DIRECTIONS[header[2].toUpperCase()];
|
||||
if (dir) direction = dir;
|
||||
continue;
|
||||
}
|
||||
if (/^(sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|journey)\b/.test(line)) {
|
||||
throw new MermaidParseError(
|
||||
`only 'flowchart'/'graph' is supported (got '${line.split(/\s+/)[0]}'); use drawioFromGraph instead`,
|
||||
);
|
||||
}
|
||||
|
||||
// Subgraph open: `subgraph id [Title]` or `subgraph Title`.
|
||||
const sg = /^subgraph\s+(.+)$/.exec(line);
|
||||
if (sg) {
|
||||
const spec = sg[1].trim();
|
||||
let id: string;
|
||||
let label: string;
|
||||
const bracket = /^([A-Za-z0-9_.-]+)\s*\[(.+)\]$/.exec(spec);
|
||||
if (bracket) {
|
||||
id = bracket[1];
|
||||
label = cleanLabel(bracket[2]);
|
||||
} else if (/^[A-Za-z0-9_.-]+$/.test(spec)) {
|
||||
id = spec;
|
||||
label = spec;
|
||||
} else {
|
||||
id = `sg${anonGroup++}`;
|
||||
label = cleanLabel(spec);
|
||||
}
|
||||
if (!groups.some((g) => g.id === id)) {
|
||||
if (groups.length >= MAX_MERMAID_GROUPS) {
|
||||
throw new MermaidParseError(
|
||||
`too many subgraphs (max ${MAX_MERMAID_GROUPS}); use drawioFromGraph for a diagram this large`,
|
||||
);
|
||||
}
|
||||
groups.push({ id, label, kind: "group" });
|
||||
}
|
||||
groupStack.push(id);
|
||||
continue;
|
||||
}
|
||||
if (/^end\b/.test(line)) {
|
||||
groupStack.pop();
|
||||
continue;
|
||||
}
|
||||
// `direction LR` inside a subgraph — apply to the top-level direction.
|
||||
const innerDir = /^direction\s+([A-Za-z]{2})\b/.exec(line);
|
||||
if (innerDir) {
|
||||
const dir = DIRECTIONS[innerDir[1].toUpperCase()];
|
||||
if (dir) direction = dir;
|
||||
continue;
|
||||
}
|
||||
// Style/class/click directives: ignore (no visual mapping in our palette).
|
||||
if (/^(style|classDef|class|click|linkStyle)\b/.test(line)) continue;
|
||||
|
||||
// Strip a trailing semicolon.
|
||||
if (line.endsWith(";")) line = line.slice(0, -1).trim();
|
||||
|
||||
// Connection line (possibly chained: A --> B --> C).
|
||||
const conn = splitConnection(line);
|
||||
if (conn) {
|
||||
// Handle a simple chain by re-splitting the right side.
|
||||
let leftTok = conn.left;
|
||||
let seg: typeof conn | null = conn;
|
||||
let guard = 0;
|
||||
while (seg) {
|
||||
if (guard++ >= MAX_CHAIN_NODES) {
|
||||
// Don't silently drop the tail of an over-long chain — surface it so
|
||||
// the model knows the diagram was too large rather than getting a
|
||||
// quietly-truncated result.
|
||||
throw new MermaidParseError(
|
||||
`a single connection chain exceeds ${MAX_CHAIN_NODES} nodes; split it or use drawioFromGraph`,
|
||||
);
|
||||
}
|
||||
const leftRef = parseNodeToken(leftTok);
|
||||
// The right side may itself contain another arrow (a chain).
|
||||
const nextSeg = splitConnection(seg.right);
|
||||
const rightTokenStr = nextSeg ? seg.right.slice(0, splitIndex(seg.right)) : seg.right;
|
||||
const rightRef = parseNodeToken(nextSeg ? nextSeg.left : seg.right);
|
||||
if (leftRef && rightRef) {
|
||||
ensureNode(leftRef);
|
||||
ensureNode(rightRef);
|
||||
edges.push({
|
||||
from: leftRef.id,
|
||||
to: rightRef.id,
|
||||
label: seg.label,
|
||||
kind: seg.kind,
|
||||
});
|
||||
}
|
||||
if (!nextSeg) break;
|
||||
leftTok = nextSeg.left;
|
||||
seg = nextSeg;
|
||||
void rightTokenStr;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standalone node declaration `A[Label]` OR a bare member ref `C` inside a
|
||||
// subgraph (which claims that node for the current group).
|
||||
const nodeRef = parseNodeToken(line);
|
||||
if (nodeRef && (nodeRef.node || groupStack.length > 0)) {
|
||||
ensureNode(nodeRef);
|
||||
continue;
|
||||
}
|
||||
// Unknown line: skip silently (robustness over strictness).
|
||||
}
|
||||
|
||||
if (!sawHeader && nodes.size === 0) {
|
||||
throw new MermaidParseError(
|
||||
"input does not look like a mermaid flowchart (no 'flowchart'/'graph' header and no nodes)",
|
||||
);
|
||||
}
|
||||
if (nodes.size === 0) {
|
||||
throw new MermaidParseError("no nodes parsed from the flowchart");
|
||||
}
|
||||
|
||||
const graph: Graph = {
|
||||
nodes: Array.from(nodes.values()),
|
||||
direction,
|
||||
};
|
||||
if (groups.length > 0) graph.groups = groups;
|
||||
if (edges.length > 0) graph.edges = edges;
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Index of the first arrow in a segment (for chain splitting). */
|
||||
function splitIndex(s: string): number {
|
||||
let best = -1;
|
||||
for (const arrow of ARROWS) {
|
||||
const m = arrow.re.exec(s);
|
||||
if (m && (best === -1 || m.index < best)) best = m.index;
|
||||
}
|
||||
return best === -1 ? s.length : best;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Semantic color/line presets for the graph tools (issue #425, stage 3). The
|
||||
// PALETTE is DATA (packages/mcp/data/drawio-presets.json), not code: a node
|
||||
// `kind` maps to a { fillColor, strokeColor, fontColor } slot and an edge `kind`
|
||||
// maps to line-style props, per named preset (`default` / `dark` /
|
||||
// `colorblind-safe`). This module only loads that data and turns a slot into a
|
||||
// draw.io style fragment. The INVARIANT of the graph tools is that the model
|
||||
// never sees a style string — it names a `kind`, the server picks the slot.
|
||||
//
|
||||
// Loading mirrors drawio-shapes.ts: the JSON is read once via `import.meta.url`
|
||||
// relative to the built module. That is why this module (and drawio-graph.ts
|
||||
// which imports it) is reached ONLY through client.ts's ESM build and never
|
||||
// value-imported into the zod-agnostic tool-specs.ts (which the in-app server
|
||||
// type-checks under module:commonjs, where `import.meta` is a TS1343 error).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** A node color slot: the three draw.io color values for a `kind`. */
|
||||
export interface NodeSlot {
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
fontColor: string;
|
||||
}
|
||||
|
||||
/** An edge line style: the extra style props appended for an edge `kind`. */
|
||||
export interface EdgeStyle {
|
||||
props: string;
|
||||
}
|
||||
|
||||
export interface PresetData {
|
||||
canvasDark: boolean;
|
||||
okabeIto?: string[];
|
||||
nodes: Record<string, NodeSlot>;
|
||||
edges: Record<string, EdgeStyle>;
|
||||
edgeDefault: { strokeColor: string; fontColor: string };
|
||||
group: { strokeColor: string; fontColor: string };
|
||||
}
|
||||
|
||||
interface PresetsFile {
|
||||
presets: Record<string, PresetData>;
|
||||
}
|
||||
|
||||
/** The three shipped preset names. */
|
||||
export const PRESET_NAMES = ["default", "dark", "colorblind-safe"] as const;
|
||||
export type PresetName = (typeof PRESET_NAMES)[number];
|
||||
|
||||
/** Every node `kind` the base palette defines (also the generic-shape kinds). */
|
||||
export const NODE_KINDS = [
|
||||
"service",
|
||||
"db",
|
||||
"queue",
|
||||
"gateway",
|
||||
"error",
|
||||
"external",
|
||||
"security",
|
||||
] as const;
|
||||
export type NodeKind = (typeof NODE_KINDS)[number];
|
||||
|
||||
/** Edge `kind`s the palette styles; anything else falls back to `sync`. */
|
||||
export const EDGE_KINDS = ["sync", "async", "error"] as const;
|
||||
export type EdgeKind = (typeof EDGE_KINDS)[number];
|
||||
|
||||
let _presets: Record<string, PresetData> | null = null;
|
||||
|
||||
function presetsPath(): URL {
|
||||
// build/lib/drawio-presets.js -> ../../data/drawio-presets.json
|
||||
return new URL("../../data/drawio-presets.json", import.meta.url);
|
||||
}
|
||||
|
||||
/** Load + parse the bundled preset table once, then cache it. */
|
||||
export function loadPresets(): Record<string, PresetData> {
|
||||
if (_presets) return _presets;
|
||||
const json = readFileSync(presetsPath(), "utf-8");
|
||||
const parsed = JSON.parse(json) as PresetsFile;
|
||||
_presets = parsed.presets;
|
||||
return _presets;
|
||||
}
|
||||
|
||||
/** Resolve a preset by name, defaulting to `default` for an unknown name. */
|
||||
export function getPreset(name?: string): PresetData {
|
||||
const presets = loadPresets();
|
||||
if (name && presets[name]) return presets[name];
|
||||
return presets["default"];
|
||||
}
|
||||
|
||||
/** The slot for a node `kind` in a preset, falling back to `service`. */
|
||||
export function nodeSlot(preset: PresetData, kind?: string): NodeSlot {
|
||||
if (kind && preset.nodes[kind]) return preset.nodes[kind];
|
||||
return preset.nodes["service"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the draw.io style string for a GENERIC (no-icon) node of a given kind.
|
||||
* A rounded rectangle carrying the slot's fill/stroke/font. `whiteSpace=wrap`
|
||||
* and `html=1` let a long label wrap inside the shape (the assembler also sizes
|
||||
* the shape to the label, so the linter's label-overflow warning never fires).
|
||||
*/
|
||||
export function genericNodeStyle(preset: PresetData, kind?: string): string {
|
||||
const s = nodeSlot(preset, kind);
|
||||
return (
|
||||
`rounded=1;whiteSpace=wrap;html=1;` +
|
||||
`fillColor=${s.fillColor};strokeColor=${s.strokeColor};fontColor=${s.fontColor};`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay the preset's node slot colors onto a resolved ICON style-string
|
||||
* (from the shape catalog). An AWS/Azure icon carries its OWN mandatory
|
||||
* fill/stroke (the category color / white outline) that MUST NOT be recolored,
|
||||
* so for an icon we only ensure a readable fontColor when the preset is dark;
|
||||
* otherwise the icon style is returned verbatim. Keeping the icon's own colors
|
||||
* is deliberate: recoloring an AWS service icon breaks its category semantics.
|
||||
*/
|
||||
export function iconNodeStyle(preset: PresetData, iconStyle: string): string {
|
||||
if (!preset.canvasDark) return iconStyle;
|
||||
// On a dark canvas an icon's fontColor is usually a dark ink that vanishes;
|
||||
// append a light fontColor (icons put their label BELOW the glyph, so this
|
||||
// only affects the caption, never the glyph fill).
|
||||
if (/fontColor=/.test(iconStyle)) {
|
||||
return iconStyle.replace(/fontColor=[^;]*/, "fontColor=#e0e0e0");
|
||||
}
|
||||
return iconStyle + (iconStyle.endsWith(";") ? "" : ";") + "fontColor=#e0e0e0;";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the draw.io style for an edge of a given `kind`. Base is an orthogonal
|
||||
* connector (edgeStyle=orthogonalEdgeStyle) with rounded corners and an open
|
||||
* arrowhead, plus the preset's default stroke/font, then the kind's extra props
|
||||
* (dashed / colored) overlaid. An unknown kind falls back to `sync` (solid).
|
||||
*/
|
||||
export function edgeStyle(preset: PresetData, kind?: string): string {
|
||||
const k = kind && preset.edges[kind] ? kind : "sync";
|
||||
const base =
|
||||
`edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;` +
|
||||
`strokeColor=${preset.edgeDefault.strokeColor};fontColor=${preset.edgeDefault.fontColor};`;
|
||||
return base + preset.edges[k].props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group (container) style: ALWAYS transparent (`fillColor=none;container=1;`)
|
||||
* per the spec, carrying the preset's group stroke/font. `dropTarget=1` marks it
|
||||
* a drop target in the editor; `verticalAlign=top;align=left;spacingLeft=8;` puts
|
||||
* the group label in the top-left like draw.io's own boundary containers.
|
||||
*/
|
||||
export function groupStyle(preset: PresetData): string {
|
||||
return (
|
||||
`rounded=0;whiteSpace=wrap;html=1;` +
|
||||
`fillColor=none;container=1;dropTarget=1;collapsible=0;` +
|
||||
`strokeColor=${preset.group.strokeColor};fontColor=${preset.group.fontColor};` +
|
||||
`verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;`
|
||||
);
|
||||
}
|
||||
@@ -83,16 +83,32 @@ export function filterComment(comment: any, markdownContent?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// Map one server search hit to the MCP output contract (#443):
|
||||
// { pageId, title, path, snippet, score }
|
||||
//
|
||||
// INVARIANT: the only page identifier exposed is `pageId` (the server `id`
|
||||
// UUID). The server also carries `slugId` — it is NEVER surfaced.
|
||||
//
|
||||
// GRACEFUL DEGRADATION: against a stock upstream server the opt-in lookup DTO
|
||||
// fields are stripped, so the response is the legacy FTS shape (no path/snippet/
|
||||
// score, a `highlight` + `rank` instead). We synthesize the contract from
|
||||
// whatever is present: `snippet` falls back to the FTS `highlight`, `score` to
|
||||
// the FTS `rank`, and `path` to [] (upstream has no path). This keeps the tool
|
||||
// usable even when the server has not been upgraded.
|
||||
export function filterSearchResult(result: any) {
|
||||
return {
|
||||
id: result.id,
|
||||
pageId: result.id,
|
||||
title: result.title,
|
||||
parentPageId: result.parentPageId,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
rank: result.rank,
|
||||
highlight: result.highlight,
|
||||
spaceId: result.space?.id,
|
||||
spaceName: result.space?.name,
|
||||
path: Array.isArray(result.path) ? result.path : [],
|
||||
snippet:
|
||||
typeof result.snippet === "string"
|
||||
? result.snippet
|
||||
: (result.highlight ?? ""),
|
||||
score:
|
||||
typeof result.score === "number"
|
||||
? result.score
|
||||
: typeof result.rank === "number"
|
||||
? result.rank
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
|
||||
const chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). This is the SINGLE source of truth for
|
||||
// "is this a canonical page UUID?" in the MCP: client.ts's resolvePageId
|
||||
// imports isUuid from here to decide whether a pageId already IS a UUID (and so
|
||||
// needs no /pages/info round-trip). page.repo.ts treats any non-UUID pageId as
|
||||
// a slugId; a 10-char nanoid slugId never contains dashes, so it can never be
|
||||
// misread as a UUID here.
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
// The returned promise carries the real result/rejection of `fn` and MUST be
|
||||
// awaited/handled by the caller; only the internal chaining tail swallows
|
||||
// errors (purely to gate ordering).
|
||||
@@ -17,6 +31,25 @@ export function withPageLock<T>(
|
||||
pageId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
// STRUCTURAL INVARIANT (issue #449, "resolve-then-lock"): the mutex key MUST
|
||||
// be the canonical page UUID, never a raw slugId. The whole write path relies
|
||||
// on the lock key AND the CollabSession cache key being the resolved UUID
|
||||
// (#260) — if a future write method forgot to call resolvePageId and locked
|
||||
// under a slugId, two writes to the same page would take DIFFERENT mutex keys
|
||||
// and silently lose serialization (clobbering live human edits). This was an
|
||||
// invariant enforced only by comments/convention; assert it in CODE so the
|
||||
// violation fails fast and loud at the lock instead of corrupting data in
|
||||
// prod. The centralizing helper (mutatePageContent/replacePageContent) already
|
||||
// guards a raw-input caller, but this backstop catches ANY path.
|
||||
if (!isUuid(pageId)) {
|
||||
throw new Error(
|
||||
`withPageLock: key must be a canonical page UUID, got '${pageId}'. ` +
|
||||
`The write path must resolvePageId(pageId) BEFORE locking so the ` +
|
||||
`mutex/CollabSession cache key is the UUID (invariant "resolve-then-` +
|
||||
`lock", #260/#449). A slugId or other non-UUID key would silently lose ` +
|
||||
`per-page serialization.`,
|
||||
);
|
||||
}
|
||||
// Wait for the previous op on this page; swallow its error so a failure does
|
||||
// not poison the queue for the next caller.
|
||||
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => {});
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
/**
|
||||
* Options for `buildPageTree`. Fully OPTIONAL so the existing call form
|
||||
* `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId,
|
||||
* title, children?}` output, no depth cut) unchanged.
|
||||
*
|
||||
* - `shape: "getTree"` — emit the #443 `getTree` output node shape
|
||||
* `{pageId, title, children?, hasChildren?}` instead of the lean
|
||||
* `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are
|
||||
* never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer).
|
||||
* - `maxDepth` — trim the built tree to this many levels (root nodes are
|
||||
* depth 1). Only meaningful together with `shape: "getTree"` (the lean shape
|
||||
* has no `hasChildren` to signal a cut). See the depth logic below.
|
||||
*/
|
||||
export interface BuildPageTreeOptions {
|
||||
shape?: "lean" | "getTree";
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
||||
* by `enumerateSpacePages`) into a nested tree.
|
||||
*
|
||||
* Input: a flat array of nodes. Each node is expected to carry at least
|
||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored),
|
||||
* plus a server `hasChildren` boolean used by the `getTree` shape below.
|
||||
*
|
||||
* Output: an array of ROOT nodes, each shaped as
|
||||
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
|
||||
* { id, slugId, title, children? }
|
||||
* where `children` is the array of child nodes (same shape, recursively). The
|
||||
* `children` key is OMITTED entirely when a node has no children — consistent
|
||||
@@ -13,6 +32,14 @@
|
||||
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
||||
* are intentionally dropped from the output).
|
||||
*
|
||||
* Output (`shape: "getTree"`, the #443 tool shape): each node is
|
||||
* { pageId, title, children?, hasChildren? }
|
||||
* — the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
|
||||
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
|
||||
* `hasChildren: true` is set ONLY on a node whose children exist on the server
|
||||
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
|
||||
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
|
||||
*
|
||||
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
||||
* parent id is actually present in the input. Otherwise — including a null /
|
||||
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
||||
@@ -26,18 +53,42 @@
|
||||
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
||||
* `position` sort last.
|
||||
*
|
||||
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
|
||||
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
|
||||
* <= N and drops the `children` of any node AT depth N. A node whose children
|
||||
* were dropped this way gets `hasChildren: true` when it actually had children
|
||||
* in the flat input (source of truth = the server `hasChildren` flag), so the
|
||||
* caller knows it can descend further with a follow-up `rootPageId` call. An
|
||||
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
|
||||
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
|
||||
*
|
||||
* Pure: no I/O, no network, deterministic.
|
||||
*/
|
||||
export function buildPageTree(nodes: any[]): any[] {
|
||||
type OutputNode = {
|
||||
export function buildPageTree(
|
||||
nodes: any[],
|
||||
options: BuildPageTreeOptions = {},
|
||||
): any[] {
|
||||
const getTreeShape = options.shape === "getTree";
|
||||
// A finite, positive cut only; anything else means "no cut".
|
||||
const maxDepth =
|
||||
typeof options.maxDepth === "number" &&
|
||||
Number.isFinite(options.maxDepth) &&
|
||||
options.maxDepth > 0
|
||||
? Math.floor(options.maxDepth)
|
||||
: undefined;
|
||||
|
||||
type InternalNode = {
|
||||
id: string;
|
||||
// Retained internally for shaping; never all emitted at once.
|
||||
slugId: any;
|
||||
title: any;
|
||||
children?: OutputNode[];
|
||||
hasServerChildren: boolean;
|
||||
children?: InternalNode[];
|
||||
};
|
||||
|
||||
// Map id -> output node. Build the lean output shape up front.
|
||||
const byId = new Map<string, OutputNode>();
|
||||
// Map id -> internal node. Build up front; the output shape is projected at
|
||||
// the very end so the maxDepth cut can consult `hasServerChildren`.
|
||||
const byId = new Map<string, InternalNode>();
|
||||
// Preserve the original position string for sorting (kept off the output).
|
||||
const positionById = new Map<string, string | undefined>();
|
||||
|
||||
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
|
||||
id: node.id,
|
||||
slugId: node.slugId,
|
||||
title: node.title,
|
||||
hasServerChildren: node.hasChildren === true,
|
||||
});
|
||||
positionById.set(node.id, node.position);
|
||||
}
|
||||
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
|
||||
}
|
||||
|
||||
roots.sort(byPosition);
|
||||
return roots.map((id) => byId.get(id)!);
|
||||
const rootNodes = roots.map((id) => byId.get(id)!);
|
||||
|
||||
// Project the internal nodes into the requested OUTPUT shape, applying the
|
||||
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
|
||||
const project = (node: InternalNode, depth: number): any => {
|
||||
if (getTreeShape) {
|
||||
const out: any = { pageId: node.id, title: node.title };
|
||||
const atCut = maxDepth !== undefined && depth >= maxDepth;
|
||||
if (!atCut && node.children && node.children.length > 0) {
|
||||
out.children = node.children.map((c) => project(c, depth + 1));
|
||||
} else if (atCut && node.hasServerChildren) {
|
||||
// Children exist on the server but were trimmed by maxDepth: signal it
|
||||
// so the caller can descend with a follow-up rootPageId call.
|
||||
out.hasChildren = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
|
||||
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
|
||||
if (node.children && node.children.length > 0) {
|
||||
out.children = node.children.map((c) => project(c, depth + 1));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return rootNodes.map((n) => project(n, 1));
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||
*/
|
||||
export const ROUTING_PROSE =
|
||||
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
||||
"READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
|
||||
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||
@@ -82,6 +82,8 @@ const TOOL_FAMILY: Record<string, Family> = {
|
||||
// READ
|
||||
search: "READ",
|
||||
listPages: "READ",
|
||||
getTree: "READ",
|
||||
getPageContext: "READ",
|
||||
listSpaces: "READ",
|
||||
getOutline: "READ",
|
||||
getNode: "READ",
|
||||
@@ -107,6 +109,9 @@ const TOOL_FAMILY: Record<string, Family> = {
|
||||
drawioGet: "EDIT",
|
||||
drawioCreate: "EDIT",
|
||||
drawioUpdate: "EDIT",
|
||||
drawioEditCells: "EDIT",
|
||||
drawioFromGraph: "EDIT",
|
||||
drawioFromMermaid: "EDIT",
|
||||
drawioShapes: "EDIT",
|
||||
drawioGuide: "EDIT",
|
||||
docmostTransform: "EDIT",
|
||||
@@ -152,7 +157,7 @@ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
|
||||
{
|
||||
name: "search",
|
||||
purpose:
|
||||
"full-text search for pages and content across the whole workspace.",
|
||||
"find pages by a fragment of a technical string (hybrid substring + full-text); returns each hit's path and a snippet.",
|
||||
},
|
||||
{
|
||||
name: "docmostTransform",
|
||||
|
||||
+334
-16
@@ -63,6 +63,8 @@ export type DocmostClientLike = Pick<
|
||||
| 'getSpaces'
|
||||
| 'listShares'
|
||||
| 'listPages'
|
||||
| 'getTree'
|
||||
| 'getPageContext'
|
||||
| 'getPage'
|
||||
| 'getPageJson'
|
||||
| 'getOutline'
|
||||
@@ -98,6 +100,9 @@ export type DocmostClientLike = Pick<
|
||||
| 'drawioGet'
|
||||
| 'drawioCreate'
|
||||
| 'drawioUpdate'
|
||||
| 'drawioEditCells'
|
||||
| 'drawioFromGraph'
|
||||
| 'drawioFromMermaid'
|
||||
| 'createComment'
|
||||
| 'resolveComment'
|
||||
>;
|
||||
@@ -877,11 +882,17 @@ export const SHARED_TOOL_SPECS = {
|
||||
inAppKey: 'getPage',
|
||||
description:
|
||||
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
||||
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
|
||||
'table/callout structure are approximated); for a lossless representation ' +
|
||||
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
||||
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
||||
'treat them as markup, not page text.',
|
||||
'its Markdown content. The converter is canonical (round-trips text and ' +
|
||||
'block structure), so this is sufficient for text edits; use the ' +
|
||||
'page-JSON read tool only when you need what Markdown cannot carry. The ' +
|
||||
'Markdown drops exactly: (1) block ids (not visible in Markdown); ' +
|
||||
'(2) resolved-comment anchors (hidden here; only active <span ' +
|
||||
'data-comment-id> anchors remain); (3) a fixed set of attributes with no ' +
|
||||
'Markdown representation — table-cell colspan/rowspan/colwidth/' +
|
||||
'backgroundColor/backgroundColorName, heading/paragraph indent, ' +
|
||||
'callout.icon, orderedList.type, and link internal/target/rel/class. ' +
|
||||
'Inline <span data-comment-id> tags in the markdown are comment highlight ' +
|
||||
'anchors — treat them as markup, not page text.',
|
||||
tier: 'core',
|
||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||
@@ -911,11 +922,13 @@ export const SHARED_TOOL_SPECS = {
|
||||
description:
|
||||
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
||||
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
||||
'nested tree.',
|
||||
'50, max 100) — use search for lookups in large spaces. tree:true (with ' +
|
||||
"spaceId) returns the space's full page hierarchy as a nested tree, but " +
|
||||
'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
|
||||
'maxDepth).',
|
||||
tier: 'core',
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
catalogLine:
|
||||
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
|
||||
buildShape: (z) => ({
|
||||
spaceId: z
|
||||
.string()
|
||||
@@ -948,6 +961,79 @@ export const SHARED_TOOL_SPECS = {
|
||||
),
|
||||
},
|
||||
|
||||
getTree: {
|
||||
mcpName: 'getTree',
|
||||
inAppKey: 'getTree',
|
||||
description:
|
||||
"Get a space's page hierarchy (or one subtree) as a nested tree in a " +
|
||||
'SINGLE request — completely and without loss. Each node is ' +
|
||||
'`{ pageId, title, children? }`; children are ordered as in the sidebar. ' +
|
||||
'Pass rootPageId to return only that page and its descendants (exactly ' +
|
||||
'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' +
|
||||
'depth 1, so maxDepth:1 returns only the roots); a node whose children ' +
|
||||
'were trimmed carries `hasChildren:true` so you can descend later with ' +
|
||||
'getTree(rootPageId=that page). Prefer this over listPages tree:true.',
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
"getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.",
|
||||
buildShape: (z) => ({
|
||||
spaceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The id of the space whose page tree to return.'),
|
||||
rootPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional page id: return only this page and its descendants (one root).',
|
||||
),
|
||||
maxDepth: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' +
|
||||
'roots; trimmed nodes carry hasChildren:true.',
|
||||
),
|
||||
}),
|
||||
execute: (client, { spaceId, rootPageId, maxDepth }) =>
|
||||
client.getTree(
|
||||
spaceId as string,
|
||||
rootPageId as string | undefined,
|
||||
maxDepth as number | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
getPageContext: {
|
||||
mcpName: 'getPageContext',
|
||||
inAppKey: 'getPageContext',
|
||||
description:
|
||||
'Given a pageId, get its LOCATION and immediate surroundings (metadata ' +
|
||||
'only, no page content) in one call — answers "where am I / what is ' +
|
||||
"around this page\". Returns `{ page: { pageId, title, spaceId }, " +
|
||||
'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' +
|
||||
'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' +
|
||||
'root down to the PARENT (the parent is its last element; a root page ' +
|
||||
'has `breadcrumbs: []`). `children` are the direct children in sidebar ' +
|
||||
'order, each flagged `hasChildren` so you know which can be expanded ' +
|
||||
'(descend with getTree(rootPageId=that child) or another getPageContext). ' +
|
||||
'Ids, titles and child order are consistent with getTree.',
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'The id of the page to locate (a pageId/UUID, or a slugId from a URL).',
|
||||
),
|
||||
}),
|
||||
execute: (client, { pageId }) =>
|
||||
client.getPageContext(pageId as string),
|
||||
},
|
||||
|
||||
createPage: {
|
||||
mcpName: 'createPage',
|
||||
inAppKey: 'createPage',
|
||||
@@ -1240,13 +1326,17 @@ export const SHARED_TOOL_SPECS = {
|
||||
inAppKey: 'exportPageMarkdown',
|
||||
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||
description:
|
||||
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
||||
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
||||
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
||||
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
||||
'round-trip that preserves everything, including comment highlights. ' +
|
||||
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
||||
'server on import.',
|
||||
'Export a page to a single self-contained Docmost-flavoured Markdown ' +
|
||||
'file (custom extensions): YAML-free meta header, body with inline ' +
|
||||
'comment anchors (resolved ones kept) and diagrams, and a trailing ' +
|
||||
'comments-thread block. Designed for a download -> edit body -> ' +
|
||||
'page-Markdown import round-trip; block ids regenerate and comment ' +
|
||||
'THREADS, though kept in the file, are not re-pushed to the server on ' +
|
||||
'import. The round-trip SILENTLY DROPS a fixed set of attributes with no ' +
|
||||
'Markdown representation — table-cell merge spans (colspan/rowspan), ' +
|
||||
'colwidth, backgroundColor/backgroundColorName, heading/paragraph indent, ' +
|
||||
'callout.icon, orderedList.type, and link internal/target/rel/class. Use ' +
|
||||
'the page-JSON tools if those must survive.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||
@@ -1926,6 +2016,234 @@ export const SHARED_TOOL_SPECS = {
|
||||
),
|
||||
},
|
||||
|
||||
drawioEditCells: {
|
||||
mcpName: 'drawioEditCells',
|
||||
inAppKey: 'drawioEditCells',
|
||||
description:
|
||||
'Make TARGETED, id-based edits to an existing draw.io diagram instead of ' +
|
||||
'resending the whole XML (a full-XML diff is fragile — draw.io reorders ' +
|
||||
'attributes). `operations` is an ordered list of: ' +
|
||||
'{ op:"add", xml:"<mxCell .../>" } (append a new cell), ' +
|
||||
'{ op:"update", cellId:"n3", xml:"<mxCell id=\\"n3\\" .../>" } (replace that ' +
|
||||
'cell; the id MUST stay the same), or { op:"delete", cellId:"n5" } — a ' +
|
||||
'delete CASCADES to the cell\'s container children AND to every edge whose ' +
|
||||
'source/target is deleted. Ids are STABLE across edits so diffs stay ' +
|
||||
'meaningful. `baseHash` is MANDATORY: pass the hash from the drawioGet you ' +
|
||||
'based the edit on; if the diagram changed since, the edit is refused with ' +
|
||||
'a conflict error — re-read with drawioGet and retry. The edited model goes ' +
|
||||
'through the same lint + quality-warning pipeline as drawioUpdate. `node` is ' +
|
||||
'the drawio node attrs.id or "#<index>". Use this to tweak a diagram (move ' +
|
||||
'or restyle a few cells, add/remove nodes); to (re)generate a whole diagram ' +
|
||||
'from a description use drawioFromGraph.' +
|
||||
DRAWIO_HARD_RULES,
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'drawioEditCells — id-based add/update/delete edits to a draw.io diagram (cascade delete).',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
node: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The drawio node attrs.id, or "#<index>" for a top-level block.'),
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
op: z.enum(['add', 'update', 'delete']),
|
||||
cellId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Target cell id (required for update/delete).'),
|
||||
xml: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The <mxCell> element (required for add/update).'),
|
||||
}),
|
||||
)
|
||||
.describe('Ordered add/update/delete operations keyed by cell id.'),
|
||||
baseHash: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The meta.hash from the drawioGet this edit is based on.'),
|
||||
}),
|
||||
execute: (client, { pageId, node, operations, baseHash }) =>
|
||||
client.drawioEditCells(
|
||||
pageId as string,
|
||||
node as string,
|
||||
operations as any,
|
||||
baseHash as string,
|
||||
),
|
||||
},
|
||||
|
||||
drawioFromGraph: {
|
||||
mcpName: 'drawioFromGraph',
|
||||
inAppKey: 'drawioFromGraph',
|
||||
description:
|
||||
'Build a draw.io diagram from a SEMANTIC graph — you describe nodes, groups ' +
|
||||
'and edges by MEANING and the server picks every coordinate, color and icon ' +
|
||||
'so the whole class of layout/icon mistakes (overlaps, edges through shapes, ' +
|
||||
'empty-box stencils) cannot happen. This is the PREFERRED tool for ' +
|
||||
'architecture / cloud / network diagrams. `graph` = { nodes:[{ id, label, ' +
|
||||
'kind?, icon?, group?, layer?, sameLayerAs?, pinned? }], groups?:[{ id, ' +
|
||||
'label, kind? }], edges?:[{ from, to, label?, kind? }] }. Node `kind` picks ' +
|
||||
'a palette color (service/db/queue/gateway/error/external/security); `icon` ' +
|
||||
'(e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos") resolves to the exact ' +
|
||||
'verified stencil — an unknown icon degrades to a labelled generic shape, ' +
|
||||
'never an empty box. Edge `kind` sets the line style (sync=solid, ' +
|
||||
'async=dashed, error=red-dashed). Groups are TRANSPARENT containers. ' +
|
||||
'`direction` (LR/RL/TB/BT) and `preset` (default/dark/colorblind-safe) tune ' +
|
||||
'the layout/palette. Layout hints: `layer` (column index), `sameLayerAs` ' +
|
||||
'(align two nodes), `pinned:{x,y}` (fix a node). `layout`: "full" (default, ' +
|
||||
'auto-place everything), "incremental" (with `node`: keep the existing ' +
|
||||
'diagram\'s coordinates, place only new cells), "none" (no auto-layout). The ' +
|
||||
'result reports { iconsResolved, iconsMissing } so you can verify all icons ' +
|
||||
'resolved. For standard flowcharts you can also write Mermaid and call ' +
|
||||
'drawioFromMermaid; for exotic/wireframe diagrams use raw XML via drawioCreate.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'drawioFromGraph — build a draw.io diagram from a semantic node/group/edge graph (server picks layout+icons).',
|
||||
buildShape: (z) => {
|
||||
const node = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
kind: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Palette slot: service/db/queue/gateway/error/external/security.',
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Icon ref, e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos".'),
|
||||
group: z.string().optional().describe('Id of the group (container) it sits in.'),
|
||||
layer: z.number().optional().describe('Layer/column index hint (>=0).'),
|
||||
sameLayerAs: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Put this node in the same layer as another node id.'),
|
||||
pinned: z
|
||||
.object({ x: z.number(), y: z.number() })
|
||||
.optional()
|
||||
.describe('Fix the node at these exact coordinates.'),
|
||||
});
|
||||
const group = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
kind: z.string().optional(),
|
||||
});
|
||||
const edge = z.object({
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
label: z.string().optional(),
|
||||
kind: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('sync (solid), async (dashed), error (red-dashed).'),
|
||||
});
|
||||
return {
|
||||
pageId: z.string().min(1),
|
||||
graph: z
|
||||
.object({
|
||||
nodes: z.array(node),
|
||||
groups: z.array(group).optional(),
|
||||
edges: z.array(edge).optional(),
|
||||
direction: z.enum(['LR', 'RL', 'TB', 'BT']).optional(),
|
||||
preset: z.enum(['default', 'dark', 'colorblind-safe']).optional(),
|
||||
})
|
||||
.describe('The semantic graph: nodes, groups, edges.'),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
.describe('Where to insert relative to the anchor.'),
|
||||
anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'),
|
||||
anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'),
|
||||
direction: z
|
||||
.enum(['LR', 'RL', 'TB', 'BT'])
|
||||
.optional()
|
||||
.describe('Layout direction (overrides graph.direction).'),
|
||||
preset: z
|
||||
.enum(['default', 'dark', 'colorblind-safe'])
|
||||
.optional()
|
||||
.describe('Color preset (overrides graph.preset).'),
|
||||
layout: z
|
||||
.enum(['none', 'full', 'incremental'])
|
||||
.optional()
|
||||
.describe(
|
||||
'"full" (default) auto-places all; "incremental" (with node) keeps ' +
|
||||
'existing coords and places only new cells; "none" no auto-layout.',
|
||||
),
|
||||
node: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'An existing diagram to (re)build into — required for layout:"incremental".',
|
||||
),
|
||||
};
|
||||
},
|
||||
execute: (
|
||||
client,
|
||||
{ pageId, graph, position, anchorNodeId, anchorText, direction, preset, layout, node },
|
||||
) =>
|
||||
client.drawioFromGraph(
|
||||
pageId as string,
|
||||
{
|
||||
position: position as 'before' | 'after' | 'append',
|
||||
anchorNodeId: anchorNodeId as string | undefined,
|
||||
anchorText: anchorText as string | undefined,
|
||||
},
|
||||
graph as any,
|
||||
direction as 'LR' | 'RL' | 'TB' | 'BT' | undefined,
|
||||
preset as string | undefined,
|
||||
layout as 'none' | 'full' | 'incremental' | undefined,
|
||||
node as string | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
drawioFromMermaid: {
|
||||
mcpName: 'drawioFromMermaid',
|
||||
inAppKey: 'drawioFromMermaid',
|
||||
description:
|
||||
'Convert Mermaid `flowchart` text into an EDITABLE draw.io diagram (LLMs ' +
|
||||
'write Mermaid reliably). Best for STANDARD flowcharts/decision trees: ' +
|
||||
'write the mermaid, the server parses it (pure parser — no browser/CLI), ' +
|
||||
'maps it to the same semantic pipeline as drawioFromGraph, and inserts a ' +
|
||||
'real draw.io diagram you can then refine with drawioEditCells. Node shapes ' +
|
||||
'map to palette colors (a `{decision}` -> yellow, a `[(db)]` -> green, etc.); ' +
|
||||
'`subgraph … end` becomes a transparent group; dotted `-.->` edges become ' +
|
||||
'dashed. ONLY flowchart/graph is supported — for sequence/class diagrams, or ' +
|
||||
'for cloud/architecture diagrams with real service icons, use drawioFromGraph ' +
|
||||
'instead. `where` positions the block like insertNode.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'drawioFromMermaid — turn Mermaid flowchart text into an editable draw.io diagram.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
mermaid: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Mermaid flowchart source (flowchart/graph LR|TB|...).'),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
.describe('Where to insert relative to the anchor.'),
|
||||
anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'),
|
||||
anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'),
|
||||
preset: z
|
||||
.enum(['default', 'dark', 'colorblind-safe'])
|
||||
.optional()
|
||||
.describe('Color preset.'),
|
||||
}),
|
||||
execute: (client, { pageId, mermaid, position, anchorNodeId, anchorText, preset }) =>
|
||||
client.drawioFromMermaid(
|
||||
pageId as string,
|
||||
{
|
||||
position: position as 'before' | 'after' | 'append',
|
||||
anchorNodeId: anchorNodeId as string | undefined,
|
||||
anchorText: anchorText as string | undefined,
|
||||
},
|
||||
mermaid as string,
|
||||
preset as string | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
drawioShapes: {
|
||||
mcpName: 'drawioShapes',
|
||||
inAppKey: 'drawioShapes',
|
||||
|
||||
@@ -316,7 +316,8 @@ async function main() {
|
||||
const [idA, idB, idC] = seedIds;
|
||||
|
||||
// patchNode: replace the middle paragraph; siblings' ids must be unchanged.
|
||||
await client.patchNode(nid, idB, mkPara(idB, "Bravo PATCHED."));
|
||||
// #413 XOR input: the raw ProseMirror node goes under the `node` key.
|
||||
await client.patchNode(nid, idB, { node: mkPara(idB, "Bravo PATCHED.") });
|
||||
await new Promise((r) => setTimeout(r, 16000));
|
||||
const afterPatch = (await client.getPageJson(nid)).content;
|
||||
const patchText = JSON.stringify(afterPatch);
|
||||
@@ -327,7 +328,7 @@ async function main() {
|
||||
// insertNode: place a new block after the first paragraph.
|
||||
await client.insertNode(
|
||||
nid,
|
||||
mkPara("nodeops-ins", "Inserted paragraph."),
|
||||
{ node: mkPara("nodeops-ins", "Inserted paragraph.") },
|
||||
{ position: "after", anchorNodeId: idA },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 16000));
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Unit tests for the collab-token reset on a Hocuspocus WS auth failure (#486).
|
||||
//
|
||||
// Before this fix the cached collab token (#435) was dropped ONLY on an HTTP
|
||||
// 401/403 (the REST interceptor + login()); a rejected collab-WEBSOCKET handshake
|
||||
// left the stale token in the cache, so every subsequent mutation re-presented
|
||||
// the SAME bad token for up to the collab-token TTL (minutes) with no self-heal.
|
||||
//
|
||||
// The fix wraps collab writes in `writeWithCollabAuthRetry`: when the write
|
||||
// rejects with the tagged collab-auth error (collab-session.ts's
|
||||
// onAuthenticationFailed), it invalidates the cached token and retries the write
|
||||
// ONCE with a force-refreshed token — symmetric to the HTTP-401 path.
|
||||
//
|
||||
// writeWithCollabAuthRetry / getCollabTokenWithReauth are protected in TS but
|
||||
// plain methods on the compiled build, so the tests call them directly (same
|
||||
// convention as collab-token-cache.test.mjs).
|
||||
import { test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
const ENV_KEY = "MCP_COLLAB_TOKEN_TTL_MS";
|
||||
afterEach(() => {
|
||||
delete process.env[ENV_KEY];
|
||||
});
|
||||
|
||||
// A counting provider that returns a distinct token each call so a cached
|
||||
// (reused) token is visibly the SAME string while a fresh mint is different.
|
||||
function countingProvider() {
|
||||
let n = 0;
|
||||
const fn = async () => {
|
||||
n++;
|
||||
return `provider-token-${n}`;
|
||||
};
|
||||
return {
|
||||
fn,
|
||||
get calls() {
|
||||
return n;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The tagged error collab-session.ts throws on a rejected WS handshake.
|
||||
function collabAuthError() {
|
||||
const err = new Error("Authentication failed for collaboration connection");
|
||||
err.collabAuthFailed = true;
|
||||
return err;
|
||||
}
|
||||
|
||||
test("a WS auth failure clears the cached token and retries the write with a FRESH one (#486)", async () => {
|
||||
process.env[ENV_KEY] = "300000"; // 5 min: the cache is warm across the burst.
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
// Warm the cache the way a real write would (mints provider-token-1).
|
||||
const initial = await client.getCollabTokenWithReauth();
|
||||
assert.equal(initial, "provider-token-1");
|
||||
assert.equal(p.calls, 1);
|
||||
|
||||
const tokensSeen = [];
|
||||
const write = async (token) => {
|
||||
tokensSeen.push(token);
|
||||
// The FIRST attempt (with the stale cached token) fails the WS handshake;
|
||||
// the retry (with a fresh token) succeeds.
|
||||
if (tokensSeen.length === 1) throw collabAuthError();
|
||||
return `written-with:${token}`;
|
||||
};
|
||||
|
||||
const result = await client.writeWithCollabAuthRetry(initial, write);
|
||||
|
||||
assert.equal(tokensSeen.length, 2, "write attempted exactly twice (one retry)");
|
||||
assert.equal(tokensSeen[0], "provider-token-1", "first attempt used the stale token");
|
||||
assert.equal(
|
||||
tokensSeen[1],
|
||||
"provider-token-2",
|
||||
"retry used a FRESH force-refreshed token, not the stale cached one",
|
||||
);
|
||||
assert.equal(result, "written-with:provider-token-2", "the retry's result wins");
|
||||
assert.equal(p.calls, 2, "exactly one extra mint for the retry — no loop");
|
||||
|
||||
// The cache now holds the fresh token, so a subsequent op reuses it (proving
|
||||
// the stale token was evicted and the fresh one cached, not re-minted).
|
||||
const next = await client.getCollabTokenWithReauth();
|
||||
assert.equal(next, "provider-token-2", "the fresh token replaced the stale cache");
|
||||
assert.equal(p.calls, 2, "served from cache — provider not re-invoked");
|
||||
});
|
||||
|
||||
test("a successful write is NOT retried and mints nothing extra", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
const initial = await client.getCollabTokenWithReauth(); // provider-token-1
|
||||
let attempts = 0;
|
||||
const result = await client.writeWithCollabAuthRetry(initial, async (token) => {
|
||||
attempts++;
|
||||
return `ok:${token}`;
|
||||
});
|
||||
|
||||
assert.equal(attempts, 1, "no retry on success");
|
||||
assert.equal(result, "ok:provider-token-1");
|
||||
assert.equal(p.calls, 1, "no extra mint");
|
||||
});
|
||||
|
||||
test("a NON-auth write error propagates unchanged (no reset, no retry)", async () => {
|
||||
process.env[ENV_KEY] = "300000";
|
||||
const p = countingProvider();
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
getCollabToken: p.fn,
|
||||
});
|
||||
|
||||
const initial = await client.getCollabTokenWithReauth(); // provider-token-1
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
client.writeWithCollabAuthRetry(initial, async () => {
|
||||
attempts++;
|
||||
throw new Error("collab connection closed before persist"); // NOT tagged.
|
||||
}),
|
||||
/closed before persist/,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 1, "a non-auth error is not retried");
|
||||
assert.equal(p.calls, 1, "the cache is untouched -> no fresh mint");
|
||||
|
||||
// Cache still holds the original token (was never invalidated).
|
||||
const still = await client.getCollabTokenWithReauth();
|
||||
assert.equal(still, "provider-token-1");
|
||||
assert.equal(p.calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// Contract tests for the stage-3 drawio client methods (issue #425):
|
||||
// drawioEditCells / drawioFromGraph / drawioFromMermaid. Same seam-override
|
||||
// pattern as drawio-tools.test.mjs: a DocmostClient subclass stubs the I/O seams
|
||||
// so the tool logic runs without a live Docmost / collab socket.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
import {
|
||||
buildDrawioSvg,
|
||||
normalizeXml,
|
||||
mxHash,
|
||||
decodeDrawioSvg,
|
||||
parseCells,
|
||||
} from "../../build/lib/drawio-xml.js";
|
||||
|
||||
const DRAWIO_SCHEMA_ATTRS = new Set([
|
||||
"src", "title", "alt", "width", "height", "size", "aspectRatio", "align", "attachmentId",
|
||||
]);
|
||||
function applyDrawioSchemaDrop(node) {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.type === "drawio" && node.attrs && typeof node.attrs === "object") {
|
||||
for (const key of Object.keys(node.attrs))
|
||||
if (!DRAWIO_SCHEMA_ATTRS.has(key)) delete node.attrs[key];
|
||||
}
|
||||
if (Array.isArray(node.content)) for (const c of node.content) applyDrawioSchemaDrop(c);
|
||||
}
|
||||
|
||||
function svgFor(model, bbox = { width: 400, height: 300 }) {
|
||||
return buildDrawioSvg(normalizeXml(model), "<g/>", bbox);
|
||||
}
|
||||
|
||||
function makeClient({ pageDoc, attachmentSvg } = {}) {
|
||||
const calls = { uploads: [], mutations: [] };
|
||||
class TestClient extends DocmostClient {
|
||||
async ensureAuthenticated() {}
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
async resolvePageId(pageId) {
|
||||
return `uuid-${pageId}`;
|
||||
}
|
||||
async getPageRaw(pageId) {
|
||||
return {
|
||||
id: pageId, slugId: "s", title: "P", spaceId: "sp",
|
||||
content: pageDoc ?? { type: "doc", content: [] },
|
||||
};
|
||||
}
|
||||
async uploadAttachmentBuffer(pageId, buffer, fileName) {
|
||||
const id = `att-${calls.uploads.length + 1}`;
|
||||
calls.uploads.push({ pageId, fileName, svg: buffer.toString("utf-8") });
|
||||
return { id, fileName, fileSize: buffer.length };
|
||||
}
|
||||
async fetchAttachmentText() {
|
||||
return attachmentSvg;
|
||||
}
|
||||
mutatePage(pageId, token, apiUrl, transform) {
|
||||
const clone = structuredClone(pageDoc ?? { type: "doc", content: [] });
|
||||
const doc = transform(clone);
|
||||
if (doc) applyDrawioSchemaDrop(doc);
|
||||
calls.mutations.push({ pageId, doc });
|
||||
return Promise.resolve({ doc, verify: { changed: doc != null } });
|
||||
}
|
||||
}
|
||||
const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
function findDrawio(node, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === "drawio") acc.push(node);
|
||||
if (Array.isArray(node.content)) for (const c of node.content) findDrawio(c, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// A stored diagram: a group with two children and an edge.
|
||||
const STORED =
|
||||
"<mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>" +
|
||||
'<mxCell id="grp" value="G" style="container=1;fillColor=none;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="0" y="0" width="300" height="200" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="a" value="A" style="rounded=1;" vertex="1" parent="grp">' +
|
||||
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="b" value="B" style="rounded=1;" vertex="1" parent="grp">' +
|
||||
'<mxGeometry x="10" y="90" width="80" height="40" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="e" style="" edge="1" parent="grp" source="a" target="b">' +
|
||||
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
|
||||
"</root></mxGraphModel>";
|
||||
|
||||
function drawioPageDoc() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
id: "d1", src: "/api/files/att-1/diagram.drawio.svg",
|
||||
attachmentId: "att-1", width: 400, height: 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- drawioEditCells --------------------------------------------------------
|
||||
|
||||
test("drawioEditCells: applies ops and repoints the node (current baseHash)", async () => {
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: drawioPageDoc(),
|
||||
attachmentSvg: svgFor(STORED),
|
||||
});
|
||||
const baseHash = mxHash(normalizeXml(STORED));
|
||||
const res = await client.drawioEditCells(
|
||||
"page1",
|
||||
"d1",
|
||||
[
|
||||
{
|
||||
op: "update",
|
||||
cellId: "a",
|
||||
xml:
|
||||
'<mxCell id="a" value="Renamed" style="rounded=1;" vertex="1" parent="grp">' +
|
||||
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>',
|
||||
},
|
||||
],
|
||||
baseHash,
|
||||
);
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(calls.uploads.length, 1);
|
||||
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||
const cells = parseCells(written);
|
||||
assert.equal(cells.find((c) => c.id === "a").value, "Renamed");
|
||||
assert.equal(cells.find((c) => c.id === "b").value, "B"); // untouched
|
||||
const n = findDrawio(calls.mutations[0].doc)[0];
|
||||
// The stub numbers uploads from 1; this edit is the first upload -> att-1.
|
||||
assert.equal(n.attrs.attachmentId, "att-1");
|
||||
});
|
||||
|
||||
test("drawioEditCells: delete of the container cascades to children + edge", async () => {
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: drawioPageDoc(),
|
||||
attachmentSvg: svgFor(STORED),
|
||||
});
|
||||
const baseHash = mxHash(normalizeXml(STORED));
|
||||
await client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "grp" }], baseHash);
|
||||
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||
const ids = parseCells(written).filter((c) => c.id !== "0" && c.id !== "1").map((c) => c.id);
|
||||
assert.deepEqual(ids, [], "grp + a + b + edge all cascaded away");
|
||||
});
|
||||
|
||||
test("drawioEditCells: stale baseHash -> conflict, no upload", async () => {
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: drawioPageDoc(),
|
||||
attachmentSvg: svgFor(STORED),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], "stale"),
|
||||
/conflict/,
|
||||
);
|
||||
assert.equal(calls.uploads.length, 0);
|
||||
});
|
||||
|
||||
test("drawioEditCells: baseHash is mandatory", async () => {
|
||||
const { client } = makeClient({ pageDoc: drawioPageDoc(), attachmentSvg: svgFor(STORED) });
|
||||
await assert.rejects(
|
||||
() => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], ""),
|
||||
/baseHash is mandatory/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- drawioFromGraph --------------------------------------------------------
|
||||
|
||||
test("drawioFromGraph: builds a diagram from a graph and inserts a node", async () => {
|
||||
const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] };
|
||||
const { client, calls } = makeClient({ pageDoc });
|
||||
const res = await client.drawioFromGraph(
|
||||
"page1",
|
||||
{ position: "append" },
|
||||
{
|
||||
nodes: [
|
||||
{ id: "api", label: "API", kind: "gateway", icon: "aws:api_gateway", group: "vpc" },
|
||||
{ id: "fn", label: "Handler", kind: "service", icon: "aws:lambda", group: "vpc" },
|
||||
{ id: "db", label: "Orders", kind: "db", icon: "aws:dynamodb" },
|
||||
],
|
||||
groups: [{ id: "vpc", label: "VPC" }],
|
||||
edges: [{ from: "api", to: "fn", kind: "sync" }, { from: "fn", to: "db", kind: "async" }],
|
||||
},
|
||||
"LR",
|
||||
"default",
|
||||
);
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.nodeId, "#1");
|
||||
assert.equal(res.iconsMissing.length, 0, `unresolved: ${res.iconsMissing}`);
|
||||
assert.equal(res.iconsResolved, 3);
|
||||
// The uploaded model decodes back and carries the group + nodes.
|
||||
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||
const cells = parseCells(written);
|
||||
assert.ok(cells.some((c) => c.id === "vpc"));
|
||||
assert.ok(cells.some((c) => c.id === "api"));
|
||||
// Group is transparent.
|
||||
const vpc = cells.find((c) => c.id === "vpc");
|
||||
assert.equal(vpc.styleMap.fillColor, "none");
|
||||
assert.equal(vpc.styleMap.container, "1");
|
||||
});
|
||||
|
||||
test("drawioFromGraph: an invalid graph throws before any upload", async () => {
|
||||
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||
await assert.rejects(
|
||||
() => client.drawioFromGraph("page1", { position: "append" }, { nodes: [] }),
|
||||
/non-empty/,
|
||||
);
|
||||
assert.equal(calls.uploads.length, 0);
|
||||
});
|
||||
|
||||
test("drawioFromGraph incremental into an existing node keeps prior coords", async () => {
|
||||
// The stored diagram has a,b at known coords; add a new node c incrementally.
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: drawioPageDoc(),
|
||||
attachmentSvg: svgFor(STORED),
|
||||
});
|
||||
const res = await client.drawioFromGraph(
|
||||
"page1",
|
||||
{ position: "append" },
|
||||
{
|
||||
nodes: [
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B" },
|
||||
{ id: "c", label: "C new" },
|
||||
],
|
||||
edges: [{ from: "b", to: "c" }],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
"incremental",
|
||||
"d1", // target the existing diagram
|
||||
);
|
||||
assert.equal(res.success, true);
|
||||
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||
const cells = parseCells(written);
|
||||
const a = cells.find((c) => c.id === "a");
|
||||
const b = cells.find((c) => c.id === "b");
|
||||
// Existing coords preserved (the stored a/b absolute coords from STORED).
|
||||
assert.equal(a.geometry.x, 10);
|
||||
assert.equal(a.geometry.y, 10);
|
||||
assert.equal(b.geometry.x, 10);
|
||||
assert.equal(b.geometry.y, 90);
|
||||
assert.ok(cells.some((c) => c.id === "c"), "new node c added");
|
||||
});
|
||||
|
||||
// --- drawioFromMermaid ------------------------------------------------------
|
||||
|
||||
test("drawioFromMermaid: converts a flowchart and inserts a diagram", async () => {
|
||||
const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] };
|
||||
const { client, calls } = makeClient({ pageDoc });
|
||||
const res = await client.drawioFromMermaid(
|
||||
"page1",
|
||||
{ position: "append" },
|
||||
"flowchart LR\n A[Start] --> B{Choose}\n B -->|yes| C[Done]\n B -->|no| D[Stop]",
|
||||
);
|
||||
assert.equal(res.success, true);
|
||||
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||
const cells = parseCells(written);
|
||||
for (const id of ["A", "B", "C", "D"]) {
|
||||
assert.ok(cells.some((c) => c.id === id), `node ${id} present`);
|
||||
}
|
||||
});
|
||||
|
||||
test("drawioFromMermaid: a non-flowchart is rejected, no upload", async () => {
|
||||
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||
await assert.rejects(
|
||||
() => client.drawioFromMermaid("page1", { position: "append" }, "sequenceDiagram\n A->>B: x"),
|
||||
/only 'flowchart'\/'graph' is supported/,
|
||||
);
|
||||
assert.equal(calls.uploads.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I /
|
||||
// what's around" read tool. A local http.createServer stands in for Docmost
|
||||
// (same harness style as pagination-cursor.test.mjs) so everything is
|
||||
// deterministic and offline.
|
||||
//
|
||||
// Contract pinned here:
|
||||
// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page
|
||||
// INCLUDED as the LAST element) + listSidebarPages (direct children).
|
||||
// - Split: last chain element -> `page`; the rest (root->parent) ->
|
||||
// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: [].
|
||||
// - children: {pageId, title, hasChildren} in sidebar order.
|
||||
// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`.
|
||||
// - A slugId input is resolved via /pages/info first (adds one request); a
|
||||
// UUID input short-circuits (stays at two requests).
|
||||
// - A bad/inaccessible pageId throws a CLEAR error, not an empty object.
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const openServers = [];
|
||||
async function spawn(handler) {
|
||||
const { server, baseURL } = await startServer(handler);
|
||||
openServers.push(server);
|
||||
return { server, baseURL };
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
await Promise.all(openServers.map((s) => closeServer(s)));
|
||||
});
|
||||
|
||||
function handleLogin(req, res) {
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip).
|
||||
const ROOT_UUID = "00000000-0000-4000-8000-000000000001";
|
||||
const MID_UUID = "00000000-0000-4000-8000-000000000002";
|
||||
const PAGE_UUID = "00000000-0000-4000-8000-000000000003";
|
||||
const CHILD_A = "00000000-0000-4000-8000-00000000000a";
|
||||
const CHILD_B = "00000000-0000-4000-8000-00000000000b";
|
||||
|
||||
// Build a breadcrumbs response as the server sends it: root->page order, page
|
||||
// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present
|
||||
// on the wire (they must NOT leak into the tool output).
|
||||
function breadcrumbsEnvelope(chain) {
|
||||
return { success: true, data: chain };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors
|
||||
// root->parent; children mapped {pageId,title,hasChildren} in order; no leak;
|
||||
// exactly two requests for a UUID input.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => {
|
||||
let breadcrumbReqs = 0;
|
||||
let sidebarReqs = 0;
|
||||
let infoReqs = 0;
|
||||
let breadcrumbBody = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/info") {
|
||||
infoReqs++;
|
||||
sendJson(res, 404, {});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
breadcrumbReqs++;
|
||||
breadcrumbBody = JSON.parse(raw || "{}");
|
||||
// root -> parent -> page (page LAST). slugId/icon/position on the wire.
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true },
|
||||
{ id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true },
|
||||
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sidebarReqs++;
|
||||
const body = JSON.parse(raw || "{}");
|
||||
assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID");
|
||||
assert.equal(body.spaceId, "sp1", "children scoped to the page's space");
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" },
|
||||
{ id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" },
|
||||
],
|
||||
meta: { hasNextPage: false, nextCursor: null },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext(PAGE_UUID);
|
||||
|
||||
assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)");
|
||||
assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request");
|
||||
assert.equal(sidebarReqs, 1, "exactly one sidebar request");
|
||||
assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID");
|
||||
|
||||
// page = the LAST chain element.
|
||||
assert.deepEqual(result.page, {
|
||||
pageId: PAGE_UUID,
|
||||
title: "Rack 12",
|
||||
spaceId: "sp1",
|
||||
});
|
||||
// breadcrumbs = root->parent (the chain minus the page itself).
|
||||
assert.deepEqual(result.breadcrumbs, [
|
||||
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||
{ pageId: MID_UUID, title: "Datacenter A" },
|
||||
]);
|
||||
// children mapped in order, hasChildren coerced to boolean.
|
||||
assert.deepEqual(result.children, [
|
||||
{ pageId: CHILD_A, title: "Servers", hasChildren: true },
|
||||
{ pageId: CHILD_B, title: "Network", hasChildren: false },
|
||||
]);
|
||||
|
||||
// No slugId anywhere in the output.
|
||||
const dump = JSON.stringify(result);
|
||||
assert.ok(!dump.includes("Slug"), "no slugId leaks into the output");
|
||||
assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: [].
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a root page has breadcrumbs: []", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
// A root page: the CTE returns only the page itself.
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext(ROOT_UUID);
|
||||
|
||||
assert.deepEqual(result.page, {
|
||||
pageId: ROOT_UUID,
|
||||
title: "Infrastructure",
|
||||
spaceId: "sp1",
|
||||
});
|
||||
assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors");
|
||||
assert.deepEqual(result.children, [], "no children");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3) A slugId input is resolved via /pages/info first (one extra request), then
|
||||
// breadcrumbs/sidebar use the resolved UUID.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a slugId input is resolved via /pages/info", async () => {
|
||||
let infoReqs = 0;
|
||||
let infoBody = null;
|
||||
let breadcrumbBody = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/info") {
|
||||
infoReqs++;
|
||||
infoBody = JSON.parse(raw || "{}");
|
||||
// getPageRaw: slugId -> canonical UUID.
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
breadcrumbBody = JSON.parse(raw || "{}");
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null },
|
||||
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext("pageSlug");
|
||||
|
||||
assert.equal(infoReqs, 1, "slugId resolved via one /pages/info");
|
||||
assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId");
|
||||
assert.deepEqual(
|
||||
breadcrumbBody,
|
||||
{ pageId: PAGE_UUID },
|
||||
"breadcrumbs posts the RESOLVED uuid, not the slugId",
|
||||
);
|
||||
assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID");
|
||||
assert.deepEqual(result.breadcrumbs, [
|
||||
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||
]);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression
|
||||
// on the #442 bug class — getPageContext must not re-introduce a cap).
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => {
|
||||
// 45 children spread over three cursor pages.
|
||||
const all = Array.from({ length: 45 }, (_, i) => ({
|
||||
id: `child-${i}`,
|
||||
slugId: `slug-${i}`,
|
||||
title: `Child ${i}`,
|
||||
parentPageId: PAGE_UUID,
|
||||
hasChildren: i % 2 === 0,
|
||||
}));
|
||||
const PAGES = {
|
||||
"": { items: all.slice(0, 20), nextCursor: "c1" },
|
||||
c1: { items: all.slice(20, 40), nextCursor: "c2" },
|
||||
c2: { items: all.slice(40), nextCursor: null },
|
||||
};
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: page.items,
|
||||
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext(PAGE_UUID);
|
||||
|
||||
assert.equal(result.children.length, 45, "all 45 children returned");
|
||||
const ids = result.children.map((c) => c.pageId);
|
||||
assert.equal(new Set(ids).size, 45, "no duplicate children");
|
||||
assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages");
|
||||
assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)");
|
||||
assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
// Server rejects an unknown/forbidden page.
|
||||
sendJson(res, 404, { message: "Page not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
await assert.rejects(
|
||||
() => client.getPageContext(PAGE_UUID),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, "throws an Error");
|
||||
return true;
|
||||
},
|
||||
"a 404 from breadcrumbs propagates as a thrown error, not a hollow {}",
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6) An empty breadcrumbs chain (should never happen — the endpoint always
|
||||
// includes the page itself) is treated as not-found, not a hollow {page:...}.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
sendJson(res, 200, breadcrumbsEnvelope([]));
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
await assert.rejects(
|
||||
() => client.getPageContext(PAGE_UUID),
|
||||
/not found or inaccessible/,
|
||||
"an empty chain is a clear error, not {}",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// Unit test: listPages tree mode must propagate the `truncated` flag (#486).
|
||||
//
|
||||
// enumerateSpacePages returns { pages, truncated } — truncated is true ONLY when
|
||||
// the stdio-fallback BFS hit its node cap (the primary /pages/tree path is
|
||||
// uncapped). The old tree-mode listPages destructured only `pages` and returned a
|
||||
// bare tree, dropping `truncated`, so a caller handed an INCOMPLETE tree had no
|
||||
// way to know pages were missing. The fix returns { tree, truncated } (same
|
||||
// pattern check_new_comments uses).
|
||||
//
|
||||
// Reaching the real cap (MAX_NODES = 10000) in a mock is impractical, so we stub
|
||||
// enumerateSpacePages directly to assert the flag is threaded through verbatim.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function stubClient() {
|
||||
const client = new DocmostClient({
|
||||
apiUrl: "http://127.0.0.1:1/api",
|
||||
getToken: async () => "access",
|
||||
});
|
||||
// No network: the tree path only calls ensureAuthenticated + enumerateSpacePages.
|
||||
client.ensureAuthenticated = async () => {};
|
||||
return client;
|
||||
}
|
||||
|
||||
const onePage = [{ id: "r1", title: "Root", parentPageId: null }];
|
||||
|
||||
test("tree mode carries truncated:true when the enumeration truncated (#486)", async () => {
|
||||
const client = stubClient();
|
||||
client.enumerateSpacePages = async () => ({ pages: onePage, truncated: true });
|
||||
|
||||
const res = await client.listPages("space-1", 50, true);
|
||||
|
||||
assert.equal(res.truncated, true, "the truncated flag is threaded through");
|
||||
assert.ok(Array.isArray(res.tree), "the built tree rides alongside the flag");
|
||||
assert.equal(res.tree[0].id, "r1");
|
||||
});
|
||||
|
||||
test("tree mode carries truncated:false for a complete enumeration", async () => {
|
||||
const client = stubClient();
|
||||
client.enumerateSpacePages = async () => ({ pages: onePage, truncated: false });
|
||||
|
||||
const res = await client.listPages("space-1", 50, true);
|
||||
|
||||
assert.equal(res.truncated, false);
|
||||
assert.equal(res.tree[0].id, "r1");
|
||||
});
|
||||
|
||||
test("tree mode still requires a spaceId", async () => {
|
||||
const client = stubClient();
|
||||
await assert.rejects(
|
||||
client.listPages(undefined, 50, true),
|
||||
/tree mode requires a spaceId/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// #487 commit 1 — the in-app tool cancellation safe-point inside paginateAll.
|
||||
//
|
||||
// The in-app tool host sets a composite abort signal on the client
|
||||
// (setToolAbortSignal) before each tool call; paginateAll checks it at a
|
||||
// safe-point BEFORE every sequential page fetch, so a Stop that lands mid-read
|
||||
// stops the NEXT HTTP request from STARTING (a read tool can no longer paginate
|
||||
// for minutes past a Stop). This pins the HONEST observable property against the
|
||||
// REAL client + a real HTTP server: "after Stop, no NEW request starts".
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
function sendJson(res, status, obj, extra = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extra });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
const openServers = [];
|
||||
async function spawn(handler) {
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = http.createServer(handler);
|
||||
s.listen(0, "127.0.0.1", () => resolve(s));
|
||||
});
|
||||
openServers.push(server);
|
||||
const { port } = server.address();
|
||||
return { baseURL: `http://127.0.0.1:${port}/api` };
|
||||
}
|
||||
after(async () => {
|
||||
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
|
||||
});
|
||||
function handleLogin(req, res) {
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A Stop that lands DURING pagination: the server aborts the client signal as it
|
||||
// serves page 1 (more pages remain). The loop's next safe-point must throw before
|
||||
// the page-2 request is sent.
|
||||
test("paginateAll stops the NEXT request when the signal aborts mid-pagination", async () => {
|
||||
let requests = 0;
|
||||
const ac = new AbortController();
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/spaces") {
|
||||
requests++;
|
||||
// Simulate a user Stop that lands while page 1 is in flight.
|
||||
if (requests === 1) ac.abort(new Error("user stop"));
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ id: `p${requests}` }],
|
||||
meta: { hasNextPage: true, nextCursor: `c${requests}` },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
client.setToolAbortSignal(ac.signal);
|
||||
|
||||
await assert.rejects(
|
||||
() => client.paginateAll("/spaces", {}),
|
||||
/user stop/,
|
||||
"the aborted safe-point rejects with the signal's reason",
|
||||
);
|
||||
assert.equal(requests, 1, "page 2 never started after the Stop");
|
||||
});
|
||||
|
||||
// A Stop that is already in effect before the read starts: zero requests fire.
|
||||
test("paginateAll starts no request when the signal is already aborted", async () => {
|
||||
let requests = 0;
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/spaces") {
|
||||
requests++;
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
// Warm the auth so ensureAuthenticated does not itself POST after the abort.
|
||||
await client.ensureAuthenticated();
|
||||
const ac = new AbortController();
|
||||
ac.abort(new Error("already stopped"));
|
||||
client.setToolAbortSignal(ac.signal);
|
||||
|
||||
await assert.rejects(() => client.paginateAll("/spaces", {}), /already stopped/);
|
||||
assert.equal(requests, 0, "no /spaces request started once already aborted");
|
||||
});
|
||||
|
||||
// Without a tool signal (default), pagination is unaffected — the safe-point is a
|
||||
// pure no-op, so pre-#487 behaviour is byte-identical.
|
||||
test("paginateAll is unaffected when no tool signal is set", async () => {
|
||||
let requests = 0;
|
||||
const PAGES = {
|
||||
"": { items: [{ id: "a" }], nextCursor: "c1" },
|
||||
c1: { items: [{ id: "b" }], nextCursor: null },
|
||||
};
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/spaces") {
|
||||
requests++;
|
||||
const body = JSON.parse(raw || "{}");
|
||||
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: page.items,
|
||||
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const all = await client.paginateAll("/spaces", {});
|
||||
assert.equal(requests, 2, "both pages fetched with no signal set");
|
||||
assert.deepEqual(all.map((p) => p.id), ["a", "b"]);
|
||||
});
|
||||
@@ -184,12 +184,14 @@ test("enumerateSpacePages (via listPages tree) uses one /pages/tree request", as
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
// listPages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
|
||||
const tree = await client.listPages("space-1", 50, true);
|
||||
// listPages tree:true -> enumerateSpacePages(spaceId) -> { tree, truncated }.
|
||||
const { tree, truncated } = await client.listPages("space-1", 50, true);
|
||||
|
||||
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
|
||||
assert.equal(sidebarRequests, 0, "no per-node sidebar BFS requests");
|
||||
assert.deepEqual(treeBody, { spaceId: "space-1" }, "space scope posts spaceId only");
|
||||
// The uncapped /pages/tree path is never truncated (#486).
|
||||
assert.equal(truncated, false, "primary /pages/tree path is not truncated");
|
||||
// buildPageTree nests c1 under r1; two roots at the top level.
|
||||
assert.equal(tree.length, 2, "two root nodes");
|
||||
const r1 = tree.find((n) => n.id === "r1");
|
||||
@@ -249,7 +251,7 @@ test("enumerateSpacePages falls back to the cursor BFS on /pages/tree 404", asyn
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const tree = await client.listPages("space-1", 50, true);
|
||||
const { tree, truncated } = await client.listPages("space-1", 50, true);
|
||||
|
||||
assert.ok(treeRequests >= 1, "the tree endpoint was attempted first");
|
||||
assert.deepEqual(
|
||||
@@ -257,6 +259,8 @@ test("enumerateSpacePages falls back to the cursor BFS on /pages/tree 404", asyn
|
||||
["<root>", "r1"],
|
||||
"fell back to the sidebar BFS: roots then the root's children",
|
||||
);
|
||||
// Small fallback walk well under the node cap -> not truncated (#486).
|
||||
assert.equal(truncated, false, "fallback BFS below the cap is not truncated");
|
||||
assert.equal(tree.length, 1, "one root in the built tree");
|
||||
assert.equal(tree[0].children[0].id, "c1", "leaf nested via the BFS");
|
||||
});
|
||||
|
||||
@@ -372,7 +372,11 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
|
||||
// single flush. This proves the flush actually executes queued callbacks, so
|
||||
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
||||
let freeRan = false;
|
||||
const freeDone = withPageLock(`page.free-${UUID}`, async () => {
|
||||
// A DIFFERENT canonical UUID (unrelated to the page under test). withPageLock
|
||||
// now asserts its key is a canonical UUID (#449), so the "free" probe key must
|
||||
// also be a valid — but distinct — UUID, not a synthetic label.
|
||||
const FREE_UUID = "99999999-9999-4999-8999-999999999999";
|
||||
const freeDone = withPageLock(FREE_UUID, async () => {
|
||||
freeRan = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
@@ -323,7 +323,9 @@ test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)",
|
||||
});
|
||||
|
||||
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
|
||||
const pageId = "page-lock";
|
||||
// withPageLock now asserts a canonical UUID key (#449); this flow takes the
|
||||
// real page lock (mirroring replaceImage), so the key must be a valid UUID.
|
||||
const pageId = "77777777-7777-4777-8777-777777777777";
|
||||
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
|
||||
// each going through the non-locking acquireCollabSession.
|
||||
const result = await withPageLock(pageId, async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user