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:
Miguel Ángel
2026-09-25 14:26:09 +00:00
committed by GitHub
parent e266ad5e5c
commit 2734ee2c02
14 changed files with 1465 additions and 24 deletions
+1
View File
@@ -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),
+486
View File
@@ -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);
});
+469
View File
@@ -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),
});
+1
View File
@@ -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"],
],
},
+4
View File
@@ -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;
+219
View File
@@ -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(
+2
View File
@@ -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 });
+25 -7
View File
@@ -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) =>
+2 -2
View File
@@ -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": {
+14 -1
View File
@@ -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
+6
View File
@@ -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