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
15 changes: 11 additions & 4 deletions packages/studio-server/src/history/historyLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ export interface HistoryLog {
baseline: Manifest;
entries: HistoryEntry[];
pins: Set<string>;
keepsLedger?: boolean;
}

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

Expand Down Expand Up @@ -74,8 +75,10 @@ export function readLog(file: string, onUnreadable: (line: number) => void): His
}

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);
if (record.type === "baseline") {
log.baseline = new Map(Object.entries(record.files));
log.keepsLedger = record.keepsLedger === true;
} 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);
}
Expand All @@ -99,7 +102,11 @@ export function saveRecord(file: string, log: HistoryLog, record: LogRecord): vo

export function writeLog(file: string, log: HistoryLog): void {
const records: LogRecord[] = [
{ type: "baseline", files: Object.fromEntries(log.baseline) },
{
type: "baseline",
files: Object.fromEntries(log.baseline),
...(log.keepsLedger && { keepsLedger: true }),
},
...log.entries.map((entry) => ({ type: "entry" as const, entry })),
...[...log.pins].map((id) => ({ type: "pin" as const, id, pinned: true })),
];
Expand Down
80 changes: 80 additions & 0 deletions packages/studio-server/src/history/projectHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ async function project(files: Record<string, string | Buffer>, options = {}) {
}

/** Undoes the newest change still in effect, whoever made it; Cmd+Z steps only over the caller's own. */
/** What 0.8.123 leaves: a log whose baseline never names the media ledger and carries no marker. */
function asWrittenBy0_8_123(logFile: string) {
const lines = readFileSync(logFile, "utf-8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
for (const record of lines.filter((line) => line.type === "baseline")) {
delete record.files[".media/manifest.jsonl"];
delete record.keepsLedger;
}
writeFileSync(logFile, lines.map((record) => JSON.stringify(record)).join("\n") + "\n");
}

async function undoNewest(history: ProjectHistory) {
const newest = [...history.list()].reverse().find((entry) => !entry.undoes && !entry.undone);
return history.undo(newest!.id, { who: you });
Expand Down Expand Up @@ -201,6 +214,73 @@ describe("openProjectHistory", () => {
expect(reopened.next("forward", you)).toBeUndefined();
});

it("a log 0.8.123 wrote without the media ledger takes it in, so the first Undo undoes the edit", async () => {
const { history, write, read, projectDir, historyRoot } = await project({
"index.html": "A",
".media/manifest.jsonl": "{}\n",
});
await change(history, you, "Your edit", () => write("index.html", "B"));
await history.close();
asWrittenBy0_8_123(join(historyRoot, history.projectId, "log.jsonl"));
const reopened = await open(projectDir, historyRoot);
expect(await reopened.step("back", you)).toMatchObject({ ok: true });
expect([read("index.html"), read(".media/manifest.jsonl")]).toEqual(["A", "{}\n"]);
});

// Windows needs a privilege to create symlinks.
it.skipIf(process.platform === "win32")(
"a log 0.8.123 wrote never takes in a ledger behind a link",
async () => {
const { history, projectDir, historyRoot } = await project(
{ "index.html": "A" },
{ quietMs: 30 },
);
await history.close();
asWrittenBy0_8_123(join(historyRoot, history.projectId, "log.jsonl"));
const outside = tempDir("hf-history-linked-");
writeFileSync(join(outside, "manifest.jsonl"), "{}\n");
symlinkSync(outside, join(projectDir, ".media"), "dir");
const reopened = await open(projectDir, historyRoot, { quietMs: 30 });
await reopened.flush();
expect(reopened.list()).toEqual([]);
},
);

it("a ledger made while the project was closed is a change like any file, once a log keeps the ledger", async () => {
const { history, write, read, has, projectDir, historyRoot } = await project(
{ "index.html": "A" },
{ quietMs: 30 },
);
await history.close();
write("index.html", "B");
write(".media/manifest.jsonl", "{}\n");
const reopened = await open(projectDir, historyRoot, { quietMs: 30 });
await reopened.flush();
const [outside] = reopened.list();
expect(outside!.files.map((file) => file.path).sort()).toEqual([
".media/manifest.jsonl",
"index.html",
]);
expect((await reopened.undo(outside!.id, { who: you })).ok).toBe(true);
expect([read("index.html"), has(".media/manifest.jsonl")]).toEqual(["A", false]);
});

it("takes the media ledger back with the film when a change is undone", async () => {
const { history, write, read } = await project({
"index.html": "<h1>Hello</h1>",
".media/manifest.jsonl": "{}\n",
});
const cutout = await change(history, agent, "Cut out the logo", () => {
write("index.html", "<h1>Hello</h1><img src='logo.png'>");
write(".media/manifest.jsonl", '{}\n{"id":"logo"}\n');
write(".media/images/logo.png", "png");
write(".tools/manifest.jsonl", "{}\n");
});
expect(cutout.files.map((file) => file.path)).toEqual([".media/manifest.jsonl", "index.html"]);
expect((await history.undo(cutout.id, { who: you })).ok).toBe(true);
expect(read(".media/manifest.jsonl")).toBe("{}\n");
});

it("never files a hidden file name as a change, but keeps Studio's two manifests", async () => {
const { history, write } = await project({ "index.html": "<h1>Hello</h1>" }, { quietMs: 30 });
const turn = await history.beginWindow(agent, "Agent turn");
Expand Down
22 changes: 20 additions & 2 deletions packages/studio-server/src/history/projectHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,12 @@ const statKey = (file: { size: number; mtimeMs: number; ctimeMs: number }, swept
? ""
: `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;

/** A hidden name anywhere in a path (a tool's own record, .DS_Store) is nobody's work, except Studio's manifests. */
const MEDIA_LEDGER = ".media/manifest.jsonl";
const KEPT_HIDDEN_PATHS = new Set<string>([...STUDIO_SIGNATURE_MANIFEST_PATHS, MEDIA_LEDGER]);

/** A hidden name anywhere in a path (a tool's own record, .DS_Store) is nobody's work, except the kept ones above. */
function isHistoryPath(path: string): boolean {
if ((STUDIO_SIGNATURE_MANIFEST_PATHS as readonly string[]).includes(path)) return true;
if (KEPT_HIDDEN_PATHS.has(path)) return true;
return !path.split("/").some((segment) => segment.startsWith("."));
}

Expand Down Expand Up @@ -403,6 +406,7 @@ class Engine {
);
if (!log) return this.firstOpen();
this.log = withoutHiddenPaths(log);
if (!this.log.keepsLedger) await this.takeInUnnamedLedger();
const cache = this.readStatCache();
const last = this.log.entries.at(-1)?.id ?? START;
for (const [path, hash] of manifestAt(this.log, last) ?? []) {
Expand Down Expand Up @@ -462,6 +466,19 @@ class Engine {
this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: new Map() });
}

async takeInUnnamedLedger(): Promise<void> {
const named = (path: string) =>
this.log.entries.some((entry) => entry.files.some((file) => file.path === path));
const listed = () => historyFiles(this.dir).some((file) => file.path === MEDIA_LEDGER);
const hash =
this.log.baseline.has(MEDIA_LEDGER) || named(MEDIA_LEDGER) || !listed()
? null
: await this.storeIfPresent(MEDIA_LEDGER);
if (hash !== null) this.log.baseline.set(MEDIA_LEDGER, hash);
this.log.keepsLedger = true;
this.persistLog();
}

async firstOpen(): Promise<void> {
const sweptAt = this.now();
for (const file of historyFiles(this.dir)) {
Expand All @@ -470,6 +487,7 @@ class Engine {
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
}
this.log.baseline = this.manifest();
this.log.keepsLedger = true;
this.persistLog();
this.saveStatCache();
}
Expand Down
Loading