feat(E1b.4): per-uuid pre-push backup cache + retention + "Revert last push"

Before any auto/manual push, the pre-push Foundry entry is cached so a wrong
push is one-click undoable from the dashboard (no shell, no hunting backups).

- src/push.ts: optional backupPath in PushDeps (writes there with mkdir -p;
  else the default flat <outDir>/bak/<name>.<stamp>.json for manual push).
  PushOutcome.backupPath returns the actual path.
- src/server.ts runPushBody: auto-sync passes the per-uuid
  <outDir>/foundry-backups/<uuid>/<iso>.json layout (so retention is per-entry,
  distinct from manual push's flat bak/). After a successful push: records a
  last-push {uuid,name,backupPath,time,relPath} in an in-memory map + as
  controller.lastPush (drives the dashboard button); prunes the per-uuid backup
  dir to the last N (AUTOSYNC_BACKUP_RETAIN default 10, by mtime, awaited so the
  post-push state is deterministic). GET /api/autosync/last-push?uuid=… returns
  the record or 404; status() exposes lastPush + conflictCount.
- src/server.ts AutoSyncController.revert(uuid): acquires the per-uuid lock in
  the "pull" direction (queues behind an in-flight push, 5s max); reads the
  backup JSON (400 if no last-push, 409 if the file is missing/corrupt); calls
  relay.updateEntry(uuid, fullBackupDoc) — a FULL /update (not a diff — the one
  place a full PUT is correct, to restore _id/pages/ownership/flags exactly);
  re-baselines the note (contentHash=body, ccHash=ccHash(backupDoc)) via the
  E1b.2 path + records self-write suppression; clears any TOCTOU conflict for
  the uuid. POST /api/autosync/revert endpoint (unguarded — E7 not landed).
  Gated by AUTOSYNC_FOUNDRY_GUARD (404 when off).
- src/dashboard.html: "Revert last push" button in the auto-sync panel (shown
  when a recent push exists + guard on), wired to POST /api/autosync/revert
  with a confirm stating the note keeps the edit while Foundry reverts.
- tests/e1b4-revert.test.ts: 6 tests — per-uuid backup written + last-push
  recorded; retention prunes to N; revert full-PUTs the backup + re-baselines;
  400 (no last-push); 409 (missing backup); 404 (guard off).

tsc clean; 135 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 00:00:48 +00:00
parent 8406a0a52a
commit f189fd739e
4 changed files with 319 additions and 6 deletions

View File

@@ -93,6 +93,7 @@
</header>
<details id="autoSyncPanel" class="autosync-panel" open style="display:none;margin:0 12px;border-top:1px solid #ddd;padding:6px 12px;background:#fafafa">
<summary style="cursor:pointer">Auto-sync activity <span id="autoSyncCounts" class="meta"></span><span id="autoSyncNote" class="meta" style="margin-left:8px"></span></summary>
<div id="revertBar" style="margin:6px 0;display:none"><button id="revertBtn" class="bad" onclick="revertLastPush()" title="Restore Foundry to the state captured BEFORE the most recent auto-sync push (a full /update), then re-baseline the note. Use this to undo a wrong push."></button></div>
<pre id="autoSyncLog" class="autosync-log" style="max-height:180px;overflow:auto;margin:6px 0 8px;font-size:12px;background:#fff;border:1px solid #eee;padding:6px">(no activity yet)</pre>
</details>
<main>
@@ -353,6 +354,29 @@ async function refreshAutosync(){
: '(no activity yet — save a linked, seeded note in your vault to trigger a push)';
if (r.enabled && !autoPoll) autoPoll = setInterval(refreshAutosync, 2000);
if (!r.enabled && autoPoll) { clearInterval(autoPoll); autoPoll = null; }
// E1b.4: "Revert last push" button — shown when there's a recent push and
// the guard is on (revert is meaningless without the per-uuid backups).
const revertBar = document.getElementById('revertBar');
const revertBtn = document.getElementById('revertBtn');
if (r.lastPush && r.enabled) {
revertBar.style.display = '';
revertBtn.textContent = `Revert last push: ${r.lastPush.name}`;
revertBtn.dataset.uuid = r.lastPush.uuid;
} else {
revertBar.style.display = 'none';
}
}
async function revertLastPush(){
const btn = document.getElementById('revertBtn');
const uuid = btn.dataset.uuid;
if (!uuid) return;
const noteName = btn.textContent.replace('Revert last push: ', '');
if (!confirm(`Revert the last push of "${noteName}"?\n\nThis restores Foundry to the state captured BEFORE the push (a full /update — the one place a full PUT is correct) and re-baselines the note. The note keeps your edit; Foundry reverts.`)) return;
toast('reverting last push…');
const r = await fetch('/api/autosync/revert', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({uuid})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) toast(`reverted — Foundry restored to pre-push state ("${r.restoredName ?? noteName}")`);
else toast(`revert failed: ${r?.error || 'unknown'}`);
refreshAutosync();
}
async function toggleAutosync(){
const want = !(AUTO && AUTO.enabled);

View File

@@ -1,5 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, dirname } from "node:path";
import { obsidianToFoundryJsonLive } from "./toFoundry.js";
import { splitFrontmatter, readFoundryBlock } from "./frontmatter.js";
import { RelayClient } from "./relay/client.js";
@@ -50,6 +50,12 @@ export interface PushDeps {
* (fail-safe: a Foundry-side edit since the last baseline must not be
* overwritten). Return `{ abort: false }` (or omit the guard) to proceed. */
prePushGuard?: (liveEntry: JournalEntry) => { abort: boolean; reason?: string };
/** E1b.4: override the backup file path. If set, the pre-push live-entry
* snapshot is written here (mkdir -p the dirname) instead of the default
* `<outDir>/bak/<noteName>.<stamp>.json` (which manual push keeps). Auto-sync
* uses the per-uuid `foundry-backups/<uuid>/<iso>.json` layout so retention is
* per-entry. */
backupPath?: string;
}
export interface PushOutcome {
@@ -205,10 +211,10 @@ export async function pushNote(deps: PushDeps): Promise<PushOutcome> {
}
// Apply: snapshot the live entry first (reversible), then PUT the diff.
const bakDir = join(deps.outDir, "bak");
await mkdir(bakDir, { recursive: true });
const stamp = backupStamp();
const backupPath = join(bakDir, `${deps.noteName}.${stamp}.json`);
// E1b.4: auto-sync passes a per-uuid backupPath (foundry-backups/<uuid>/<iso>.json);
// manual push keeps the default flat <outDir>/bak/<noteName>.<stamp>.json.
const backupPath = deps.backupPath ?? join(deps.outDir, "bak", `${deps.noteName}.${backupStamp()}.json`);
await mkdir(dirname(backupPath), { recursive: true });
await writeFile(backupPath, JSON.stringify(liveEntry, null, 2) + "\n", "utf8");
log(`push: backed up live entry -> ${backupPath}`);

View File

@@ -9,7 +9,7 @@
// unless the server was started with --apply.
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
import { readFile, writeFile, mkdir, copyFile, access, stat } from "node:fs/promises";
import { readFile, writeFile, mkdir, copyFile, access, stat, readdir, unlink } from "node:fs/promises";
import { watch, readdirSync, statSync, type FSWatcher } from "node:fs";
import { join, dirname, relative, basename, extname } from "node:path";
import { fileURLToPath } from "node:url";
@@ -287,6 +287,17 @@ async function handleRefresh(state: State, req: IncomingMessage, res: ServerResp
}
}
/** E1b.4: POST /api/autosync/revert {uuid} — restore Foundry to the pre-push
* backup and re-baseline. Thin wrapper over AutoSyncController.revert. */
async function handleRevert(state: State, req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readJsonBody(req);
if (body === null) return send(res, 400, { error: "bad json" });
const uuid = String(body.uuid ?? "");
if (!uuid) return send(res, 400, { error: "uuid required" });
const result = await state.autosync.revert(uuid);
send(res, result.code, result.body);
}
interface PushAllItem {
name: string;
ok: boolean;
@@ -538,6 +549,14 @@ export class AutoSyncController {
// successful push. Exposed via GET /api/autosync/conflicts.
conflicts: ConflictRow[] = [];
private readonly maxConflicts = 50;
// E1b.4: last-push record per uuid, pointing at the per-uuid backup file
// (foundry-backups/<uuid>/<iso>.json) pushNote wrote. Drives the "Revert last
// push" action. Exposed via GET /api/autosync/last-push?uuid=…
lastPushes = new Map<string, { uuid: string; name: string; backupPath: string; time: string; relPath: string }>();
private readonly backupRetain = Math.max(1, Number(process.env.AUTOSYNC_BACKUP_RETAIN ?? 10));
/** E1b.4: the most-recent push record (drives the dashboard "Revert last push"
* button). Updated on every successful push. */
lastPush: { uuid: string; name: string; backupPath: string; time: string; relPath: string } | null = null;
private readonly syncLockEnabled = SYNC_LOCK_ENABLED;
// relPath → last-resolved lock key (foundry.cc_uuid or relPath: fallback),
// populated by `process` so the debounce pre-check can fast-skip a redundant
@@ -571,6 +590,8 @@ export class AutoSyncController {
// a push and needs manual reconciliation.
conflictCount: this.conflicts.length,
conflicts: this.conflicts,
// E1b.4: the most-recent push record (drives the "Revert last push" button).
lastPush: this.lastPush,
};
}
@@ -800,6 +821,9 @@ export class AutoSyncController {
}
: undefined;
// E1b.4: per-uuid backup path (foundry-backups/<uuid>/<iso>.json) so retention
// is per-entry and "Revert last push" can restore the pre-push Foundry state.
const backupPath = join(this.state.cfg.outDir, "foundry-backups", fb!.cc_uuid, `${backupStamp()}.json`);
const outcome = await pushNote({
notePath: abs,
noteName: name,
@@ -810,6 +834,7 @@ export class AutoSyncController {
dryRun: false, // auto-sync always applies — the whole point is hands-off live push
log: () => {},
prePushGuard,
backupPath,
});
// E1b.1: guard aborted the push (Foundry-side drift or unreadable). No PUT,
@@ -903,15 +928,80 @@ export class AutoSyncController {
if (this.conflicts.some((c) => c.uuid === fb!.cc_uuid)) {
this.conflicts = this.conflicts.filter((c) => c.uuid !== fb!.cc_uuid);
}
// E1b.4: record the last-push (pointing at the per-uuid backup) for "Revert
// last push", and prune the per-uuid backup dir to the last N (by mtime).
this.lastPushes.set(fb!.cc_uuid, { uuid: fb!.cc_uuid, name, backupPath, time: new Date().toISOString(), relPath });
this.lastPush = this.lastPushes.get(fb!.cc_uuid) ?? null;
// E1b.4: prune the per-uuid backup dir to the last N (awaited so the
// post-push state is deterministic — tests assert the retention count).
await this.pruneBackups(fb!.cc_uuid).catch((e) => this.log(name, "error", `backup prune failed: ${(e as Error).message}`));
this.log(name, "pushed", `${outcome.ccUuid}${outcome.updatedName ? ` ("${outcome.updatedName}")` : ""}${baselined ? " · baselined (content+cc)" : ""}`);
return true;
}
/** E1b.4: keep only the last `backupRetain` backups for a uuid (newest by mtime),
* deleting older files. Deletions are logged. Best-effort (never throws). */
private async pruneBackups(uuid: string): Promise<void> {
const dir = join(this.state.cfg.outDir, "foundry-backups", uuid);
let files: string[];
try { files = await readdir(dir); } catch { return; } // dir missing — nothing to prune
const stamped = await Promise.all(files.map(async (f) => {
try { return { f, mtime: (await stat(join(dir, f))).mtimeMs }; } catch { return null; }
}));
const ok = stamped.filter(Boolean) as { f: string; mtime: number }[];
if (ok.length <= this.backupRetain) return;
ok.sort((a, b) => b.mtime - a.mtime); // newest first
const toDelete = ok.slice(this.backupRetain);
for (const { f } of toDelete) {
try { await unlink(join(dir, f)); this.log(uuid, "skipped", `backup pruned: ${f}`); } catch { /* best-effort */ }
}
}
/** E1b.3: record a TOCTOU conflict (newest first, bounded to maxConflicts). */
private recordConflict(row: ConflictRow): void {
this.conflicts.unshift(row);
if (this.conflicts.length > this.maxConflicts) this.conflicts.length = this.maxConflicts;
}
/** E1b.4: revert the last push for a uuid — restore Foundry to the pre-push
* backup (a FULL /update, NOT a diff — the one place a full PUT is correct, so
* _id/pages/ownership/flags are restored exactly), then re-baseline the note
* to the restored Foundry state. Acquires the per-uuid lock in the "pull"
* direction (a Foundry→vault reconciliation), queuing behind an in-flight push.
* Gated by AUTOSYNC_FOUNDRY_GUARD (404 when off). */
async revert(uuid: string): Promise<{ code: number; body: unknown }> {
if (!this.foundryGuardEnabled) return { code: 404, body: { error: "revert disabled (AUTOSYNC_FOUNDRY_GUARD off)" } };
const rec = this.lastPushes.get(uuid);
if (!rec) return { code: 400, body: { error: `no last push for ${uuid}` } };
let backupJson: string;
try { backupJson = await readFile(rec.backupPath, "utf8"); }
catch { return { code: 409, body: { error: `backup file missing: ${rec.backupPath}` } }; }
let backupDoc: JournalEntry;
try { backupDoc = JSON.parse(backupJson) as JournalEntry; }
catch { return { code: 409, body: { error: `backup file corrupt: ${rec.backupPath}` } }; }
const result = await this.lock.withLock<{ code: number; body: unknown }>(uuid, "pull", async () => {
try {
const relay = relayClient(this.state);
const updated = await relay.updateEntry(uuid, backupDoc as unknown as Record<string, unknown>);
const abs = await resolveRefined(this.state, rec.relPath);
const restoredCcHash = ccHash(backupDoc);
this.ccHashBaselines.set(uuid, restoredCcHash);
await baselineNote(this.state, rec.relPath, abs, restoredCcHash);
try {
const st = statSync(join(this.state.cfg.refinedDir, rec.relPath));
this.recentlyBaselined.set(rec.relPath, { mtime: st.mtimeMs, baselineHash: restoredCcHash, expires: Date.now() + this.baselineSuppressMs });
} catch { /* skip self-write suppression */ }
// Revert resolves any open TOCTOU conflict for this uuid.
if (this.conflicts.some((c) => c.uuid === uuid)) this.conflicts = this.conflicts.filter((c) => c.uuid !== uuid);
this.log(rec.name, "pushed", `reverted → ${uuid} (restored pre-push Foundry state) · baselined (content+cc)`);
return { code: 200, body: { ok: true, uuid, name: rec.name, restoredName: updated.name } };
} catch (e) {
this.log(rec.name, "error", `revert failed: ${(e as Error).message}`);
return { code: 500, body: { error: (e as Error).message } };
}
}, { policy: "queue", maxWaitMs: 5000 });
return result ?? { code: 409, body: { error: "lock busy — another operation holds this uuid; try again" } };
}
}
export async function startServer(cfg: ServerConfig): Promise<{ server: Server; state: State }> {
@@ -985,6 +1075,18 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
if (req.method === "GET" && url.pathname === "/api/autosync/conflicts") {
return send(res, 200, { conflicts: state.autosync.conflicts });
}
// E1b.4: last-push record per uuid (drives the "Revert last push" button).
if (req.method === "GET" && url.pathname === "/api/autosync/last-push") {
const uuid = url.searchParams.get("uuid") ?? "";
const rec = state.autosync.lastPushes.get(uuid);
if (!rec) return send(res, 404, { error: `no last push for ${uuid}` });
return send(res, 200, rec);
}
// E1b.4: revert the last push (restore Foundry to the pre-push backup +
// re-baseline). Registered unguarded (E7 not landed yet).
if (req.method === "POST" && url.pathname === "/api/autosync/revert") {
return handleRevert(state, req, res);
}
if (req.method === "POST" && url.pathname === "/api/autosync") {
const body = await readJsonBody(req);
if (body === null) return send(res, 400, { error: "bad json" });

181
tests/e1b4-revert.test.ts Normal file
View File

@@ -0,0 +1,181 @@
// E1b.4 — per-uuid backup cache + retention + "Revert last push".
//
// Exercises the REAL pushNote + controller against a MOCKED relay (behaves like
// Foundry: /get returns current state, /update applies the diff or, for revert,
// receives the FULL backup doc). Covers:
// 1. a clean push writes foundry-backups/<uuid>/<iso>.json + records last-push.
// 2. retention keeps the last N backups per uuid (older files pruned).
// 3. revert restores Foundry to the backup (FULL /update, not a diff) +
// re-baselines the note (ccHash = ccHash(backupDoc)).
// 4. revert with no last-push record → 400.
// 5. revert with a missing backup file → 409.
// 6. revert disabled when AUTOSYNC_FOUNDRY_GUARD is off → 404.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm, readFile, readdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { ccHash } from "../src/cchash.js";
import { obsidianToFoundryJsonLive } from "../src/toFoundry.js";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
import type { JournalEntry } from "../src/types.js";
import type { NameResolver } from "../src/resolver.js";
const UUID = "JournalEntry.abc1";
const REL = "Roland.md";
const EMPTY_RESOLVER: NameResolver = { nameOf: () => undefined, uuidOf: () => undefined };
function liveEntry(description: string): JournalEntry {
return { name: "Roland", _id: "abc1", folder: "Folder.test", flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } } };
}
function seededNote(ccHashBaseline: string, body: string): string {
return [
"---", "type: npc", "foundry:", ` cc_uuid: ${UUID}`, " cc_type: npc", " folder_path: NPCs",
` contentHash: ${"0".repeat(64)}`, ` ccHash: ${ccHashBaseline}`, " syncedAt: 2026-06-22T00:00:00.000Z",
"---", body, "",
].join("\n");
}
let dir: string;
let state: State;
let putCalls: number;
let currentState: JournalEntry;
let lastUpdateBody: unknown; // the body sent to the last /update (to assert full vs diff)
const realFetch = globalThis.fetch;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e1b4-"));
await mkdir(join(dir, "refined"), { recursive: true });
putCalls = 0;
currentState = liveEntry("<p>Original body.</p>");
lastUpdateBody = null;
const cfg: ServerConfig = {
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
mode: "apply", port: 0, host: "", relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
};
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
let getCalls = 0;
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url); const method = init?.method ?? "GET";
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
const resp = (o: unknown, ok = true, status = 200) => ({ ok, status, text: async () => (typeof o === "string" ? o : JSON.stringify(o)) });
if (method === "GET" && u.includes("/get?")) { const re = getCalls === 1; getCalls++; return resp({ data: re ? currentState : currentState }); }
if (method === "GET" && u.includes("/search")) return resp({ results: [] });
if (method === "PUT" && u.includes("/update?")) {
putCalls++; lastUpdateBody = body;
const diff = body?.data ?? {};
currentState = { ...currentState, name: diff.name ?? currentState.name, flags: { ...currentState.flags, "campaign-codex": diff["flags.campaign-codex"] ?? currentState.flags?.["campaign-codex"] } };
return resp({ entity: [currentState] });
}
return resp({ error: "not found" }, false, 404);
}) as unknown as typeof fetch;
});
afterEach(async () => {
globalThis.fetch = realFetch;
await rm(dir, { recursive: true, force: true });
});
async function writeNote(baseline: string, body: string): Promise<void> {
await writeFile(join(state.cfg.refinedDir, REL), seededNote(baseline, body), "utf8");
}
function expectedPushedEntry(body: string, base: JournalEntry): JournalEntry {
return obsidianToFoundryJsonLive(body, "Roland", base, EMPTY_RESOLVER);
}
describe("E1b.4a per-uuid backup + retention", () => {
it("a clean push writes foundry-backups/<uuid>/<iso>.json + records last-push", async () => {
const body = "The gunslinger drew his revolver.\n";
const prePush = currentState;
await writeNote(ccHash(prePush), body);
const controller = new AutoSyncController(state);
await (controller as any).process(REL);
expect(putCalls).toBe(1);
const rec = controller.lastPushes.get(UUID);
expect(rec).toBeTruthy();
expect(rec!.uuid).toBe(UUID);
expect(rec!.relPath).toBe(REL);
// The backup file exists at the per-uuid path.
expect(existsSync(rec!.backupPath)).toBe(true);
expect(rec!.backupPath).toContain(join("foundry-backups", UUID));
// The backup content is the pre-push live entry.
const backup = JSON.parse(await readFile(rec!.backupPath, "utf8")) as JournalEntry;
expect(backup.flags?.["campaign-codex"]?.data?.description).toBe("<p>Original body.</p>");
});
it("retention keeps only the last N backups per uuid (older pruned)", async () => {
const controller = new AutoSyncController(state);
(controller as any).backupRetain = 2; // shrink so the test is quick
let prePush = currentState;
for (let i = 0; i < 3; i++) {
const body = `Body version ${i}.\n`;
await writeNote(ccHash(currentState), body); // baseline = current Foundry state (guard passes)
prePush = currentState;
await (controller as any).process(REL);
// After the push, currentState is the pushed entry; next iteration baselines to it.
}
expect(putCalls).toBe(3);
const backupDir = join(state.cfg.outDir, "foundry-backups", UUID);
const files = await readdir(backupDir);
expect(files.length).toBe(2); // pruned to last N (2)
});
});
describe("E1b.4b revert last push", () => {
it("revert restores Foundry to the backup (FULL /update) + re-baselines the note", async () => {
const body = "The gunslinger drew his revolver.\n";
const prePush = currentState;
await writeNote(ccHash(prePush), body);
const controller = new AutoSyncController(state);
await (controller as any).process(REL); // push 1: records last-push + backup
expect(putCalls).toBe(1);
const rec = controller.lastPushes.get(UUID)!;
const backupDoc = JSON.parse(await readFile(rec.backupPath, "utf8")) as JournalEntry;
const result = await controller.revert(UUID);
expect(result.code).toBe(200);
expect(putCalls).toBe(2); // the revert PUT fired
// The revert sent the FULL backup doc (with _id/pages/ownership), not a diff.
expect((lastUpdateBody as any)?.data).toEqual(backupDoc);
// The note re-baselined to the restored Foundry state (ccHash = ccHash(backupDoc)).
const fb = readFoundryBlock(splitFrontmatter(await readFile(join(state.cfg.refinedDir, REL), "utf8")).fm);
expect(fb?.ccHash).toBe(ccHash(backupDoc));
// The in-memory ccHash baseline also reflects the restored state.
expect((controller as any).ccHashBaselines.get(UUID)).toBe(ccHash(backupDoc));
expect(controller.events.some((e) => e.message.includes("reverted"))).toBe(true);
});
it("revert with no last-push record → 400", async () => {
const controller = new AutoSyncController(state);
const result = await controller.revert(UUID);
expect(result.code).toBe(400);
expect((result.body as { error: string }).error).toMatch(/no last push/);
});
it("revert with a missing backup file → 409", async () => {
const body = "The gunslinger drew his revolver.\n";
await writeNote(ccHash(currentState), body);
const controller = new AutoSyncController(state);
await (controller as any).process(REL); // records last-push + writes backup
const rec = controller.lastPushes.get(UUID)!;
await rm(rec.backupPath, { force: true }); // manual cleanup deletes the backup
const result = await controller.revert(UUID);
expect(result.code).toBe(409);
expect((result.body as { error: string }).error).toMatch(/backup file missing/);
});
it("revert disabled when AUTOSYNC_FOUNDRY_GUARD is off → 404", async () => {
const body = "The gunslinger drew his revolver.\n";
await writeNote(ccHash(currentState), body);
const controller = new AutoSyncController(state);
(controller as any).foundryGuardEnabled = false;
const result = await controller.revert(UUID);
expect(result.code).toBe(404);
});
});