diff --git a/src/dashboard.html b/src/dashboard.html index b97d27a..c99d87a 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -121,7 +121,7 @@
(no activity yet)
@@ -562,6 +562,16 @@ async function refreshLiveNewEntries() { entries.map(e => `
${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((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((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((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((r) => server.close(() => r())); + }); + + async function boot(opts: { foundryPoll?: boolean } = {}): Promise { + 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(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((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); + }); +}); \ No newline at end of file