mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 13:09:52 +08:00
feat(mcp-server): saved sessions, restore_version and History on disk (#981)
* feat(mcp-server): list and resume saved diagram sessions start_session accepts an optional session_id to continue a saved diagram under the same preview URL and auto-save file, reporting the restored pages. New read-only tool list_saved_diagrams prints every auto-saved file newest first with its pages. Autosaver gains list() and dataDir(). INSTRUCTIONS and both READMEs mention the new tool. * feat(mcp-server): add restore_version tool for undo and redo from History Add the restore_version write tool so the model can put an earlier version from History back on the canvas (steps_back, default 1). The current canvas is kept in History unless it is the blank page, so a redo is possible. http-server.ts exports restoreHistoryEntry, shared by the HTTP restore endpoint and the tool; history.ts exports HistoryEntry and otherVersions (distinct versions other than the canvas, newest first). The drawing guide tells the model to call restore_version when the user asks to undo, and both READMEs document the tool. * feat(mcp-server): save the diagram History next to the auto-saved file The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files. * fix(mcp-server): review fixes for saved sessions, undo tool, history on disk * fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk - start_session resumes every saved file, also a cleared one or one of empty named pages, and decompresses pages draw.io compressed - History kept by a lost user edit or a recovering tab is saved too - A diagram cleared before its first save keeps its History on disk - The History file is written again after the 50-file cap removed it - restore_version compares versions like the edit gate (a bare mxGraphModel from the browser has no page name), checks the restore result, and says "none" when no version is left - The preview closes an open History list when the server state was recreated, since the entries got new ids - start_session's description mentions session_id * fix(mcp-server): compare History versions pairwise and tie restores to the server state Second Codex review of the History fixes: - One bare mxGraphModel in History no longer hides page renames in every comparison: names are left out only when one of the two is bare - A History list asked for before the server recreated the session is dropped, and a restore names the state its list belongs to; the server refuses one from a lost state (History ids change after a restart) * fix(mcp-server): restore from a History list with the state it was taken in Third Codex review: a restore sent the tab's current state, so an old list shown after a failed refresh passed the server's check. The list now keeps the state it came from, and the server refuses a missing (null) state too; only a tab of an older version, which sends none, is let through.
This commit is contained in:
@@ -14,8 +14,10 @@ import {
|
||||
getState,
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
requestExport,
|
||||
requestSync,
|
||||
restoreHistoryEntry,
|
||||
setState,
|
||||
shutdown,
|
||||
startHttpServer,
|
||||
@@ -256,6 +258,25 @@ describe("POST /api/state", () => {
|
||||
expect(history.at(-1)?.xml).toBe("<mxfile>lost user edit</mxfile>")
|
||||
})
|
||||
|
||||
it("asks for a save when a lost user edit is kept in history", async () => {
|
||||
const id = "mcp-conflict-save"
|
||||
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
|
||||
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
||||
const saves: string[] = []
|
||||
onStateChange((sessionId, xml) => saves.push(`${sessionId} ${xml}`))
|
||||
try {
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: "<mxfile>lost user edit</mxfile>",
|
||||
baseVersion: aiVersion - 1,
|
||||
})
|
||||
} finally {
|
||||
onStateChange(() => {})
|
||||
}
|
||||
// The canvas stays the AI write; the save takes the new History
|
||||
expect(saves).toEqual([`${id} <mxfile>AI edit</mxfile>`])
|
||||
})
|
||||
|
||||
it("ends a pending sync when the sync reply is older than an AI write", async () => {
|
||||
const id = "mcp-stale-sync"
|
||||
setState(id, "<mxfile>before</mxfile>", undefined, true)
|
||||
@@ -468,6 +489,34 @@ describe("history restore", () => {
|
||||
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("refuses a restore picked from a state the server has since lost", async () => {
|
||||
const id = "mcp-history-stale-state"
|
||||
setState(id, page("now"))
|
||||
addHistory(id, page("old"))
|
||||
const [entry] = getHistory(id)
|
||||
const stale = await postJson("/api/restore", {
|
||||
sessionId: id,
|
||||
id: entry.id,
|
||||
stateId: "a-lost-state",
|
||||
})
|
||||
expect(stale.status).toBe(409)
|
||||
// A list taken before the tab knew the state
|
||||
const unknown = await postJson("/api/restore", {
|
||||
sessionId: id,
|
||||
id: entry.id,
|
||||
stateId: null,
|
||||
})
|
||||
expect(unknown.status).toBe(409)
|
||||
expect(getState(id)?.xml).toBe(page("now"))
|
||||
const fresh = await postJson("/api/restore", {
|
||||
sessionId: id,
|
||||
id: entry.id,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
expect(fresh.status).toBe(200)
|
||||
expect(getState(id)?.xml).toBe(page("old"))
|
||||
})
|
||||
|
||||
// A thumbnail the tab took after loading the server write at `version`
|
||||
const thumbnail = (id: string, svg: string, version: number) =>
|
||||
postJson("/api/history-svg", {
|
||||
@@ -664,6 +713,32 @@ describe("history restore", () => {
|
||||
expect(getState(id)?.xml).toBe(doc("ai"))
|
||||
expect(getHistory(id).map((e) => e.xml)).toContain(doc("manual"))
|
||||
})
|
||||
|
||||
it("restoreHistoryEntry leaves the canvas alone for an unknown entry", () => {
|
||||
const id = "mcp-restore-unknown"
|
||||
setState(id, page("x"))
|
||||
addHistory(id, page("x"))
|
||||
expect(restoreHistoryEntry(id, 999999)).toBeNull()
|
||||
expect(getState(id)?.xml).toBe(page("x"))
|
||||
expect(getHistory(id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("restoreHistoryEntry restores an entry and keeps the canvas in History", () => {
|
||||
const id = "mcp-restore-entry"
|
||||
addHistory(id, page("old"))
|
||||
const [entry] = getHistory(id)
|
||||
// The canvas is a later state that is not in History yet
|
||||
setState(id, page("ai"))
|
||||
|
||||
const newVersion = restoreHistoryEntry(id, entry.id)
|
||||
expect(newVersion).toBe(getState(id)?.version)
|
||||
expect(getState(id)?.xml).toBe(page("old"))
|
||||
expect(getHistory(id).map((e) => e.xml)).toEqual([
|
||||
page("old"),
|
||||
page("ai"),
|
||||
page("old"),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("bodies over the size limit", () => {
|
||||
|
||||
Reference in New Issue
Block a user