diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts
index a5258345f4d..030f5740316 100644
--- a/packages/studio-server/src/history/projectHistory.test.ts
+++ b/packages/studio-server/src/history/projectHistory.test.ts
@@ -104,6 +104,123 @@ async function change(history: ProjectHistory, who: HistoryWho, label: string, w
}
describe("openProjectHistory", () => {
+ it("never writes a hidden file an older log still names, on undo or restore", async () => {
+ const { history, write, read, projectDir, historyRoot } = await project({
+ "index.html": "v1",
+ "turn-record": "A",
+ });
+ const turn = await change(history, agent, "Agent turn", () => {
+ write("index.html", "v2");
+ write("turn-record", "B");
+ });
+ await history.close();
+ // What an older log holds: the record under its hidden name, as history filed it before.
+ const logFile = join(historyRoot, history.projectId, "log.jsonl");
+ writeFileSync(
+ logFile,
+ readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'),
+ );
+ renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record"));
+ const reopened = await open(projectDir, historyRoot);
+ write(".turn-record", "C");
+ expect((await reopened.undo(turn.id, { who: you })).ok).toBe(true);
+ expect(read("index.html")).toBe("v1");
+ expect(read(".turn-record")).toBe("C");
+ await reopened.restore(START, you);
+ expect(read(".turn-record")).toBe("C");
+ });
+
+ it("steps Undo past an older log's entry that changed only a hidden file", async () => {
+ const { history, write, read, projectDir, historyRoot } = await project(
+ { "index.html": "v1", "turn-record": "A" },
+ { quietMs: 30 },
+ );
+ await change(history, you, "Your edit", () => write("index.html", "v2"));
+ write("turn-record", "B");
+ history.noteChange("turn-record");
+ await history.flush();
+ expect(history.list().map((entry) => entry.who.kind)).toEqual(["person", "outside"]);
+ await history.close();
+ const logFile = join(historyRoot, history.projectId, "log.jsonl");
+ writeFileSync(
+ logFile,
+ readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'),
+ );
+ renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record"));
+ const reopened = await open(projectDir, historyRoot);
+ expect(reopened.list().map((entry) => entry.files.length)).toEqual([1, 0]);
+ expect(await reopened.step("back", you)).toMatchObject({ ok: true });
+ expect(read("index.html")).toBe("v1");
+ });
+
+ it("keeps every entry an older log names, emptied ones too, and undoing one still lands", async () => {
+ const { history, write, projectDir, historyRoot } = await project(
+ { "index.html": "v1", "turn-record": "A" },
+ { quietMs: 30 },
+ );
+ write("turn-record", "B");
+ history.noteChange("turn-record");
+ await history.flush();
+ const [hidden] = history.list();
+ await history.pin(hidden!.id, true);
+ await history.close();
+ const logFile = join(historyRoot, history.projectId, "log.jsonl");
+ writeFileSync(
+ logFile,
+ readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'),
+ );
+ renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record"));
+ const reopened = await open(projectDir, historyRoot);
+ expect(reopened.list()).toMatchObject([{ id: hidden!.id, files: [], pinned: true }]);
+ const undone = await reopened.undo(hidden!.id, { who: you });
+ expect(undone).toMatchObject({ ok: true, entry: { undoes: hidden!.id, files: [] } });
+ });
+
+ it("keeps an older log's Undo that only reverted a hidden file, so its target stays undone", async () => {
+ const { history, write, projectDir, historyRoot } = await project({
+ "index.html": "A",
+ "turn-record": "H0",
+ });
+ const yours = await change(history, you, "Your edit", () => {
+ write("index.html", "B");
+ write("turn-record", "H1");
+ });
+ await change(history, agent, "Agent turn", () => write("index.html", "C"));
+ const keep = { who: you, mode: "keep-later-edits" } as const;
+ expect(await history.undo(yours.id, keep)).toMatchObject({ ok: true });
+ expect(history.next("back", you)).toBeUndefined();
+ await history.close();
+ const logFile = join(historyRoot, history.projectId, "log.jsonl");
+ writeFileSync(
+ logFile,
+ readFileSync(logFile, "utf-8").replaceAll('"turn-record"', '".turn-record"'),
+ );
+ renameSync(join(projectDir, "turn-record"), join(projectDir, ".turn-record"));
+ const reopened = await open(projectDir, historyRoot);
+ expect(reopened.next("back", you)).toBeUndefined();
+ expect(reopened.next("forward", you)).toBeUndefined();
+ });
+
+ it("never files a hidden file name as a change, but keeps Studio's two manifests", async () => {
+ const { history, write } = await project({ "index.html": "
Hello
" }, { quietMs: 30 });
+ const turn = await history.beginWindow(agent, "Agent turn");
+ write("index.html", "Bye
");
+ write(".turn-record", "a tool's own record");
+ write("sub/.DS_Store", "finder");
+ write(".hyperframes/studio-motion.json", "{}");
+ const entry = await turn.close();
+ expect(entry!.files.map((file) => file.path)).toEqual([
+ ".hyperframes/studio-motion.json",
+ "index.html",
+ ]);
+ const records = await history.beginWindow(agent, "Records only");
+ write(".spawn-record", "pid");
+ expect(await records.close()).toBeNull();
+ history.noteChange(".spawn-record");
+ await history.flush();
+ expect(history.list()).toHaveLength(1);
+ });
+
it("records a write nobody announced as one outside entry, and undo puts the bytes back as a new entry", async () => {
const { history, write, read, projectDir } = await project(
{ "index.html": "Hello
", "assets/logo.png": Buffer.from([1, 2, 3]) },
diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts
index efccf1e4b3f..690191dc427 100644
--- a/packages/studio-server/src/history/projectHistory.ts
+++ b/packages/studio-server/src/history/projectHistory.ts
@@ -24,7 +24,11 @@ import {
recordFileWriteReceipt,
} from "../helpers/fileVersion.js";
import { realFilePath } from "../helpers/safePath.js";
-import { affectsProjectSignature, listProjectFiles } from "../helpers/projectSignature.js";
+import {
+ STUDIO_SIGNATURE_MANIFEST_PATHS,
+ affectsProjectSignature,
+ listProjectFiles,
+} from "../helpers/projectSignature.js";
import { openBlobStore, type BlobStore } from "./blobStore.js";
import { pruneGoneProjectHistoriesDaily } from "./pruneHistories.js";
import {
@@ -199,6 +203,22 @@ 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. */
+function isHistoryPath(path: string): boolean {
+ if ((STUDIO_SIGNATURE_MANIFEST_PATHS as readonly string[]).includes(path)) return true;
+ return !path.split("/").some((segment) => segment.startsWith("."));
+}
+
+function withoutHiddenPaths(log: HistoryLog): HistoryLog {
+ for (const path of log.baseline.keys()) if (!isHistoryPath(path)) log.baseline.delete(path);
+ for (const entry of log.entries)
+ entry.files = entry.files.filter((file) => isHistoryPath(file.path));
+ return log;
+}
+
+const historyFiles = (dir: string) =>
+ listProjectFiles(dir).filter((file) => isHistoryPath(file.path));
+
const sameWho = (a: HistoryWho, b: HistoryWho) => a.kind === b.kind && a.name === b.name;
/** Cuts a change at the overwritten `at`: [kept before it, claimed after]; `at` unknown or not kept claims all. */
@@ -382,7 +402,7 @@ class Engine {
),
);
if (!log) return this.firstOpen();
- this.log = log;
+ this.log = withoutHiddenPaths(log);
const cache = this.readStatCache();
const last = this.log.entries.at(-1)?.id ?? START;
for (const [path, hash] of manifestAt(this.log, last) ?? []) {
@@ -402,6 +422,7 @@ class Engine {
const replaced = hashOfVersion(fileContentVersion(bytes));
if (!replaced || replaced === after) return;
const path = relative(realDir, absPath).split(sep).join("/");
+ if (!isHistoryPath(path)) return;
// One chain per file, a new Map per write (forgetWritesBefore checks identity); off-chain notes were overwritten.
this.overwritten.set(
path,
@@ -443,7 +464,7 @@ class Engine {
async firstOpen(): Promise {
const sweptAt = this.now();
- for (const file of listProjectFiles(this.dir)) {
+ for (const file of historyFiles(this.dir)) {
const hash = await this.storeIfPresent(file.path);
if (this.whereFolder() !== "here") throw this.replaced();
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
@@ -482,7 +503,7 @@ class Engine {
const sweptAt = this.now();
const changedAt = (file: { mtimeMs: number; ctimeMs: number }) =>
Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
- const seen = listProjectFiles(this.dir);
+ const seen = historyFiles(this.dir);
const heard = new Map(this.overwritten);
const present = new Set(seen.map((file) => file.path));
const removed = [...this.tracked.keys()]
@@ -737,7 +758,9 @@ class Engine {
}
async commit(group: Group, extra: Partial = {}): Promise {
- if (!group.changes.size) return null;
+ const undoesEmptied =
+ this.log.entries.find((entry) => entry.id === extra.undoes)?.files.length === 0;
+ if (!group.changes.size && !undoesEmptied) return null;
const pending = this.pendingEntry(group);
const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
const entry: HistoryEntry = { ...pending, endedAt, ...extra };
@@ -802,7 +825,9 @@ class Engine {
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
noteChange(path: string): void {
- if (this.notedTimer || !affectsProjectSignature(this.dir, resolve(this.dir, path))) return;
+ const absolute = resolve(this.dir, path);
+ if (this.notedTimer || !affectsProjectSignature(this.dir, absolute)) return;
+ if (!isHistoryPath(relative(this.dir, absolute).split(sep).join("/"))) return;
this.notedTimer = setTimeout(() => {
this.notedTimer = null;
this.background(() => this.sweep());
@@ -995,7 +1020,7 @@ class Engine {
return stepTarget(
this.log.entries,
direction,
- (entry) => mine(entry.who) || (everyone && !ofOpenTurn(entry)),
+ (entry) => entry.files.length > 0 && (mine(entry.who) || (everyone && !ofOpenTurn(entry))),
);
}