From 1360671dcae7dbfbe39a9b6f02e613b3e5b1df7d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:11:54 -0400 Subject: [PATCH 1/5] fix(studio-server): a history restore refuses to remove every tracked file --- packages/cli/src/commands/history.test.ts | 21 +++++++ packages/cli/src/commands/history.ts | 4 +- packages/studio-server/src/history/index.ts | 1 + .../src/history/projectHistory.test.ts | 59 +++++++++++++++++++ .../src/history/projectHistory.ts | 18 ++++++ .../studio-server/src/routes/history.test.ts | 20 +++++++ 6 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/history.test.ts b/packages/cli/src/commands/history.test.ts index 7dd8e4e8c9c..c6edea726b9 100644 --- a/packages/cli/src/commands/history.test.ts +++ b/packages/cli/src/commands/history.test.ts @@ -183,6 +183,27 @@ describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => { return { ...p, personWrites }; } + it("refuses an empty start restore with exit 2 and leaves files intact", async () => { + const p = project(); + rmSync(join(p.dir, "index.html")); + rmSync(join(p.dir, "notes.html")); + const history = await openProjectHistory({ + projectDir: p.dir, + historyRoot: historyDeps.historyRoot, + now: () => clock.at, + }); + p.write("index.html", "A"); + p.write("notes.html", "N"); + await history.close(); + if (mode === "preview") await preview(p.dir); + const result = await p.hf("restore", "start"); + expect(result.code).toBe(2); + expect(result.err).toBe( + "Restoring this point would remove every file in this project, so nothing was changed.", + ); + expect(p.files()).toEqual(["A", "N"]); + }); + it("an agent's labelled turn, a person's edit after it: --since shows both, undo of the turn keeps the edit", async () => { const { json, hf, turn, personWrites, files } = await setup(); const startedAt = new Date(clock.at - 1).toISOString(); diff --git a/packages/cli/src/commands/history.ts b/packages/cli/src/commands/history.ts index bed3589e1f7..0dc5d03e4cf 100644 --- a/packages/cli/src/commands/history.ts +++ b/packages/cli/src/commands/history.ts @@ -16,6 +16,7 @@ import { HISTORY_START, HistoryBusyError, HistoryIdError, + HistoryRefusedError, type HistoryEntry, type HistoryListItem, type HistoryResult, @@ -358,7 +359,8 @@ function guarded(run: (args: A) => Promise) { error instanceof Refusal || error instanceof AmbiguousPreviewServerError || error instanceof HistoryBusyError || - error instanceof HistoryIdError; + error instanceof HistoryIdError || + error instanceof HistoryRefusedError; if (!refused) throw error; setCommandExitCode(2); const message = diff --git a/packages/studio-server/src/history/index.ts b/packages/studio-server/src/history/index.ts index c048388de00..a3c0e58564e 100644 --- a/packages/studio-server/src/history/index.ts +++ b/packages/studio-server/src/history/index.ts @@ -13,6 +13,7 @@ export { type HistoryResult, type HistoryWindow, HistoryClosedError, + HistoryRefusedError, } from "./projectHistory.js"; export { reclaimUncopiedBlobs, type ReclaimedHistory } from "./reclaimUncopied.js"; export { HistoryBusyError } from "./ownerLock.js"; diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 9ee0f0fad00..75acd30e093 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -25,6 +25,7 @@ import { HistoryBusyError } from "./ownerLock"; import { HistoryIdError } from "./historyId"; import { HistoryClosedError, + HistoryRefusedError, openProjectHistory, readProjectHistory, type ProjectHistory, @@ -2652,3 +2653,61 @@ describe("the history's copy of a file", () => { }, ); }); + +describe("restore refuses to empty a project", () => { + it.each(["restore", "back-to-before"])( + "refuses %s from an empty start without changing files", + async (action) => { + const { history, write, read } = await project({}); + const files = { "index.html": "A", "compositions/a.html": "B", "assets/x.css": "C" }; + const window = await history.beginWindow(you, "Add files"); + for (const [path, content] of Object.entries(files)) write(path, content); + const entry = await window.close(); + const restore = + action === "restore" + ? history.restore(START, you) + : history.undo(entry!.id, { who: you, mode: "back-to-before" }); + await expect(restore).rejects.toThrow(HistoryRefusedError); + for (const [path, content] of Object.entries(files)) expect(read(path)).toBe(content); + }, + ); + + it("refuses start while its media copy is still adopting", async () => { + let release = () => {}; + mediaCopy.held = new Promise((resolve) => (release = resolve)); + const clip = Buffer.alloc(2 * 1024 ** 2, 7); + const { history, projectDir, read } = await project({ "index.html": "A", "clip.mp4": clip }); + try { + await expect(history.restore(START, you)).rejects.toThrow(HistoryRefusedError); + expect(read("index.html")).toBe("A"); + expect(readFileSync(join(projectDir, "clip.mp4"))).toEqual(clip); + } finally { + release(); + } + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"])); + await expect(history.restore(START, you)).resolves.toBeNull(); + }); + + it("restores an earlier entry and removes only files added later", async () => { + const { history, write, read, has } = await project({ "index.html": "A" }); + const first = await history.beginWindow(you, "Edit index"); + write("index.html", "B"); + const entry = await first.close(); + const later = await history.beginWindow(you, "Add file"); + write("index.html", "C"); + write("later.html", "later"); + await later.close(); + await history.restore(entry!.id, you); + expect(read("index.html")).toBe("B"); + expect(has("later.html")).toBe(false); + }); + + it("allows normal undo to remove the only added file", async () => { + const { history, write, has } = await project({}); + const window = await history.beginWindow(you, "Add index"); + write("index.html", "A"); + const entry = await window.close(); + expect((await history.undo(entry!.id, { who: you })).ok).toBe(true); + expect(has("index.html")).toBe(false); + }); +}); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index a5eef2707d5..b737b1b06aa 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1238,6 +1238,16 @@ class Engine { [...this.tracked.keys()].map((path) => [path, null]), ); for (const [path, hash] of files) target.set(path, hash); + if (point === START && this.adopting.size > 0) { + throw new HistoryRefusedError( + "The project's starting copy is still being saved; try again in a moment. Nothing was changed.", + ); + } + if (this.tracked.size > 0 && [...target.values()].every((hash) => hash === null)) { + throw new HistoryRefusedError( + "Restoring this point would remove every file in this project, so nothing was changed.", + ); + } return this.writeAs(who, label, target, { restoredTo: point }); } @@ -1372,6 +1382,14 @@ export class HistoryClosedError extends Error { } } +/** A restore that would empty the project, or reach a start not fully copied yet; nothing was written. */ +export class HistoryRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "HistoryRefusedError"; + } +} + /** Opens a project's history: every write to its files becomes an entry that can be undone or restored. */ export async function openProjectHistory(options: ProjectHistoryOptions): Promise { if (!existsSync(options.projectDir)) diff --git a/packages/studio-server/src/routes/history.test.ts b/packages/studio-server/src/routes/history.test.ts index f1823cb6087..4b5d8c16d9b 100644 --- a/packages/studio-server/src/routes/history.test.ts +++ b/packages/studio-server/src/routes/history.test.ts @@ -645,3 +645,23 @@ it("checkpoints pending writes without closing an agent window", async () => { expect(history.list()).toHaveLength(2); expect(history.list().at(-1)?.who).toEqual(who); }); + +it("refuses an empty start restore with 409 and leaves files intact", async () => { + const projectDir = tempDir("hf-history-empty-start-"); + const history = await openProjectHistory({ + projectDir, + historyRoot: tempDir("hf-history-root-"), + }); + cleanup.push(() => history.close()); + const call = apiFor(projectDir, history); + writeFileSync(join(projectDir, "index.html"), "A"); + writeFileSync(join(projectDir, "notes.html"), "N"); + await history.checkpoint(); + const response = await call("/restore", { point: "start" }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Restoring this point would remove every file in this project, so nothing was changed.", + }); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("A"); + expect(readFileSync(join(projectDir, "notes.html"), "utf-8")).toBe("N"); +}); From 0d27b6019a6a297fa8558905d5abd72d1c1bfffd Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:23:43 -0400 Subject: [PATCH 2/5] test(studio-server): compare the held clip by bytes so the adopting check stays fast --- packages/studio-server/src/history/projectHistory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 75acd30e093..50cc4129260 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -2680,7 +2680,7 @@ describe("restore refuses to empty a project", () => { try { await expect(history.restore(START, you)).rejects.toThrow(HistoryRefusedError); expect(read("index.html")).toBe("A"); - expect(readFileSync(join(projectDir, "clip.mp4"))).toEqual(clip); + expect(readFileSync(join(projectDir, "clip.mp4")).equals(clip)).toBe(true); } finally { release(); } From 466d18deae9b4235760735a4bbc2967a3f3a2f93 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:31:39 -0400 Subject: [PATCH 3/5] fix(studio-server): history's own hidden records do not count as files a restore keeps --- .../studio-server/src/history/projectHistory.test.ts | 11 +++++++++++ packages/studio-server/src/history/projectHistory.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 50cc4129260..e6199dffe94 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -2672,6 +2672,17 @@ describe("restore refuses to empty a project", () => { }, ); + it("refuses a start that holds only the media ledger", async () => { + const { history, write, read } = await project({ ".media/manifest.jsonl": "{}\n" }); + expect(history.peek(START)).toHaveProperty([".media/manifest.jsonl"]); + const window = await history.beginWindow(you, "Add files"); + write("index.html", "A"); + write("compositions/a.html", "B"); + await window.close(); + await expect(history.restore(START, you)).rejects.toThrow(HistoryRefusedError); + expect([read("index.html"), read("compositions/a.html")]).toEqual(["A", "B"]); + }); + it("refuses start while its media copy is still adopting", async () => { let release = () => {}; mediaCopy.held = new Promise((resolve) => (release = resolve)); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index b737b1b06aa..d0b8eacda3a 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1243,7 +1243,10 @@ class Engine { "The project's starting copy is still being saved; try again in a moment. Nothing was changed.", ); } - if (this.tracked.size > 0 && [...target.values()].every((hash) => hash === null)) { + // History's own hidden records (the media ledger, Studio's manifests) are not the project's work. + const work = (path: string) => !KEPT_HIDDEN_PATHS.has(path); + const kept = [...target].some(([path, hash]) => hash !== null && work(path)); + if ([...this.tracked.keys()].some(work) && !kept) { throw new HistoryRefusedError( "Restoring this point would remove every file in this project, so nothing was changed.", ); From 48c2b804a297de51b24879c5f18696fbf89e2370 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:34:15 -0400 Subject: [PATCH 4/5] refactor(studio-server): name the restore guard's check instead of commenting it --- packages/studio-server/src/history/projectHistory.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index d0b8eacda3a..ec389369ef2 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1243,10 +1243,9 @@ class Engine { "The project's starting copy is still being saved; try again in a moment. Nothing was changed.", ); } - // History's own hidden records (the media ledger, Studio's manifests) are not the project's work. - const work = (path: string) => !KEPT_HIDDEN_PATHS.has(path); - const kept = [...target].some(([path, hash]) => hash !== null && work(path)); - if ([...this.tracked.keys()].some(work) && !kept) { + const isProjectFile = (path: string) => !KEPT_HIDDEN_PATHS.has(path); + const keepsAny = [...target].some(([path, hash]) => hash !== null && isProjectFile(path)); + if ([...this.tracked.keys()].some(isProjectFile) && !keepsAny) { throw new HistoryRefusedError( "Restoring this point would remove every file in this project, so nothing was changed.", ); @@ -1385,7 +1384,6 @@ export class HistoryClosedError extends Error { } } -/** A restore that would empty the project, or reach a start not fully copied yet; nothing was written. */ export class HistoryRefusedError extends Error { constructor(message: string) { super(message); From b711c19b3ab2c12d4e0dd42e6fa112f683439e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 24 Oct 2026 08:00:06 -0400 Subject: [PATCH 5/5] chore(studio-server): re-baseline the packed size budget for the restore guard --- scripts/package-size-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 8194a4a9155..1e5ae92bcd9 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -52,7 +52,7 @@ "unpacked": 24360208 }, "@hyperframes/studio-server": { - "packed": 142100, + "packed": 143700, "unpacked": 559199 } }