Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/cli/src/commands/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
HISTORY_START,
HistoryBusyError,
HistoryIdError,
HistoryRefusedError,
type HistoryEntry,
type HistoryListItem,
type HistoryResult,
Expand Down Expand Up @@ -358,7 +359,8 @@ function guarded<A>(run: (args: A) => Promise<void>) {
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 =
Expand Down
1 change: 1 addition & 0 deletions packages/studio-server/src/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
70 changes: 70 additions & 0 deletions packages/studio-server/src/history/projectHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { HistoryBusyError } from "./ownerLock";
import { HistoryIdError } from "./historyId";
import {
HistoryClosedError,
HistoryRefusedError,
openProjectHistory,
readProjectHistory,
type ProjectHistory,
Expand Down Expand Up @@ -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<void>((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);
});
});
19 changes: 19 additions & 0 deletions packages/studio-server/src/history/projectHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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<ProjectHistory> {
if (!existsSync(options.projectDir))
Expand Down
20 changes: 20 additions & 0 deletions packages/studio-server/src/routes/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
2 changes: 1 addition & 1 deletion scripts/package-size-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"unpacked": 24360208
},
"@hyperframes/studio-server": {
"packed": 142100,
"packed": 143700,
"unpacked": 559199
}
}
Loading