diff --git a/packages/studio-server/src/history/historyLog.ts b/packages/studio-server/src/history/historyLog.ts index bdce41b647..ff78e737c0 100644 --- a/packages/studio-server/src/history/historyLog.ts +++ b/packages/studio-server/src/history/historyLog.ts @@ -40,10 +40,11 @@ export interface HistoryLog { baseline: Manifest; entries: HistoryEntry[]; pins: Set; + keepsLedger?: boolean; } export type LogRecord = - | { type: "baseline"; files: Record } + | { type: "baseline"; files: Record; keepsLedger?: boolean } | { type: "entry"; entry: HistoryEntry } | { type: "pin"; id: string; pinned: boolean }; @@ -74,8 +75,10 @@ export function readLog(file: string, onUnreadable: (line: number) => void): His } function applyRecord(log: HistoryLog, record: LogRecord): void { - if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files)); - else if (record.type === "entry") log.entries.push(record.entry); + if (record.type === "baseline") { + log.baseline = new Map(Object.entries(record.files)); + log.keepsLedger = record.keepsLedger === true; + } else if (record.type === "entry") log.entries.push(record.entry); else if (record.pinned) log.pins.add(record.id); else log.pins.delete(record.id); } @@ -99,7 +102,11 @@ export function saveRecord(file: string, log: HistoryLog, record: LogRecord): vo export function writeLog(file: string, log: HistoryLog): void { const records: LogRecord[] = [ - { type: "baseline", files: Object.fromEntries(log.baseline) }, + { + type: "baseline", + files: Object.fromEntries(log.baseline), + ...(log.keepsLedger && { keepsLedger: true }), + }, ...log.entries.map((entry) => ({ type: "entry" as const, entry })), ...[...log.pins].map((id) => ({ type: "pin" as const, id, pinned: true })), ]; diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 030f574031..0d9a80f190 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -71,6 +71,19 @@ async function project(files: Record, options = {}) { } /** Undoes the newest change still in effect, whoever made it; Cmd+Z steps only over the caller's own. */ +/** What 0.8.123 leaves: a log whose baseline never names the media ledger and carries no marker. */ +function asWrittenBy0_8_123(logFile: string) { + const lines = readFileSync(logFile, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + for (const record of lines.filter((line) => line.type === "baseline")) { + delete record.files[".media/manifest.jsonl"]; + delete record.keepsLedger; + } + writeFileSync(logFile, lines.map((record) => JSON.stringify(record)).join("\n") + "\n"); +} + async function undoNewest(history: ProjectHistory) { const newest = [...history.list()].reverse().find((entry) => !entry.undoes && !entry.undone); return history.undo(newest!.id, { who: you }); @@ -201,6 +214,73 @@ describe("openProjectHistory", () => { expect(reopened.next("forward", you)).toBeUndefined(); }); + it("a log 0.8.123 wrote without the media ledger takes it in, so the first Undo undoes the edit", async () => { + const { history, write, read, projectDir, historyRoot } = await project({ + "index.html": "A", + ".media/manifest.jsonl": "{}\n", + }); + await change(history, you, "Your edit", () => write("index.html", "B")); + await history.close(); + asWrittenBy0_8_123(join(historyRoot, history.projectId, "log.jsonl")); + const reopened = await open(projectDir, historyRoot); + expect(await reopened.step("back", you)).toMatchObject({ ok: true }); + expect([read("index.html"), read(".media/manifest.jsonl")]).toEqual(["A", "{}\n"]); + }); + + // Windows needs a privilege to create symlinks. + it.skipIf(process.platform === "win32")( + "a log 0.8.123 wrote never takes in a ledger behind a link", + async () => { + const { history, projectDir, historyRoot } = await project( + { "index.html": "A" }, + { quietMs: 30 }, + ); + await history.close(); + asWrittenBy0_8_123(join(historyRoot, history.projectId, "log.jsonl")); + const outside = tempDir("hf-history-linked-"); + writeFileSync(join(outside, "manifest.jsonl"), "{}\n"); + symlinkSync(outside, join(projectDir, ".media"), "dir"); + const reopened = await open(projectDir, historyRoot, { quietMs: 30 }); + await reopened.flush(); + expect(reopened.list()).toEqual([]); + }, + ); + + it("a ledger made while the project was closed is a change like any file, once a log keeps the ledger", async () => { + const { history, write, read, has, projectDir, historyRoot } = await project( + { "index.html": "A" }, + { quietMs: 30 }, + ); + await history.close(); + write("index.html", "B"); + write(".media/manifest.jsonl", "{}\n"); + const reopened = await open(projectDir, historyRoot, { quietMs: 30 }); + await reopened.flush(); + const [outside] = reopened.list(); + expect(outside!.files.map((file) => file.path).sort()).toEqual([ + ".media/manifest.jsonl", + "index.html", + ]); + expect((await reopened.undo(outside!.id, { who: you })).ok).toBe(true); + expect([read("index.html"), has(".media/manifest.jsonl")]).toEqual(["A", false]); + }); + + it("takes the media ledger back with the film when a change is undone", async () => { + const { history, write, read } = await project({ + "index.html": "

Hello

", + ".media/manifest.jsonl": "{}\n", + }); + const cutout = await change(history, agent, "Cut out the logo", () => { + write("index.html", "

Hello

"); + write(".media/manifest.jsonl", '{}\n{"id":"logo"}\n'); + write(".media/images/logo.png", "png"); + write(".tools/manifest.jsonl", "{}\n"); + }); + expect(cutout.files.map((file) => file.path)).toEqual([".media/manifest.jsonl", "index.html"]); + expect((await history.undo(cutout.id, { who: you })).ok).toBe(true); + expect(read(".media/manifest.jsonl")).toBe("{}\n"); + }); + it("never files a hidden file name as a change, but keeps Studio's two manifests", async () => { const { history, write } = await project({ "index.html": "

Hello

" }, { quietMs: 30 }); const turn = await history.beginWindow(agent, "Agent turn"); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 690191dc42..787ba3ba20 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -203,9 +203,12 @@ const statKey = (file: { size: number; mtimeMs: number; ctimeMs: number }, swept ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`; -/** A hidden name anywhere in a path (a tool's own record, .DS_Store) is nobody's work, except Studio's manifests. */ +const MEDIA_LEDGER = ".media/manifest.jsonl"; +const KEPT_HIDDEN_PATHS = new Set([...STUDIO_SIGNATURE_MANIFEST_PATHS, MEDIA_LEDGER]); + +/** A hidden name anywhere in a path (a tool's own record, .DS_Store) is nobody's work, except the kept ones above. */ function isHistoryPath(path: string): boolean { - if ((STUDIO_SIGNATURE_MANIFEST_PATHS as readonly string[]).includes(path)) return true; + if (KEPT_HIDDEN_PATHS.has(path)) return true; return !path.split("/").some((segment) => segment.startsWith(".")); } @@ -403,6 +406,7 @@ class Engine { ); if (!log) return this.firstOpen(); this.log = withoutHiddenPaths(log); + if (!this.log.keepsLedger) await this.takeInUnnamedLedger(); const cache = this.readStatCache(); const last = this.log.entries.at(-1)?.id ?? START; for (const [path, hash] of manifestAt(this.log, last) ?? []) { @@ -462,6 +466,19 @@ class Engine { this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: new Map() }); } + async takeInUnnamedLedger(): Promise { + const named = (path: string) => + this.log.entries.some((entry) => entry.files.some((file) => file.path === path)); + const listed = () => historyFiles(this.dir).some((file) => file.path === MEDIA_LEDGER); + const hash = + this.log.baseline.has(MEDIA_LEDGER) || named(MEDIA_LEDGER) || !listed() + ? null + : await this.storeIfPresent(MEDIA_LEDGER); + if (hash !== null) this.log.baseline.set(MEDIA_LEDGER, hash); + this.log.keepsLedger = true; + this.persistLog(); + } + async firstOpen(): Promise { const sweptAt = this.now(); for (const file of historyFiles(this.dir)) { @@ -470,6 +487,7 @@ class Engine { if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); } this.log.baseline = this.manifest(); + this.log.keepsLedger = true; this.persistLog(); this.saveStatCache(); }