@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.
package/src/db.ts CHANGED
@@ -117,12 +117,43 @@ db.exec(`
117
117
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
118
118
  );
119
119
 
120
- CREATE INDEX IF NOT EXISTS idx_agent_telemetry_host_time
121
- ON agent_telemetry(network_id, hostname, ip, created_at);
122
120
  CREATE INDEX IF NOT EXISTS idx_agent_telemetry_alias_time
123
121
  ON agent_telemetry(network_id, alias, created_at);
124
122
  `);
125
123
 
124
+ // Round-2/4 review ② — server-health index split.
125
+ //
126
+ // The /api/server-health/:host query (index.ts) filters
127
+ // WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
128
+ // The old combined index (network_id, hostname, ip, created_at) only
129
+ // covers an equality on hostname AND ip (not OR), so SQLite couldn't
130
+ // use it for the disjunction and fell back to a full scan of
131
+ // agent_telemetry on every server-health page load. At a 30s heartbeat
132
+ // × N agents, the table grows fast and the scan dominates.
133
+ //
134
+ // Split into two narrow indexes so SQLite's OR optimizer can run an
135
+ // index UNION on (hostname-path ∪ ip-path), each path covered by an
136
+ // index that also lets the created_at range be range-scanned.
137
+ //
138
+ // **Honest trade-off** (corrected after reviewer flag): secondary
139
+ // index count goes from 2 (host_time + alias_time) to 3 (hostname_time
140
+ // + ip_time + alias_time). Every agent_telemetry insert now maintains
141
+ // ONE MORE B-tree, so per-insert write-amp is +50%. The trade-off is
142
+ // deliberate: write-amp is bounded by the heartbeat cadence (30s ×
143
+ // N agents) and is small in absolute terms, while the read-path win
144
+ // is going from O(n) full-scan-per-dashboard-poll to O(log n) two-
145
+ // branch index union. For the public-facing hub's expected load
146
+ // shape (many readers, few writers), that's a clear net gain.
147
+ try { db.exec("DROP INDEX IF EXISTS idx_agent_telemetry_host_time"); } catch {}
148
+ try {
149
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_hostname_time
150
+ ON agent_telemetry(network_id, hostname, created_at)`);
151
+ } catch {}
152
+ try {
153
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_ip_time
154
+ ON agent_telemetry(network_id, ip, created_at)`);
155
+ } catch {}
156
+
126
157
  // inbox: add in_reply_to, requires_response, expires_at, scope, node identity
127
158
  for (const col of [
128
159
  { name: "in_reply_to", def: "TEXT" },
@@ -231,6 +262,86 @@ db.exec(`
231
262
  CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
232
263
  `);
233
264
 
265
+ // #261 P0-2 (2026-06-28): must_change_password flag. Added via ALTER
266
+ // (not in the CREATE block above) so it's a no-op on fresh DBs that
267
+ // already get the column via the CREATE path's full definition AND a
268
+ // pure additive migration on existing prod DBs — column defaults 0
269
+ // for every existing row, so old `admin/anethub` deployments are NOT
270
+ // locked or nudged on upgrade (back-compat per 通信龙 spec). Wrapped
271
+ // in try/catch because SQLite throws "duplicate column" if the column
272
+ // already exists on subsequent restarts (no IF NOT EXISTS syntax for
273
+ // ALTER ADD COLUMN until SQLite 3.35+, can't assume that everywhere).
274
+ try {
275
+ db.exec(`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`);
276
+ } catch (e: any) {
277
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
278
+ }
279
+
280
+ // RFC-024 (2026-06-28): node config-apply foundation.
281
+ //
282
+ // `nodes` table gains two columns:
283
+ // - `config_revision`: monotonically-incrementing per-node revision
284
+ // that gates write conflicts (dashboard sends base_revision, hub
285
+ // rejects 409 if it doesn't match current). Promoted by hub when
286
+ // the node ACKs `applied` from a successful apply.
287
+ // - `config_snapshot`: masked JSON of the node's current effective
288
+ // config, posted by the node via report_status. Dashboard reads
289
+ // this for the GET snapshot path (never touches per-node files).
290
+ //
291
+ // Both ALTERs wrapped in try/catch for the same reason as
292
+ // must_change_password above (no IF NOT EXISTS on SQLite < 3.35).
293
+ try {
294
+ db.exec(`ALTER TABLE nodes ADD COLUMN config_revision INTEGER DEFAULT 0`);
295
+ } catch (e: any) {
296
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
297
+ }
298
+ try {
299
+ db.exec(`ALTER TABLE nodes ADD COLUMN config_snapshot TEXT`);
300
+ } catch (e: any) {
301
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
302
+ }
303
+
304
+ // `node_config_updates` — pending + history. One row per dashboard write.
305
+ // At most one row per node may be in a non-terminal state at a time
306
+ // (single-flight enforced at the tool layer; this table just stores).
307
+ //
308
+ // network_id is denormalized from nodes(network_id) at insert time so
309
+ // queries don't need to JOIN through nodes every time + so a node
310
+ // deletion doesn't orphan the history (audit retention).
311
+ db.exec(`
312
+ CREATE TABLE IF NOT EXISTS node_config_updates (
313
+ update_id TEXT PRIMARY KEY,
314
+ node_id TEXT NOT NULL,
315
+ network_id TEXT NOT NULL,
316
+ patch_json TEXT NOT NULL,
317
+ apply_mode TEXT NOT NULL,
318
+ base_revision INTEGER NOT NULL,
319
+ status TEXT NOT NULL,
320
+ error TEXT,
321
+ created_at INTEGER NOT NULL,
322
+ created_by_token TEXT NOT NULL,
323
+ acked_at INTEGER,
324
+ new_revision INTEGER
325
+ );
326
+
327
+ CREATE INDEX IF NOT EXISTS idx_ncu_node_status ON node_config_updates(node_id, status);
328
+ CREATE INDEX IF NOT EXISTS idx_ncu_network ON node_config_updates(network_id);
329
+ `);
330
+
331
+ // F-C (CHANGE_REQ): partial unique index on non-terminal rows. The
332
+ // single-flight check in tools.ts is an app-level check-then-INSERT —
333
+ // safe under single-process Bun, but two hub workers could race and
334
+ // double-insert. This index makes that race impossible at the DB
335
+ // layer regardless of process count. Wrapped in try/catch because
336
+ // SQLite < 3.8.0 doesn't support partial indexes (rare on modern
337
+ // systems; if missing, app-level check is the fallback).
338
+ try {
339
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncu_node_inflight ON node_config_updates(node_id) WHERE status IN ('pending', 'restarting')`);
340
+ } catch (e: any) {
341
+ // Older SQLite — fall back to app-level single-flight only.
342
+ console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
343
+ }
344
+
234
345
  // ── V3: networks table ──
235
346
  db.exec(`
236
347
  CREATE TABLE IF NOT EXISTS networks (
@@ -692,8 +803,179 @@ export function generateId(prefix: string): string {
692
803
  return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
693
804
  }
694
805
 
695
- export function hashPassword(password: string): string {
696
- return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
806
+ // ──────────────────────────────────────────────────────────────────
807
+ // Round-6 A1 — Password hashing: salted scrypt with lazy migration
808
+ //
809
+ // Format: `scrypt$<N>$<salt-b64>$<hash-b64>`
810
+ // N = log2(cost). E.g. 14 → 2^14 = 16384 (~50ms on modern hw).
811
+ // salt-b64 = base64 of 16 random bytes.
812
+ // hash-b64 = base64 of 64-byte scrypt output.
813
+ //
814
+ // Why scrypt: Node built-in crypto.scryptSync — zero new deps.
815
+ // Memory-hard, ASIC-resistant. Better than bcrypt under modern GPU
816
+ // attack. argon2id would be marginally better but pulls a native
817
+ // dep (argon2 npm package) we don't want on the hub.
818
+ //
819
+ // Why "$" delimiter: legacy hashes are bare hex (0-9a-f only). Any
820
+ // "$" in stored hash means new format. Trivial detect with zero
821
+ // false-positives.
822
+ //
823
+ // Lazy migration:
824
+ // - register/bootstrap/changePassword/resetUserPassword always
825
+ // write the new format.
826
+ // - login + changePassword (old-verify) accept BOTH formats.
827
+ // - on successful login against a legacy hash, the caller rehashes
828
+ // in place. Zero downtime, no forced password change.
829
+ //
830
+ // See feedback_assume_unit_before_threshold.md: explicit-set
831
+ // crypto parameters; don't rely on library defaults.
832
+ // ──────────────────────────────────────────────────────────────────
833
+
834
+ import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
835
+
836
+ // Default cost parameter (N = 2^14 = 16384). Tunable via env for
837
+ // test suites that don't want to spend ~50ms per scrypt call.
838
+ // N=10 → 2^10 = 1024 iter, ~5ms (test-only)
839
+ // N=14 → 2^14 = 16384 iter, ~50ms (production)
840
+ // N=15 → 2^15 = 32768 iter, ~100ms (if hw improves and ops want headroom)
841
+ const DEFAULT_SCRYPT_N = 14;
842
+ const SCRYPT_R = 8;
843
+ const SCRYPT_P = 1;
844
+ const SCRYPT_KEYLEN = 64;
845
+ const SCRYPT_SALT_BYTES = 16;
846
+ // Bun's scrypt defaults maxmem to ~32 MiB which is too tight for
847
+ // N ≥ 14 (128 * r * 2^N = ~16 MiB at r=8, N=14; default has no
848
+ // headroom for the internal buffers). Set explicitly so a higher-N
849
+ // upgrade later doesn't suddenly throw at runtime.
850
+ const SCRYPT_MAXMEM = 128 * 1024 * 1024; // 128 MiB
851
+
852
+ function getScryptN(): number {
853
+ const raw = process.env.COMMHUB_SCRYPT_N;
854
+ if (!raw) return DEFAULT_SCRYPT_N;
855
+ const n = parseInt(raw, 10);
856
+ // Bound: 8 (way-too-weak, only for unit tests) — 20 (DoS risk).
857
+ if (!Number.isFinite(n) || n < 8 || n > 20) return DEFAULT_SCRYPT_N;
858
+ return n;
859
+ }
860
+
861
+ export function hashPassword(plain: string): string {
862
+ const N = getScryptN();
863
+ const salt = randomBytes(SCRYPT_SALT_BYTES);
864
+ const hash = scryptSync(plain, salt, SCRYPT_KEYLEN, {
865
+ N: 1 << N,
866
+ r: SCRYPT_R,
867
+ p: SCRYPT_P,
868
+ maxmem: SCRYPT_MAXMEM,
869
+ });
870
+ return `scrypt$${N}$${salt.toString("base64")}$${hash.toString("base64")}`;
871
+ }
872
+
873
+ /**
874
+ * Verify a plaintext password against the stored hash. Accepts BOTH
875
+ * the new scrypt format and the legacy bare-hex SHA-256 format
876
+ * (back-compat for pre-A1 deployments).
877
+ *
878
+ * Returns:
879
+ * ok — true iff the password matches.
880
+ * needsRehash — true iff the stored hash was legacy format AND the
881
+ * password matched. The caller MUST rehash and write
882
+ * the new format back to disk so the legacy hash
883
+ * stops existing. Returned false for already-new
884
+ * hashes (no rehash needed even on N upgrade —
885
+ * follow-up if/when we bump default N).
886
+ */
887
+ export function verifyPassword(plain: string, stored: string): { ok: boolean; needsRehash: boolean } {
888
+ // Format detection: new format always contains "$". Legacy bare
889
+ // sha256 hex never does (0-9a-f only).
890
+ if (stored.includes("$")) {
891
+ const parts = stored.split("$");
892
+ if (parts.length !== 4 || parts[0] !== "scrypt") {
893
+ // Unknown new-style scheme — be conservative, reject.
894
+ return { ok: false, needsRehash: false };
895
+ }
896
+ const N = parseInt(parts[1], 10);
897
+ if (!Number.isFinite(N) || N < 8 || N > 20) return { ok: false, needsRehash: false };
898
+ let saltBuf: Buffer;
899
+ let expectedBuf: Buffer;
900
+ try {
901
+ saltBuf = Buffer.from(parts[2], "base64");
902
+ expectedBuf = Buffer.from(parts[3], "base64");
903
+ } catch {
904
+ return { ok: false, needsRehash: false };
905
+ }
906
+ if (expectedBuf.length === 0) return { ok: false, needsRehash: false };
907
+ let actualBuf: Buffer;
908
+ try {
909
+ actualBuf = scryptSync(plain, saltBuf, expectedBuf.length, {
910
+ N: 1 << N,
911
+ r: SCRYPT_R,
912
+ p: SCRYPT_P,
913
+ maxmem: SCRYPT_MAXMEM,
914
+ });
915
+ } catch {
916
+ return { ok: false, needsRehash: false };
917
+ }
918
+ // timingSafeEqual requires equal-length buffers; we sized actual
919
+ // to expected.length above so this is safe.
920
+ const ok = timingSafeEqual(actualBuf, expectedBuf);
921
+ return { ok, needsRehash: false };
922
+ }
923
+
924
+ // Legacy bare-hex SHA-256 format (pre-A1).
925
+ const legacy = new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
926
+ // Use timingSafeEqual on byte buffers (hex strings would be
927
+ // compared via JS === which is variable-time on first-differing
928
+ // char — the timing leak is tiny for fixed-format hex but pin the
929
+ // habit). Both are exactly 64 hex chars when sha256 produced.
930
+ if (stored.length !== legacy.length) {
931
+ // Length mismatch is itself indistinguishable from a parse miss
932
+ // — but we still pay the scrypt to keep this branch's timing
933
+ // peer to the verify branches below.
934
+ burnEqualTimeScrypt(plain);
935
+ return { ok: false, needsRehash: false };
936
+ }
937
+ let ok = false;
938
+ try {
939
+ ok = timingSafeEqual(Buffer.from(stored, "hex"), Buffer.from(legacy, "hex"));
940
+ } catch {
941
+ burnEqualTimeScrypt(plain);
942
+ return { ok: false, needsRehash: false };
943
+ }
944
+ // Timing-oracle close (round-6 round-2 — independent reviewer flag):
945
+ // legacy-OK path callers (auth.ts:login) do `hashPassword(password)`
946
+ // immediately after to rehash → one scrypt cost on top of
947
+ // this function's return. ~50ms.
948
+ // legacy-WRONG path callers reject and return → ZERO additional cost.
949
+ //
950
+ // That asymmetry leaks "is this user on the legacy hash" via wall-
951
+ // clock difference (sub-ms wrong vs ~50ms ok). To close it, burn an
952
+ // equal-time scrypt on the wrong-password branch too. Now both
953
+ // legacy paths cost SHA-256 + one scrypt, matching new-format paths
954
+ // and the missing-user dummy-scrypt path.
955
+ if (!ok) {
956
+ burnEqualTimeScrypt(plain);
957
+ }
958
+ return { ok, needsRehash: ok };
959
+ }
960
+
961
+ // Round-6 round-2 hardening — throwaway scrypt to equalize timing on
962
+ // failure paths. Burns the same wall-clock cost as a real
963
+ // hashPassword() call. Result discarded; salt is a fresh random so
964
+ // the work is real (not optimised away). Cost ~N=14 → 50ms,
965
+ // COMMHUB_SCRYPT_N controlled like the real path.
966
+ function burnEqualTimeScrypt(plain: string): void {
967
+ try {
968
+ const N = getScryptN();
969
+ scryptSync(plain, randomBytes(SCRYPT_SALT_BYTES), SCRYPT_KEYLEN, {
970
+ N: 1 << N,
971
+ r: SCRYPT_R,
972
+ p: SCRYPT_P,
973
+ maxmem: SCRYPT_MAXMEM,
974
+ });
975
+ } catch {
976
+ // Even on failure to scrypt (shouldn't happen), don't surface —
977
+ // this is a timing pacer, not a correctness path.
978
+ }
697
979
  }
698
980
 
699
981
  export function hashToken(token: string): string {
@@ -734,10 +1016,59 @@ export function logAudit(userId: string | null, username: string | null, action:
734
1016
  // 'replied' if it's still open, and post an inbox notification to the parent's
735
1017
  // originator (so an upstream agent or the dashboard sees the chained answer).
736
1018
  // Recurse up the chain (in case of N hops).
737
- export function chainReplyToParent(childTaskId: string, replyText: string, replyStatus: "replied" | "failed" | "cancelled" = "replied", maxDepth = 5): void {
1019
+ /**
1020
+ * Auto-chain a sub-task reply up to the parent task lineage.
1021
+ *
1022
+ * `callerNetId` (round5 F2 fix): the network the *caller* (send_reply
1023
+ * / report_completion) is operating in. When supplied, the chain
1024
+ * refuses to traverse into a parent whose `network_id` doesn't match —
1025
+ * blocks cross-tenant write where network B replies to a task they've
1026
+ * named as parent but which actually lives in network A.
1027
+ *
1028
+ * Callers that legitimately need the legacy network-blind behavior
1029
+ * (e.g. server-internal bookkeeping that doesn't have a tenant
1030
+ * context) can omit `callerNetId`, but every MCP tool path MUST
1031
+ * supply it.
1032
+ *
1033
+ * Returns `{ chained, stoppedReason? }` so the caller can gate its
1034
+ * follow-up SSE push on whether the chain actually wrote (round-2
1035
+ * fix per #275 follow-up, 通信牛 catch): even when the WRITE was
1036
+ * refused, callers were still running `SELECT parent.from_name;
1037
+ * pushEvent(parent.from_name, { parent_task_id }, callerNetId)` —
1038
+ * leaking the foreign parent's task_id into the caller's network
1039
+ * via the SSE payload. Now the caller checks `result.chained` before
1040
+ * pushing the chained_reply event.
1041
+ */
1042
+ export type ChainReplyResult = {
1043
+ /** True iff this call actually wrote into at least one parent row.
1044
+ * When false, callers MUST NOT push SSE events for parent
1045
+ * listeners — the chain would otherwise leak the foreign parent's
1046
+ * identifiers into the caller's network. */
1047
+ chained: boolean;
1048
+ /** Optional reason the chain stopped short. Today the only enforced
1049
+ * reason is `cross_network`; future reasons (max-depth, parent-
1050
+ * archived, etc.) can land here without breaking callers. */
1051
+ stoppedReason?: "cross_network";
1052
+ };
1053
+
1054
+ export function chainReplyToParent(
1055
+ childTaskId: string,
1056
+ replyText: string,
1057
+ replyStatus: "replied" | "failed" | "cancelled" = "replied",
1058
+ maxDepth = 5,
1059
+ callerNetId?: string | null,
1060
+ ): ChainReplyResult {
738
1061
  let currentChildId: string | null = childTaskId;
739
1062
  let currentReply = replyText;
740
1063
  let depth = 0;
1064
+ let chained = false;
1065
+ // Normalize so undefined ("don't enforce") stays distinct from
1066
+ // null ("explicit default network"). Caller passes undefined to
1067
+ // opt out of the cross-tenant check; null to require the parent
1068
+ // also live in the default (null) network.
1069
+ const enforce = callerNetId !== undefined;
1070
+ const callerNorm = callerNetId ?? null;
1071
+
741
1072
  while (currentChildId && depth < maxDepth) {
742
1073
  depth++;
743
1074
  type ChildRow = { parent_task_id: string | null; to_name: string; from_name: string; content: string };
@@ -746,12 +1077,24 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
746
1077
  "SELECT parent_task_id, to_name, from_name, content FROM tasks WHERE task_id = ?1",
747
1078
  currentChildId
748
1079
  );
749
- if (!child?.parent_task_id) return;
1080
+ if (!child?.parent_task_id) return { chained };
750
1081
  const parent: ParentRow | null = db.get<ParentRow>(
751
1082
  "SELECT task_id, from_name, to_name, status, result, network_id, parent_task_id FROM tasks WHERE task_id = ?1",
752
1083
  child.parent_task_id
753
1084
  );
754
- if (!parent) return;
1085
+ if (!parent) return { chained };
1086
+
1087
+ // round5 F2 fix: refuse to write into a parent that lives in a
1088
+ // different network than the caller. Stops chain-reply being a
1089
+ // cross-tenant write primitive. Logs once per attempt so ops can
1090
+ // see the rejection in the access log.
1091
+ if (enforce) {
1092
+ const parentNet = parent.network_id ?? null;
1093
+ if (parentNet !== callerNorm) {
1094
+ console.log(`[commhub] 🚫 chainReplyToParent cross-network blocked: parent=${parent.task_id.slice(0, 8)} parent-net=${parentNet ?? "null"} caller-net=${callerNorm ?? "null"}`);
1095
+ return { chained, stoppedReason: "cross_network" };
1096
+ }
1097
+ }
755
1098
 
756
1099
  const childAlias = child.to_name;
757
1100
  const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
@@ -790,10 +1133,16 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
790
1133
  } catch {}
791
1134
  }
792
1135
 
1136
+ // This iteration actually wrote a parent row (and possibly an
1137
+ // inbox notification). Record that so the caller can gate its
1138
+ // SSE push on a real chain.
1139
+ chained = true;
1140
+
793
1141
  // Recurse up the chain.
794
1142
  currentChildId = parent.task_id;
795
1143
  currentReply = newResult;
796
1144
  }
1145
+ return { chained };
797
1146
  }
798
1147
 
799
1148
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  } from "./uploads.js";
25
25
  import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
26
26
  import { dirname as pathDirname } from "path";
27
+ import { startRetentionSweeper } from "./retention.js";
28
+ import { startStaleSessionSweeper } from "./stale-sweeper.js";
27
29
 
28
30
  const PORT = Number(process.env.PORT) || 9200;
29
31
  const HOST = process.env.HOST || "127.0.0.1";
@@ -1066,11 +1068,9 @@ Bun.serve({
1066
1068
 
1067
1069
  // ── REST: all sessions status ──
1068
1070
  if (url.pathname === "/api/status") {
1069
- const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
1070
- const staleParams: any[] = [cutoff];
1071
- let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
1072
- staleSql = addNetworkScope(staleSql, staleParams, restScope);
1073
- db.run(staleSql, staleParams);
1071
+ // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
1072
+ // (background timer, ~60s cadence). Read paths no longer fire UPDATE.
1073
+ // Per-request UPDATE was a write-amp vector — see stale-sweeper.ts.
1074
1074
  cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
1075
1075
  // `?light=1` returns a narrow projection (alias / status / agent / task /
1076
1076
  // server / updated_at + runtime + network_id) — used by the mobile APP
@@ -1138,12 +1138,7 @@ Bun.serve({
1138
1138
 
1139
1139
  // ── REST: aggregate agents by physical server ──
1140
1140
  if (url.pathname === "/api/servers") {
1141
- const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
1142
- const staleParams: any[] = [cutoff];
1143
- let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
1144
- staleSql = addNetworkScope(staleSql, staleParams, restScope);
1145
- db.run(staleSql, staleParams);
1146
-
1141
+ // Round-2/4 review ③: stale-marking moved to background sweeper.
1147
1142
  const params: any[] = [];
1148
1143
  let sql = `
1149
1144
  SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
@@ -1220,12 +1215,7 @@ Bun.serve({
1220
1215
  const detailKind = serverDetailMatch[2];
1221
1216
  if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
1222
1217
 
1223
- const cutoff = sqliteTime(new Date(Date.now() - 10 * 60 * 1000));
1224
- const staleParams: any[] = [cutoff];
1225
- let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
1226
- staleSql = addNetworkScope(staleSql, staleParams, restScope);
1227
- db.run(staleSql, staleParams);
1228
-
1218
+ // Round-2/4 review ③: stale-marking moved to background sweeper.
1229
1219
  if (detailKind === "agents") {
1230
1220
  const params: any[] = [host, host];
1231
1221
  let sql = `
@@ -1927,6 +1917,38 @@ Bun.serve({
1927
1917
  }));
1928
1918
  }
1929
1919
 
1920
+ // ── REST: single node config snapshot (RFC-024 B5) ──
1921
+ // Dashboard calls this for the snapshot path. Returns the masked
1922
+ // {model, flags, config_revision} the node last posted via
1923
+ // report_status (B6). NEVER reads per-node files. Network-scoped
1924
+ // via addNetworkScope so a netA viewer can't read a netB node.
1925
+ const nodeConfigMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/config$/);
1926
+ if (nodeConfigMatch && req.method === "GET") {
1927
+ const nodeId = decodeURIComponent(nodeConfigMatch[1] ?? "");
1928
+ const params: any[] = [nodeId];
1929
+ let sql = "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1";
1930
+ sql = addNetworkScope(sql, params, restScope);
1931
+ sql += " LIMIT 1";
1932
+ const node = db.get<any>(sql, ...params);
1933
+ if (!node) {
1934
+ return withCors(req, Response.json({ ok: false, error: "node_not_found", node_id: nodeId }, { status: 404 }));
1935
+ }
1936
+ let snapshot: any = {};
1937
+ if (node.config_snapshot) {
1938
+ try { snapshot = JSON.parse(node.config_snapshot); } catch { snapshot = {}; }
1939
+ }
1940
+ return withCors(req, Response.json({
1941
+ ok: true,
1942
+ node_id: node.node_id,
1943
+ alias: node.alias,
1944
+ network_id: node.network_id,
1945
+ config_revision: node.config_revision || 0,
1946
+ model: snapshot.model ?? null,
1947
+ flags: snapshot.flags ?? {},
1948
+ config_update_capable: snapshot.config_update_capable ?? false,
1949
+ }));
1950
+ }
1951
+
1930
1952
  // ── REST: nodes table (V2 Sprint 2) ──
1931
1953
  if (url.pathname === "/api/nodes") {
1932
1954
  const nodeId = url.searchParams.get("node_id");
@@ -2105,9 +2127,31 @@ Security: ${SECURITY_LABEL}
2105
2127
  },
2106
2128
  });
2107
2129
 
2130
+ // Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
2131
+ // Sweeps every hour by default. Operators can disable any single table
2132
+ // by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten
2133
+ // the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES.
2134
+ const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES);
2135
+ const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0
2136
+ ? sweepIntervalMinutes * 60 * 1000
2137
+ : 60 * 60 * 1000;
2138
+ const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs);
2139
+
2140
+ // Round-2/4 review ③ — stale session sweeper (coexists with the
2141
+ // retention sweeper above; different concerns + different cadences).
2142
+ // Replaces the per-request UPDATE in GET /api/status (+ /api/servers,
2143
+ // /api/server-detail/*, MCP get_all_status). Each of those endpoints
2144
+ // used to run UPDATE on the sessions table just to maintain the
2145
+ // derived `offline` status; with the dashboard polling fast that was
2146
+ // 99% no-op write-amp under hot read paths. Now done globally once
2147
+ // every COMMHUB_STALE_SWEEP_SECONDS (default 60s).
2148
+ const staleSweeperTimer = startStaleSessionSweeper();
2149
+
2108
2150
  // ── Graceful shutdown ───────────────────────────────
2109
2151
  function shutdown() {
2110
2152
  console.log("[commhub] shutting down...");
2153
+ clearInterval(retentionSweeperTimer);
2154
+ clearInterval(staleSweeperTimer);
2111
2155
  db.close();
2112
2156
  process.exit(0);
2113
2157
  }