@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ // Round-2/4 review ② — retention sweep + incremental VACUUM.
2
+ //
3
+ // Background maintenance for the commhub database. Multi-tenant
4
+ // public-facing hubs (e.g. <hub-domain>) write to high-frequency tables
5
+ // on every heartbeat (agent_telemetry, task_events, completions) — left
6
+ // unswept these grow unbounded, slow down indexed scans, and bloat the
7
+ // on-disk footprint. SQLite never reclaims freed pages without VACUUM,
8
+ // so a row delete by itself doesn't shrink the file.
9
+ //
10
+ // This module exposes:
11
+ // - sweepRetention(cfg): one-shot sweep across high-growth tables
12
+ // - startRetentionSweeper(): wires a 1-hour setInterval, fires once
13
+ // at startup with an immediate (non-blocking) sweep
14
+ //
15
+ // Defaults are conservative — long-tail debugging traces survive a
16
+ // week. Operators can override per env (COMMHUB_RETENTION_*_DAYS) for
17
+ // hot hubs.
18
+ //
19
+ // Run isolated tests against retention.test.ts:
20
+ // COMMHUB_DB=/tmp/test-retention.db bun test src/retention.test.ts
21
+
22
+ import { db } from "./db.js";
23
+
24
+ export type RetentionConfig = {
25
+ // High-frequency telemetry; default = 7 days.
26
+ // Heartbeat is ~30s → 50 agents × 86400 s/day ÷ 30 s = 144000 rows/day.
27
+ // At 7d that's ~1M rows. Indexed scans stay fast; raw disk = a few MB.
28
+ telemetryDays: number;
29
+ // Task state-change audit; default = 30 days.
30
+ // One row per status transition. Useful for incident forensics.
31
+ taskEventsDays: number;
32
+ // Acked inbox; default = 7 days post-ack.
33
+ // Once acked the row is no longer hot — keep a week for debugging.
34
+ // Unacked rows are NEVER swept (could be in-flight delivery).
35
+ ackedInboxDays: number;
36
+ // Completed/cancelled/failed tasks; default = 30 days.
37
+ // Active tasks (created/delivered/acked/running/replied less than
38
+ // 30 days old) are NEVER swept.
39
+ terminalTasksDays: number;
40
+ // Audit log; default = 90 days.
41
+ // Longer retention because audit_log is a security artifact.
42
+ auditLogDays: number;
43
+ };
44
+
45
+ export type SweepResult = {
46
+ startedAt: string;
47
+ durationMs: number;
48
+ deletes: {
49
+ agent_telemetry: number;
50
+ task_events: number;
51
+ inbox: number;
52
+ tasks: number;
53
+ audit_log: number;
54
+ };
55
+ vacuum: {
56
+ walCheckpointPagesMoved: number | null;
57
+ incrementalFreedPages: number | null;
58
+ errored: boolean;
59
+ };
60
+ };
61
+
62
+ const DEFAULTS: RetentionConfig = {
63
+ telemetryDays: 7,
64
+ taskEventsDays: 30,
65
+ ackedInboxDays: 7,
66
+ terminalTasksDays: 30,
67
+ auditLogDays: 90,
68
+ };
69
+
70
+ function readEnvDays(name: string, fallback: number): number {
71
+ const raw = process.env[name];
72
+ if (raw === undefined || raw === "") return fallback;
73
+ const n = Number(raw);
74
+ // Negative = disable that table's sweep entirely (operator opt-out).
75
+ // NaN / non-finite falls back to default — guard against fat-fingered env.
76
+ if (!Number.isFinite(n)) return fallback;
77
+ return n;
78
+ }
79
+
80
+ export function readRetentionConfig(): RetentionConfig {
81
+ return {
82
+ telemetryDays: readEnvDays("COMMHUB_RETENTION_TELEMETRY_DAYS", DEFAULTS.telemetryDays),
83
+ taskEventsDays: readEnvDays("COMMHUB_RETENTION_TASK_EVENTS_DAYS", DEFAULTS.taskEventsDays),
84
+ ackedInboxDays: readEnvDays("COMMHUB_RETENTION_INBOX_ACKED_DAYS", DEFAULTS.ackedInboxDays),
85
+ terminalTasksDays: readEnvDays("COMMHUB_RETENTION_TASKS_TERMINAL_DAYS", DEFAULTS.terminalTasksDays),
86
+ auditLogDays: readEnvDays("COMMHUB_RETENTION_AUDIT_DAYS", DEFAULTS.auditLogDays),
87
+ };
88
+ }
89
+
90
+ function sweepOne(
91
+ sql: string,
92
+ params: (string | number)[],
93
+ days: number,
94
+ ): number {
95
+ // days < 0 → operator opt-out for this table; skip the DELETE.
96
+ // days === 0 is a legitimate "sweep everything older than now"
97
+ // (effectively a TRUNCATE); we honour it.
98
+ if (days < 0) return 0;
99
+ try {
100
+ const res = db.run(sql, params);
101
+ return res.changes ?? 0;
102
+ } catch (e: any) {
103
+ console.log(`[commhub retention] sweep failed: ${e?.message ?? e}`);
104
+ return 0;
105
+ }
106
+ }
107
+
108
+ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): SweepResult {
109
+ const startedAt = new Date().toISOString();
110
+ const t0 = Date.now();
111
+
112
+ const telemetry = sweepOne(
113
+ `DELETE FROM agent_telemetry WHERE created_at < datetime('now', ?1)`,
114
+ [`-${cfg.telemetryDays} days`],
115
+ cfg.telemetryDays,
116
+ );
117
+ const taskEvents = sweepOne(
118
+ `DELETE FROM task_events WHERE created_at < datetime('now', ?1)`,
119
+ [`-${cfg.taskEventsDays} days`],
120
+ cfg.taskEventsDays,
121
+ );
122
+ // Only sweep ACKED inbox rows — unacked may be in-flight delivery
123
+ // for an agent that's offline but coming back. Acked rows have
124
+ // served their purpose and exist only for debugging.
125
+ const inbox = sweepOne(
126
+ `DELETE FROM inbox WHERE acked = 1 AND created_at < datetime('now', ?1)`,
127
+ [`-${cfg.ackedInboxDays} days`],
128
+ cfg.ackedInboxDays,
129
+ );
130
+ // Terminal-task sweep — split into two DELETEs:
131
+ //
132
+ // (a) "safe terminals": cancelled / failed / expired / (theoretical)
133
+ // completed have no chain-ancestor risk and are safe to reap
134
+ // unconditionally past the horizon. NOTE: as of this writing
135
+ // 'completed' is dead code — no code path sets it (every reply
136
+ // uses 'replied'/'failed'/'cancelled' via send_reply or
137
+ // chainReplyToParent). Kept in the IN-list as a forward guard:
138
+ // if someone introduces a 'completed' write later, the sweep
139
+ // picks it up without code changes.
140
+ //
141
+ // (b) "replied" — was excluded entirely in the original PR, which
142
+ // missed the goal: every fulfilled task lives at 'replied'
143
+ // status, so the tasks table's MAIN GROWTH SOURCE would never
144
+ // be reclaimed. Reviewer caught this. Fix: child-ref-aware
145
+ // delete — reap an old replied row ONLY IF no other task
146
+ // references it as parent_task_id. A row with no children is
147
+ // not a chain ancestor and is safe to drop.
148
+ //
149
+ // **Time field** (both DELETEs): use COALESCE(completed_at, created_at).
150
+ // Terminal status means the server set `completed_at` at the
151
+ // reply/fail/cancel moment. Sweeping on `created_at` alone would
152
+ // reap a task created 60d ago but completed today the instant it
153
+ // entered terminal state — zero post-terminal retention. The
154
+ // COALESCE falls back to created_at for legacy rows that pre-date
155
+ // the completed_at column (back-compat).
156
+ const tasksSafeTerminal = sweepOne(
157
+ `DELETE FROM tasks
158
+ WHERE status IN ('completed', 'cancelled', 'failed', 'expired')
159
+ AND COALESCE(completed_at, created_at) < datetime('now', ?1)`,
160
+ [`-${cfg.terminalTasksDays} days`],
161
+ cfg.terminalTasksDays,
162
+ );
163
+ const tasksRepliedChildSafe = sweepOne(
164
+ `DELETE FROM tasks
165
+ WHERE status = 'replied'
166
+ AND COALESCE(completed_at, created_at) < datetime('now', ?1)
167
+ AND NOT EXISTS (
168
+ SELECT 1 FROM tasks AS child
169
+ WHERE child.parent_task_id = tasks.task_id
170
+ )`,
171
+ [`-${cfg.terminalTasksDays} days`],
172
+ cfg.terminalTasksDays,
173
+ );
174
+ const tasks = tasksSafeTerminal + tasksRepliedChildSafe;
175
+ const auditLog = sweepOne(
176
+ `DELETE FROM audit_log WHERE created_at < datetime('now', ?1)`,
177
+ [`-${cfg.auditLogDays} days`],
178
+ cfg.auditLogDays,
179
+ );
180
+
181
+ // VACUUM:
182
+ // - wal_checkpoint(TRUNCATE): trims the WAL file. Works on every
183
+ // SQLite DB regardless of auto_vacuum setting. Useful after
184
+ // big DELETEs so the WAL doesn't stay bloated until the next
185
+ // natural checkpoint.
186
+ // - incremental_vacuum: ONLY reclaims main-DB pages on databases
187
+ // created with `PRAGMA auto_vacuum = INCREMENTAL`. On legacy
188
+ // DBs (the default mode 0) this is a no-op and the main file
189
+ // does NOT shrink — freed pages stay in the freelist for
190
+ // future inserts. Operators who want disk reclamation on old
191
+ // DBs must run a one-time blocking `VACUUM` themselves (out of
192
+ // scope for the sweeper, which deliberately stays non-
193
+ // blocking).
194
+ let walCheckpointPagesMoved: number | null = null;
195
+ let incrementalFreedPages: number | null = null;
196
+ let errored = false;
197
+ try {
198
+ // wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
199
+ // We expose `log` (page count moved into the main DB) for ops visibility.
200
+ const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
201
+ "PRAGMA wal_checkpoint(TRUNCATE)",
202
+ );
203
+ walCheckpointPagesMoved = checkpoint?.log ?? null;
204
+ } catch (e: any) {
205
+ console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
206
+ errored = true;
207
+ }
208
+ try {
209
+ // incremental_vacuum is a no-op on databases not created with
210
+ // PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
211
+ // weren't, the operator would have to run a full one-time VACUUM
212
+ // to migrate — out of scope for the sweeper.
213
+ const freelistBefore = db.get<{ freelist_count: number }>("PRAGMA freelist_count");
214
+ db.exec("PRAGMA incremental_vacuum");
215
+ const freelistAfter = db.get<{ freelist_count: number }>("PRAGMA freelist_count");
216
+ incrementalFreedPages =
217
+ (freelistBefore?.freelist_count ?? 0) - (freelistAfter?.freelist_count ?? 0);
218
+ } catch (e: any) {
219
+ console.log(`[commhub retention] incremental_vacuum failed: ${e?.message ?? e}`);
220
+ errored = true;
221
+ }
222
+
223
+ return {
224
+ startedAt,
225
+ durationMs: Date.now() - t0,
226
+ deletes: {
227
+ agent_telemetry: telemetry,
228
+ task_events: taskEvents,
229
+ inbox,
230
+ tasks,
231
+ audit_log: auditLog,
232
+ },
233
+ vacuum: { walCheckpointPagesMoved, incrementalFreedPages, errored },
234
+ };
235
+ }
236
+
237
+ // Used by index.ts to wire the periodic sweeper. Returns the timer so
238
+ // callers can stop it in tests / shutdown.
239
+ export function startRetentionSweeper(
240
+ intervalMs = 60 * 60 * 1000, // 1 hour
241
+ cfg?: RetentionConfig,
242
+ ): ReturnType<typeof setInterval> {
243
+ const sweep = () => {
244
+ try {
245
+ const r = sweepRetention(cfg);
246
+ const totalDeletes =
247
+ r.deletes.agent_telemetry +
248
+ r.deletes.task_events +
249
+ r.deletes.inbox +
250
+ r.deletes.tasks +
251
+ r.deletes.audit_log;
252
+ if (totalDeletes > 0 || r.vacuum.errored) {
253
+ console.log(
254
+ `[commhub retention] swept in ${r.durationMs}ms — ` +
255
+ `telemetry=${r.deletes.agent_telemetry} events=${r.deletes.task_events} ` +
256
+ `inbox=${r.deletes.inbox} tasks=${r.deletes.tasks} audit=${r.deletes.audit_log} ` +
257
+ `walPages=${r.vacuum.walCheckpointPagesMoved} freedPages=${r.vacuum.incrementalFreedPages}`,
258
+ );
259
+ }
260
+ } catch (e: any) {
261
+ console.log(`[commhub retention] sweep top-level failed: ${e?.message ?? e}`);
262
+ }
263
+ };
264
+ // Fire once on startup so a long-running hub eventually catches up
265
+ // even if it's restarted just after the previous sweep. setImmediate
266
+ // (vs synchronous) keeps server startup from blocking on the sweep.
267
+ setImmediate(sweep);
268
+ return setInterval(sweep, intervalMs);
269
+ }
@@ -0,0 +1,181 @@
1
+ // Round-2/4 review ③ — stale session sweeper tests.
2
+ //
3
+ // Drives the real sweepStaleSessions against isolated in-process
4
+ // SQLite (COMMHUB_DB=/tmp/...). Pins behaviour the read-path
5
+ // regression (per-request UPDATE) would re-introduce if anyone
6
+ // undoes the fix:
7
+ //
8
+ // 1. fresh sessions stay non-offline regardless of how many
9
+ // times the sweeper runs;
10
+ // 2. stale sessions get marked offline exactly once (idempotent);
11
+ // 3. global sweep crosses tenant boundaries — a stale agent in
12
+ // network B is marked offline even when no one in net B is
13
+ // polling /api/status;
14
+ // 4. env-var cutoff is respected and bad values fall back silently.
15
+ //
16
+ // Run with:
17
+ // COMMHUB_DB=/tmp/stale-test.db bun test src/stale-sweeper.test.ts
18
+
19
+ import { describe, expect, test, beforeEach } from "bun:test";
20
+ import { db } from "./db.js";
21
+ import { sweepStaleSessions, staleCutoffMinutes } from "./stale-sweeper.js";
22
+
23
+ function insertSession(opts: {
24
+ alias: string;
25
+ status?: string;
26
+ minutesAgo: number;
27
+ network_id?: string;
28
+ }): void {
29
+ const id = `r_${opts.alias}`;
30
+ db.run(
31
+ `INSERT OR REPLACE INTO sessions
32
+ (resume_id, alias, status, network_id, updated_at, registered_at)
33
+ VALUES (?1, ?2, ?3, ?4, datetime('now', ?5), datetime('now', ?5))`,
34
+ [id, opts.alias, opts.status ?? "idle", opts.network_id ?? "default", `-${opts.minutesAgo} minutes`]
35
+ );
36
+ }
37
+
38
+ function getStatus(alias: string): string | null {
39
+ const row = db.get<{ status: string }>(
40
+ "SELECT status FROM sessions WHERE alias = ?1",
41
+ alias
42
+ );
43
+ return row?.status ?? null;
44
+ }
45
+
46
+ beforeEach(() => {
47
+ db.run("DELETE FROM sessions");
48
+ });
49
+
50
+ describe("sweepStaleSessions — fresh agents not touched", () => {
51
+ test("agent updated < cutoff stays at its current status", () => {
52
+ insertSession({ alias: "live-1", status: "idle", minutesAgo: 1 });
53
+ insertSession({ alias: "live-2", status: "working", minutesAgo: 5 });
54
+
55
+ sweepStaleSessions(10);
56
+
57
+ expect(getStatus("live-1")).toBe("idle");
58
+ expect(getStatus("live-2")).toBe("working");
59
+ });
60
+
61
+ test("multiple sweeps on quiet hub are pure no-ops (no write fired)", () => {
62
+ insertSession({ alias: "live", status: "idle", minutesAgo: 1 });
63
+
64
+ const r1 = sweepStaleSessions(10);
65
+ const r2 = sweepStaleSessions(10);
66
+ const r3 = sweepStaleSessions(10);
67
+
68
+ expect(r1.markedOffline).toBe(0);
69
+ expect(r2.markedOffline).toBe(0);
70
+ expect(r3.markedOffline).toBe(0);
71
+ expect(getStatus("live")).toBe("idle");
72
+ });
73
+ });
74
+
75
+ describe("sweepStaleSessions — stale agents marked offline", () => {
76
+ test("agent updated > cutoff gets marked offline exactly once", () => {
77
+ insertSession({ alias: "stale", status: "idle", minutesAgo: 30 });
78
+
79
+ const r1 = sweepStaleSessions(10);
80
+ expect(r1.markedOffline).toBe(1);
81
+ expect(getStatus("stale")).toBe("offline");
82
+
83
+ // Second sweep does nothing because the row is already offline
84
+ // (the predicate is `status != 'offline'`).
85
+ const r2 = sweepStaleSessions(10);
86
+ expect(r2.markedOffline).toBe(0);
87
+ expect(getStatus("stale")).toBe("offline");
88
+ });
89
+
90
+ test("already-offline stale row not re-marked (write avoided)", () => {
91
+ insertSession({ alias: "old-offline", status: "offline", minutesAgo: 60 });
92
+
93
+ const r = sweepStaleSessions(10);
94
+
95
+ expect(r.markedOffline).toBe(0);
96
+ expect(getStatus("old-offline")).toBe("offline");
97
+ });
98
+ });
99
+
100
+ describe("sweepStaleSessions — global across tenants (the read-path fix)", () => {
101
+ test("stale row in network B is marked offline even with no net-B polling", () => {
102
+ // Pre-fix: GET /api/status from net-A would only UPDATE rows in
103
+ // net-A's scope. A stale agent in net-B would stay non-offline
104
+ // until someone polled net-B. After the fix, the global sweeper
105
+ // is tenant-blind on the maintenance write.
106
+ insertSession({ alias: "stale-a", status: "idle", minutesAgo: 30, network_id: "net-A" });
107
+ insertSession({ alias: "stale-b", status: "idle", minutesAgo: 30, network_id: "net-B" });
108
+ insertSession({ alias: "stale-c", status: "idle", minutesAgo: 30, network_id: null as any });
109
+
110
+ const r = sweepStaleSessions(10);
111
+
112
+ expect(r.markedOffline).toBe(3);
113
+ expect(getStatus("stale-a")).toBe("offline");
114
+ expect(getStatus("stale-b")).toBe("offline");
115
+ expect(getStatus("stale-c")).toBe("offline");
116
+ });
117
+ });
118
+
119
+ describe("sweepStaleSessions — cutoff boundary + env", () => {
120
+ test("exactly-at-cutoff agent treated as stale (< strict, not <=)", () => {
121
+ // Just past the cutoff window.
122
+ insertSession({ alias: "just-stale", status: "idle", minutesAgo: 11 });
123
+ sweepStaleSessions(10);
124
+ expect(getStatus("just-stale")).toBe("offline");
125
+ });
126
+
127
+ test("just-under-cutoff agent treated as live", () => {
128
+ insertSession({ alias: "just-live", status: "working", minutesAgo: 9 });
129
+ sweepStaleSessions(10);
130
+ expect(getStatus("just-live")).toBe("working");
131
+ });
132
+
133
+ test("env COMMHUB_STALE_CUTOFF_MINUTES overrides default", () => {
134
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "3";
135
+ expect(staleCutoffMinutes()).toBe(3);
136
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
137
+ });
138
+
139
+ test("env invalid value falls back to default 10 (no operator footgun)", () => {
140
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "not-a-number";
141
+ expect(staleCutoffMinutes()).toBe(10);
142
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
143
+
144
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "0";
145
+ expect(staleCutoffMinutes()).toBe(10);
146
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
147
+
148
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "-5";
149
+ expect(staleCutoffMinutes()).toBe(10);
150
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
151
+ });
152
+ });
153
+
154
+ describe("sweepStaleSessions — read-path regression guard (pin the fix)", () => {
155
+ // This is the load-bearing pin: if anyone adds back a per-request
156
+ // UPDATE in /api/status / /api/servers / /api/server-detail-* /
157
+ // get_all_status, this test plus the absence of those UPDATEs in
158
+ // the source should make the regression visible. Since this is a
159
+ // unit-level test of the sweeper itself, the regression-prevention
160
+ // role is mostly social — the comments in this file + the comments
161
+ // in index.ts and tools.ts at the former UPDATE sites tell future
162
+ // editors "do not put the UPDATE back; use the sweeper."
163
+ test("after sweep, sessions row has the offline status persisted (single source of truth)", () => {
164
+ insertSession({ alias: "x", status: "working", minutesAgo: 30 });
165
+ const before = sweepStaleSessions(10);
166
+ expect(before.markedOffline).toBe(1);
167
+
168
+ // Re-read directly — no extra UPDATE needed by a caller.
169
+ const status = getStatus("x");
170
+ expect(status).toBe("offline");
171
+
172
+ // 100 simulated read-path lookups don't fire any maintenance writes.
173
+ for (let i = 0; i < 100; i++) {
174
+ const row = db.get<{ status: string }>(
175
+ "SELECT status FROM sessions WHERE alias = ?1",
176
+ "x"
177
+ );
178
+ expect(row?.status).toBe("offline");
179
+ }
180
+ });
181
+ });
@@ -0,0 +1,114 @@
1
+ // Round-2/4 review ③ — read-path write amplification fix.
2
+ //
3
+ // Before this module: GET /api/status, GET /api/servers, GET
4
+ // /api/server-health/:host, and the MCP `get_all_status` tool each
5
+ // fired:
6
+ //
7
+ // UPDATE sessions SET status = 'offline'
8
+ // WHERE updated_at < ?1 AND status != 'offline'
9
+ //
10
+ // on every call. With the dashboard polling /api/status every few
11
+ // seconds × N tabs × M users, that's a high-frequency WRITE on a
12
+ // heavily-read table just to maintain a derived field. Wait events
13
+ // stack up (WAL fsync, page locks) and the writes are 99% no-ops.
14
+ //
15
+ // Fix: move the stale-marking off the read path into a single
16
+ // background timer. Reads now SELECT only; the timer runs every 60s
17
+ // and applies the same UPDATE GLOBALLY (no per-network scope — stale
18
+ // is stale regardless of which tenant is asking). Worst-case delay
19
+ // between an agent crashing and going `offline` in the dashboard:
20
+ //
21
+ // 10min stale cutoff + 60s sweep = up to ~11min
22
+ //
23
+ // (unchanged from before the fix for any reader that wasn't actively
24
+ // polling).
25
+ //
26
+ // Same pattern as `src/retention.ts` for shutdown (clearInterval in
27
+ // the graceful shutdown handler).
28
+
29
+ import { db } from "./db.js";
30
+
31
+ export type StaleSweepResult = {
32
+ startedAt: string;
33
+ durationMs: number;
34
+ markedOffline: number;
35
+ staleCutoffMinutes: number;
36
+ };
37
+
38
+ // Stale window: an agent that hasn't reported in this many minutes is
39
+ // considered offline. Matches the pre-fix per-request cutoff so the
40
+ // observable behaviour from the dashboard's perspective is unchanged.
41
+ const DEFAULT_STALE_MINUTES = 10;
42
+ const DEFAULT_SWEEP_MS = 60 * 1000;
43
+
44
+ export function staleCutoffMinutes(): number {
45
+ const raw = process.env.COMMHUB_STALE_CUTOFF_MINUTES;
46
+ if (raw === undefined || raw === "") return DEFAULT_STALE_MINUTES;
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_STALE_MINUTES;
49
+ return n;
50
+ }
51
+
52
+ function sqliteCutoff(minutes: number): string {
53
+ return new Date(Date.now() - minutes * 60 * 1000)
54
+ .toISOString()
55
+ .replace("T", " ")
56
+ .slice(0, 19);
57
+ }
58
+
59
+ // Single GLOBAL UPDATE — no per-network filtering. The pre-fix per-
60
+ // request stale marker WAS scoped (each REST call only saw its own
61
+ // network's stale rows), but the semantics of "this row hasn't been
62
+ // heard from in 10min" is global. A network that no one's looking at
63
+ // still wants its agents marked offline so an admin scanning all
64
+ // networks gets consistent state.
65
+ export function sweepStaleSessions(cutoffMinutes = staleCutoffMinutes()): StaleSweepResult {
66
+ const startedAt = new Date().toISOString();
67
+ const t0 = Date.now();
68
+ let markedOffline = 0;
69
+ try {
70
+ const cutoff = sqliteCutoff(cutoffMinutes);
71
+ const res = db.run(
72
+ "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'",
73
+ [cutoff],
74
+ );
75
+ markedOffline = res.changes ?? 0;
76
+ } catch (e: any) {
77
+ console.log(`[commhub stale-sweep] failed: ${e?.message ?? e}`);
78
+ }
79
+ return {
80
+ startedAt,
81
+ durationMs: Date.now() - t0,
82
+ markedOffline,
83
+ staleCutoffMinutes: cutoffMinutes,
84
+ };
85
+ }
86
+
87
+ export function startStaleSessionSweeper(
88
+ intervalMs?: number,
89
+ ): ReturnType<typeof setInterval> {
90
+ const env = process.env.COMMHUB_STALE_SWEEP_SECONDS;
91
+ const envMs = env && Number.isFinite(Number(env)) && Number(env) > 0
92
+ ? Number(env) * 1000
93
+ : undefined;
94
+ const ms = intervalMs ?? envMs ?? DEFAULT_SWEEP_MS;
95
+ const sweep = () => {
96
+ try {
97
+ const r = sweepStaleSessions();
98
+ // Log only when something actually changed — quiet hubs stay quiet.
99
+ if (r.markedOffline > 0) {
100
+ console.log(
101
+ `[commhub stale-sweep] marked ${r.markedOffline} session(s) offline in ${r.durationMs}ms ` +
102
+ `(cutoff=${r.staleCutoffMinutes}min)`,
103
+ );
104
+ }
105
+ } catch (e: any) {
106
+ console.log(`[commhub stale-sweep] top-level failed: ${e?.message ?? e}`);
107
+ }
108
+ };
109
+ // Fire once at boot via setImmediate so the very-first /api/status
110
+ // after server start gets fresh stale state without blocking
111
+ // startup.
112
+ setImmediate(sweep);
113
+ return setInterval(sweep, ms);
114
+ }