/** * @file Periodic release/upstream check and WebSocket broadcast when update * availability changes. Hourly by default (`DASHBOARD_UPDATE_CHECK_INTERVAL_MS`, * floor 60 s); a tick with no browser connected is skipped — a checkout install * runs `git fetch` on every check, and nobody would see the answer — and the * first browser to connect after a skipped or stale check gets one promptly. * The manual "Check now" (`POST /api/updates/check`, `agenthub update-check`) * is not routed through here and always runs. * @author Nguyễn Ngọc Trí Vĩ */ const { getUpdatesStatus } = require("./lib/update-check"); function isUpdateCheckDisabled() { const v = process.env.DASHBOARD_UPDATE_CHECK; return v === "0" || v === "false" || v === "off"; } function intervalMs() { const n = Number.parseInt(process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS || "", 10); if (Number.isFinite(n) && n >= 60_000) return n; return 60 * 60 * 1000; } /** Delay before the catch-up check a reconnecting browser triggers. */ const CONNECT_CHECK_DELAY_MS = 3_000; /** * @param {object} opts * @param {Function} opts.broadcast * @param {() => number} [opts.getConnectionCount] browsers connected now * (default: server/websocket.js's); 0 skips a scheduled tick. * @param {(fn: Function) => Function} [opts.onConnection] subscribe to new * browser connections; returns an unsubscribe. * @param {Function} [opts.getStatus] the check itself (tests inject it). * @param {number} [opts.initialDelayMs] first tick after start (default 8 s). */ function startUpdateScheduler({ broadcast, getConnectionCount, onConnection, getStatus = getUpdatesStatus, initialDelayMs = 8_000, }) { if (isUpdateCheckDisabled()) return { stop: () => {} }; if (!getConnectionCount || !onConnection) { const ws = require("./websocket"); getConnectionCount = getConnectionCount || ws.getConnectionCount; onConnection = onConnection || ws.onConnection; } const every = intervalMs(); let lastCheckAt = 0; let inFlight = false; let connectTimer = null; let lastFingerprint = ""; let lastHadUpdate = false; let stopped = false; let initialTimer = null; let intervalTimer = null; // A scheduled tick: skipped while no browser is connected. function scheduledTick() { if (stopped) return; let clients = 1; try { clients = getConnectionCount(); } catch { /* unknown: check as before */ } if (clients > 0) tick(); } async function tick() { if (stopped || inFlight) return; inFlight = true; lastCheckAt = Date.now(); try { const status = await getStatus(); // Fingerprint must change whenever anything user-visible changes, // including the *shape* of manual_command (e.g. user switches branch // or adds an upstream remote — same remote_sha / commits_behind, but // different command). Including manual_command here covers situation // transitions automatically. const fp = JSON.stringify({ a: Boolean(status.update_available), r: status.remote_sha || null, b: status.commits_behind || 0, e: status.fetch_error || null, c: status.manual_command || null, }); const changed = fp !== lastFingerprint; lastFingerprint = fp; if (changed) { broadcast("update_status", status); } const becameAvailable = status.update_available && !lastHadUpdate; lastHadUpdate = Boolean(status.update_available); if (becameAvailable) { const line = "━".repeat(52); console.log(`\n${line}`); console.log(" Agent Dashboard: upstream update available"); console.log(` ${status.message || ""}`); if (status.situation_note) { console.log(` ${status.situation_note}`); } if (status.manual_command) { console.log(` Run: ${status.manual_command}`); // Only suggest restart when the command actually rewrites the // working tree. fetch-only situations (feature_branch / // detached_head) don't need a restart. const updatesWorkingTree = status.situation === "tracking_canonical" || status.situation === "fork_or_diverged_tracking"; if (updatesWorkingTree) { console.log(" Then restart the dashboard the same way you started it."); } } console.log(`${line}\n`); } } catch { // Non-fatal — never block the server on update checks } finally { inFlight = false; } } // A browser came back after the scheduled checks were skipped (or the last // one is older than the interval): check once, shortly, so the banner is // current without waiting up to an hour. let unsubscribe = null; try { unsubscribe = onConnection(() => { if (stopped || connectTimer || inFlight) return; if (Date.now() - lastCheckAt < every) return; connectTimer = setTimeout(() => { connectTimer = null; tick(); }, CONNECT_CHECK_DELAY_MS); if (typeof connectTimer.unref === "function") connectTimer.unref(); }); } catch { /* no WebSocket server: scheduled ticks only */ } initialTimer = setTimeout(scheduledTick, initialDelayMs); if (typeof initialTimer.unref === "function") initialTimer.unref(); intervalTimer = setInterval(scheduledTick, every); if (typeof intervalTimer.unref === "function") intervalTimer.unref(); return { stop: () => { stopped = true; if (initialTimer) clearTimeout(initialTimer); if (intervalTimer) clearInterval(intervalTimer); if (connectTimer) clearTimeout(connectTimer); if (typeof unsubscribe === "function") unsubscribe(); }, }; } module.exports = { startUpdateScheduler, intervalMs };