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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ vi.mock("../browser/manager.js", () => ({
ensureBrowser: async () => ({ executablePath: undefined, source: "system" }),
}));

// Lets one test hold the project history in its opening; every other test opens the real one.
const historyState = vi.hoisted(() => ({
open: null as null | ((...args: unknown[]) => Promise<unknown>),
}));
vi.mock("@hyperframes/studio-server", async (importOriginal) => {
const original = await importOriginal<typeof import("@hyperframes/studio-server")>();
return {
...original,
openProjectHistory: (...args: Parameters<typeof original.openProjectHistory>) =>
historyState.open ? historyState.open(...args) : original.openProjectHistory(...args),
};
});

// Only `fs.watch` is replaced, so the SSE describe below can fire a file-change
// on demand; every other server test keeps reading and writing real files.
const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
Expand Down Expand Up @@ -113,6 +126,47 @@ describe("Studio thumbnail GPU capture plumbing", () => {
});
});

describe("createStudioServer project history (D-491)", () => {
it("serves the project's history, and a change the watcher sees becomes an entry", async () => {
const projectDir = tmpProject();
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
const list = async () =>
(await (await server!.app.request(historyUrl)).json()) as {
entries: Array<{ who: { kind: string } }>;
back: { label: string } | null;
};
expect(await list()).toMatchObject({ entries: [], back: null });

writeFileSync(join(projectDir, "index.html"), "<html>agent</html>");
mockWatcher.emit("change", "change", "index.html");

// Writes with no window open group until 2 s of quiet.
await vi.waitFor(async () => expect((await list()).entries).toHaveLength(1), {
timeout: 5_000,
interval: 200,
});
expect((await list()).entries[0]!.who.kind).toBe("outside");
await server.shutdown();
});

it("shutdown returns within preview's exit watchdog while the history is still opening", async () => {
historyState.open = () => new Promise(() => {});
try {
const projectDir = tmpProject();
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
void server.app.request(`/api/projects/${encodeURIComponent(basename(projectDir))}/history`);
await new Promise((resolve) => setTimeout(resolve, 50));
const started = Date.now();
await server.shutdown();
expect(Date.now() - started).toBeLessThan(2_900);
} finally {
historyState.open = null;
}
});
});

describe("createStudioServer autoProxy plumbing", () => {
it("hyperframes.json media.autoProxy=false flows through to the adapter", () => {
const projectDir = tmpProject();
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { homedir } from "node:os";
import { readBundleFile } from "./readBundleFile.js";
import {
createProjectWatcher,
Expand Down Expand Up @@ -46,6 +47,8 @@ import {
type ResolvedProject,
type RenderJobState,
type BackgroundRemovalRender,
openProjectHistory,
type ProjectHistory,
} from "@hyperframes/studio-server";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
Expand All @@ -64,6 +67,9 @@ import {

const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";

/** Where `hyperframes preview` keeps project histories: outside every project, so no tidy-up takes one away. */
const DEFAULT_HISTORY_ROOT = join(homedir(), ".cache", "hyperframes", "history");

// Under preview.ts's 3s process-exit watchdog, so shutdown() always returns
// before that watchdog can fire and skip this file's browser cleanup.
const RENDER_SHUTDOWN_WAIT_MS = 2_000;
Expand Down Expand Up @@ -324,6 +330,8 @@ export interface StudioServerOptions {
autoProxy?: boolean | undefined;
/** GPU policy used by Studio thumbnails and frame capture. */
browserGpuMode?: BrowserGpuMode;
/** Where project histories are kept; defaults to ~/.cache/hyperframes/history. */
historyRoot?: string;
}

export interface StudioServer {
Expand Down Expand Up @@ -405,13 +413,29 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}
});

// Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off
// for this run.
let history: Promise<ProjectHistory | null> | undefined;
const projectHistory = () =>
(history ??= openProjectHistory({
projectDir,
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
}).catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
return null;
}));
watcher.addListener((changedPath) => {
void history?.then((opened) => opened?.noteChange(changedPath));
});

const inFlightRenders = new Map<AbortController, Promise<void>>();
// Set synchronously by shutdown() before any await, so a render or
// thumbnail request already queued behind it sees the flag instead of
// launching a browser shutdown() has no way to know about and close.
let shuttingDown = false;

const adapter: PreviewApiAdapter = {
history: () => projectHistory(),
// Explicit option wins (preview's resolved --proxy/--no-proxy + config);
// otherwise honor the project's hyperframes.json media.autoProxy so every
// createStudioServer caller (e.g. the background preview child) gets the
Expand Down Expand Up @@ -1051,6 +1075,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {

const shutdown = async (): Promise<void> => {
shuttingDown = true;
// Commits any open edit window; bounded with the renders below, so a history still opening cannot hold exit.
const closeHistory = history?.then((opened) => opened?.close()).catch(() => {});
const renders = [...inFlightRenders];
for (const [abortController] of renders) abortController.abort();
const { killTrackedProcesses, closeBrowserPool } = await import("@hyperframes/engine");
Expand All @@ -1063,7 +1089,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
closeBrowserPool().catch(() => {}),
]);
await Promise.race([
Promise.allSettled(renders.map(([, done]) => done)),
Promise.allSettled([...renders.map(([, done]) => done), closeHistory]),
new Promise<void>((resolve) => setTimeout(resolve, RENDER_SHUTDOWN_WAIT_MS).unref()),
]);
await closeBrowsers;
Expand Down
82 changes: 82 additions & 0 deletions packages/studio-server/src/history/projectHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,85 @@ describe("openProjectHistory", () => {
expect(read("index.html")).toBe("c".repeat(40));
});
});

describe("claim: a writer that records after writing", () => {
it("files the claimed paths' writes as the claimer's entry; other outside writes stay outside", async () => {
const { history, write, read } = await project({ "index.html": "A", "notes.md": "n" });
write("index.html", "B");
write("notes.md", "agent notes");
const claimed = await history.claim(you, "Moved Title", ["index.html"]);
expect(history.list()).toMatchObject([
{ id: claimed!.id, who: you, label: "Moved Title", files: [{ path: "index.html" }] },
]);
await history.flush();
expect(history.list().map((entry) => [entry.who.kind, entry.files[0]!.path])).toEqual([
["person", "index.html"],
["outside", "notes.md"],
]);
await history.undo(claimed!.id, { who: you });
expect(read("index.html")).toBe("A");
expect(await history.claim(you, "Nothing", ["index.html"]), "nothing left to claim").toBeNull();
});

it("merges claims with one coalesceKey into one entry, and Cmd+Z right after undoes all of it", async () => {
const { history, write, read } = await project({ "index.html": "A" });
write("index.html", "B");
const first = await history.claim(you, "Dragged Title", ["./index.html"], {
coalesceKey: "drag",
});
write("index.html", "C");
const second = await history.claim(you, "Dragged Title", ["index.html"], {
coalesceKey: "drag",
});
expect(second!.id).toBe(first!.id);
expect(history.list(), "still open for the next write of the drag").toEqual([]);

expect(await history.step("back", you)).toMatchObject({
ok: true,
entry: { label: "Undid: Dragged Title" },
});
expect(read("index.html")).toBe("A");
expect(history.list()[0]).toMatchObject({ id: first!.id, files: [{ path: "index.html" }] });
});

it("a coalescing claim whose writes net to nothing returns null and records nothing", async () => {
const { history, write } = await project({ "index.html": "A" });
write("index.html", "B");
expect(
await history.claim(you, "Dragged Title", ["index.html"], { coalesceKey: "drag" }),
).not.toBeNull();
write("index.html", "A");
expect(
await history.claim(you, "Dragged Title", ["index.html"], { coalesceKey: "drag" }),
).toBeNull();
await history.flush();
expect(history.list()).toEqual([]);
});

it("a claim with another key, or its idle time, ends the coalescing claim", async () => {
const { history, write } = await project({ "a.html": "A", "b.html": "B" });
write("a.html", "A2");
await history.claim(you, "Dragged A", ["a.html"], { coalesceKey: "a", idleMs: 30 });
await vi.waitFor(() => expect(history.list()).toMatchObject([{ label: "Dragged A" }]));
write("b.html", "B2");
await history.claim(you, "Dragged B", ["b.html"], { coalesceKey: "b" });
write("a.html", "A3");
await history.claim(you, "Dragged A again", ["a.html"], { coalesceKey: "a" });
expect(history.list().map((entry) => entry.label)).toEqual(["Dragged A", "Dragged B"]);
});

it("an outside write to a claimed path between the write and its claim folds into the claim (the ceiling)", async () => {
const { history, write } = await project({ "index.html": "A" });
write("index.html", "B");
write("index.html", "C");
await history.claim(you, "Moved Title", ["index.html"]);
const [entry] = history.list();
expect(entry).toMatchObject({ who: you, label: "Moved Title" });
const blob = async (hash: string | null) =>
hash ? String(await history.readBlob(hash)) : null;
expect([await blob(entry!.files[0]!.before), await blob(entry!.files[0]!.after)]).toEqual([
"A",
"C",
]);
});
});
94 changes: 88 additions & 6 deletions packages/studio-server/src/history/projectHistory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { join, relative, resolve, sep } from "node:path";
import { replaceFileAtomically } from "../helpers/atomicFile.js";
import { affectsProjectSignature, listProjectFiles } from "../helpers/projectSignature.js";
import { openBlobStore, type BlobStore } from "./blobStore.js";
Expand Down Expand Up @@ -65,6 +65,18 @@ export interface ProjectHistory {
label: string,
options?: { idleMs?: number },
): Promise<HistoryWindow>;
/**
* For a writer that records after writing (Studio): takes in every write so far and moves the not yet committed
* outside changes to `paths` into one entry of `who`'s. Claims with the same coalesceKey merge until a claim with
* another key, `idleMs` (default quietMs) without one, or any operation. Null when nothing was claimed, or
* when a coalescing claim's writes net to nothing (a drag back to where it started).
*/
claim(
who: HistoryWho,
label: string,
paths: readonly string[],
options?: { coalesceKey?: string; idleMs?: number },
): Promise<{ id: string } | null>;
/** A watcher saw `path` change (project-relative or absolute). */
noteChange(path: string): void;
list(): HistoryListItem[];
Expand Down Expand Up @@ -117,13 +129,23 @@ const statKey = (file: { size: number; mtimeMs: number; ctimeMs: number }, swept
? ""
: `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;

/** 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);
const from = earlier ? earlier.before : before;
if (from === after) group.changes.delete(path);
else group.changes.set(path, { path, before: from, after });
}

class Engine {
readonly dir: string;
readonly home: string;
log: HistoryLog = { baseline: new Map(), entries: [], pins: new Set() };
tracked = new Map<string, Tracked>();
windows: Group[] = [];
outside: Group | null = null;
/** A coalescing claim, open until another key, its idle timer, or an operation commits it. */
claimed: { group: Group; key: string; timer: NodeJS.Timeout } | null = null;
quietTimer: NodeJS.Timeout | undefined;
maxTimer: NodeJS.Timeout | undefined;
notedTimer: NodeJS.Timeout | null = null;
Expand Down Expand Up @@ -249,11 +271,67 @@ class Engine {
record(path: string, before: string | null, after: string | null): void {
const window = this.windows.at(-1);
if (window) this.touch(window);
const group = window ?? this.outsideGroup();
const earlier = group.changes.get(path);
const from = earlier ? earlier.before : before;
if (from === after) group.changes.delete(path);
else group.changes.set(path, { path, before: from, after });
addChange(window ?? this.outsideGroup(), path, before, after);
}

/**
* ponytail: a claim takes every uncommitted outside change to its paths, so another writer's write to the same file
* between Studio's write and its claim (milliseconds) folds into Studio's entry. Per-write tokens would split them.
* A write filed to another writer's open window stays that window's.
*/
async claimNow(
who: HistoryWho,
label: string,
paths: readonly string[],
{ coalesceKey, idleMs }: { coalesceKey?: string; idleMs?: number },
): Promise<{ id: string } | null> {
await this.sweep();
const taken = this.takeOutside(paths);
if (!taken.length) return null;
const group = await this.claimGroup(who, label, coalesceKey);
for (const change of taken) addChange(group, change.path, change.before, change.after);
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
const entry = await this.commit(group);
return entry && { id: entry.id };
}

/** The held claim when the key matches; otherwise it is committed and a new group starts. */
async claimGroup(who: HistoryWho, label: string, key: string | undefined): Promise<Group> {
if (key && this.claimed?.key === key) return this.claimed.group;
await this.commitClaim();
return this.newGroup(who, label);
}

/** Removes and returns the uncommitted outside changes to `paths` (project-relative or absolute). */
takeOutside(paths: readonly string[]): HistoryFileChange[] {
const outside = this.outside;
if (!outside) return [];
const wanted = new Set(
paths.map((path) => relative(this.dir, resolve(this.dir, path)).split(sep).join("/")),
);
const taken = [...outside.changes.values()].filter((change) => wanted.has(change.path));
for (const change of taken) outside.changes.delete(change.path);
return taken;
}

holdClaim(
group: Group,
key: string,
idleMs = this.options.quietMs ?? 2000,
): { id: string } | null {
clearTimeout(this.claimed?.timer);
const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
timer.unref?.();
this.claimed = { group, key, timer };
return group.changes.size ? { id: group.id } : null;
}

async commitClaim(): Promise<void> {
const held = this.claimed;
this.claimed = null;
if (!held) return;
clearTimeout(held.timer);
await this.commit(held.group);
}

outsideGroup(): Group {
Expand Down Expand Up @@ -322,6 +400,7 @@ class Engine {
/** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
async settle(): Promise<void> {
await this.sweep();
await this.commitClaim();
await this.commitOutside();
}

Expand Down Expand Up @@ -378,6 +457,7 @@ class Engine {
/** Every pending write, open window and outside group, committed: for flush and close. */
async settleAll(): Promise<void> {
await this.sweep();
await this.commitClaim();
for (const window of [...this.windows]) await this.endWindow(window);
await this.commitOutside();
}
Expand Down Expand Up @@ -470,6 +550,8 @@ class Engine {
projectId: this.projectId,
beginWindow: (who, label, options = {}) =>
this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 30_000),
claim: (who, label, paths, options = {}) =>
this.queue(() => this.claimNow(who, label, paths, options)),
noteChange: (path) => this.noteChange(path),
list: () => {
const undone = undoneIds(this.log.entries);
Expand Down
Loading
Loading