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:
@@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
131
tests/e2-4-conflict.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user