diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index a5258345f4d..030f5740316 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -104,6 +104,123 @@ async function change(history: ProjectHistory, who: HistoryWho, label: string, w } describe("openProjectHistory", () => { + it("never writes a hidden file an older log still names, on undo or restore", async () => { + const { history, write, read, projectDir, historyRoot } = await project({ + "index.html": "v1", + "turn-record": "A", + }); + const turn = await change(history, agent, "Agent turn", () => { + write("index.html", "v2"); + write("turn-record", "B"); + }); + await history.close(); + // What an older log holds: the record under its hidden name, as history filed it before. + const logFile = join(historyRoot, history.projectId, "log.jsonl"); + writeFileSync( + logFile, + readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'), + ); + renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record")); + const reopened = await open(projectDir, historyRoot); + write(".turn-record", "C"); + expect((await reopened.undo(turn.id, { who: you })).ok).toBe(true); + expect(read("index.html")).toBe("v1"); + expect(read(".turn-record")).toBe("C"); + await reopened.restore(START, you); + expect(read(".turn-record")).toBe("C"); + }); + + it("steps Undo past an older log's entry that changed only a hidden file", async () => { + const { history, write, read, projectDir, historyRoot } = await project( + { "index.html": "v1", "turn-record": "A" }, + { quietMs: 30 }, + ); + await change(history, you, "Your edit", () => write("index.html", "v2")); + write("turn-record", "B"); + history.noteChange("turn-record"); + await history.flush(); + expect(history.list().map((entry) => entry.who.kind)).toEqual(["person", "outside"]); + await history.close(); + const logFile = join(historyRoot, history.projectId, "log.jsonl"); + writeFileSync( + logFile, + readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'), + ); + renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record")); + const reopened = await open(projectDir, historyRoot); + expect(reopened.list().map((entry) => entry.files.length)).toEqual([1, 0]); + expect(await reopened.step("back", you)).toMatchObject({ ok: true }); + expect(read("index.html")).toBe("v1"); + }); + + it("keeps every entry an older log names, emptied ones too, and undoing one still lands", async () => { + const { history, write, projectDir, historyRoot } = await project( + { "index.html": "v1", "turn-record": "A" }, + { quietMs: 30 }, + ); + write("turn-record", "B"); + history.noteChange("turn-record"); + await history.flush(); + const [hidden] = history.list(); + await history.pin(hidden!.id, true); + await history.close(); + const logFile = join(historyRoot, history.projectId, "log.jsonl"); + writeFileSync( + logFile, + readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'), + ); + renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record")); + const reopened = await open(projectDir, historyRoot); + expect(reopened.list()).toMatchObject([{ id: hidden!.id, files: [], pinned: true }]); + const undone = await reopened.undo(hidden!.id, { who: you }); + expect(undone).toMatchObject({ ok: true, entry: { undoes: hidden!.id, files: [] } }); + }); + + it("keeps an older log's Undo that only reverted a hidden file, so its target stays undone", async () => { + const { history, write, projectDir, historyRoot } = await project({ + "index.html": "A", + "turn-record": "H0", + }); + const yours = await change(history, you, "Your edit", () => { + write("index.html", "B"); + write("turn-record", "H1"); + }); + await change(history, agent, "Agent turn", () => write("index.html", "C")); + const keep = { who: you, mode: "keep-later-edits" } as const; + expect(await history.undo(yours.id, keep)).toMatchObject({ ok: true }); + expect(history.next("back", you)).toBeUndefined(); + await history.close(); + const logFile = join(historyRoot, history.projectId, "log.jsonl"); + writeFileSync( + logFile, + readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'), + ); + renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record")); + const reopened = await open(projectDir, historyRoot); + expect(reopened.next("back", you)).toBeUndefined(); + expect(reopened.next("forward", you)).toBeUndefined(); + }); + + 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"); + write("index.html", "

Bye

"); + write(".turn-record", "a tool's own record"); + write("sub/.DS_Store", "finder"); + write(".hyperframes/studio-motion.json", "{}"); + const entry = await turn.close(); + expect(entry!.files.map((file) => file.path)).toEqual([ + ".hyperframes/studio-motion.json", + "index.html", + ]); + const records = await history.beginWindow(agent, "Records only"); + write(".spawn-record", "pid"); + expect(await records.close()).toBeNull(); + history.noteChange(".spawn-record"); + await history.flush(); + expect(history.list()).toHaveLength(1); + }); + it("records a write nobody announced as one outside entry, and undo puts the bytes back as a new entry", async () => { const { history, write, read, projectDir } = await project( { "index.html": "

Hello

", "assets/logo.png": Buffer.from([1, 2, 3]) }, diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index efccf1e4b3f..690191dc427 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -24,7 +24,11 @@ import { recordFileWriteReceipt, } from "../helpers/fileVersion.js"; import { realFilePath } from "../helpers/safePath.js"; -import { affectsProjectSignature, listProjectFiles } from "../helpers/projectSignature.js"; +import { + STUDIO_SIGNATURE_MANIFEST_PATHS, + affectsProjectSignature, + listProjectFiles, +} from "../helpers/projectSignature.js"; import { openBlobStore, type BlobStore } from "./blobStore.js"; import { pruneGoneProjectHistoriesDaily } from "./pruneHistories.js"; import { @@ -199,6 +203,22 @@ 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. */ +function isHistoryPath(path: string): boolean { + if ((STUDIO_SIGNATURE_MANIFEST_PATHS as readonly string[]).includes(path)) return true; + return !path.split("/").some((segment) => segment.startsWith(".")); +} + +function withoutHiddenPaths(log: HistoryLog): HistoryLog { + for (const path of log.baseline.keys()) if (!isHistoryPath(path)) log.baseline.delete(path); + for (const entry of log.entries) + entry.files = entry.files.filter((file) => isHistoryPath(file.path)); + return log; +} + +const historyFiles = (dir: string) => + listProjectFiles(dir).filter((file) => isHistoryPath(file.path)); + const sameWho = (a: HistoryWho, b: HistoryWho) => a.kind === b.kind && a.name === b.name; /** Cuts a change at the overwritten `at`: [kept before it, claimed after]; `at` unknown or not kept claims all. */ @@ -382,7 +402,7 @@ class Engine { ), ); if (!log) return this.firstOpen(); - this.log = log; + this.log = withoutHiddenPaths(log); const cache = this.readStatCache(); const last = this.log.entries.at(-1)?.id ?? START; for (const [path, hash] of manifestAt(this.log, last) ?? []) { @@ -402,6 +422,7 @@ class Engine { const replaced = hashOfVersion(fileContentVersion(bytes)); if (!replaced || replaced === after) return; const path = relative(realDir, absPath).split(sep).join("/"); + if (!isHistoryPath(path)) return; // One chain per file, a new Map per write (forgetWritesBefore checks identity); off-chain notes were overwritten. this.overwritten.set( path, @@ -443,7 +464,7 @@ class Engine { async firstOpen(): Promise { const sweptAt = this.now(); - for (const file of listProjectFiles(this.dir)) { + for (const file of historyFiles(this.dir)) { const hash = await this.storeIfPresent(file.path); if (this.whereFolder() !== "here") throw this.replaced(); if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) }); @@ -482,7 +503,7 @@ class Engine { const sweptAt = this.now(); const changedAt = (file: { mtimeMs: number; ctimeMs: number }) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs)); - const seen = listProjectFiles(this.dir); + const seen = historyFiles(this.dir); const heard = new Map(this.overwritten); const present = new Set(seen.map((file) => file.path)); const removed = [...this.tracked.keys()] @@ -737,7 +758,9 @@ class Engine { } async commit(group: Group, extra: Partial = {}): Promise { - if (!group.changes.size) return null; + const undoesEmptied = + this.log.entries.find((entry) => entry.id === extra.undoes)?.files.length === 0; + if (!group.changes.size && !undoesEmptied) return null; const pending = this.pendingEntry(group); const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0); const entry: HistoryEntry = { ...pending, endedAt, ...extra }; @@ -802,7 +825,9 @@ class Engine { /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */ noteChange(path: string): void { - if (this.notedTimer || !affectsProjectSignature(this.dir, resolve(this.dir, path))) return; + const absolute = resolve(this.dir, path); + if (this.notedTimer || !affectsProjectSignature(this.dir, absolute)) return; + if (!isHistoryPath(relative(this.dir, absolute).split(sep).join("/"))) return; this.notedTimer = setTimeout(() => { this.notedTimer = null; this.background(() => this.sweep()); @@ -995,7 +1020,7 @@ class Engine { return stepTarget( this.log.entries, direction, - (entry) => mine(entry.who) || (everyone && !ofOpenTurn(entry)), + (entry) => entry.files.length > 0 && (mine(entry.who) || (everyone && !ofOpenTurn(entry))), ); }