feat(E2.4): never-clobber routing — both-diverged/vault-newer → pending conflict

When both sides changed since last sync (both-diverged) OR Foundry's entry is
missing while the vault note exists (vault-newer), the F→O pull is SKIPPED and a
pending conflict row is recorded for E3 to render. No auto-pick, no auto-write.

- src/server.ts PendingConflictRow type: {uuid, name, state:
  "both-diverged"|"vault-newer", detectedAt, lastFHash, lastOHash}.
  AutoSyncController.recordPendingConflict(uuid, name, state, lastFHash,
  lastOHash): records in sync-state.json.pendingConflicts (deduped by uuid),
  removes from fPending, updates parity, saves atomically.
  pullFChanged: the "conflict" return now records a both-diverged pending conflict
  row (with lastFHash=ccHash(liveEntry), lastOHash=contentHash(body)) before
  returning — awaited so the save is durable.
- src/foundry-poll.ts shallowPoll: "missing" entries now route to
  recordPendingConflict("vault-newer") via state.autosync (guarded — falls back
  to fPending-only if autosync is unavailable). GET /api/foundry-poll includes
  pendingConflicts from sync-state.json in the response.
- tests/e2-4-conflict.test.ts: 3 tests — both-diverged → pendingConflicts row
  with correct hashes + removed from fPending; vault-newer (shallow poll missing)
  → pendingConflicts row; recordPendingConflict accessible.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:21:20 +00:00
parent 2457596f26
commit 8f48f356ce
3 changed files with 180 additions and 2 deletions

View File

@@ -191,6 +191,10 @@ export class FoundryPollController {
if (!snapshot.has(uuid)) {
const prev = this.prevSnapshot.get(uuid);
if (prev) { // was in the last snapshot, now gone
// E2.4: route to pendingConflicts (vault-newer) if autosync is available.
if (this.state.autosync && typeof (this.state.autosync as any).recordPendingConflict === "function") {
void (this.state.autosync as any).recordPendingConflict(uuid, prev.name, "vault-newer", "", "");
}
changes.push({ uuid, name: prev.name, change: "missing", detectedAt: new Date().toISOString() });
}
}

View File

@@ -786,6 +786,17 @@ export interface ConflictRow {
relPath: string;
}
/** E2.4: a pending conflict (both-diverged or vault-newer) awaiting E3 resolution.
* Recorded in sync-state.json.pendingConflicts; surfaced via GET /api/foundry-poll. */
export interface PendingConflictRow {
uuid: string;
name: string;
state: "both-diverged" | "vault-newer";
detectedAt: string;
lastFHash: string;
lastOHash: string;
}
/**
* Auto-sync (Obsidian→Foundry, instant). Watches the refined vault dir for .md saves
* and pushes each changed, linked, seeded note into live Foundry via the relay — the
@@ -1443,7 +1454,14 @@ export class AutoSyncController {
const { fm, body } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
if (!fb?.contentHash) return "skipped"; // unseeded
if (contentHash(body) !== fb.contentHash) return "conflict"; // O-side changed → E2.4
if (contentHash(body) !== fb.contentHash) {
// E2.4: both-diverged (F-changed AND O-changed) → record a pending conflict
// row in sync-state.json, remove from fPending. E3 renders resolution.
const lastFHash = (() => { try { return ccHash(liveEntry); } catch { return ""; } })();
const lastOHash = contentHash(body);
await this.recordPendingConflict(uuid, row.name, "both-diverged", lastFHash, lastOHash);
return "conflict";
}
// O-unchanged → pull under the per-uuid lock.
const ran = await this.lock.withLock<boolean>(uuid, "pull", async () => {
const out = await rePullRow(row, this.state.cfg.refinedDir, this.state.db, new Date().toISOString(), abs);
@@ -1473,6 +1491,28 @@ export class AutoSyncController {
return "skipped"; // lock busy
}
/** E2.4: record a pending conflict row (both-diverged or vault-newer) in
* sync-state.json.pendingConflicts + remove the uuid from fPending + update
* parity. E3 renders resolution; this story does NOT auto-write. */
async recordPendingConflict(uuid: string, name: string, conflictState: "both-diverged" | "vault-newer", lastFHash: string, lastOHash: string): Promise<void> {
if (!this.state.syncState) return;
const s = this.state.syncState;
const pc = (Array.isArray((s as unknown as { pendingConflicts?: unknown[] }).pendingConflicts) ? (s as unknown as { pendingConflicts: PendingConflictRow[] }).pendingConflicts : []) as PendingConflictRow[];
// Dedup by uuid (replace if already present).
const filtered = pc.filter((e) => e.uuid !== uuid);
filtered.push({ uuid, name, state: conflictState, detectedAt: new Date().toISOString(), lastFHash, lastOHash });
(s as unknown as { pendingConflicts: PendingConflictRow[] }).pendingConflicts = filtered;
// Remove from fPending.
const fPending = (s as unknown as { fPending?: { uuid: string }[] }).fPending;
if (Array.isArray(fPending)) {
(s as unknown as { fPending: { uuid: string }[] }).fPending = fPending.filter((e) => e.uuid !== uuid);
s.parity.fPending = (s as unknown as { fPending: { uuid: string }[] }).fPending.length;
}
const p = s.parity;
p.status = p.conflict > 0 ? "conflict" : p.oPending > 0 ? "O-pending" : p.fPending > 0 ? "F-pending" : p.unsyncedLinked > 0 ? "unsynced-linked" : "in-parity";
await saveSyncState(this.state.cfg.outDir, s).catch(() => {});
}
/** 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
@@ -1736,7 +1776,10 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
method: "GET", requireAuth: true, requireCSRF: false,
handler: async (_s, _req, res) => {
if (!state.cfg.features?.foundryPoll) return send(res, 404, { error: "foundry-poll disabled" });
return send(res, 200, state.foundryPoll?.status() ?? { enabled: false });
const st = state.foundryPoll?.status() ?? { enabled: false };
// E2.4: include pendingConflicts from sync-state.json for E3 to render.
const pc = (state.syncState as unknown as { pendingConflicts?: unknown[] })?.pendingConflicts ?? [];
return send(res, 200, { ...st, pendingConflicts: pc });
},
},
"POST /api/foundry-poll": {

131
tests/e2-4-conflict.test.ts Normal file
View File

@@ -0,0 +1,131 @@
// E2.4 — never-clobber routing: both-diverged / vault-newer → pending conflict row.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AutoSyncController } from "../src/server.js";
import { FoundryPollController } from "../src/foundry-poll.js";
import type { State, ServerConfig } from "../src/server.js";
import { ccHash } from "../src/cchash.js";
import { contentHash } from "../src/normalize.js";
import { saveSyncState, defaultSyncState, type SyncState } from "../src/sync-state.js";
import type { JournalEntry } from "../src/types.js";
import type { FileRow } from "../src/batch.js";
const UUID = "JournalEntry.aaa";
let dir: string;
let state: State;
const realFetch = globalThis.fetch;
function liveEntry(description: string): JournalEntry {
return { name: "Roland", _id: "aaa", folder: "Folder.test", flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } } };
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e2-4-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "out"), { recursive: true });
const cfg: ServerConfig = {
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: true, foundryPoll: true },
};
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
state.syncState = { ...defaultSyncState(cfg.refinedDir) } as SyncState;
await saveSyncState(cfg.outDir, state.syncState!);
state.autosync = new AutoSyncController(state);
});
afterEach(async () => { globalThis.fetch = realFetch; await rm(dir, { recursive: true, force: true }); });
async function writeNote(body: string, contentHashBaseline: string, ccHashBaseline: string): Promise<void> {
await writeFile(join(dir, "refined", "Roland.md"), [
"---", "type: npc", "foundry:", ` cc_uuid: ${UUID}`, " cc_type: npc",
` folder_path: Folder.test`, ` contentHash: ${contentHashBaseline}`,
` ccHash: ${ccHashBaseline}`, " syncedAt: 2026-06-22T00:00:00.000Z", "---", body, "",
].join("\n"), "utf8");
}
function setupIndex(): void {
const row: FileRow = {
name: "Roland", basename: "Roland", status: "matched" as never,
refinedPath: join(dir, "refined", "Roland.md"), ccPath: null, ccId: UUID, ccType: "npc",
curatedType: null, entry: { _id: "aaa", name: "Roland" } as JournalEntry, recommendation: "in-sync" as never,
refinedChanged: false, ccChanged: false, refinedMtime: null, ccMtime: null,
storedRefinedHash: null, storedCcHash: null,
};
(state as any).index = { matched: [row], ccOnly: [], refinedOnly: [], counts: { matched: 1, ccOnly: 0, refinedOnly: 0, unlinked: 0 } };
}
async function readSyncState(): Promise<SyncState & { pendingConflicts?: { uuid: string; state: string; lastFHash: string; lastOHash: string }[]; fPending?: { uuid: string }[] }> {
return JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8"));
}
describe("E2.4 both-diverged → pending conflict row", () => {
it("F-changed + O-changed → 'conflict' + pendingConflicts row recorded + removed from fPending", async () => {
const live = liveEntry("<p>Foundry-edited body.</p>");
// Note: contentHash = "0"*64 (stale) → O-side changed (bodyHash ≠ contentHash).
// ccHash = "0"*64 (stale) → F-side changed (ccHash(live) ≠ ccHash baseline).
await writeNote("Edited in Obsidian.\n", "0".repeat(64), "0".repeat(64));
setupIndex();
// Pre-populate fPending with the uuid (simulating the deep poll detected it).
(state.syncState as any).fPending = [{ uuid: UUID, name: "Roland", change: "edited", detectedAt: new Date().toISOString() }];
await saveSyncState(state.cfg.outDir, state.syncState!);
const result = await state.autosync.pullFChanged(UUID, live);
expect(result).toBe("conflict");
const saved = await readSyncState();
expect(saved.pendingConflicts).toBeTruthy();
expect(saved.pendingConflicts!.length).toBe(1);
const pc = saved.pendingConflicts![0];
expect(pc.uuid).toBe(UUID);
expect(pc.state).toBe("both-diverged");
expect(pc.lastFHash).toBe(ccHash(live));
expect(pc.lastOHash).toBe(contentHash("Edited in Obsidian.\n"));
// Removed from fPending.
expect((saved.fPending ?? []).some((e) => e.uuid === UUID)).toBe(false);
});
});
describe("E2.4 vault-newer (missing) → pending conflict row", () => {
it("shallow poll detects missing → recordPendingConflict('vault-newer')", async () => {
setupIndex();
// First poll: Roland present.
globalThis.fetch = vi.fn(async (url: string) => {
if (String(url).includes("/search")) return { ok: true, status: 200, text: async () => JSON.stringify({ results: [{ uuid: UUID, id: "aaa", name: "Roland", documentType: "JournalEntry" }] }) } as unknown as Response;
return { ok: false, status: 404, text: async () => '{"error":"not found"}' } as unknown as Response;
}) as unknown as typeof fetch;
const controller = new FoundryPollController(state);
await controller.setEnabled(true);
// Wait for first poll.
while ((controller as any).prevSnapshot.size === 0) await new Promise<void>((r) => setTimeout(r, 10));
// Second poll: Roland gone.
globalThis.fetch = vi.fn(async (url: string) => {
if (String(url).includes("/search")) return { ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) } as unknown as Response;
return { ok: false, status: 404, text: async () => '{"error":"not found"}' } as unknown as Response;
}) as unknown as typeof fetch;
await (controller as any).tick();
controller.stop();
const saved = await readSyncState();
expect(saved.pendingConflicts).toBeTruthy();
expect(saved.pendingConflicts!.some((e) => e.uuid === UUID && e.state === "vault-newer")).toBe(true);
});
});
describe("E2.4 GET /api/foundry-poll includes pendingConflicts", () => {
it("recordPendingConflict → the row is in sync-state.json + accessible via the poll status", async () => {
await state.autosync.recordPendingConflict(UUID, "Roland", "both-diverged", "abc", "def");
const saved = await readSyncState();
expect(saved.pendingConflicts?.length).toBe(1);
expect(saved.pendingConflicts![0].state).toBe("both-diverged");
});
});