mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
feat(cli): hyperframes history lists, undoes and restores a project's recorded changes (#4449)
* feat(cli): hyperframes history lists, undoes and restores a project's recorded changes * fix(cli): an agent's history turn ends after 10 idle minutes on every path A turn left open no longer takes the person's later edits: the engine files a CLI turn's writes by the same idle rule as a preview window. Studio claims take their paths back from an agent's window; refusals, --json shapes, --limit and time parsing follow the review. * refactor(cli): the project-history opener lives in its own module The history command and other commands open a project's history the same way: through a running preview, or directly under the engine's lock. * docs(skills): the history loop lines sit in one marked trial block per skill * fix(cli): a turn marker without its last write time has ended Idle-limit tests set file times instead of racing the clock, and the restore refusal test picks a ref no entry id starts with. * docs(cli): the history entry shape comment names no other app * refactor(cli): history reuses the busy error class and its who helper * fix(cli): undo --who reverts the agent's whole last turn and nothing older When the open turn recorded nothing, undo fell back to the agent's earlier turn; when a mid-turn command split the turn, it reverted only the newest part. The turn now keeps the entries it filed, and undo --who reverts exactly those or refuses. Engine tests pin the write timing rules, and a date that rolls over (Feb 31) is refused. * fix(cli): an undo --who that meets a conflict leaves the whole turn as it was It redoes the parts it already undid, lists the newer entries as they are now, and offers --just-this and --back-to-before for the whole turn. The last turn is recorded atomically before its marker is cleared. * chore(cli): history keeps each file's comment share at or under main's * fix(cli): history keeps log order past a turn's idle limit and undoes a turn's cut parts * fix(cli): history drops unused imports and splits undo and reopen steps
This commit is contained in:
@@ -160,6 +160,7 @@ const commandLoaders = {
|
||||
info: () => import("./commands/info.js").then((m) => m.default),
|
||||
compositions: () => import("./commands/compositions.js").then((m) => m.default),
|
||||
timeline: () => import("./commands/timeline.js").then((m) => m.default),
|
||||
history: () => import("./commands/history.js").then((m) => m.default),
|
||||
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
|
||||
browser: () => import("./commands/browser.js").then((m) => m.default),
|
||||
"remove-background": () => import("./commands/remove-background.js").then((m) => m.default),
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createAdaptorServer } from "@hono/node-server";
|
||||
import {
|
||||
createStudioApi,
|
||||
fileContentVersion,
|
||||
openProjectHistory,
|
||||
type StudioApiAdapter,
|
||||
} from "@hyperframes/studio-server";
|
||||
import { runCommand } from "citty";
|
||||
import { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { consumeCommandResult } from "../utils/commandResult.js";
|
||||
import { historyDeps } from "../utils/historyOwner.js";
|
||||
import historyCommand from "./history.js";
|
||||
|
||||
const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms));
|
||||
|
||||
const tracked = vi.hoisted(() => [] as Array<{ action: string; via: string }>);
|
||||
vi.mock("../telemetry/events.js", () => ({
|
||||
trackHistoryAction: (props: { action: string; via: string }) => tracked.push(props),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
tracked.length = 0;
|
||||
});
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A project of index.html "A" and notes.html "N", its history kept in a temp root, no preview running. */
|
||||
function project() {
|
||||
const dir = tempDir("hf-history-cli-");
|
||||
writeFileSync(join(dir, "index.html"), "A");
|
||||
writeFileSync(join(dir, "notes.html"), "N");
|
||||
historyDeps.historyRoot = tempDir("hf-history-cli-root-");
|
||||
historyDeps.findServer = async () => null;
|
||||
historyDeps.turnIdleMs = 60_000;
|
||||
const write = (path: string, text: string) => writeFileSync(join(dir, path), text);
|
||||
const read = (path: string) => readFileSync(join(dir, path), "utf-8");
|
||||
const files = () => [read("index.html"), read("notes.html")];
|
||||
async function hf(...args: string[]) {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
await runCommand(historyCommand, { rawArgs: [...args, "--dir", dir] });
|
||||
const out = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
const err = error.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
return { out, err, code: consumeCommandResult().exitCode };
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
}
|
||||
}
|
||||
const json = async (...args: string[]) => JSON.parse((await hf(...args, "--json")).out);
|
||||
/** One agent turn that writes `path`. */
|
||||
async function turn(name: string, label: string, path: string, text: string) {
|
||||
await hf("begin", "--who", name, "--label", label);
|
||||
write(path, text);
|
||||
return (await json("end")).entry;
|
||||
}
|
||||
return { dir, write, read, files, hf, json, turn };
|
||||
}
|
||||
|
||||
/** A running preview over the project's history, as `hyperframes preview` serves it. */
|
||||
async function preview(dir: string) {
|
||||
const history = await openProjectHistory({
|
||||
projectDir: dir,
|
||||
historyRoot: historyDeps.historyRoot,
|
||||
quietMs: 20,
|
||||
});
|
||||
const adapter = {
|
||||
listProjects: () => [],
|
||||
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
|
||||
history: () => history,
|
||||
} as unknown as StudioApiAdapter;
|
||||
const server = createAdaptorServer({
|
||||
fetch: new Hono().route("/api", createStudioApi(adapter)).fetch,
|
||||
});
|
||||
await new Promise<void>((ready) => server.listen(0, "127.0.0.1", ready));
|
||||
onTestFinished(async () => {
|
||||
server.close();
|
||||
await history.close();
|
||||
});
|
||||
const { port } = server.address() as AddressInfo;
|
||||
historyDeps.findServer = async () =>
|
||||
({
|
||||
port,
|
||||
host: "127.0.0.1",
|
||||
projectName: "demo",
|
||||
projectDir: dir,
|
||||
version: "test",
|
||||
pid: null,
|
||||
}) as never;
|
||||
return history;
|
||||
}
|
||||
|
||||
async function turnCutByStudio(
|
||||
dir: string,
|
||||
write: (path: string, text: string) => void,
|
||||
hf: (...args: string[]) => Promise<unknown>,
|
||||
) {
|
||||
const held = await preview(dir);
|
||||
const studio = { kind: "person", name: "You" } as const;
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
write("index.html", "A2");
|
||||
await held.claim(studio, "Nothing", []); // a claim scans first: the agent's write is seen
|
||||
write("index.html", "A3");
|
||||
await held.claim(studio, "Dragged Title", ["index.html"], {
|
||||
overwrote: { "index.html": fileContentVersion("A2") },
|
||||
});
|
||||
return held;
|
||||
}
|
||||
|
||||
describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => {
|
||||
async function setup() {
|
||||
const p = project();
|
||||
await p.hf(); // the first open records the baseline
|
||||
const held = mode === "preview" ? await preview(p.dir) : null;
|
||||
/** A person's edit, taken in as preview's file watcher would. */
|
||||
async function personWrites(path: string, text: string) {
|
||||
p.write(path, text);
|
||||
if (!held) return;
|
||||
held.noteChange(path);
|
||||
await vi.waitFor(() => expect(held.list().at(-1)?.files[0]?.path).toBe(path));
|
||||
}
|
||||
return { ...p, personWrites };
|
||||
}
|
||||
|
||||
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(Date.now() - 1).toISOString();
|
||||
await turn("claude", "Bigger title", "index.html", "A2");
|
||||
await personWrites("notes.html", "N2");
|
||||
|
||||
const both = (await json("--since", startedAt)).entries;
|
||||
expect(both.map((entry: { who: object; files: string[] }) => [entry.who, entry.files])).toEqual(
|
||||
[
|
||||
[{ kind: "outside", name: "Outside" }, ["notes.html"]],
|
||||
[{ kind: "agent", name: "claude" }, ["index.html"]],
|
||||
],
|
||||
);
|
||||
const mine = (await json("--since", "mine", "--who", "claude")).entries;
|
||||
expect(mine).toMatchObject([{ who: { name: "Outside" }, files: ["notes.html"] }]);
|
||||
|
||||
const undo = await hf("undo", "--who", "claude");
|
||||
expect(undo.code, undo.err).toBe(0);
|
||||
expect(files()).toEqual(["A", "N2"]);
|
||||
expect(tracked.map((event) => event.via)).toContain(mode);
|
||||
expect(tracked.map((event) => event.action)).toEqual(
|
||||
expect.arrayContaining(["begin", "end", "list", "undo"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("a turn left open ends after the idle limit: a person's later edit stays theirs through undo of the turn", async () => {
|
||||
const { write, hf, personWrites, files } = await setup();
|
||||
historyDeps.turnIdleMs = 300;
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
write("index.html", "A2");
|
||||
await pause(600);
|
||||
await personWrites("notes.html", "N2");
|
||||
|
||||
const undo = await hf("undo", "--who", "claude");
|
||||
expect(undo.code, undo.err).toBe(0);
|
||||
expect(files()).toEqual(["A", "N2"]);
|
||||
});
|
||||
|
||||
it("undo by one agent leaves another agent's open turn open", async () => {
|
||||
const { write, read, json, hf, turn } = await setup();
|
||||
await turn("gemini", "Notes", "notes.html", "N2");
|
||||
await hf("begin", "--who", "claude", "--label", "Bigger title");
|
||||
write("index.html", "A2");
|
||||
|
||||
expect((await hf("undo", "--who", "gemini")).code).toBe(0);
|
||||
expect(read("notes.html")).toBe("N");
|
||||
expect((await hf("end")).code).toBe(0);
|
||||
const claude = (await json()).entries.filter(
|
||||
(e: { who: { name: string } }) => e.who.name === "claude",
|
||||
);
|
||||
expect(claude).toMatchObject([{ files: ["index.html"] }]);
|
||||
});
|
||||
|
||||
it("undo of an id without --who is the person's, and leaves an agent's open turn open", async () => {
|
||||
const { write, json, hf, turn } = await setup();
|
||||
const notes = await turn("gemini", "Notes", "notes.html", "N2");
|
||||
await hf("begin", "--who", "claude", "--label", "Bigger title");
|
||||
write("index.html", "A2");
|
||||
|
||||
expect((await json("undo", notes.id)).entry.who).toEqual({ kind: "person", name: "You" });
|
||||
expect((await hf("end")).code).toBe(0);
|
||||
});
|
||||
|
||||
it("undo refuses a conflict with exit 2, names the newer entries and both choices, then takes one", async () => {
|
||||
const { read, hf, turn } = await setup();
|
||||
const first = await turn("claude", "First", "index.html", "A2");
|
||||
await turn("gemini", "Second", "index.html", "A3");
|
||||
|
||||
const refused = await hf("undo", first.id.slice(0, 8));
|
||||
expect(refused.code).toBe(2);
|
||||
expect(refused.out).toContain("Second");
|
||||
expect(refused.out).toMatch(/--just-this[\s\S]*--back-to-before/);
|
||||
expect(read("index.html")).toBe("A3");
|
||||
|
||||
expect((await hf("undo", first.id, "--just-this")).code).toBe(0);
|
||||
expect(read("index.html")).toBe("A");
|
||||
});
|
||||
|
||||
it("show lists an entry's files and --diff prints the text change", async () => {
|
||||
const { hf, turn } = await setup();
|
||||
const entry = await turn("claude", "Retitle", "index.html", "A2");
|
||||
expect((await hf("show", entry.id)).out).toContain("M index.html");
|
||||
const diff = (await hf("show", entry.id.slice(0, 8), "--diff")).out;
|
||||
expect(diff).toMatch(/-A\n\\ No newline at end of file\n\+A2/);
|
||||
});
|
||||
|
||||
it("peek reads a file as it was without writing, and restore puts every file back", async () => {
|
||||
const { read, hf, turn } = await setup();
|
||||
const entry = await turn("claude", "Retitle", "index.html", "A2");
|
||||
await turn("claude", "Again", "index.html", "A3");
|
||||
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
await hf("peek", entry.id, "index.html");
|
||||
expect(String(stdout.mock.calls[0]?.[0])).toBe("A2");
|
||||
stdout.mockRestore();
|
||||
expect(read("index.html")).toBe("A3");
|
||||
|
||||
expect((await hf("restore", "start")).code).toBe(0);
|
||||
expect(read("index.html")).toBe("A");
|
||||
});
|
||||
|
||||
it("pin marks an entry kept, --off unmarks it", async () => {
|
||||
const { json, hf, turn } = await setup();
|
||||
const entry = await turn("claude", "Retitle", "index.html", "A2");
|
||||
await hf("pin", entry.id);
|
||||
expect((await json()).entries[0]).toMatchObject({ id: entry.id, pinned: true });
|
||||
await hf("pin", entry.id, "--off");
|
||||
expect((await json()).entries[0]).toMatchObject({ pinned: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperframes history, one owner", () => {
|
||||
it("goes through a running preview, so the log keeps one baseline and every entry once", async () => {
|
||||
const { dir, json, turn } = project();
|
||||
const held = await preview(dir);
|
||||
await turn("claude", "Retitle", "index.html", "A2");
|
||||
await json();
|
||||
const log = readFileSync(join(historyDeps.historyRoot, held.projectId, "log.jsonl"), "utf-8");
|
||||
const records = log
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((row) => JSON.parse(row).type);
|
||||
expect(records).toEqual(["baseline", "entry"]);
|
||||
});
|
||||
|
||||
it("a turn begun through a preview that stopped without closing it is still the agent's", async () => {
|
||||
const { dir, write, json, hf } = project();
|
||||
await hf();
|
||||
const turn = {
|
||||
via: "preview",
|
||||
id: "turn-1",
|
||||
who: { kind: "agent", name: "claude" },
|
||||
label: "Retitle",
|
||||
startedAt: 1,
|
||||
lastWriteAt: Date.now(),
|
||||
};
|
||||
writeFileSync(join(dir, ".hyperframes", "history-turn.json"), JSON.stringify(turn));
|
||||
write("index.html", "A2");
|
||||
|
||||
expect((await json("end")).entry).toMatchObject({
|
||||
id: "turn-1",
|
||||
who: turn.who,
|
||||
files: ["index.html"],
|
||||
});
|
||||
});
|
||||
|
||||
it("undo --who also reverts the part of a turn that a Studio edit cut off", async () => {
|
||||
const { dir, write, hf, json, files } = project();
|
||||
await turnCutByStudio(dir, write, hf);
|
||||
write("notes.html", "N2");
|
||||
expect((await json("end")).parts).toHaveLength(2);
|
||||
|
||||
const undo = await hf("undo", "--who", "claude", "--just-this");
|
||||
expect(undo.code, undo.err).toBe(0);
|
||||
expect(files()).toEqual(["A", "N"]);
|
||||
});
|
||||
|
||||
it("undo --who reverts a turn a Studio edit cut off whole, after the preview stopped", async () => {
|
||||
const { dir, read, write, hf, json } = project();
|
||||
const held = await turnCutByStudio(dir, write, hf);
|
||||
await held.close();
|
||||
historyDeps.findServer = async () => null;
|
||||
expect((await json("end")).parts).toHaveLength(1);
|
||||
|
||||
const undo = await hf("undo", "--who", "claude", "--just-this");
|
||||
expect(undo.code, undo.err).toBe(0);
|
||||
expect(read("index.html")).toBe("A");
|
||||
});
|
||||
|
||||
it("a turn whose writes come closer together than the idle limit stays the agent's past it", async () => {
|
||||
const { write, json, hf } = project();
|
||||
await hf();
|
||||
historyDeps.turnIdleMs = 1000;
|
||||
// Each write 600 ms after the one before, past 1000 ms in all.
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
await pause(600);
|
||||
write("index.html", "2");
|
||||
await hf(); // a command mid-turn files the turn so far; the rest counts from index.html
|
||||
await pause(600);
|
||||
write("notes.html", "2");
|
||||
await pause(600);
|
||||
write("extra.html", "2");
|
||||
await hf("end");
|
||||
const entries = (await json()).entries.reverse();
|
||||
expect(
|
||||
entries.map((entry: { who: object; files: string[] }) => [entry.who, entry.files]),
|
||||
).toEqual([
|
||||
[{ kind: "agent", name: "claude" }, ["index.html"]],
|
||||
[{ kind: "agent", name: "claude" }, ["extra.html", "notes.html"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("undo --who refuses when the agent's last turn recorded nothing, and leaves its earlier turn alone", async () => {
|
||||
const { write, hf, turn, files } = project();
|
||||
await hf();
|
||||
historyDeps.turnIdleMs = 400;
|
||||
await turn("claude", "Earlier", "notes.html", "N2");
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
// The only change lands past the idle limit (the person's edit over the agent's), so it is Outside.
|
||||
await pause(700);
|
||||
write("index.html", "A2");
|
||||
|
||||
const refused = await hf("undo", "--who", "claude");
|
||||
expect([refused.code, refused.err]).toEqual([
|
||||
2,
|
||||
"claude's last turn has no change still in effect; undo an older entry by its id",
|
||||
]);
|
||||
expect(files()).toEqual(["A2", "N2"]);
|
||||
});
|
||||
|
||||
it("undo --who reverts every part of a turn that a mid-turn command split", async () => {
|
||||
const { write, hf, json, files } = project();
|
||||
await hf();
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
write("index.html", "A2");
|
||||
await hf(); // files the turn so far as its first part
|
||||
write("notes.html", "N2");
|
||||
expect((await json("end")).parts).toHaveLength(2);
|
||||
|
||||
const undo = await hf("undo", "--who", "claude");
|
||||
expect(undo.code, undo.err).toBe(0);
|
||||
expect(files()).toEqual(["A", "N"]);
|
||||
});
|
||||
|
||||
it("undo --who of a split turn with a conflict changes nothing and offers the choices for the whole turn", async () => {
|
||||
const { write, hf, files } = project();
|
||||
await hf();
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
write("index.html", "A2");
|
||||
await hf(); // files the turn so far as its first part
|
||||
write("notes.html", "N2");
|
||||
await hf("end");
|
||||
write("index.html", "A3"); // the person's edit over the first part
|
||||
|
||||
const refused = await hf("undo", "--who", "claude");
|
||||
expect(refused.code).toBe(2);
|
||||
expect(refused.out).toContain("hyperframes history undo --who claude --just-this");
|
||||
expect(files(), "no part stays undone").toEqual(["A3", "N2"]);
|
||||
|
||||
expect((await hf("undo", "--who", "claude", "--just-this")).code).toBe(0);
|
||||
expect(files()).toEqual(["A", "N"]);
|
||||
});
|
||||
|
||||
it("a turn marker with no last write time has ended, so the next edit is not the agent's", async () => {
|
||||
const { dir, write, json, hf } = project();
|
||||
await hf();
|
||||
const who = { kind: "agent", name: "claude" };
|
||||
const marker = { via: "direct", id: "turn-1", who, label: "Retitle", startedAt: 1 };
|
||||
writeFileSync(join(dir, ".hyperframes", "history-turn.json"), JSON.stringify(marker));
|
||||
write("index.html", "A2");
|
||||
|
||||
expect((await json()).entries[0]).toMatchObject({ who: { kind: "outside" } });
|
||||
});
|
||||
|
||||
it("a CLI run waits while another process holds the history, instead of forking its log", async () => {
|
||||
const { dir, write } = project();
|
||||
const home = tempDir("hf-history-cli-home-");
|
||||
const historyRoot = join(home, ".cache", "hyperframes", "history");
|
||||
const held = await openProjectHistory({ projectDir: dir, historyRoot });
|
||||
write("index.html", "A2");
|
||||
const cli = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts");
|
||||
const child = spawn("bun", ["run", cli, "history", "--dir", dir], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
HYPERFRAMES_SKIP_UPDATE_CHECK: "1",
|
||||
HYPERFRAMES_NO_TELEMETRY: "1",
|
||||
},
|
||||
});
|
||||
const exited = new Promise<number | null>((done) => child.on("exit", done));
|
||||
const logFile = join(historyRoot, held.projectId, "log.jsonl");
|
||||
await new Promise((settle) => setTimeout(settle, 1500));
|
||||
expect(
|
||||
readFileSync(logFile, "utf-8").trim().split("\n"),
|
||||
"nothing written while held",
|
||||
).toHaveLength(1);
|
||||
|
||||
await held.close(); // commits the outside edit once
|
||||
expect(await exited).toBe(0);
|
||||
const types = readFileSync(logFile, "utf-8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((row) => JSON.parse(row).type);
|
||||
expect(types).toEqual(["baseline", "entry"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperframes history, refusals", () => {
|
||||
it("undo --who refuses once the agent's newest entry is already undone", async () => {
|
||||
const { read, hf, turn } = project();
|
||||
await hf();
|
||||
await turn("claude", "Retitle", "index.html", "A2");
|
||||
expect((await hf("undo", "--who", "claude")).code).toBe(0);
|
||||
|
||||
const again = await hf("undo", "--who", "claude");
|
||||
expect([again.code, again.err]).toEqual([
|
||||
2,
|
||||
"claude's last turn has no change still in effect; undo an older entry by its id",
|
||||
]);
|
||||
expect(read("index.html")).toBe("A");
|
||||
});
|
||||
|
||||
it("restore refuses a ref that is neither an entry nor a date, instead of reading it as a time", async () => {
|
||||
const { read, write, hf, json, turn } = project();
|
||||
await hf();
|
||||
await turn("claude", "Retitle", "index.html", "A2");
|
||||
write("added.html", "new");
|
||||
const ids = (await json()).entries.map((entry: { id: string }) => entry.id);
|
||||
const digit = [..."0123456789"].find((d) => !ids.some((id: string) => id.startsWith(d)))!;
|
||||
|
||||
const refused = await hf("restore", digit);
|
||||
expect([refused.code, refused.err]).toEqual([2, `No entry "${digit}" in this history`]);
|
||||
expect((await hf("restore", "2026-02-31")).err).toBe('"2026-02-31" is not a date');
|
||||
expect(read("added.html")).toBe("new");
|
||||
});
|
||||
|
||||
it("--json refusals go to stdout as {ok: false, error}", async () => {
|
||||
const { hf } = project();
|
||||
await hf();
|
||||
const refused = await hf("show", "zzzz", "--json");
|
||||
expect(refused.code).toBe(2);
|
||||
expect(JSON.parse(refused.out)).toMatchObject({
|
||||
ok: false,
|
||||
error: 'No entry "zzzz" in this history',
|
||||
});
|
||||
expect((await hf("--limit", "0")).code).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses a folder with no index.html, and records nothing for it", async () => {
|
||||
project();
|
||||
const folder = tempDir("hf-history-cli-not-a-project-");
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(runCommand(historyCommand, { rawArgs: ["--dir", folder] })).rejects.toThrow();
|
||||
error.mockRestore();
|
||||
expect(readdirSync(historyDeps.historyRoot)).toEqual([]);
|
||||
});
|
||||
|
||||
it("exits 2 with the holder's pid when another process keeps the history past the wait", async () => {
|
||||
const { dir, hf } = project();
|
||||
await openProjectHistory({ projectDir: dir, historyRoot: historyDeps.historyRoot }).then(
|
||||
(held) => onTestFinished(() => held.close()),
|
||||
);
|
||||
const busy = await hf();
|
||||
expect([busy.code, busy.err]).toEqual([
|
||||
2,
|
||||
`This project's history is open in another process (pid ${process.pid}).`,
|
||||
]);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { defineCommand, type ArgsDef } from "citty";
|
||||
import {
|
||||
HISTORY_START,
|
||||
HistoryBusyError,
|
||||
type HistoryEntry,
|
||||
type HistoryListItem,
|
||||
type HistoryResult,
|
||||
type HistoryWho,
|
||||
} from "@hyperframes/studio-server";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { trackHistoryAction } from "../telemetry/events.js";
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import {
|
||||
Refusal,
|
||||
endTurn,
|
||||
lastTurnParts,
|
||||
withOwner as withHistoryOwner,
|
||||
writeTurn,
|
||||
type Owner,
|
||||
type Turn,
|
||||
type UndoMode,
|
||||
} from "../utils/historyOwner.js";
|
||||
import { AmbiguousPreviewServerError } from "../utils/studioSelectionClient.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
[
|
||||
"What changed since your last turn, and who changed it",
|
||||
"hyperframes history --since mine --who claude",
|
||||
],
|
||||
[
|
||||
"Label your writes as one entry",
|
||||
"hyperframes history begin --who claude --label 'Bigger title'",
|
||||
],
|
||||
["Undo your newest entry after a failed check", "hyperframes history undo --who claude"],
|
||||
];
|
||||
|
||||
const YOU: HistoryWho = { kind: "person", name: "You" };
|
||||
|
||||
/** The project's history for one subcommand, counted by action and by who kept it (preview or this process). */
|
||||
const withOwner = <T>(
|
||||
action: string,
|
||||
dir: string | undefined,
|
||||
task: (owner: Owner, turn: Turn | null, projectDir: string) => Promise<T>,
|
||||
) =>
|
||||
withHistoryOwner(dir, (owner, turn, projectDir) => {
|
||||
trackHistoryAction({ action, via: owner.via });
|
||||
return task(owner, turn, projectDir);
|
||||
});
|
||||
|
||||
/** An agent names itself with --who; without it the caller is the person, even during an agent's turn. */
|
||||
const whoOf = (name: string | undefined): HistoryWho => (name ? { kind: "agent", name } : YOU);
|
||||
|
||||
const short = (id: string) => id.slice(0, 8);
|
||||
|
||||
function lastIndex<T>(items: readonly T[], match: (item: T) => boolean): number {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) if (match(items[index]!)) return index;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** An entry id or a unique prefix of one. */
|
||||
function entryOf(entries: readonly HistoryListItem[], ref: string): HistoryListItem {
|
||||
const found = entries.filter((entry) => entry.id.startsWith(ref));
|
||||
if (found.length === 1) return found[0]!;
|
||||
throw new Refusal(
|
||||
found.length ? `"${ref}" matches ${found.length} entries` : `No entry "${ref}" in this history`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A local ISO date or date-time, when `ref` is no entry id (a date like 2026-09-24 also reads as hex). */
|
||||
function timeOf(entries: readonly HistoryListItem[], ref: string): number | null {
|
||||
if (!/^\d{4}-\d\d-\d\d(T.+)?$/.test(ref) || entries.some((entry) => entry.id.startsWith(ref)))
|
||||
return null;
|
||||
// A bare date would parse as UTC midnight; with a time it parses as local.
|
||||
const time = Date.parse(ref.includes("T") ? ref : `${ref}T00:00`);
|
||||
// Date.parse rolls 2026-02-31 over to March instead of refusing it.
|
||||
const [year, month, date] = ref.slice(0, 10).split("-").map(Number) as [number, number, number];
|
||||
const day = new Date(Date.UTC(year, month - 1, date));
|
||||
if (Number.isNaN(time) || day.getUTCMonth() !== month - 1 || day.getUTCDate() !== date)
|
||||
throw new Refusal(`"${ref}" is not a date`);
|
||||
return time;
|
||||
}
|
||||
|
||||
/** A point is START, an entry id, or a time (the files right after the newest entry by then). */
|
||||
function pointOf(entries: readonly HistoryListItem[], ref: string): string {
|
||||
if (ref === HISTORY_START) return ref;
|
||||
const time = timeOf(entries, ref);
|
||||
if (time === null) return entryOf(entries, ref).id;
|
||||
return entries[lastIndex(entries, (entry) => entry.endedAt <= time)]?.id ?? HISTORY_START;
|
||||
}
|
||||
|
||||
function since(entries: HistoryListItem[], ref: string, who: HistoryWho): HistoryListItem[] {
|
||||
if (ref === "mine")
|
||||
return entries.slice(lastIndex(entries, (entry) => entry.who.name === who.name) + 1);
|
||||
const time = timeOf(entries, ref);
|
||||
if (time !== null) return entries.filter((entry) => entry.endedAt > time);
|
||||
return entries.slice(entries.indexOf(entryOf(entries, ref)) + 1);
|
||||
}
|
||||
|
||||
function ago(at: number): string {
|
||||
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
||||
if (seconds < 86_400) return `${Math.round(seconds / 3600)}h ago`;
|
||||
return `${Math.round(seconds / 86_400)}d ago`;
|
||||
}
|
||||
|
||||
function line(entry: HistoryListItem | HistoryEntry): string {
|
||||
const paths = entry.files.map((file) => file.path);
|
||||
const files = paths.slice(0, 2).join(", ") + (paths.length > 2 ? ` +${paths.length - 2}` : "");
|
||||
const flags =
|
||||
"undone" in entry ? `${entry.undone ? " [undone]" : ""}${entry.pinned ? " [pinned]" : ""}` : "";
|
||||
return `${short(entry.id)} ${ago(entry.endedAt)} ${entry.who.name} ${entry.label} (${files})${flags}`;
|
||||
}
|
||||
|
||||
/** The public entry shape: files as paths, no hashes. */
|
||||
const publicEntry = (entry: HistoryEntry & Partial<HistoryListItem>) => ({
|
||||
id: entry.id,
|
||||
who: entry.who,
|
||||
label: entry.label,
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt,
|
||||
files: entry.files.map((file) => file.path),
|
||||
undone: entry.undone ?? false,
|
||||
pinned: entry.pinned ?? false,
|
||||
});
|
||||
|
||||
const changeOf = (file: HistoryEntry["files"][number]) =>
|
||||
file.before === null ? "added" : file.after === null ? "deleted" : "modified";
|
||||
|
||||
function print(json: boolean, data: object, text: string): void {
|
||||
console.log(json ? JSON.stringify(withMeta(data), null, 2) : text);
|
||||
}
|
||||
|
||||
async function textDiff(owner: Owner, entry: HistoryEntry): Promise<string> {
|
||||
const work = mkdtempSync(join(tmpdir(), "hf-history-diff-"));
|
||||
try {
|
||||
const out: string[] = [];
|
||||
for (const file of entry.files) {
|
||||
const sides = await Promise.all(
|
||||
[file.before, file.after].map((hash) => (hash ? owner.blob(hash) : Buffer.alloc(0))),
|
||||
);
|
||||
if (sides.some((bytes) => bytes.subarray(0, 8000).includes(0))) {
|
||||
out.push(`Binary ${file.path} changed`);
|
||||
continue;
|
||||
}
|
||||
const [a, b] = ["a", "b"].map((side, index) => {
|
||||
const path = join(side, file.path);
|
||||
mkdirSync(dirname(join(work, path)), { recursive: true });
|
||||
writeFileSync(join(work, path), sides[index]!);
|
||||
return path;
|
||||
});
|
||||
const diff = spawnSync("git", ["diff", "--no-index", "--no-color", "--no-prefix", a!, b!], {
|
||||
cwd: work,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (diff.error) throw new Refusal("--diff needs git on PATH");
|
||||
out.push(diff.stdout.trimEnd());
|
||||
}
|
||||
return out.join("\n");
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** `again` is the command to run once more with a choice: `undo <id>`, or `undo --who <name>` for a whole turn. */
|
||||
function conflictText(
|
||||
entry: HistoryListItem,
|
||||
newer: HistoryListItem[],
|
||||
files: string[],
|
||||
again: string,
|
||||
): string {
|
||||
return [
|
||||
`${files.join(", ")} changed since "${entry.label}". Newer entries:`,
|
||||
...newer.map((later) => ` ${line(later)}`),
|
||||
`Choose one:`,
|
||||
` hyperframes history ${again} --just-this put back only its files, over the newer edits to them`,
|
||||
` hyperframes history ${again} --back-to-before go back to before it, undoing the newer entries too`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* What `undo --who` reverts: every entry of the agent's last turn still in effect, newest first. An agent that never
|
||||
* ended a turn here gets its newest entry.
|
||||
*/
|
||||
function turnTargets(entries: HistoryListItem[], who: HistoryWho, dir: string): HistoryListItem[] {
|
||||
const inEffect = (entry: HistoryListItem) => !entry.undone && !entry.undoes;
|
||||
const parts = lastTurnParts(dir, who.name);
|
||||
const newest = entries[lastIndex(entries, (e) => e.who.name === who.name && inEffect(e))];
|
||||
const targets = parts
|
||||
? entries.filter((entry) => parts.includes(entry.id) && inEffect(entry)).reverse()
|
||||
: newest
|
||||
? [newest]
|
||||
: [];
|
||||
if (!targets.length)
|
||||
throw new Refusal(
|
||||
parts
|
||||
? `${who.name}'s last turn has no change still in effect; undo an older entry by its id`
|
||||
: `${who.name} has no entry still in effect`,
|
||||
);
|
||||
return targets;
|
||||
}
|
||||
|
||||
type Undone = { target: HistoryListItem; undo: HistoryEntry };
|
||||
type Refused = { target: HistoryListItem; result: Extract<HistoryResult, { ok: false }> };
|
||||
|
||||
/** A turn is undone whole or not at all: on a conflict, the parts this run already undid are redone. */
|
||||
async function undoWhole(
|
||||
owner: Owner,
|
||||
targets: HistoryListItem[],
|
||||
who: HistoryWho,
|
||||
mode: UndoMode | undefined,
|
||||
): Promise<{ undid: Undone[]; refused?: Refused }> {
|
||||
const undid: Undone[] = [];
|
||||
for (const target of targets) {
|
||||
const result = await owner.undo(target.id, who, mode);
|
||||
if (!result.ok) {
|
||||
for (const done of undid.reverse()) await owner.undo(done.undo.id, who);
|
||||
return { undid: [], refused: { target, result } };
|
||||
}
|
||||
if (result.entry) undid.push({ target, undo: result.entry });
|
||||
}
|
||||
return { undid };
|
||||
}
|
||||
|
||||
async function runUndo(args: {
|
||||
ref?: string;
|
||||
who?: string;
|
||||
justThis?: boolean;
|
||||
backToBefore?: boolean;
|
||||
dir?: string;
|
||||
json: boolean;
|
||||
}): Promise<void> {
|
||||
const mode: UndoMode | undefined = args.justThis
|
||||
? "just-this"
|
||||
: args.backToBefore
|
||||
? "back-to-before"
|
||||
: undefined;
|
||||
await withOwner("undo", args.dir, async (owner, turn, projectDir) => {
|
||||
const who = whoOf(args.who);
|
||||
// Undo ends the caller's own open turn first, so its entries can be undone.
|
||||
if (turn?.who.name === who.name) await endTurn(owner, turn, projectDir);
|
||||
const entries = await owner.list();
|
||||
const targets = args.ref ? [entryOf(entries, args.ref)] : turnTargets(entries, who, projectDir);
|
||||
const { undid, refused } = await undoWhole(owner, targets, who, mode);
|
||||
if (refused) {
|
||||
const { target, result } = refused;
|
||||
setCommandExitCode(2);
|
||||
const now = await owner.list();
|
||||
const newer = result.conflict.newer.map((id) => entryOf(now, id));
|
||||
const again = args.ref ? `undo ${short(target.id)}` : `undo --who ${who.name}`;
|
||||
return print(args.json, result, conflictText(target, newer, result.conflict.files, again));
|
||||
}
|
||||
print(
|
||||
args.json,
|
||||
{
|
||||
ok: true,
|
||||
entry: undid[0] ? publicEntry(undid[0].undo) : null,
|
||||
entries: undid.map((done) => publicEntry(done.undo)),
|
||||
},
|
||||
undid.map((done) => `Undid ${short(done.target.id)}: ${line(done.undo)}`).join("\n") ||
|
||||
"Nothing to undo.",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Refusals are the user's to fix: one line on stderr (or JSON on stdout) and exit 2, never a stack. */
|
||||
function guarded<A>(run: (args: A) => Promise<void>) {
|
||||
return async (args: A) => {
|
||||
try {
|
||||
await run(args);
|
||||
} catch (error) {
|
||||
const refused =
|
||||
error instanceof Refusal ||
|
||||
error instanceof AmbiguousPreviewServerError ||
|
||||
error instanceof HistoryBusyError;
|
||||
if (!refused) throw error;
|
||||
setCommandExitCode(2);
|
||||
const { message } = error as Error;
|
||||
if ((args as { json?: boolean }).json) print(true, { ok: false, error: message }, message);
|
||||
else console.error(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const common = {
|
||||
dir: { type: "string", description: "Project directory (default: current)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
} as const;
|
||||
|
||||
const sub = <A>(
|
||||
name: string,
|
||||
description: string,
|
||||
args: ArgsDef,
|
||||
run: (args: A) => Promise<void>,
|
||||
) =>
|
||||
defineCommand({
|
||||
meta: { name, description },
|
||||
args: { ...common, ...args },
|
||||
run: ({ args: parsed }) => guarded(run)(parsed as A),
|
||||
});
|
||||
|
||||
const listEntries = async (args: {
|
||||
_?: string[];
|
||||
since?: string;
|
||||
who?: string;
|
||||
limit?: string;
|
||||
dir?: string;
|
||||
json: boolean;
|
||||
}) => {
|
||||
if (args._?.[0]) return;
|
||||
await withOwner("list", args.dir, async (owner) => {
|
||||
const all = await owner.list();
|
||||
const picked = args.since ? since(all, args.since, whoOf(args.who)) : all;
|
||||
const limit = Number(args.limit ?? 20);
|
||||
if (!Number.isInteger(limit) || limit < 1)
|
||||
throw new Refusal("--limit takes a whole number above 0");
|
||||
const shown = picked.slice(-limit).reverse();
|
||||
const more = picked.length - shown.length;
|
||||
const text = shown.map(line).join("\n") || "No changes recorded.";
|
||||
print(
|
||||
args.json,
|
||||
{ entries: shown.map(publicEntry) },
|
||||
more ? `${text}\n(${more} older; --limit)` : text,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "history", description: "List, undo and restore the project's recorded changes" },
|
||||
args: {
|
||||
...common,
|
||||
since: { type: "string", description: "Only entries after an entry id, a time, or 'mine'" },
|
||||
who: { type: "string", description: "Your name, for --since mine" },
|
||||
limit: { type: "string", description: "Newest entries to show (default 20)" },
|
||||
},
|
||||
subCommands: {
|
||||
show: () =>
|
||||
sub<{ ref: string; diff: boolean; dir?: string; json: boolean }>(
|
||||
"show",
|
||||
"One entry's files, before and after",
|
||||
{ ref: { type: "positional", required: true }, diff: { type: "boolean", default: false } },
|
||||
(args) =>
|
||||
withOwner("show", args.dir, async (owner) => {
|
||||
const entry = entryOf(await owner.list(), args.ref);
|
||||
const changes = entry.files.map((file) => ({
|
||||
path: file.path,
|
||||
change: changeOf(file),
|
||||
}));
|
||||
const diff = args.diff ? await textDiff(owner, entry) : undefined;
|
||||
const marks = changes.map((file) => ` ${file.change[0]!.toUpperCase()} ${file.path}`);
|
||||
print(
|
||||
args.json,
|
||||
{ entry: publicEntry(entry), changes, ...(diff !== undefined && { diff }) },
|
||||
`${line(entry)}\n${diff ?? marks.join("\n")}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
undo: () =>
|
||||
sub(
|
||||
"undo",
|
||||
"Undo an entry (default: your newest one still in effect)",
|
||||
{
|
||||
ref: { type: "positional", required: false },
|
||||
who: { type: "string", description: "Your name" },
|
||||
"just-this": { type: "boolean", default: false },
|
||||
"back-to-before": { type: "boolean", default: false },
|
||||
},
|
||||
runUndo,
|
||||
),
|
||||
restore: () =>
|
||||
sub<{ ref: string; who?: string; dir?: string; json: boolean }>(
|
||||
"restore",
|
||||
"Make the files what they were right after an entry, a time, or 'start'",
|
||||
{ ref: { type: "positional", required: true }, who: { type: "string" } },
|
||||
(args) =>
|
||||
withOwner("restore", args.dir, async (owner) => {
|
||||
const entry = await owner.restore(
|
||||
pointOf(await owner.list(), args.ref),
|
||||
whoOf(args.who),
|
||||
);
|
||||
print(
|
||||
args.json,
|
||||
{ entry: entry && publicEntry(entry) },
|
||||
entry ? `Restored: ${line(entry)}` : "Already there.",
|
||||
);
|
||||
}),
|
||||
),
|
||||
peek: () =>
|
||||
sub<{ ref: string; path?: string; dir?: string; json: boolean }>(
|
||||
"peek",
|
||||
"The files at an entry, a time, or 'start' (read-only); with a path, that file",
|
||||
{
|
||||
ref: { type: "positional", required: true },
|
||||
path: { type: "positional", required: false },
|
||||
},
|
||||
(args) =>
|
||||
withOwner("peek", args.dir, async (owner) => {
|
||||
const files = await owner.peek(pointOf(await owner.list(), args.ref));
|
||||
if (!files) throw new Refusal("That point is no longer kept");
|
||||
if (!args.path) return print(args.json, { files }, Object.keys(files).join("\n"));
|
||||
const hash = files[args.path];
|
||||
if (!hash) throw new Refusal(`${args.path} did not exist then`);
|
||||
process.stdout.write(await owner.blob(hash));
|
||||
}),
|
||||
),
|
||||
pin: () =>
|
||||
sub<{ ref: string; off: boolean; dir?: string; json: boolean }>(
|
||||
"pin",
|
||||
"Keep an entry (and what it needs) when old history is trimmed",
|
||||
{ ref: { type: "positional", required: true }, off: { type: "boolean", default: false } },
|
||||
(args) =>
|
||||
withOwner("pin", args.dir, async (owner) => {
|
||||
const entry = entryOf(await owner.list(), args.ref);
|
||||
await owner.pin(entry.id, !args.off);
|
||||
print(
|
||||
args.json,
|
||||
{ id: entry.id, pinned: !args.off },
|
||||
`${args.off ? "Unpinned" : "Pinned"} ${short(entry.id)}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
begin: () =>
|
||||
sub<{ who: string; label: string; dir?: string; json: boolean }>(
|
||||
"begin",
|
||||
"Start a turn: your writes until `end` become one entry under your name",
|
||||
{ who: { type: "string", required: true }, label: { type: "string", required: true } },
|
||||
(args) =>
|
||||
withOwner("begin", args.dir, async (owner, turn, projectDir) => {
|
||||
if (turn) await endTurn(owner, turn, projectDir);
|
||||
const who = whoOf(args.who);
|
||||
const { id, startedAt } = await owner.begin(who, args.label);
|
||||
writeTurn(projectDir, {
|
||||
via: owner.via,
|
||||
id,
|
||||
who,
|
||||
label: args.label,
|
||||
startedAt,
|
||||
lastWriteAt: startedAt,
|
||||
parts: [],
|
||||
});
|
||||
print(args.json, { entryId: id }, short(id));
|
||||
}),
|
||||
),
|
||||
end: () =>
|
||||
sub<{ dir?: string; json: boolean }>("end", "End your turn and print its entry", {}, (args) =>
|
||||
withOwner("end", args.dir, async (owner, turn, projectDir) => {
|
||||
if (!turn) throw new Refusal("No turn is open; start one with history begin");
|
||||
const { entry, parts } = await endTurn(owner, turn, projectDir);
|
||||
const earlier = parts.length - (entry ? 1 : 0);
|
||||
const text = entry
|
||||
? line(entry)
|
||||
: earlier
|
||||
? "Nothing changed since this turn's last command."
|
||||
: "Nothing changed in this turn.";
|
||||
print(
|
||||
args.json,
|
||||
{ entry: entry && publicEntry(entry), parts },
|
||||
earlier ? `${text}\n(+${earlier} earlier part${earlier > 1 ? "s" : ""})` : text,
|
||||
);
|
||||
}),
|
||||
),
|
||||
},
|
||||
run: ({ args }) => guarded(listEntries)(args),
|
||||
});
|
||||
@@ -58,6 +58,7 @@ const GROUPS: Group[] = [
|
||||
["info", "Print project metadata"],
|
||||
["compositions", "List all compositions in a project"],
|
||||
["timeline", "Print the project's tracks and clips"],
|
||||
["history", "List, undo and restore the project's recorded changes"],
|
||||
["docs", "View inline documentation in the terminal"],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -964,6 +964,10 @@ export function trackCompareSheet(props: {
|
||||
});
|
||||
}
|
||||
|
||||
export function trackHistoryAction(props: { action: string; via: "preview" | "direct" }): void {
|
||||
trackEvent("cli_history", props);
|
||||
}
|
||||
|
||||
// A skills install was skipped because a required prerequisite binary is
|
||||
// absent from PATH (e.g. git on a fresh Windows box). Best-effort callers
|
||||
// (init) skip cleanly rather than crash, so the skip is otherwise invisible;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
DEFAULT_HISTORY_ROOT,
|
||||
MAX_WINDOW_IDLE_MS,
|
||||
openProjectHistory,
|
||||
type HistoryEntry,
|
||||
type HistoryListItem,
|
||||
type HistoryResult,
|
||||
type HistoryWho,
|
||||
} from "@hyperframes/studio-server";
|
||||
import { resolveProject } from "./project.js";
|
||||
import { findPreviewServerForProject, studioApiUrl } from "./studioSelectionClient.js";
|
||||
|
||||
export type UndoMode = "just-this" | "back-to-before";
|
||||
|
||||
/** An agent's turn: its writes until `end`, or until the idle limit passes without one, are one entry of its own. */
|
||||
export interface Turn {
|
||||
via: "preview" | "direct";
|
||||
id: string;
|
||||
who: HistoryWho;
|
||||
label: string;
|
||||
startedAt: number;
|
||||
lastWriteAt: number;
|
||||
/** Entries this turn already filed: without a preview, each command mid-turn files the turn so far. */
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
/** Whoever holds the project's history: a running preview (over its routes) or this process (the engine). */
|
||||
export interface Owner {
|
||||
via: "preview" | "direct";
|
||||
list(): Promise<HistoryListItem[]>;
|
||||
peek(point: string): Promise<Record<string, string> | null>;
|
||||
blob(hash: string): Promise<Buffer>;
|
||||
undo(id: string, who: HistoryWho, mode?: UndoMode): Promise<HistoryResult>;
|
||||
restore(point: string, who: HistoryWho): Promise<HistoryEntry | null>;
|
||||
pin(id: string, pinned: boolean): Promise<void>;
|
||||
begin(who: HistoryWho, label: string): Promise<{ id: string; startedAt: number }>;
|
||||
end(id: string): Promise<HistoryEntry | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Swapped by tests. */
|
||||
export const historyDeps = {
|
||||
historyRoot: DEFAULT_HISTORY_ROOT,
|
||||
findServer: (projectDir: string) => findPreviewServerForProject(projectDir),
|
||||
/** A turn with no write for this long has ended, through a preview or not. */
|
||||
turnIdleMs: MAX_WINDOW_IDLE_MS,
|
||||
};
|
||||
|
||||
/** A refusal is the caller's to fix: commands print its message, never a stack. */
|
||||
export class Refusal extends Error {}
|
||||
|
||||
const turnFile = (dir: string) => join(dir, ".hyperframes", "history-turn.json");
|
||||
|
||||
/** The open turn, or null; a marker with no last write time cannot say when the turn ended, so it has. */
|
||||
function readTurn(dir: string): Turn | null {
|
||||
try {
|
||||
const turn = JSON.parse(readFileSync(turnFile(dir), "utf-8")) as Turn;
|
||||
return typeof turn.lastWriteAt === "number" ? { ...turn, parts: turn.parts ?? [] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTurn(dir: string, turn: Turn | null): void {
|
||||
if (!turn) return rmSync(turnFile(dir), { force: true });
|
||||
mkdirSync(dirname(turnFile(dir)), { recursive: true });
|
||||
writeFileSync(turnFile(dir), JSON.stringify(turn));
|
||||
}
|
||||
|
||||
/** Each agent's last ended turn, as the entries it filed; kept beside the marker, outside the history. */
|
||||
const lastTurnsFile = (dir: string) => join(dir, ".hyperframes", "history-turns.json");
|
||||
|
||||
function readLastTurns(dir: string): Record<string, string[]> {
|
||||
try {
|
||||
const turns: unknown = JSON.parse(readFileSync(lastTurnsFile(dir), "utf-8"));
|
||||
return turns && typeof turns === "object" ? (turns as Record<string, string[]>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The entries of `name`'s last ended turn; null when `name` never ended a turn here. */
|
||||
export function lastTurnParts(dir: string, name: string): string[] | null {
|
||||
return readLastTurns(dir)[name] ?? null;
|
||||
}
|
||||
|
||||
type TurnKey = Pick<HistoryEntry, "who" | "label" | "startedAt">;
|
||||
const samePart = (a: TurnKey, b: TurnKey) =>
|
||||
a.who.kind === b.who.kind &&
|
||||
a.who.name === b.who.name &&
|
||||
a.label === b.label &&
|
||||
a.startedAt === b.startedAt;
|
||||
|
||||
/** Ends an open turn: its last part is the entry `end` returns, and every part is kept as its agent's last turn. */
|
||||
export async function endTurn(
|
||||
owner: Owner,
|
||||
turn: Turn,
|
||||
dir: string,
|
||||
): Promise<{ entry: HistoryEntry | null; parts: string[] }> {
|
||||
const entry = await owner.end(turn.id);
|
||||
// A Studio claim can cut a turn: each cut part is an entry with the turn's who, label and start.
|
||||
const cut = (await owner.list()).filter((other) => samePart(other, entry ?? turn));
|
||||
const own = entry ? [entry.id] : [];
|
||||
const parts = [...new Set([...turn.parts, ...cut.map((part) => part.id), ...own])];
|
||||
// Recorded before the marker goes, so a crash between the two never leaves the older turn as the last one.
|
||||
const draft = `${lastTurnsFile(dir)}.${process.pid}.tmp`;
|
||||
mkdirSync(dirname(draft), { recursive: true });
|
||||
writeFileSync(draft, JSON.stringify({ ...readLastTurns(dir), [turn.who.name]: parts }));
|
||||
renameSync(draft, lastTurnsFile(dir));
|
||||
writeTurn(dir, null);
|
||||
return { entry, parts };
|
||||
}
|
||||
|
||||
function previewOwner(route: (path: string) => string): Owner {
|
||||
const call = async (path: string, body?: object) => {
|
||||
const init = body && {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
const response = await fetch(route(path), init);
|
||||
if (response.ok) return response;
|
||||
const error = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Refusal(error?.error ?? `The preview answered ${response.status}.`);
|
||||
};
|
||||
const json = async (path: string, body?: object) => (await call(path, body)).json();
|
||||
const list = async () => (await json("")).entries as HistoryListItem[];
|
||||
return {
|
||||
via: "preview",
|
||||
list,
|
||||
peek: async (point) => (await json(`/peek/${encodeURIComponent(point)}`)).files,
|
||||
blob: async (hash) => Buffer.from(await (await call(`/blob/${hash}`)).arrayBuffer()),
|
||||
undo: (id, who, mode) => json("/undo", { entryId: id, who, mode }),
|
||||
restore: (point, who) => json("/restore", { point, who }),
|
||||
pin: async (id, pinned) => void (await json("/pin", { entryId: id, pinned })),
|
||||
begin: async (who, label) => {
|
||||
const opened = await json("/window", { who, label, idleMs: historyDeps.turnIdleMs });
|
||||
return { id: opened.windowId, startedAt: opened.startedAt ?? Date.now() };
|
||||
},
|
||||
// A preview restarted since begin committed the window on its way down.
|
||||
end: (id) =>
|
||||
json(`/window/${id}/close`, {})
|
||||
.then((closed) => closed.entry as HistoryEntry | null)
|
||||
.catch(async () => (await list()).find((entry) => entry.id === id) ?? null),
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function directOwner(projectDir: string, turn: Turn | null): Promise<Owner> {
|
||||
const history = await openProjectHistory({
|
||||
projectDir,
|
||||
historyRoot: historyDeps.historyRoot,
|
||||
// A turn begun through a preview that has since stopped is still the agent's, until its idle limit.
|
||||
...(turn && {
|
||||
closedWindow: {
|
||||
id: turn.id,
|
||||
who: turn.who,
|
||||
label: turn.label,
|
||||
startedAt: turn.startedAt,
|
||||
lastWriteAt: turn.lastWriteAt,
|
||||
idleMs: historyDeps.turnIdleMs,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return {
|
||||
via: "direct",
|
||||
list: async () => history.list(),
|
||||
peek: async (point) => history.peek(point),
|
||||
blob: (hash) => history.readBlob(hash),
|
||||
undo: (id, who, mode) => history.undo(id, { who, ...(mode && { mode }) }),
|
||||
restore: (point, who) => history.restore(point, who),
|
||||
pin: async (id, pinned) => history.pin(id, pinned),
|
||||
// Opening filed every earlier write; the turn's own writes are filed to it when the next open passes it.
|
||||
begin: async () => ({ id: randomUUID(), startedAt: Date.now() }),
|
||||
end: async (id) => history.list().find((entry) => entry.id === id) ?? null,
|
||||
close: () => history.close(),
|
||||
};
|
||||
}
|
||||
|
||||
/** One owner: a preview that keeps this project's history, else the engine, which refuses a second opener. */
|
||||
async function connect(projectDir: string, turn: Turn | null): Promise<Owner> {
|
||||
const server = await historyDeps.findServer(projectDir);
|
||||
if (server) {
|
||||
const route = (path: string) => studioApiUrl(server, `history${path}`);
|
||||
// 404, or gone since the scan: no preview keeps this history, and the engine's lock guards the rest.
|
||||
const status = await fetch(route("")).then(
|
||||
(response) => response.status,
|
||||
() => 404,
|
||||
);
|
||||
if (status !== 404) return previewOwner(route);
|
||||
}
|
||||
return directOwner(projectDir, turn);
|
||||
}
|
||||
|
||||
/** Runs `task` with the project's history. ponytail: with no preview, a command mid-turn splits the turn in two. */
|
||||
export async function withOwner<T>(
|
||||
dir: string | undefined,
|
||||
task: (owner: Owner, turn: Turn | null, projectDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { dir: projectDir } = resolveProject(dir);
|
||||
const turn = readTurn(projectDir);
|
||||
const owner = await connect(projectDir, turn);
|
||||
try {
|
||||
return await task(owner, turn, projectDir);
|
||||
} finally {
|
||||
// This open filed the turn so far under its id; the turn goes on under a fresh one, from its last write.
|
||||
const direct = owner.via === "direct" && turn;
|
||||
const kept = direct && (await owner.list()).find((entry) => entry.id === turn.id);
|
||||
await owner.close();
|
||||
if (direct && readTurn(projectDir)?.id === turn.id) {
|
||||
const lastWriteAt = kept ? kept.endedAt : turn.lastWriteAt;
|
||||
const parts = kept ? [...turn.parts, kept.id] : turn.parts;
|
||||
writeTurn(projectDir, { ...turn, via: "direct", id: randomUUID(), lastWriteAt, parts });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
@@ -20,6 +22,7 @@ import { openProjectHistory, type ProjectHistory } from "./projectHistory";
|
||||
import { START, type HistoryWho } from "./historyLog";
|
||||
|
||||
const you: HistoryWho = { kind: "person", name: "You" };
|
||||
const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms));
|
||||
const agent: HistoryWho = { kind: "agent", name: "Agent" };
|
||||
const cleanup: Array<() => unknown> = [];
|
||||
|
||||
@@ -203,6 +206,153 @@ describe("openProjectHistory", () => {
|
||||
expect((await waiting).projectId).toBe(history.projectId);
|
||||
});
|
||||
|
||||
it("files what changed while closed to a window begun on an earlier open, under its id", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await history.close();
|
||||
write("index.html", "v2");
|
||||
const closedWindow = {
|
||||
id: "turn-1",
|
||||
who: agent,
|
||||
label: "Bigger title",
|
||||
startedAt: 1,
|
||||
lastWriteAt: Date.now(),
|
||||
idleMs: 60_000,
|
||||
};
|
||||
|
||||
const reopened = await open(projectDir, historyRoot, { closedWindow });
|
||||
expect(reopened.list()).toMatchObject([{ id: "turn-1", who: agent, label: "Bigger title" }]);
|
||||
await reopened.close();
|
||||
|
||||
write("index.html", "v3");
|
||||
const again = await open(projectDir, historyRoot, { closedWindow });
|
||||
expect(
|
||||
again.list().map((entry) => entry.who),
|
||||
"an id already kept is not reused",
|
||||
).toEqual([agent, { kind: "outside", name: "Outside" }]);
|
||||
});
|
||||
|
||||
it("files to a closed window only the writes before its idle limit ran out, counting from each write", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({
|
||||
"a.html": "a1",
|
||||
"b.html": "b1",
|
||||
"c.html": "c1",
|
||||
});
|
||||
await history.close();
|
||||
// A file's change time is its ctime, which only the clock sets: the writes are spaced in real time.
|
||||
const lastWriteAt = Date.now();
|
||||
await pause(300);
|
||||
write("a.html", "2");
|
||||
await pause(300);
|
||||
write("b.html", "2"); // 600 ms after the window's last write, but 300 ms after a.html: still the window's
|
||||
await pause(700);
|
||||
write("c.html", "2"); // 700 ms without a write: the window had ended
|
||||
const closedWindow = { id: "turn-1", who: agent, label: "Turn", startedAt: 1, lastWriteAt };
|
||||
|
||||
const reopened = await open(projectDir, historyRoot, {
|
||||
closedWindow: { ...closedWindow, idleMs: 400 },
|
||||
});
|
||||
const [turn, outside] = reopened.list();
|
||||
expect([turn, outside].map((entry) => entry?.files.map((file) => file.path))).toEqual([
|
||||
["a.html", "b.html"],
|
||||
["c.html"],
|
||||
]);
|
||||
expect(outside?.who.kind).toBe("outside");
|
||||
const onDisk = readFileSync(join(historyRoot, reopened.projectId, "log.jsonl"), "utf-8");
|
||||
expect(Object.keys(JSON.parse(onDisk.trim().split("\n")[1]!).entry).sort()).toEqual([
|
||||
"endedAt",
|
||||
"files",
|
||||
"id",
|
||||
"label",
|
||||
"startedAt",
|
||||
"who",
|
||||
]);
|
||||
});
|
||||
|
||||
it("times each write by its file: oldest first, a copy that keeps an old mtime as now, a removal as now", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({
|
||||
"a.html": "a1",
|
||||
"b.html": "b1",
|
||||
"c.html": "c1",
|
||||
});
|
||||
await history.close();
|
||||
const writeAt = (path: string, text: string, at: number) => {
|
||||
write(path, text);
|
||||
utimesSync(join(projectDir, path), at / 1000, at / 1000);
|
||||
};
|
||||
const turn = (id: string, lastWriteAt: number) => ({
|
||||
closedWindow: { id, who: agent, label: "Turn", startedAt: 1, lastWriteAt, idleMs: 400 },
|
||||
});
|
||||
|
||||
// b.html comes first in time though not by name: taken in order, both are the window's.
|
||||
const first = Date.now();
|
||||
await pause(300);
|
||||
write("b.html", "b2");
|
||||
await pause(300);
|
||||
write("a.html", "a2"); // past the limit from the window's last write, so it counts only after b.html
|
||||
const lastWrite = statSync(join(projectDir, "a.html")).ctimeMs;
|
||||
const reopened = await open(projectDir, historyRoot, turn("turn-1", first));
|
||||
const [kept] = reopened.list();
|
||||
expect(kept?.files.map((file) => file.path)).toEqual(["a.html", "b.html"]);
|
||||
expect(Math.abs(kept!.endedAt - lastWrite), "ends at its last write").toBeLessThan(2);
|
||||
await reopened.close();
|
||||
|
||||
// Long after the window's last write: an old mtime does not hide a write made now, nor does a removal.
|
||||
const stale = Date.now() - 5000;
|
||||
writeAt("a.html", "a3", stale + 100);
|
||||
rmSync(join(projectDir, "c.html"));
|
||||
const again = await open(projectDir, historyRoot, turn("turn-2", stale));
|
||||
expect(again.list().at(-1)).toMatchObject({
|
||||
who: { kind: "outside" },
|
||||
files: [{ path: "a.html" }, { path: "c.html" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("logs an agent window past its idle limit before a later Studio edit that claims its file", async () => {
|
||||
const { history, write } = await project({ "index.html": "A" });
|
||||
// The idle timer stays asleep (a laptop lid closed) while real time passes the limit.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
const window = await history.beginWindow(agent, "Retitle", { idleMs: 50 });
|
||||
write("index.html", "A2");
|
||||
await history.claim(you, "Nothing", []); // a claim scans first: the agent's write is seen
|
||||
for (const until = Date.now() + 150; Date.now() < until; );
|
||||
write("index.html", "A3");
|
||||
await history.claim(you, "Dragged Title", ["index.html"], {
|
||||
overwrote: { "index.html": fileContentVersion("A2") },
|
||||
});
|
||||
await window.close();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(history.list().map((entry) => entry.label)).toEqual(["Retitle", "Dragged Title"]);
|
||||
});
|
||||
|
||||
it("counts a file time ahead of the scan as the scan's time, so it does not end an open turn", async () => {
|
||||
const { projectDir, history, write } = await project({ "a.js": "1", "b.js": "1" });
|
||||
const window = await history.beginWindow(agent, "Build", { idleMs: 2000 });
|
||||
write("a.js", "2");
|
||||
const ahead = Date.now() + 60_000;
|
||||
utimesSync(join(projectDir, "a.js"), ahead / 1000, ahead / 1000);
|
||||
await history.claim(you, "Nothing", []);
|
||||
write("b.js", "2");
|
||||
|
||||
expect((await window.close())?.files.map((file) => file.path)).toEqual(["a.js", "b.js"]);
|
||||
});
|
||||
|
||||
it("ends every entry at or after the one logged before it, though a window ends at its last write", async () => {
|
||||
const { history, write } = await project({ "index.html": "A", "notes.html": "N" });
|
||||
const window = await history.beginWindow(agent, "Retitle");
|
||||
write("index.html", "A2");
|
||||
write("notes.html", "N2");
|
||||
await new Promise((settle) => setTimeout(settle, 20));
|
||||
await history.claim(you, "Edited notes", ["notes.html"]);
|
||||
await window.close();
|
||||
|
||||
const ends = history.list().map((entry) => entry.endedAt);
|
||||
expect(ends).toEqual([...ends].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("takes over the lock of an owner that died without closing", async () => {
|
||||
const { history, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await history.close();
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface ProjectHistoryOptions {
|
||||
onError?: (error: unknown) => void;
|
||||
/** How long an open waits for another process to close the same history (default 5 s). */
|
||||
ownerWaitMs?: number;
|
||||
/** A CLI turn's window from an earlier open: writes since, within its idle limit, become the entry with its id. */
|
||||
closedWindow?: ClosedWindow;
|
||||
}
|
||||
|
||||
interface ClaimOptions {
|
||||
@@ -62,6 +64,17 @@ interface Writing {
|
||||
writeToken?: string;
|
||||
}
|
||||
|
||||
export interface ClosedWindow {
|
||||
id: string;
|
||||
who: HistoryWho;
|
||||
label: string;
|
||||
startedAt: number;
|
||||
lastWriteAt: number;
|
||||
idleMs: number;
|
||||
}
|
||||
|
||||
export const MAX_WINDOW_IDLE_MS = 10 * 60_000;
|
||||
|
||||
export interface HistoryListItem extends HistoryEntry {
|
||||
pinned: boolean;
|
||||
undone: boolean;
|
||||
@@ -73,6 +86,7 @@ export type HistoryResult =
|
||||
|
||||
export interface HistoryWindow {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
/** Records everything written since the window opened as one entry (null when nothing changed); after the window
|
||||
* ended by itself or by flush, returns the entry it became. */
|
||||
close(): Promise<HistoryEntry | null>;
|
||||
@@ -136,8 +150,9 @@ interface Group {
|
||||
label: string;
|
||||
startedAt: number;
|
||||
changes: Map<string, HistoryFileChange>;
|
||||
/** Windows only: the idle lifetime, its timer, and the entry it became once ended. */
|
||||
/** Windows only: the idle lifetime, its timer, the last write's time, and the entry it became once ended. */
|
||||
idleMs?: number;
|
||||
lastWriteAt?: number;
|
||||
idleTimer?: NodeJS.Timeout;
|
||||
entry?: HistoryEntry | null;
|
||||
}
|
||||
@@ -168,6 +183,10 @@ function splitAt(
|
||||
];
|
||||
}
|
||||
|
||||
/** A window takes a write within idleMs of its last one; past that it has ended, even before its timer commits it. */
|
||||
const takesWrite = (window: Group, at: number) =>
|
||||
window.idleMs === undefined || at - (window.lastWriteAt ?? at) <= window.idleMs;
|
||||
|
||||
/** Files one change to a group; a later change to the same path keeps the group's first "before". */
|
||||
function addChange(group: Group, path: string, before: string | null, after: string | null): void {
|
||||
const earlier = group.changes.get(path);
|
||||
@@ -230,11 +249,20 @@ class Engine {
|
||||
const cached = cache.get(path);
|
||||
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
||||
}
|
||||
// What changed while the project was closed is one outside entry.
|
||||
// What changed while the project was closed is one outside entry, or the closed window's.
|
||||
this.reopenClosedWindow();
|
||||
await this.sweep();
|
||||
for (const window of [...this.windows]) await this.endWindow(window);
|
||||
await this.commitOutside();
|
||||
}
|
||||
|
||||
reopenClosedWindow(): void {
|
||||
const closed = this.options.closedWindow;
|
||||
if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
|
||||
const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
|
||||
this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: new Map() });
|
||||
}
|
||||
|
||||
async firstOpen(): Promise<void> {
|
||||
const sweptAt = Date.now();
|
||||
for (const file of listProjectFiles(this.dir))
|
||||
@@ -274,15 +302,18 @@ class Engine {
|
||||
// A missing project folder was moved or removed, not emptied: that is no change to its files.
|
||||
if (!existsSync(this.dir)) return;
|
||||
const sweptAt = Date.now();
|
||||
const seen = listProjectFiles(this.dir);
|
||||
const changedAt = (file: { mtimeMs: number; ctimeMs: number }) =>
|
||||
Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
|
||||
const seen = listProjectFiles(this.dir).sort((a, b) => changedAt(a) - changedAt(b));
|
||||
let changed = false;
|
||||
for (const file of seen)
|
||||
changed = (await this.observe(file.path, statKey(file, sweptAt))) || changed;
|
||||
changed = (await this.observe(file.path, statKey(file, sweptAt), changedAt(file))) || changed;
|
||||
const present = new Set(seen.map((file) => file.path));
|
||||
for (const [path, known] of this.tracked) {
|
||||
if (present.has(path)) continue;
|
||||
this.tracked.delete(path);
|
||||
this.record(path, known.hash, null);
|
||||
const removedAt = sweptAt;
|
||||
await this.record(path, known.hash, null, removedAt);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) this.saveStatCache();
|
||||
@@ -298,19 +329,27 @@ class Engine {
|
||||
}
|
||||
}
|
||||
|
||||
async observe(path: string, stat: string): Promise<boolean> {
|
||||
async observe(path: string, stat: string, at: number): Promise<boolean> {
|
||||
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
||||
if (stat && known.stat === stat) return false;
|
||||
const hash = await this.storeIfPresent(path);
|
||||
if (hash === null) return false;
|
||||
this.tracked.set(path, { hash, stat });
|
||||
if (known.hash !== hash) this.record(path, known.hash, hash);
|
||||
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
||||
return known.hash !== hash || known.stat !== stat;
|
||||
}
|
||||
|
||||
record(path: string, before: string | null, after: string | null): void {
|
||||
async record(
|
||||
path: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
at: number,
|
||||
): Promise<void> {
|
||||
const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
|
||||
if (endedBeforeThisWrite.length) await this.commitOutside();
|
||||
for (const window of endedBeforeThisWrite) await this.endWindow(window);
|
||||
const window = this.windows.at(-1);
|
||||
if (window) this.touch(window);
|
||||
if (window) this.touch(window, at);
|
||||
addChange(window ?? this.outsideGroup(), path, before, after);
|
||||
}
|
||||
|
||||
@@ -461,9 +500,11 @@ class Engine {
|
||||
|
||||
async commit(group: Group, extra: Partial<HistoryEntry> = {}): Promise<HistoryEntry | null> {
|
||||
if (!group.changes.size) return null;
|
||||
const { id, who, label, startedAt, changes } = group;
|
||||
const { id, who, label, startedAt, lastWriteAt, changes } = group;
|
||||
const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const entry: HistoryEntry = { id, who, label, startedAt, endedAt: this.now(), files, ...extra };
|
||||
const previousEnd = this.log.entries.at(-1)?.endedAt ?? 0;
|
||||
const endedAt = Math.max(lastWriteAt ?? this.now(), previousEnd);
|
||||
const entry: HistoryEntry = { id, who, label, startedAt, endedAt, files, ...extra };
|
||||
this.log.entries.push(entry);
|
||||
try {
|
||||
saveRecord(this.logFile, this.log, { type: "entry", entry });
|
||||
@@ -521,13 +562,15 @@ class Engine {
|
||||
await this.sweep();
|
||||
const window = { ...this.newGroup(who, label), idleMs };
|
||||
this.windows.push(window);
|
||||
this.touch(window);
|
||||
return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
|
||||
this.touch(window, Date.now());
|
||||
const close = () => this.queue(() => this.sweepAndEnd(window));
|
||||
return { id: window.id, startedAt: window.startedAt, close };
|
||||
});
|
||||
}
|
||||
|
||||
/** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
|
||||
touch(window: Group): void {
|
||||
touch(window: Group, at: number): void {
|
||||
window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
|
||||
clearTimeout(window.idleTimer);
|
||||
if (window.idleMs === undefined || !Number.isFinite(window.idleMs)) return;
|
||||
window.idleTimer = setTimeout(
|
||||
|
||||
@@ -4,6 +4,8 @@ export { compositionsAffectedBy } from "./helpers/compositionInputs.js";
|
||||
export {
|
||||
DEFAULT_HISTORY_ROOT,
|
||||
openProjectHistory,
|
||||
MAX_WINDOW_IDLE_MS,
|
||||
type ClosedWindow,
|
||||
type ProjectHistory,
|
||||
type ProjectHistoryOptions,
|
||||
type HistoryListItem,
|
||||
|
||||
@@ -61,6 +61,35 @@ describe("history routes", () => {
|
||||
expect((await (await call("")).json()).entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("record an agent that names itself as the writer of its window and of its undo", async () => {
|
||||
const { projectDir, call } = await demoProject();
|
||||
const agent = { kind: "agent", name: "Claude" };
|
||||
|
||||
const { windowId } = await (
|
||||
await call("/window", { label: "Bigger title", who: agent })
|
||||
).json();
|
||||
writeFileSync(join(projectDir, "index.html"), "B");
|
||||
const { entry } = await (await call(`/window/${windowId}/close`, {})).json();
|
||||
expect(entry.who).toEqual(agent);
|
||||
|
||||
const undo = await (await call("/undo", { entryId: windowId, who: agent })).json();
|
||||
expect(undo.entry).toMatchObject({ who: agent, label: "Undid: Bigger title" });
|
||||
const restore = await (await call("/restore", { point: windowId, who: { name: "x" } })).json();
|
||||
expect(restore.who, "only an agent names itself").toEqual({ kind: "person", name: "You" });
|
||||
});
|
||||
|
||||
it("serve a kept file's bytes by hash, and nothing for a hash that is not one", async () => {
|
||||
const { projectDir, call } = await demoProject();
|
||||
const outsideHistory = `${"../".repeat(40)}${projectDir.slice(1)}/index.html`;
|
||||
const { windowId } = await (await call("/window", { label: "Edit" })).json();
|
||||
writeFileSync(join(projectDir, "index.html"), "B");
|
||||
const { entry } = await (await call(`/window/${windowId}/close`, {})).json();
|
||||
|
||||
expect(await (await call(`/blob/${entry.files[0].before}`)).text()).toBe("A");
|
||||
expect((await call(`/blob/${"0".repeat(64)}`)).status).toBe(404);
|
||||
expect((await call(`/blob/${encodeURIComponent(outsideHistory)}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("name what Cmd+Z and Cmd+Shift+Z would revert next", async () => {
|
||||
const { projectDir, call } = await demoProject();
|
||||
expect(await (await call("")).json()).toMatchObject({ back: null, forward: null });
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Context, Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { createWriteToken } from "../helpers/fileVersion.js";
|
||||
import type { HistoryWindow, ProjectHistory } from "../history/projectHistory.js";
|
||||
import {
|
||||
MAX_WINDOW_IDLE_MS,
|
||||
type HistoryWindow,
|
||||
type ProjectHistory,
|
||||
} from "../history/projectHistory.js";
|
||||
import type { HistoryWho } from "../history/historyLog.js";
|
||||
|
||||
const YOU: HistoryWho = { kind: "person", name: "You" };
|
||||
/** No edit waits this long between writes. */
|
||||
const MAX_WINDOW_IDLE_MS = 10 * 60_000;
|
||||
|
||||
async function historyOf(adapter: StudioApiAdapter, c: Context): Promise<ProjectHistory | null> {
|
||||
const project = await adapter.resolveProject(c.req.param("id") ?? "");
|
||||
@@ -26,6 +28,13 @@ function writing(c: Context): { writeToken?: string } {
|
||||
return header ? { writeToken: createWriteToken(header) } : {};
|
||||
}
|
||||
|
||||
/** An agent names itself (`who: {kind: "agent", name}`); anyone else is the person at Studio. */
|
||||
function whoOf(body: Record<string, unknown>): HistoryWho {
|
||||
const who = body.who as { kind?: unknown; name?: unknown } | undefined;
|
||||
const name = text(who?.name);
|
||||
return who?.kind === "agent" && name ? { kind: "agent", name } : YOU;
|
||||
}
|
||||
|
||||
/** How long a window or a coalescing claim may wait for its next write; past the cap a timer overflows. */
|
||||
function idleOf(body: Record<string, unknown>): number | undefined {
|
||||
const idleMs = body.idleMs;
|
||||
@@ -87,12 +96,12 @@ export function registerHistoryRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
withHistory(adapter, c, (history, body) => {
|
||||
const mode =
|
||||
body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : undefined;
|
||||
return history.undo(text(body.entryId) ?? "", { who: YOU, mode, ...writing(c) });
|
||||
return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
|
||||
}),
|
||||
);
|
||||
api.post(`${base}/restore`, (c) =>
|
||||
withHistory(adapter, c, (history, body) =>
|
||||
history.restore(text(body.point) ?? "", YOU, writing(c)),
|
||||
history.restore(text(body.point) ?? "", whoOf(body), writing(c)),
|
||||
),
|
||||
);
|
||||
api.get(`${base}/peek/:point`, (c) =>
|
||||
@@ -104,6 +113,15 @@ export function registerHistoryRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
return { ok: true };
|
||||
}),
|
||||
);
|
||||
api.get(`${base}/blob/:hash`, async (c) => {
|
||||
const history = await historyOf(adapter, c);
|
||||
const hash = c.req.param("hash") ?? "";
|
||||
const missing = () =>
|
||||
c.json({ error: "That file is not kept in this project's history." }, 404);
|
||||
if (!history || !/^[0-9a-f]{64}$/.test(hash)) return missing();
|
||||
const bytes = await history.readBlob(hash).catch(() => null);
|
||||
return bytes ? c.body(new Uint8Array(bytes)) : missing();
|
||||
});
|
||||
// Studio records after it writes: its edit claims the paths it just wrote, under the edit's label.
|
||||
api.post(`${base}/claim`, (c) =>
|
||||
withHistory(adapter, c, async (history, body) => {
|
||||
@@ -123,13 +141,13 @@ export function registerHistoryRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
withHistory(adapter, c, async (history, body) => {
|
||||
const idleMs = idleOf(body);
|
||||
const window = await history.beginWindow(
|
||||
YOU,
|
||||
whoOf(body),
|
||||
text(body.label) ?? "Edited in Studio",
|
||||
idleMs ? { idleMs } : undefined,
|
||||
);
|
||||
windows.set(window.id, { history, window });
|
||||
// The window's id is the id of the entry it becomes (its last one, when a claim cut it).
|
||||
return { windowId: window.id };
|
||||
return { windowId: window.id, startedAt: window.startedAt };
|
||||
}),
|
||||
);
|
||||
api.post(`${base}/window/:windowId/close`, (c) =>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"files": 5
|
||||
},
|
||||
"hyperframes": {
|
||||
"hash": "e4788caea448bc93",
|
||||
"hash": "a624fd0aa39bdd09",
|
||||
"files": 26
|
||||
},
|
||||
"hyperframes-animation": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"files": 7
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "ce021b1e5053d507",
|
||||
"hash": "f006eefddf345fd1",
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
|
||||
@@ -3,7 +3,7 @@ name: hyperframes-cli
|
||||
description: >
|
||||
Use the HyperFrames CLI development loop: init, add, catalog, capture, lint, check, snapshot,
|
||||
compare, grade-compare, preview, play, present, beats, keyframes, single or batch render, publish,
|
||||
cloud, cloudrun, feedback, lambda, doctor, browser, info, upgrade, skills, compositions, timeline, docs,
|
||||
cloud, cloudrun, feedback, lambda, doctor, browser, info, upgrade, skills, compositions, timeline, history, docs,
|
||||
benchmark, telemetry, transcribe, auth, tts, and remove-background. Also use when diagnosing build
|
||||
or render failures. validate, inspect, and layout are deprecated aliases; use check. Covers local,
|
||||
HeyGen-hosted cloud, AWS Lambda, and Google Cloud Run rendering.
|
||||
@@ -25,6 +25,19 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap
|
||||
8. **Render only after approval:** use `--quality draft` while iterating, `--quality looks` for the first real encode (the CLI default), and `--quality delivery` for final delivery.
|
||||
9. **Verify the output:** confirm the file exists and is non-empty. Read the render summary's second line (`beginframe` vs `screenshot`, GPU, stage timings). `screenshot` + `software gpu` on Linux is the slow path. `ffprobe -v error -show_format -show_streams` and compare duration (and fps if the brief set it) to the root `data-duration`.
|
||||
|
||||
<!-- history (trial): remove this block together with the command -->
|
||||
|
||||
### Project history in your turn
|
||||
|
||||
Every write to the project is kept as an entry that can be undone. Use it at two moments only, never on every step:
|
||||
|
||||
- **Start of a turn:** `npx hyperframes history begin --who <your-name> --label "<what you are about to do>"`, then `npx hyperframes history --since mine --who <your-name>` to see what the person changed since your last turn. Build on their edits; never overwrite them.
|
||||
- **A check failed, or the person says it got worse:** `npx hyperframes history undo --who <your-name>` undoes your newest turn and leaves the person's edits alone. Do not hand-edit back. On a conflict it exits 2 and prints both choices.
|
||||
|
||||
End each turn with `npx hyperframes history end`, so your writes read as yours, not as "Changed outside the app". While a turn is open, every write to the project counts as yours until 10 minutes pass without one; after that the turn has ended by itself.
|
||||
|
||||
<!-- /history (trial) -->
|
||||
|
||||
## Mandatory creator-edit cross-references
|
||||
|
||||
- Before authoring or diagnosing a zoom, punch-in/punch-out, reframe, camera
|
||||
|
||||
@@ -29,6 +29,12 @@ Apply the first matching row; do not evaluate lower state rows:
|
||||
| No brief, but `hyperframes.json` or `STORYBOARD.md` exists | Resume from project files and recorded preferences. Infer the owning workflow from existing artifacts. If it cannot be determined uniquely, ask one routing-only question; do not run the intent interview. |
|
||||
| Fresh creation | Run the intent layer — `references/intent-interview.md` — then route once using § 2's table. |
|
||||
|
||||
<!-- history (trial): remove this block together with the command -->
|
||||
|
||||
When you edit an existing project, bracket your edits with project history (`/hyperframes-cli`, Project history in your turn).
|
||||
|
||||
<!-- /history (trial) -->
|
||||
|
||||
If a fresh request does not identify the subject or input, ask what the video is about before routing. Check preferences and recipes before asking anything (`references/intent-interview.md`, step 1). A `figma.com` input or a named recipe changes intake, not routing — the interview's "Adapt orthogonal inputs" section handles both.
|
||||
|
||||
### Keep the project's CLI current
|
||||
|
||||
Reference in New Issue
Block a user