fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart

This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 00aa3c22e6
commit 70d392729a
3 changed files with 108 additions and 22 deletions
+3 -1
View File
@@ -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
})
+41 -11
View File
@@ -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 <session-id>.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
* <session-id>.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)
+64 -10
View File
@@ -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 }])
})
})