Compare commits

..

1 Commits

Author SHA1 Message Date
agent_coder 4b2af3d34a refactor(mcp): дедупликация конвертер-смежных хелперов (node-ops форк, footnote-*, parse-node-arg)
Аудит при подготовке #413 нашёл дрейфующие дубли между packages/mcp и
packages/prosemirror-markdown. Четыре дедупа (поведение тулов не меняется):

1. node-ops: форк ~960 строк сведён в ОДНУ копию в prosemirror-markdown (живая
   mcp-версия — строгое надмножество замороженного #293-seed'а пакета; сверено по
   git-истории, новая пакет-копия байт-в-байт == прежней mcp-копии). Barrel-экспорт
   полной поверхности; mcp/client.ts/page-search.ts/transforms.ts/collaboration.ts
   импортируют из пакета; тесты переехали. node-ops тянет stripInlineMarkdown ->
   пакет-локальная text-normalize.ts несёт только этот примитив (mcp-версия —
   домен #408; заголовок документирует дубликацию + источник истины).
2. footnote-lex/footnote-analyze (vestigial legacy [^id]: диагностика): сведены к
   одному fence-aware предупреждению 'reference-style footnotes -> use ^[...]'
   (полезно для класса #410); footnote-lex удалён.
3. footnote-authoring -> примитивы (footnoteContentKey/makeFootnoteDefinition/
   generateFootnoteId) перенесены в пакетный footnote.ts, одна реализация конвенции.
4. parse-node-arg -> перенесён в prosemirror-markdown (не mcp: сервер CommonJS не
   импортирует ESM-only @docmost/mcp, но нативно импортирует пакет), обе копии
   удалены, консьюмеры перенаправлены.

canonicalizeFootnotes/ENFORCEMENT RULE #228 и comment-anchor/json-edit/text-normalize
(mcp) не тронуты. API-поверхность node-ops оставлена чистой для #409/#413.

closes #414

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 01:06:31 +03:00
64 changed files with 1045 additions and 7032 deletions
@@ -86,19 +86,11 @@ const MIN_HEIGHT = 400;
// Margin kept between the window and the viewport edges while dragging. // Margin kept between the window and the viewport edges while dragging.
const EDGE_MARGIN = 8; const EDGE_MARGIN = 8;
// #184 phase 1.5 / #430: backstop for the degraded-poll fallback. The poll is // #184 phase 1.5: hard cap on the degraded-poll fallback. The poll is armed when
// armed when a resume attempt could not attach to the live run and disarmed by the // a resume attempt could not attach to the live run and disarmed by the thread on
// thread on settle / local stream; this cap is the ONLY backstop against an endless // settle / local stream; this cap is the ONLY backstop against an endless tick
// tick (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no // (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no run).
// run). const DEGRADED_POLL_MAX_MS = 10 * 60_000;
//
// #430: measured from RUN ACTIVITY, not from arm-time. A real autonomous run takes
// 11-25 min — longer than a fixed 10-min-from-start cap, which used to cut the poll
// off mid-run. Instead we cap on INACTIVITY: keep polling as long as the run is
// still making progress (its persisted rows keep changing), and only give up after
// this long with NO new activity. A genuinely stuck run produces no row changes, so
// the idle cap still bounds it; a long-but-progressing run polls to completion.
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
/** Compact token formatter: 1.2M / 3.4k / 950. */ /** Compact token formatter: 1.2M / 3.4k / 950. */
function formatTokens(n: number): string { function formatTokens(n: number): string {
@@ -262,12 +254,9 @@ export default function AiChatWindow() {
// onResumeFallback(true); the thread disarms it on settle / local stream. The // onResumeFallback(true); the thread disarms it on settle / local stream. The
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap). // window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
const [degradedPoll, setDegradedPoll] = useState(false); const [degradedPoll, setDegradedPoll] = useState(false);
// #430: timestamp of the LAST run activity while the poll is armed — stamped on const armedAtRef = useRef(0);
// arm and re-stamped whenever the polled rows change (see the effect below). The
// idle cap is measured from this, so a long-but-progressing run keeps polling.
const lastActivityAtRef = useRef(0);
const onResumeFallback = useCallback((active: boolean): void => { const onResumeFallback = useCallback((active: boolean): void => {
if (active) lastActivityAtRef.current = Date.now(); if (active) armedAtRef.current = Date.now();
setDegradedPoll(active); setDegradedPoll(active);
}, []); }, []);
// Reset the degraded poll whenever the open chat changes: it is scoped to the // Reset the degraded poll whenever the open chat changes: it is scoped to the
@@ -280,28 +269,18 @@ export default function AiChatWindow() {
useAiChatMessagesQuery( useAiChatMessagesQuery(
activeChatId ?? undefined, activeChatId ?? undefined,
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed // DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
// and while the run is still active (#430: under the INACTIVITY cap, not a // and under the 10-min cap; otherwise off. NO error checks (TanStack v5
// fixed-from-start cap); otherwise off. NO error checks (TanStack v5 resets // resets fetchFailureCount each fetch, so consecutive errors are not
// fetchFailureCount each fetch, so consecutive errors are not expressible — // expressible — and the poll must survive a server restart) and NO tail
// and the poll must survive a server restart) and NO tail checks (the // checks (the settled/local-stream semantics live in ChatThread, which
// settled/local-stream semantics live in ChatThread, which disarms via // disarms via onResumeFallback(false)). The time cap is the only backstop.
// onResumeFallback(false)). The idle cap is the only backstop.
() => () =>
degradedPoll === true && degradedPoll === true &&
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS
? 2500 ? 2500
: false, : false,
); );
// #430: re-stamp the activity clock whenever the polled rows change while the
// poll is armed. TanStack keeps the same `messageRows` reference across refetches
// that return deep-equal data (structural sharing), so a new reference means the
// run genuinely progressed — which extends the inactivity cap above. A stuck run
// yields no reference change, so the cap eventually fires and stops the poll.
useEffect(() => {
if (degradedPoll) lastActivityAtRef.current = Date.now();
}, [degradedPoll, messageRows]);
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for // #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
// this workspace. When the feature is off no runs are ever created, so the // this workspace. When the feature is off no runs are ever created, so the
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a // resume attempt would only ever 204; gating ChatThread's resume on it avoids a
@@ -739,170 +739,3 @@ function renderResumable(initialRows: IAiChatMessageRow[]) {
act(() => view.rerender(<Wrapper rows={rows} />)); act(() => view.rerender(<Wrapper rows={rows} />));
return { rerender, onResumeFallback }; return { rerender, onResumeFallback };
} }
// #430: auto-reconnect to a DETACHED run after a LIVE SSE disconnect. The mount
// path only resumes on mount/reload; these cover the missing trigger — a live
// `isDisconnect` on onFinish must (backoff-)re-attach WITHOUT a reload, pin+strip
// the live row to avoid duplicates, fall back to the degraded poll on a 204, and
// exhaust to a manual Retry.
describe("ChatThread — live reconnect after isDisconnect (#430)", () => {
// A LIVE local turn that just dropped: the settled tail existed before, and the
// partial assistant row lives only in `messages` (not persisted as a tail).
const settledTail = () => [
row("u1", "user", undefined, "hi"),
row("a1", "assistant", "succeeded", "done"),
];
// The partial assistant message onFinish hands us for the dropped LIVE turn.
const liveMsg = {
id: "a2",
role: "assistant",
parts: [{ type: "text", text: "partial live answer" }],
};
beforeEach(() => {
resetState();
// status "ready": with a live disconnect the mock is not streaming, so the
// status==="streaming" auto-clear effect stays out of the way.
h.state.status = "ready";
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
// Render a NON-resuming mount (settled tail -> no mount resume) with autonomous
// runs on, then simulate a live disconnect via onFinish.
function renderLiveThenDisconnect() {
const view = renderThread({
autonomousRunsEnabled: true,
initialRows: settledTail(),
});
// The settled tail must NOT have triggered a mount resume.
expect(h.state.resumeStream).not.toHaveBeenCalled();
act(() => {
h.state.onFinish?.({
message: liveMsg,
isAbort: false,
isDisconnect: true,
isError: false,
});
});
return view;
}
// Fire the pending (scheduled) attempt for `attempt` (backoff = 1s,2s,4s,...).
function advanceToAttempt(attempt: number) {
act(() => {
vi.advanceTimersByTime(1000 * 2 ** (attempt - 1));
});
}
// Simulate the reconnect GET returning 204 (nothing live) so the transport's
// no-active-stream recovery runs.
async function reconnect204() {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ status: 204, ok: false }),
);
await act(async () => {
await h.state.transport!.fetch!("http://x", { method: "GET" });
});
}
// Simulate the reconnect GET returning a live 2xx stream.
async function reconnect200() {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ status: 200, ok: true }),
);
await act(async () => {
await h.state.transport!.fetch!("http://x", { method: "GET" });
});
}
it("calls resumeStream POST-mount (a live disconnect triggers a backoff reconnect)", () => {
renderLiveThenDisconnect();
// The banner shows immediately; the attach itself fires after the first backoff.
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
expect(h.state.resumeStream).not.toHaveBeenCalled();
advanceToAttempt(1);
// resumeStream is now called AFTER mount — the bug was it only ever fired once
// on mount. The reconnect URL pins expect=live&anchor to OUR run.
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a2",
);
});
it("strips the pinned live row before replay so content is NOT duplicated", () => {
renderLiveThenDisconnect();
advanceToAttempt(1);
// The attempt strips the anchor row from the store (the live replay rebuilds
// it). Apply the setMessages updater to prove it removes exactly the anchor.
const updater = h.state.setMessages.mock.calls.at(-1)![0] as (
prev: { id: string }[],
) => { id: string }[];
expect(updater([{ id: "u1" }, { id: "a2" }])).toEqual([{ id: "u1" }]);
});
it("a live re-attach (2xx) clears the reconnect banner", async () => {
renderLiveThenDisconnect();
advanceToAttempt(1);
await reconnect200();
expect(screen.queryByText(/reconnecting/i)).toBeNull();
});
it("a 204 arms the degraded poll and backs off to the next attempt", async () => {
const { onResumeFallback } = renderLiveThenDisconnect();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
await reconnect204();
// Fallback engaged: the degraded poll is armed (204 -> onNoActiveStream).
expect(onResumeFallback).toHaveBeenCalledWith(true);
// Still reconnecting — the banner advanced to attempt 2/5.
expect(screen.getByText(/reconnecting.*2\/5/i)).toBeTruthy();
// The next backoff fires attempt 2 (another resumeStream).
advanceToAttempt(2);
expect(h.state.resumeStream).toHaveBeenCalledTimes(2);
});
it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => {
renderLiveThenDisconnect();
// Drive all 5 attempts, each failing with a 204.
for (let n = 1; n <= 5; n++) {
advanceToAttempt(n);
expect(h.state.resumeStream).toHaveBeenCalledTimes(n);
await reconnect204();
}
// The 5th 204 exhausted the cap -> the manual Retry replaces the banner.
expect(screen.queryByText(/reconnecting/i)).toBeNull();
const retry = screen.getByText("Retry");
expect(retry).toBeTruthy();
// Retry fires attempt 1 immediately (no backoff) — a 6th resumeStream.
act(() => {
fireEvent.click(retry);
});
expect(h.state.resumeStream).toHaveBeenCalledTimes(6);
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
});
it("does NOT reconnect when autonomous runs are disabled", () => {
renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() });
act(() => {
h.state.onFinish?.({
message: liveMsg,
isAbort: false,
isDisconnect: true,
isError: false,
});
});
expect(screen.queryByText(/reconnecting/i)).toBeNull();
// The terminal "connection lost" notice is shown instead (unchanged behavior).
expect(
screen.getByText("Connection lost — the answer was interrupted."),
).toBeTruthy();
advanceToAttempt(1);
expect(h.state.resumeStream).not.toHaveBeenCalled();
});
});
@@ -1,17 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { generateId } from "ai"; import { generateId } from "ai";
import { import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
ActionIcon,
Alert,
Box,
Button,
Group,
Loader,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { import {
IconClockHour4, IconClockHour4,
IconPlayerPlayFilled, IconPlayerPlayFilled,
@@ -61,15 +51,6 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
// from the token rate. // from the token rate.
const STREAM_THROTTLE_MS = 50; const STREAM_THROTTLE_MS = 50;
// #430: auto-reconnect after a LIVE SSE disconnect of a DETACHED (autonomous) run.
// The run keeps executing server-side, so instead of a dead "Lost connection"
// banner we re-attach to the live tail through the SAME resumable machinery the
// mount path uses. Attempts back off exponentially and are capped; on exhaustion
// the user gets a manual Retry (the degraded poll keeps catching up underneath).
const RECONNECT_MAX_ATTEMPTS = 5;
// Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s.
const RECONNECT_BASE_DELAY_MS = 1000;
/** The page the user is currently viewing, sent as chat context. */ /** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext { export interface OpenPageContext {
id: string; id: string;
@@ -194,10 +175,6 @@ export default function ChatThread({
const reconcileTailRef = useRef(false); const reconcileTailRef = useRef(false);
const noStreamHandledRef = useRef(false); const noStreamHandledRef = useRef(false);
const onNoActiveStreamRef = useRef<(() => void) | null>(null); const onNoActiveStreamRef = useRef<(() => void) | null>(null);
// #430: called from the transport's reconnect-GET success branch when a live
// stream re-attached (2xx, not 204) — clears the reconnect banner. Kept in a ref
// because the transport's fetch closure (useMemo([])) reads it live.
const onReconnectAttachedRef = useRef<(() => void) | null>(null);
// Live mount flag. The attach GET and the resumed `onFinish` are async and can // Live mount flag. The attach GET and the resumed `onFinish` are async and can
// land AFTER this thread unmounts (the parent remounts per chat via `key`); with // land AFTER this thread unmounts (the parent remounts per chat via `key`); with
// chatIdRef then pointing at the NEW chat, an ungated late callback would arm a // chatIdRef then pointing at the NEW chat, an ungated late callback would arm a
@@ -401,10 +378,6 @@ export default function ChatThread({
// NOT drop the in-progress row or stop tracking the durable run. // NOT drop the in-progress row or stop tracking the durable run.
if (response.status === 204 || !response.ok) if (response.status === 204 || !response.ok)
onNoActiveStreamRef.current?.(); onNoActiveStreamRef.current?.();
// #430: a 2xx stream re-attached (live tail or finished-replay). Signal
// the reconnect controller to clear its banner. No-op outside an active
// reconnect sequence (e.g. the mount attach), so it is safe here.
else onReconnectAttachedRef.current?.();
return response; return response;
} catch (err) { } catch (err) {
// Network throw: same no-onFinish recovery, then rethrow so the SDK // Network throw: same no-onFinish recovery, then rethrow so the SDK
@@ -508,31 +481,6 @@ export default function ChatThread({
); );
} }
} }
// (2b) #430: a LIVE (non-resumed) detached run whose SSE just dropped. The
// server run keeps executing, so instead of a dead "Lost connection" banner
// start a reconnect sequence: pin the CURRENT streaming assistant row as the
// strip/anchor (the live tail is the already-shown partial in `messages`, not
// a persistent row) and re-attach to the live tail via the resumable machinery.
const startedReconnect =
isDisconnect &&
!wasResumed &&
autonomousRunsEnabled === true &&
mountedRef.current &&
message?.role === "assistant" &&
typeof message.id === "string";
if (startedReconnect) {
beginReconnect({
id: message.id,
role: "assistant",
content: "",
status: "streaming",
createdAt: new Date().toISOString(),
// Preserve the partial parts so a 204 restore (onNoActiveStream) re-shows
// what was on screen while the degraded poll catches the run up to
// terminal (rowToUiMessage prefers metadata.parts).
metadata: { parts: message.parts },
});
}
// (3) Standard branches. // (3) Standard branches.
// Forward the authoritative server chatId (streamed on the assistant // Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new // message metadata) so the parent adopts the REAL created chat id for a new
@@ -542,11 +490,9 @@ export default function ChatThread({
onTurnFinished(extractServerChatId(message), threadKey); onTurnFinished(extractServerChatId(message), threadKey);
// Show a neutral "stopped" marker for an aborted turn; the red error banner // Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker. // (via `error`) already covers isError, and a clean finish clears any marker.
// On a live disconnect that STARTED a reconnect, suppress the terminal
// "connection lost" notice — the reconnect banner takes over (#430).
if (isError) setStopNotice(null); if (isError) setStopNotice(null);
else if (isAbort) setStopNotice("manual"); else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice(startedReconnect ? null : "disconnect"); else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null); else setStopNotice(null);
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the // A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
// flush-on-abort branch and the plain flush. The local streamer is the only // flush-on-abort branch and the plain flush. The local streamer is the only
@@ -633,106 +579,6 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming"; const isStreaming = status === "submitted" || status === "streaming";
// #430: live-disconnect reconnect controller. `null` = idle; `{ trying, attempt }`
// = a backoff sequence is running (drives the "reconnecting… (N/max)" banner);
// `{ failed }` = attempts exhausted (drives the manual Retry). Mirrored into a ref
// so the transport/onNoActiveStream closures branch on the LIVE value.
type ReconnectState =
| null
| { phase: "trying"; attempt: number }
| { phase: "failed" };
const [reconnectState, setReconnectState] = useState<ReconnectState>(null);
const reconnectStateRef = useRef<ReconnectState>(null);
const setReconnectStatePair = useCallback((s: ReconnectState) => {
reconnectStateRef.current = s;
setReconnectState(s);
}, []);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
// One reconnect attempt — MIRRORS the mount strip/anchor path for the LIVE case.
// beginReconnect pinned strippedRowRef/stripRef to the run's assistant row, so:
// - remove that row from the store (the mount path strips it from the SEED; here
// it is already shown, so filter it out) — the live replay's `text-start` then
// rebuilds it without DUPLICATING parts (the main dedup risk, #430);
// - reset the one-shot 204 guard so onNoActiveStream can fire for THIS attempt;
// - mark the turn resumed (invariant 7/8) so onFinish runs the recovery block and
// never flushes the queue;
// - resumeStream() -> prepareReconnectToStreamRequest builds
// ?expect=live&anchor=<pinned id>, pinning the replay to OUR run (invariant 6).
const attemptReconnectOnce = useCallback(
(attempt: number) => {
if (!mountedRef.current) return;
const anchor = strippedRowRef.current;
if (anchor) {
setMessages((prev) => prev.filter((m) => m.id !== anchor.id));
}
noStreamHandledRef.current = false;
setResumedTurnPair(true);
setReconnectStatePair({ phase: "trying", attempt });
void resumeStream();
},
[setMessages, setResumedTurnPair, setReconnectStatePair, resumeStream],
);
// Schedule attempt `attempt` after an exponential backoff.
const scheduleReconnectAttempt = useCallback(
(attempt: number) => {
clearReconnectTimer();
setReconnectStatePair({ phase: "trying", attempt });
reconnectTimerRef.current = setTimeout(
() => attemptReconnectOnce(attempt),
RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
);
},
[clearReconnectTimer, setReconnectStatePair, attemptReconnectOnce],
);
// Start a fresh reconnect sequence, pinning `anchorRow` (the live run's assistant
// row) as the strip/anchor reused by every attempt.
const beginReconnect = useCallback(
(anchorRow: IAiChatMessageRow) => {
if (!autonomousRunsEnabled || !mountedRef.current) return;
strippedRowRef.current = anchorRow;
stripRef.current = true;
scheduleReconnectAttempt(1);
},
[autonomousRunsEnabled, scheduleReconnectAttempt],
);
// Manual Retry (shown once attempts are exhausted): restart at attempt 1 and fire
// immediately (the user asked for it now — no backoff).
const retryReconnect = useCallback(() => {
clearReconnectTimer();
attemptReconnectOnce(1);
}, [clearReconnectTimer, attemptReconnectOnce]);
// Live SSE re-attached (the reconnect GET returned a 2xx stream): clear the
// banner + any pending backoff. No-op outside a sequence (e.g. the mount attach).
const onReconnectAttached = useCallback(() => {
if (!mountedRef.current || !reconnectStateRef.current) return;
clearReconnectTimer();
setReconnectStatePair(null);
}, [clearReconnectTimer, setReconnectStatePair]);
onReconnectAttachedRef.current = onReconnectAttached;
// The reconnect GET could not attach (204 / error). onNoActiveStream has already
// armed the degraded poll (the robust fallback that drives the row to terminal
// from the DB), so this only decides the LIVE-attach retry: back off and try
// again up to the cap, else surface the manual Retry.
const onReconnectNoStream = useCallback(() => {
const s = reconnectStateRef.current;
if (s?.phase !== "trying") return;
if (s.attempt < RECONNECT_MAX_ATTEMPTS)
scheduleReconnectAttempt(s.attempt + 1);
else setReconnectStatePair({ phase: "failed" });
}, [scheduleReconnectAttempt, setReconnectStatePair]);
// 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to // 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to
// resume (overflow / begin-failure / after retention / anchor-mismatch). One- // resume (overflow / begin-failure / after retention / anchor-mismatch). One-
// shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four // shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four
@@ -764,17 +610,7 @@ export default function ChatThread({
// (d) 204 means onFinish will NOT fire — clear the suppression flag so it // (d) 204 means onFinish will NOT fire — clear the suppression flag so it
// cannot swallow the NEXT local turn's queue flush. // cannot swallow the NEXT local turn's queue flush.
setResumedTurnPair(false); setResumedTurnPair(false);
// (e) #430: if this 204/error landed during a live-disconnect reconnect }, [setMessages, queryClient, onResumeFallback, setResumedTurnPair]);
// sequence, back off and retry the live attach (or give up to the manual
// Retry). The degraded poll armed in (c) is the fallback either way.
onReconnectNoStream();
}, [
setMessages,
queryClient,
onResumeFallback,
setResumedTurnPair,
onReconnectNoStream,
]);
onNoActiveStreamRef.current = onNoActiveStream; onNoActiveStreamRef.current = onNoActiveStream;
// Mount effect: kick off the resume attempt for a non-settled tail. Marking the // Mount effect: kick off the resume attempt for a non-settled tail. Marking the
@@ -792,9 +628,6 @@ export default function ChatThread({
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
attachAbortRef.current?.abort(); attachAbortRef.current?.abort();
// #430: drop any pending reconnect backoff so it can't fire against the next
// chat this thread's refs are reused for.
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
}; };
// Mount-only by design; the parent remounts per chat via `key`. // Mount-only by design; the parent remounts per chat via `key`.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -833,27 +666,12 @@ export default function ChatThread({
if (tail.status !== "streaming") { if (tail.status !== "streaming") {
reconcileTailRef.current = false; reconcileTailRef.current = false;
onResumeFallback?.(false); onResumeFallback?.(false);
// #430: the run reached its terminal state via the degraded poll — there is
// no live tail left to reconnect to, so drop any reconnect banner / Retry.
clearReconnectTimer();
setReconnectStatePair(null);
} }
// onResumeFallback intentionally omitted (parent-stable callback); deps are // onResumeFallback intentionally omitted (parent-stable callback); deps are
// fixed by the resume design. // fixed by the resume design.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialRows, isStreaming, setMessages]); }, [initialRows, isStreaming, setMessages]);
// #430: a real stream is live again — the reconnect re-attached to the live tail
// (status -> "streaming") OR the user started a new local turn. Either way clear
// the reconnect banner + any pending backoff. Gated on "streaming" (not the
// broader "submitted") so a still-pending attach GET does not clear prematurely.
useEffect(() => {
if (status === "streaming") {
clearReconnectTimer();
setReconnectStatePair(null);
}
}, [status, clearReconnectTimer, setReconnectStatePair]);
// "Send now" on a queued message: interrupt the current turn and immediately // "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message, keeping the agent's partial output. Other queued messages // send THIS message, keeping the agent's partial output. Other queued messages
// stay queued and flush normally after the new turn. Reuses the existing // stay queued and flush normally after the new turn. Reuses the existing
@@ -901,9 +719,6 @@ export default function ChatThread({
// observer's Stop would otherwise leave the attach fetch running. // observer's Stop would otherwise leave the attach fetch running.
attachAbortRef.current?.abort(); attachAbortRef.current?.abort();
stop(); stop();
// #430: pressing Stop also cancels an in-progress reconnect sequence.
clearReconnectTimer();
setReconnectStatePair(null);
if (!autonomousRunsEnabled) return; if (!autonomousRunsEnabled) return;
if (chatIdRef.current) { if (chatIdRef.current) {
onServerStop?.(chatIdRef.current); onServerStop?.(chatIdRef.current);
@@ -925,13 +740,7 @@ export default function ChatThread({
// for this fix. Documented so a future change can address the abort-ordering. // for this fix. Documented so a future change can address the abort-ordering.
stopPendingRef.current = true; stopPendingRef.current = true;
} }
}, [ }, [stop, autonomousRunsEnabled, onServerStop]);
stop,
autonomousRunsEnabled,
onServerStop,
clearReconnectTimer,
setReconnectStatePair,
]);
// Clear the stopped marker as soon as a new turn begins streaming, and drop any // Clear the stopped marker as soon as a new turn begins streaming, and drop any
// stale "Send now" interrupt flags. On the legit interrupt path both refs are // stale "Send now" interrupt flags. On the legit interrupt path both refs are
@@ -1016,43 +825,6 @@ export default function ChatThread({
detail={errorView.detail} detail={errorView.detail}
mb="xs" mb="xs"
/> />
) : reconnectState ? (
// #430: while auto-reconnecting to a detached run's live tail, show progress
// instead of a dead "Lost connection" banner; once attempts are exhausted,
// offer a manual Retry (the degraded poll keeps catching up underneath).
<Alert
variant="light"
color="gray"
p="xs"
mb="xs"
style={{ flexShrink: 0 }}
>
<Group gap={8} wrap="nowrap" align="center">
{reconnectState.phase === "trying" ? (
<>
<Loader size={14} color="gray" style={{ flex: "none" }} />
<Text size="sm" lh={1.3} c="dimmed">
{t("Connection lost — reconnecting…")}
{` (${reconnectState.attempt}/${RECONNECT_MAX_ATTEMPTS})`}
</Text>
</>
) : (
<>
<Text size="sm" lh={1.3} c="dimmed" style={{ flex: 1 }}>
{t("Couldn't reconnect to the answer.")}
</Text>
<Button
size="compact-xs"
variant="light"
color="gray"
onClick={retryReconnect}
>
{t("Retry")}
</Button>
</>
)}
</Group>
</Alert>
) : stopNotice ? ( ) : stopNotice ? (
<ChatStoppedNotice <ChatStoppedNotice
text={ text={
@@ -49,14 +49,19 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
className={classes.definition} className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }} style={{ ["--footnote-number" as any]: `"${number}"` }}
> >
{/* #146: contentDOM MUST be the first child — non-editable chrome before {/* #146: contentDOM MUST be the first child — a non-editable marker before
it makes click hit-testing snap the caret above. Content first; the it makes click hit-testing snap the caret above. Content first; the
back-link follows in DOM and is placed on the right via CSS flex. The marker + back-link follow in DOM and are placed left/right via CSS
decorative "N." number is rendered inline via the .definitionContent flex `order`. The second #146 mitigation lives in
::before rule (from the --footnote-number var), so no marker element
precedes the content. The second #146 mitigation lives in
editor-paste-handler.tsx (reflowAfterPaste). */} editor-paste-handler.tsx (reflowAfterPaste). */}
<NodeViewContent className={classes.definitionContent} /> <NodeViewContent className={classes.definitionContent} />
<span
className={classes.definitionMarker}
contentEditable={false}
aria-hidden="true"
>
{number}.
</span>
{refCount > 1 ? ( {refCount > 1 ? (
// Multiple references -> ↩ followed by one lettered link per occurrence. // Multiple references -> ↩ followed by one lettered link per occurrence.
<span <span
@@ -81,34 +81,34 @@
.definition { .definition {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
/* Tight spacing between the content and the trailing ↩ back-link. */ /* Tight number→text spacing (~one space) so it reads like "1. text"
gap: 0.3em; instead of leaving a wide gap after the period. */
gap: 0.4em;
padding: 2px 0; padding: 2px 0;
/* Footnotes read smaller than body text (16px). Matches .listHeading. */
font-size: var(--mantine-font-size-sm);
} }
/* The "N." number is decorative (from the --footnote-number CSS var on the .definitionMarker {
wrapper, never in the document model) and is rendered inline at the start of order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
the first content line via ::before. This keeps text and wrapped lines flush flex: 0 0 auto;
to the left margin — no hanging indent — while the editable contentDOM stays min-width: 1.5em;
the FIRST DOM child (#146). */ /* Right-align within the narrow column so the period sits next to the text
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--mantine-color-dimmed);
user-select: none;
}
.definitionContent { .definitionContent {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.definitionContent > :first-child::before { /* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
content: var(--footnote-number, "?") ". "; which pushes the first text line ~0.5em below the "N." marker (aligned to
color: var(--mantine-color-dimmed); flex-start), making the number float above the text. Drop the outer margins
font-variant-numeric: tabular-nums; so the marker and the first line share the same top edge — same approach
user-select: none; used for callouts in core.css. */
}
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
Drop the outer margins so the definition sits tight to the heading above and
the ::before number aligns with the top of the row — same approach used for
callouts in core.css. */
.definitionContent > :first-child { .definitionContent > :first-child {
margin-top: 0; margin-top: 0;
} }
@@ -1,140 +0,0 @@
/**
* gitmost #401 — regression test for the connect-vs-unload race in
* @hocuspocus/server 3.4.4 (patched via patches/@hocuspocus__server@3.4.4.patch).
*
* The race (unpatched): when the last client disconnects, storeDocumentHooks'
* `finally` schedules an async `unloadDocument`. That unload runs its
* `beforeUnloadDocument` hooks asynchronously and, meanwhile, records an
* in-flight promise in `this.unloadingDocuments`. In the original 3.4.4
* `createDocument`, a NEW connection arriving in that window falls straight
* through to the `loadingDocuments`/`documents` checks — it never consults
* `unloadingDocuments`. So the new connection can start loading (or reuse) a
* document while the old instance is still being torn down; the re-check inside
* unload (`shouldUnloadDocument`, which sees 0 connections because async auth
* hooks have not registered the new connection yet) then deletes/destroys the
* doc out from under the freshly-connected client → orphaned Document → later
* redis-sync takes the "doc not loaded" path → sync never completes → the
* provider hangs until its ~25s timeout.
*
* The patch: `createDocument` first awaits any in-flight
* `unloadingDocuments.get(name)` before proceeding. Once that settles, the
* decision is deterministic — either the doc was fully unloaded (gone from
* `documents`, so a clean fresh load) or the unload aborted (healthy doc still
* in `documents`, reused). The new connection can never hand-shake onto an
* about-to-be-destroyed Document.
*
* These tests exercise the REAL patched `Hocuspocus.createDocument` (the class
* is directly constructible) by seeding `unloadingDocuments` with a controllable
* in-flight unload and observing that createDocument waits for it.
*/
import { Hocuspocus } from '@hocuspocus/server';
// A promise we can resolve on demand, to model an unload that is mid-flight.
function deferred<T = void>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe('gitmost #401 — hocuspocus createDocument awaits in-flight unload', () => {
it('does NOT start loading a new doc until the in-flight unload settles, then loads fresh', async () => {
const hp = new Hocuspocus();
const name = 'page.race';
// Observe loadDocument: on the unpatched code it is invoked synchronously
// within createDocument (before the unload settles); on the patched code it
// must be deferred until unloadingDocuments resolves.
const freshDoc = { name, __fresh: true } as any;
const loadSpy = jest
.spyOn(hp as any, 'loadDocument')
.mockResolvedValue(freshDoc);
// Model an unload in progress: an entry sits in unloadingDocuments and, when
// it completes, it removes the doc from `documents` (a real full unload).
const unload = deferred();
(hp as any).documents.set(name, { name, __dying: true });
(hp as any).unloadingDocuments.set(
name,
unload.promise.then(() => {
(hp as any).documents.delete(name);
}),
);
// Kick off a new connection's createDocument but do not await it yet.
const createPromise = (hp as any).createDocument(
name,
{},
'socket-1',
{ isAuthenticated: true, readOnly: false },
{},
);
// Let all currently-schedulable microtasks run. The patched createDocument is
// now parked on `await unloadingDocuments.get(name)`, so loadDocument must
// NOT have been called yet, and it must NOT have returned the dying doc.
await Promise.resolve();
await Promise.resolve();
expect(loadSpy).not.toHaveBeenCalled();
// The unload completes (doc removed from `documents`).
unload.resolve();
// createDocument now proceeds: sees no existing doc → fresh load.
const doc = await createPromise;
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(doc).toBe(freshDoc);
// The freshly-loaded doc is the one registered — never the dying instance.
expect((hp as any).documents.get(name)).toBe(freshDoc);
});
it('reuses the live doc when the in-flight unload aborts (doc left in documents)', async () => {
const hp = new Hocuspocus();
const name = 'page.abort';
const loadSpy = jest.spyOn(hp as any, 'loadDocument');
// Model an unload that ABORTS (e.g. a new connection reappeared before the
// sync re-check): it settles WITHOUT deleting the doc from `documents`.
const unload = deferred();
const liveDoc = { name, __live: true } as any;
(hp as any).documents.set(name, liveDoc);
(hp as any).unloadingDocuments.set(name, unload.promise); // no-op unload
const createPromise = (hp as any).createDocument(
name,
{},
'socket-2',
{ isAuthenticated: true, readOnly: false },
{},
);
unload.resolve();
const doc = await createPromise;
// The still-present live doc is reused; no fresh load happened.
expect(doc).toBe(liveDoc);
expect(loadSpy).not.toHaveBeenCalled();
});
it('no in-flight unload → behaves normally (fresh load)', async () => {
const hp = new Hocuspocus();
const name = 'page.normal';
const freshDoc = { name } as any;
const loadSpy = jest
.spyOn(hp as any, 'loadDocument')
.mockResolvedValue(freshDoc);
const doc = await (hp as any).createDocument(
name,
{},
'socket-3',
{ isAuthenticated: true, readOnly: false },
{},
);
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(doc).toBe(freshDoc);
});
});
@@ -1,130 +0,0 @@
/**
* gitmost #401 fix 2 — onLoadDocument applies the DB state directly into the
* hook's target document and returns undefined (instead of building a NEW Y.Doc
* and returning it, which made hocuspocus re-encode+apply the whole state a
* SECOND time on every cold load).
*
* These tests assert:
* - the hook mutates `data.document` in place so its content equals the DB doc,
* - onLoadDocument returns undefined (so hocuspocus keeps the mutated doc and
* does NOT run its own applyUpdate(encodeStateAsUpdate(...)) merge),
* - both the raw-ydoc branch and the json→ydoc conversion branch behave so.
*
* Returning undefined is the observable signal that the double-encode is gone
* (the old code returned a new Y.Doc, which made hocuspocus re-encode+apply the
* state a second time); we assert that contract rather than counting internal
* encode calls, which is brittle given the encodes inside toYdoc and the test's
* own `expected` fixtures.
*/
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { tiptapExtensions } from '../collaboration.util';
// A fresh hocuspocus Document (extends Y.Doc, adds isEmpty()) as hocuspocus
// hands to onLoadDocument on a cold load.
const freshDoc = () => new Document(`page.${PAGE_ID}`, {});
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const doc = (text: string) => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
const jsonOf = (ydoc: Y.Doc) =>
TiptapTransformer.fromYdoc(ydoc, 'default');
describe('PersistenceExtension.onLoadDocument — #401 fix 2 (apply-into-hook-doc)', () => {
let ext: PersistenceExtension;
let pageRepo: { findById: jest.Mock };
beforeEach(() => {
pageRepo = { findById: jest.fn() };
ext = new PersistenceExtension(
pageRepo as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
});
const load = (document: Document) =>
ext.onLoadDocument({ documentName: `page.${PAGE_ID}`, document } as any);
it('raw ydoc branch: mutates the hook doc to the DB state and returns undefined', async () => {
// Source doc representing the persisted ydoc state.
const source = TiptapTransformer.toYdoc(
doc('DB CONTENT'),
'default',
tiptapExtensions,
);
const dbState = Buffer.from(Y.encodeStateAsUpdate(source));
pageRepo.findById.mockResolvedValue({ id: PAGE_ID, ydoc: dbState });
// The hook target is a fresh empty doc (as hocuspocus supplies on cold load).
const target = freshDoc();
const result = await load(target);
// Return undefined so hocuspocus keeps `target` as-is (no second merge).
expect(result).toBeUndefined();
// The hook document now carries the DB content.
expect(jsonOf(target)).toEqual(jsonOf(source));
});
it('json→ydoc branch: converts page.content into the hook doc and returns undefined', async () => {
pageRepo.findById.mockResolvedValue({
id: PAGE_ID,
ydoc: null,
content: doc('JSON CONTENT'),
});
const target = freshDoc();
const result = await load(target);
// Returning undefined is what keeps hocuspocus from re-encoding+applying the
// state a second time (the old code returned the doc, forcing that extra
// encode). We assert the observable contract here — the return value and the
// resulting content — rather than counting internal encode calls, which is
// brittle: toYdoc and the `expected` build below both encode too.
expect(result).toBeUndefined();
// The converted content landed in the hook document.
const expected = TiptapTransformer.toYdoc(
doc('JSON CONTENT'),
'default',
tiptapExtensions,
);
expect(jsonOf(target)).toEqual(jsonOf(expected));
});
it('live doc already non-empty: early return, no DB read', async () => {
// A hocuspocus Document carrying live content (isEmpty('default') === false).
const target = freshDoc();
const live = TiptapTransformer.toYdoc(
doc('LIVE'),
'default',
tiptapExtensions,
);
Y.applyUpdate(target, Y.encodeStateAsUpdate(live));
const result = await load(target);
expect(result).toBeUndefined();
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('no persisted state: leaves the fresh empty doc untouched, returns undefined', async () => {
pageRepo.findById.mockResolvedValue({ id: PAGE_ID, ydoc: null, content: null });
const target = freshDoc();
const result = await load(target);
expect(result).toBeUndefined();
expect(target.isEmpty('default')).toBe(true);
});
});
@@ -171,21 +171,15 @@ export class PersistenceExtension implements Extension {
return; return;
} }
// #401 fix 2 — apply the DB state DIRECTLY into the hook's target document
// (`document` === `data.document`) and return undefined. When onLoadDocument
// returns undefined, hocuspocus keeps the mutated hook document as-is; only
// when the hook RETURNS a Y.Doc does hocuspocus re-`applyUpdate(document,
// encodeStateAsUpdate(returned))` — a second full encode+apply of the whole
// (e.g. 315KB) state on every cold load. Mutating in place performs a single
// apply and avoids the throwaway `new Y.Doc()` allocation.
if (page.ydoc) { if (page.ydoc) {
this.logger.debug(`ydoc loaded from db: ${pageId}`); this.logger.debug(`ydoc loaded from db: ${pageId}`);
const doc = new Y.Doc();
const dbState = new Uint8Array(page.ydoc); const dbState = new Uint8Array(page.ydoc);
Y.applyUpdate(document, dbState); Y.applyUpdate(doc, dbState);
observeCollabLoad(dbState.length, (performance.now() - startedAt) / 1000); observeCollabLoad(dbState.length, (performance.now() - startedAt) / 1000);
return; return doc;
} }
// if no ydoc state in db convert json in page.content to Ydoc. // if no ydoc state in db convert json in page.content to Ydoc.
@@ -198,23 +192,18 @@ export class PersistenceExtension implements Extension {
tiptapExtensions, tiptapExtensions,
); );
// Encode the converted doc ONCE, reuse the bytes for both the size label // Reuse this single encode for the size label (do NOT add a second one).
// and the single apply into the hook document (previously this encode's
// result was returned and hocuspocus re-encoded+applied it a second time).
const encoded = Y.encodeStateAsUpdate(ydoc); const encoded = Y.encodeStateAsUpdate(ydoc);
Y.applyUpdate(document, encoded);
observeCollabLoad( observeCollabLoad(
encoded.byteLength, encoded.byteLength,
(performance.now() - startedAt) / 1000, (performance.now() - startedAt) / 1000,
); );
return; return ydoc;
} }
// No persisted state: the hook document is already a fresh empty Y.Doc, so
// leave it untouched and return undefined (no re-encode of an empty doc).
this.logger.debug(`creating fresh ydoc: ${pageId}`); this.logger.debug(`creating fresh ydoc: ${pageId}`);
observeCollabLoad(0, (performance.now() - startedAt) / 1000); observeCollabLoad(0, (performance.now() - startedAt) / 1000);
return; return new Y.Doc();
} }
async onStoreDocument(data: onStoreDocumentPayload) { async onStoreDocument(data: onStoreDocumentPayload) {
@@ -17,24 +17,10 @@ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
/** How long a finished entry is retained for late attach (replay + immediate end). */ /** How long a finished entry is retained for late attach (replay + immediate end). */
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000; export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
/** /** Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204). */
* Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204, and export const RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
* the client falls back to its restore + degraded-poll path, #430).
*
* Raised from 4MB to 32MB (#430): marathon autonomous runs (11-25 min observed)
* stream far more than 4MB of SSE frames, so a live disconnect mid-run would find
* an already-overflowed buffer and could only degrade-poll instead of re-attaching
* to the live tail. 32MB comfortably covers those runs while staying bounded.
*
* Memory cost: this is the WORST-CASE retained size PER ACTIVE run (the buffer is
* freed on finish + retention, or dropped immediately on overflow). With the small
* number of concurrent autonomous runs a single workspace realistically has, 32MB
* each is an acceptable ceiling; the overflow->204->degraded-poll fallback remains
* the backstop for anything larger, so correctness never depends on this bound.
*/
export const RUN_STREAM_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
// 2x the replay cap: a just-written full-replay burst alone can never trip the // 2x the replay cap: a just-written 4MB replay burst alone can never trip the
// per-subscriber cap (see controller); only a genuinely stalled socket can. // per-subscriber cap (see controller); only a genuinely stalled socket can.
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES; export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
@@ -2,7 +2,6 @@ import {
AiChatStreamRegistryService, AiChatStreamRegistryService,
RUN_STREAM_MAX_BUFFER_BYTES, RUN_STREAM_MAX_BUFFER_BYTES,
RUN_STREAM_RETAIN_FINISHED_MS, RUN_STREAM_RETAIN_FINISHED_MS,
SUBSCRIBER_MAX_BUFFERED_BYTES,
RunStreamCallbacks, RunStreamCallbacks,
} from './ai-chat-stream-registry.service'; } from './ai-chat-stream-registry.service';
@@ -211,10 +210,9 @@ describe('AiChatStreamRegistryService', () => {
const att = (await registry.attach(CHAT, false, undefined, c.cb))!; const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
att.start(); att.start();
// Cap-relative so it survives a buffer-cap change (#430): a quarter-cap frame const oneMb = 'x'.repeat(1024 * 1024);
// means 5 frames comfortably exceed the replay cap; the last one crosses. // 5 x 1MB = 5MB > 4MB cap; the 5th frame is the one that crosses.
const chunk = 'x'.repeat(Math.floor(RUN_STREAM_MAX_BUFFER_BYTES / 4)); for (let i = 0; i < 5; i++) src.push(oneMb + i);
for (let i = 0; i < 5; i++) src.push(chunk + i);
await flush(); await flush();
const entry = (registry as any).entries.get(CHAT); const entry = (registry as any).entries.get(CHAT);
@@ -222,7 +220,7 @@ describe('AiChatStreamRegistryService', () => {
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES); expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
// The live subscriber received ALL 5 frames, including the crossing one. // The live subscriber received ALL 5 frames, including the crossing one.
expect(c.frames).toHaveLength(5); expect(c.frames).toHaveLength(5);
expect(c.frames[4]).toBe(chunk + 4); expect(c.frames[4]).toBe(oneMb + 4);
// A NEW attach after overflow gets null (replay buffer is gone). // A NEW attach after overflow gets null (replay buffer is gone).
const c2 = collector(); const c2 = collector();
@@ -242,11 +240,9 @@ describe('AiChatStreamRegistryService', () => {
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!; const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
attB.start(); attB.start();
// Cap-relative so it survives a buffer-cap change (#430): a quarter-of-the- const oneMb = 'x'.repeat(1024 * 1024);
// per-subscriber-cap frame means 5 frames exceed A's paused-pending cap while // 9 x 1MB = 9MB > 8MB per-subscriber cap; A's pending overflows, B streams live.
// B streams every frame live. for (let i = 0; i < 9; i++) src.push(oneMb + i);
const chunk = 'x'.repeat(Math.floor(SUBSCRIBER_MAX_BUFFERED_BYTES / 4));
for (let i = 0; i < 5; i++) src.push(chunk + i);
await flush(); await flush();
const entry = (registry as any).entries.get(CHAT); const entry = (registry as any).entries.get(CHAT);
@@ -254,7 +250,7 @@ describe('AiChatStreamRegistryService', () => {
expect(entry.subscribers.size).toBe(1); expect(entry.subscribers.size).toBe(1);
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
// B received every frame live (delivery unaffected by A's overflow). // B received every frame live (delivery unaffected by A's overflow).
expect(b.frames).toHaveLength(5); expect(b.frames).toHaveLength(9);
// A's start() (arriving late) degrades to an immediate end, not a partial replay. // A's start() (arriving late) degrades to an immediate end, not a partial replay.
attA.start(); attA.start();
@@ -148,53 +148,6 @@ describe('assistantParts', () => {
expect(toolPart).not.toHaveProperty('output'); expect(toolPart).not.toHaveProperty('output');
}); });
it('replays the REAL error text for a THROWN tool (tool-error part)', () => {
const steps = [
{
text: '',
toolCalls: [
{ toolCallId: 'c7', toolName: 'editPageText', input: { id: 'p1' } },
],
// A thrown tool is a `tool-error` content part; toolResults holds only
// successes and stays empty for this call.
toolResults: [],
content: [
{
type: 'tool-error',
toolCallId: 'c7',
toolName: 'editPageText',
input: { id: 'p1' },
error: new Error('page is locked'),
},
],
},
];
const parts = assistantParts(steps, '') as AnyPart[];
const toolPart = parts.find((p) => p.type === 'tool-editPageText');
expect(toolPart).toBeDefined();
expect(toolPart!.state).toBe('output-error');
// The REAL error is replayed, NOT the 'Tool call did not complete.' placeholder.
expect(toolPart!.errorText).toBe('page is locked');
expect(toolPart).not.toHaveProperty('output');
});
it('keeps the placeholder ONLY for a call with neither result nor tool-error', () => {
const steps = [
{
text: '',
toolCalls: [
{ toolCallId: 'c8', toolName: 'insertNode', input: { node: {} } },
],
toolResults: [],
content: [], // aborted mid-step: no result AND no tool-error
},
];
const parts = assistantParts(steps, '') as AnyPart[];
const toolPart = parts.find((p) => p.type === 'tool-insertNode');
expect(toolPart!.state).toBe('output-error');
expect(toolPart!.errorText).toBe('Tool call did not complete.');
});
it('skips malformed tool-calls (missing toolName or toolCallId)', () => { it('skips malformed tool-calls (missing toolName or toolCallId)', () => {
const steps = [ const steps = [
{ {
@@ -242,45 +195,6 @@ describe('serializeSteps', () => {
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } }); expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } }); expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } });
}); });
it('records a THROWN tool failure (tool-error part) with its error message', () => {
const trace = serializeSteps([
{
toolCalls: [{ toolName: 'editPageText', input: { id: 'p1' } }],
toolResults: [],
content: [
{
type: 'tool-error',
toolName: 'editPageText',
error: new Error('page is locked'),
},
],
},
]) as Array<Record<string, unknown>>;
// The call element is followed by a paired error element (mirroring how a
// successful result is appended), so the failure survives in the trace.
expect(trace).toHaveLength(2);
expect(trace[0]).toEqual({ toolName: 'editPageText', input: { id: 'p1' } });
expect(trace[1]).toEqual({
toolName: 'editPageText',
error: 'page is locked',
});
});
it('truncates a very long tool-error message to the tool-output limit', () => {
const long = 'x'.repeat(5000);
const trace = serializeSteps([
{
toolCalls: [{ toolName: 'editPageText', input: {} }],
toolResults: [],
content: [{ type: 'tool-error', toolName: 'editPageText', error: long }],
},
]) as Array<Record<string, unknown>>;
const errorText = trace[1].error as string;
// Truncated (not the full 5000 chars) and carries the omission marker.
expect(errorText.length).toBeLessThan(long.length);
expect(errorText).toContain('chars omitted');
});
}); });
describe('rowToUiMessage', () => { describe('rowToUiMessage', () => {
@@ -1637,17 +1637,6 @@ type StepLike = {
toolName?: string; toolName?: string;
output?: unknown; output?: unknown;
}>; }>;
// ai@6.0.134: a tool that THREW surfaces as a `tool-error` content part
// ({ type:'tool-error', toolCallId, toolName, input, error }), NOT as a
// `toolResults` entry (which holds only successes). Read from here so failed
// calls are persisted with their real error instead of being dropped.
content?: ReadonlyArray<{
type?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
error?: unknown;
}>;
}; };
/** /**
@@ -1750,26 +1739,6 @@ function compactValue(value: unknown, depth: number): unknown {
return value; return value;
} }
/**
* Extract a bounded string message from a `tool-error` part's `error` field for
* persistence and history replay. The field may be an `Error`, a string, or an
* arbitrary object, so pull a message robustly. The result is passed through
* `compactValue` so a very long error honors the SAME truncation limits the file
* already applies to tool outputs (no new limit is introduced here).
*/
function normalizeToolError(error: unknown): string {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: error != null &&
typeof (error as { message?: unknown }).message === 'string'
? (error as { message: string }).message
: String(error);
return compactValue(message, 0) as string;
}
/** /**
* Rebuild the FULL UIMessage `parts` for an assistant turn from the SDK steps, * Rebuild the FULL UIMessage `parts` for an assistant turn from the SDK steps,
* so multi-turn history replays prior tool-calls/results to the model (not just * so multi-turn history replays prior tool-calls/results to the model (not just
@@ -1802,14 +1771,6 @@ export function assistantParts(
for (const r of step.toolResults ?? []) { for (const r of step.toolResults ?? []) {
if (r.toolCallId) resultsById.set(r.toolCallId, r.output); if (r.toolCallId) resultsById.set(r.toolCallId, r.output);
} }
// Index this step's THROWN tool failures (ai@6 `tool-error` content parts)
// by tool call id, so a call that failed replays with its real error text.
const errorsById = new Map<string, unknown>();
for (const part of step.content ?? []) {
if (part.type === 'tool-error' && part.toolCallId) {
errorsById.set(part.toolCallId, part.error);
}
}
for (const call of step.toolCalls ?? []) { for (const call of step.toolCalls ?? []) {
if (!call.toolName || !call.toolCallId) continue; if (!call.toolName || !call.toolCallId) continue;
const hasResult = resultsById.has(call.toolCallId); const hasResult = resultsById.has(call.toolCallId);
@@ -1822,21 +1783,9 @@ export function assistantParts(
input: call.input, input: call.input,
output: compactToolOutput(resultsById.get(call.toolCallId)), output: compactToolOutput(resultsById.get(call.toolCallId)),
}); });
} else if (errorsById.has(call.toolCallId)) {
// The tool THREW: replay the REAL error so the model on the next turn
// knows WHY the call failed (and does not blindly repeat it). An
// output-error round-trips through convertToModelMessages as a balanced
// tool-call + tool-result, keeping the rebuilt history valid.
parts.push({
type: `tool-${call.toolName}`,
toolCallId: call.toolCallId,
state: 'output-error',
input: call.input,
errorText: normalizeToolError(errorsById.get(call.toolCallId)),
});
} else { } else {
// No paired result AND no tool-error (e.g. aborted mid-step). Persisting // No paired result (e.g. aborted mid-step). Persisting a bare
// a bare tool-call (input-available) would replay as an unpaired call and // tool-call (input-available) would replay as an unpaired call and
// throw MissingToolResultsError on the next turn (convertToModelMessages // throw MissingToolResultsError on the next turn (convertToModelMessages
// emits no tool-result for it). Emit a SYNTHETIC paired result instead: // emits no tool-result for it). Emit a SYNTHETIC paired result instead:
// an output-error round-trips through convertToModelMessages as a // an output-error round-trips through convertToModelMessages as a
@@ -2072,19 +2021,10 @@ export function serializeSteps(
steps: ReadonlyArray<{ steps: ReadonlyArray<{
toolCalls?: ReadonlyArray<{ toolName?: string; input?: unknown }>; toolCalls?: ReadonlyArray<{ toolName?: string; input?: unknown }>;
toolResults?: ReadonlyArray<{ toolName?: string; output?: unknown }>; toolResults?: ReadonlyArray<{ toolName?: string; output?: unknown }>;
content?: ReadonlyArray<{
type?: string;
toolName?: string;
error?: unknown;
}>;
}>, }>,
): unknown { ): unknown {
const calls: Array<{ const calls: Array<{ toolName?: string; input?: unknown; output?: unknown }> =
toolName?: string; [];
input?: unknown;
output?: unknown;
error?: string;
}> = [];
for (const step of steps ?? []) { for (const step of steps ?? []) {
for (const call of step.toolCalls ?? []) { for (const call of step.toolCalls ?? []) {
calls.push({ toolName: call.toolName, input: call.input }); calls.push({ toolName: call.toolName, input: call.input });
@@ -2092,18 +2032,6 @@ export function serializeSteps(
for (const r of step.toolResults ?? []) { for (const r of step.toolResults ?? []) {
calls.push({ toolName: r.toolName, output: compactToolOutput(r.output) }); calls.push({ toolName: r.toolName, output: compactToolOutput(r.output) });
} }
// ai@6 surfaces a THROWN tool failure as a `tool-error` content part, NOT as
// a `toolResults` entry. Record it as its own paired element (mirroring how a
// successful result is appended) so the failure and its reason survive in the
// trace instead of leaving an orphaned call with no result.
for (const part of step.content ?? []) {
if (part.type === 'tool-error') {
calls.push({
toolName: part.toolName,
error: normalizeToolError(part.error),
});
}
}
} }
return calls.length > 0 ? calls : null; return calls.length > 0 ? calls : null;
} }
@@ -17,7 +17,7 @@ import {
resolveCurrentPageResult, resolveCurrentPageResult,
type SelectionContext, type SelectionContext,
} from './current-page.util'; } from './current-page.util';
import { parseNodeArg } from './parse-node-arg'; import { parseNodeArg } from '@docmost/prosemirror-markdown';
import { modelFriendlyInput } from './model-friendly-input'; import { modelFriendlyInput } from './model-friendly-input';
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store'; import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
import { import {
@@ -729,35 +729,6 @@ export class AiChatToolsService {
}), }),
), ),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// meta.hash in the result is the baseHash drawioUpdate requires.
drawioGet: sharedTool(
sharedToolSpecs.drawioGet,
async ({ pageId, node, format }) =>
await client.drawioGet(pageId, node, format ?? 'xml'),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// The flat schema fields are regrouped into the client's `where` object.
drawioCreate: sharedTool(
sharedToolSpecs.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
await client.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// baseHash is the optimistic lock: mismatch => structured conflict error.
drawioUpdate: sharedTool(
sharedToolSpecs.drawioUpdate,
async ({ pageId, node, xml, baseHash }) =>
await client.drawioUpdate(pageId, node, xml, baseHash),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). // Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The table reference parameter was unified to `table` (was `tableRef`). // The table reference parameter was unified to `table` (was `tableRef`).
tableInsertRow: sharedTool( tableInsertRow: sharedTool(
@@ -168,32 +168,6 @@ export interface DocmostClientLike {
url: string, url: string,
opts?: { align?: 'left' | 'center' | 'right'; alt?: string }, opts?: { align?: 'left' | 'center' | 'right'; alt?: string },
): Promise<Record<string, unknown>>; ): Promise<Record<string, unknown>>;
// --- draw.io diagrams (#423, stage 1) ---
// Read a diagram as decoded mxGraph XML (default) or the raw .drawio.svg.
// meta.hash is the optimistic-lock key drawioUpdate expects as baseHash.
drawioGet(
pageId: string,
node: string,
format?: 'xml' | 'svg',
): Promise<Record<string, unknown>>;
// Lint mxGraph XML, build the .drawio.svg attachment and insert a drawio node.
drawioCreate(
pageId: string,
where: {
position: 'before' | 'after' | 'append';
anchorNodeId?: string;
anchorText?: string;
},
xml: string,
title?: string,
): Promise<Record<string, unknown>>;
// Optimistic-locked full replacement of a diagram (baseHash from drawioGet).
drawioUpdate(
pageId: string,
node: string,
xml: string,
baseHash: string,
): Promise<Record<string, unknown>>;
tableInsertRow( tableInsertRow(
pageId: string, pageId: string,
tableRef: string, tableRef: string,
@@ -1,10 +1,10 @@
import { parseNodeArg } from './parse-node-arg'; import { parseNodeArg } from '@docmost/prosemirror-markdown';
/** /**
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone * Unit tests for the shared `parseNodeArg` helper (#414: now the single copy in
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the * `@docmost/prosemirror-markdown`, imported by both the server tool adapters and
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be * `@docmost/mcp`). Used by the patchNode / insertNode / updatePageJson adapters.
* byte-identical: object passthrough, valid-string parse, invalid-string throw. * Behavior: object passthrough, valid-string parse, invalid-string throw.
*/ */
describe('parseNodeArg', () => { describe('parseNodeArg', () => {
it('passes an object through unchanged', () => { it('passes an object through unchanged', () => {
@@ -1,26 +0,0 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patchNode /
// insertNode (and the analogous updatePageJson content parsing).
//
// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts`
// (the function logic, default/explicit throw messages and branch order match;
// only comments and quote style differ). We cannot import that helper here:
// `@docmost/mcp` is ESM-only and this server
// compiles with module:commonjs, so it is loaded at runtime via the
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
// runtime code across that ESM/CJS boundary by a normal import is impossible,
// hence the mirrored copy.
export function parseNodeArg(
node: unknown,
errMsg = 'node was a string but not valid JSON',
): unknown {
if (typeof node === 'string') {
try {
return JSON.parse(node);
} catch {
throw new Error(errMsg);
}
}
return node;
}
+39 -77
View File
@@ -13,14 +13,12 @@ Read the **Gotchas** section before you trust any error count.
- Agent chats live in Postgres, DB `docmost`, tables `ai_chat_*`. - Agent chats live in Postgres, DB `docmost`, tables `ai_chat_*`.
- Each tool invocation is stored as **two** array elements (a `tool-call` part and - Each tool invocation is stored as **two** array elements (a `tool-call` part and
a `tool-result` part), so naive counting double-counts. a `tool-result` part), so naive counting double-counts.
- **A tool that *throws* writes no result part.** Since the #407 fix its error is - **A tool that *throws* writes no result part at all.** Its error text is nowhere
persisted as a dedicated `{toolName, error}` element in `tool_calls` (queryable + in the DB — not in `tool_calls`, `content`, or `metadata`. It is shown live in
replayed to the model). **Rows written before #407 still drop it** — the error is the UI only. So `isError` / `success=false` scans under-report by design.
nowhere in the DB and shows only in the live UI. So `isError` / `success=false` - To find where agents fail you need **three** sources: (1) soft-failure markers in
scans under-report by design, and pre-#407 thrown errors are invisible. `tool_calls`, (2) the orphan-gap proxy for thrown errors, (3) server logs / the
- To find where agents fail: (1) soft-failure markers in `tool_calls`, (2) the new live UI for the actual error text.
`error` field for thrown errors (new rows) / the orphan-gap proxy (old rows),
(3) server logs / the live UI for full stack traces beyond the truncated message.
## Where the data lives ## Where the data lives
@@ -63,17 +61,13 @@ index 0: { "toolName": "getPage", "input": { "pageId": "…" } } ← tool-ca
index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input) index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input)
``` ```
The keys that appear on an element are `toolName`, `input`, `output`, and — for a The **only** keys that ever appear on an element are `toolName`, `input`, `output`.
**thrown** failure on rows written after the #407 fix — `error` (the tool's error There is no `state`, no `errorText`, no `type`. Consequences:
message; see the "Hard failures" section below). There is no `state`, no `errorText`,
no `type`. On pre-#407 rows a thrown failure has NO paired result element at all
(silent orphan). Consequences:
1. **Real invocation count = elements that have `output` or `error`.** Counting every 1. **Real invocation count = elements that have `output`.** Counting every element
element double-counts (you get ~2× and a spurious "~50% of every tool has no output"). double-counts (you get ~2× and a spurious "~50% of every tool has no output").
2. **Pairing:** a call = a `tool-call` part followed by its result part. A success 2. **Pairing:** a successful call = a `tool-call` part followed by its `tool-result`
carries `output`; a thrown failure (post-#407) carries `error` instead. Both carry part. Both carry `toolName`, so you can group by tool on either.
`toolName`, so you can group by tool on either.
## The two classes of failure (and which the DB can see) ## The two classes of failure (and which the DB can see)
@@ -91,37 +85,25 @@ These are visible in the `tool-result` `output`. The marker differs per tool:
Note `editPageText` returns `failed: []` on success — filtering on the *presence* Note `editPageText` returns `failed: []` on success — filtering on the *presence*
of the key gives false positives; filter on **non-empty**. of the key gives false positives; filter on **non-empty**.
### 2. Hard failures — tool THREW → NOW PERSISTED (since the #407 fix) ### 2. Hard failures — tool THREW → NOT PERSISTED (the trap)
When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell` When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell`
`Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the `Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the
runtime still writes **no `tool-result` part** — the failure is an ai@6 `tool-error` runtime writes **no `tool-result` part**. The orphaned `tool-call` part stays, but
content part instead. **Since the #407 fix, that error is persisted**: `serializeSteps` the error text is **nowhere in the DB**. It is streamed to the UI live and (until
appends a dedicated element `{toolName, error: "<message>"}` right after the failed rotation) to server logs — that is it.
call, mirroring how a successful `{toolName, output}` element is appended. So a thrown
error now leaves a queryable `error` field carrying its (truncated) reason, and the
same real text is replayed to the model on the next turn (an `output-error` part with
the real `errorText`, no longer the `'Tool call did not complete.'` placeholder).
**Cutover caveat — old rows keep the old blind shape.** Rows written **before** this So any query like `count(*) FILTER (WHERE output.success = false)` will happily
change have the two-part shape (`call` + `output` only) and simply **drop** thrown return **0** for `patchNode` even when the chat is visibly full of red failures.
errors, leaving a silent **orphan** (a `call` with no `output` *and* no `error`). Rows That is survivorship bias, not reliability.
written **after** the fix additionally carry the `error` element. So:
- **New rows:** query the `error` field directly (see the hard-error query below) — no The only DB-side proxy for a thrown error is an **orphan**: a `tool-call` part with
orphan heuristic needed for thrown failures. no matching `tool-result`. Caveat: orphans also appear when a run is **aborted**
- **Old rows (pre-#407):** the only DB-side proxy is still an **orphan**: a `tool-call` mid-flight (server restart), so a high-volume tool (`createComment`, `searchInPage`,
part with no matching `tool-result` *and* no `error`. Orphans also appear when a run `Search_web_search`) shows orphans from aborts, not from real errors. Treat the
is **aborted** mid-flight (server restart), so a high-volume tool (`createComment`, orphan gap as an *upper bound* on hard errors, and cross-check the tool: a gap on a
`searchInPage`, `Search_web_search`) shows orphans from aborts, not real errors on structural editor (`patchNode`, `insertNode`, `updatePageJson`, `transformPage`) is
old rows. Treat the orphan gap as an *upper bound*, and cross-check the tool: a gap on almost certainly a thrown Yjs-encode error; a gap on `createComment` is mostly aborts.
a structural editor (`patchNode`, `insertNode`, `updatePageJson`, `transformPage`) is
almost certainly a thrown Yjs-encode error; a gap on `createComment` is mostly aborts.
A note on the aborted-call fallback: a call with **neither** a result **nor** a
`tool-error` (genuinely interrupted mid-step) still replays with the
`'Tool call did not complete.'` placeholder and persists as an orphan — that path is
unchanged, and is distinct from a real thrown error, which now carries `error`.
### 3. Run-level failures → `ai_chat_runs` ### 3. Run-level failures → `ai_chat_runs`
@@ -182,28 +164,14 @@ WHERE jsonb_typeof(o->'failed') = 'array'
GROUP BY 1 ORDER BY 2 DESC; GROUP BY 1 ORDER BY 2 DESC;
``` ```
**Hard errors — persisted `error` field per tool (NEW rows, since #407)** — thrown **Hard-error proxy — orphan gap per tool, WITH a spread column** (call parts minus
tool failures now carry their real reason, so query them directly: result parts, plus how many distinct chats the gap is spread across):
```sql
SELECT elem->>'toolName' AS tool, count(*) AS thrown_errors,
min(elem->>'error') AS sample_error
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'error'
GROUP BY 1 ORDER BY 2 DESC;
```
**Hard-error proxy for OLD rows (pre-#407) — orphan gap per tool, WITH a spread column**
(call parts minus result parts, plus how many distinct chats the gap is spread across).
This covers rows written before thrown errors were persisted; on new rows a thrown
failure now has its own `error` element (use the query above) and an orphan means only
a genuinely aborted mid-step call:
```sql ```sql
WITH parts AS ( WITH parts AS (
SELECT m.chat_id, elem->>'toolName' AS tool, SELECT m.chat_id, elem->>'toolName' AS tool,
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call, (elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
(elem ? 'output' OR elem ? 'error') AS is_result (elem ? 'output') AS is_result
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant' WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant'
), ),
@@ -220,12 +188,8 @@ HAVING sum(gap) FILTER (WHERE gap > 0) > 0
ORDER BY missing_results DESC; ORDER BY missing_results DESC;
``` ```
The `is_result` predicate counts an `error` element as a paired result too, so on new **`missing_results` mixes thrown errors AND aborted/interrupted runs — you cannot
rows a persisted thrown error no longer inflates the orphan gap; a remaining gap is an split them from `output` alone** (a positional "what follows the orphan" heuristic
aborted/interrupted call.
**On OLD rows, `missing_results` mixes thrown errors AND aborted/interrupted runs — you
cannot split them from `output` alone** (a positional "what follows the orphan" heuristic
breaks on parallel tool batches, which persist as `call,call,…,result,result`). Use breaks on parallel tool batches, which persist as `call,call,…,result,result`). Use
`chats_spread` to disambiguate: `chats_spread` to disambiguate:
@@ -280,20 +244,18 @@ docker compose -p gitmost logs -f --tail=100 # whole stack
``` ```
Logging is `json-file`, `max-size=10m max-file=5` → ~50 MB retained, then rotated, Logging is `json-file`, `max-size=10m max-file=5` → ~50 MB retained, then rotated,
and **wiped on container recreate**. Since the #407 fix, thrown-tool error text is and **wiped on container recreate**. So thrown-tool error text is only reliably
**persisted in the `error` field** of `tool_calls` (see the hard-error query above), so caught **in real time** (or in the live chat UI, which renders the failed part with
you no longer depend on live logs for it. Logs/live UI remain useful for **pre-#407 its message). There is no durable, queryable store of hard tool errors today — if you
rows** (whose thrown errors were dropped) and for full stack traces beyond the need one, that is a feature to add (persist `output-error` parts, or emit a
truncated stored message. A per-tool `tool_calls_total{tool,status}` metric to `tool_calls_total{tool,status}` metric to VictoriaMetrics).
VictoriaMetrics is still a possible future add for aggregate dashboards.
## Gotchas checklist ## Gotchas checklist
- [ ] Counting every `tool_calls` element → **overcount**. Count `output` elements; add `error` elements for thrown failures (new rows), but don't count both as invocations. - [ ] Counting every `tool_calls` element → **overcount**. Count elements with `output`.
- [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors are a separate `error` element (new rows) or dropped entirely (pre-#407 rows). - [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors aren't persisted.
- [ ] Thrown errors persist only on rows written **after the #407 fix** — pre-#407 rows still drop them (orphan only). Mind the cutover when trending over time.
- [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence. - [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence.
- [ ] Orphan gap on OLD rows mixes thrown errors **and** aborted runs — split by tool. On NEW rows a thrown error is its own `error` element, so a gap ≈ aborted call. - [ ] Orphan gap mixes thrown errors **and** aborted runs — split by tool before concluding.
- [ ] `aborted` runs = server restarts, `failed` runs = provider overload — not agent mistakes. - [ ] `aborted` runs = server restarts, `failed` runs = provider overload — not agent mistakes.
- [ ] Never dump a raw `tool_calls` cell — it can be hundreds of KB. - [ ] Never dump a raw `tool_calls` cell — it can be hundreds of KB.
- [ ] Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live. - [ ] Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live.
+1 -2
View File
@@ -97,8 +97,7 @@
"patchedDependencies": { "patchedDependencies": {
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch", "scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
"yjs@13.6.30": "patches/yjs@13.6.30.patch", "yjs@13.6.30": "patches/yjs@13.6.30.patch",
"ai@6.0.134": "patches/ai@6.0.134.patch", "ai@6.0.134": "patches/ai@6.0.134.patch"
"@hocuspocus/server@3.4.4": "patches/@hocuspocus__server@3.4.4.patch"
}, },
"overrides": { "overrides": {
"prosemirror-changeset": "2.4.0", "prosemirror-changeset": "2.4.0",
-1
View File
@@ -52,7 +52,6 @@
"form-data": "^4.0.0", "form-data": "^4.0.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"marked": "^17.0.1", "marked": "^17.0.1",
"pako": "^2.0.3",
"re2": "^1.21.0", "re2": "^1.21.0",
"ws": "^8.19.0", "ws": "^8.19.0",
"y-prosemirror": "1.3.7", "y-prosemirror": "1.3.7",
+221 -712
View File
File diff suppressed because it is too large Load Diff
+2 -39
View File
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { DocmostClient, DocmostMcpConfig } from "./client.js"; import { DocmostClient, DocmostMcpConfig } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js"; import { parseNodeArg } from "@docmost/prosemirror-markdown";
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js"; import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost // Re-export the client and its config type so embedding hosts (e.g. the gitmost
@@ -13,11 +13,6 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
export { DocmostClient } from "./client.js"; export { DocmostClient } from "./client.js";
export type { DocmostMcpConfig } from "./client.js"; export type { DocmostMcpConfig } from "./client.js";
// Teardown for the live per-page CollabSession cache (issue #400). An embedding
// HTTP host (the gitmost NestJS server) should call this from its own shutdown
// hook so no cached collab provider outlives the process.
export { destroyAllSessions } from "./lib/collab-session.js";
// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK // Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
// service can read it off the loaded module (it cannot import the ESM package's // service can read it off the loaded module (it cannot import the ESM package's
// internals directly; it goes through loadDocmostMcp()). // internals directly; it goes through loadDocmostMcp()).
@@ -52,7 +47,7 @@ const VERSION = packageJson.version;
export const SERVER_INSTRUCTIONS = export const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent.\n" + "Docmost editing guide — choose the tool by intent.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" + "READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: 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" + "EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: 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 -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (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) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (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) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment 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 -> create_comment 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 -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" + "COMMENTS: create_comment 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 -> create_comment 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 -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown."; "HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
@@ -465,38 +460,6 @@ registerShared(
}, },
); );
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
registerShared(
SHARED_TOOL_SPECS.drawioGet,
async ({ pageId, node, format }) => {
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
return jsonContent(result);
},
);
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
registerShared(
SHARED_TOOL_SPECS.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
const result = await docmostClient.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
);
return jsonContent(result);
},
);
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
registerShared(
SHARED_TOOL_SPECS.drawioUpdate,
async ({ pageId, node, xml, baseHash }) => {
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
return jsonContent(result);
},
);
// Tool: share_page // Tool: share_page
// Schema + description now live in the shared registry (#294). The execute body // Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own `searchIndexing ?? true` default. // keeps this transport's own `searchIndexing ?? true` default.
-668
View File
@@ -1,668 +0,0 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
import {
buildCollabWsUrl,
applyDocToFragment,
MutationResult,
} from "./collaboration.js";
import { summarizeChange } from "./diff.js";
/**
* Live per-page collaboration session cache (issue #400).
*
* The one-shot write path (collaboration.mutatePageContent /
* client.mutateLiveContentUnlocked) used to open a NEW HocuspocusProvider, run
* the full connect -> auth -> onLoadDocument -> initial-sync handshake, apply a
* single edit, wait for persistence, and then `provider.destroy()` for EVERY
* content mutation. Disconnecting after every edit means that once the pause
* between calls exceeds the server's write debounce, the server does a full
* store -> unload -> reload per cell, causing 25s connect timeouts and
* event-loop lag under a burst of edits on one page.
*
* This module keeps ONE live provider + ydoc per (wsUrl, pageId, token) alive
* across a SERIES of edits. While the provider stays connected the server never
* enters store -> unload -> reload, its debounce coalesces N writes into 1-2
* stores, and the repeated auth/load/initial-sync disappears.
*
* The synchronous read -> transform -> write section and the per-edit
* persistence-ack logic are preserved VERBATIM from the one-shot machine the
* only change is that they run on a persistent provider instead of a throwaway
* one. See CollabSession.mutate.
*/
/** Time we wait for the initial handshake/sync before giving up. */
const CONNECT_TIMEOUT_MS = 25000;
/** Time we wait for the server to acknowledge our write before giving up. */
const PERSIST_TIMEOUT_MS = 20000;
/**
* Tunables, read fresh from the environment on every acquire so tests (and a
* live rollback) can change them without reloading the module. Mirrors how
* http.ts parses MCP_SESSION_IDLE_MS.
* - MCP_COLLAB_SESSION_IDLE_MS: idle TTL, reset after every op. Default 60s.
* 0 (or negative) DISABLES the cache every op opens its own provider and
* destroys it after the op, i.e. the exact legacy per-op-provider behavior
* (the rollback path).
* - MCP_COLLAB_SESSION_MAX_AGE_MS: hard lifetime checked at acquire; bounds
* the permission-staleness window. Default 10 min.
* - MCP_COLLAB_SESSION_MAX_ENTRIES: registry cap; the least-recently-used
* session is destroy-evicted when the cap is reached. Default 32.
*/
interface SessionConfig {
idleMs: number;
maxAgeMs: number;
maxEntries: number;
}
function parseEnvInt(value: string | undefined, fallback: number): number {
const parsed = parseInt(value ?? "", 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readConfig(): SessionConfig {
// idleMs: allow 0 (disable). A malformed value falls back to the default.
const idleRaw = parseInt(process.env.MCP_COLLAB_SESSION_IDLE_MS ?? "", 10);
const idleMs = Number.isFinite(idleRaw) ? Math.max(0, idleRaw) : 60 * 1000;
const maxAgeMs = Math.max(
0,
parseEnvInt(process.env.MCP_COLLAB_SESSION_MAX_AGE_MS, 10 * 60 * 1000),
);
const maxEntriesRaw = parseEnvInt(
process.env.MCP_COLLAB_SESSION_MAX_ENTRIES,
32,
);
const maxEntries = maxEntriesRaw > 0 ? maxEntriesRaw : 32;
return { idleMs, maxAgeMs, maxEntries };
}
/**
* The subset of HocuspocusProvider this module depends on, so the provider can
* be replaced with a fake in unit tests (there is no server in the test env).
*/
export interface CollabProviderLike {
synced: boolean;
unsyncedChanges: number;
destroy(): void;
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
}
/** The configuration object passed to the provider factory. */
export interface CollabProviderConfig {
url: string;
name: string;
document: Y.Doc;
token: string;
WebSocketPolyfill: unknown;
onConnect: () => void;
onSynced: () => void;
onDisconnect: () => void;
onClose: () => void;
onAuthenticationFailed: () => void;
}
export type CollabProviderFactory = (
config: CollabProviderConfig,
) => CollabProviderLike;
const defaultProviderFactory: CollabProviderFactory = (config) =>
// @ts-ignore - WebSocketPolyfill is required for the Node.js environment.
new HocuspocusProvider(config) as unknown as CollabProviderLike;
let providerFactory: CollabProviderFactory = defaultProviderFactory;
/**
* TEST SEAM: swap the provider factory (pass null to restore the real one).
* Not part of the public API used only by the unit tests, which cannot reach
* a real collaboration server.
*/
export function __setCollabProviderFactory(
factory: CollabProviderFactory | null,
): void {
providerFactory = factory ?? defaultProviderFactory;
}
/** Optional per-acquire hooks (metrics), passed through from the call site. */
export interface AcquireOptions {
/** Invoked when the initial connect handshake times out (CONNECT_TIMEOUT_MS). */
onConnectTimeout?: () => void;
}
type SessionState = "connecting" | "ready" | "dead";
/**
* One live provider + ydoc for a single (wsUrl, pageId, token) triple.
*
* Lifecycle: connecting -> ready -> dead. A session becomes `dead` on the first
* disconnect/close/auth-failure at ANY time, on an idle/eviction/max-age
* teardown, or on an explicit destroy(); death is terminal and removes the
* session from the registry so the next acquire opens a fresh one. We never use
* the provider's auto-reconnect destroying on the first disconnect closes the
* "reconnect drove unsyncedChanges to 0 without retransmitting our write" class
* of false success.
*/
export class CollabSession {
readonly key: string;
readonly pageId: string;
readonly wsUrl: string;
readonly token: string;
readonly createdAt: number;
state: SessionState = "connecting";
/**
* Set true on disconnect/close/auth-failure so a reconnect-driven
* unsyncedChanges->0 cannot be mistaken for a successful persist of our
* write (preserved verbatim from the one-shot machine).
*/
connectionLost = false;
provider: CollabProviderLike | undefined;
private readonly ydoc: Y.Doc;
private readonly cfg: SessionConfig;
/**
* Ephemeral sessions (cache disabled, MCP_COLLAB_SESSION_IDLE_MS<=0) are never
* registered and self-destroy after their single op the legacy
* provider-per-op behavior.
*/
private readonly ephemeral: boolean;
private readonly opts: AcquireOptions | undefined;
private dead = false;
private connectTimer: ReturnType<typeof setTimeout> | undefined;
private idleTimer: ReturnType<typeof setTimeout> | undefined;
private openPromise: Promise<void> | undefined;
private openResolve: (() => void) | undefined;
private openReject: ((err: Error) => void) | undefined;
private openSettled = false;
/**
* The rejector of the CURRENT in-flight mutate, if any. A disconnect/close/
* auth-failure or timeout at ANY time rejects the in-flight op through this
* with the SAME error text the one-shot machine emitted.
*/
private inflightReject: ((err: Error) => void) | undefined;
constructor(
key: string,
pageId: string,
wsUrl: string,
token: string,
cfg: SessionConfig,
ephemeral: boolean,
opts: AcquireOptions | undefined,
) {
this.key = key;
this.pageId = pageId;
this.wsUrl = wsUrl;
this.token = token;
this.cfg = cfg;
this.ephemeral = ephemeral;
this.opts = opts;
this.createdAt = Date.now();
this.ydoc = new Y.Doc();
}
/**
* A cached session may be reused only when it is fully ready, still synced,
* has not lost its connection, and has not exceeded its max age (invariant 5
* "validate on reuse" + the max-age acquire check).
*/
isReusable(): boolean {
return (
!this.dead &&
this.state === "ready" &&
!this.connectionLost &&
!!this.provider &&
this.provider.synced === true &&
Date.now() - this.createdAt < this.cfg.maxAgeMs
);
}
/**
* Connect and wait for the initial sync (onSynced) within CONNECT_TIMEOUT_MS.
* Idempotent: repeated calls return the same in-flight/settled promise.
*/
open(): Promise<void> {
if (this.openPromise) return this.openPromise;
this.openPromise = new Promise<void>((resolve, reject) => {
this.openResolve = resolve;
this.openReject = reject;
this.connectTimer = setTimeout(() => {
// The 25s connect timeout: the collab connection never became ready.
this.opts?.onConnectTimeout?.();
this.teardown(
new Error("Connection timeout to collaboration server"),
false,
);
}, CONNECT_TIMEOUT_MS);
if (process.env.DEBUG)
console.error(`Connecting to WebSocket: ${this.wsUrl}`);
this.provider = providerFactory({
url: this.wsUrl,
name: `page.${this.pageId}`,
document: this.ydoc,
token: this.token,
WebSocketPolyfill: WebSocket,
onConnect: () => {
if (process.env.DEBUG) console.error("WS Connect");
},
// An unexpected disconnect/close at ANY time (during the connect-wait,
// between edits, or during a persistence wait) makes the session dead:
// surface it now instead of hanging, reject any in-flight op with the
// same error text as the one-shot machine, and remove ourselves from
// the registry so the next acquire opens fresh. `teardown` is idempotent
// so the onClose our own destroy() triggers is a harmless no-op.
onDisconnect: () => {
if (process.env.DEBUG) console.error("WS Disconnect");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
true,
);
},
onClose: () => {
if (process.env.DEBUG) console.error("WS Close");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
true,
);
},
onSynced: () => {
if (this.dead || this.openSettled) return;
if (process.env.DEBUG) console.error("Connected and synced!");
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = undefined;
}
this.state = "ready";
this.openSettled = true;
this.openResolve?.();
},
onAuthenticationFailed: () => {
this.teardown(
new Error("Authentication failed for collaboration connection"),
true,
);
},
});
});
return this.openPromise;
}
/**
* Run one atomic read -> transform -> write against the LIVE doc and wait for
* the server to acknowledge the write.
*
* INVARIANT 1 (read->write atomicity): between `TiptapTransformer.fromYdoc`
* and `applyDocToFragment` there is NO `await`. Yjs applies remote updates
* only when the event loop yields, so this synchronous block sees a consistent
* live doc and no concurrent human edit can interleave and be clobbered
* exactly as in the one-shot onSynced code, just on a persistent provider.
*
* INVARIANT 2 (per-edit ack): after the write, resolve immediately if
* unsyncedChanges is already 0, else wait for the unsyncedChanges->0 event
* (PERSIST_TIMEOUT_MS), guarded by connectionLost so a reconnect handshake
* cannot report a false success.
*
* CONCURRENCY: not safe to invoke concurrently on ONE session the caller
* MUST serialize (hold the per-page lock), mirroring acquireCollabSession.
* The in-flight op is tracked in a single `inflightReject` field, so an
* overlapping second call would clobber the first's rejector and leave it
* hanging on disconnect. A fail-fast guard below rejects the overlap instead.
* Sequential (awaited) mutates are fine: localFinish clears inflightReject
* before the promise settles, so the guard is clear by the time the next runs.
*/
mutate(
transform: (liveDoc: any) => any | null,
): Promise<MutationResult> {
// Belt-and-suspenders (acquire already validated): refuse to write on a
// session that is not in a live, synced, ready state.
if (
this.dead ||
this.state !== "ready" ||
this.connectionLost ||
!this.provider ||
this.provider.synced !== true
) {
return Promise.reject(
new Error("Collaboration session is not in a ready state"),
);
}
// Fail-fast on concurrent use: a second overlapping mutate would overwrite
// the first's inflightReject, so a disconnect would only reject the second
// and hang the first until PERSIST_TIMEOUT_MS. Reject the overlap WITHOUT
// touching the in-flight op's state (no localFinish/teardown here).
if (this.inflightReject) {
return Promise.reject(
new Error(
"mutate already in-flight; caller must serialize (hold the page lock)",
),
);
}
return new Promise<MutationResult>((resolve, reject) => {
let settled = false;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let unsyncedHandler:
| ((data: { number: number }) => void)
| undefined;
// The verifiable result resolved on every success/abort path. Set on
// abort (no-op report) and after a real write (computed change report).
let mutationResult: MutationResult;
const localFinish = (err: Error | null, value?: MutationResult) => {
if (settled) return;
settled = true;
if (persistTimer) clearTimeout(persistTimer);
if (unsyncedHandler && this.provider) {
try {
this.provider.off("unsyncedChanges", unsyncedHandler);
} catch (e) {}
}
this.inflightReject = undefined;
if (err) reject(err);
else resolve(value as MutationResult);
// Post-settle lifecycle: an ephemeral (cache-disabled) session dies with
// its single op; a cached session that is still alive re-arms its idle
// TTL so the clock starts from the LAST op.
if (this.ephemeral) {
this.destroy("ephemeral op complete");
} else if (!this.dead) {
this.armIdle();
}
};
// Register so a disconnect/close/auth-failure/teardown rejects THIS op
// with the connection-loss error text. localFinish's `settled` guard makes
// a racing teardown + normal resolve safe (first one wins).
this.inflightReject = (e: Error) => localFinish(e);
// Resolve once the server acknowledges our update: the provider increments
// unsyncedChanges when the local update is sent and decrements it on the
// server's SyncStatus(applied=true); reaching 0 means the authoritative
// in-memory ydoc on the server now contains our write.
const waitForPersistence = () => {
if (settled) return;
// A missing provider is a failure, not a success: without it the write
// can never have been acknowledged.
if (!this.provider) {
localFinish(new Error("collab provider gone before persistence"));
return;
}
if (this.provider.unsyncedChanges === 0) {
localFinish(null, mutationResult);
return;
}
persistTimer = setTimeout(() => {
localFinish(
new Error(
"Timeout waiting for collaboration server to persist the update",
),
);
}, PERSIST_TIMEOUT_MS);
unsyncedHandler = (data: { number: number }) => {
// Only treat unsyncedChanges->0 as success when the connection is
// still up. A transient disconnect + reconnect handshake can drive the
// counter back to 0 without our write being re-transmitted; in that
// case let the disconnect/close error win instead.
if (data.number === 0 && !this.connectionLost) {
localFinish(null, mutationResult);
}
};
this.provider.on("unsyncedChanges", unsyncedHandler);
};
// CRITICAL: everything between reading the live doc and writing it back
// 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.
let newDoc: any;
let beforeDoc: any;
try {
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
if (
!liveDoc ||
typeof liveDoc !== "object" ||
!Array.isArray(liveDoc.content)
) {
liveDoc = { type: "doc", content: [] };
}
// Snapshot the before-doc for the change report. Docs are
// JSON-serializable, so this is a safe deep clone.
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
newDoc = transform(liveDoc);
if (newDoc == null) {
// Transform aborted — write nothing, return the live doc with a no-op
// change report.
mutationResult = {
doc: liveDoc,
verify: {
changed: false,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "no changes (transform aborted)",
},
};
localFinish(null, mutationResult);
return;
}
// Structural diff into the live fragment (issue #152): preserves the Yjs
// 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);
} catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller.
localFinish(e instanceof Error ? e : new Error(String(e)));
return;
}
// Compute the verifiable change report AFTER the transact write: it only
// needs the JSON before/after, so it cannot affect the atomic read->write
// window, and summarizeChange never throws.
mutationResult = {
doc: newDoc,
verify: summarizeChange(beforeDoc, newDoc),
};
if (process.env.DEBUG)
console.error("Content written, waiting for server to persist...");
waitForPersistence();
});
}
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
armIdle(): void {
if (this.dead || this.ephemeral) return;
if (this.idleTimer) clearTimeout(this.idleTimer);
if (this.cfg.idleMs > 0) {
this.idleTimer = setTimeout(() => {
this.destroy("idle timeout");
}, this.cfg.idleMs);
// Never let the idle timer keep the process alive.
(this.idleTimer as any).unref?.();
}
}
/**
* Idempotent teardown: mark dead, clear timers, remove from the registry, fail
* any pending open/in-flight op, and destroy the provider. `inflightError` is
* the error a pending open or in-flight op is rejected with; `connectionLoss`
* marks the session as connection-lost so the ack guard cannot report a false
* success on a racing unsyncedChanges->0.
*/
private teardown(inflightError: Error | null, connectionLoss: boolean): void {
if (this.dead) return;
this.dead = true;
this.state = "dead";
if (connectionLoss) this.connectionLost = true;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = undefined;
}
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = undefined;
}
// Remove ourselves from the registry (only if we are still the live entry —
// a re-open under the same key must not be evicted by our teardown).
if (sessions.get(this.key) === this) {
sessions.delete(this.key);
}
// Fail a pending open() and any in-flight mutate with the terminal error.
if (!this.openSettled) {
this.openSettled = true;
this.openReject?.(
inflightError ?? new Error("Collaboration session destroyed"),
);
}
if (this.inflightReject) {
const rej = this.inflightReject;
this.inflightReject = undefined;
rej(inflightError ?? new Error("Collaboration session destroyed"));
}
if (this.provider) {
try {
this.provider.destroy();
} catch (e) {}
this.provider = undefined;
}
}
/**
* Public idempotent teardown used by the acquire/eviction paths and by a
* caller that wants the session dropped after a failed op ("next call
* reconnects fresh").
*/
destroy(reason: string): void {
if (this.dead) return;
if (process.env.DEBUG)
console.error(`Destroying collab session ${this.pageId}: ${reason}`);
this.teardown(new Error(`Collaboration session destroyed: ${reason}`), false);
}
}
/** key = wsUrl + pageId + collabToken (identity isolation: invariant 4). */
const sessions = new Map<string, CollabSession>();
function sessionKey(wsUrl: string, pageId: string, token: string): string {
// The token is part of the key so sessions are NEVER shared between different
// users' MCP sessions (HTTP mode), and a token rotation makes a new entry
// while the old one idles out.
return `${wsUrl}${pageId}${token}`;
}
/**
* Get a live, synced CollabSession for a page, reusing a cached one when it is
* still valid or opening a fresh one otherwise. Does NOT take the per-page lock
* the caller MUST already hold it (both call sites run inside withPageLock,
* which is not reentrant, so acquiring the lock here would deadlock
* mutateLiveContentUnlocked).
*/
export async function acquireCollabSession(
pageId: string,
collabToken: string,
baseUrl: string,
opts?: AcquireOptions,
): Promise<CollabSession> {
const cfg = readConfig();
const wsUrl = buildCollabWsUrl(baseUrl);
// Cache disabled (rollback path): open an unregistered ephemeral session that
// self-destroys after its single op — the exact legacy per-op-provider flow.
if (cfg.idleMs <= 0) {
const session = new CollabSession(
sessionKey(wsUrl, pageId, collabToken),
pageId,
wsUrl,
collabToken,
cfg,
true,
opts,
);
await session.open();
return session;
}
const key = sessionKey(wsUrl, pageId, collabToken);
const existing = sessions.get(key);
if (existing) {
if (existing.isReusable()) {
// Reuse. Refresh LRU order (re-insert = most recently used) and re-arm the
// idle TTL so the reuse counts as activity.
sessions.delete(key);
sessions.set(key, existing);
existing.armIdle();
if (process.env.DEBUG)
console.error(`Reusing collab session for page ${pageId}`);
return existing;
}
// Stale (not synced / past max age / lost): drop it and open fresh.
existing.destroy("stale on reuse");
}
// Enforce the registry cap before inserting: destroy-evict the least recently
// used (the first entry in insertion order) until there is room.
while (sessions.size >= cfg.maxEntries) {
const oldestKey: string | undefined = sessions.keys().next().value;
if (oldestKey === undefined) break;
const victim = sessions.get(oldestKey);
if (victim) victim.destroy("evicted (LRU cap)");
// destroy() removes it from the map; guard against a no-op destroy.
if (sessions.has(oldestKey)) sessions.delete(oldestKey);
}
const session = new CollabSession(
key,
pageId,
wsUrl,
collabToken,
cfg,
false,
opts,
);
sessions.set(key, session);
try {
await session.open();
} catch (e) {
// Failed connect/sync: make sure it is not left cached.
session.destroy("open failed");
throw e;
}
session.armIdle();
if (process.env.DEBUG)
console.error(`Opened new collab session for page ${pageId}`);
return session;
}
/**
* Destroy every cached session. Wired into the process shutdown so a hanging
* session does not keep a doc loaded on the server past exit.
*/
export function destroyAllSessions(): void {
for (const session of [...sessions.values()]) {
session.destroy("process shutdown");
}
sessions.clear();
}
/** TEST-ONLY: number of currently cached sessions. */
export function __sessionCountForTests(): number {
return sessions.size;
}
+211 -25
View File
@@ -1,3 +1,4 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer"; import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs"; import * as Y from "yjs";
import WebSocket from "ws"; import WebSocket from "ws";
@@ -13,10 +14,9 @@ import { JSDOM } from "jsdom";
import { markdownToProseMirror } from "@docmost/prosemirror-markdown"; import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
import { docmostExtensions, docmostSchema } from "./docmost-schema.js"; import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js"; import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { VerifyReport } from "./diff.js"; import { summarizeChange, VerifyReport } from "./diff.js";
import { acquireCollabSession } from "./collab-session.js";
export { markdownToProseMirror }; export { markdownToProseMirror };
@@ -194,27 +194,26 @@ export function assertYjsEncodable(doc: any): void {
} }
} }
/** Time we wait for the initial handshake/sync before giving up. */
const CONNECT_TIMEOUT_MS = 25000;
/** Time we wait for the server to acknowledge our write before giving up. */
const PERSIST_TIMEOUT_MS = 20000;
/** /**
* Safely mutate the live content of a page over the collaboration websocket. * Safely mutate the live content of a page over the collaboration websocket.
* *
* This is the single safe write path for every MCP content mutation. It: * This is the single safe write path for every MCP content mutation. It:
* 1. serializes per-page writes through withPageLock (no two MCP writes on * 1. serializes per-page writes through withPageLock (no two MCP writes on
* the same page overlap); * the same page overlap);
* 2. acquires a LIVE, synced CollabSession for the page (issue #400) a * 2. connects to Hocuspocus and waits for the initial sync so the local ydoc
* cached provider whose local ydoc mirrors the authoritative server doc * mirrors the authoritative server doc INCLUDING edits/comments/images
* (INCLUDING edits/comments/images not yet in the debounced REST snapshot), * that are not yet in the debounced REST snapshot;
* reused across a series of edits instead of a fresh connect/auth/sync per * 3. inside onSynced, SYNCHRONOUSLY reads the live doc, runs `transform`, and
* call; * writes the result back with no `await` between read and write so no
* 3. SYNCHRONOUSLY reads the live doc, runs `transform`, and writes the result * remote update can interleave and clobber concurrent human edits;
* back with no `await` between read and write so no remote update can
* interleave and clobber concurrent human edits (CollabSession.mutate);
* 4. waits for the server to acknowledge the write (unsyncedChanges -> 0) * 4. waits for the server to acknowledge the write (unsyncedChanges -> 0)
* before resolving, so the next operation observes our change. * before resolving, so the next operation observes our change.
* *
* On any mutate failure the session is destroyed so the next call reconnects
* fresh; the page lock is held for the whole acquire+mutate so the session's
* synchronous read->write window never overlaps another MCP write on the page.
*
* `transform` receives the live ProseMirror doc and returns the NEW full * `transform` receives the live ProseMirror doc and returns the NEW full
* ProseMirror doc to write, or `null` to abort with no write (a no-op). If * ProseMirror doc to write, or `null` to abort with no write (a no-op). If
* `transform` throws, the error is propagated to the caller (not swallowed). * `transform` throws, the error is propagated to the caller (not swallowed).
@@ -231,7 +230,7 @@ export async function mutatePageContent(
baseUrl: string, baseUrl: string,
transform: (liveDoc: any) => any | null, transform: (liveDoc: any) => any | null,
): Promise<MutationResult> { ): Promise<MutationResult> {
return withPageLock(pageId, async () => { return withPageLock(pageId, () => {
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.error(`Starting realtime content mutate for page ${pageId}`); console.error(`Starting realtime content mutate for page ${pageId}`);
// Token prefix is sensitive; only log it under DEBUG. // Token prefix is sensitive; only log it under DEBUG.
@@ -240,15 +239,202 @@ export async function mutatePageContent(
); );
} }
const session = await acquireCollabSession(pageId, collabToken, baseUrl); const ydoc = new Y.Doc();
try { const wsUrl = buildCollabWsUrl(baseUrl);
return await session.mutate(transform); if (process.env.DEBUG) console.error(`Connecting to WebSocket: ${wsUrl}`);
} catch (e) {
// Drop the session on any failure so the next call reconnects fresh (this return new Promise<MutationResult>((resolve, reject) => {
// also closes the "reconnect drove the counter to 0" false-success class). let provider: HocuspocusProvider | undefined;
session.destroy("mutate failed"); let applied = false; // onSynced may fire again on reconnect — apply once.
throw e; let settled = false;
} // Set true on disconnect/close so a reconnect-driven unsyncedChanges->0
// cannot be mistaken for a successful persist of our write.
let connectionLost = false;
let connectTimer: ReturnType<typeof setTimeout> | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let unsyncedHandler: ((data: { number: number }) => void) | undefined;
const cleanup = () => {
if (connectTimer) clearTimeout(connectTimer);
if (persistTimer) clearTimeout(persistTimer);
if (provider) {
if (unsyncedHandler) {
try {
provider.off("unsyncedChanges", unsyncedHandler);
} catch (err) {}
}
try {
provider.destroy();
} catch (err) {}
}
};
const finish = (err: Error | null, value?: MutationResult) => {
if (settled) return;
settled = true;
cleanup();
if (err) reject(err);
else resolve(value as MutationResult);
};
connectTimer = setTimeout(() => {
finish(new Error("Connection timeout to collaboration server"));
}, CONNECT_TIMEOUT_MS);
// Resolve once the server has acknowledged our update. The provider
// increments unsyncedChanges when our local update is sent and
// decrements it when the server replies with a SyncStatus(applied=true);
// reaching 0 means the authoritative in-memory ydoc on the server now
// contains our write.
const waitForPersistence = () => {
if (settled) return;
// A missing provider is a failure, not a success: without it the write
// can never have been acknowledged. Only an actual unsyncedChanges===0
// on a live provider counts as persisted.
if (!provider) {
finish(new Error("collab provider gone before persistence"));
return;
}
if (provider.unsyncedChanges === 0) {
finish(null, mutationResult);
return;
}
persistTimer = setTimeout(() => {
finish(
new Error(
"Timeout waiting for collaboration server to persist the update",
),
);
}, PERSIST_TIMEOUT_MS);
unsyncedHandler = (data: { number: number }) => {
// Only treat unsyncedChanges->0 as success when the connection is
// still up. A transient disconnect + reconnect handshake can drive
// the counter back to 0 without our write being re-transmitted; in
// that case let the disconnect/close error win instead.
if (data.number === 0 && !connectionLost) {
finish(null, mutationResult);
}
};
provider.on("unsyncedChanges", unsyncedHandler);
};
// The verifiable result resolved on every success/abort path. Set on
// abort (no-op report) and after a real write (computed change report).
let mutationResult: MutationResult;
provider = new HocuspocusProvider({
url: wsUrl,
name: `page.${pageId}`,
document: ydoc,
token: collabToken,
// @ts-ignore - Required for Node.js environment
WebSocketPolyfill: WebSocket,
onConnect: () => {
if (process.env.DEBUG) console.error("WS Connect");
},
// An unexpected disconnect/close while we are still waiting (during the
// connect-wait before onSynced, or during the persistence wait after the
// write) means the update will never be acknowledged — surface it now
// instead of hanging until the connect/persist timeout fires. `finish`
// is idempotent via the `settled` flag, so the onClose that our own
// cleanup()->provider.destroy() triggers (after settled=true is set) is
// a harmless no-op and cannot cause a double-resolve.
onDisconnect: () => {
if (process.env.DEBUG) console.error("WS Disconnect");
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
// sees the connection as lost and won't report a false success.
connectionLost = true;
finish(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
);
},
onClose: () => {
if (process.env.DEBUG) console.error("WS Close");
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
// sees the connection as lost and won't report a false success.
connectionLost = true;
finish(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
);
},
onSynced: () => {
if (applied || settled) return;
applied = true;
if (process.env.DEBUG) console.error("Connected and synced!");
// CRITICAL: everything between reading the live doc and writing it
// back 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.
let newDoc: any;
let beforeDoc: any;
try {
let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default");
if (
!liveDoc ||
typeof liveDoc !== "object" ||
!Array.isArray(liveDoc.content)
) {
liveDoc = { type: "doc", content: [] };
}
// Snapshot the before-doc for the change report. Docs are
// JSON-serializable, so this is a safe deep clone.
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
newDoc = transform(liveDoc);
if (newDoc == null) {
// Transform aborted — write nothing, return the live doc with a
// no-op change report.
mutationResult = {
doc: liveDoc,
verify: {
changed: false,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "no changes (transform aborted)",
},
};
finish(null, mutationResult);
return;
}
// Structural diff into the live fragment (issue #152): preserves
// the Yjs ids of unchanged nodes, so an open editor's cursor is not
// yanked to the end of the document on every agent write.
applyDocToFragment(ydoc, newDoc);
} catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller.
finish(e instanceof Error ? e : new Error(String(e)));
return;
}
// Compute the verifiable change report AFTER the transact write: it
// only needs the JSON before/after, so it cannot affect the atomic
// read->write window, and summarizeChange never throws.
mutationResult = {
doc: newDoc,
verify: summarizeChange(beforeDoc, newDoc),
};
if (process.env.DEBUG)
console.error("Content written, waiting for server to persist...");
waitForPersistence();
},
onAuthenticationFailed: () => {
finish(
new Error("Authentication failed for collaboration connection"),
);
},
});
});
}); });
} }
+10 -84
View File
@@ -17,23 +17,8 @@
* comparing and match across maximal runs of consecutive text nodes within a * comparing and match across maximal runs of consecutive text nodes within a
* single block, while mapping every normalized character back to its raw index * single block, while mapping every normalized character back to its raw index
* so the mark lands on the exact original characters. * so the mark lands on the exact original characters.
*
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
* match the document's plain text. Exactly like edit_page_text's json-edit
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
* `resolveAnchorSelection`. `countAnchorMatches` keeps its OWN parallel exact-wins
* implementation (it needs a raw match COUNT, not a single resolved locator), kept
* deliberately in sync with `resolveAnchorSelection`: raw match use raw, else fall
* back to the stripped count. All four therefore agree on which locator matched
* the suggestion-uniqueness gate depends on count and can/get never disagreeing, so
* these two exact-wins implementations MUST stay in sync if either is changed.
*/ */
import { stripInlineMarkdown } from "./text-normalize.js";
/** Typographic double-quote variants mapped to ASCII `"`. */ /** Typographic double-quote variants mapped to ASCII `"`. */
const DOUBLE_QUOTES = "«»„“”‟〝〞""; const DOUBLE_QUOTES = "«»„“”‟〝〞"";
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */ /** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
@@ -229,17 +214,15 @@ function reconstructRawText(blockContent: any[], match: AnchorMatch): string {
* un-appliable (spurious 409). * un-appliable (spurious 409).
*/ */
export function getAnchoredText(doc: any, selection: string): string | null { export function getAnchoredText(doc: any, selection: string): string | null {
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
if (!found) return null;
const visit = (node: any, depth: number): string | null => { const visit = (node: any, depth: number): string | null => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return null; if (depth > MAX_DEPTH || !node || typeof node !== "object") return null;
if (!Array.isArray(node.content)) return null; if (!Array.isArray(node.content)) return null;
const match = findAnchorInBlock(node.content, effective); const match = findAnchorInBlock(node.content, selection);
if (match) return reconstructRawText(node.content, match); if (match) return reconstructRawText(node.content, match);
for (const child of node.content) { for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) { if (child && typeof child === "object" && Array.isArray(child.content)) {
const foundText = visit(child, depth + 1); const found = visit(child, depth + 1);
if (foundText !== null) return foundText; if (found !== null) return found;
} }
} }
return null; return null;
@@ -248,11 +231,12 @@ export function getAnchoredText(doc: any, selection: string): string | null {
} }
/** /**
* RAW (no markdown-strip fallback) depth-first check that `selection` anchors * Depth-first, document-order check for whether `selection` can be anchored
* somewhere in `doc`. This is the primitive `resolveAnchorSelection` builds on; * anywhere in `doc`. At each node with an array `content`, first try to match
* public callers should use `canAnchorInDoc`, which adds the strip fallback. * within that node's own content, then recurse into children that themselves
* have a `content` array.
*/ */
function rawCanAnchorInDoc(doc: any, selection: string): boolean { export function canAnchorInDoc(doc: any, selection: string): boolean {
const visit = (node: any, depth: number): boolean => { const visit = (node: any, depth: number): boolean => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false; if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
if (!Array.isArray(node.content)) return false; if (!Array.isArray(node.content)) return false;
@@ -267,43 +251,6 @@ function rawCanAnchorInDoc(doc: any, selection: string): boolean {
return visit(doc, 0); return visit(doc, 0);
} }
/**
* Decide the locator that ACTUALLY anchors `selection` in `doc`, applying the
* markdown-strip fallback once (so every public entry point agrees):
* - EXACT WINS: if the verbatim selection anchors anywhere, use it as-is.
* - FALLBACK: only if the verbatim selection anchors nowhere, and the
* markdown-stripped form differs and DOES anchor, use the stripped form and
* flag `normalized` so callers can surface a soft warning.
* - otherwise `found` is false and `selection` is returned unchanged.
*
* The stripped form is used ONLY to LOCATE the anchor; getAnchoredText still
* reconstructs and stores the RAW document substring, so the strip never leaks
* into what gets persisted.
*/
export function resolveAnchorSelection(
doc: any,
selection: string,
): { selection: string; found: boolean; normalized: boolean } {
if (rawCanAnchorInDoc(doc, selection)) {
return { selection, found: true, normalized: false };
}
const stripped = stripInlineMarkdown(selection);
if (stripped !== selection && rawCanAnchorInDoc(doc, stripped)) {
return { selection: stripped, found: true, normalized: true };
}
return { selection, found: false, normalized: false };
}
/**
* Depth-first, document-order check for whether `selection` can be anchored
* anywhere in `doc` (with the markdown-strip fallback). At each node with an
* array `content`, first try to match within that node's own content, then
* recurse into children that themselves have a `content` array.
*/
export function canAnchorInDoc(doc: any, selection: string): boolean {
return resolveAnchorSelection(doc, selection).found;
}
/** /**
* Split the matched text nodes and splice the comment mark across the range. * Split the matched text nodes and splice the comment mark across the range.
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text * `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
@@ -368,7 +315,7 @@ function spliceCommentMark(
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two * not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
* occurrences inside one block are correctly reported as 2.) * occurrences inside one block are correctly reported as 2.)
*/ */
function rawCountAnchorMatches(doc: any, selection: string): number { export function countAnchorMatches(doc: any, selection: string): number {
const normSel = normalizeForMatch(selection).norm.trim(); const normSel = normalizeForMatch(selection).norm.trim();
if (normSel.length === 0) return 0; if (normSel.length === 0) return 0;
@@ -422,25 +369,6 @@ function rawCountAnchorMatches(doc: any, selection: string): number {
return total; return total;
} }
/**
* Uniqueness gate for suggestions, with the SAME markdown-strip fallback as the
* other entry points so count never disagrees with can/get/apply. EXACT WINS: if
* the verbatim selection occurs at all, return its raw occurrence count (so a
* selection that is unique raw stays unique the fallback never runs and cannot
* introduce a spurious second match). Only when the verbatim selection is absent
* do we count occurrences of the markdown-stripped form.
*/
export function countAnchorMatches(doc: any, selection: string): number {
const raw = rawCountAnchorMatches(doc, selection);
if (raw > 0) return raw;
const stripped = stripInlineMarkdown(selection);
if (stripped !== selection) {
const strippedCount = rawCountAnchorMatches(doc, stripped);
if (strippedCount > 0) return strippedCount;
}
return 0;
}
/** /**
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block * Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
* whose content matches `selection`, splice the comment mark across the matched * whose content matches `selection`, splice the comment mark across the matched
@@ -452,12 +380,10 @@ export function applyAnchorInDoc(
selection: string, selection: string,
commentId: string, commentId: string,
): boolean { ): boolean {
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
if (!found) return false;
const visit = (node: any, depth: number): boolean => { const visit = (node: any, depth: number): boolean => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false; if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
if (!Array.isArray(node.content)) return false; if (!Array.isArray(node.content)) return false;
const match = findAnchorInBlock(node.content, effective); const match = findAnchorInBlock(node.content, selection);
if (match) { if (match) {
spliceCommentMark(node.content, match, commentId); spliceCommentMark(node.content, match, commentId);
return true; return true;
-193
View File
@@ -1,193 +0,0 @@
// Pure-TS schematic SVG preview for draw.io diagrams (issue #423, stage 1).
//
// HARD CONSTRAINT: no backend rendering. This is a dependency-free string
// builder — given the parsed mxGraph cells it draws a rough schematic (rects,
// ellipses, diamonds, edges + labels) that stands in as the diagram's visible
// image UNTIL a human first opens it in the draw.io editor and saves, at which
// point the client replaces this with the pixel-perfect export SVG. It is
// deliberately approximate: it exists so a freshly-agent-created diagram is not
// an empty box in the page.
import type { DrawioCell, DrawioBBox } from "./drawio-xml.js";
import { absolutePos } from "./drawio-xml.js";
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Strip HTML markup from a cell value (draw.io labels are HTML when html=1),
* decode the handful of entities we care about, and collapse whitespace so the
* label fits on the schematic. `<br>` becomes a space (this is a one-line
* preview label, not a faithful multi-line render).
*/
function labelText(value: string): string {
return value
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&#xa;|&#10;/gi, " ")
.replace(/&amp;/g, "&")
.replace(/\s+/g, " ")
.trim();
}
function centeredLabel(cx: number, cy: number, value: string, color = "#000000"): string {
const text = labelText(value);
if (!text) return "";
return (
`<text x="${round(cx)}" y="${round(cy)}" ` +
`font-family="Helvetica, Arial, sans-serif" font-size="12" ` +
`text-anchor="middle" dominant-baseline="middle" fill="${esc(color)}">` +
`${esc(text)}</text>`
);
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
interface ShapeKind {
kind: "ellipse" | "rhombus" | "triangle" | "rect";
}
/**
* Decide which schematic primitive to draw for a vertex. A shape can be named
* either as the style's base token (e.g. "ellipse;…") or as a key (e.g.
* "shape=rhombus" / "ellipse=1"), so both the base style and the map are
* checked.
*/
function shapeKind(
styleMap: Record<string, string>,
baseStyle?: string,
): ShapeKind {
const shape = styleMap.shape ?? baseStyle;
const has = (name: string) => shape === name || styleMap[name] != null;
if (has("ellipse")) return { kind: "ellipse" };
if (has("rhombus")) return { kind: "rhombus" };
if (has("triangle")) return { kind: "triangle" };
// Everything else — including unknown stencils (shape=mxgraph.*), swimlanes,
// and plain boxes — is drawn as a (rounded) rectangle.
return { kind: "rect" };
}
function fill(styleMap: Record<string, string>): string {
const c = styleMap.fillColor;
if (!c || c.toLowerCase() === "none") return "#ffffff";
return c;
}
function stroke(styleMap: Record<string, string>): string {
const c = styleMap.strokeColor;
if (!c || c.toLowerCase() === "none") return "#000000";
return c;
}
/**
* Render the schematic shapes as the INNER content of the `.drawio.svg` (the
* outer <svg> wrapper is added by drawio-xml.buildDrawioSvg). Coordinates are
* absolute (container children are resolved via the parent chain).
*/
export function renderDiagramShapes(cells: DrawioCell[], _bbox: DrawioBBox): string {
const byId = new Map(cells.map((c) => [c.id, c]));
const parts: string[] = [];
// Edges first so vertices sit on top of their connectors.
for (const c of cells) {
if (!c.edge) continue;
parts.push(renderEdge(c, byId));
}
for (const c of cells) {
if (!c.vertex || !c.geometry.hasGeometry) continue;
const g = c.geometry;
if (g.width == null || g.height == null) continue;
const { x, y } = absolutePos(c, byId);
parts.push(renderVertex(c, x, y, g.width, g.height));
}
return `<g>${parts.filter(Boolean).join("")}</g>`;
}
function renderVertex(
c: DrawioCell,
x: number,
y: number,
w: number,
h: number,
): string {
const f = esc(fill(c.styleMap));
const s = esc(stroke(c.styleMap));
const { kind } = shapeKind(c.styleMap, c.baseStyle);
const cx = x + w / 2;
const cy = y + h / 2;
let shape = "";
switch (kind) {
case "ellipse":
shape =
`<ellipse cx="${round(cx)}" cy="${round(cy)}" rx="${round(w / 2)}" ` +
`ry="${round(h / 2)}" fill="${f}" stroke="${s}"/>`;
break;
case "rhombus": {
const pts = [
`${round(cx)},${round(y)}`,
`${round(x + w)},${round(cy)}`,
`${round(cx)},${round(y + h)}`,
`${round(x)},${round(cy)}`,
].join(" ");
shape = `<polygon points="${pts}" fill="${f}" stroke="${s}"/>`;
break;
}
case "triangle": {
const pts = [
`${round(x)},${round(y)}`,
`${round(x + w)},${round(cy)}`,
`${round(x)},${round(y + h)}`,
].join(" ");
shape = `<polygon points="${pts}" fill="${f}" stroke="${s}"/>`;
break;
}
default: {
const rounded = c.styleMap.rounded === "1";
const rx = rounded ? Math.min(12, w / 2, h / 2) : 0;
shape =
`<rect x="${round(x)}" y="${round(y)}" width="${round(w)}" ` +
`height="${round(h)}" rx="${round(rx)}" ry="${round(rx)}" ` +
`fill="${f}" stroke="${s}"/>`;
}
}
return shape + centeredLabel(cx, cy, c.value, c.styleMap.fontColor || "#000000");
}
function renderEdge(c: DrawioCell, byId: Map<string, DrawioCell>): string {
const src = c.source != null ? byId.get(c.source) : undefined;
const tgt = c.target != null ? byId.get(c.target) : undefined;
const p1 = anchorPoint(src, byId);
const p2 = anchorPoint(tgt, byId);
if (!p1 || !p2) return ""; // a floating endpoint with no fixed point: skip
const line =
`<line x1="${round(p1.x)}" y1="${round(p1.y)}" ` +
`x2="${round(p2.x)}" y2="${round(p2.y)}" ` +
`stroke="#000000" stroke-width="1"/>`;
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
return line + centeredLabel(mid.x, mid.y, c.value);
}
/** Center point of a vertex used as an edge anchor (approximate). */
function anchorPoint(
cell: DrawioCell | undefined,
byId: Map<string, DrawioCell>,
): { x: number; y: number } | null {
if (!cell || !cell.geometry.hasGeometry) return null;
const g = cell.geometry;
if (g.width == null || g.height == null) return null;
const { x, y } = absolutePos(cell, byId);
return { x: x + g.width / 2, y: y + g.height / 2 };
}
-771
View File
@@ -1,771 +0,0 @@
// draw.io (mxGraph) XML support for the MCP drawio tools (issue #423, stage 1).
//
// This module owns everything that is pure data-plumbing for draw.io diagrams:
// - the DECODE CHAIN that turns a stored `diagram.drawio.svg` attachment back
// into mxGraph XML (handles both the plain nested-XML form Docmost writes
// and draw.io's own COMPRESSED `<diagram>` payload — base64 + raw-deflate);
// - the ENCODE side that wraps mxGraph XML into the `.drawio.svg` attachment
// using the exact same contract as the import service's createDrawioSvg;
// - a deterministic LINTER that rejects the structural mistakes generators
// make before anything is written (each violation carries the offending
// cellId + position so the model can auto-retry);
// - a stable HASH over the normalized XML, used as the optimistic-lock key.
//
// HARD CONSTRAINT: no backend rendering. Nothing here shells out or renders a
// bitmap; the only runtime dependencies are jsdom (already used across this
// package for XML parsing) and pako (raw-inflate for the compressed format).
import { createHash } from "node:crypto";
import { JSDOM } from "jsdom";
import pako from "pako";
// --- shared XML parser -----------------------------------------------------
// A single reusable JSDOM window; constructing one per parse is wasteful and
// these tools are low-frequency. Only the DOMParser is used.
let _window: any = null;
function xmlWindow(): any {
if (!_window) _window = new JSDOM("").window;
return _window;
}
/** Default mxGraphModel attributes used when the server wraps a cell list. */
const DEFAULT_MODEL_ATTRS =
'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100"';
// --- structured lint errors ------------------------------------------------
export interface DrawioLintIssue {
/** Machine-readable rule id, e.g. "edge-geometry". */
rule: string;
/** Human-readable explanation the model can act on. */
message: string;
/** The offending cell's id, when the rule is cell-scoped. */
cellId?: string;
/** Extra location info: cell index in <root>, or a parser line:col. */
position?: string;
}
/**
* Thrown by the linter and by decode/prepare when the input is unusable. Carries
* the full list of issues so the caller can surface a structured tool-error the
* model auto-retries against.
*/
export class DrawioLintError extends Error {
issues: DrawioLintIssue[];
constructor(issues: DrawioLintIssue[]) {
const summary = issues
.map((i) => {
const where = [
i.cellId != null ? `cellId=${i.cellId}` : null,
i.position != null ? `at ${i.position}` : null,
]
.filter(Boolean)
.join(", ");
return `[${i.rule}] ${i.message}${where ? ` (${where})` : ""}`;
})
.join("; ");
super(`drawio lint failed: ${summary}`);
this.name = "DrawioLintError";
this.issues = issues;
}
}
// --- parsed-cell model -----------------------------------------------------
export interface DrawioGeometry {
x?: number;
y?: number;
width?: number;
height?: number;
relative: boolean;
hasGeometry: boolean;
}
export interface DrawioCell {
id: string;
parent?: string;
source?: string;
target?: string;
vertex: boolean;
edge: boolean;
value: string;
style: string;
styleMap: Record<string, string>;
/** Non-key/value leading token of the style (a base stylename), if any. */
baseStyle?: string;
geometry: DrawioGeometry;
}
export interface DrawioBBox {
width: number;
height: number;
}
// --- style parsing ---------------------------------------------------------
/**
* Parse a draw.io style string into { baseStyle, map }. Grammar:
* [stylename;]key=value;key=value;...
* A single leading token without '=' is the base stylename (e.g. "text" or
* "ellipse"). Every other non-empty segment must be exactly one key=value pair.
* Returns `null` (the segment index) on the first malformed segment so the
* linter can report a precise error.
*/
export function parseStyle(
style: string,
): { baseStyle?: string; map: Record<string, string>; badSegment?: string } {
const map: Record<string, string> = {};
let baseStyle: string | undefined;
const segments = style.split(";");
for (let i = 0; i < segments.length; i++) {
const seg = segments[i].trim();
if (seg === "") continue; // trailing/empty segments are fine
const eq = seg.indexOf("=");
if (eq === -1) {
// A bare token is only valid as the FIRST meaningful segment (base style).
if (baseStyle === undefined && Object.keys(map).length === 0) {
baseStyle = seg;
continue;
}
return { baseStyle, map, badSegment: seg };
}
// A second '=' inside the same segment is malformed.
if (seg.indexOf("=", eq + 1) !== -1) {
return { baseStyle, map, badSegment: seg };
}
const key = seg.slice(0, eq).trim();
const val = seg.slice(eq + 1).trim();
if (key === "") return { baseStyle, map, badSegment: seg };
map[key] = val;
}
return { baseStyle, map };
}
// --- low-level XML helpers -------------------------------------------------
function parseXml(xml: string): { doc: any; error: string | null } {
const parser = new (xmlWindow().DOMParser)();
const doc = parser.parseFromString(xml, "application/xml");
const err = doc.getElementsByTagName("parsererror");
if (err.length > 0) {
// jsdom prefixes the message with "line:col:" — keep it as the position.
return { doc, error: (err[0].textContent || "malformed XML").trim() };
}
return { doc, error: null };
}
function num(v: string | null): number | undefined {
if (v == null || v === "") return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
/** Extract the raw `<mxGraphModel …>…</mxGraphModel>` substring, or null. */
function sliceModel(xml: string): string | null {
const open = xml.indexOf("<mxGraphModel");
if (open === -1) return null;
const close = xml.indexOf("</mxGraphModel>", open);
if (close === -1) {
// Self-closed empty model, e.g. `<mxGraphModel .../>`.
const selfClose = xml.indexOf("/>", open);
if (selfClose !== -1) return xml.slice(open, selfClose + 2);
return null;
}
return xml.slice(open, close + "</mxGraphModel>".length);
}
// --- decode chain ----------------------------------------------------------
/**
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a
* base64 payload there (createDrawioSvg); draw.io's own SVG export may store the
* XML entity-encoded instead. The DOM decodes entities for us, so the caller
* only has to distinguish "starts with '<'" (raw XML) from base64.
*/
export function extractContentAttr(svg: string): string {
const { doc, error } = parseXml(svg);
if (!error) {
const root = doc.documentElement;
if (root && root.hasAttribute && root.hasAttribute("content")) {
return root.getAttribute("content") || "";
}
}
// Fallback for a malformed wrapper: pull the attribute directly. The content
// value itself never contains a double-quote (base64 / entity-encoded XML).
const m = /content="([^"]*)"/.exec(svg);
if (m) {
// Decode the handful of XML entities a raw regex would leave encoded.
return m[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&");
}
throw new Error("drawio: SVG has no content= attribute to decode");
}
/**
* Turn a decoded draw.io file (`<mxfile>` or a bare `<mxGraphModel>`, possibly
* with a COMPRESSED `<diagram>` payload) into the mxGraphModel XML. For the
* plain form the raw substring is returned verbatim so a round-trip stays
* byte-stable; the compressed form is inflated (base64 raw-deflate
* decodeURIComponent), which is how draw.io stores diagrams by default.
*/
export function decodeDrawioFileToModel(fileXml: string): string {
// Plain, nested XML: return the model substring untouched (byte-stable).
const sliced = sliceModel(fileXml);
if (sliced) return sliced;
// Otherwise it must be the compressed `<diagram>…</diagram>` text payload.
const open = fileXml.indexOf("<diagram");
if (open !== -1) {
const gt = fileXml.indexOf(">", open);
const close = fileXml.indexOf("</diagram>", gt);
if (gt !== -1 && close !== -1) {
const payload = fileXml.slice(gt + 1, close).trim();
if (payload) {
const inflated = inflateDiagramPayload(payload);
const model = sliceModel(inflated);
if (model) return model;
return inflated;
}
}
}
throw new Error(
"drawio: could not decode file — no <mxGraphModel> and no compressed <diagram> payload",
);
}
/**
* Upper bound on the inflated size of a compressed `<diagram>` payload
* (decompression-bomb guard). `fetchInternalFile` caps the DOWNLOAD at 64 MiB,
* but a tiny crafted compressed payload can inflate to gigabytes and OOM the
* process. A real diagram's mxGraphModel XML is small (KBs to low MBs even for
* large diagrams), so 16 MiB is far above any legitimate payload while keeping
* memory bounded. Chars ~= bytes for the (mostly ASCII) URI-encoded XML.
*/
export const MAX_INFLATED_DIAGRAM_BYTES = 16 * 1024 * 1024;
/**
* Inflate draw.io's compressed diagram payload:
* base64-decode raw-inflate (raw deflate, windowBits -15)
* decodeURIComponent.
*
* Uses pako's streaming Inflate so we can abort as soon as the decompressed
* output exceeds MAX_INFLATED_DIAGRAM_BYTES the full bomb is never
* materialised in memory.
*/
export function inflateDiagramPayload(base64: string): string {
const bytes = Buffer.from(base64, "base64");
const inflator = new pako.Inflate({ raw: true, to: "string" });
let total = 0;
const passthrough = inflator.onData.bind(inflator);
inflator.onData = (chunk: string | Uint8Array) => {
total += chunk.length;
if (total > MAX_INFLATED_DIAGRAM_BYTES) {
// Throwing here propagates out of push(), aborting inflation immediately.
throw new Error(
`drawio: refusing to decode diagram — decompressed size exceeds ` +
`${MAX_INFLATED_DIAGRAM_BYTES} bytes (possible decompression bomb)`,
);
}
passthrough(chunk);
};
inflator.push(bytes, true);
if (inflator.err) {
throw new Error(
`drawio: failed to inflate compressed <diagram> payload (${inflator.msg || inflator.err})`,
);
}
const uriEncoded = inflator.result as string;
return decodeURIComponent(uriEncoded);
}
/** Full decode chain: `.drawio.svg` string → mxGraphModel XML. */
export function decodeDrawioSvg(svg: string): string {
const content = extractContentAttr(svg).trim();
const fileXml = content.startsWith("<")
? content
: Buffer.from(content, "base64").toString("utf-8");
return decodeDrawioFileToModel(fileXml);
}
// --- encode side -----------------------------------------------------------
/**
* Wrap an mxGraphModel in the plain (uncompressed) `<mxfile><diagram>` envelope.
* draw.io opens uncompressed XML fine, and staying uncompressed keeps the
* write path deterministic and the round-trip byte-stable.
*/
export function encodeDrawioFile(modelXml: string, title = "Page-1"): string {
const safeTitle = xmlEscape(title);
return `<mxfile host="drawio"><diagram id="page-1" name="${safeTitle}">${modelXml}</diagram></mxfile>`;
}
/**
* Build the `diagram.drawio.svg` attachment. Mirrors the import service's
* createDrawioSvg contract exactly:
* <svg xmlns= xmlns:xlink= content="${base64(drawioFile)}">${inner}</svg>
* plus width/height/viewBox from the diagram bounding box and the schematic
* preview as the visible children (`inner`).
*/
export function buildDrawioSvg(
modelXml: string,
inner: string,
bbox: DrawioBBox,
title = "Page-1",
): string {
const file = encodeDrawioFile(modelXml, title);
const base64 = Buffer.from(file, "utf-8").toString("base64");
const w = Math.max(1, Math.round(bbox.width));
const h = Math.max(1, Math.round(bbox.height));
return (
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`xmlns:xlink="http://www.w3.org/1999/xlink" ` +
`width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
`content="${base64}">${inner}</svg>`
);
}
function xmlEscape(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// --- normalization + hash --------------------------------------------------
/**
* Normalize mxGraph XML for hashing / stable comparison: drop the whitespace
* between tags and trim. This is intentionally conservative it never reorders
* attributes or cells (that would be lossy) so two documents hash equal iff
* they differ only in inter-tag formatting.
*/
export function normalizeXml(xml: string): string {
return xml.replace(/>\s+</g, "><").trim();
}
/** Stable optimistic-lock hash over the normalized model XML (sha256, hex). */
export function mxHash(modelXml: string): string {
return createHash("sha256").update(normalizeXml(modelXml), "utf-8").digest("hex");
}
// --- cell parsing ----------------------------------------------------------
/** Parse every `<mxCell>` in a model into a structured DrawioCell list. */
export function parseCells(modelXml: string): DrawioCell[] {
const { doc, error } = parseXml(modelXml);
if (error) {
throw new DrawioLintError([
{ rule: "well-formed-xml", message: error, position: firstLineCol(error) },
]);
}
const cells: DrawioCell[] = [];
const els = doc.getElementsByTagName("mxCell");
for (let i = 0; i < els.length; i++) {
cells.push(readCell(els[i]));
}
return cells;
}
function readCell(el: any): DrawioCell {
const style = el.getAttribute("style") || "";
const parsed = parseStyle(style);
const geoEl = firstChildByTag(el, "mxGeometry");
const geometry: DrawioGeometry = geoEl
? {
x: num(geoEl.getAttribute("x")),
y: num(geoEl.getAttribute("y")),
width: num(geoEl.getAttribute("width")),
height: num(geoEl.getAttribute("height")),
relative: geoEl.getAttribute("relative") === "1",
hasGeometry: true,
}
: { relative: false, hasGeometry: false };
return {
id: el.getAttribute("id") ?? "",
parent: el.getAttribute("parent") ?? undefined,
source: el.getAttribute("source") ?? undefined,
target: el.getAttribute("target") ?? undefined,
vertex: el.getAttribute("vertex") === "1",
edge: el.getAttribute("edge") === "1",
value: el.getAttribute("value") ?? "",
style,
styleMap: parsed.map,
baseStyle: parsed.baseStyle,
geometry,
};
}
function firstChildByTag(el: any, tag: string): any {
for (let i = 0; i < el.childNodes.length; i++) {
const c = el.childNodes[i];
if (c.nodeType === 1 && c.tagName === tag) return c;
}
return null;
}
function firstLineCol(msg: string): string | undefined {
const m = /^(\d+:\d+)/.exec(msg);
return m ? m[1] : undefined;
}
// --- bounding box ----------------------------------------------------------
/**
* Absolute bounding box of the diagram from its vertex geometries. Container
* children are relative, so absolute positions are resolved along the parent
* chain before taking the extent. Falls back to a default canvas when empty.
*/
export function computeBBox(cells: DrawioCell[]): DrawioBBox {
const byId = new Map(cells.map((c) => [c.id, c]));
let maxX = 0;
let maxY = 0;
let any = false;
for (const c of cells) {
if (!c.vertex || !c.geometry.hasGeometry) continue;
const g = c.geometry;
if (g.width == null || g.height == null) continue;
const { x, y } = absolutePos(c, byId);
maxX = Math.max(maxX, x + g.width);
maxY = Math.max(maxY, y + g.height);
any = true;
}
if (!any) return { width: 300, height: 200 };
// A small margin so borders/labels are not clipped at the edge.
return { width: Math.ceil(maxX) + 20, height: Math.ceil(maxY) + 20 };
}
/** Absolute (x,y) of a vertex, following its parent chain (containers). */
export function absolutePos(
cell: DrawioCell,
byId: Map<string, DrawioCell>,
): { x: number; y: number } {
let x = cell.geometry.x ?? 0;
let y = cell.geometry.y ?? 0;
const seen = new Set<string>([cell.id]);
let parentId = cell.parent;
while (parentId && !seen.has(parentId)) {
seen.add(parentId);
const p = byId.get(parentId);
// Sentinels (0/1) carry no geometry; stop there.
if (!p || !p.vertex || !p.geometry.hasGeometry) break;
x += p.geometry.x ?? 0;
y += p.geometry.y ?? 0;
parentId = p.parent;
}
return { x, y };
}
// --- linter ----------------------------------------------------------------
/**
* Run every deterministic pre-write rule over a full mxGraphModel string. On any
* violation it throws a DrawioLintError carrying one issue per violation, each
* with the offending cellId + position. Returns the parsed cells on success.
*/
export function lintModel(modelXml: string): {
cells: DrawioCell[];
warnings: string[];
} {
const issues: DrawioLintIssue[] = [];
const warnings: string[] = [];
// Rule: no XML comments. Checked on the raw string (a comment survives DOM
// parsing as a comment node, but the intent is to reject them outright — they
// routinely wrap "TODO" cruft that breaks downstream tooling).
if (modelXml.includes("<!--")) {
issues.push({
rule: "no-comments",
message: "XML comments (<!-- -->) are not allowed in diagram XML",
});
}
// Rule: value escaping + literal newline. Scan raw <mxCell> tags so the error
// can name the cell id even when the whole document is otherwise malformed.
scanRawValues(modelXml, issues);
// Well-formedness — everything below needs a parsed DOM.
const { doc, error } = parseXml(modelXml);
if (error) {
issues.push({
rule: "well-formed-xml",
message: error,
position: firstLineCol(error),
});
throw new DrawioLintError(issues);
}
const root = doc.documentElement;
if (!root || root.tagName !== "mxGraphModel") {
issues.push({
rule: "structure",
message: `root element must be <mxGraphModel>, got <${root ? root.tagName : "?"}>`,
});
throw new DrawioLintError(issues);
}
if (!firstChildByTag(root, "root")) {
issues.push({
rule: "structure",
message: "<mxGraphModel> must contain a <root> element",
});
throw new DrawioLintError(issues);
}
const cells = parseCells(modelXml);
const ids = new Set<string>();
// Rule: sentinel cells id="0" and id="1"(parent="0").
const cell0 = cells.find((c) => c.id === "0");
const cell1 = cells.find((c) => c.id === "1");
if (!cell0) {
issues.push({
rule: "sentinel-cells",
message: 'missing the root sentinel cell <mxCell id="0"/>',
cellId: "0",
});
}
if (!cell1) {
issues.push({
rule: "sentinel-cells",
message: 'missing the layer sentinel cell <mxCell id="1" parent="0"/>',
cellId: "1",
});
} else if (cell1.parent !== "0") {
issues.push({
rule: "sentinel-cells",
message: 'the layer sentinel <mxCell id="1"> must have parent="0"',
cellId: "1",
});
}
cells.forEach((c, index) => {
const pos = `cell #${index}`;
const isSentinel = c.id === "0" || c.id === "1";
// Rule: unique, non-empty ids; user cells must not reuse 0/1.
if (c.id === "") {
issues.push({ rule: "cell-id", message: "cell has an empty id", position: pos });
} else if (ids.has(c.id)) {
issues.push({
rule: "duplicate-id",
message: `duplicate cell id "${c.id}"`,
cellId: c.id,
position: pos,
});
}
ids.add(c.id);
if (isSentinel) return; // sentinels are exempt from the shape rules below
// Rule: vertex XOR edge (a cell may be neither: groups/containers).
if (c.vertex && c.edge) {
issues.push({
rule: "vertex-edge-exclusive",
message: 'a cell cannot be both vertex="1" and edge="1"',
cellId: c.id,
position: pos,
});
}
// Rule: every edge has a child <mxGeometry as="geometry"/>.
if (c.edge && !c.geometry.hasGeometry) {
issues.push({
rule: "edge-geometry",
message:
'edge is missing its child <mxGeometry relative="1" as="geometry"/> — it will not render',
cellId: c.id,
position: pos,
});
}
// Rule: edge endpoints resolve to existing ids.
if (c.edge) {
for (const end of ["source", "target"] as const) {
const ref = c[end];
if (ref != null && ref !== "" && !cellExists(cells, ref)) {
issues.push({
rule: "edge-endpoint",
message: `edge ${end} "${ref}" does not resolve to any cell`,
cellId: c.id,
position: pos,
});
}
}
}
// Rule: parent must exist.
if (c.parent != null && c.parent !== "" && !cellExists(cells, c.parent)) {
issues.push({
rule: "parent-exists",
message: `parent "${c.parent}" does not resolve to any cell`,
cellId: c.id,
position: pos,
});
}
// Rule: style parses as key=value; pairs.
if (c.style !== "") {
const parsed = parseStyle(c.style);
if (parsed.badSegment !== undefined) {
issues.push({
rule: "style-format",
message: `malformed style segment "${parsed.badSegment}" (expected key=value)`,
cellId: c.id,
position: pos,
});
}
}
});
if (issues.length > 0) throw new DrawioLintError(issues);
return { cells, warnings };
}
function cellExists(cells: DrawioCell[], id: string): boolean {
return cells.some((c) => c.id === id);
}
/**
* Raw-string scan of every `value="…"`/`value='…'` on an mxCell tag. Catches an
* unescaped `&`/`<`/`>` and a literal newline character inside a value, keyed to
* the cell's id. Runs before DOM parsing so a value bug is reported with its
* cellId even when the document is otherwise malformed.
*/
function scanRawValues(xml: string, issues: DrawioLintIssue[]): void {
const tagRe = /<mxCell\b([^>]*?)\/?>/g;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(xml)) !== null) {
const attrs = m[1];
const idM = /\bid\s*=\s*"([^"]*)"/.exec(attrs);
const cellId = idM ? idM[1] : undefined;
const valM = /\bvalue\s*=\s*"([^"]*)"/.exec(attrs) || /\bvalue\s*=\s*'([^']*)'/.exec(attrs);
if (!valM) continue;
const raw = valM[1];
// Literal newline (0x0A / 0x0D) inside the attribute value.
if (/[\n\r]/.test(raw)) {
issues.push({
rule: "value-newline",
message:
"value contains a literal newline; use &#xa; (or <br> with html=1) instead",
cellId,
});
}
// Unescaped '<' or '>' inside a value.
if (raw.includes("<") || raw.includes(">")) {
issues.push({
rule: "value-escaping",
message: "value contains an unescaped '<' or '>'; use &lt; / &gt;",
cellId,
});
}
// '&' that does not begin a valid entity.
const badAmp = /&(?!(amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/.test(raw);
if (badAmp) {
issues.push({
rule: "value-escaping",
message: "value contains an unescaped '&'; use &amp;",
cellId,
});
}
}
}
// --- input normalization + prepare -----------------------------------------
/**
* Normalize an accepted tool input into a full mxGraphModel string:
* - a bare `<mxGraphModel>` is used as-is;
* - an `<mxfile>` is decoded to its first page's model;
* - a list of `<mxCell>` is wrapped with the mxGraphModel/root envelope and
* the sentinel cells (id=0, id=1 parent=0) are added when absent.
*/
export function normalizeInput(inputXml: string): string {
let xml = inputXml.trim();
// Strip an optional XML prolog.
if (xml.startsWith("<?xml")) {
const end = xml.indexOf("?>");
if (end !== -1) xml = xml.slice(end + 2).trim();
}
if (xml.startsWith("<mxfile")) {
return decodeDrawioFileToModel(xml);
}
if (xml.startsWith("<mxGraphModel")) {
return xml;
}
if (xml.includes("<mxCell")) {
return wrapCellFragment(xml);
}
throw new DrawioLintError([
{
rule: "unrecognized-input",
message:
"input must be a <mxGraphModel>, an <mxfile>, or a list of <mxCell> elements",
},
]);
}
function wrapCellFragment(fragment: string): string {
// Validate the fragment is well-formed (wrapped so a bare list parses) and
// discover which sentinels are already present.
const { doc, error } = parseXml(`<root>${fragment}</root>`);
if (error) {
throw new DrawioLintError([
{
rule: "well-formed-xml",
message: error,
position: firstLineCol(error),
},
]);
}
const existing = new Set<string>();
const els = doc.getElementsByTagName("mxCell");
for (let i = 0; i < els.length; i++) {
existing.add(els[i].getAttribute("id") ?? "");
}
let prefix = "";
if (!existing.has("0")) prefix += '<mxCell id="0"/>';
if (!existing.has("1")) prefix += '<mxCell id="1" parent="0"/>';
return `<mxGraphModel ${DEFAULT_MODEL_ATTRS}><root>${prefix}${fragment}</root></mxGraphModel>`;
}
export interface PreparedModel {
/** Canonical (normalized) mxGraphModel XML that gets written. */
modelXml: string;
cells: DrawioCell[];
bbox: DrawioBBox;
/** Number of user cells (excludes the id=0/id=1 sentinels). */
cellCount: number;
warnings: string[];
hash: string;
}
/**
* Full pre-write pipeline for create/update: normalize the input into a model,
* lint it (throws DrawioLintError on any violation), then compute the canonical
* form, bounding box, cell count and hash. Never touches the network.
*/
export function prepareModel(inputXml: string): PreparedModel {
const rawModel = normalizeInput(inputXml);
const { cells, warnings } = lintModel(rawModel);
const modelXml = normalizeXml(rawModel);
const bbox = computeBBox(cells);
const cellCount = cells.filter((c) => c.id !== "0" && c.id !== "1").length;
return {
modelXml,
cells,
bbox,
cellCount,
warnings,
hash: mxHash(modelXml),
};
}
/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */
export function countUserCells(modelXml: string): number {
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
}
+41 -115
View File
@@ -1,136 +1,62 @@
/** /**
* Legacy footnote diagnostics for imported Markdown (issue #166). * Legacy footnote advisory for imported Markdown (issue #166, reduced in #414).
* *
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror * Since #293 STEP 5 the canonical import form is inline `^[body]` footnotes
* conversion path, so it reports the same problems for `create_page`, * (handled by `@docmost/prosemirror-markdown`). LEGACY reference-style
* `update_page` and `import_page_markdown`). It never changes the document the * `[^id]: …` definition markup is now INERT on import the importer leaves it as
* importer still creates the page; this only surfaces footnote problems to the * literal text so authoring it silently produces broken footnotes (the #410
* caller so an agent can fix its own markup instead of shipping broken footnotes. * incident class). Rather than the old, elaborate diagnostics of every problem
* SHAPE (dangling/duplicate/empty/in-table) that no longer describe what the
* importer builds, this module surfaces ONE advisory warning whenever legacy
* reference-style definition syntax is present, nudging the author to the inline
* form. It never changes the document the importer still creates the page.
* *
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]` * The scan is fence-aware: a `[^id]:` line inside a ``` / ~~~ code block is
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems * example text, not markup, so it never triggers the warning.
* cannot arise. This scan therefore targets the LEGACY reference-style
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
* text). The warnings remain useful as an advisory nudge when an agent still
* authors the old syntax, but they no longer describe what the importer builds.
*
* Detected problems:
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
* first would have been kept under the old first-wins import).
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
* the line, trimmed, starts with `|`) footnotes in table cells often do not
* render as expected.
*/ */
import { /** A legacy footnote DEFINITION line: `[^id]:` at the start of a (non-fenced) line. */
lexFootnoteLines, const FOOTNOTE_DEF_RE = /^\[\^[^\]\s]+\]:/;
forEachFootnoteReference, /** Opening/closing code fence marker (``` or ~~~). */
} from "./footnote-lex.js"; const FENCE_RE = /^\s*(`{3,}|~{3,})/;
export interface FootnoteDiagnostics { /** The single advisory shown when legacy reference-style footnotes are present. */
/** Reference ids (distinct, document order) with no matching definition. */ export const LEGACY_FOOTNOTE_WARNING =
danglingReferences: string[]; "Reference-style footnotes (`[^id]: …`) are not parsed on import and will " +
/** Definition ids whose first (kept) text is empty/whitespace. */ "appear as literal text. Use inline footnotes instead: `^[footnote text]`.";
emptyDefinitions: string[];
/** Ids defined by two or more `[^id]:` lines (only the first is kept). */
duplicateDefinitions: string[];
/** Reference ids found inside a GFM table row (heuristic). */
referencesInTables: string[];
/** Human-readable warning lines for the tool result (one per problem class). */
warnings: string[];
}
/** /**
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body. * True when `markdown` contains a legacy `[^id]:` definition line OUTSIDE any
* code fence. Pure; safe to call on any body.
*/ */
export function analyzeFootnotes(markdown: string): FootnoteDiagnostics { export function hasLegacyFootnoteDefinition(markdown: string): boolean {
// Distinct reference ids in first-appearance order, plus the set of ids seen if (typeof markdown !== "string" || !markdown.includes("[^")) return false;
// inside a table row. let fence: string | null = null;
const refIds: string[] = []; for (const line of markdown.split("\n")) {
const refIdSet = new Set<string>(); const fenceMatch = FENCE_RE.exec(line);
const referencesInTables = new Set<string>(); if (fenceMatch) {
const addRef = (id: string, inTable: boolean) => { const marker = fenceMatch[1][0];
if (!refIdSet.has(id)) { if (fence === null) fence = marker; // opening fence
refIdSet.add(id); else if (marker === fence) fence = null; // matching closing fence
refIds.push(id);
}
if (inTable) referencesInTables.add(id);
};
// Definition texts per id, in first-appearance order of the id.
const defTextsById = new Map<string, string[]>();
// Same lexer the importer uses, so the analysis matches exactly what import
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
for (const tok of lexFootnoteLines(markdown)) {
if (tok.inFence) continue;
if (tok.definition) {
const { id, text } = tok.definition;
const arr = defTextsById.get(id);
if (arr) arr.push(text);
else defTextsById.set(id, [text]);
// A definition's TEXT can itself reference another footnote (`[^a]: see
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
forEachFootnoteReference(text, (rid) => addRef(rid, false));
continue; continue;
} }
const inTable = tok.line.trimStart().startsWith("|"); if (fence !== null) continue; // inside a fence: inert example text
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable)); if (FOOTNOTE_DEF_RE.test(line)) return true;
} }
return false;
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
const duplicateDefinitions: string[] = [];
const emptyDefinitions: string[] = [];
for (const [id, texts] of defTextsById) {
if (texts.length >= 2) duplicateDefinitions.push(id);
// First-wins: the kept definition is the first one; flag it if it is blank.
if ((texts[0] ?? "").trim().length === 0) emptyDefinitions.push(id);
}
const tableRefs = [...referencesInTables];
const warnings: string[] = [];
const list = (ids: string[]) => ids.map((id) => `[^${id}]`).join(", ");
if (danglingReferences.length > 0) {
warnings.push(
`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`,
);
}
if (emptyDefinitions.length > 0) {
warnings.push(
`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`,
);
}
if (duplicateDefinitions.length > 0) {
warnings.push(
`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`,
);
}
if (tableRefs.length > 0) {
warnings.push(
`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`,
);
}
return {
danglingReferences,
emptyDefinitions,
duplicateDefinitions,
referencesInTables: tableRefs,
warnings,
};
} }
/** /**
* The optional `footnoteWarnings` field for a page-write tool result: present * The optional `footnoteWarnings` field for a page-write tool result: present
* (with the warning lines) only when `markdown` has footnote problems, omitted * (with the single advisory) only when `markdown` uses legacy reference-style
* otherwise. One helper so all three call sites (create/update/import) attach the * footnote syntax, omitted otherwise. One helper so all three call sites
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`. * (create/update/import) attach the field identically. Spread into the result:
* `{ ...result, ...footnoteWarningsField(text) }`.
*/ */
export function footnoteWarningsField(markdown: string): { export function footnoteWarningsField(markdown: string): {
footnoteWarnings?: string[]; footnoteWarnings?: string[];
} { } {
const { warnings } = analyzeFootnotes(markdown); return hasLegacyFootnoteDefinition(markdown)
return warnings.length > 0 ? { footnoteWarnings: warnings } : {}; ? { footnoteWarnings: [LEGACY_FOOTNOTE_WARNING] }
: {};
} }
@@ -1,91 +0,0 @@
/**
* Inline-authoring helpers for footnotes (MCP).
*
* These build/identify footnote DEFINITION nodes for the author-inline tool
* (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes
* by text, a definition-node factory, and a fresh uuidv7-style id generator.
*
* Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of
* the editor-ext canonicalizer (compositionally symmetric to the editor-ext
* copy, which keeps its authoring helpers in `footnote-util.ts`). The pure
* canonicalizer has no dependency on these.
*/
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
function cloneJson<T>(v: T): T {
if (typeof structuredClone === "function") return structuredClone(v);
return JSON.parse(JSON.stringify(v)) as T;
}
/**
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
*
* Two definitions with the same key are the SAME footnote so the inline
* authoring tool reuses one id (one number, one definition, several references)
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
* read the same but differ in formatting (one bold, one plain) are NOT merged.
* Conservative: only an exact match merges.
*/
export function footnoteContentKey(defNode: any): string {
const parts: string[] = [];
const visit = (n: any): void => {
if (!n || typeof n !== "object") return;
if (n.type === "text" && typeof n.text === "string") {
const marks = Array.isArray(n.marks)
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
: "";
parts.push(`${n.text}${marks}`);
}
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
};
visit(defNode);
// Collapse the assembled text's whitespace and trim, keeping the mark
// signature attached so formatting differences still distinguish notes.
return parts
.join("")
.replace(/[ \t\r\n]+/g, " ")
.trim();
}
/**
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
*/
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
return {
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id },
content: [{ type: "paragraph", content }],
};
}
/**
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
*/
export function generateFootnoteId(): string {
const now = Date.now();
const timeHex = now.toString(16).padStart(12, "0");
const rand = (length: number) => {
let s = "";
for (let i = 0; i < length; i++)
s += Math.floor(Math.random() * 16).toString(16);
return s;
};
const versioned = "7" + rand(3);
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
const variant = variantNibble + rand(3);
return (
timeHex.slice(0, 8) +
"-" +
timeHex.slice(8, 12) +
"-" +
versioned +
"-" +
variant +
"-" +
rand(12)
);
}
@@ -4,8 +4,8 @@
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's * `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
* `footnoteSyncPlugin` end-state, identical in behaviour to * `footnoteSyncPlugin` end-state, identical in behaviour to
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here rather * `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here rather
* than imported from editor-ext for the SAME reason `footnote-lex.ts` and the * than imported from editor-ext for the SAME reason the `docmost-schema.ts`
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately * nodes are mirrored: the MCP package is deliberately
* decoupled from the browser/React-heavy editor barrel and operates on plain * decoupled from the browser/React-heavy editor barrel and operates on plain
* JSON. The editor-ext copy owns the golden test against the live plugin; this * JSON. The editor-ext copy owns the golden test against the live plugin; this
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by * copy must stay behaviourally identical (a SHARED golden corpus, exercised by
@@ -13,8 +13,8 @@
* *
* This module is the pure MIRROR only. The inline-authoring helpers * This module is the pure MIRROR only. The inline-authoring helpers
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by * (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this * `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
* file is compositionally symmetric to the editor-ext copy. * importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
* *
* Why it exists: every NON-editor write path (markdown import, update_page_json, * Why it exists: every NON-editor write path (markdown import, update_page_json,
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
-73
View File
@@ -1,73 +0,0 @@
/**
* Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal).
*
* Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared
* `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this
* lexer no longer backs an mcp importer. It now backs ONLY the import-time
* diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the
* raw markdown for legacy reference-style `[^id]:` definition lines and surface
* advisory warnings (duplicate/orphan definitions) about content that is now
* inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is
* NOT a definition) is the property the analyzer relies on.
*
* NOTE: this is deliberately NOT shared with editor-ext's
* `extractFootnoteDefinitions` that lives in a different package and the
* decoupling between the editor and the MCP mirror is intentional.
*/
/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */
export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */
export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g;
/** Opening/closing code fence marker (``` or ~~~). */
const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
export interface FootnoteLine {
/** The raw line, verbatim. */
line: string;
/**
* True for a code-fence marker line AND every line inside a fence footnote
* syntax on such lines is inert (example text, not real markup). The importer
* keeps these in the body; the analyzer skips them.
*/
inFence: boolean;
/** The parsed definition, when this is a `[^id]: text` line OUTSIDE any fence. */
definition: { id: string; text: string } | null;
}
/** Classify every line of `markdown`, tracking fenced-code state. Pure. */
export function lexFootnoteLines(markdown: string): FootnoteLine[] {
const out: FootnoteLine[] = [];
let fence: string | null = null;
for (const line of markdown.split("\n")) {
const fenceMatch = FENCE_RE.exec(line);
if (fenceMatch) {
const marker = fenceMatch[2][0];
if (fence === null) fence = marker; // opening fence
else if (marker === fence) fence = null; // matching closing fence
out.push({ line, inFence: true, definition: null });
continue;
}
if (fence !== null) {
out.push({ line, inFence: true, definition: null });
continue;
}
const m = FOOTNOTE_DEF_RE.exec(line);
out.push({
line,
inFence: false,
definition: m ? { id: m[1], text: m[2] } : null,
});
}
return out;
}
/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */
export function forEachFootnoteReference(
line: string,
onRef: (id: string) => void,
): void {
FOOTNOTE_REF_RE_G.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null) onRef(m[1]);
}
+24 -8
View File
@@ -12,11 +12,7 @@
* re-import for small wording fixes. * re-import for small wording fixes.
*/ */
import { import { stripInlineMarkdown, stripBalancedWrappers } from "./text-normalize.js";
stripInlineMarkdown,
stripBalancedWrappers,
closestBlockHint,
} from "./text-normalize.js";
export interface TextEdit { export interface TextEdit {
find: string; find: string;
@@ -385,9 +381,29 @@ export function applyTextEdits(
} else { } else {
// Append a bounded "closest text" hint: find the FIRST block that // Append a bounded "closest text" hint: find the FIRST block that
// contains the longest whitespace-delimited token (>= 3 chars) of the // contains the longest whitespace-delimited token (>= 3 chars) of the
// (stripped, then raw) locator, and quote that block's plain text. Shared // (stripped, then raw) locator, and quote that block's plain text.
// with create_comment via closestBlockHint so both give the same hint. reason = "text not found in the document.";
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find); const tokenSource = stripped.length > 0 ? stripped : edit.find;
const longestToken = tokenSource
.split(/\s+/)
.filter((t) => t.length >= 3)
.sort((a, b) => b.length - a.length)[0];
if (longestToken) {
const hitBlock = blockPlain.find((plain) =>
plain.includes(longestToken),
);
if (hitBlock) {
// Truncate by code point (spread iterates by code point) so a
// surrogate pair is never split; append the ellipsis only when the
// text was actually longer than the limit.
const points = [...hitBlock];
const snippet =
points.length > 120
? points.slice(0, 120).join("") + "…"
: hitBlock;
reason += ` Closest block text: "${snippet}".`;
}
}
} }
failed.push({ find: edit.find, reason }); failed.push({ find: edit.find, reason });
continue; continue;
-963
View File
@@ -1,963 +0,0 @@
/**
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document
* tree by node id.
*
* A ProseMirror node here is a plain JSON object of the shape produced by
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
* `content` array; a node carries a stable id in `attrs.id`. Callouts and
* table cells hold their children in `content` just like any other block, so a
* single recursive walk reaches them all.
*
* Every exported function operates on a DEEP CLONE of the input document and
* returns the new document. The input doc and any `newNode`/`node` argument are
* never mutated. All functions are defensively null-safe: missing/!Array
* `content`, non-object nodes, and absent `attrs` are tolerated.
*/
import { stripInlineMarkdown } from "./text-normalize.js";
/** Deep-clone a JSON-serializable value without mutating the original. */
function clone<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
// Fallback for environments without structuredClone.
return JSON.parse(JSON.stringify(value)) as T;
}
/** True if `value` is a non-null object (and not an array). */
function isObject(value: any): value is Record<string, any> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/** True if `node` carries the given id in `node.attrs.id`. */
function matchesId(node: any, nodeId: string): boolean {
return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId;
}
/**
* Recursively concatenate all text contained in a node.
*
* Text nodes contribute their `text` string; container nodes contribute the
* joined `blockPlainText` of their `content` children. Returns "" for nullish
* or non-object inputs.
*/
export function blockPlainText(node: any): string {
if (!isObject(node)) return "";
let out = "";
if (typeof node.text === "string") {
out += node.text;
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
out += blockPlainText(child);
}
}
return out;
}
/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */
function truncate(text: string, n: number): string {
return text.length > n ? text.slice(0, n) + "…" : text;
}
/** One compact outline entry for a single top-level block. */
export interface OutlineEntry {
index: number;
type: string | undefined;
id: string | null;
firstText: string;
/** Present for headings only. */
level?: number | null;
/** Present for tables only. */
rows?: number;
cols?: number;
header?: string[];
/** Present for list blocks only (bulletList/orderedList/taskList). */
items?: number;
}
/**
* Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in
* `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or
* table cells compactness is the point; use `getNodeByRef` to drill into a
* specific block.
*
* Each entry carries `{ index, type, id, firstText }`, plus type-specific
* extras: headings add `level`; tables add `rows`/`cols` and the first row's
* cell texts as `header`; list blocks (types ending in "List") add `items`.
* `firstText` is the block's plain text truncated to 100 chars. Null-safe:
* a missing or non-object doc/content yields `[]`.
*/
export function buildOutline(doc: any): OutlineEntry[] {
if (!isObject(doc) || !Array.isArray(doc.content)) return [];
const out: OutlineEntry[] = [];
for (let i = 0; i < doc.content.length; i++) {
const block = doc.content[i];
const type = isObject(block) ? block.type : undefined;
const entry: OutlineEntry = {
index: i,
type,
id:
isObject(block) && isObject(block.attrs)
? (block.attrs.id ?? null)
: null,
firstText: truncate(blockPlainText(block), 100),
};
if (type === "heading") {
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
} else if (type === "table") {
const headerRow = block.content?.[0]?.content ?? [];
entry.rows = block.content?.length ?? 0;
entry.cols = block.content?.[0]?.content?.length ?? 0;
entry.header = headerRow.map((cell: any) =>
truncate(blockPlainText(cell), 40),
);
} else if (typeof type === "string" && type.endsWith("List")) {
entry.items = block.content?.length ?? 0;
}
out.push(entry);
}
return out;
}
/**
* Resolve a single node by reference and return `{ node, path, type }`, or
* `null` when nothing matches.
*
* - `ref` of the form `#<n>` (e.g. `#2`) selects the TOP-LEVEL block at index
* `n` in `doc.content`. This is the only way to address table/tableRow/
* tableCell nodes, which carry no `attrs.id`.
* - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the
* tree with `attrs.id === ref` is returned.
*
* `path` is the array of child indices from the doc root down to the node
* (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE,
* so callers can mutate it without touching the input doc. Null-safe.
*/
export function getNodeByRef(
doc: any,
ref: string,
): { node: any; path: number[]; type: string | undefined } | null {
if (!isObject(doc)) return null;
// "#<n>": index into the top-level content array.
const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null;
if (indexMatch) {
const index = Number(indexMatch[1]);
const block = Array.isArray(doc.content) ? doc.content[index] : undefined;
if (!isObject(block)) return null;
return { node: clone(block), path: [index], type: block.type };
}
// Otherwise: depth-first search for the first node with attrs.id === ref.
const search = (
node: any,
trail: number[],
): { node: any; path: number[]; type: string } | null => {
if (!isObject(node)) return null;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const child = node.content[i];
const path = [...trail, i];
if (matchesId(child, ref)) {
return { node: clone(child), path, type: child.type };
}
const hit = search(child, path);
if (hit != null) return hit;
}
}
return null;
};
return search(doc, []);
}
/**
* Replace EVERY node whose `attrs.id === nodeId` with a deep clone of
* `newNode`, anywhere in the tree (including inside callouts and table cells).
*
* Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced`
* is the number of nodes substituted. A fresh clone of `newNode` is used for
* each match so they do not share references.
*/
export function replaceNodeById(
doc: any,
nodeId: string,
newNode: any,
): { doc: any; replaced: number } {
const out = clone(doc);
let replaced = 0;
// Walk a content array, replacing direct matches and recursing into the
// (possibly new) children of non-matching nodes.
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, nodeId)) {
content[i] = clone(newNode);
replaced++;
// Do not recurse into a freshly substituted node.
continue;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, replaced };
}
/**
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
* array, anywhere in the tree (recursive, including callouts and tables).
*
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
* the number of nodes removed.
*/
export function deleteNodeById(
doc: any,
nodeId: string,
): { doc: any; deleted: number } {
const out = clone(doc);
let deleted = 0;
// Filter a content array in place, dropping matches and recursing into the
// surviving children.
const walkContent = (content: any[]): any[] => {
const kept: any[] = [];
for (const child of content) {
if (matchesId(child, nodeId)) {
deleted++;
continue;
}
if (isObject(child) && Array.isArray(child.content)) {
child.content = walkContent(child.content);
}
kept.push(child);
}
return kept;
};
if (isObject(out) && Array.isArray(out.content)) {
out.content = walkContent(out.content);
}
return { doc: out, deleted };
}
/**
* Throw a clear, model-actionable error when a node-id write op did NOT match
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
* "ambiguous, refused" Docmost duplicates block ids on copy/paste, so a write
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
* changed. No-op for the unambiguous single-match case.
*/
export function assertUnambiguousMatch(
op: "patch_node" | "delete_node",
verb: "replace" | "delete",
count: number,
nodeId: string,
pageId: string,
): void {
if (count === 0) {
throw new Error(
`${op}: no node with id "${nodeId}" found on page ${pageId}`,
);
}
if (count > 1) {
throw new Error(
`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`,
);
}
}
/**
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
* "Unexpected content type" when asked to store an `undefined` attribute value).
*
* Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all
* legitimate JSON-storable values and are preserved. Operates on a clone and
* returns it; the input is never mutated. Defensively null-safe like the rest
* of the file.
*/
export function sanitizeForYjs(doc: any): any {
const out = clone(doc);
// Drop every key whose value is strictly `undefined` from an attrs object.
const stripUndefined = (attrs: any): void => {
if (!isObject(attrs)) return;
for (const key of Object.keys(attrs)) {
if (attrs[key] === undefined) {
delete attrs[key];
}
}
};
const walk = (node: any): void => {
if (!isObject(node)) return;
stripUndefined(node.attrs);
if (Array.isArray(node.marks)) {
for (const mark of node.marks) {
if (isObject(mark)) stripUndefined(mark.attrs);
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child);
}
}
};
walk(out);
return out;
}
/**
* Diagnostics helper: walk the tree and return a human-readable path string for
* the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs
* cannot store i.e. `undefined`, a `function`, a `symbol`, or a `bigint`
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
* every attribute is storable. Null-safe.
*/
export function findUnstorableAttr(doc: any): string | null {
const isUnstorable = (value: any): string | null => {
if (value === undefined) return "undefined";
const t = typeof value;
if (t === "function") return "function";
if (t === "symbol") return "symbol";
if (t === "bigint") return "bigint";
return null;
};
// Check an attrs object; return the offending sub-path or null.
const checkAttrs = (attrs: any, basePath: string): string | null => {
if (!isObject(attrs)) return null;
for (const key of Object.keys(attrs)) {
const kind = isUnstorable(attrs[key]);
if (kind != null) return `${basePath}.${key} (${kind})`;
}
return null;
};
const walk = (node: any, path: string): string | null => {
if (!isObject(node)) return null;
const attrHit = checkAttrs(node.attrs, `${path}.attrs`);
if (attrHit != null) return attrHit;
if (Array.isArray(node.marks)) {
for (let i = 0; i < node.marks.length; i++) {
const markHit = checkAttrs(
node.marks[i]?.attrs,
`${path}.marks[${i}].attrs`,
);
if (markHit != null) return markHit;
}
}
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const childHit = walk(node.content[i], `${path}.content[${i}]`);
if (childHit != null) return childHit;
}
}
return null;
};
// The root doc node carries no useful index, so start the path at "doc".
if (!isObject(doc)) return null;
const attrHit = checkAttrs(doc.attrs, "attrs");
if (attrHit != null) return attrHit;
if (Array.isArray(doc.content)) {
for (let i = 0; i < doc.content.length; i++) {
const childHit = walk(doc.content[i], `content[${i}]`);
if (childHit != null) return childHit;
}
}
return null;
}
/**
* Table structural node types and the container each must live directly inside.
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
* rather than blindly into the anchor's direct parent (which would corrupt the
* table's nesting).
*/
const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]);
const REQUIRED_CONTAINER: Record<string, string> = {
tableRow: "table",
tableCell: "tableRow",
tableHeader: "tableRow",
};
/**
* Find the index of the first TOP-LEVEL block whose plain text includes the
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
*
* Two passes preserve "exact wins globally":
* - Pass 1: first block containing the verbatim `anchorText`.
* - Pass 2 (only if pass 1 found nothing): first block containing the
* markdown-stripped anchor, when stripping actually changed it.
*/
function findAnchorTextIndex(content: any[], anchorText: string): number {
if (!Array.isArray(content)) return -1;
// Pass 1: exact.
for (let i = 0; i < content.length; i++) {
if (blockPlainText(content[i]).includes(anchorText)) return i;
}
// Pass 2: markdown-stripped fallback.
const a = stripInlineMarkdown(anchorText);
if (a !== anchorText && a.length > 0) {
for (let i = 0; i < content.length; i++) {
if (blockPlainText(content[i]).includes(a)) return i;
}
}
return -1;
}
/**
* Locate an anchor and return its ancestor chain (from `doc` down to and
* including the matched node). Each chain entry is `{ node, index }` where
* `index` is the node's position inside its parent's `content` array (the root
* doc has index -1). Returns `null` when the anchor cannot be resolved.
*/
function findAnchorChain(
doc: any,
opts: InsertOptions,
): { node: any; index: number }[] | null {
if (!isObject(doc)) return null;
// DFS by id anywhere in the tree, accumulating the path.
if (opts.anchorNodeId != null) {
const targetId = opts.anchorNodeId;
const search = (
node: any,
index: number,
trail: { node: any; index: number }[],
): { node: any; index: number }[] | null => {
if (!isObject(node)) return null;
const here = [...trail, { node, index }];
if (matchesId(node, targetId)) return here;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const hit = search(node.content[i], i, here);
if (hit != null) return hit;
}
}
return null;
};
return search(doc, -1, []);
}
// By text: only top-level blocks are scanned (same rule as the JSON path).
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && Array.isArray(doc.content)) {
const i = findAnchorTextIndex(doc.content, opts.anchorText);
if (i !== -1) {
return [
{ node: doc, index: -1 },
{ node: doc.content[i], index: i },
];
}
}
return null;
}
/** Options controlling where `insertNodeRelative` places the new node. */
export interface InsertOptions {
position: "before" | "after" | "append";
/** Resolve the anchor by node id anywhere in the tree (preferred). */
anchorNodeId?: string;
/** Fallback: first TOP-LEVEL block whose plain text includes this string. */
anchorText?: string;
}
/**
* Insert a deep clone of `node` relative to an anchor.
*
* - position "append": push the node onto the top-level `doc.content`.
* - position "before"/"after": locate the anchor and splice the node into the
* anchor's parent `content` array immediately before / after it.
*
* Anchor resolution for before/after:
* - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId`
* anywhere in the tree (recursive);
* - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content`
* blocks and pick the first whose `blockPlainText` includes `anchorText`.
*
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
* false when the anchor could not be resolved (the doc is returned unchanged
* apart from being cloned).
*/
export function insertNodeRelative(
doc: any,
node: any,
opts: InsertOptions,
): { doc: any; inserted: boolean } {
const out = clone(doc);
const fresh = clone(node);
// Defensive: stay null-safe like the other exports — a missing opts means
// there is nothing actionable to do.
if (!isObject(opts)) return { doc: out, inserted: false };
const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type);
// "append": top-level push.
if (opts.position === "append") {
// Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the
// top level — appending one would produce invalid nesting.
if (isStructural) {
throw new Error(
`insert_node: cannot append a ${node.type} at the top level; use ` +
`position before/after with an anchor inside the target table`,
);
}
if (isObject(out)) {
if (!Array.isArray(out.content)) out.content = [];
out.content.push(fresh);
return { doc: out, inserted: true };
}
return { doc: out, inserted: false };
}
const offset = opts.position === "after" ? 1 : 0;
// Structural insert (before/after a tableRow/tableCell/tableHeader): splice
// into the nearest enclosing table/tableRow rather than the anchor's direct
// parent, so the row/cell lands at the correct level of the table.
if (isStructural) {
const containerType = REQUIRED_CONTAINER[node.type];
const chain = findAnchorChain(out, opts);
// Anchor not resolved at all — keep the existing "anchor not found" path.
if (chain == null) return { doc: out, inserted: false };
// Find the DEEPEST ancestor (including the anchor itself) of the required
// container type.
let containerIdx = -1;
for (let i = chain.length - 1; i >= 0; i--) {
if (isObject(chain[i].node) && chain[i].node.type === containerType) {
containerIdx = i;
break;
}
}
if (containerIdx === -1) {
throw new Error(
`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
`that lives inside the target table.`,
);
}
const container = chain[containerIdx].node;
if (!Array.isArray(container.content)) container.content = [];
if (containerIdx === chain.length - 1) {
// The matched container IS the anchor node itself (e.g. anchorText
// resolved to the table block): append/prepend within it.
const at = opts.position === "after" ? container.content.length : 0;
container.content.splice(at, 0, fresh);
} else {
// The immediate child on the path leading to the anchor is the row/cell
// to splice next to.
const enclosingChildIndex = chain[containerIdx + 1].index;
container.content.splice(enclosingChildIndex + offset, 0, fresh);
}
return { doc: out, inserted: true };
}
// Resolve by id anywhere in the tree: splice into the parent content array.
if (opts.anchorNodeId != null) {
let inserted = false;
const walkContent = (content: any[]): void => {
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (matchesId(child, opts.anchorNodeId as string)) {
content.splice(i + offset, 0, fresh);
inserted = true;
return;
}
if (isObject(child) && Array.isArray(child.content)) {
walkContent(child.content);
if (inserted) return;
}
}
};
if (isObject(out) && Array.isArray(out.content)) {
walkContent(out.content);
}
return { doc: out, inserted };
}
// Resolve by text: only top-level doc.content blocks are scanned. Exact
// match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
const i = findAnchorTextIndex(out.content, opts.anchorText);
if (i !== -1) {
out.content.splice(i + offset, 0, fresh);
return { doc: out, inserted: true };
}
}
return { doc: out, inserted: false };
}
// ===========================================================================
// Table editing helpers
//
// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes:
// table -> { type:"table", content:[tableRow...] }
// row -> { type:"tableRow", content:[tableCell|tableHeader...] }
// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth},
// content:[paragraph...] }
// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] }
// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the
// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE
// of the input doc (via `clone`) and never mutate their inputs.
// ===========================================================================
/**
* Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed
* `makeFreshId` so generated paragraph ids never collide with existing ones.
*/
function collectIds(node: any, used: Set<string>): void {
if (!isObject(node)) return;
if (isObject(node.attrs) && typeof node.attrs.id === "string") {
used.add(node.attrs.id);
}
if (Array.isArray(node.content)) {
for (const child of node.content) collectIds(child, used);
}
}
/**
* Fresh-id generator: returns a random Docmost-style id (12 chars from
* lowercase `a-z0-9`) that is not already in `used`, and records it. On the
* rare collision the id is regenerated. Callers rely on uniqueness, not on the
* exact string, so randomness is fine and unlike a module-local counter it
* needs no reset and cannot become predictable across calls.
*/
function makeFreshId(used: Set<string>): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let id: string;
do {
id = "";
for (let i = 0; i < 12; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
} while (used.has(id) || id === "");
used.add(id);
return id;
}
/**
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
* its index path. Returns null when no table matches.
*
* - `#<n>`: the top-level block at index `n`, only if its `type === "table"`.
* - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its
* ancestor chain to the nearest `type === "table"` ancestor.
*/
function locateTable(
rootClone: any,
tableRef: string,
): { table: any; path: number[] } | null {
if (!isObject(rootClone)) return null;
// "#<n>": index into the top-level content array; must be a table.
const indexMatch =
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
if (indexMatch) {
const index = Number(indexMatch[1]);
const block = Array.isArray(rootClone.content)
? rootClone.content[index]
: undefined;
if (isObject(block) && block.type === "table") {
return { table: block, path: [index] };
}
return null;
}
// Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then
// climb to the nearest enclosing table.
const search = (
node: any,
trail: { node: any; index: number }[],
): { table: any; path: number[] } | null => {
if (!isObject(node)) return null;
if (Array.isArray(node.content)) {
for (let i = 0; i < node.content.length; i++) {
const child = node.content[i];
const here = [...trail, { node: child, index: i }];
if (matchesId(child, tableRef)) {
// Walk UP to the nearest table ancestor (including the match itself).
for (let j = here.length - 1; j >= 0; j--) {
if (isObject(here[j].node) && here[j].node.type === "table") {
return {
table: here[j].node,
path: here.slice(0, j + 1).map((e) => e.index),
};
}
}
return null; // id found but no enclosing table
}
const hit = search(child, here);
if (hit != null) return hit;
}
}
return null;
};
return search(rootClone, []);
}
/** Build the plain-text → single-paragraph cell content used by all writers. */
function makeCellParagraph(id: string, text: string): any {
return {
type: "paragraph",
attrs: { id, indent: 0 },
// Empty string → a paragraph with an empty content array.
content: text ? [{ type: "text", text }] : [],
};
}
/**
* Read a table as a matrix. Returns null when `tableRef` resolves to no table.
*
* - `rows`/`cols`: the table's row count and the column count of its FIRST row.
* Tables may be ragged (rows of differing length), so `cols` reflects only
* row 0; use the per-row length of `cells`/`cellIds` for each row's actual
* width.
* - `cells`: `string[][]` of each cell's `blockPlainText`.
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
* so callers can `patch_node` a cell for rich-formatted edits.
* - `path`: index path of the table within the doc.
*/
export function readTable(
doc: any,
tableRef: string,
): {
rows: number;
cols: number;
cells: string[][];
cellIds: (string | null)[][];
path: number[];
} | null {
const root = clone(doc);
const located = locateTable(root, tableRef);
if (located == null) return null;
const { table, path } = located;
const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length;
const cols = rowNodes[0]?.content?.length ?? 0;
const cells: string[][] = [];
const cellIds: (string | null)[][] = [];
for (const rowNode of rowNodes) {
const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : [];
const rowText: string[] = [];
const rowIds: (string | null)[] = [];
for (const cellNode of cellNodes) {
rowText.push(blockPlainText(cellNode));
// The cell's first paragraph carries the id used for patch_node.
const firstPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
const id =
isObject(firstPara) && isObject(firstPara.attrs)
? (firstPara.attrs.id ?? null)
: null;
rowIds.push(id);
}
cells.push(rowText);
cellIds.push(rowIds);
}
return { rows, cols, cells, cellIds, path };
}
/**
* Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`.
*
* The row is padded to the table's column count (`cells[i] ?? ""`); supplying
* MORE cells than columns throws. Each new cell copies `colwidth` for its
* column from the header row when present, gets a fresh-id paragraph, and a
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
* the row there; otherwise the row is appended at the end.
*/
export function insertTableRow(
doc: any,
tableRef: string,
cells: string[],
index?: number,
): { doc: any; inserted: boolean } {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null) return { doc: out, inserted: false };
const { table } = located;
if (!Array.isArray(table.content)) table.content = [];
const rows = table.content.length;
const headerRow = table.content[0];
const headerCells = Array.isArray(headerRow?.content)
? headerRow.content
: [];
// Column count is the WIDEST existing row, so the guard below stays
// meaningful for ragged tables and the new row matches the table's width.
// Fall back to the supplied cell count only when the table has no rows.
let colCount = 0;
for (const r of table.content) {
if (isObject(r) && Array.isArray(r.content))
colCount = Math.max(colCount, r.content.length);
}
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
if (Array.isArray(cells) && cells.length > colCount) {
throw new Error(
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
);
}
// Resolve the landing index up front so the cell-type decision and the splice
// below agree: a valid integer in [0, rows] splices there, else we append.
const landingIndex =
typeof index === "number" &&
Number.isInteger(index) &&
index >= 0 &&
index <= rows
? index
: rows;
// Seed the id generator with every id already in the doc so the new cell
// paragraph ids are unique within the whole document.
const used = new Set<string>();
collectIds(out, used);
const newCells: any[] = [];
for (let i = 0; i < colCount; i++) {
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
const attrs: Record<string, any> = { colspan: 1, rowspan: 1 };
// Copy this column's colwidth from the header row's cell when present.
const colwidth = headerCells[i]?.attrs?.colwidth;
if (colwidth !== undefined) attrs.colwidth = colwidth;
// A row landing at index 0 becomes the new header row, so inherit the
// current header cell's type per column (Docmost uses "tableHeader" there);
// every other position is a plain data cell.
const cellType =
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
newCells.push({
type: cellType,
attrs,
content: [makeCellParagraph(makeFreshId(used), text)],
});
}
const newRow = { type: "tableRow", content: newCells };
// Splice at the resolved landing index (append when index was omitted/invalid).
table.content.splice(landingIndex, 0, newRow);
return { doc: out, inserted: true };
}
/**
* Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`.
* `deleted` is false only when the table cannot be located. Throws on an
* out-of-range index, and refuses to delete the table's only row.
*/
export function deleteTableRow(
doc: any,
tableRef: string,
index: number,
): { doc: any; deleted: boolean } {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null) return { doc: out, deleted: false };
const { table } = located;
if (!Array.isArray(table.content)) table.content = [];
const rows = table.content.length;
if (!Number.isInteger(index) || index < 0 || index >= rows) {
throw new Error(
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`,
);
}
if (rows <= 1) {
throw new Error(
"table_delete_row: refusing to delete the only row of the table",
);
}
table.content.splice(index, 1);
return { doc: out, deleted: true };
}
/**
* Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns
* `{ doc, updated }`; `updated` is false only when the table cannot be located.
* Throws when `row`/`col` is out of range. The cell's own attrs (colspan/
* rowspan/colwidth) are preserved; its content becomes a single text paragraph
* that reuses the cell's existing first-paragraph id when present, else a fresh
* one.
*/
export function updateTableCell(
doc: any,
tableRef: string,
row: number,
col: number,
text: string,
): { doc: any; updated: boolean } {
const out = clone(doc);
const located = locateTable(out, tableRef);
if (located == null) return { doc: out, updated: false };
const { table } = located;
const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length;
const rowNode = rowNodes[row];
const cols =
isObject(rowNode) && Array.isArray(rowNode.content)
? rowNode.content.length
: 0;
if (
!Number.isInteger(row) ||
row < 0 ||
row >= rows ||
!Number.isInteger(col) ||
col < 0 ||
col >= cols
) {
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
}
const cellNode = rowNode.content[col];
// Reuse the cell's existing first-paragraph id, or mint a fresh unique one.
const existingPara = Array.isArray(cellNode?.content)
? cellNode.content[0]
: undefined;
let id =
isObject(existingPara) && isObject(existingPara.attrs)
? existingPara.attrs.id
: undefined;
if (typeof id !== "string" || id.length === 0) {
const used = new Set<string>();
collectIds(out, used);
id = makeFreshId(used);
}
cellNode.content = [makeCellParagraph(id, text)];
return { doc: out, updated: true };
}
+1 -1
View File
@@ -33,7 +33,7 @@
import RE2 from "re2"; import RE2 from "re2";
import { blockPlainText } from "./node-ops.js"; import { blockPlainText } from "@docmost/prosemirror-markdown";
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */ /** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
type Re2Regex = InstanceType<typeof RE2>; type Re2Regex = InstanceType<typeof RE2>;
-61
View File
@@ -1,61 +0,0 @@
// Minimal ambient type declaration for `pako` (no @types/pako is installed and
// pako 2.x ships no bundled .d.ts). We only use the raw-deflate codec to read
// draw.io's compressed `<diagram>` payload, so declare just that surface.
declare module "pako" {
interface RawOptions {
/** When "string", the result is returned as a (binary/UTF-8) string. */
to?: "string";
/** Raw-deflate window bits; draw.io uses raw deflate (no zlib header). */
windowBits?: number;
level?: number;
}
/** Raw-inflate (windowBits: -15). `to:"string"` yields a string. */
export function inflateRaw(
data: Uint8Array | ArrayBuffer | number[],
options: RawOptions & { to: "string" },
): string;
export function inflateRaw(
data: Uint8Array | ArrayBuffer | number[],
options?: RawOptions,
): Uint8Array;
/** Raw-deflate (windowBits: -15). Used only by tests to build fixtures. */
export function deflateRaw(
data: Uint8Array | string,
options?: RawOptions,
): Uint8Array;
interface InflateStreamOptions {
to?: "string";
windowBits?: number;
/** Raw deflate (no zlib header) — equivalent to windowBits: -15. */
raw?: boolean;
chunkSize?: number;
}
/**
* Streaming inflate. We use it to bound the decompressed size: `onData` is
* invoked per output chunk, letting us abort a decompression bomb before the
* full output is materialised.
*/
export class Inflate {
constructor(options?: InflateStreamOptions);
onData: (chunk: string | Uint8Array) => void;
onEnd: (status: number) => void;
push(
data: Uint8Array | ArrayBuffer | number[] | string,
flushMode?: boolean | number,
): boolean;
result: string | Uint8Array;
err: number;
msg: string;
}
const _default: {
inflateRaw: typeof inflateRaw;
deflateRaw: typeof deflateRaw;
Inflate: typeof Inflate;
};
export default _default;
}
-34
View File
@@ -114,37 +114,3 @@ export function stripInlineMarkdown(s: string): string {
return out; return out;
} }
/**
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
* edit_page_text (json-edit) and create_comment (client) so both surface the
* same self-correction affordance.
*
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
* (markdown-stripped first, so `**bold**` contributes `bold`), find the FIRST
* of `blockTexts` that contains it, and return ` Closest block text: "…".` with
* the block quoted (truncated to 120 code points + ellipsis). Returns "" when
* no token qualifies or no block contains it, so the caller can append it
* unconditionally.
*/
export function closestBlockHint(
blockTexts: string[],
locator: string,
): string {
if (typeof locator !== "string" || locator.length === 0) return "";
const stripped = stripInlineMarkdown(locator);
const tokenSource = stripped.length > 0 ? stripped : locator;
const longestToken = tokenSource
.split(/\s+/)
.filter((t) => t.length >= 3)
.sort((a, b) => b.length - a.length)[0];
if (!longestToken) return "";
const hitBlock = blockTexts.find((plain) => plain.includes(longestToken));
if (!hitBlock) return "";
// Truncate by code point (spread iterates by code point) so a surrogate pair
// is never split; append the ellipsis only when the text was actually longer.
const points = [...hitBlock];
const snippet =
points.length > 120 ? points.slice(0, 120).join("") + "…" : hitBlock;
return ` Closest block text: "${snippet}".`;
}
+4 -4
View File
@@ -14,13 +14,13 @@
* - `marks` arrays are preserved verbatim when fragments are split/reordered. * - `marks` arrays are preserved verbatim when fragments are split/reordered.
*/ */
import { blockPlainText } from "./node-ops.js";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { import {
blockPlainText,
footnoteContentKey, footnoteContentKey,
makeFootnoteDefinition, makeFootnoteDefinition,
generateFootnoteId, generateFootnoteId,
} from "./footnote-authoring.js"; } from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
export { canonicalizeFootnotes } from "./footnote-canonicalize.js"; export { canonicalizeFootnotes } from "./footnote-canonicalize.js";
@@ -365,7 +365,7 @@ export function noteItem(inlineNodes: any[]): any {
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node). * (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
* *
* Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts); * Built on the shared `makeFootnoteDefinition` factory (`@docmost/prosemirror-markdown`);
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one, * the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
* and the canonicalizer preserves attrs as-is). Single factory, one place to * and the canonicalizer preserves attrs as-is). Single factory, one place to
* change the definition shape. * change the definition shape.
-15
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createDocmostMcpServer } from "./index.js"; import { createDocmostMcpServer } from "./index.js";
import { destroyAllSessions } from "./lib/collab-session.js";
// Standalone stdio entrypoint. This restores the original behavior of the // Standalone stdio entrypoint. This restores the original behavior of the
// package when run as a CLI (`docmost-mcp`): it reads credentials from the // package when run as a CLI (`docmost-mcp`): it reads credentials from the
@@ -34,20 +33,6 @@ async function run() {
console.error("Uncaught exception:", error); console.error("Uncaught exception:", error);
}); });
// Teardown hook (issue #400): destroy every cached live CollabSession on exit
// so a hanging session does not keep a doc loaded on the server (which would
// also defer the server's afterUnloadDocument cleanup). `exit` runs the
// synchronous idempotent teardown; SIGINT/SIGTERM also run it, then exit.
process.on("exit", () => {
destroyAllSessions();
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
destroyAllSessions();
process.exit(0);
});
}
const server = createDocmostMcpServer({ const server = createDocmostMcpServer({
apiUrl: API_URL!, apiUrl: API_URL!,
email: EMAIL!, email: EMAIL!,
+3 -115
View File
@@ -771,13 +771,9 @@ export const SHARED_TOOL_SPECS = {
'The comment is anchored inline to the given exact `selection` text ' + 'The comment is anchored inline to the given exact `selection` text ' +
'(which gets highlighted); page-level comments are NOT supported. A ' + '(which gets highlighted); page-level comments are NOT supported. A ' +
'new top-level comment REQUIRES a `selection`. Replies inherit the ' + 'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
"parent's anchor and take no selection. Always COPY the `selection` " + "parent's anchor and take no selection. If the call fails with a " +
'VERBATIM from get_page / search_in_page output — do NOT quote it from ' + '"selection not found" error, retry with a corrected EXACT selection ' +
'memory (stale-memory quoting is the top cause of anchor misses). If the ' + 'copied verbatim from a single paragraph/block. You may also attach a ' +
'call fails with a "selection not found" error, the error quotes the ' +
"closest block text (or says the selection spans multiple blocks); retry " +
"with a corrected EXACT selection copied verbatim from a single " +
'paragraph/block. You may also attach a ' +
'`suggestedText` proposing a replacement for the `selection` (a human ' + '`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' + 'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.', 'once in the page. Reversible via the comment UI.',
@@ -1119,112 +1115,4 @@ export const SHARED_TOOL_SPECS = {
alt: z.string().optional(), alt: z.string().optional(),
}), }),
}, },
// --- draw.io diagrams (issue #423, stage 1) ---
drawioGet: {
mcpName: 'drawio_get',
inAppKey: 'drawioGet',
description:
'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' +
'`.drawio.svg`. `node` is the drawio node\'s attrs.id (from get_outline / ' +
'get_page_json) or "#<index>" for a top-level block. Returns the decoded ' +
'mxGraphModel XML plus meta { attachmentId, title, width, height, ' +
'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' +
'as baseHash to drawio_update. Diagrams a human saved from the editor ' +
'(including draw.io\'s compressed format) decode losslessly.',
tier: 'deferred',
catalogLine:
'drawioGet — read a draw.io diagram as mxGraph XML (+ hash for updates).',
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.'),
format: z
.enum(['xml', 'svg'])
.optional()
.describe('"xml" (default) for mxGraph XML, or "svg" for the raw .drawio.svg.'),
}),
},
drawioCreate: {
mcpName: 'drawio_create',
inAppKey: 'drawioCreate',
description:
'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' +
'block. `xml` is a bare `<mxGraphModel>` OR a list of `<mxCell>` elements ' +
'(the server wraps it and adds the id=0 / id=1 sentinel cells). The XML is ' +
'LINTED first (well-formedness, sentinel cells, unique ids, vertex XOR ' +
'edge, every edge has a child <mxGeometry as="geometry"/>, edge ' +
'source/target and every parent resolve, style parses, no XML comments, ' +
'value escaping) — a violation returns a structured error naming the rule ' +
'and cellId so you can fix and retry. `where` positions the block like ' +
'insert_node: position before/after (with exactly one of anchorNodeId or ' +
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' +
'no attrs.id): it addresses the new top-level block and can be fed straight ' +
'back into drawio_get / drawio_update for THIS document. It is positional, ' +
'so if you add or remove blocks before it, re-resolve via get_outline. The ' +
'diagram is editable in the draw.io editor and can be re-read with ' +
'drawio_get.',
tier: 'deferred',
catalogLine:
'drawioCreate — create a draw.io diagram from mxGraph XML and insert it.',
buildShape: (z) => ({
pageId: z.string().min(1),
xml: z
.string()
.min(1)
.describe(
'mxGraph XML: a bare <mxGraphModel> or a list of <mxCell> elements.',
),
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).'),
title: z.string().optional().describe('Optional diagram title.'),
}),
},
drawioUpdate: {
mcpName: 'drawio_update',
inAppKey: 'drawioUpdate',
description:
'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' +
'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' +
'the drawio_get you based the edit on. If the diagram changed since ' +
'(a human or another agent edited it) the hash mismatches and the update ' +
'is refused with a conflict error — re-read with drawio_get and retry. On ' +
'success it overwrites the diagram attachment and updates the node ' +
'width/height. `node` is the drawio node attrs.id or "#<index>".',
tier: 'deferred',
catalogLine:
'drawioUpdate — replace a draw.io diagram (optimistic-locked by baseHash).',
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.'),
xml: z
.string()
.min(1)
.describe(
'New mxGraph XML: a bare <mxGraphModel> or a list of <mxCell> elements.',
),
baseHash: z
.string()
.min(1)
.describe('The meta.hash from the drawio_get this edit is based on.'),
}),
},
} satisfies Record<string, SharedToolSpec>; } satisfies Record<string, SharedToolSpec>;
@@ -1,282 +0,0 @@
// Unit tests for the collab-token cache (issue #435). The live CollabSession
// registry (#400/#431) keys sessions on (wsUrl, pageId, collabToken), so a token
// string that changes every op defeats reuse. This cache holds the last minted
// token per DocmostClient for MCP_COLLAB_TOKEN_TTL_MS so a burst of mutations
// reuses ONE token -> ONE session. These tests exercise both mint sources:
// - the getCollabToken PROVIDER path (in-app agent), via a counting provider fn;
// - the REST /auth/collab-token path (external MCP), via a mock http server.
// getCollabTokenWithReauth is private in TS but a plain method on the compiled
// build, so the tests call it directly (same convention as reauth.test.mjs).
import { test, afterEach, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
// Restore the env knob after each test so cases do not leak into one another.
const ENV_KEY = "MCP_COLLAB_TOKEN_TTL_MS";
afterEach(() => {
delete process.env[ENV_KEY];
});
// ---------------------------------------------------------------------------
// Small mock server for the REST /auth/collab-token path. Counts collab-token
// mints and can be told to 401 the first N of them (to drive the reauth retry).
// ---------------------------------------------------------------------------
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 = [];
after(async () => {
await Promise.all(
openServers.map((s) => new Promise((r) => s.close(r))),
);
});
// state: { collabCalls, loginCalls, unauthorizedCollabHits }
function spawnCollabServer(state, { collabAuthFailsFor = 0 } = {}) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
state.loginCalls++;
// A fresh authToken per login so an identity change is observable.
sendJson(res, 200, { success: true }, {
"Set-Cookie": `authToken=login-${state.loginCalls}; Path=/; HttpOnly`,
});
return;
}
if (req.url === "/api/auth/collab-token") {
state.collabCalls++;
if (state.collabCalls <= collabAuthFailsFor) {
sendJson(res, 401, { message: "Unauthorized" });
return;
}
// Unique token per mint so a stale cached value is distinguishable.
sendJson(res, 200, { data: { token: `collab-${state.collabCalls}` } });
return;
}
sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
// ===========================================================================
// PROVIDER path (in-app agent getCollabToken fn)
// ===========================================================================
// 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;
},
};
}
test("within TTL, repeated calls return the SAME token and mint ONCE (provider path)", async () => {
process.env[ENV_KEY] = "300000"; // 5 min
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const a = await client.getCollabTokenWithReauth();
const b = await client.getCollabTokenWithReauth();
const c = await client.getCollabTokenWithReauth();
assert.equal(a, "provider-token-1");
assert.equal(b, a, "second call reuses the cached token");
assert.equal(c, a, "third call reuses the cached token");
assert.equal(p.calls, 1, "the provider is invoked exactly once within the TTL");
});
test("after TTL expiry a new token is minted (provider path)", async () => {
process.env[ENV_KEY] = "20"; // 20ms TTL
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const a = await client.getCollabTokenWithReauth();
await new Promise((r) => setTimeout(r, 40)); // let the TTL lapse
const b = await client.getCollabTokenWithReauth();
assert.equal(a, "provider-token-1");
assert.equal(b, "provider-token-2", "a fresh token is minted after expiry");
assert.equal(p.calls, 2);
});
test("MCP_COLLAB_TOKEN_TTL_MS=0 disables the cache: mint on EVERY call (provider path)", async () => {
process.env[ENV_KEY] = "0";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
assert.equal(p.calls, 3, "cache disabled -> exact fetch-per-call legacy path");
});
test("a 401 triggers the internal reauth retry, which bypasses the cache and mints fresh (provider path)", async () => {
process.env[ENV_KEY] = "300000";
let n = 0;
const provider = async () => {
n++;
if (n === 1) {
// The FIRST mint fails with an auth error; the internal reauth retry must
// re-invoke the provider (bypassing the empty cache) for a fresh token.
const err = new Error("collab token expired");
err.status = 401;
throw err;
}
return `provider-token-${n}`;
};
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: provider,
});
// Cache is empty: mint #1 401s -> the reauth retry mints #2 and caches it.
const tok = await client.getCollabTokenWithReauth();
assert.equal(tok, "provider-token-2", "the post-401 retry token wins");
assert.equal(n, 2, "exactly one failed mint + one retry, no loop");
// The retried token is what got cached (no extra mint on a cache hit).
const cached = await client.getCollabTokenWithReauth();
assert.equal(cached, "provider-token-2");
assert.equal(n, 2, "served from cache, provider not re-invoked");
});
test("forceRefresh=true bypasses a warm cache and mints a fresh token (provider path)", 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 first = await client.getCollabTokenWithReauth(); // caches token-1
assert.equal(first, "provider-token-1");
// A forced refresh (what the reauth path passes) must NOT return the cached
// token-1; it mints a fresh token-2 and replaces the cache.
const forced = await client.getCollabTokenWithReauth(true);
assert.equal(forced, "provider-token-2", "cache bypassed on forceRefresh");
assert.equal(p.calls, 2);
const cached = await client.getCollabTokenWithReauth();
assert.equal(cached, "provider-token-2", "the fresh token replaced the cache");
assert.equal(p.calls, 2);
});
test("two consecutive mutations keep the SAME token, so the session key is stable (provider path)", async () => {
// The whole point of #435: acquireCollabSession keys on the token, so two
// acquire calls in a burst must be handed the identical token string.
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 t1 = await client.getCollabTokenWithReauth();
const t2 = await client.getCollabTokenWithReauth();
assert.equal(t1, t2, "identical token across two mutations -> one session key");
assert.equal(p.calls, 1);
});
// ===========================================================================
// REST /auth/collab-token path (external MCP)
// ===========================================================================
test("within TTL, the REST /auth/collab-token endpoint is hit ONCE", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const a = await client.getCollabTokenWithReauth();
const b = await client.getCollabTokenWithReauth();
assert.equal(a, "collab-1");
assert.equal(b, a, "cached token reused");
assert.equal(state.collabCalls, 1, "POST /auth/collab-token called once");
});
test("TTL=0 hits the REST endpoint on every call", async () => {
process.env[ENV_KEY] = "0";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
assert.equal(state.collabCalls, 2, "cache disabled -> fetch each call");
});
test("401 on REST collab-token re-logs-in and refetches (cache bypassed)", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
// The first collab-token mint 401s; the reauth path logs in and retries.
const baseURL = await spawnCollabServer(state, { collabAuthFailsFor: 1 });
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// Pre-seed a token so the initial call does not perform an initial login.
client.token = "seed";
client.client.defaults.headers.common["Authorization"] = "Bearer seed";
const tok = await client.getCollabTokenWithReauth();
assert.equal(tok, "collab-2", "the post-reauth mint wins, not the failed one");
assert.equal(state.loginCalls, 1, "re-login happened exactly once");
assert.equal(state.collabCalls, 2, "one failed mint + one successful retry");
});
test("a fresh login clears the cache so a collab token cannot outlive the identity", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const before = await client.getCollabTokenWithReauth();
assert.equal(before, "collab-1");
// Simulate an identity change (the 401 interceptor / re-login path calls
// login(), which must drop the cached collab token).
await client.login();
const after = await client.getCollabTokenWithReauth();
assert.equal(after, "collab-2", "cache was invalidated by login(); refetched");
assert.equal(state.collabCalls, 2);
});
@@ -548,94 +548,3 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
); );
assert.equal(createPayload.suggestedText, "goodbye"); assert.equal(createPayload.suggestedText, "goodbye");
}); });
// -----------------------------------------------------------------------------
// 8) #408: a not-found selection error QUOTES the closest block text so the
// model can self-correct instead of blind-retrying.
// -----------------------------------------------------------------------------
test("a not-found selection error includes a 'Closest block text' hint", async () => {
let createCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 200, {
data: {
id: "page-1",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "The quick brown fox jumps" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createCalls++;
sendJson(res, 200, { data: { id: "should-not-happen" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "quick brown cat"),
/Closest block text: "The quick brown fox jumps"/,
"a not-found selection must quote the closest block text",
);
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
});
// -----------------------------------------------------------------------------
// 9) #408: a selection that straddles two blocks gets the explicit
// "spans multiple blocks" message instead of a bare not-found.
// -----------------------------------------------------------------------------
test("a selection spanning multiple blocks gets the explicit spans-multiple-blocks message", async () => {
let createCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 200, {
data: {
id: "page-1",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "the quick brown" }] },
{ type: "paragraph", content: [{ type: "text", text: "fox jumps over" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createCalls++;
sendJson(res, 200, { data: { id: "should-not-happen" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "brown fox"),
/spans multiple blocks/,
"a cross-block selection must report the spans-multiple-blocks hint",
);
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
});
@@ -1,467 +0,0 @@
// Contract tests for the drawio_get / drawio_create / drawio_update client
// methods (issue #423). Follows the repo's seam-override pattern (see
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
// write) so the tool logic is exercised without a live Docmost or collab socket.
import { test } from "node:test";
import assert from "node:assert/strict";
import pako from "pako";
import { DocmostClient } from "../../build/client.js";
import {
buildDrawioSvg,
encodeDrawioFile,
normalizeXml,
mxHash,
decodeDrawioSvg,
} from "../../build/lib/drawio-xml.js";
const MODEL =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Hi" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="20" y="20" width="120" height="60" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
// Build a Docmost-style `.drawio.svg` (base64 content) for a model.
function svgFor(model) {
return buildDrawioSvg(normalizeXml(model), "<g/>", { width: 200, height: 120 });
}
// Build a human/compressed-export `.drawio.svg` (base64 content wrapping a
// compressed <diagram> payload), mimicking a diagram a person saved.
function compressedSvgFor(model) {
const compressed = Buffer.from(
pako.deflateRaw(encodeURIComponent(normalizeXml(model))),
).toString("base64");
const file = `<mxfile host="Electron"><diagram id="a" name="Page-1">${compressed}</diagram></mxfile>`;
const content = Buffer.from(file, "utf-8").toString("base64");
return `<svg xmlns="http://www.w3.org/2000/svg" content="${content}"><image href="x"/></svg>`;
}
// The vendored `drawio` node schema (diagramAttributes) declares ONLY these
// attributes; PMNode.fromJSON drops anything else on save. Mirror that here so
// the mock write path behaves like the real one — in particular, a block `id`
// set on a drawio node does NOT survive the save, so a handle keyed on it is
// un-resolvable. This is exactly what the production bug (issue #423 Fix 1) was.
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 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, mime) {
const id = `att-${calls.uploads.length + 1}`;
calls.uploads.push({ pageId, fileName, mime, svg: buffer.toString("utf-8") });
return { id, fileName, fileSize: buffer.length };
}
async fetchAttachmentText(src) {
return attachmentSvg;
}
mutatePage(pageId, token, apiUrl, transform) {
// Run the transform against a clone of the source doc, capture the result.
const clone = structuredClone(pageDoc ?? { type: "doc", content: [] });
const doc = transform(clone);
// Mirror the real schema: unknown drawio attrs (e.g. a block `id`) are
// dropped on save, so callers can never rely on them to address the node.
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;
}
// --- drawio_create ---------------------------------------------------------
test("drawio_create: lints, builds the .drawio.svg, uploads 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.drawioCreate("page1", { position: "append" }, MODEL, "My diagram");
assert.equal(res.success, true);
// The returned handle is an index-based "#<index>" ref (drawio nodes carry no
// persisted attrs.id), addressing the appended top-level block (index 1, after
// the existing paragraph).
assert.equal(res.nodeId, "#1");
assert.equal(res.attachmentId, "att-1");
assert.equal(calls.uploads.length, 1);
assert.equal(calls.uploads[0].fileName, "diagram.drawio.svg");
assert.equal(calls.uploads[0].mime, "image/svg+xml");
// The uploaded SVG carries the model back (round-trips through the decode chain).
assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(MODEL));
// A drawio node was appended with src/attachmentId/dimensions and the title.
const drawios = findDrawio(calls.mutations[0].doc);
assert.equal(drawios.length, 1);
const n = drawios[0];
// No `id` attribute is set/persisted on the node (schema has none).
assert.equal(n.attrs.id, undefined);
assert.equal(n.attrs.attachmentId, "att-1");
assert.match(n.attrs.src, /^\/api\/files\/att-1\//);
assert.ok(n.attrs.width > 0 && n.attrs.height > 0);
assert.equal(n.attrs.title, "My diagram");
});
test("drawio_create: a lint violation throws before any upload", async () => {
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
// Edge with no child geometry -> edge-geometry rule.
const bad =
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'<mxCell id="3" edge="1" parent="1" source="2" target="2"/></root></mxGraphModel>';
await assert.rejects(
() => client.drawioCreate("page1", { position: "append" }, bad, undefined),
/edge-geometry/,
);
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
});
test("drawio_create: before/after requires exactly one anchor", async () => {
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
await assert.rejects(
() => client.drawioCreate("page1", { position: "before" }, MODEL),
/exactly one of anchorNodeId or anchorText/,
);
});
// --- drawio_get ------------------------------------------------------------
test("drawio_get: decodes the model and returns meta with a hash", async () => {
const pageDoc = {
type: "doc",
content: [
{
type: "drawio",
attrs: {
id: "d1",
src: "/api/files/att-1/diagram.drawio.svg",
attachmentId: "att-1",
title: "T",
width: 200,
height: 120,
},
},
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
const res = await client.drawioGet("page1", "d1", "xml");
assert.equal(res.content, normalizeXml(MODEL));
assert.equal(res.meta.attachmentId, "att-1");
assert.equal(res.meta.title, "T");
assert.equal(res.meta.cellCount, 1);
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
});
test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
const svg = svgFor(MODEL);
const pageDoc = {
type: "doc",
content: [
{ type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } },
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svg });
const res = await client.drawioGet("page1", "d1", "svg");
assert.equal(res.content, svg);
});
test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
const pageDoc = {
type: "doc",
content: [
{ type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } },
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: compressedSvgFor(MODEL) });
const res = await client.drawioGet("page1", "d1", "xml");
assert.equal(res.content, normalizeXml(MODEL));
});
// --- drawio_update ---------------------------------------------------------
const UPDATED_MODEL =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Changed" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="20" y="20" width="300" height="200" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
function updatePageDoc() {
return {
type: "doc",
content: [
{
type: "drawio",
attrs: {
id: "d1",
src: "/api/files/att-1/diagram.drawio.svg",
attachmentId: "att-1",
width: 200,
height: 120,
},
},
],
};
}
test("drawio_update: stale baseHash -> conflict, no upload", async () => {
const { client, calls } = makeClient({
pageDoc: updatePageDoc(),
attachmentSvg: svgFor(MODEL),
});
await assert.rejects(
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, "deadbeef-stale"),
/conflict/,
);
assert.equal(calls.uploads.length, 0, "no upload on conflict");
});
test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => {
const currentHash = mxHash(normalizeXml(MODEL));
const { client, calls } = makeClient({
pageDoc: updatePageDoc(),
attachmentSvg: svgFor(MODEL),
});
const res = await client.drawioUpdate("page1", "d1", UPDATED_MODEL, currentHash);
assert.equal(res.success, true);
assert.equal(res.attachmentId, "att-1"); // fresh id from the stub sequence
assert.equal(calls.uploads.length, 1);
// The uploaded SVG carries the NEW model.
assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(UPDATED_MODEL));
// The node was repointed with the new bounding-box dimensions:
// vertex maxX=320,maxY=220 + the 20px preview margin -> 340 x 240.
const n = findDrawio(calls.mutations[0].doc)[0];
assert.equal(n.attrs.attachmentId, "att-1");
assert.equal(n.attrs.width, 340);
assert.equal(n.attrs.height, 240);
// The block `id` used as the legacy resolution handle is dropped on save
// (schema declares no `id`); the update still targeted the correct node.
assert.equal(n.attrs.id, undefined);
});
test("drawio_update: baseHash is mandatory", async () => {
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
/baseHash is mandatory/,
);
});
// --- Fix 1: the create handle must resolve on the SAVED doc (no id) ---------
test("drawio_create -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
// Create appends a drawio node after the existing paragraph.
const createDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const create = makeClient({ pageDoc: createDoc });
const res = await create.client.drawioCreate(
"page1",
{ position: "append" },
MODEL,
"T",
);
// The handle is index-based, not a block id.
assert.equal(res.nodeId, "#1");
// Take the document EXACTLY as it was saved: the schema drop stripped the
// node's id, so no id-based handle could ever resolve against it.
const savedDoc = create.calls.mutations[0].doc;
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
// drawio_get with the returned handle resolves the just-created node.
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
assert.equal(got.nodeId, res.nodeId);
assert.equal(got.content, normalizeXml(MODEL));
// drawio_update with the same handle + the hash from get repoints that node.
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const upd = await upClient.client.drawioUpdate(
"page1",
res.nodeId,
UPDATED_MODEL,
got.meta.hash,
);
assert.equal(upd.success, true);
assert.equal(upd.nodeId, res.nodeId);
const updated = findDrawio(upClient.calls.mutations[0].doc)[0];
assert.equal(
decodeDrawioSvg(upClient.calls.uploads[0].svg),
normalizeXml(UPDATED_MODEL),
);
assert.equal(updated.attrs.width, 340);
});
// --- error paths: the LLM must get a clean error, not a crash --------------
test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
// Page has one paragraph; the requested ref resolves to nothing.
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioGet("page1", "does-not-exist", "xml"),
/no node found for "does-not-exist"/,
);
});
test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => {
const pageDoc = {
type: "doc",
content: [
// A drawio node that carries no `src` (e.g. a half-written node).
{ type: "drawio", attrs: { id: "d1", attachmentId: "att-1" } },
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioGet("page1", "d1", "xml"),
/node "d1" on page page1 has no src to read/,
);
});
test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
// "#0" resolves to a paragraph. The update must refuse cleanly rather than
// crash or repoint the wrong node.
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client, calls } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioUpdate("page1", "#0", UPDATED_MODEL, "any-nonempty-hash"),
/node "#0" on page page1 is a paragraph, not a drawio diagram/,
);
assert.equal(calls.uploads.length, 0, "no upload when the node is not a diagram");
assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram");
});
test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => {
// The upload happens before the mutate transform; when the anchor cannot be
// found the write is skipped and the (now unreferenced) attachment is named
// in the error, exactly as the code documents.
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client, calls } = makeClient({ pageDoc });
await assert.rejects(
() =>
client.drawioCreate(
"page1",
{ position: "after", anchorNodeId: "nope" },
MODEL,
"T",
),
(err) =>
/anchor not found/.test(err.message) &&
/unreferenced orphan/.test(err.message) &&
/att-1/.test(err.message),
);
// The orphan was uploaded (and reported), but no node was written.
assert.equal(calls.uploads.length, 1, "attachment uploaded before the failed insert");
const drawios = calls.mutations.length ? findDrawio(calls.mutations[0].doc) : [];
assert.equal(drawios.length, 0, "no drawio node written when the anchor is missing");
});
// --- Fix 2: update targets ONLY the resolved node --------------------------
test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
// A copied diagram: two drawio nodes share one attachmentId. Updating via the
// "#0" handle must touch node #0 only, never the sibling copy.
const shared = {
type: "doc",
content: [
{
type: "drawio",
attrs: {
src: "/api/files/shared/x.svg",
attachmentId: "shared",
width: 200,
height: 120,
},
},
{
type: "drawio",
attrs: {
src: "/api/files/shared/x.svg",
attachmentId: "shared",
width: 200,
height: 120,
},
},
],
};
const { client, calls } = makeClient({
pageDoc: shared,
attachmentSvg: svgFor(MODEL),
});
const res = await client.drawioUpdate(
"page1",
"#0",
UPDATED_MODEL,
mxHash(normalizeXml(MODEL)),
);
assert.equal(res.success, true);
const drawios = findDrawio(calls.mutations[0].doc);
assert.equal(drawios.length, 2);
// Node #0 repointed to the NEW attachment ("att-1" from the stub) and dims.
assert.equal(drawios[0].attrs.attachmentId, "att-1");
assert.equal(drawios[0].attrs.width, 340);
assert.match(drawios[0].attrs.src, /^\/api\/files\/att-1\//);
// Node #1 (the sibling copy) is untouched despite sharing the old attachmentId.
assert.equal(drawios[1].attrs.attachmentId, "shared");
assert.equal(drawios[1].attrs.width, 200);
assert.equal(drawios[1].attrs.src, "/api/files/shared/x.svg");
});
@@ -1,7 +1,7 @@
// Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the // Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the
// representative path that is fully plain-HTTP (import + getPage) and so is // representative path that is fully plain-HTTP (import + getPage) and so is
// mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the // mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the
// IDENTICAL wiring (`analyzeFootnotes(...)` + spread-when-non-empty) but run their // IDENTICAL wiring (`footnoteWarningsField(...)` spread-when-non-empty) but run their
// mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness // mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness
// does not stand up. The analyzer itself is unit-tested in footnote-analyze.test. // does not stand up. The analyzer itself is unit-tested in footnote-analyze.test.
import { test, after } from "node:test"; import { test, after } from "node:test";
@@ -76,35 +76,29 @@ function pageHandler() {
}; };
} }
test("createPage attaches footnoteWarnings when the content has footnote problems", async () => { test("createPage attaches footnoteWarnings when the content uses legacy footnote syntax", async () => {
const baseURL = await spawn(pageHandler()); const baseURL = await spawn(pageHandler());
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
// A dangling reference + a duplicate definition + a table marker. // Legacy reference-style `[^id]:` definitions — inert on import since #293.
const content = [ const content = ["Intro[^a].", "", "[^a]: a definition"].join("\n");
"Intro[^missing] and| cell[^t] |.",
"",
"[^d]: one",
"[^d]: two",
"[^t]: in table",
].join("\n");
const result = await client.createPage("T", content, "sp-1"); const result = await client.createPage("T", content, "sp-1");
assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present"); assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present");
const joined = result.footnoteWarnings.join("\n"); const joined = result.footnoteWarnings.join("\n");
assert.match(joined, /no matching definition/); // dangling [^missing] assert.match(joined, /reference-style footnotes/i);
assert.match(joined, /defined more than once/); // duplicate [^d] assert.match(joined, /\^\[footnote text\]/); // nudge to the inline form
// The page itself is still returned. // The page itself is still returned.
assert.equal(result.success, true); assert.equal(result.success, true);
}); });
test("createPage omits footnoteWarnings when the content is clean", async () => { test("createPage omits footnoteWarnings when the content uses the inline form", async () => {
const baseURL = await spawn(pageHandler()); const baseURL = await spawn(pageHandler());
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
const content = ["A[^a] and reuse[^a].", "", "[^a]: fine"].join("\n"); const content = "A note.^[the body] and reuse.^[the body]";
const result = await client.createPage("T", content, "sp-1"); const result = await client.createPage("T", content, "sp-1");
assert.equal( assert.equal(
"footnoteWarnings" in result, "footnoteWarnings" in result,
false, false,
"no footnoteWarnings field on clean input", "no footnoteWarnings field on inline-footnote input",
); );
assert.equal(result.success, true); assert.equal(result.success, true);
}); });
@@ -19,7 +19,6 @@ import { WebSocketServer } from "ws";
import { Hocuspocus } from "@hocuspocus/server"; import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js"; import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js"; import { buildYDoc } from "../../build/lib/collaboration.js";
import { destroyAllSessions } from "../../build/lib/collab-session.js";
// Import the SAME page-lock module instance that build/client.js imports. ESM // Import the SAME page-lock module instance that build/client.js imports. ESM
// caches modules by resolved URL, so this `withPageLock` shares the very // caches modules by resolved URL, so this `withPageLock` shares the very
// per-page mutex map (`chains`) the client uses — letting the replaceImage test // per-page mutex map (`chains`) the client uses — letting the replaceImage test
@@ -189,10 +188,6 @@ async function spawnCollabStack(opts = {}) {
const openStacks = []; const openStacks = [];
after(async () => { after(async () => {
// #400: tests now leave a cached live CollabSession per page. Destroy them
// first (closes the client ws) so the server.close() below is not racing an
// open collab connection.
destroyAllSessions();
await Promise.all( await Promise.all(
openStacks.map( openStacks.map(
({ server, hocuspocus }) => ({ server, hocuspocus }) =>
@@ -275,23 +270,17 @@ test("a UUID input is passed through unchanged and triggers NO /pages/info fetch
); );
}); });
test("repeated slugId edits reuse ONE live collab session and resolve the UUID only once (#400 cache)", async () => { test("a repeated slugId edit resolves the UUID only once (cache)", async () => {
const { state, baseURL } = await spawnCollabStack(); const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw"); const client = new DocmostClient(baseURL, "user@example.com", "pw");
// #400: a series of edits on the same page reuses ONE live CollabSession, so // Each mock connection re-seeds a fresh "hello world" doc (the mock does not
// the connect/handshake happens once and the collab doc is OPENED a single // persist across connects), so both edits target "hello". The cache assertion
// time (not per edit). The live ydoc persists between edits (the whole point), // only concerns the slugId->uuid resolution, not the document content.
// so the second edit sees the first edit's result: after "hello" -> "hi world"
// it targets the still-present "world".
await client.editPageText(SLUG, [{ find: "hello", replace: "hi" }]); await client.editPageText(SLUG, [{ find: "hello", replace: "hi" }]);
await client.editPageText(SLUG, [{ find: "world", replace: "planet" }]); await client.editPageText(SLUG, [{ find: "hello", replace: "hey" }]);
assert.deepEqual( assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
state.docNames,
[`page.${UUID}`],
"the two edits must reuse one live collab session -> a single collab-doc open (#400)",
);
assert.equal( assert.equal(
state.pagesInfoCalls.length, state.pagesInfoCalls.length,
1, 1,
@@ -336,9 +325,8 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
await uploadStarted; // deterministic: replaceImage now holds its page lock. await uploadStarted; // deterministic: replaceImage now holds its page lock.
// (a) OPEN BY UUID: the only collab doc opened so far (the scan pass) used the // (a) OPEN BY UUID: the only collab doc opened so far (the scan pass) used the
// canonical UUID, never the slugId. (#400: the write pass will REUSE this same // canonical UUID, never the slugId. (The write pass opens a second time after
// live session rather than reopen, so docNames stays a single entry — asserted // we release the gate; asserted at the end.)
// at the end.)
assert.deepEqual( assert.deepEqual(
state.docNames, state.docNames,
[`page.${UUID}`], [`page.${UUID}`],
@@ -390,9 +378,8 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
assert.equal(res.success, true); assert.equal(res.success, true);
assert.equal(res.replaced, 1, "the one seeded image must be repointed"); assert.equal(res.replaced, 1, "the one seeded image must be repointed");
// #400: the write pass REUSES the scan pass's live session, so the collab doc // Both opens (scan pass + write pass) used the UUID; the slugId never appears.
// is opened ONCE across both passes (never reopened, never by the slugId). assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
assert.deepEqual(state.docNames, [`page.${UUID}`]);
assert.ok( assert.ok(
!state.docNames.includes(`page.${SLUG}`), !state.docNames.includes(`page.${SLUG}`),
"replaceImage must NEVER open the collab doc by the slugId (the #260 bug)", "replaceImage must NEVER open the collab doc by the slugId (the #260 bug)",
@@ -81,10 +81,6 @@ const HOST_CONTRACT_METHODS = [
"insertImage", "insertImage",
"replaceImage", "replaceImage",
"insertFootnote", "insertFootnote",
// draw.io diagrams (#423, stage 1) — read + create + optimistic-locked update
"drawioGet",
"drawioCreate",
"drawioUpdate",
// write (comment) // write (comment)
"createComment", "createComment",
"resolveComment", "resolveComment",
@@ -1,348 +0,0 @@
import { test, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
acquireCollabSession,
destroyAllSessions,
__setCollabProviderFactory,
__sessionCountForTests,
} from "../../build/lib/collab-session.js";
import { withPageLock } from "../../build/lib/page-lock.js";
// A stand-in for HocuspocusProvider: it shares the ydoc (so the real yjs
// read/transform/write in CollabSession.mutate runs unchanged), auto-completes
// the connect+sync handshake on a microtask (unaffected by mock timers), and
// exposes hooks to drive disconnect/close/auth-failure and the unsyncedChanges
// ack. There is no collaboration server in the test env, so every test drives
// the provider through this fake.
class FakeProvider extends EventEmitter {
static instances = [];
static connectCount = 0;
static reset() {
FakeProvider.instances = [];
FakeProvider.connectCount = 0;
}
static last() {
return FakeProvider.instances[FakeProvider.instances.length - 1];
}
constructor(config, opts = {}) {
super();
this.config = config;
this.ydoc = config.document;
this.synced = false;
this.unsyncedChanges = opts.unsynced ?? 0;
this.destroyed = false;
FakeProvider.instances.push(this);
FakeProvider.connectCount += 1;
if (opts.autoSync !== false) {
// Real HocuspocusProvider fires onSynced asynchronously after the
// handshake; a microtask reproduces that without depending on timers.
queueMicrotask(() => {
if (this.destroyed) return;
this.config.onConnect?.();
this.synced = true;
this.config.onSynced?.();
});
}
}
destroy() {
this.destroyed = true;
}
// --- test drivers ---
_disconnect() {
this.config.onDisconnect?.();
}
_close() {
this.config.onClose?.();
}
_authFail() {
this.config.onAuthenticationFailed?.();
}
_ack() {
this.unsyncedChanges = 0;
this.emit("unsyncedChanges", { number: 0 });
}
}
/** Build a provider factory that stamps every provider with the given opts. */
function factory(opts = {}) {
return (config) => new FakeProvider(config, opts);
}
/** A minimal, schema-valid ProseMirror doc for a write. */
function docWith(text) {
return {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: String(text) }] },
],
};
}
const ENV_KEYS = [
"MCP_COLLAB_SESSION_IDLE_MS",
"MCP_COLLAB_SESSION_MAX_AGE_MS",
"MCP_COLLAB_SESSION_MAX_ENTRIES",
];
let savedEnv;
beforeEach(() => {
savedEnv = {};
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
FakeProvider.reset();
__setCollabProviderFactory(factory());
});
afterEach(() => {
destroyAllSessions();
__setCollabProviderFactory(null);
mock.timers.reset();
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
test("acquire opens one provider; reuse returns the SAME live session", async () => {
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(a, b, "same (wsUrl,page,token) must reuse the session");
assert.equal(FakeProvider.connectCount, 1, "exactly one connect/sync");
assert.equal(__sessionCountForTests(), 1);
});
test("N mutates on one page open the provider ONCE (coalesced series)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
for (let i = 0; i < 20; i++) {
const r = await session.mutate(() => docWith(`edit ${i}`));
assert.ok(r && r.doc, "each mutate resolves a MutationResult");
}
assert.equal(
FakeProvider.connectCount,
1,
"20 mutates must not reconnect — one live provider for the whole series",
);
});
test("mutate preserves the transform-abort no-op report (null transform)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const r = await session.mutate(() => null);
assert.equal(r.verify.changed, false);
assert.equal(r.verify.summary, "no changes (transform aborted)");
});
test("registry key includes the token: different tokens => different sessions", async () => {
const a = await acquireCollabSession("page-1", "tok-A", "http://h/api");
const b = await acquireCollabSession("page-1", "tok-B", "http://h/api");
assert.notEqual(a, b, "different tokens must never share a session");
assert.equal(FakeProvider.connectCount, 2);
assert.equal(__sessionCountForTests(), 2);
// Same token reuses.
const a2 = await acquireCollabSession("page-1", "tok-A", "http://h/api");
assert.equal(a, a2);
assert.equal(FakeProvider.connectCount, 2);
});
test("disconnect at any time kills the session and removes it from the registry", async () => {
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 1);
FakeProvider.last()._disconnect();
assert.equal(__sessionCountForTests(), 0, "dead session is deregistered");
// Re-acquire opens a brand new provider.
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b);
assert.equal(FakeProvider.connectCount, 2);
});
test("an in-flight mutate rejects with the connection-closed text on disconnect", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 })); // stay pending after write
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const p = session.mutate(() => docWith("x"));
// The write happened synchronously; persistence is pending. Now the socket drops.
FakeProvider.last()._disconnect();
await assert.rejects(
p,
/Collaboration connection closed before the update was persisted\/synced/,
);
});
test("auth failure rejects the pending open with the auth error text", async () => {
__setCollabProviderFactory(factory({ autoSync: false }));
const p = acquireCollabSession("page-1", "tok", "http://h/api");
// Let the provider be constructed, then fail auth.
await Promise.resolve();
FakeProvider.last()._authFail();
await assert.rejects(
p,
/Authentication failed for collaboration connection/,
);
assert.equal(__sessionCountForTests(), 0);
});
test("mutate-error invalidates the session (caller destroys; re-acquire is fresh)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
await assert.rejects(
session.mutate(() => {
throw new Error("afterText not found");
}),
/afterText not found/,
);
// The production caller destroys on failure; mirror that here.
session.destroy("mutate failed");
assert.equal(__sessionCountForTests(), 0);
const fresh = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(fresh, session);
assert.equal(FakeProvider.connectCount, 2);
});
test("a pending write resolves when the server acks (unsyncedChanges -> 0)", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 }));
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const p = session.mutate(() => docWith("y"));
FakeProvider.last()._ack();
const r = await p;
assert.ok(r.doc);
});
test("concurrent mutate on one session: the second rejects, the first is unaffected", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 })); // first stays pending after write
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
// First mutate: write happens synchronously, persistence ack still pending.
const first = session.mutate(() => docWith("first"));
// Second overlapping mutate on the SAME session must fail fast.
await assert.rejects(
session.mutate(() => docWith("second")),
/mutate already in-flight; caller must serialize \(hold the page lock\)/,
);
// The first op is untouched: its rejector was not clobbered. Ack it now.
FakeProvider.last()._ack();
const r = await first;
assert.ok(r.doc, "the first in-flight mutate still resolves on its own ack");
assert.equal(FakeProvider.connectCount, 1, "no reconnect from the rejected overlap");
});
test("sequential mutates on one session both succeed (the guard doesn't break serialized use)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
// Await the first fully, then run the second: inflightReject was cleared by
// localFinish before the first settled, so the guard is clear for the second.
const r1 = await session.mutate(() => docWith("one"));
assert.ok(r1.doc, "first sequential mutate resolves");
const r2 = await session.mutate(() => docWith("two"));
assert.ok(r2.doc, "second sequential mutate resolves — guard not tripped");
assert.equal(FakeProvider.connectCount, 1, "sequential mutates reuse one provider");
});
test("connect timeout rejects with the connect-timeout text and fires the metric hook", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
__setCollabProviderFactory(factory({ autoSync: false }));
let metricFired = 0;
const p = acquireCollabSession("page-1", "tok", "http://h/api", {
onConnectTimeout: () => {
metricFired += 1;
},
});
mock.timers.tick(25000);
await assert.rejects(p, /Connection timeout to collaboration server/);
assert.equal(metricFired, 1);
assert.equal(__sessionCountForTests(), 0);
});
test("idle TTL destroys the session; re-acquire reconnects", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
process.env.MCP_COLLAB_SESSION_IDLE_MS = "1000";
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 1);
mock.timers.tick(1000); // idle fires
assert.equal(__sessionCountForTests(), 0, "idle timeout destroyed it");
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b);
assert.equal(FakeProvider.connectCount, 2);
});
test("max age is enforced at acquire (destroy + open fresh), idle held off", async () => {
mock.timers.enable({ apis: ["setTimeout", "Date"] });
process.env.MCP_COLLAB_SESSION_MAX_AGE_MS = "1000";
process.env.MCP_COLLAB_SESSION_IDLE_MS = "10000000"; // never fires in this test
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
mock.timers.tick(2000); // past max age, below idle
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b, "a session past its max age must be replaced");
assert.equal(FakeProvider.connectCount, 2);
});
test("registry cap: least-recently-used session is destroy-evicted", async () => {
process.env.MCP_COLLAB_SESSION_MAX_ENTRIES = "2";
const s1 = await acquireCollabSession("page-1", "tok", "http://h/api");
const p1prov = FakeProvider.last();
const s2 = await acquireCollabSession("page-2", "tok", "http://h/api");
const p2prov = FakeProvider.last();
assert.equal(__sessionCountForTests(), 2);
// Touch page-1 so page-2 becomes the least-recently-used entry.
assert.equal(await acquireCollabSession("page-1", "tok", "http://h/api"), s1);
assert.equal(FakeProvider.connectCount, 2, "reuse must not reconnect");
// A third distinct page (cap 2) must evict the LRU (page-2), not page-1.
const s3 = await acquireCollabSession("page-3", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 2);
assert.equal(FakeProvider.connectCount, 3);
assert.equal(p2prov.destroyed, true, "the LRU provider was destroy-evicted");
assert.equal(p1prov.destroyed, false, "the MRU page-1 survived");
// page-1 still reused (survived), page-3 still reused.
assert.equal(await acquireCollabSession("page-1", "tok", "http://h/api"), s1);
assert.equal(await acquireCollabSession("page-3", "tok", "http://h/api"), s3);
assert.equal(FakeProvider.connectCount, 3, "no extra reconnects");
});
test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)", async () => {
process.env.MCP_COLLAB_SESSION_IDLE_MS = "0";
const s1 = await acquireCollabSession("page-1", "tok", "http://h/api");
// Never registered (ephemeral).
assert.equal(__sessionCountForTests(), 0);
const r1 = await s1.mutate(() => docWith("a"));
assert.ok(r1.doc);
// After its single op the ephemeral session self-destroyed.
assert.equal(FakeProvider.instances[0].destroyed, true);
// A second op opens a BRAND NEW provider (no reuse).
const s2 = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(s1, s2);
assert.equal(FakeProvider.connectCount, 2);
});
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
const pageId = "page-lock";
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
// each going through the non-locking acquireCollabSession.
const result = await withPageLock(pageId, async () => {
// pass 1: read-only scan (transform returns null -> no write)
const scan = await acquireCollabSession(pageId, "tok", "http://h/api");
const scanRes = await scan.mutate(() => null);
assert.equal(scanRes.verify.changed, false);
// pass 2: the actual write, same held lock
const write = await acquireCollabSession(pageId, "tok", "http://h/api");
return write.mutate(() => docWith("repointed"));
});
assert.ok(result.doc, "the locked scan+write completed without deadlock");
assert.equal(
FakeProvider.connectCount,
1,
"both passes under the held lock reuse ONE live session",
);
});
test("destroyAllSessions tears down every cached session", async () => {
await acquireCollabSession("page-1", "tok", "http://h/api");
await acquireCollabSession("page-2", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 2);
const provs = [...FakeProvider.instances];
destroyAllSessions();
assert.equal(__sessionCountForTests(), 0);
assert.ok(provs.every((p) => p.destroyed), "all providers destroyed");
});
@@ -8,7 +8,6 @@ import {
applyAnchorInDoc, applyAnchorInDoc,
countAnchorMatches, countAnchorMatches,
getAnchoredText, getAnchoredText,
resolveAnchorSelection,
} from "../../build/lib/comment-anchor.js"; } from "../../build/lib/comment-anchor.js";
const COMMENT_ID = "cmt-123"; const COMMENT_ID = "cmt-123";
@@ -309,70 +308,3 @@ test("getAnchoredText returns null when the selection does not anchor", () => {
const doc = paragraphDoc([{ type: "text", text: "hello world" }]); const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
assert.equal(getAnchoredText(doc, "not present"), null); assert.equal(getAnchoredText(doc, "not present"), null);
}); });
// ---------------------------------------------------------------------------
// #408 MARKDOWN-STRIP FALLBACK. A selection copied with inline markdown still
// carries `**`/`` ` ``/`[t](u)` markers the plain document text lacks. When the
// verbatim selection anchors nowhere, all four entry points retry with the
// markdown stripped — consistently, so the suggestion-uniqueness gate stays
// coherent — while what gets STORED remains the raw document substring.
// ---------------------------------------------------------------------------
test("a markdown-styled selection anchors against plain doc text via the strip fallback", () => {
const doc = paragraphDoc([{ type: "text", text: "a bold word here" }]);
// The agent quoted "**bold** word" from a styled view; the doc is plain text.
const sel = "**bold** word";
const resolved = resolveAnchorSelection(doc, sel);
assert.equal(resolved.found, true, "strip fallback finds the anchor");
assert.equal(resolved.normalized, true, "reports the soft-warning flag");
assert.equal(canAnchorInDoc(doc, sel), true);
assert.equal(countAnchorMatches(doc, sel), 1);
const ok = applyAnchorInDoc(doc, sel, COMMENT_ID);
assert.equal(ok, true);
const marked = doc.content[0].content.filter((p) => commentMark(p));
assert.equal(marked.map((m) => m.text).join(""), "bold word",
"the mark lands on the plain-text span");
});
test("getAnchoredText stores the RAW doc substring even when matched via the strip fallback", () => {
// Doc uses a smart apostrophe; the agent typed ASCII + markdown emphasis.
const doc = paragraphDoc([{ type: "text", text: "it’s bold now" }]);
const stored = getAnchoredText(doc, "it's **bold**");
assert.equal(stored, "it’s bold",
"stored selection is the raw document text, not the stripped/ASCII locator");
});
test("the strip fallback does not flip a raw-unique selection to ambiguous", () => {
// "config" appears twice, but the raw phrase "config value" appears once.
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "the config value here" }] },
{ type: "paragraph", content: [{ type: "text", text: "another config here" }] },
],
};
// Raw phrase is unique -> exactly 1, and no strip happens (nothing to strip).
assert.equal(countAnchorMatches(doc, "config value"), 1);
assert.equal(resolveAnchorSelection(doc, "config value").normalized, false);
});
test("EXACT WINS: a raw match short-circuits the strip fallback (count reflects raw)", () => {
// A literal "**" run exists raw once; its stripped form would also appear.
const doc = paragraphDoc([{ type: "text", text: "use **stars** and stars" }]);
// Raw "**stars**" occurs once -> count 1 from the verbatim locator; the
// fallback (which would find two "stars") never runs.
assert.equal(countAnchorMatches(doc, "**stars**"), 1);
assert.equal(resolveAnchorSelection(doc, "**stars**").normalized, false);
});
test("a markdown selection whose stripped form is ambiguous is counted as ambiguous", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first config here" }] },
{ type: "paragraph", content: [{ type: "text", text: "second config here" }] },
],
};
// Verbatim "**config**" matches nothing; stripped "config" matches twice.
assert.equal(countAnchorMatches(doc, "**config**"), 2);
});
@@ -1,84 +0,0 @@
// Unit tests for the pure-TS schematic SVG preview (issue #423). Asserts that
// the preview emits well-formed SVG covering each primitive (rect/ellipse/
// rhombus/edge), resolves container-relative coordinates to absolute, and that
// the full `.drawio.svg` wrapper parses.
import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { renderDiagramShapes } from "../../build/lib/drawio-preview.js";
import {
parseCells,
computeBBox,
buildDrawioSvg,
normalizeXml,
} from "../../build/lib/drawio-xml.js";
const { window } = new JSDOM("");
function parseSvg(svg) {
const doc = new window.DOMParser().parseFromString(svg, "application/xml");
const err = doc.getElementsByTagName("parsererror");
assert.equal(err.length, 0, `SVG did not parse: ${err[0]?.textContent}`);
return doc;
}
const MODEL =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Box &amp; Co" style="rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">' +
'<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="3" value="Circle" style="ellipse;fillColor=#d5e8d4;" vertex="1" parent="1">' +
'<mxGeometry x="240" y="40" width="80" height="80" as="geometry"/></mxCell>' +
'<mxCell id="5" value="Dec" style="rhombus;" vertex="1" parent="1">' +
'<mxGeometry x="40" y="160" width="100" height="60" as="geometry"/></mxCell>' +
'<mxCell id="4" value="link" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
test("renderDiagramShapes emits rect, ellipse, polygon and a line", () => {
const cells = parseCells(MODEL);
const inner = renderDiagramShapes(cells, computeBBox(cells));
assert.ok(inner.includes("<rect"), "has a rect");
assert.ok(inner.includes("<ellipse"), "has an ellipse");
assert.ok(inner.includes("<polygon"), "has a polygon (rhombus)");
assert.ok(inner.includes("<line"), "has an edge line");
// Labels are HTML-escaped.
assert.ok(inner.includes("Box &amp; Co"));
});
test("the full .drawio.svg wrapper parses as valid XML", () => {
const cells = parseCells(MODEL);
const bbox = computeBBox(cells);
const inner = renderDiagramShapes(cells, bbox);
const svg = buildDrawioSvg(normalizeXml(MODEL), inner, bbox);
const doc = parseSvg(svg);
assert.equal(doc.documentElement.tagName, "svg");
assert.ok(doc.documentElement.getAttribute("content"), "carries content=");
// The visible children exist.
assert.ok(doc.getElementsByTagName("rect").length >= 1);
});
test("container children resolve to absolute coordinates", () => {
// A group at (100,100) with a child rect at relative (10,10,20,20) -> abs 110,110.
const model =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="g" style="group;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="200" height="200" as="geometry"/></mxCell>' +
'<mxCell id="c" value="in" vertex="1" parent="g"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const cells = parseCells(model);
const inner = renderDiagramShapes(cells, computeBBox(cells));
// The child rect must be placed at absolute x=110,y=110.
assert.ok(/<rect x="110" y="110"/.test(inner), `expected abs child rect, got: ${inner}`);
});
test("unknown stencil (shape=mxgraph.*) degrades to a labeled rectangle", () => {
const model =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="AWS" style="shape=mxgraph.aws4.lambda;" vertex="1" parent="1">' +
'<mxGeometry x="0" y="0" width="60" height="60" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const cells = parseCells(model);
const inner = renderDiagramShapes(cells, computeBBox(cells));
assert.ok(inner.includes("<rect"), "unknown stencil -> rect");
assert.ok(inner.includes(">AWS<"), "keeps the label");
});
-360
View File
@@ -1,360 +0,0 @@
// Unit tests for the drawio-xml module (issue #423): the linter (a positive
// baseline + a negative case per rule, each asserting rule + cellId), the
// decode chain (plain nested XML AND draw.io's compressed <diagram> via pako),
// encode/round-trip byte-stability, hash stability, style parsing and the
// bounding box.
import { test } from "node:test";
import assert from "node:assert/strict";
import pako from "pako";
import {
parseStyle,
lintModel,
prepareModel,
normalizeInput,
normalizeXml,
mxHash,
computeBBox,
parseCells,
decodeDrawioSvg,
decodeDrawioFileToModel,
buildDrawioSvg,
encodeDrawioFile,
countUserCells,
DrawioLintError,
inflateDiagramPayload,
MAX_INFLATED_DIAGRAM_BYTES,
} from "../../build/lib/drawio-xml.js";
// A well-formed model with one vertex and a valid edge to it.
const VALID_MODEL =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Hello" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">' +
'<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="3" value="Two" style="ellipse;" vertex="1" parent="1">' +
'<mxGeometry x="300" y="100" width="80" height="80" as="geometry"/></mxCell>' +
'<mxCell id="4" edge="1" parent="1" source="2" target="3">' +
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
function issuesOf(fn) {
try {
fn();
return null;
} catch (e) {
assert.ok(e instanceof DrawioLintError, `expected DrawioLintError, got ${e}`);
return e.issues;
}
}
const hasRule = (issues, rule, cellId) =>
issues.some(
(i) => i.rule === rule && (cellId === undefined || i.cellId === cellId),
);
// --- style parsing ---------------------------------------------------------
test("parseStyle: base stylename + key=value pairs", () => {
const r = parseStyle("ellipse;fillColor=#ff0000;whiteSpace=wrap;");
assert.equal(r.baseStyle, "ellipse");
assert.equal(r.map.fillColor, "#ff0000");
assert.equal(r.map.whiteSpace, "wrap");
assert.equal(r.badSegment, undefined);
});
test("parseStyle: flags a segment with two '='", () => {
const r = parseStyle("a=b=c;");
assert.equal(r.badSegment, "a=b=c");
});
test("parseStyle: a second bare token is malformed", () => {
const r = parseStyle("rounded=1;bareword");
assert.equal(r.badSegment, "bareword");
});
// --- linter: positive baseline ---------------------------------------------
test("lintModel: the canonical valid model passes", () => {
const { cells } = lintModel(VALID_MODEL);
assert.equal(cells.length, 5);
});
// --- linter: one negative case per rule ------------------------------------
test("rule well-formed-xml: malformed XML", () => {
const issues = issuesOf(() => lintModel("<mxGraphModel><root><mxCell id=\"0\"></root>"));
assert.ok(hasRule(issues, "well-formed-xml"));
assert.ok(issues[0].position, "carries a line:col position");
});
test("rule structure: root is not mxGraphModel", () => {
const issues = issuesOf(() => lintModel("<foo><root/></foo>"));
assert.ok(hasRule(issues, "structure"));
});
test("rule sentinel-cells: missing id=0 / id=1", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "sentinel-cells", "0"));
assert.ok(hasRule(issues, "sentinel-cells", "1"));
});
test("rule duplicate-id: two cells share an id", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "duplicate-id", "2"));
});
test("rule vertex-edge-exclusive: cell is both vertex and edge", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" edge="1" parent="1"><mxGeometry as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "vertex-edge-exclusive", "2"));
});
test("rule edge-geometry: self-closed edge without child mxGeometry", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'<mxCell id="3" vertex="1" parent="1"><mxGeometry x="30" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'<mxCell id="4" edge="1" parent="1" source="2" target="3"/>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "edge-geometry", "4"));
});
test("rule edge-endpoint: source/target does not resolve", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'<mxCell id="4" edge="1" parent="1" source="2" target="99"><mxGeometry relative="1" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "edge-endpoint", "4"));
});
test("rule parent-exists: parent points at a missing id", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="42"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "parent-exists", "2"));
});
test("rule no-comments: XML comment present", () => {
const m =
'<mxGraphModel><root>' +
'<!-- a comment --><mxCell id="0"/><mxCell id="1" parent="0"/>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "no-comments"));
});
test("rule style-format: malformed style segment (cellId reported)", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" style="rounded=1;a=b=c;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "style-format", "2"));
});
test("rule value-newline: literal newline in a value (cellId reported)", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="line1\nline2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "value-newline", "2"));
});
test("rule value-escaping: unescaped ampersand in a value (cellId reported)", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="A & B" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
const issues = issuesOf(() => lintModel(m));
assert.ok(hasRule(issues, "value-escaping", "2"));
});
test("rule reserved id: escaped entity value passes (no false positive)", () => {
const m =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="A &amp; B &lt;ok&gt;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
assert.doesNotThrow(() => lintModel(m));
});
// --- input normalization ---------------------------------------------------
test("normalizeInput: a list of <mxCell> is wrapped and sentinels added", () => {
const frag =
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>';
const model = normalizeInput(frag);
assert.ok(model.startsWith("<mxGraphModel"));
assert.ok(model.includes('<mxCell id="0"/>'));
assert.ok(model.includes('<mxCell id="1" parent="0"/>'));
// And it lints clean.
assert.doesNotThrow(() => lintModel(model));
});
test("normalizeInput: an existing sentinel is not duplicated", () => {
const frag =
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>';
const model = normalizeInput(frag);
const count0 = (model.match(/id="0"/g) || []).length;
assert.equal(count0, 1);
});
test("prepareModel: returns bbox, cellCount, hash and lints", () => {
const p = prepareModel(VALID_MODEL);
assert.equal(p.cellCount, 3); // 2, 3, 4 (sentinels excluded)
assert.ok(p.bbox.width > 0 && p.bbox.height > 0);
assert.equal(p.hash, mxHash(normalizeXml(VALID_MODEL)));
});
// --- decode chain: plain -----------------------------------------------------
test("decode chain (plain): buildDrawioSvg -> decodeDrawioSvg round-trips byte-stable", () => {
const model = normalizeXml(VALID_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 400, height: 200 });
const decoded = decodeDrawioSvg(svg);
assert.equal(decoded, model);
});
test("decode chain: entity-encoded content= (draw.io export style) is read directly", () => {
const model = normalizeXml(VALID_MODEL);
const file = encodeDrawioFile(model, "Page-1");
const escaped = file
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${escaped}"></svg>`;
const decoded = decodeDrawioSvg(svg);
assert.equal(decoded, model);
});
// --- decode chain: compressed (pako) ---------------------------------------
test("decode chain (compressed pako): human-saved <diagram> payload decodes losslessly", () => {
const model = normalizeXml(VALID_MODEL);
// Reproduce draw.io's compression: encodeURIComponent -> raw deflate -> base64.
const compressed = Buffer.from(
pako.deflateRaw(encodeURIComponent(model)),
).toString("base64");
const file = `<mxfile host="Electron"><diagram id="abc" name="Page-1">${compressed}</diagram></mxfile>`;
// Docmost stores the file base64 in content=.
const contentB64 = Buffer.from(file, "utf-8").toString("base64");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${contentB64}"><image href="x"/></svg>`;
const decoded = decodeDrawioSvg(svg);
assert.equal(decoded, model);
});
test("decodeDrawioFileToModel: bare mxGraphModel file returns the model substring", () => {
const model = normalizeXml(VALID_MODEL);
assert.equal(decodeDrawioFileToModel(model), model);
});
// --- hash stability --------------------------------------------------------
test("mxHash: stable across inter-tag whitespace, sensitive to content", () => {
const a = VALID_MODEL;
const b = VALID_MODEL.replace(/></g, ">\n <"); // reformat only
assert.equal(mxHash(a), mxHash(b));
const c = VALID_MODEL.replace('value="Hello"', 'value="Changed"');
assert.notEqual(mxHash(a), mxHash(c));
});
// --- bounding box + cell count ---------------------------------------------
test("computeBBox + countUserCells", () => {
const cells = parseCells(VALID_MODEL);
const bbox = computeBBox(cells);
// Vertex 3 spans to x=380,y=180; plus the 20px margin.
assert.equal(bbox.width, 400);
assert.equal(bbox.height, 200);
assert.equal(countUserCells(VALID_MODEL), 3);
});
// --- decompression-bomb guard (Fix 3) --------------------------------------
test("inflateDiagramPayload: a small legitimate payload inflates fine", () => {
const xml = normalizeXml(VALID_MODEL);
const base64 = Buffer.from(
pako.deflateRaw(encodeURIComponent(xml)),
).toString("base64");
assert.equal(inflateDiagramPayload(base64), xml);
});
test("inflateDiagramPayload: rejects an over-cap decompression bomb", () => {
// A tiny compressed payload that inflates to just over the cap. Highly
// compressible (all one byte) -> the base64 is small, but the inflated output
// exceeds MAX_INFLATED_DIAGRAM_BYTES and must be refused before it is fully
// materialised.
const bombSize = MAX_INFLATED_DIAGRAM_BYTES + 1024;
const base64 = Buffer.from(
pako.deflateRaw(Buffer.alloc(bombSize, 0x41 /* 'A' */)),
).toString("base64");
assert.ok(
base64.length < 1024 * 1024,
"the compressed bomb is tiny relative to its inflated size",
);
assert.throws(
() => inflateDiagramPayload(base64),
/decompression bomb/,
);
});
test("encode/build: a title with < > \" & round-trips without corrupting the SVG", async () => {
// A user-supplied title full of XML metacharacters must be escaped so the
// inner <mxfile> stays well-formed and the outer content="..." attribute is
// never broken out of. Prove it survives the encode -> build -> decode chain.
const title = 'A < B > C " D & E';
const model = normalizeXml(VALID_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, title);
// The outer content="..." attribute must not be broken by the title: the raw
// title metacharacters never appear literally in the SVG markup (they are
// base64-encoded inside content=, and escaped inside the file XML).
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
// The diagram model still decodes losslessly despite the exotic title.
assert.equal(decodeDrawioSvg(svg), model);
// The file XML is well-formed: the title lives in name="..." as escaped
// entities, so unescaping recovers the original title byte-for-byte.
const fileXml = Buffer.from(contentMatch[1], "base64").toString("utf-8");
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "the diagram name attribute is intact and quote-safe");
const decodedTitle = nameMatch[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
assert.equal(decodedTitle, title);
// encodeDrawioFile alone produces the same escaped, well-formed envelope.
const file = encodeDrawioFile(model, title);
assert.match(file, /name="A &lt; B &gt; C &quot; D &amp; E">/);
});
@@ -1,64 +1,45 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js"; import {
footnoteWarningsField,
hasLegacyFootnoteDefinition,
} from "../../build/lib/footnote-analyze.js";
test("clean footnotes produce no diagnostics", () => { // #414: the legacy footnote diagnostics were reduced to ONE advisory that fires
const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n"); // on the PRESENCE of legacy reference-style `[^id]:` definition syntax (inert on
const d = analyzeFootnotes(md); // import since #293), nudging the author to inline `^[...]` footnotes.
assert.deepEqual(d.danglingReferences, []);
assert.deepEqual(d.emptyDefinitions, []); test("inline `^[...]` footnotes produce no warning", () => {
assert.deepEqual(d.duplicateDefinitions, []); const md = "A note here.^[the body] and reuse elsewhere.^[the body]";
assert.deepEqual(d.referencesInTables, []); assert.equal(hasLegacyFootnoteDefinition(md), false);
assert.deepEqual(d.warnings, []); assert.deepEqual(footnoteWarningsField(md), {});
}); });
test("reuse (repeated references to one definition) is NOT a warning", () => { test("no footnotes at all produce no warning", () => {
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n"); const md = "Just a paragraph with [a link](https://x) and no footnotes.";
const d = analyzeFootnotes(md); assert.equal(hasLegacyFootnoteDefinition(md), false);
assert.deepEqual(d.danglingReferences, []); assert.deepEqual(footnoteWarningsField(md), {});
assert.deepEqual(d.warnings, []);
}); });
test("dangling reference (no definition) is reported", () => { test("a legacy `[^id]:` definition triggers the single advisory", () => {
const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n"); const md = ["See[^a].", "", "[^a]: defined"].join("\n");
const d = analyzeFootnotes(md); assert.equal(hasLegacyFootnoteDefinition(md), true);
assert.deepEqual(d.danglingReferences, ["missing"]); const field = footnoteWarningsField(md);
assert.equal(d.warnings.length, 1); assert.equal(field.footnoteWarnings.length, 1);
assert.match(d.warnings[0], /no matching definition/); assert.match(field.footnoteWarnings[0], /reference-style footnotes/i);
assert.match(d.warnings[0], /\[\^missing\]/); assert.match(field.footnoteWarnings[0], /\^\[footnote text\]/);
}); });
test("empty definition text is reported", () => { test("a bare `[^id]` reference (no definition line) is not flagged", () => {
const md = ["See[^a].", "", "[^a]: "].join("\n"); // Only the definition syntax `[^id]:` is a reliable signal of legacy authoring;
const d = analyzeFootnotes(md); // a lone `[^x]` in prose is too ambiguous to warn on.
assert.deepEqual(d.emptyDefinitions, ["a"]); const md = "A sentence mentioning [^x] with no definition.";
assert.match(d.warnings.join("\n"), /empty text/); assert.equal(hasLegacyFootnoteDefinition(md), false);
assert.deepEqual(footnoteWarningsField(md), {});
}); });
test("duplicate definition id is reported (first-wins)", () => { test("legacy syntax inside a code fence is ignored (fence-aware)", () => {
const md = ["See[^d].", "", "[^d]: first", "[^d]: second"].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.duplicateDefinitions, ["d"]);
assert.match(d.warnings.join("\n"), /defined more than once/);
});
test("reference inside a GFM table row is reported (heuristic)", () => {
const md = [
"| Col |",
"| --- |",
"| cell[^t] |",
"",
"[^t]: table note",
].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.referencesInTables, ["t"]);
assert.match(d.warnings.join("\n"), /table/);
// It is defined, so it is NOT also dangling.
assert.deepEqual(d.danglingReferences, []);
});
test("footnote syntax inside a code fence is ignored", () => {
const md = [ const md = [
"Intro.", "Intro.",
"", "",
@@ -67,40 +48,22 @@ test("footnote syntax inside a code fence is ignored", () => {
"[^demo]: not a real definition", "[^demo]: not a real definition",
"```", "```",
"", "",
"Outro[^a].", "Outro with an inline note.^[real]",
"",
"[^a]: real",
].join("\n"); ].join("\n");
const d = analyzeFootnotes(md); assert.equal(hasLegacyFootnoteDefinition(md), false);
// `[^demo]` lives only in the fenced block, so it is neither a reference nor a assert.deepEqual(footnoteWarningsField(md), {});
// dangling one, and `[^demo]:` is not counted as a definition.
assert.deepEqual(d.danglingReferences, []);
assert.deepEqual(d.duplicateDefinitions, []);
assert.deepEqual(d.warnings, []);
}); });
test("a reference that only appears inside a definition's text is not dangling", () => { test("a legacy definition OUTSIDE a fence still warns even with a fenced sample", () => {
// `[^b]` is referenced from within [^a]'s text and has its own definition.
const md = ["See[^a].", "", "[^a]: see also [^b]", "[^b]: the other"].join(
"\n",
);
const d = analyzeFootnotes(md);
assert.deepEqual(d.danglingReferences, []);
});
test("multiple problem classes accumulate distinct warnings", () => {
const md = [ const md = [
"Ref[^x] and[^dup].", "```",
"[^demo]: example inside a fence",
"```",
"", "",
"[^dup]: one", "See[^a].",
"[^dup]: two", "",
"[^empty]:", "[^a]: real definition outside the fence",
].join("\n"); ].join("\n");
const d = analyzeFootnotes(md); assert.equal(hasLegacyFootnoteDefinition(md), true);
// x has no definition; dup is defined twice; empty is empty AND has no ref. assert.equal(footnoteWarningsField(md).footnoteWarnings.length, 1);
assert.ok(d.danglingReferences.includes("x"));
assert.deepEqual(d.duplicateDefinitions, ["dup"]);
assert.deepEqual(d.emptyDefinitions, ["empty"]);
// One warning line per problem class present.
assert.ok(d.warnings.length >= 3);
}); });
@@ -5,7 +5,7 @@ import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"
import { import {
footnoteContentKey, footnoteContentKey,
generateFootnoteId, generateFootnoteId,
} from "../../build/lib/footnote-authoring.js"; } from "@docmost/prosemirror-markdown";
import { insertInlineFootnote } from "../../build/lib/transforms.js"; import { insertInlineFootnote } from "../../build/lib/transforms.js";
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js"; import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
@@ -1,39 +1,37 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import { footnoteWarningsField } from "../../build/lib/footnote-analyze.js";
analyzeFootnotes,
footnoteWarningsField,
} from "../../build/lib/footnote-analyze.js";
import { import {
serializeDocmostMarkdown, serializeDocmostMarkdown,
parseDocmostMarkdown, parseDocmostMarkdown,
} from "../../build/lib/markdown-document.js"; } from "../../build/lib/markdown-document.js";
// Pins the footnoteWarnings PLUMBING contract (#169 review): the field is // Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
// present only on problems and omitted on clean input, AND `import_page_markdown` // field is present only when legacy reference-style `[^id]:` syntax is used and
// analyzes the BODY (after the docmost:meta / docmost:comments blocks) — so a // omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the
// footnote-like token inside those JSON blocks never warns, while a real marker // docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
// in the body does. importPageMarkdown does exactly // JSON blocks never warns, while a real definition in the body does.
// `footnoteWarningsField(parseDocmostMarkdown(full).body)` over a collab socket // importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
// this harness does not stand up, so we test the same pure composition directly. // over a collab socket this harness does not stand up, so we test the same pure
// composition directly.
test("footnoteWarningsField is present on problems and omitted on clean input", () => { test("footnoteWarningsField is present on legacy syntax and omitted on the inline form", () => {
const problem = footnoteWarningsField("See[^missing].\n\n[^a]: defined"); const legacy = footnoteWarningsField("See[^a].\n\n[^a]: defined");
assert.ok(Array.isArray(problem.footnoteWarnings)); assert.ok(Array.isArray(legacy.footnoteWarnings));
assert.match(problem.footnoteWarnings.join("\n"), /no matching definition/); assert.match(legacy.footnoteWarnings.join("\n"), /reference-style footnotes/i);
const clean = footnoteWarningsField("A[^a] and reuse[^a].\n\n[^a]: fine"); const inline = footnoteWarningsField("A note.^[the body] reused.^[the body]");
assert.deepEqual(clean, {}); // no key at all on clean input assert.deepEqual(inline, {}); // no key at all on inline-footnote input
}); });
test("import analyzes the BODY only — tokens inside meta/comments never warn", () => { test("import analyzes the BODY only — tokens inside meta/comments never warn", () => {
// meta + comments JSON carry `[^metaonly]` / `[^commentonly]`-looking text; the // meta + comments JSON carry `[^metaonly]:` / `[^commentonly]:`-looking text;
// BODY has a genuinely dangling `[^bodyref]`. // the BODY has a genuine legacy `[^bodyref]:` definition.
const full = serializeDocmostMarkdown( const full = serializeDocmostMarkdown(
{ pageId: "p1", note: "front-matter mentions [^metaonly] in text" }, { pageId: "p1", note: "front-matter mentions [^metaonly]: in text" },
"Body with a dangling[^bodyref] marker.", "Body with a legacy[^bodyref] marker.\n\n[^bodyref]: the definition",
[{ id: "c1", content: "a comment that says [^commentonly]" }], [{ id: "c1", content: "a comment that says [^commentonly]: text" }],
); );
const { body } = parseDocmostMarkdown(full); const { body } = parseDocmostMarkdown(full);
@@ -42,20 +40,19 @@ test("import analyzes the BODY only — tokens inside meta/comments never warn",
assert.ok(!body.includes("[^commentonly]")); assert.ok(!body.includes("[^commentonly]"));
const field = footnoteWarningsField(body); const field = footnoteWarningsField(body);
const joined = (field.footnoteWarnings ?? []).join("\n"); // ONLY the body's legacy definition triggers the advisory.
// ONLY the body's dangling reference is flagged. assert.ok(Array.isArray(field.footnoteWarnings));
assert.match(joined, /\[\^bodyref\]/); assert.match(field.footnoteWarnings.join("\n"), /reference-style footnotes/i);
assert.ok(!joined.includes("metaonly"));
assert.ok(!joined.includes("commentonly"));
// Cross-check against analyzeFootnotes directly (same composition the importer uses). // The meta/comments tokens, analyzed on their own, would NOT have warned in a
assert.deepEqual(analyzeFootnotes(body).danglingReferences, ["bodyref"]); // way that leaks here — the field is computed over the body only.
assert.deepEqual(footnoteWarningsField("front-matter mentions text"), {});
}); });
test("import on a clean body yields no footnoteWarnings field", () => { test("import on an inline-footnote body yields no footnoteWarnings field", () => {
const full = serializeDocmostMarkdown( const full = serializeDocmostMarkdown(
{ pageId: "p1" }, { pageId: "p1" },
"Clean body[^a] reusing[^a].\n\n[^a]: ok", "Clean body.^[a note] reusing.^[a note]",
[], [],
); );
const { body } = parseDocmostMarkdown(full); const { body } = parseDocmostMarkdown(full);
@@ -5,7 +5,7 @@ import {
insertNodeRelative, insertNodeRelative,
sanitizeForYjs, sanitizeForYjs,
findUnstorableAttr, findUnstorableAttr,
} from "../../build/lib/node-ops.js"; } from "@docmost/prosemirror-markdown";
// ProseMirror builders. Blocks carry a stable id in attrs.id. // ProseMirror builders. Blocks carry a stable id in attrs.id.
const textNode = (text) => ({ type: "text", text }); const textNode = (text) => ({ type: "text", text });
+1 -1
View File
@@ -7,7 +7,7 @@ import {
deleteNodeById, deleteNodeById,
assertUnambiguousMatch, assertUnambiguousMatch,
insertNodeRelative, insertNodeRelative,
} from "../../build/lib/node-ops.js"; } from "@docmost/prosemirror-markdown";
// ProseMirror builders. Blocks carry a stable id in attrs.id. // ProseMirror builders. Blocks carry a stable id in attrs.id.
const textNode = (text) => ({ type: "text", text }); const textNode = (text) => ({ type: "text", text });
+1 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js"; import { buildOutline, getNodeByRef } from "@docmost/prosemirror-markdown";
// Helpers to build the small fixture doc. // Helpers to build the small fixture doc.
const textNode = (text) => ({ type: "text", text }); const textNode = (text) => ({ type: "text", text });
+1 -1
View File
@@ -2,7 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { searchInDoc } from "../../build/lib/page-search.js"; import { searchInDoc } from "../../build/lib/page-search.js";
import { getNodeByRef } from "../../build/lib/node-ops.js"; import { getNodeByRef } from "@docmost/prosemirror-markdown";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings // Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
@@ -1,7 +1,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseNodeArg } from "../../build/lib/parse-node-arg.js"; import { parseNodeArg } from "@docmost/prosemirror-markdown";
test("parseNodeArg passes an object through unchanged", () => { test("parseNodeArg passes an object through unchanged", () => {
const obj = { type: "paragraph", content: [] }; const obj = { type: "paragraph", content: [] };
+1 -1
View File
@@ -6,7 +6,7 @@ import {
insertTableRow, insertTableRow,
deleteTableRow, deleteTableRow,
updateTableCell, updateTableCell,
} from "../../build/lib/node-ops.js"; } from "@docmost/prosemirror-markdown";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a // Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
@@ -59,3 +59,89 @@ export function splitFootnoteParagraphs(encoded: string): string[] {
paragraphs.push(current); paragraphs.push(current);
return paragraphs; return paragraphs;
} }
// ---------------------------------------------------------------------------
// Inline-authoring helpers (#414: moved here from the mcp `footnote-authoring.ts`
// fork so the dedup convention — content-key + definition factory + id gen —
// has ONE home next to the importer that shares the convention). Used by the
// mcp author-inline tool (`insertInlineFootnote` in transforms.ts).
// ---------------------------------------------------------------------------
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
function cloneJson<T>(v: T): T {
if (typeof structuredClone === "function") return structuredClone(v);
return JSON.parse(JSON.stringify(v)) as T;
}
/**
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
*
* Two definitions with the same key are the SAME footnote so the inline
* authoring tool reuses one id (one number, one definition, several references)
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
* read the same but differ in formatting (one bold, one plain) are NOT merged.
* Conservative: only an exact match merges.
*/
export function footnoteContentKey(defNode: any): string {
const parts: string[] = [];
const visit = (n: any): void => {
if (!n || typeof n !== "object") return;
if (n.type === "text" && typeof n.text === "string") {
const marks = Array.isArray(n.marks)
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
: "";
parts.push(`${n.text}${marks}`);
}
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
};
visit(defNode);
// Collapse the assembled text's whitespace and trim, keeping the mark
// signature attached so formatting differences still distinguish notes.
return parts
.join("")
.replace(/[ \t\r\n]+/g, " ")
.trim();
}
/**
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
*/
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
return {
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id },
content: [{ type: "paragraph", content }],
};
}
/**
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
*/
export function generateFootnoteId(): string {
const now = Date.now();
const timeHex = now.toString(16).padStart(12, "0");
const rand = (length: number) => {
let s = "";
for (let i = 0; i < length; i++)
s += Math.floor(Math.random() * 16).toString(16);
return s;
};
const versioned = "7" + rand(3);
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
const variant = variantNibble + rand(3);
return (
timeHex.slice(0, 8) +
"-" +
timeHex.slice(8, 12) +
"-" +
versioned +
"-" +
variant +
"-" +
rand(12)
);
}
@@ -44,3 +44,35 @@ export {
docsCanonicallyEqual, docsCanonicallyEqual,
} from "./canonicalize.js"; } from "./canonicalize.js";
export { parsePageFile, serializePageFile } from "./page-file.js"; export { parsePageFile, serializePageFile } from "./page-file.js";
// Pure, network-free helpers for manipulating a ProseMirror/TipTap document
// tree by node id (#414: the single canonical copy, formerly forked into mcp).
// Consumed by `@docmost/mcp` (patch/insert/delete node, table tools, outline).
export {
blockPlainText,
buildOutline,
getNodeByRef,
replaceNodeById,
deleteNodeById,
sanitizeForYjs,
findUnstorableAttr,
insertNodeRelative,
readTable,
insertTableRow,
deleteTableRow,
updateTableCell,
assertUnambiguousMatch,
} from "./node-ops.js";
export type { OutlineEntry } from "./node-ops.js";
// Normalize a ProseMirror node arg that the model may have serialized as a JSON
// string (#414: single copy shared by mcp and the CommonJS server app).
export { parseNodeArg } from "./parse-node-arg.js";
// Inline-footnote authoring convention (#414: single copy, formerly the mcp
// `footnote-authoring.ts` fork), shared with the importer's `assembleFootnotes`.
export {
footnoteContentKey,
makeFootnoteDefinition,
generateFootnoteId,
} from "./footnote.js";
@@ -14,6 +14,8 @@
* `content`, non-object nodes, and absent `attrs` are tolerated. * `content`, non-object nodes, and absent `attrs` are tolerated.
*/ */
import { stripInlineMarkdown } from "./text-normalize.js";
/** Deep-clone a JSON-serializable value without mutating the original. */ /** Deep-clone a JSON-serializable value without mutating the original. */
function clone<T>(value: T): T { function clone<T>(value: T): T {
if (typeof structuredClone === "function") { if (typeof structuredClone === "function") {
@@ -97,12 +99,15 @@ export function buildOutline(doc: any): OutlineEntry[] {
const entry: OutlineEntry = { const entry: OutlineEntry = {
index: i, index: i,
type, type,
id: isObject(block) && isObject(block.attrs) ? block.attrs.id ?? null : null, id:
isObject(block) && isObject(block.attrs)
? (block.attrs.id ?? null)
: null,
firstText: truncate(blockPlainText(block), 100), firstText: truncate(blockPlainText(block), 100),
}; };
if (type === "heading") { if (type === "heading") {
entry.level = isObject(block.attrs) ? block.attrs.level ?? null : null; entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
} else if (type === "table") { } else if (type === "table") {
const headerRow = block.content?.[0]?.content ?? []; const headerRow = block.content?.[0]?.content ?? [];
entry.rows = block.content?.length ?? 0; entry.rows = block.content?.length ?? 0;
@@ -247,6 +252,33 @@ export function deleteNodeById(
return { doc: out, deleted }; return { doc: out, deleted };
} }
/**
* Throw a clear, model-actionable error when a node-id write op did NOT match
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
* "ambiguous, refused" Docmost duplicates block ids on copy/paste, so a write
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
* changed. No-op for the unambiguous single-match case.
*/
export function assertUnambiguousMatch(
op: "patch_node" | "delete_node",
verb: "replace" | "delete",
count: number,
nodeId: string,
pageId: string,
): void {
if (count === 0) {
throw new Error(
`${op}: no node with id "${nodeId}" found on page ${pageId}`,
);
}
if (count > 1) {
throw new Error(
`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`,
);
}
}
/** /**
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly * Deep-clone `doc` and strip every node/mark attribute whose value is strictly
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque * `undefined`, so the result is safe to hand to Yjs (which throws an opaque
@@ -364,6 +396,31 @@ const REQUIRED_CONTAINER: Record<string, string> = {
tableHeader: "tableRow", tableHeader: "tableRow",
}; };
/**
* Find the index of the first TOP-LEVEL block whose plain text includes the
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
*
* Two passes preserve "exact wins globally":
* - Pass 1: first block containing the verbatim `anchorText`.
* - Pass 2 (only if pass 1 found nothing): first block containing the
* markdown-stripped anchor, when stripping actually changed it.
*/
function findAnchorTextIndex(content: any[], anchorText: string): number {
if (!Array.isArray(content)) return -1;
// Pass 1: exact.
for (let i = 0; i < content.length; i++) {
if (blockPlainText(content[i]).includes(anchorText)) return i;
}
// Pass 2: markdown-stripped fallback.
const a = stripInlineMarkdown(anchorText);
if (a !== anchorText && a.length > 0) {
for (let i = 0; i < content.length; i++) {
if (blockPlainText(content[i]).includes(a)) return i;
}
}
return -1;
}
/** /**
* Locate an anchor and return its ancestor chain (from `doc` down to and * Locate an anchor and return its ancestor chain (from `doc` down to and
* including the matched node). Each chain entry is `{ node, index }` where * including the matched node). Each chain entry is `{ node, index }` where
@@ -399,14 +456,14 @@ function findAnchorChain(
} }
// By text: only top-level blocks are scanned (same rule as the JSON path). // By text: only top-level blocks are scanned (same rule as the JSON path).
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && Array.isArray(doc.content)) { if (opts.anchorText != null && Array.isArray(doc.content)) {
for (let i = 0; i < doc.content.length; i++) { const i = findAnchorTextIndex(doc.content, opts.anchorText);
if (blockPlainText(doc.content[i]).includes(opts.anchorText)) { if (i !== -1) {
return [ return [
{ node: doc, index: -1 }, { node: doc, index: -1 },
{ node: doc.content[i], index: i }, { node: doc.content[i], index: i },
]; ];
}
} }
} }
@@ -540,13 +597,13 @@ export function insertNodeRelative(
return { doc: out, inserted }; return { doc: out, inserted };
} }
// Resolve by text: only top-level doc.content blocks are scanned. // Resolve by text: only top-level doc.content blocks are scanned. Exact
// match wins; a markdown-stripped fallback is tried only on a miss.
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) { if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
for (let i = 0; i < out.content.length; i++) { const i = findAnchorTextIndex(out.content, opts.anchorText);
if (blockPlainText(out.content[i]).includes(opts.anchorText)) { if (i !== -1) {
out.content.splice(i + offset, 0, fresh); out.content.splice(i + offset, 0, fresh);
return { doc: out, inserted: true }; return { doc: out, inserted: true };
}
} }
} }
@@ -617,7 +674,8 @@ function locateTable(
if (!isObject(rootClone)) return null; if (!isObject(rootClone)) return null;
// "#<n>": index into the top-level content array; must be a table. // "#<n>": index into the top-level content array; must be a table.
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null; const indexMatch =
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
if (indexMatch) { if (indexMatch) {
const index = Number(indexMatch[1]); const index = Number(indexMatch[1]);
const block = Array.isArray(rootClone.content) const block = Array.isArray(rootClone.content)
@@ -717,7 +775,7 @@ export function readTable(
: undefined; : undefined;
const id = const id =
isObject(firstPara) && isObject(firstPara.attrs) isObject(firstPara) && isObject(firstPara.attrs)
? firstPara.attrs.id ?? null ? (firstPara.attrs.id ?? null)
: null; : null;
rowIds.push(id); rowIds.push(id);
} }
@@ -751,14 +809,17 @@ export function insertTableRow(
if (!Array.isArray(table.content)) table.content = []; if (!Array.isArray(table.content)) table.content = [];
const rows = table.content.length; const rows = table.content.length;
const headerRow = table.content[0]; const headerRow = table.content[0];
const headerCells = Array.isArray(headerRow?.content) ? headerRow.content : []; const headerCells = Array.isArray(headerRow?.content)
? headerRow.content
: [];
// Column count is the WIDEST existing row, so the guard below stays // Column count is the WIDEST existing row, so the guard below stays
// meaningful for ragged tables and the new row matches the table's width. // meaningful for ragged tables and the new row matches the table's width.
// Fall back to the supplied cell count only when the table has no rows. // Fall back to the supplied cell count only when the table has no rows.
let colCount = 0; let colCount = 0;
for (const r of table.content) { for (const r of table.content) {
if (isObject(r) && Array.isArray(r.content)) colCount = Math.max(colCount, r.content.length); if (isObject(r) && Array.isArray(r.content))
colCount = Math.max(colCount, r.content.length);
} }
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0; if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
@@ -771,7 +832,10 @@ export function insertTableRow(
// Resolve the landing index up front so the cell-type decision and the splice // Resolve the landing index up front so the cell-type decision and the splice
// below agree: a valid integer in [0, rows] splices there, else we append. // below agree: a valid integer in [0, rows] splices there, else we append.
const landingIndex = const landingIndex =
typeof index === "number" && Number.isInteger(index) && index >= 0 && index <= rows typeof index === "number" &&
Number.isInteger(index) &&
index >= 0 &&
index <= rows
? index ? index
: rows; : rows;
@@ -790,7 +854,8 @@ export function insertTableRow(
// A row landing at index 0 becomes the new header row, so inherit the // A row landing at index 0 becomes the new header row, so inherit the
// current header cell's type per column (Docmost uses "tableHeader" there); // current header cell's type per column (Docmost uses "tableHeader" there);
// every other position is a plain data cell. // every other position is a plain data cell.
const cellType = landingIndex === 0 ? headerCells[i]?.type ?? "tableCell" : "tableCell"; const cellType =
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
newCells.push({ newCells.push({
type: cellType, type: cellType,
attrs, attrs,
@@ -862,9 +927,10 @@ export function updateTableCell(
const rowNodes = Array.isArray(table.content) ? table.content : []; const rowNodes = Array.isArray(table.content) ? table.content : [];
const rows = rowNodes.length; const rows = rowNodes.length;
const rowNode = rowNodes[row]; const rowNode = rowNodes[row];
const cols = isObject(rowNode) && Array.isArray(rowNode.content) const cols =
? rowNode.content.length isObject(rowNode) && Array.isArray(rowNode.content)
: 0; ? rowNode.content.length
: 0;
if ( if (
!Number.isInteger(row) || !Number.isInteger(row) ||
@@ -2,6 +2,11 @@
// instead of an object. Normalize: parse a string to an object (throwing on // instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patch_node / // invalid JSON), pass an object through unchanged. Shared by patch_node /
// insert_node (and the analogous update_page_json content parsing). // insert_node (and the analogous update_page_json content parsing).
//
// This lives in the converter package (#414) so BOTH consumers import the ONE
// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot
// import `@docmost/mcp` directly (ESM-only, no declaration files), but it does
// import `@docmost/prosemirror-markdown` natively — so this is the shared home.
export function parseNodeArg( export function parseNodeArg(
node: unknown, node: unknown,
errMsg = "node was a string but not valid JSON", errMsg = "node was a string but not valid JSON",
@@ -0,0 +1,99 @@
/**
* Locator normalization: strip inline markdown wrappers and trailing
* decoration from a LOCATOR string so a find/anchor that the model wrote with
* markdown (or a stray emoji) can still match the document's plain text.
*
* This is used ONLY as a fallback for LOCATING (after an exact match fails);
* it is never applied to replacement text or inserted node content, so no
* formatting is ever lost.
*
* Scope note (#414): this package-local copy exists so `node-ops.ts` which
* lives here now (the single canonical copy) can resolve its markdown-tolerant
* anchor fallback without a circular dependency back on `@docmost/mcp`. It
* intentionally carries ONLY `stripInlineMarkdown` (the primitive `node-ops`
* needs); the mcp-side `text-normalize.ts` (which additionally serves
* `json-edit.ts` via `stripBalancedWrappers`) is the subject of a separate
* dedup task and is left untouched here.
*/
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
const MAX_PASSES = 8;
/**
* Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so
* `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is
* non-greedy and capture group 1 is the inner text. Applied repeatedly until
* the string stops changing (nested wrappers like `**_x_**`).
*/
const WRAPPER_PATTERNS: RegExp[] = [
/\*\*([^*]+?)\*\*/g, // **x**
/__([^_]+?)__/g, // __x__
/~~([^~]+?)~~/g, // ~~x~~
/\*([^*]+?)\*/g, // *x*
/_([^_]+?)_/g, // _x_
/``([^`]+?)``/g, // ``x``
/`([^`]+?)`/g, // `x`
];
/** Links/images -> their visible text. `!?` covers both `[t](u)` and `![a](s)`. */
const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
/**
* Apply the two balanced/link passes: first collapse links/images to their
* visible text, then collapse balanced inline wrappers repeatedly until stable.
* Does NOT trim decoration, does NOT guard against an empty result it returns
* exactly the transformed string.
*/
function stripWrappersAndLinks(s: string): string {
// 1. Links/images -> their visible text.
let out = s.replace(LINK_IMAGE_RE, "$1");
// 2. Strip balanced wrappers, repeating until the string is stable so nested
// wrappers (`**_x_**`) and adjacent runs both collapse.
for (let pass = 0; pass < MAX_PASSES; pass++) {
const before = out;
for (const re of WRAPPER_PATTERNS) {
out = out.replace(re, "$1");
}
if (out === before) break;
}
return out;
}
/**
* Conservatively strip inline markdown from a locator string.
*
* Deterministic, order-fixed steps:
* 1. Links/images: `[text](url)` -> `text`, `![alt](src)` -> `alt`.
* 2. Balanced inline wrappers (strong before emphasis, code, strikethrough),
* applied repeatedly until stable for nested cases.
* 3. Trim leading/trailing decoration only: whitespace, leftover marker chars
* (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,`
* etc.) are NEVER trimmed.
*
* If the result is empty (e.g. the input was only markers like `***`), the
* ORIGINAL string is returned so a locator can never normalize down to "" and
* match everything.
*/
export function stripInlineMarkdown(s: string): string {
if (typeof s !== "string" || s.length === 0) return s;
// 1 + 2. Shared link/image and balanced-wrapper passes.
let out = stripWrappersAndLinks(s);
// 3. Trim leading/trailing decoration: whitespace, leftover markdown markers,
// and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the
// regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT
// Extended_Pictographic). The `u` flag enables the Unicode property escape.
// Anchored runs only — interior text and sentence punctuation are untouched.
const DECORATION =
"[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+";
out = out
.replace(new RegExp("^" + DECORATION, "u"), "")
.replace(new RegExp(DECORATION + "$", "u"), "");
// 4. Never normalize a locator down to nothing.
if (out.length === 0) return s;
return out;
}
-62
View File
@@ -1,62 +0,0 @@
diff --git a/dist/hocuspocus-server.cjs b/dist/hocuspocus-server.cjs
index b24ff6d091c32f733089eeaa47b03f7b37cf5964..f003af304fc751b7edc1aee17f3651282d70666a 100644
--- a/dist/hocuspocus-server.cjs
+++ b/dist/hocuspocus-server.cjs
@@ -2426,6 +2426,26 @@ class Hocuspocus {
* Create a new document by the given request
*/
async createDocument(documentName, request, socketId, connection, context) {
+ // PATCH(gitmost #401): close the connect-vs-unload race. When the last
+ // client disconnects, storeDocumentHooks' finally schedules an unload;
+ // unloadDocument runs its beforeUnloadDocument hooks asynchronously and
+ // records an in-flight promise in `unloadingDocuments`. A NEW connection
+ // arriving in that window would otherwise fall straight through to the
+ // `documents`/`loadingDocuments` checks and could either reuse a doc that
+ // is about to be destroyed, or start loading a fresh doc concurrently
+ // with the destroy. Awaiting the in-flight unload first makes the decision
+ // deterministic: once it settles, either the doc was fully unloaded
+ // (removed from `documents`, so we do a clean fresh load below) or the
+ // unload aborted because work/connections reappeared (the healthy doc is
+ // still in `documents`, so we reuse it). Either way the new connection can
+ // never hand-shake onto an orphaned, about-to-be-destroyed Document.
+ const existingUnloadingDoc = this.unloadingDocuments.get(documentName);
+ if (existingUnloadingDoc) {
+ // Wait for the in-flight unload to settle so we never hand-shake onto a dying
+ // Document. Swallow a rejected unload — fall through to a fresh load, matching
+ // pre-patch behavior (the doc is already removed from `documents` by then).
+ try { await existingUnloadingDoc; } catch { /* unload rejected — fresh load */ }
+ }
const existingLoadingDoc = this.loadingDocuments.get(documentName);
if (existingLoadingDoc) {
return existingLoadingDoc;
diff --git a/dist/hocuspocus-server.esm.js b/dist/hocuspocus-server.esm.js
index 1f4dd80244e899128e2c4e5dad8eab7cfc1cbad6..8c2411747bba27fb9486e1df81678a14e41e884e 100644
--- a/dist/hocuspocus-server.esm.js
+++ b/dist/hocuspocus-server.esm.js
@@ -2406,6 +2406,26 @@ class Hocuspocus {
* Create a new document by the given request
*/
async createDocument(documentName, request, socketId, connection, context) {
+ // PATCH(gitmost #401): close the connect-vs-unload race. When the last
+ // client disconnects, storeDocumentHooks' finally schedules an unload;
+ // unloadDocument runs its beforeUnloadDocument hooks asynchronously and
+ // records an in-flight promise in `unloadingDocuments`. A NEW connection
+ // arriving in that window would otherwise fall straight through to the
+ // `documents`/`loadingDocuments` checks and could either reuse a doc that
+ // is about to be destroyed, or start loading a fresh doc concurrently
+ // with the destroy. Awaiting the in-flight unload first makes the decision
+ // deterministic: once it settles, either the doc was fully unloaded
+ // (removed from `documents`, so we do a clean fresh load below) or the
+ // unload aborted because work/connections reappeared (the healthy doc is
+ // still in `documents`, so we reuse it). Either way the new connection can
+ // never hand-shake onto an orphaned, about-to-be-destroyed Document.
+ const existingUnloadingDoc = this.unloadingDocuments.get(documentName);
+ if (existingUnloadingDoc) {
+ // Wait for the in-flight unload to settle so we never hand-shake onto a dying
+ // Document. Swallow a rejected unload — fall through to a fresh load, matching
+ // pre-patch behavior (the doc is already removed from `documents` by then).
+ try { await existingUnloadingDoc; } catch { /* unload rejected — fresh load */ }
+ }
const existingLoadingDoc = this.loadingDocuments.get(documentName);
if (existingLoadingDoc) {
return existingLoadingDoc;
+2 -8
View File
@@ -44,9 +44,6 @@ overrides:
ip-address: 10.1.1 ip-address: 10.1.1
patchedDependencies: patchedDependencies:
'@hocuspocus/server@3.4.4':
hash: d8dc66e5ec3b9d23a876b979f493b6aa901fd2d965be54729495da5136296a42
path: patches/@hocuspocus__server@3.4.4.patch
ai@6.0.134: ai@6.0.134:
hash: f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9 hash: f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9
path: patches/ai@6.0.134.patch path: patches/ai@6.0.134.patch
@@ -78,7 +75,7 @@ importers:
version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))
'@hocuspocus/server': '@hocuspocus/server':
specifier: 3.4.4 specifier: 3.4.4
version: 3.4.4(patch_hash=d8dc66e5ec3b9d23a876b979f493b6aa901fd2d965be54729495da5136296a42)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) version: 3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))
'@hocuspocus/transformer': '@hocuspocus/transformer':
specifier: 3.4.4 specifier: 3.4.4
version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)) version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))
@@ -1044,9 +1041,6 @@ importers:
marked: marked:
specifier: ^17.0.1 specifier: ^17.0.1
version: 17.0.5 version: 17.0.5
pako:
specifier: ^2.0.3
version: 2.0.3
re2: re2:
specifier: ^1.21.0 specifier: ^1.21.0
version: 1.25.0 version: 1.25.0
@@ -13061,7 +13055,7 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
'@hocuspocus/server@3.4.4(patch_hash=d8dc66e5ec3b9d23a876b979f493b6aa901fd2d965be54729495da5136296a42)(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))': '@hocuspocus/server@3.4.4(y-protocols@1.0.6(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)))(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810))':
dependencies: dependencies:
'@hocuspocus/common': 3.4.4 '@hocuspocus/common': 3.4.4
async-lock: 1.4.1 async-lock: 1.4.1