feat(mcp): agent save_page_version tool — collab stateless transport + role prompts (#370)

Stage A of #370: wire the MCP client transport for the already-shipped server
save-version handler (PR-1 #374). An agent can now pin an intentional named
version (kind='agent', derived server-side from the signed actor) of a page's
CURRENT live collaboration content.

- collab-session.ts: add CollabSession.sendStatelessAndAwait(payload, predicate,
  timeoutMs) — sends a stateless message over the live provider and resolves on
  the first matching reply, with a bounded timeout; lifecycle (inflightReject,
  ready-guard, concurrent fail-fast, idle re-arm) mirrors mutate(). Extend
  CollabProviderLike with sendStateless + the stateless on/off overloads.
- collaboration.ts: add savePageVersionRealtime() — under withPageLock, reuse the
  cached agent-authenticated CollabSession (#400), send {type:'save-version'},
  await {type:'version.saved', …}; no REST read (would race the stale page row).
- client/pages.ts: add savePageVersion(pageId) — resolvePageId +
  getCollabTokenWithReauth + writeWithCollabAuthRetry (#486 self-heal).
- tool-specs.ts: add the deferred savePageVersion spec (+ DocmostClientLike Pick);
  auto-registered on both hosts by the shared loops.
- server-instructions.ts: HISTORY family + routing-prose mention.
- ai-chat-tools.service.ts: contract type-assert for the new client method.
- agent-roles-catalog: tell the content-authoring roles (researcher,
  call-summarizer, ru+en) to save a version when the document is done; bump
  their catalog versions.
- test: unit coverage for savePageVersionRealtime (ack + bounded-timeout paths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:33:41 +03:00
parent 6b825ad440
commit 67c94de6ef
10 changed files with 407 additions and 3 deletions
@@ -346,6 +346,9 @@ roles:
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
appendix at the end of the document or clearly separated from the report
body.
□ Once the report is complete, call save_page_version to pin the finished
document as a restorable named version (a checkpoint the reader can
return to). A repeat call after no further edits is a harmless no-op.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
autoStart: false
@@ -442,6 +445,7 @@ roles:
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
- **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op.
## Style example (excerpt)
@@ -345,6 +345,9 @@ roles:
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
an appendix at the end of the document or clearly separated from the
report body.
□ Once the report is complete, call save_page_version to pin the finished
document as a restorable named version (a checkpoint the reader can
return to). A repeat call after no further edits is a harmless no-op.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
autoStart: false
@@ -441,6 +444,7 @@ roles:
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
- **Не оценивай участников** и не комментируй качество обсуждения.
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
## Пример стиля (фрагмент)
+2 -2
View File
@@ -33,6 +33,6 @@ bundles:
- en
roles:
- slug: researcher
version: 9
version: 10
- slug: call-summarizer
version: 1
version: 2
@@ -106,6 +106,7 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.sharePage(s, true);
void client.unsharePage(s);
void client.restorePageVersion(s);
void client.savePageVersion(s);
void client.transformPage(s, s, { dryRun: true });
void client.stashPage(s);
// --- write (image / footnote), in-app since #410 ---
+18
View File
@@ -11,6 +11,7 @@ import {
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
savePageVersionRealtime,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
@@ -55,6 +56,7 @@ export interface IPagesMixin {
listPageHistory(pageId: string, cursor?: string): any;
getPageHistory(historyId: string): any;
restorePageVersion(historyId: string): any;
savePageVersion(pageId: string): any;
diffPageVersions(pageId: string, from?: string, to?: string): any;
}
@@ -587,6 +589,22 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Bas
};
}
/**
* Save an intentional NAMED version of a page's CURRENT live content (#370).
* The write goes over the same agent-authenticated collab session content edits
* use, so the server derives kind='agent' from the signed actor. Returns the
* created (or promoted) history id, its kind, and whether it was already saved.
*/
async savePageVersion(pageId: string) {
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
// Self-heal a rejected WS handshake once (#486): re-mint the collab token and
// retry, symmetric to the content-write path.
return this.writeWithCollabAuthRetry(collabToken, (token) =>
savePageVersionRealtime(pageUuid, token, this.apiUrl),
);
}
/**
* Diff two versions of a page and return a Docmost-equivalent change set.
* `from`/`to` each resolve to a ProseMirror doc:
+125
View File
@@ -139,7 +139,13 @@ export interface CollabProviderLike {
unsyncedChanges: number;
destroy(): void;
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
on(event: "stateless", handler: (data: { payload: string }) => void): void;
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
off(event: "stateless", handler: (data: { payload: string }) => void): void;
// Send a stateless (out-of-band, non-doc) message to the server. Used by the
// #370 explicit save-version path (payload `{type:'save-version'}`); the server
// replies with a broadcast `{type:'version.saved', …}` on the same channel.
sendStateless(payload: string): void;
}
/** The configuration object passed to the provider factory. */
@@ -564,6 +570,125 @@ export class CollabSession {
});
}
/**
* Send a stateless message over the live collaboration connection and resolve
* with the FIRST reply whose parsed payload satisfies `predicate`, rejecting on
* a bounded `timeoutMs`.
*
* Used by the #370 explicit save-version path: the client sends
* `{type:'save-version'}` and awaits the server's broadcast
* `{type:'version.saved', historyId, kind, alreadySaved}`. The stateless channel
* (not a REST read) is deliberate the server versions the LIVE in-memory ydoc,
* which the up-to-10s-stale page row would not yet reflect.
*
* `predicate` receives each incoming stateless message (already JSON-parsed) and
* returns the resolved value for a match or `undefined` to keep waiting; a
* malformed / unrelated payload is ignored. The listener is registered BEFORE
* the send so a fast reply is never missed.
*
* Lifecycle mirrors mutate(): it registers through `inflightReject` so a
* disconnect/close/auth-failure/teardown rejects THIS wait with the same
* connection-loss error, refuses to run on a non-ready session, fails fast on a
* concurrent in-flight op (the caller MUST hold the per-page lock), and re-arms
* the idle TTL (or self-destroys an ephemeral session) on completion.
*
* NOTE: the server broadcast carries no request-correlation id, so under a
* genuinely concurrent save on the SAME page (e.g. a human Cmd+S racing the
* agent) this resolves on whichever `version.saved` arrives first. The per-page
* lock serializes THIS process's saves; a cross-client race is inherent to the
* broadcast design and is benign (the result still describes a real save of this
* page's current content, and the server's save is promote-not-duplicate).
*/
sendStatelessAndAwait<T>(
payload: string,
predicate: (message: any) => T | undefined,
timeoutMs: number,
): Promise<T> {
// Belt-and-suspenders (acquire already validated): refuse to send 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 op would overwrite the
// first's inflightReject and hang it on disconnect (same guard as mutate).
if (this.inflightReject) {
return Promise.reject(
new Error(
"stateless op already in-flight; caller must serialize (hold the page lock)",
),
);
}
return new Promise<T>((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let statelessHandler: ((data: { payload: string }) => void) | undefined;
const localFinish = (err: Error | null, value?: T) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (statelessHandler && this.provider) {
try {
this.provider.off("stateless", statelessHandler);
} catch (e) {}
}
this.inflightReject = undefined;
if (err) reject(err);
else resolve(value as T);
// Post-settle lifecycle, identical to mutate's localFinish.
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 (the `settled` guard makes a racing
// teardown + normal resolve safe — first one wins).
this.inflightReject = (e: Error) => localFinish(e);
// Register the reply listener BEFORE sending so a fast server broadcast is
// never missed.
statelessHandler = (data: { payload: string }) => {
if (settled) return;
let message: any;
try {
message = JSON.parse(data.payload);
} catch {
return; // unrelated / malformed stateless message — keep waiting
}
const matched = predicate(message);
if (matched !== undefined) localFinish(null, matched);
};
this.provider!.on("stateless", statelessHandler);
timer = setTimeout(() => {
localFinish(
new Error(
`Timeout waiting for a stateless reply from the collaboration server ${this.hint()}`,
),
);
}, timeoutMs);
try {
this.provider!.sendStateless(payload);
} catch (e) {
localFinish(e instanceof Error ? e : new Error(String(e)));
}
});
}
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
armIdle(): void {
if (this.dead || this.ephemeral) return;
+70
View File
@@ -324,6 +324,76 @@ export async function mutatePageContent(
});
}
/**
* Stateless-channel message types for the #370 explicit save-version handshake.
* These MUST match the server constants in
* apps/server/src/collaboration/extensions/persistence.extension.ts
* (SAVE_VERSION_MESSAGE_TYPE / the 'version.saved' broadcast) the mcp package
* cannot import server code, so the literals are duplicated here with this note.
*/
const SAVE_VERSION_MESSAGE_TYPE = "save-version";
const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
/**
* Bounded wait for the server's `version.saved` broadcast after we ask it to save
* a version. The server flushes the live ydoc through its store path and writes a
* history row inside a DB transaction before broadcasting, so allow the same
* headroom as a persistence ack; reject (do NOT hang) past it.
*/
const SAVE_VERSION_ACK_TIMEOUT_MS = 20000;
/** The resolved shape of an explicit save-version, surfaced to the tool caller. */
export interface SaveVersionResult {
historyId: string;
kind: string;
alreadySaved: boolean;
}
/**
* Save an intentional version of a page's CURRENT live collaboration content
* (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the
* content writes use (#400) so it authenticates with the caller's agent collab
* token, and the server derives kind='agent' from that signed actor then sends a
* `{type:'save-version'}` stateless message and awaits the server's
* `{type:'version.saved', …}` reply on the same channel.
*
* Deliberately does NOT read `pages.content` over REST: the versioned content is
* the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would
* not yet reflect. The stateless round-trip is what makes the save exact.
*
* On any failure the session is destroyed so the next call reconnects fresh, and
* the error propagates. A save is safe to retry the server promotes-not-
* duplicates an identical latest version so the caller (and its agent) may
* re-issue it without risking a duplicate heavy history row.
*/
export async function savePageVersionRealtime(
pageId: PageId,
collabToken: string,
baseUrl: string,
): Promise<SaveVersionResult> {
return withPageLock(pageId, async () => {
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
try {
return await session.sendStatelessAndAwait<SaveVersionResult>(
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
(message) =>
message && message.type === VERSION_SAVED_MESSAGE_TYPE
? {
historyId: String(message.historyId),
kind: String(message.kind),
alreadySaved: !!message.alreadySaved,
}
: undefined,
SAVE_VERSION_ACK_TIMEOUT_MS,
);
} catch (e) {
// Drop the session on any failure so the next call reconnects fresh.
session.destroy("save-version failed");
throw e;
}
});
}
/**
* Replace the live content of a page over the collaboration websocket.
* Accepts a ready ProseMirror JSON document; the caller controls whether
+2 -1
View File
@@ -44,7 +44,7 @@ export const ROUTING_PROSE =
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Pin the page's CURRENT content as a restorable named checkpoint when you finish a coherent editing pass -> savePageVersion (kind derived server-side as an agent version; an identical-to-last save is promoted/no-op'd, so a redundant call is harmless). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
/**
* Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE:
@@ -218,6 +218,7 @@ const TOOL_FAMILY: Record<string, Family> = {
diffPageVersions: "HISTORY",
listPageHistory: "HISTORY",
restorePageVersion: "HISTORY",
savePageVersion: "HISTORY",
exportPageMarkdown: "HISTORY",
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory.
+23
View File
@@ -93,6 +93,7 @@ export type DocmostClientLike = Pick<
| 'sharePage'
| 'unsharePage'
| 'restorePageVersion'
| 'savePageVersion'
| 'stashPage'
| 'insertFootnote'
| 'insertImage'
@@ -743,6 +744,28 @@ export const SHARED_TOOL_SPECS = {
client.restorePageVersion(historyId as string),
},
savePageVersion: {
mcpName: 'savePageVersion',
inAppKey: 'savePageVersion',
writeClass: 'write',
description:
'Save an intentional, NAMED version (kind=agent) of the page\'s CURRENT ' +
'live content — a restorable checkpoint pinned into its history. Call it ' +
'when you have FINISHED a coherent editing pass (not after every small ' +
'edit), so the reader can see and roll back to the state you left. The ' +
'version type is derived SERVER-SIDE from your signed agent identity (you ' +
'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' +
'version is promoted/no-op\'d server-side, so a redundant call is harmless ' +
'and never duplicates a version. Returns { historyId, kind, alreadySaved }.',
tier: 'deferred',
catalogLine:
'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).',
buildShape: (z) => ({
pageId: z.string().min(1),
}),
execute: (client, { pageId }) => client.savePageVersion(pageId as string),
},
// --- markdown round-trip ---
importPageMarkdown: {
@@ -0,0 +1,158 @@
import { test, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
destroyAllSessions,
__setCollabProviderFactory,
} from "../../build/lib/collab-session.js";
import { savePageVersionRealtime } from "../../build/lib/collaboration.js";
// #370 — unit coverage for the MCP client transport of the explicit save-version.
// There is no collaboration server in the test env, so a fake HocuspocusProvider
// drives the stateless round-trip: it auto-completes the connect/sync handshake on
// a microtask (like the real provider), records sends, and — depending on the
// `reply` opt — either broadcasts a `version.saved` stateless message back or stays
// silent (to exercise the bounded-timeout path).
class FakeProvider extends EventEmitter {
static instances = [];
static reset() {
FakeProvider.instances = [];
}
constructor(config, opts = {}) {
super();
this.config = config;
this.synced = false;
this.unsyncedChanges = 0;
this.destroyed = false;
this.sent = [];
this.opts = opts;
FakeProvider.instances.push(this);
// Real HocuspocusProvider fires onSynced asynchronously after the handshake;
// a microtask reproduces that without depending on timers (so mock.timers on
// setTimeout does not stall the connect).
queueMicrotask(() => {
if (this.destroyed) return;
this.config.onConnect?.();
this.synced = true;
this.config.onSynced?.();
});
}
destroy() {
this.destroyed = true;
}
sendStateless(payload) {
this.sent.push(payload);
const reply = this.opts.reply;
if (!reply) return; // silent → the caller must hit its timeout
// Broadcast the configured server reply on a microtask, mirroring
// document.broadcastStateless landing back on this provider's `stateless` event.
queueMicrotask(() => {
if (this.destroyed) return;
this.emit("stateless", { payload: JSON.stringify(reply) });
});
}
}
const ENV_KEYS = [
"MCP_COLLAB_SESSION_IDLE_MS",
"MCP_COLLAB_SESSION_MAX_AGE_MS",
"MCP_COLLAB_SESSION_MAX_ENTRIES",
"MCP_COLLAB_TOKEN_TTL_MS",
];
let savedEnv;
beforeEach(() => {
savedEnv = {};
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
FakeProvider.reset();
});
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("resolves with the server's version.saved reply (historyId/kind/alreadySaved)", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: {
type: "version.saved",
historyId: "hist-42",
kind: "agent",
alreadySaved: false,
},
}),
);
const result = await savePageVersionRealtime(
"11111111-1111-4111-8111-111111111111",
"collab-tok",
"http://host/api",
);
assert.deepEqual(result, {
historyId: "hist-42",
kind: "agent",
alreadySaved: false,
});
// The client actually asked the server to save a version (the stateless send).
const provider = FakeProvider.instances.at(-1);
assert.deepEqual(
provider.sent.map((p) => JSON.parse(p)),
[{ type: "save-version" }],
);
});
test("surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: {
type: "version.saved",
historyId: "hist-7",
kind: "manual",
alreadySaved: true,
},
}),
);
const result = await savePageVersionRealtime(
"22222222-2222-4222-8222-222222222222",
"collab-tok",
"http://host/api",
);
assert.deepEqual(result, {
historyId: "hist-7",
kind: "manual",
alreadySaved: true,
});
});
test("rejects on a bounded timeout when the server never replies", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
mock.timers.enable({ apis: ["setTimeout"] });
const p = savePageVersionRealtime("33333333-3333-4333-8333-333333333333", "collab-tok", "http://host/api");
// Swallow the eventual rejection so an unhandled-rejection does not race the tick.
p.catch(() => {});
// Flush the (microtask-driven) connect handshake + the stateless send so the
// ack timer is armed before we advance the clock.
await new Promise((r) => setImmediate(r));
// Advance well past the 20s ack window; the connect timer (25s) does not fire.
mock.timers.tick(20000 + 100);
await assert.rejects(p, /Timeout waiting for a stateless reply/);
});