Files
AgentHub/server/update-scheduler.js
nntrivi2001 e07993c536 perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact
- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
2026-10-16 10:20:38 +07:00

164 lines
5.7 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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 };