mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): a history restore refuses to remove every tracked file (#6175)
* fix(studio-server): a history restore refuses to remove every tracked file * test(studio-server): compare the held clip by bytes so the adopting check stays fast * fix(studio-server): history's own hidden records do not count as files a restore keeps * refactor(studio-server): name the restore guard's check instead of commenting it * chore(studio-server): re-baseline the packed size budget for the restore guard
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
HISTORY_START,
|
||||
HistoryBusyError,
|
||||
HistoryIdError,
|
||||
HistoryRefusedError,
|
||||
type HistoryEntry,
|
||||
type HistoryListItem,
|
||||
type HistoryResult,
|
||||
@@ -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 =
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProjectHistory> {
|
||||
if (!existsSync(options.projectDir))
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"unpacked": 24360208
|
||||
},
|
||||
"@hyperframes/studio-server": {
|
||||
"packed": 142100,
|
||||
"packed": 143700,
|
||||
"unpacked": 559199
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user