From 70d392729a3142895d7f375132719ac3a72a5bcb Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 18:38:09 +0900 Subject: [PATCH] fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart --- packages/mcp-server/src/index.ts | 4 +- packages/mcp-server/src/persistence.ts | 52 ++++++++++--- packages/mcp-server/tests/persistence.test.ts | 74 ++++++++++++++++--- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 8a96779d..7e22e41c 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -125,7 +125,9 @@ onSessionRecreate((sessionId) => { const loaded = parseDrawioFileContent(saved) // History comes back with the diagram (addHistory drops consecutive // duplicates) - for (const x of autosaver.loadHistory(sessionId)) addHistory(sessionId, x) + for (const e of autosaver.loadHistory(sessionId)) { + addHistory(sessionId, e.xml, "", e.source) + } return loaded.ok ? loaded.xml : saved }) diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts index 135792c0..cd2577b1 100644 --- a/packages/mcp-server/src/persistence.ts +++ b/packages/mcp-server/src/persistence.ts @@ -3,8 +3,9 @@ * diagram survives the MCP process (hosts start a new one when a * conversation is resumed). Like the web app's IndexedDB sessions * (lib/session-storage.ts): saved 1 second after the last change, at most - * 50 kept. The session's History (the XML of its last 20 versions, without - * the thumbnails) is saved next to it as .history.json. + * 50 kept. The session's History (the XML of its last 20 versions and who + * made each, without the thumbnails) is saved next to it as + * .history.json. */ import { @@ -21,6 +22,7 @@ import { import { homedir } from "node:os" import { join } from "node:path" import { contentFingerprint } from "./edit-gate.ts" +import type { HistorySource } from "./history.ts" import { log } from "./logger.ts" import { BLANK_MXFILE, hasCells } from "./pages.ts" @@ -33,6 +35,16 @@ const isBlank = (xml: string) => const DELAY_MS = 1000 const MAX_FILES = 50 +/** A History entry as the file keeps it */ +export interface SavedHistoryEntry { + xml: string + source?: HistorySource +} + +const SOURCES: readonly string[] = ["user", "recover", "restore"] +const isSource = (v: unknown): v is HistorySource => + typeof v === "string" && SOURCES.includes(v) + /** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */ export function expandHome(p: string): string { if (p === "~") return homedir() @@ -70,7 +82,7 @@ export class Autosaver { // The session's History entries, saved next to its diagram private history: ( sessionId: string, - ) => { id: number; xml: string }[] = () => [], + ) => { id: number; xml: string; source?: HistorySource }[] = () => [], ) {} /** Path of a session's file, or null when saving is off. */ @@ -83,16 +95,28 @@ export class Autosaver { return this.dir ? join(this.dir, `${sessionId}.history.json`) : null } - /** The XML of the session's saved History entries, oldest first. */ - loadHistory(sessionId: string): string[] { + /** The session's saved History entries, oldest first. */ + loadHistory(sessionId: string): SavedHistoryEntry[] { const path = this.historyPathFor(sessionId) if (!path) return [] try { const entries: unknown = JSON.parse(readFileSync(path, "utf-8")) - return Array.isArray(entries) && - entries.every((e) => typeof e === "string") - ? entries - : [] + if (!Array.isArray(entries)) return [] + const result: SavedHistoryEntry[] = [] + for (const e of entries) { + // Files of earlier versions hold the XML strings alone + if (typeof e === "string") { + result.push({ xml: e }) + } else if (e && typeof e.xml === "string") { + result.push({ + xml: e.xml, + ...(isSource(e.source) && { source: e.source }), + }) + } else { + return [] + } + } + return result } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { log.warn(`Could not read the saved History ${path}: ${error}`) @@ -218,7 +242,9 @@ export class Autosaver { // Saved with the diagram, after the same delay, so the History entry // that follows every tool write is included. The thumbnails are left - // out (large); the History grid shows the entry's number instead. + // out (large); the History grid shows the entry's number instead. Who + // made an entry goes with it: the shell's cards tell a clear of the + // canvas from a restore of one by it. private writeHistory(sessionId: string): void { const path = this.historyPathFor(sessionId) const entries = this.history(sessionId) @@ -226,7 +252,11 @@ export class Autosaver { const key = `${entries.length}:${entries[entries.length - 1].id}` if (this.historyKeys.get(sessionId) === key) return try { - const json = JSON.stringify(entries.map((e) => e.xml)) + const saved: SavedHistoryEntry[] = entries.map((e) => ({ + xml: e.xml, + ...(e.source && { source: e.source }), + })) + const json = JSON.stringify(saved) writeFileSync(`${path}.tmp`, json, "utf-8") renameSync(`${path}.tmp`, path) this.historyKeys.set(sessionId, key) diff --git a/packages/mcp-server/tests/persistence.test.ts b/packages/mcp-server/tests/persistence.test.ts index 7c2882d4..932b67d1 100644 --- a/packages/mcp-server/tests/persistence.test.ts +++ b/packages/mcp-server/tests/persistence.test.ts @@ -17,6 +17,7 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { afterEach, beforeAll, describe, expect, it } from "vitest" import { installDomPolyfill } from "../src/dom.ts" +import type { HistorySource } from "../src/history.ts" import { Autosaver, defaultDataDir } from "../src/persistence.ts" beforeAll(() => { @@ -247,8 +248,10 @@ describe("Autosaver History", () => { const V3 = DIAGRAM.replace('id="a"', 'id="v3"') // One session's History entries, as history.ts keeps them (the saver // leaves the thumbnails out) - const withHistory = (dir: string, entries: { id: number; xml: string }[]) => - new Autosaver(dir, 30, 50, () => entries) + const withHistory = ( + dir: string, + entries: { id: number; xml: string; source?: HistorySource }[], + ) => new Autosaver(dir, 30, 50, () => entries) it("saves the XML of the History entries next to the diagram", async () => { const dir = tempDir() @@ -262,8 +265,11 @@ describe("Autosaver History", () => { saver.schedule("mcp-h", V2) expect(existsSync(path)).toBe(false) await sleep(80) - expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual([V1, V2]) - expect(saver.loadHistory("mcp-h")).toEqual([V1, V2]) + expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual([ + { xml: V1 }, + { xml: V2 }, + ]) + expect(saver.loadHistory("mcp-h")).toEqual([{ xml: V1 }, { xml: V2 }]) expect(readdirSync(dir).sort()).toEqual([ "mcp-h.drawio", "mcp-h.history.json", @@ -278,7 +284,7 @@ describe("Autosaver History", () => { expect( readFileSync(saver.pathFor("mcp-cleared") as string, "utf-8"), ).toBe(BLANK) - expect(saver.loadHistory("mcp-cleared")).toEqual([V1]) + expect(saver.loadHistory("mcp-cleared")).toEqual([{ xml: V1 }]) }) it("still skips a blank page whose History holds only blank pages", () => { @@ -304,7 +310,7 @@ describe("Autosaver History", () => { // The old session is still open and saves again, History unchanged saver.schedule(old, V1) saver.flush() - expect(saver.loadHistory(old)).toEqual([V1]) + expect(saver.loadHistory(old)).toEqual([{ xml: V1 }]) }) it("rewrites the History file only when the entries changed", () => { @@ -326,14 +332,20 @@ describe("Autosaver History", () => { saver.schedule("mcp-same", V2) saver.flush() expect(statSync(path).mtimeMs).not.toBe(old) - expect(saver.loadHistory("mcp-same")).toEqual([V1, V2]) + expect(saver.loadHistory("mcp-same")).toEqual([ + { xml: V1 }, + { xml: V2 }, + ]) // The circular buffer dropped the oldest entry: same length, new id entries.shift() entries.push({ id: 2, xml: V3 }) saver.schedule("mcp-same", V3) saver.flush() - expect(saver.loadHistory("mcp-same")).toEqual([V2, V3]) + expect(saver.loadHistory("mcp-same")).toEqual([ + { xml: V2 }, + { xml: V3 }, + ]) }) it("writes no History file while there are no entries", () => { @@ -349,18 +361,60 @@ describe("Autosaver History", () => { const saver = new Autosaver(dir, 10) const path = saver.historyPathFor("mcp-bad") as string expect(saver.loadHistory("mcp-bad")).toEqual([]) - for (const content of ["{not json", '{"a":1}', "[1, 2]", '"x"']) { + for (const content of [ + "{not json", + '{"a":1}', + "[1, 2]", + '"x"', + '[{"source":"user"}]', + ]) { writeFileSync(path, content) expect(saver.loadHistory("mcp-bad")).toEqual([]) } }) + it("reads the History files of earlier versions, which hold the XML alone", () => { + const dir = tempDir() + const saver = new Autosaver(dir, 10) + writeFileSync( + saver.historyPathFor("mcp-old") as string, + JSON.stringify([V1, V2]), + ) + expect(saver.loadHistory("mcp-old")).toEqual([{ xml: V1 }, { xml: V2 }]) + }) + + it("keeps who made each entry, so the shell's cards look the same after a restart", () => { + // A clear of the canvas and a restore of it: without the source, the + // restored copy would show as a second clear + const dir = tempDir() + const saver = withHistory(dir, [ + { id: 0, xml: V1 }, + { id: 1, xml: BLANK, source: "user" }, + { id: 2, xml: V2, source: "restore" }, + { id: 3, xml: V1, source: "recover" }, + ]) + saver.schedule("mcp-who", V1) + saver.flush() + expect(saver.loadHistory("mcp-who")).toEqual([ + { xml: V1 }, + { xml: BLANK, source: "user" }, + { xml: V2, source: "restore" }, + { xml: V1, source: "recover" }, + ]) + // A source the server does not know is left out + writeFileSync( + saver.historyPathFor("mcp-who") as string, + JSON.stringify([{ xml: V1, source: "robot" }]), + ) + expect(saver.loadHistory("mcp-who")).toEqual([{ xml: V1 }]) + }) + it("flush() writes the History on shutdown", () => { const dir = tempDir() const saver = withHistory(dir, [{ id: 0, xml: V1 }]) saver.schedule("mcp-exit", V1) saver.flush() - expect(saver.loadHistory("mcp-exit")).toEqual([V1]) + expect(saver.loadHistory("mcp-exit")).toEqual([{ xml: V1 }]) }) })