From e732a0574fb72560dce6df637316288bcd2e4fb8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 06:02:44 -0700 Subject: [PATCH 1/4] fix(studio-server): undoing a change takes the media ledger back with the film --- .../src/history/projectHistory.test.ts | 14 ++++++++++++++ .../studio-server/src/history/projectHistory.ts | 9 +++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 030f574031..9ff13a294c 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -201,6 +201,20 @@ describe("openProjectHistory", () => { expect(reopened.next("forward", you)).toBeUndefined(); }); + 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'); + }); + expect(cutout.files.map((file) => file.path)).toContain(".media/manifest.jsonl"); + 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..4297627f4a 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -203,9 +203,14 @@ 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 KEPT_HIDDEN_PATHS = new Set([ + ...STUDIO_SIGNATURE_MANIFEST_PATHS, + ".media/manifest.jsonl", +]); + +/** 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(".")); } From 62df8fc0947b2cf9021d948e36d870b42af13cd8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 07:25:40 -0700 Subject: [PATCH 2/4] fix(studio-server): a log 0.8.123 wrote takes the media ledger in, so the first Undo undoes the edit --- .../src/history/projectHistory.test.ts | 20 +++++++++++++++++++ .../src/history/projectHistory.ts | 17 ++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 9ff13a294c..e150d5b626 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -201,6 +201,26 @@ 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(); + const logFile = join(historyRoot, history.projectId, "log.jsonl"); + const lines = readFileSync(logFile, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + for (const record of lines) + if (record.type === "baseline") delete record.files[".media/manifest.jsonl"]; + writeFileSync(logFile, lines.map((record) => JSON.stringify(record)).join("\n") + "\n"); + 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"]); + }); + it("takes the media ledger back with the film when a change is undone", async () => { const { history, write, read } = await project({ "index.html": "

Hello

", diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 4297627f4a..4bbbafb206 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -203,10 +203,8 @@ const statKey = (file: { size: number; mtimeMs: number; ctimeMs: number }, swept ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`; -const KEPT_HIDDEN_PATHS = new Set([ - ...STUDIO_SIGNATURE_MANIFEST_PATHS, - ".media/manifest.jsonl", -]); +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 { @@ -408,6 +406,7 @@ class Engine { ); if (!log) return this.firstOpen(); this.log = withoutHiddenPaths(log); + 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) ?? []) { @@ -467,6 +466,16 @@ 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)); + if (this.log.baseline.has(MEDIA_LEDGER) || named(MEDIA_LEDGER)) return; + const hash = await this.storeIfPresent(MEDIA_LEDGER); + if (hash === null) return; + this.log.baseline.set(MEDIA_LEDGER, hash); + this.persistLog(); + } + async firstOpen(): Promise { const sweptAt = this.now(); for (const file of historyFiles(this.dir)) { From 87116f96953d2143ae8d99838027299f197d73b7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 07:42:26 -0700 Subject: [PATCH 3/4] fix(studio-server): only a log from before the ledger fix takes it in, once --- .../studio-server/src/history/historyLog.ts | 15 ++++++++--- .../src/history/projectHistory.test.ts | 25 +++++++++++++++++-- .../src/history/projectHistory.ts | 13 ++++++---- 3 files changed, 42 insertions(+), 11 deletions(-) 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 e150d5b626..bff5a4cfcd 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -213,14 +213,35 @@ describe("openProjectHistory", () => { .trim() .split("\n") .map((line) => JSON.parse(line)); - for (const record of lines) - if (record.type === "baseline") delete record.files[".media/manifest.jsonl"]; + 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"); 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"]); }); + 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

", diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 4bbbafb206..1f60ed1a4d 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -406,7 +406,7 @@ class Engine { ); if (!log) return this.firstOpen(); this.log = withoutHiddenPaths(log); - await this.takeInUnnamedLedger(); + 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) ?? []) { @@ -469,10 +469,12 @@ class Engine { async takeInUnnamedLedger(): Promise { const named = (path: string) => this.log.entries.some((entry) => entry.files.some((file) => file.path === path)); - if (this.log.baseline.has(MEDIA_LEDGER) || named(MEDIA_LEDGER)) return; - const hash = await this.storeIfPresent(MEDIA_LEDGER); - if (hash === null) return; - this.log.baseline.set(MEDIA_LEDGER, hash); + const hash = + this.log.baseline.has(MEDIA_LEDGER) || named(MEDIA_LEDGER) + ? null + : await this.storeIfPresent(MEDIA_LEDGER); + if (hash !== null) this.log.baseline.set(MEDIA_LEDGER, hash); + this.log.keepsLedger = true; this.persistLog(); } @@ -484,6 +486,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(); } From 9fdb1fa98837823d9df3243ad399099dbc1519fc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 08:05:04 -0700 Subject: [PATCH 4/4] fix(studio-server): an older log takes in the media ledger only as the sweep would see it A ledger behind a link was read straight into the baseline, then filed as a deletion by the first sweep. The take-in now asks the same scan the sweep uses. The cutout test also pins that other .media files and other manifest.jsonl names stay out of history. --- .../src/history/projectHistory.test.ts | 47 ++++++++++++++----- .../src/history/projectHistory.ts | 3 +- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index bff5a4cfcd..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 }); @@ -208,21 +221,31 @@ describe("openProjectHistory", () => { }); await change(history, you, "Your edit", () => write("index.html", "B")); await history.close(); - const logFile = join(historyRoot, history.projectId, "log.jsonl"); - 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"); + 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" }, @@ -250,8 +273,10 @@ describe("openProjectHistory", () => { 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)).toContain(".media/manifest.jsonl"); + 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"); }); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 1f60ed1a4d..787ba3ba20 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -469,8 +469,9 @@ class Engine { 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) + 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);