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:
@@ -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}…`);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
139
tests/e2-6-catchup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user