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
2 changes: 2 additions & 0 deletions packages/studio-server/src/createStudioApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerRegistryRoutes } from "./routes/registry.js";
import { registerSelectionRoutes } from "./routes/selection.js";
import { registerMediaRoutes } from "./routes/media.js";
import { registerGlobalAssetRoutes } from "./routes/globalAssets.js";
import { registerHistoryRoutes } from "./routes/history.js";

/**
* Create a Hono sub-app with all studio API routes.
Expand All @@ -34,6 +35,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerFontRoutes(api);
registerRegistryRoutes(api, adapter);
registerGlobalAssetRoutes(api);
registerHistoryRoutes(api, adapter);

return api;
}
24 changes: 21 additions & 3 deletions packages/studio-server/src/helpers/projectSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,26 @@ function createProjectFingerprint(projectDir: string, files: ProjectSignatureFil
return hash.digest("hex").slice(0, 24);
}

function collectProjectFiles(normalizedProjectDir: string): ProjectSignatureFile[] {
const collected: ProjectSignatureFile[] = [];
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
return collected;
}

/** The files a project is made of (the signature's set: source plus Studio's two manifests), paths with `/`. */
export function listProjectFiles(
projectDir: string,
): Array<{ path: string; size: number; mtimeMs: number; ctimeMs: number }> {
const normalizedProjectDir = resolve(projectDir);
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
size: entry.size,
mtimeMs: entry.mtimeMs,
ctimeMs: entry.ctimeMs,
}));
}

/**
* Resolve the project signature through the adapter's cached path when the host
* provides one (the CLI invalidates its cache from the file watcher), falling
Expand Down Expand Up @@ -204,9 +224,7 @@ export function createProjectSignature(
excluding: ReadonlySet<string> = new Set(),
): string {
const normalizedProjectDir = resolve(projectDir);
const collected: ProjectSignatureFile[] = [];
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
const collected = collectProjectFiles(normalizedProjectDir);
const files = collected.filter(
(entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/")),
);
Expand Down
78 changes: 78 additions & 0 deletions packages/studio-server/src/history/blobStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createHash, randomUUID } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
import { dirname, join } from "node:path";

/** File contents stored once by sha256, text and binary alike. */
export interface BlobStore {
/** Copies the file in (a clone where the file system can) and returns the copy's hash. */
put(absPath: string): Promise<string>;
read(hash: string): Promise<Buffer>;
/** Writes the blob's bytes to `absPath` by clone-or-copy and rename, so a reader never sees half a file. */
writeTo(hash: string, absPath: string): Promise<void>;
bytes(): number;
size(hash: string): number;
/** Deletes every blob not in `keep`. */
prune(keep: ReadonlySet<string>): Promise<void>;
}

async function hashFile(path: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}

async function cloneOrCopy(from: string, to: string): Promise<void> {
await mkdir(dirname(to), { recursive: true });
const temp = `${to}.${randomUUID()}.tmp`;
try {
await copyFile(from, temp, constants.COPYFILE_FICLONE);
await rename(temp, to);
} catch (error) {
await rm(temp, { force: true });
throw error;
}
}

export async function openBlobStore(dir: string): Promise<BlobStore> {
const sizes = new Map<string, number>();
await mkdir(dir, { recursive: true });
for (const shard of await readdir(dir))
for (const hash of await readdir(join(dir, shard)).catch(() => []))
sizes.set(hash, (await stat(join(dir, shard, hash))).size);
const pathOf = (hash: string) => join(dir, hash.slice(0, 2), hash);
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);

return {
async put(absPath) {
// Hash the copy, not the source, so a write racing the copy can never file bytes under the wrong hash.
const temp = join(dir, `incoming-${randomUUID()}`);
// Recreated if removed while open, so a missing folder is never mistaken for a deleted project file.
await mkdir(dir, { recursive: true });
await copyFile(absPath, temp, constants.COPYFILE_FICLONE);
const hash = await hashFile(temp);
if (sizes.has(hash)) {
await rm(temp, { force: true });
return hash;
}
await mkdir(dirname(pathOf(hash)), { recursive: true });
const size = (await stat(temp)).size;
await rename(temp, pathOf(hash));
sizes.set(hash, size);
total += size;
return hash;
},
read: (hash) => readFile(pathOf(hash)),
writeTo: (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
bytes: () => total,
size: (hash) => sizes.get(hash) ?? 0,
async prune(keep) {
for (const [hash, size] of sizes) {
if (keep.has(hash)) continue;
await rm(pathOf(hash), { force: true });
sizes.delete(hash);
total -= size;
}
},
};
}
42 changes: 42 additions & 0 deletions packages/studio-server/src/history/historyId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";

const ID_PATH = join(".hyperframes", "history-id");
/** The only shape minted here; the id is project content and becomes a path, so nothing else is trusted. */
const ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

function readId(projectDir: string): string | null {
try {
const id = readFileSync(join(projectDir, ID_PATH), "utf-8").trim();
return ID_SHAPE.test(id) ? id : null;
} catch {
return null;
}
}

function recordedDir(historyDir: string): string | null {
try {
return JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8")).dir ?? null;
} catch {
return null;
}
}

/**
* The project's history id, kept in the project so a rename or move keeps its history. A folder whose id is
* still carried by the folder that history was recorded for is a copy, and gets an id of its own.
*/
export function projectHistoryId(projectDir: string, historyRoot: string): string {
const dir = resolve(projectDir);
let id = readId(dir);
const was = id && recordedDir(join(historyRoot, id));
if (!id || (was && was !== dir && existsSync(was) && readId(was) === id)) {
id = randomUUID();
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
writeFileSync(join(dir, ID_PATH), `${id}\n`);
}
mkdirSync(join(historyRoot, id), { recursive: true });
writeFileSync(join(historyRoot, id, "project.json"), JSON.stringify({ dir }));
return id;
}
178 changes: 178 additions & 0 deletions packages/studio-server/src/history/historyLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { closeSync, constants, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
import { dirname } from "node:path";
import { replaceFileAtomically } from "../helpers/atomicFile.js";

export interface HistoryWho {
kind: "person" | "agent" | "outside";
/** Shown in the History list: "You", the agent's name, "Outside". */
name: string;
}

/** A file's sha256 before and after; null is "did not exist", so a create or delete undoes like any edit. */
export interface HistoryFileChange {
path: string;
before: string | null;
after: string | null;
}

export interface HistoryEntry {
id: string;
who: HistoryWho;
label: string;
startedAt: number;
endedAt: number;
files: HistoryFileChange[];
/** Set on an undo: the entry it reverted. Redo is undoing the undo. */
undoes?: string;
/** Set on a restore: the point (an entry id, or START) the files were made equal to. */
restoredTo?: string;
}

/** The point before the first kept entry. */
export const START = "start";

/** Path to hash. */
export type Manifest = Map<string, string>;

export interface HistoryLog {
baseline: Manifest;
entries: HistoryEntry[];
pins: Set<string>;
}

type LogRecord =
| { type: "baseline"; files: Record<string, string> }
| { type: "entry"; entry: HistoryEntry }
| { type: "pin"; id: string; pinned: boolean };

function parseRecord(line: string): LogRecord | null {
try {
return JSON.parse(line);
} catch {
return null;
}
}

/** `onUnreadable` hears of a damaged line; a torn last line (a crash mid-append) is expected and dropped quietly. */
export function readLog(file: string, onUnreadable: (line: number) => void): HistoryLog | null {
let text: string;
try {
text = readFileSync(file, "utf-8");
} catch {
return null;
}
const log: HistoryLog = { baseline: new Map(), entries: [], pins: new Set() };
const lines = text.split("\n");
lines.forEach((line, index) => {
const record = line ? parseRecord(line) : null;
if (record) applyRecord(log, record);
else if (line && index < lines.length - 1) onUnreadable(index + 1);
});
return log;
}

function applyRecord(log: HistoryLog, record: LogRecord): void {
if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
else if (record.type === "entry") log.entries.push(record.entry);
else if (record.pinned) log.pins.add(record.id);
else log.pins.delete(record.id);
}

/** Appends `record`, already applied to `log`; a log file that is gone is written whole, so a restart replays it. */
export function saveRecord(file: string, log: HistoryLog, record: LogRecord): void {
let fd: number;
try {
// No O_CREAT: an append never creates a log that would lack its baseline.
fd = openSync(file, constants.O_WRONLY | constants.O_APPEND);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
return writeLog(file, log);
}
try {
writeSync(fd, `${JSON.stringify(record)}\n`);
} finally {
closeSync(fd);
}
}

export function writeLog(file: string, log: HistoryLog): void {
const records: LogRecord[] = [
{ type: "baseline", files: Object.fromEntries(log.baseline) },
...log.entries.map((entry) => ({ type: "entry" as const, entry })),
...[...log.pins].map((id) => ({ type: "pin" as const, id, pinned: true })),
];
mkdirSync(dirname(file), { recursive: true });
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}\n`).join(""), 0o644);
}

function applyEntry(manifest: Manifest, entry: HistoryEntry): void {
for (const file of entry.files)
if (file.after === null) manifest.delete(file.path);
else manifest.set(file.path, file.after);
}

/** The files as they were right after `point`. Null when that point is no longer kept. */
export function manifestAt(log: HistoryLog, point: string): Manifest | null {
const manifest = new Map(log.baseline);
if (point === START) return manifest;
for (const entry of log.entries) {
applyEntry(manifest, entry);
if (entry.id === point) return manifest;
}
return null;
}

/** Entries currently reverted: an undo that is itself in effect reverts its target. */
export function undoneIds(entries: readonly HistoryEntry[]): Set<string> {
const undone = new Set<string>();
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i]!;
if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
}
return undone;
}

/**
* What Cmd+Z (back) or Cmd+Shift+Z (forward) reverts, whoever made the change. Back: the newest change still in
* effect. Forward: the newest undo of a change that is still in effect, while no change has been made since.
*/
export function stepTarget(
entries: readonly HistoryEntry[],
direction: "back" | "forward",
): HistoryEntry | undefined {
const undone = undoneIds(entries);
const byId = new Map(entries.map((entry) => [entry.id, entry]));
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i]!;
const isChange = !entry.undoes;
if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
if (direction !== "forward") continue;
if (isChange) return undefined;
const target = byId.get(entry.undoes!);
if (!undone.has(entry.id) && target && !target.undoes) return entry;
}
return undefined;
}

/**
* Folds the oldest entry into the baseline unless it is pinned. ponytail: an old pin therefore holds everything
* after it; per-point snapshots would lift that. Returns whether one was folded.
*/
export function foldOldest(log: HistoryLog): boolean {
const oldest = log.entries[0];
if (!oldest || log.pins.has(oldest.id)) return false;
applyEntry(log.baseline, oldest);
log.entries.shift();
return true;
}

/** Every hash a kept point can still need. */
export function referencedHashes(log: HistoryLog, current: Manifest): Set<string> {
const keep = new Set([...log.baseline.values(), ...current.values()]);
for (const entry of log.entries)
for (const file of entry.files) {
if (file.before) keep.add(file.before);
if (file.after) keep.add(file.after);
}
return keep;
}
Loading
Loading