@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12
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/package.json +3 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +65 -5
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +499 -8
- package/src/index.ts +247 -19
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +2285 -64
- package/src/update-provider.test.ts +234 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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
|
+
}
|