From 66c9daf5caad97faa6366cbc828770edb15d1290 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 15:55:10 +0900 Subject: [PATCH] feat(mcp-server): history entries name their maker, and /api/history the entry on the canvas Each History entry records who made it (a user edit, a recovering tab's copy, a restored copy; none for the AI's writes), when, and its page count. GET /api/history now also returns each entry's XML, the first entry with the same content (a restore adds a copy of an older one), the state the ids belong to, and the entry whose content the canvas shows, by the rule restore_version uses. The shell's version cards read these; the classic page keeps using index, id and svg. --- packages/mcp-server/src/history.ts | 96 ++++++++++++-- packages/mcp-server/src/http-server.ts | 27 +++- packages/mcp-server/tests/history.test.ts | 79 ++++++++++++ packages/mcp-server/tests/http-server.test.ts | 117 ++++++++++++++++++ 4 files changed, 305 insertions(+), 14 deletions(-) diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 197a13e7..39bf4e3c 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -5,20 +5,44 @@ import { contentFingerprint } from "./edit-gate.ts" import { log } from "./logger.ts" -import { isMxGraphModel } from "./pages.ts" +import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts" const MAX_HISTORY = 20 +/** + * Who made an entry's diagram: an edit in the browser ("user"), a tab's + * copy kept when the server recovered the session ("recover"), a restored + * copy of an older entry ("restore"). The AI's writes and entries loaded + * from the saved file carry none. + */ +export type HistorySource = "user" | "recover" | "restore" + export interface HistoryEntry { id: number // Stable across shifts of the circular buffer xml: string svg: string + source?: HistorySource + /** When it was added, in ms since the epoch */ + time: number + /** Pages in the document (0 when it has none) */ + pages: number } let nextEntryId = 0 const historyStore = new Map() -export function addHistory(sessionId: string, xml: string, svg = ""): number { +function countPages(xml: string): number { + const normalized = normalizeToMxfile(xml) + const doc = normalized ? parseMxfile(normalized) : null + return doc ? doc.querySelectorAll("diagram").length : 0 +} + +export function addHistory( + sessionId: string, + xml: string, + svg = "", + source?: HistorySource, +): number { let history = historyStore.get(sessionId) if (!history) { history = [] @@ -34,7 +58,14 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number { return history.length - 1 } - history.push({ id: nextEntryId++, xml, svg }) + history.push({ + id: nextEntryId++, + xml, + svg, + ...(source && { source }), + time: Date.now(), + pages: countPages(xml), + }) // Circular buffer if (history.length > MAX_HISTORY) { @@ -75,7 +106,7 @@ export function otherVersions( const result: HistoryEntry[] = [] const history = getHistory(sessionId) for (let i = history.length - 1; i >= 0; i--) { - const version = versionOf(history[i].xml) + const version = entryVersion(history[i]) if (kept.some((seen) => sameVersion(seen, version))) continue kept.push(version) result.push(history[i]) @@ -87,12 +118,49 @@ export function otherVersions( export function isInHistory(sessionId: string, xml: string): boolean { const version = versionOf(xml) return getHistory(sessionId).some((entry) => - sameVersion(version, versionOf(entry.xml)), + sameVersion(version, entryVersion(entry)), ) } +/** + * The entry the canvas shows: the newest one with the content of xml (the + * rule of otherVersions), or null when the canvas is in none of them + */ +export function currentEntryId(sessionId: string, xml: string): number | null { + const version = versionOf(xml) + const history = getHistory(sessionId) + for (let i = history.length - 1; i >= 0; i--) { + if (sameVersion(version, entryVersion(history[i]))) { + return history[i].id + } + } + return null +} + +/** + * For every entry, the id of the oldest entry with the same content (its + * own when no earlier one has it). A restore adds a copy of an older entry; + * the shell shows one version per content, where its first copy is. + */ +export function firstCopyIds(sessionId: string): Map { + const history = getHistory(sessionId) + const versions = history.map(entryVersion) + const result = new Map() + history.forEach((entry, i) => { + const first = versions.findIndex((v) => sameVersion(v, versions[i])) + result.set(entry.id, history[first].id) + }) + return result +} + +interface Version { + bare: boolean + named: string + cells: string +} + /** A diagram's fingerprints, with and without its page names */ -function versionOf(xml: string) { +function versionOf(xml: string): Version { return { bare: isMxGraphModel(xml), named: contentFingerprint(xml), @@ -100,12 +168,20 @@ function versionOf(xml: string) { } } +// An entry's XML never changes: its fingerprints are computed once +const entryVersions = new WeakMap() +function entryVersion(entry: HistoryEntry): Version { + let version = entryVersions.get(entry) + if (!version) { + version = versionOf(entry.xml) + entryVersions.set(entry, version) + } + return version +} + // Same rule as checkEditGate: a bare (the browser's sync // can send one) has no page name, so names count only when both have them -function sameVersion( - a: ReturnType, - b: ReturnType, -): boolean { +function sameVersion(a: Version, b: Version): boolean { return a.bare || b.bare ? a.cells === b.cells : a.named === b.named } diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index be25934c..da46a32b 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -50,6 +50,8 @@ import { contentFingerprint } from "./edit-gate.ts" import { addHistory, clearHistory, + currentEntryId, + firstCopyIds, getHistory, getHistoryEntry, updateLastHistorySvg, @@ -335,7 +337,7 @@ export function keepInHistory(sessionId: string, xml: string, svg = ""): void { if (state && !state.userEdited && last && last.xml === state.serverXml) { return } - addHistory(sessionId, xml, svg) + addHistory(sessionId, xml, svg, state?.userEdited ? "user" : undefined) } /** @@ -741,7 +743,12 @@ function handleStateApi( !!data.xml && data.xml !== current.xml if (saved) { - addHistory(sessionId, data.xml, data.svg || "") + addHistory( + sessionId, + data.xml, + data.svg || "", + "recover", + ) // Saved with the History, as after any change stateListener?.(sessionId, current.xml) } @@ -778,7 +785,7 @@ function handleStateApi( } else if (typeof data.xml === "string" && data.xml) { // A user edit lost the race with an AI write. Keep // it in history so the user can restore it. - addHistory(sessionId, data.xml, data.svg || "") + addHistory(sessionId, data.xml, data.svg || "", "user") savedToHistory = true if (current) stateListener?.(sessionId, current.xml) } @@ -832,7 +839,12 @@ function handleHistoryApi( return } + // The classic page reads index, id and svg; the shell's version cards + // the rest: the document, who made it, which earlier entry it is a copy + // of, the state the ids belong to, and the entry the canvas shows const history = getHistory(sessionId) + const firstIds = firstCopyIds(sessionId) + const state = stateStore.get(sessionId) res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ @@ -840,8 +852,15 @@ function handleHistoryApi( index: i, id: entry.id, svg: entry.svg, + xml: entry.xml, + source: entry.source ?? null, + time: entry.time, + pages: entry.pages, + firstId: firstIds.get(entry.id), })), count: history.length, + stateId: state?.stateId ?? null, + currentId: state ? currentEntryId(sessionId, state.xml) : null, }), ) } @@ -870,7 +889,7 @@ export function restoreHistoryEntry( keepInHistory(sessionId, current.xml, current.svg) } const newVersion = setState(sessionId, entry.xml) - addHistory(sessionId, entry.xml, entry.svg) + addHistory(sessionId, entry.xml, entry.svg, "restore") log.info(`Restored session ${sessionId} to history entry ${entryId}`) return newVersion diff --git a/packages/mcp-server/tests/history.test.ts b/packages/mcp-server/tests/history.test.ts index e8be5391..b2048a0e 100644 --- a/packages/mcp-server/tests/history.test.ts +++ b/packages/mcp-server/tests/history.test.ts @@ -7,6 +7,8 @@ import { installDomPolyfill } from "../src/dom.ts" import { addHistory, clearHistory, + currentEntryId, + firstCopyIds, getHistory, isInHistory, otherVersions, @@ -116,3 +118,80 @@ describe("otherVersions", () => { expect(otherVersions(id, A)).toEqual([]) }) }) + +describe("entries", () => { + it("record who made them, when, and their page count", () => { + const id = "history-fields" + clearHistory(id) + const before = Date.now() + addHistory(id, A) + addHistory(id, B, "", "user") + addHistory( + id, + ``, + "", + "restore", + ) + const [first, second, third] = getHistory(id) + expect(first.source).toBeUndefined() + expect(first.time).toBeGreaterThanOrEqual(before) + expect(first.pages).toBe(1) + expect(second.source).toBe("user") + expect(second.svg).toBe("") + expect(third.source).toBe("restore") + expect(third.pages).toBe(2) + // A bare model is one page; text that is no diagram has none + addHistory(id, "") + addHistory(id, "not a diagram") + expect( + getHistory(id) + .slice(-2) + .map((e) => e.pages), + ).toEqual([1, 0]) + }) +}) + +// The shell's version cards: which entry the canvas shows, and which +// entries are copies of an earlier one (a restore adds one) +describe("currentEntryId and firstCopyIds", () => { + it("name the newest entry with the canvas's content, by the rule of otherVersions", () => { + const id = "history-current" + fill(id, [A, B, C, A]) + const [a, b, c, aCopy] = getHistory(id) + expect(currentEntryId(id, A)).toBe(aCopy.id) + expect(currentEntryId(id, B_COPY)).toBe(b.id) + expect(currentEntryId(id, page("manual"))).toBeNull() + // A restore's copy belongs with its first entry; the others are + // their own + expect([...firstCopyIds(id).entries()]).toEqual([ + [a.id, a.id], + [b.id, b.id], + [c.id, c.id], + [aCopy.id, a.id], + ]) + }) + + it("agree with otherVersions on a page setting that only the canvas shows", () => { + // The same cells with another background: one version to the MCP + // core (the model's edits stay valid when the user changes a page + // setting), so the canvas still shows that entry and restore_version + // offers no other copy of it + const id = "history-background" + const withBackground = A.replace( + "", + '', + ) + fill(id, [A, B]) + const [a] = getHistory(id) + expect(currentEntryId(id, withBackground)).toBe(a.id) + expect(otherVersions(id, withBackground).map((e) => e.xml)).toEqual([B]) + expect(isInHistory(id, withBackground)).toBe(true) + }) + + it("return nothing for an empty history", () => { + const id = "history-current-empty" + clearHistory(id) + expect(currentEntryId(id, A)).toBeNull() + expect(firstCopyIds(id).size).toBe(0) + }) +}) diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index ede85c56..8d1e0cb3 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -1097,6 +1097,123 @@ describe("history restore", () => { // The missing image is filled in expect(getHistory(id)[0].svg).toBe("SVG") }) +}) + +// What the shell's version cards read (the classic page uses index, id +// and svg of the same list) +describe("GET /api/history for the version cards", () => { + const page = (cellId: string) => + `` + + it("lists each entry's document, maker and first copy, with the state and the entry on the canvas", async () => { + const id = "mcp-history-cards" + addHistory(id, page("a")) + addHistory(id, page("b"), "SVG-B", "user") + setState(id, page("b")) + const res = await request(`/api/history?sessionId=${id}`) + expect(res.status).toBe(200) + const d = JSON.parse(res.body) + const [a, b] = getHistory(id) + expect(d.count).toBe(2) + expect(d.stateId).toBe(getState(id)?.stateId) + expect(d.currentId).toBe(b.id) + expect(d.entries[0]).toEqual({ + index: 0, + id: a.id, + svg: "", + xml: page("a"), + source: null, + time: a.time, + pages: 1, + firstId: a.id, + }) + expect(d.entries[1]).toMatchObject({ + id: b.id, + svg: "SVG-B", + source: "user", + firstId: b.id, + }) + }) + + it("names the entry a restored copy stands for, and marks the copy as a restore", async () => { + const id = "mcp-history-cards-restore" + addHistory(id, page("a")) + addHistory(id, page("b")) + setState(id, page("b")) + const [a, b] = getHistory(id) + const restore = await postJson("/api/restore", { + sessionId: id, + id: a.id, + stateId: getState(id)?.stateId, + }) + expect(restore.status).toBe(200) + const d = JSON.parse( + (await request(`/api/history?sessionId=${id}`)).body, + ) + // History: a, b, a' (the copy); the canvas shows a's content + expect(d.entries.map((e: { firstId: number }) => e.firstId)).toEqual([ + a.id, + b.id, + a.id, + ]) + expect(d.entries[2].source).toBe("restore") + expect(d.currentId).toBe(d.entries[2].id) + }) + + it("marks the user's edit that a write replaces, and one that lost the race", async () => { + const id = "mcp-history-cards-user" + const version = setState(id, page("ai")) + addHistory(id, page("ai")) + // The user edits in the browser; the AI writes over it + await postJson("/api/state", { + sessionId: id, + xml: page("user"), + baseVersion: version, + stateId: getState(id)?.stateId, + }) + keepInHistory(id, page("user")) + const ai2 = setState(id, page("ai2")) + addHistory(id, page("ai2")) + // A tab that had not loaded the write yet pushes another edit + await postJson("/api/state", { + sessionId: id, + xml: page("late"), + baseVersion: ai2 - 1, + stateId: getState(id)?.stateId, + }) + const d = JSON.parse( + (await request(`/api/history?sessionId=${id}`)).body, + ) + expect( + d.entries.map((e: { xml: string; source: string | null }) => [ + e.xml === page("user") + ? "user" + : e.xml === page("late") + ? "late" + : "ai", + e.source, + ]), + ).toEqual([ + ["ai", null], + ["user", "user"], + ["ai", null], + ["late", "user"], + ]) + // The canvas shows the write, not an edit + expect(d.currentId).toBe(getHistory(id)[2].id) + }) + + it("reports no state and no current entry for a session it does not know", async () => { + const d = JSON.parse( + (await request("/api/history?sessionId=mcp-never-seen")).body, + ) + expect(d).toEqual({ + entries: [], + count: 0, + stateId: null, + currentId: null, + }) + }) // draw.io's own copy of a diagram, as a sync reply or an edit brings it const drawioCopy = (xml: string, attrs = 'pageWidth="850"') =>