import type { ObserveInputs } from "./inputs"; // THE CHANGE TOKEN the global AutoRefresh polls instead of blindly re-rendering // the whole page tree every 5 seconds. // // The old refresher called router.refresh() on a timer, on every route, whether // or not anything had changed — a full server re-render that cost ~4.9 s and // held next-server at ~22% of a core forever with a single idle tab open. The // endpoint this backs answers "has anything changed?" from IN-MEMORY state plus // two stat() calls, so the idle path touches no corpus data at all. export type PulsePayload = { // Opaque token. Identical to your last one means nothing has changed and // there is nothing to re-render. rev: string; // True when this response carries the full body below. False is the idle // path: the client sent a rev that still matches, so nothing else was // computed. changed: boolean; // Sidebar badge counts, so the sidebar never needs a tree refresh to update. activeJobs: number; runningJobs: number; cleanableBytes: number; // Anything live right now — lets the client poll faster while work is moving. busy: boolean; }; // ⚠️ THIS OBSERVES; IT MUST NEVER CONSTRUCT. // // The three singletons are LAZY: asking for one CREATES it. That is fine for a // page acting on jobs and catastrophic for a status poll running every few // seconds in the background, because `/api/test/invalidate-cache` clears exactly // these globals between e2e specs — a poll landing a moment later silently // rebuilt them, re-seeding a worker pool from settings mid-reset. The symptom // was ~16 unrelated specs (branding, build, jobs, downloads) failing in // non-deterministic combinations while each passed in isolation. // // That rule is structural here rather than remembered: every singleton arrives // as an argument and every one of them is NULLABLE, so "not created yet" is a // legitimate answer meaning "nothing to report". A pulse must be able to say the // system is idle without making a system to ask. `views/inputs.ts` is the shape; // `editor/app/lib/liveInputs.ts` reads it. // // THE TOKEN'S BYTES ARE A CONTRACT. Every open tab holds a rev from a previous // deploy's build of this function; changing the order of the parts, their // separators or the encoding would not corrupt anything, but it would make every // client's next poll report a change and re-render the tree once for nothing. // Add fields at the end. export function computePulse(o: ObserveInputs): { rev: string; activeJobs: number; runningJobs: number; busy: boolean; } { const jobs = o.registry ? o.registry.list() : []; let activeJobs = 0; let runningJobs = 0; const parts: string[] = []; for (const j of jobs) { if (j.status === "running" || j.status === "queued") activeJobs++; if (j.status === "running") runningJobs++; // JobRecord has no single updatedAt, so the token is built from the fields // that actually move: lifecycle timestamps, drain state, and progress. If a // job's progress advances, pages showing that progress should re-render — // that is a change, not noise. parts.push( [ j.id, j.status, j.startedAt ?? "", j.endedAt ?? "", j.draining ? "d" : "", j.progress ? `${j.progress.initial}/${j.progress.current ?? ""}/${j.progress.target}` : "", j.tasks?.length ?? 0, ].join(":"), ); } // Queue shape and worker-pool state, both in-memory — and both skipped // entirely when the singleton doesn't exist yet (see above). if (o.scheduler) { for (const q of o.scheduler.queues()) { parts.push(`q:${q.name}:${q.running.join(",")}:${q.queued.join(",")}`); } } if (o.pool) { const workers = o.pool.summary(); parts.push(`w:${o.pool.isPaused() ? "paused" : "live"}:${workers.length}`); for (const w of workers) parts.push(`w:${w.id}:${w.busy ? 1 : 0}`); } // A report regenerated on the snapshot scheduler's debounce lands AFTER the // job that triggered it has already reached its final status — so without // this counter, the pages whose counts come from snapshots (/channels, the // dashboard) would go stale until some unrelated thing changed. One integer, // in memory, and it means "a report was rewritten" without the endpoint // having to stat 65 files to find out. parts.push(`snap:${o.snapshotGeneration}`); // Files the layout renders from. mtime only — neither is read. parts.push(`s:${o.settingsMtime}`); parts.push(`c:${o.changelogMtime}`); const rev = o.digest(parts.join("|")); return { rev, activeJobs, runningJobs, busy: runningJobs > 0 || activeJobs > 0 }; }