Skip to content

Commit 301b737

Browse files
committed
feat(cli): hyperframes history lists, undoes and restores a project's recorded changes
1 parent d8b38d5 commit 301b737

11 files changed

Lines changed: 891 additions & 13 deletions

File tree

‎packages/cli/src/cli.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ const commandLoaders = {
160160
info: () => import("./commands/info.js").then((m) => m.default),
161161
compositions: () => import("./commands/compositions.js").then((m) => m.default),
162162
timeline: () => import("./commands/timeline.js").then((m) => m.default),
163+
history: () => import("./commands/history.js").then((m) => m.default),
163164
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
164165
browser: () => import("./commands/browser.js").then((m) => m.default),
165166
"remove-background": () => import("./commands/remove-background.js").then((m) => m.default),
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// @vitest-environment node
2+
import { spawn } from "node:child_process";
3+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import type { AddressInfo } from "node:net";
5+
import { tmpdir } from "node:os";
6+
import { join, resolve } from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
import { createAdaptorServer } from "@hono/node-server";
9+
import {
10+
createStudioApi,
11+
openProjectHistory,
12+
type StudioApiAdapter,
13+
} from "@hyperframes/studio-server";
14+
import { runCommand } from "citty";
15+
import { Hono } from "hono";
16+
import { afterEach, describe, expect, it, vi } from "vitest";
17+
import { consumeCommandResult } from "../utils/commandResult.js";
18+
import historyCommand, { historyDeps } from "./history.js";
19+
20+
const tracked = vi.hoisted(() => [] as Array<{ action: string; via: string }>);
21+
vi.mock("../telemetry/events.js", () => ({
22+
trackHistoryAction: (props: { action: string; via: string }) => tracked.push(props),
23+
}));
24+
25+
const cleanup: Array<() => unknown> = [];
26+
afterEach(async () => {
27+
for (const step of cleanup.splice(0).reverse()) await step();
28+
tracked.length = 0;
29+
});
30+
31+
function tempDir(prefix: string): string {
32+
const dir = mkdtempSync(join(tmpdir(), prefix));
33+
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
34+
return dir;
35+
}
36+
37+
/** A project of index.html "A" and notes.html "N", its history kept in a temp root, no preview running. */
38+
function project() {
39+
const dir = tempDir("hf-history-cli-");
40+
writeFileSync(join(dir, "index.html"), "A");
41+
writeFileSync(join(dir, "notes.html"), "N");
42+
historyDeps.historyRoot = tempDir("hf-history-cli-root-");
43+
historyDeps.findServer = async () => null;
44+
const write = (path: string, text: string) => writeFileSync(join(dir, path), text);
45+
const read = (path: string) => readFileSync(join(dir, path), "utf-8");
46+
async function hf(...args: string[]) {
47+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
48+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
49+
try {
50+
await runCommand(historyCommand, { rawArgs: [...args, "--dir", dir] });
51+
const out = log.mock.calls.map((call) => call.join(" ")).join("\n");
52+
const err = error.mock.calls.map((call) => call.join(" ")).join("\n");
53+
return { out, err, code: consumeCommandResult().exitCode };
54+
} finally {
55+
log.mockRestore();
56+
error.mockRestore();
57+
}
58+
}
59+
const json = async (...args: string[]) => JSON.parse((await hf(...args, "--json")).out);
60+
/** One agent turn that writes `path`. */
61+
async function turn(name: string, label: string, path: string, text: string) {
62+
await hf("begin", "--who", name, "--label", label);
63+
write(path, text);
64+
return (await json("end")).entry;
65+
}
66+
return { dir, write, read, hf, json, turn };
67+
}
68+
69+
/** A running preview over the project's history, as `hyperframes preview` serves it. */
70+
async function preview(dir: string) {
71+
const history = await openProjectHistory({
72+
projectDir: dir,
73+
historyRoot: historyDeps.historyRoot,
74+
quietMs: 20,
75+
});
76+
const adapter = {
77+
listProjects: () => [],
78+
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
79+
history: () => history,
80+
} as unknown as StudioApiAdapter;
81+
const server = createAdaptorServer({
82+
fetch: new Hono().route("/api", createStudioApi(adapter)).fetch,
83+
});
84+
await new Promise<void>((ready) => server.listen(0, "127.0.0.1", ready));
85+
cleanup.push(async () => {
86+
server.close();
87+
await history.close();
88+
});
89+
const { port } = server.address() as AddressInfo;
90+
historyDeps.findServer = async () =>
91+
({
92+
port,
93+
host: "127.0.0.1",
94+
projectName: "demo",
95+
projectDir: dir,
96+
version: "test",
97+
pid: null,
98+
}) as never;
99+
return history;
100+
}
101+
102+
describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => {
103+
async function setup() {
104+
const p = project();
105+
await p.hf(); // the first open records the baseline
106+
const held = mode === "preview" ? await preview(p.dir) : null;
107+
/** A person's edit, taken in as preview's file watcher would. */
108+
async function personWrites(path: string, text: string) {
109+
p.write(path, text);
110+
if (!held) return;
111+
held.noteChange(path);
112+
await vi.waitFor(() => expect(held.list().at(-1)?.files[0]?.path).toBe(path));
113+
}
114+
return { ...p, personWrites };
115+
}
116+
117+
it("an agent's labelled turn, a person's edit after it: --since shows both, undo of the turn keeps the edit", async () => {
118+
const { read, json, hf, turn, personWrites } = await setup();
119+
const startedAt = new Date(Date.now() - 1).toISOString();
120+
await turn("claude", "Bigger title", "index.html", "A2");
121+
await personWrites("notes.html", "N2");
122+
123+
const both = (await json("--since", startedAt)).entries;
124+
expect(both.map((entry: { who: object; files: string[] }) => [entry.who, entry.files])).toEqual(
125+
[
126+
[{ kind: "outside", name: "Outside" }, ["notes.html"]],
127+
[{ kind: "agent", name: "claude" }, ["index.html"]],
128+
],
129+
);
130+
const mine = (await json("--since", "mine", "--who", "claude")).entries;
131+
expect(mine).toMatchObject([{ who: { name: "Outside" }, files: ["notes.html"] }]);
132+
133+
const undo = await hf("undo", "--who", "claude");
134+
expect(undo.code, undo.err).toBe(0);
135+
expect([read("index.html"), read("notes.html")]).toEqual(["A", "N2"]);
136+
expect(tracked.map((event) => event.via)).toContain(mode);
137+
expect(tracked.map((event) => event.action)).toEqual(
138+
expect.arrayContaining(["begin", "end", "list", "undo"]),
139+
);
140+
});
141+
142+
it("undo by one agent leaves another agent's open turn open", async () => {
143+
const { write, read, json, hf, turn } = await setup();
144+
await turn("gemini", "Notes", "notes.html", "N2");
145+
await hf("begin", "--who", "claude", "--label", "Bigger title");
146+
write("index.html", "A2");
147+
148+
expect((await hf("undo", "--who", "gemini")).code).toBe(0);
149+
expect(read("notes.html")).toBe("N");
150+
expect((await hf("end")).code).toBe(0);
151+
const claude = (await json()).entries.filter(
152+
(e: { who: { name: string } }) => e.who.name === "claude",
153+
);
154+
expect(claude).toMatchObject([{ files: ["index.html"] }]);
155+
});
156+
157+
it("undo of an id without --who is the person's, and leaves an agent's open turn open", async () => {
158+
const { write, json, hf, turn } = await setup();
159+
const notes = await turn("gemini", "Notes", "notes.html", "N2");
160+
await hf("begin", "--who", "claude", "--label", "Bigger title");
161+
write("index.html", "A2");
162+
163+
expect((await json("undo", notes.id)).entry.who).toEqual({ kind: "person", name: "You" });
164+
expect((await hf("end")).code).toBe(0);
165+
});
166+
167+
it("undo refuses a conflict with exit 2, names the newer entries and both choices, then takes one", async () => {
168+
const { read, hf, turn } = await setup();
169+
const first = await turn("claude", "First", "index.html", "A2");
170+
await turn("gemini", "Second", "index.html", "A3");
171+
172+
const refused = await hf("undo", first.id.slice(0, 8));
173+
expect(refused.code).toBe(2);
174+
expect(refused.out).toContain("Second");
175+
expect(refused.out).toMatch(/--just-this[\s\S]*--back-to-before/);
176+
expect(read("index.html")).toBe("A3");
177+
178+
expect((await hf("undo", first.id, "--just-this")).code).toBe(0);
179+
expect(read("index.html")).toBe("A");
180+
});
181+
182+
it("show lists an entry's files and --diff prints the text change", async () => {
183+
const { hf, turn } = await setup();
184+
const entry = await turn("claude", "Retitle", "index.html", "A2");
185+
expect((await hf("show", entry.id)).out).toContain("M index.html");
186+
const diff = (await hf("show", entry.id.slice(0, 8), "--diff")).out;
187+
expect(diff).toMatch(/-A\n\\ No newline at end of file\n\+A2/);
188+
});
189+
190+
it("peek reads a file as it was without writing, and restore puts every file back", async () => {
191+
const { read, hf, turn } = await setup();
192+
const entry = await turn("claude", "Retitle", "index.html", "A2");
193+
await turn("claude", "Again", "index.html", "A3");
194+
195+
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
196+
await hf("peek", entry.id, "index.html");
197+
expect(String(stdout.mock.calls[0]?.[0])).toBe("A2");
198+
stdout.mockRestore();
199+
expect(read("index.html")).toBe("A3");
200+
201+
expect((await hf("restore", "start")).code).toBe(0);
202+
expect(read("index.html")).toBe("A");
203+
});
204+
205+
it("pin marks an entry kept, --off unmarks it", async () => {
206+
const { json, hf, turn } = await setup();
207+
const entry = await turn("claude", "Retitle", "index.html", "A2");
208+
await hf("pin", entry.id);
209+
expect((await json()).entries[0]).toMatchObject({ id: entry.id, pinned: true });
210+
await hf("pin", entry.id, "--off");
211+
expect((await json()).entries[0]).toMatchObject({ pinned: false });
212+
});
213+
});
214+
215+
describe("hyperframes history, one owner", () => {
216+
it("goes through a running preview, so the log keeps one baseline and every entry once", async () => {
217+
const { dir, json, turn } = project();
218+
const held = await preview(dir);
219+
await turn("claude", "Retitle", "index.html", "A2");
220+
await json();
221+
const log = readFileSync(join(historyDeps.historyRoot, held.projectId, "log.jsonl"), "utf-8");
222+
const records = log
223+
.trim()
224+
.split("\n")
225+
.map((row) => JSON.parse(row).type);
226+
expect(records).toEqual(["baseline", "entry"]);
227+
});
228+
229+
it("a turn begun through a preview that stopped without closing it is still the agent's", async () => {
230+
const { dir, write, json, hf } = project();
231+
await hf();
232+
const turn = {
233+
via: "preview",
234+
id: "turn-1",
235+
who: { kind: "agent", name: "claude" },
236+
label: "Retitle",
237+
startedAt: 1,
238+
};
239+
writeFileSync(join(dir, ".hyperframes", "history-turn.json"), JSON.stringify(turn));
240+
write("index.html", "A2");
241+
242+
expect((await json("end")).entry).toMatchObject({
243+
id: "turn-1",
244+
who: turn.who,
245+
files: [{ path: "index.html" }],
246+
});
247+
});
248+
249+
it("a CLI run waits while another process holds the history, instead of forking its log", async () => {
250+
const { dir, write } = project();
251+
const home = tempDir("hf-history-cli-home-");
252+
const historyRoot = join(home, ".cache", "hyperframes", "history");
253+
const held = await openProjectHistory({ projectDir: dir, historyRoot });
254+
write("index.html", "A2");
255+
const cli = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts");
256+
const child = spawn("bun", ["run", cli, "history", "--dir", dir], {
257+
env: {
258+
...process.env,
259+
HOME: home,
260+
HYPERFRAMES_SKIP_UPDATE_CHECK: "1",
261+
HYPERFRAMES_NO_TELEMETRY: "1",
262+
},
263+
});
264+
const exited = new Promise<number | null>((done) => child.on("exit", done));
265+
const logFile = join(historyRoot, held.projectId, "log.jsonl");
266+
await new Promise((settle) => setTimeout(settle, 1500));
267+
expect(
268+
readFileSync(logFile, "utf-8").trim().split("\n"),
269+
"nothing written while held",
270+
).toHaveLength(1);
271+
272+
await held.close(); // commits the outside edit once
273+
expect(await exited).toBe(0);
274+
const types = readFileSync(logFile, "utf-8")
275+
.trim()
276+
.split("\n")
277+
.map((row) => JSON.parse(row).type);
278+
expect(types).toEqual(["baseline", "entry"]);
279+
});
280+
});

0 commit comments

Comments
 (0)