mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
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:
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user