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..e6199dffe94 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,72 @@ 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 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)); + 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")).equals(clip)).toBe(true); + } 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..ec389369ef2 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1238,6 +1238,18 @@ 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.", + ); + } + 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.", + ); + } return this.writeAs(who, label, target, { restoredTo: point }); } @@ -1372,6 +1384,13 @@ export class HistoryClosedError extends Error { } } +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"); +}); 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 } }