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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent a96dcbb21f
commit 66c9daf5ca
4 changed files with 305 additions and 14 deletions
+86 -10
View File
@@ -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<string, HistoryEntry[]>()
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<number, number> {
const history = getHistory(sessionId)
const versions = history.map(entryVersion)
const result = new Map<number, number>()
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<HistoryEntry, Version>()
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 <mxGraphModel> (the browser's sync
// can send one) has no page name, so names count only when both have them
function sameVersion(
a: ReturnType<typeof versionOf>,
b: ReturnType<typeof versionOf>,
): boolean {
function sameVersion(a: Version, b: Version): boolean {
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
}
+23 -4
View File
@@ -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
+79
View File
@@ -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, "<svg/>", "user")
addHistory(
id,
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/></root></mxGraphModel></diagram><diagram id="q" name="Q"><mxGraphModel><root><mxCell id="0"/></root></mxGraphModel></diagram></mxfile>`,
"",
"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("<svg/>")
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, "<mxGraphModel><root/></mxGraphModel>")
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(
"<mxGraphModel>",
'<mxGraphModel background="#ffcc00" shadow="1">',
)
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)
})
})
@@ -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) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
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"') =>