feat(E2.6): catch-up-now trigger — immediate shallow+deep sweep + dashboard button

The last E2 story — completes the E2 epic (F→O auto-sync, "safe but silent").
A "Catch up now" button forces an immediate shallow + deep sweep out of cadence,
so the DM doesn't wait for the next jittered tick before trusting the vault is
current. Gated by foundryPoll (default off).

- src/foundry-poll.ts catchUpNow(): cancels pending shallow + deep timers, runs
  an immediate shallowPoll, then on completion immediately triggers a deepPoll
  (no cadence wait). Debounced (returns {skipped: true} if either is in flight).
  Reuses the same shallowPoll/deepPoll methods (no parallel code path — same
  mapPool, same lock, same routing). On completion: activity panel summary
  {shallow: {new, renamed, missing}, deep: {pulled, skipped, conflicts},
  durationMs}, lastPoll updated, regular cadence resumes from now. Persistent
  errors halt; transient errors logged.
- src/server.ts POST /api/foundry-poll/catchup (gated by foundryPoll; 404 when
  off; 400 when poll not enabled): calls catchUpNow(), returns the summary.
- src/dashboard.html: "Catch up now" button in the live-new-entries panel header.
  catchUpNow() POSTs + toasts the result (or "catch-up already running").
- tests/e2-6-catchup.test.ts: 5 tests — catchUpNow returns a summary with
  durationMs; debounced (skipped if in flight); endpoint 200 with summary; 400
  when poll not enabled; 404 when foundryPoll off.

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

E2 epic COMPLETE (all 6 stories E2.1–E2.6).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:37:06 +00:00
parent d006311f3e
commit 42a9ae4378
4 changed files with 227 additions and 1 deletions

View File

@@ -121,7 +121,7 @@
<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>
<details id="liveNewEntriesPanel" style="display:none;margin:0 12px;border:1px solid var(--pur);border-radius:6px;padding:6px 12px;background:var(--panel)">
<summary style="cursor:pointer;color:var(--pur)">Live new entries (from Foundry) <span id="liveNewCount" class="meta"></span></summary>
<summary style="cursor:pointer;color:var(--pur)">Live new entries (from Foundry) <span id="liveNewCount" class="meta"></span><button id="catchUpBtn" onclick="catchUpNow()" style="margin-left:8px;font-size:11px" title="Force an immediate shallow + deep sweep so the vault reflects everything you just changed in Foundry.">Catch up now</button></summary>
<div id="liveNewList" style="margin:6px 0;font-size:13px"></div>
</details>
<main>
@@ -562,6 +562,16 @@ async function refreshLiveNewEntries() {
entries.map(e => `<div style="display:flex;gap:8px;align-items:center;padding:4px 0;border-bottom:1px solid var(--line)"><span style="flex:1">${e.name}</span><button class="rec" onclick="importLiveEntry('${e.uuid}','${e.name.replace(/'/g,"\\'")}')">Import as new refined note</button></div>`).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}`);

View File

@@ -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));

View File

@@ -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,

139
tests/e2-6-catchup.test.ts Normal file
View File

@@ -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<void>((r) => setTimeout(r, 10));
const result = await controller.catchUpNow();
expect(result.skipped).toBeUndefined();
expect(result.durationMs).toBeGreaterThanOrEqual(0);
expect(result.shallow).toBeDefined();
expect(result.deep).toBeDefined();
controller.stop();
});
it("debounced — returns { skipped: true } if shallow is in flight", async () => {
// Make the search slow so shallow is in flight when we call catchUpNow.
const holder: { resolve: (() => void) | null } = { resolve: null };
globalThis.fetch = vi.fn(async () => {
await new Promise<void>((r) => { holder.resolve = r; });
return { ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) } as unknown as Response;
}) as unknown as typeof fetch;
const controller = new FoundryPollController(state);
await controller.setEnabled(true);
await new Promise<void>((r) => setTimeout(r, 30)); // let the first tick start
expect((controller as any).inFlight).toBe(true);
const result = await controller.catchUpNow();
expect(result.skipped).toBe(true);
if (holder.resolve) holder.resolve();
controller.stop();
});
});
describe("E2.6 POST /api/foundry-poll/catchup (endpoint)", () => {
let server: Server;
let baseURL: string;
afterEach(async () => {
if (server) await new Promise<void>((r) => server.close(() => r()));
});
async function boot(opts: { foundryPoll?: boolean } = {}): Promise<void> {
const d = await mkdtemp(join(tmpdir(), "e2-6-srv-"));
await mkdir(join(d, "refined"), { recursive: true });
await mkdir(join(d, "cc"), { recursive: true });
const jdb = new ClassicLevel<string, string>(join(d, "journal"));
await jdb.open(); await jdb.close();
const { server: srv, state: st } = await startServer({
journal: join(d, "journal"), refinedDir: join(d, "refined"), ccDir: join(d, "cc"),
outDir: join(d, "out"), mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: true, foundryPoll: opts.foundryPoll ?? true },
});
server = srv;
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
// Set the mock for the relay.
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/search")) return { ok: true, status: 200, text: async () => JSON.stringify({ results: [] }) } as unknown as Response;
if (u.includes("/get?")) return { ok: true, status: 200, text: async () => JSON.stringify({ data: { name: "x", _id: "x", flags: {} } }) } as unknown as Response;
return realFetch(url, init);
}) as unknown as typeof fetch;
// Enable the poll + wait for the first shallow poll (lastPollAt set).
if (opts.foundryPoll !== false) {
await st.foundryPoll!.setEnabled(true);
while (!st.syncState?.parity.lastPollAt) await new Promise<void>((r) => setTimeout(r, 10));
}
(globalThis as unknown as { _e26dir: string })._e26dir = d;
}
afterEach(async () => {
const d = (globalThis as unknown as { _e26dir?: string })._e26dir;
if (d) await rm(d, { recursive: true, force: true });
});
it("200 with a summary when enabled", async () => {
await boot({ foundryPoll: true });
const r = await fetch(`${baseURL}/api/foundry-poll/catchup`, { method: "POST", headers: { "content-type": "application/json", origin: baseURL } });
expect(r.status).toBe(200);
const body = await r.json() as { durationMs?: number };
expect(body.durationMs).toBeGreaterThanOrEqual(0);
});
it("400 when the poll is not enabled", async () => {
await boot({ foundryPoll: true });
// Disable the poll before calling catchup.
await fetch(`${baseURL}/api/foundry-poll`, { method: "POST", headers: { "content-type": "application/json", origin: baseURL }, body: JSON.stringify({ enabled: false }) });
const r = await fetch(`${baseURL}/api/foundry-poll/catchup`, { method: "POST", headers: { "content-type": "application/json", origin: baseURL } });
expect(r.status).toBe(400);
});
it("404 when foundryPoll flag is off", async () => {
await boot({ foundryPoll: false });
const r = await fetch(`${baseURL}/api/foundry-poll/catchup`, { method: "POST", headers: { "content-type": "application/json", origin: baseURL } });
expect(r.status).toBe(404);
});
});