${e.name}
`).join('');
}
}
+// E2.6: catch-up-now — forces an immediate shallow + deep sweep.
+async function catchUpNow() {
+ toast('catching up…');
+ const r = await apiFetch('/api/foundry-poll/catchup', { method: 'POST', headers: { 'content-type': 'application/json' } }).then(r => r.json()).catch(() => null);
+ if (r && r.skipped) toast('catch-up already running');
+ else if (r && r.durationMs !== undefined) toast(`catch-up complete (${r.durationMs}ms)`);
+ else toast(`catch-up failed: ${r?.error || 'unknown'}`);
+ refreshLiveNewEntries();
+ refreshSyncState();
+}
async function importLiveEntry(uuid, name) {
if (!confirm(`Import "${name}" as a new refined note under refined/imported/?`)) return;
toast(`importing ${name}…`);
diff --git a/src/foundry-poll.ts b/src/foundry-poll.ts
index e8e63f4..deb8a03 100644
--- a/src/foundry-poll.ts
+++ b/src/foundry-poll.ts
@@ -110,6 +110,69 @@ export class FoundryPollController {
this.enabled = false;
}
+ /** E2.6: catch-up-now — cancel pending timers, run an immediate shallow + deep
+ * sweep out of cadence, then resume regular cadence. Debounced (ignored if
+ * already running). Returns a summary { shallow, deep, durationMs } or
+ * { skipped: true } if debounced. */
+ async catchUpNow(): Promise<{ skipped?: boolean; shallow?: unknown; deep?: unknown; durationMs?: number }> {
+ if (this.inFlight || this.deepInFlight) return { skipped: true };
+ // Cancel pending timers (the catch-up replaces them).
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
+ if (this.deepTimer) { clearTimeout(this.deepTimer); this.deepTimer = null; }
+ const start = Date.now();
+ // Immediate shallow poll.
+ this.inFlight = true;
+ let shallowResult: { new: number; renamed: number; missing: number } = { new: 0, renamed: 0, missing: 0 };
+ try {
+ const beforeLive = this.liveNewEntries.length;
+ const beforeChanges = (this.state.syncState as unknown as { fPending?: unknown[] })?.fPending?.length ?? 0;
+ await this.shallowPoll();
+ const afterLive = this.liveNewEntries.length;
+ const afterChanges = (this.state.syncState as unknown as { fPending?: unknown[] })?.fPending?.length ?? 0;
+ shallowResult = { new: Math.max(0, afterLive - beforeLive), renamed: 0, missing: Math.max(0, afterChanges - beforeChanges) };
+ } catch (e) {
+ const kind = classifyRelayError(e);
+ if (this.state.syncState) {
+ void appendActivity(this.state.cfg.outDir, this.state.syncState, {
+ time: new Date().toISOString(), kind: "error", name: "(catch-up)", status: "error",
+ message: `catch-up shallow ${kind}: ${(e as Error).message}`,
+ });
+ }
+ if (kind === "persistent") { this.stop(); return { shallow: shallowResult, durationMs: Date.now() - start }; }
+ } finally {
+ this.inFlight = false;
+ }
+ // Immediate deep poll (on shallow completion).
+ this.deepInFlight = true;
+ let deepResult: { pulled: number; skipped: number; conflicts: number } = { pulled: 0, skipped: 0, conflicts: 0 };
+ try {
+ await this.deepPoll();
+ } catch (e) {
+ const kind = classifyRelayError(e);
+ if (this.state.syncState) {
+ void appendActivity(this.state.cfg.outDir, this.state.syncState, {
+ time: new Date().toISOString(), kind: "error", name: "(catch-up)", status: "error",
+ message: `catch-up deep ${kind}: ${(e as Error).message}`,
+ });
+ }
+ if (kind === "persistent") { this.stop(); return { shallow: shallowResult, deep: deepResult, durationMs: Date.now() - start }; }
+ } finally {
+ this.deepInFlight = false;
+ }
+ const durationMs = Date.now() - start;
+ // Log the round summary to the activity panel.
+ if (this.state.syncState) {
+ void appendActivity(this.state.cfg.outDir, this.state.syncState, {
+ time: new Date().toISOString(), kind: "skip", name: "(catch-up)", status: "skipped",
+ message: `catch-up complete: shallow ${JSON.stringify(shallowResult)}, deep ${JSON.stringify(deepResult)}, ${durationMs}ms`,
+ });
+ }
+ // Resume regular cadence from now.
+ this.scheduleNext(this.config.cadenceMs);
+ this.scheduleDeepNext(this.deepCadenceMs);
+ return { shallow: shallowResult, deep: deepResult, durationMs };
+ }
+
private scheduleNext(delayMs: number): void {
if (!this.enabled) return;
const jitter = delayMs * (this.config.jitterPct * (Math.random() * 2 - 1));
diff --git a/src/server.ts b/src/server.ts
index d74352f..2226ff1 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1796,6 +1796,20 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
}
},
},
+ // E2.6: catch-up-now — immediate shallow + deep sweep out of cadence.
+ "POST /api/foundry-poll/catchup": {
+ method: "POST", requireAuth: true, requireCSRF: true,
+ handler: async (_s, _req, res) => {
+ if (!state.cfg.features?.foundryPoll) return send(res, 404, { error: "foundry-poll disabled" });
+ if (!state.foundryPoll?.enabled) return send(res, 400, { error: "foundry-poll is not enabled — enable it first" });
+ try {
+ const result = await state.foundryPoll.catchUpNow();
+ send(res, 200, result);
+ } catch (e) {
+ send(res, 500, { error: (e as Error).message });
+ }
+ },
+ },
// E2.5: import a live new entry as a refined note (one-click, never auto).
"POST /api/foundry-poll/import": {
method: "POST", requireAuth: true, requireCSRF: true,
diff --git a/tests/e2-6-catchup.test.ts b/tests/e2-6-catchup.test.ts
new file mode 100644
index 0000000..94d6b40
--- /dev/null
+++ b/tests/e2-6-catchup.test.ts
@@ -0,0 +1,139 @@
+// E2.6 — catch-up-now trigger.
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { ClassicLevel } from "classic-level";
+import { mkdtemp, mkdir, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import type { Server } from "node:http";
+
+import { FoundryPollController } from "../src/foundry-poll.js";
+import { startServer, type State } from "../src/server.js";
+import { saveSyncState, defaultSyncState } from "../src/sync-state.js";
+
+let dir: string;
+let state: State;
+const realFetch = globalThis.fetch;
+
+beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), "e2-6-"));
+ await mkdir(join(dir, "refined"), { recursive: true });
+ await mkdir(join(dir, "out"), { recursive: true });
+ const cfg = {
+ journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
+ mode: "apply" as const, 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"], foundryPoll: null as unknown as State["foundryPoll"] } as unknown as State;
+ state.syncState = { ...defaultSyncState(cfg.refinedDir) };
+ await saveSyncState(cfg.outDir, state.syncState);
+});
+
+afterEach(async () => { globalThis.fetch = realFetch; await rm(dir, { recursive: true, force: true }); });
+
+function mockSearch(): void {
+ 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;
+}
+
+describe("E2.6 catchUpNow (direct)", () => {
+ it("runs shallow + deep immediately and returns a summary with durationMs", async () => {
+ mockSearch();
+ const controller = new FoundryPollController(state);
+ await controller.setEnabled(true);
+ // Wait for the first scheduled shallow poll to complete (with empty /search
+ // results, prevSnapshot is an empty Map — so wait via lastPollAt instead).
+ while (!state.syncState?.parity.lastPollAt) await new Promise