diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index de46426a777..8ee2c0781e0 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -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), +})); +vi.mock("@hyperframes/studio-server", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + openProjectHistory: (...args: Parameters) => + 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 }; @@ -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"), "before"); + 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"), "agent"); + 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(); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 78fb4397098..e05ee9f8a7e 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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, @@ -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"; @@ -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; @@ -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 { @@ -405,6 +413,21 @@ 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 | 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>(); // Set synchronously by shutdown() before any await, so a render or // thumbnail request already queued behind it sees the flag instead of @@ -412,6 +435,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { 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 @@ -1051,6 +1075,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { const shutdown = async (): Promise => { 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"); @@ -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((resolve) => setTimeout(resolve, RENDER_SHUTDOWN_WAIT_MS).unref()), ]); await closeBrowsers; diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index e789c61d6bd..9fa2d354af2 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index ac7eaef6fee..3668ca6b1ec 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -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"; @@ -65,6 +65,18 @@ export interface ProjectHistory { label: string, options?: { idleMs?: number }, ): Promise; + /** + * 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[]; @@ -117,6 +129,14 @@ 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; @@ -124,6 +144,8 @@ class Engine { tracked = new Map(); 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; @@ -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 { + 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 { + const held = this.claimed; + this.claimed = null; + if (!held) return; + clearTimeout(held.timer); + await this.commit(held.group); } outsideGroup(): Group { @@ -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 { await this.sweep(); + await this.commitClaim(); await this.commitOutside(); } @@ -378,6 +457,7 @@ class Engine { /** Every pending write, open window and outside group, committed: for flush and close. */ async settleAll(): Promise { await this.sweep(); + await this.commitClaim(); for (const window of [...this.windows]) await this.endWindow(window); await this.commitOutside(); } @@ -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); diff --git a/packages/studio-server/src/routes/history.test.ts b/packages/studio-server/src/routes/history.test.ts index 847d54c2af2..5e8b1240b3e 100644 --- a/packages/studio-server/src/routes/history.test.ts +++ b/packages/studio-server/src/routes/history.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createStudioApi } from "../createStudioApi"; import { openProjectHistory, type ProjectHistory } from "../history/projectHistory"; import type { StudioApiAdapter } from "../types"; @@ -33,16 +33,21 @@ function apiFor(projectDir: string, history?: ProjectHistory) { ); } +/** A project whose index.html reads "A", with its history and the routes over it. */ +async function demoProject() { + const projectDir = tempDir("hf-history-routes-"); + writeFileSync(join(projectDir, "index.html"), "A"); + const history = await openProjectHistory({ + projectDir, + historyRoot: tempDir("hf-history-routes-root-"), + }); + cleanup.push(() => history.close()); + return { projectDir, call: apiFor(projectDir, history) }; +} + describe("history routes", () => { it("record a Studio edit window as the person's entry, and step back undoes it", async () => { - const projectDir = tempDir("hf-history-routes-"); - writeFileSync(join(projectDir, "index.html"), "A"); - const history = await openProjectHistory({ - projectDir, - historyRoot: tempDir("hf-history-routes-root-"), - }); - cleanup.push(() => history.close()); - const call = apiFor(projectDir, history); + const { projectDir, call } = await demoProject(); const { windowId } = await (await call("/window", { label: "Moved Title" })).json(); writeFileSync(join(projectDir, "index.html"), "B"); @@ -55,6 +60,52 @@ describe("history routes", () => { expect((await (await call("")).json()).entries).toHaveLength(2); }); + 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 }); + + const { windowId } = await (await call("/window", { label: "Moved Title" })).json(); + writeFileSync(join(projectDir, "index.html"), "B"); + await call(`/window/${windowId}/close`, {}); + expect(await (await call("")).json()).toMatchObject({ + back: { id: windowId, label: "Moved Title", paths: ["index.html"] }, + forward: null, + }); + + await call("/step", { direction: "back" }); + expect(await (await call("")).json()).toMatchObject({ + back: null, + forward: { label: "Undid: Moved Title" }, + }); + }); + + it("claim what Studio just wrote as the person's entry, under the edit's label", async () => { + const { projectDir, call } = await demoProject(); + writeFileSync(join(projectDir, "index.html"), "B"); + const { claimed } = await ( + await call("/claim", { label: "Moved Title", paths: ["index.html", 7] }) + ).json(); + const { entries, back } = await (await call("")).json(); + expect(entries).toMatchObject([{ id: claimed.id, label: "Moved Title", who: { name: "You" } }]); + expect(back).toMatchObject({ id: claimed.id, label: "Moved Title" }); + }); + + it("end a window given idleMs by itself once its writes stop", async () => { + const { projectDir, call } = await demoProject(); + + const { windowId } = await ( + await call("/window", { label: "Dragged Title", idleMs: 50 }) + ).json(); + writeFileSync(join(projectDir, "index.html"), "B"); + await vi.waitFor( + async () => + expect((await (await call("")).json()).entries).toMatchObject([{ label: "Dragged Title" }]), + { timeout: 2_000, interval: 50 }, + ); + const { entry } = await (await call(`/window/${windowId}/close`, {})).json(); + expect(entry).toMatchObject({ id: windowId, label: "Dragged Title" }); + }); + it("refuse to close a window through another project's route", async () => { const projects = new Map(); for (const id of ["demo", "other"]) { diff --git a/packages/studio-server/src/routes/history.ts b/packages/studio-server/src/routes/history.ts index b3eb3fef2f6..f4f7e7f1d54 100644 --- a/packages/studio-server/src/routes/history.ts +++ b/packages/studio-server/src/routes/history.ts @@ -1,9 +1,11 @@ import type { Context, Hono } from "hono"; import type { StudioApiAdapter } from "../types.js"; import type { HistoryWindow, ProjectHistory } from "../history/projectHistory.js"; -import type { HistoryWho } from "../history/historyLog.js"; +import { stepTarget, type HistoryEntry, 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 { const project = await adapter.resolveProject(c.req.param("id") ?? ""); @@ -17,6 +19,22 @@ async function bodyOf(c: Context): Promise> { const text = (value: unknown) => (typeof value === "string" && value ? value : null); +/** How long a window or a coalescing claim may wait for its next write; past the cap a timer overflows. */ +function idleOf(body: Record): number | undefined { + const idleMs = body.idleMs; + return typeof idleMs === "number" && idleMs > 0 + ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) + : undefined; +} + +/** What Cmd+Z or Cmd+Shift+Z would revert next, so Studio can name it on its buttons. */ +function nextStep(entries: readonly HistoryEntry[], direction: "back" | "forward") { + const target = stepTarget(entries, direction); + if (!target) return null; + const paths = target.files.map((file) => file.path); + return { id: target.id, label: target.label, endedAt: target.endedAt, paths }; +} + /** Runs `task` on the project's history; no history is a 404, an engine refusal ("no longer kept") a 409. */ async function withHistory( adapter: StudioApiAdapter, @@ -38,7 +56,12 @@ export function registerHistoryRoutes(api: Hono, adapter: StudioApiAdapter): voi const windows = new Map(); const base = "/projects/:id/history"; - api.get(base, (c) => withHistory(adapter, c, (history) => ({ entries: history.list() }))); + api.get(base, (c) => + withHistory(adapter, c, (history) => { + const entries = history.list(); + return { entries, back: nextStep(entries, "back"), forward: nextStep(entries, "forward") }; + }), + ); api.post(`${base}/step`, (c) => withHistory(adapter, c, (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU), @@ -63,9 +86,28 @@ export function registerHistoryRoutes(api: Hono, adapter: StudioApiAdapter): voi return { ok: true }; }), ); + // 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) => { + const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : []; + const coalesceKey = text(body.coalesceKey) ?? undefined; + const idleMs = idleOf(body); + const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, { + ...(coalesceKey && { coalesceKey }), + ...(idleMs && { idleMs }), + }); + return { claimed }; + }), + ); api.post(`${base}/window`, (c) => withHistory(adapter, c, async (history, body) => { - const window = await history.beginWindow(YOU, text(body.label) ?? "Edited in Studio"); + // A drag's burst of writes keeps one window open; it ends itself after idleMs without a write. + const idleMs = idleOf(body); + const window = await history.beginWindow( + YOU, + 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. return { windowId: window.id };