e07993c536
- 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).
164 lines
5.7 KiB
JavaScript
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 };
|