@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/package.json +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Round-2/4 review ② — retention sweep + index split tests.
|
|
2
|
+
//
|
|
3
|
+
// Drives the real sweepRetention against an isolated in-process
|
|
4
|
+
// SQLite (COMMHUB_DB=/tmp/...). We're not mocking — every assertion
|
|
5
|
+
// is against persisted rows so the DELETE predicate, the env-var
|
|
6
|
+
// opt-out, and the VACUUM hook all get genuinely exercised.
|
|
7
|
+
//
|
|
8
|
+
// Run with:
|
|
9
|
+
// COMMHUB_DB=/tmp/retention-test.db bun test src/retention.test.ts
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
12
|
+
import { db, uuidv4 } from "./db.js";
|
|
13
|
+
import { sweepRetention, readRetentionConfig, type RetentionConfig } from "./retention.js";
|
|
14
|
+
|
|
15
|
+
function insertTelemetry(opts: {
|
|
16
|
+
network_id?: string;
|
|
17
|
+
hostname?: string;
|
|
18
|
+
ip?: string;
|
|
19
|
+
alias?: string;
|
|
20
|
+
daysAgo: number;
|
|
21
|
+
}): string {
|
|
22
|
+
const id = uuidv4();
|
|
23
|
+
db.run(
|
|
24
|
+
`INSERT INTO agent_telemetry (id, network_id, hostname, ip, alias, created_at)
|
|
25
|
+
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now', ?6))`,
|
|
26
|
+
[id, opts.network_id ?? "default", opts.hostname ?? "host-a", opts.ip ?? "1.2.3.4", opts.alias ?? "agent-a", `-${opts.daysAgo} days`]
|
|
27
|
+
);
|
|
28
|
+
return id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function insertTaskEvent(daysAgo: number): number {
|
|
32
|
+
const res = db.run(
|
|
33
|
+
`INSERT INTO task_events (task_id, to_status, actor, created_at)
|
|
34
|
+
VALUES (?1, 'done', 'test', datetime('now', ?2))`,
|
|
35
|
+
[uuidv4(), `-${daysAgo} days`]
|
|
36
|
+
);
|
|
37
|
+
return Number(res.lastInsertRowid ?? 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function insertInbox(opts: { acked: 0 | 1; daysAgo: number }): string {
|
|
41
|
+
const id = uuidv4();
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO inbox (id, session_name, content, acked, created_at)
|
|
44
|
+
VALUES (?1, 'sess', 'x', ?2, datetime('now', ?3))`,
|
|
45
|
+
[id, opts.acked, `-${opts.daysAgo} days`]
|
|
46
|
+
);
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function insertTask(opts: {
|
|
51
|
+
status: string;
|
|
52
|
+
daysAgo: number;
|
|
53
|
+
completedDaysAgo?: number | null; // null = explicit NULL (legacy row)
|
|
54
|
+
}): string {
|
|
55
|
+
const id = uuidv4();
|
|
56
|
+
if (opts.completedDaysAgo === undefined) {
|
|
57
|
+
// Default: created N days ago, no completed_at set (the historical
|
|
58
|
+
// shape pre-completed_at column).
|
|
59
|
+
db.run(
|
|
60
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at)
|
|
61
|
+
VALUES (?1, 'a', 'b', 'normal', ?2, 'x', 'reply', datetime('now', ?3))`,
|
|
62
|
+
[id, opts.status, `-${opts.daysAgo} days`]
|
|
63
|
+
);
|
|
64
|
+
} else {
|
|
65
|
+
const completedClause = opts.completedDaysAgo === null
|
|
66
|
+
? null
|
|
67
|
+
: `datetime('now', '-${opts.completedDaysAgo} days')`;
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO tasks
|
|
70
|
+
(task_id, from_name, to_name, priority, status, content, requires_response, created_at, completed_at)
|
|
71
|
+
VALUES (?1, 'a', 'b', 'normal', ?2, 'x', 'reply', datetime('now', ?3), ${completedClause === null ? "NULL" : completedClause})`,
|
|
72
|
+
[id, opts.status, `-${opts.daysAgo} days`]
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function insertAudit(daysAgo: number): number {
|
|
79
|
+
const res = db.run(
|
|
80
|
+
`INSERT INTO audit_log (action, created_at) VALUES ('login', datetime('now', ?1))`,
|
|
81
|
+
[`-${daysAgo} days`]
|
|
82
|
+
);
|
|
83
|
+
return Number(res.lastInsertRowid ?? 0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function count(table: string): number {
|
|
87
|
+
const row = db.get<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table}`);
|
|
88
|
+
return row?.cnt ?? 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
db.run("DELETE FROM agent_telemetry");
|
|
93
|
+
db.run("DELETE FROM task_events");
|
|
94
|
+
db.run("DELETE FROM inbox");
|
|
95
|
+
db.run("DELETE FROM tasks");
|
|
96
|
+
db.run("DELETE FROM audit_log");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const STRICT_CFG: RetentionConfig = {
|
|
100
|
+
telemetryDays: 7,
|
|
101
|
+
taskEventsDays: 30,
|
|
102
|
+
ackedInboxDays: 7,
|
|
103
|
+
terminalTasksDays: 30,
|
|
104
|
+
auditLogDays: 90,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
describe("sweepRetention — high-frequency telemetry", () => {
|
|
108
|
+
test("deletes agent_telemetry older than telemetryDays, keeps fresh", () => {
|
|
109
|
+
insertTelemetry({ daysAgo: 0 }); // fresh
|
|
110
|
+
insertTelemetry({ daysAgo: 1 }); // fresh
|
|
111
|
+
insertTelemetry({ daysAgo: 6 }); // just within 7d
|
|
112
|
+
insertTelemetry({ daysAgo: 8 }); // past 7d
|
|
113
|
+
insertTelemetry({ daysAgo: 30 }); // way past
|
|
114
|
+
|
|
115
|
+
const r = sweepRetention(STRICT_CFG);
|
|
116
|
+
|
|
117
|
+
expect(r.deletes.agent_telemetry).toBe(2);
|
|
118
|
+
expect(count("agent_telemetry")).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("is idempotent: second sweep is a no-op on the same horizon", () => {
|
|
122
|
+
insertTelemetry({ daysAgo: 0 });
|
|
123
|
+
insertTelemetry({ daysAgo: 10 });
|
|
124
|
+
|
|
125
|
+
const first = sweepRetention(STRICT_CFG);
|
|
126
|
+
const second = sweepRetention(STRICT_CFG);
|
|
127
|
+
|
|
128
|
+
expect(first.deletes.agent_telemetry).toBe(1);
|
|
129
|
+
expect(second.deletes.agent_telemetry).toBe(0);
|
|
130
|
+
expect(count("agent_telemetry")).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("negative telemetryDays opts out of telemetry sweep", () => {
|
|
134
|
+
insertTelemetry({ daysAgo: 100 });
|
|
135
|
+
insertTelemetry({ daysAgo: 200 });
|
|
136
|
+
|
|
137
|
+
const r = sweepRetention({ ...STRICT_CFG, telemetryDays: -1 });
|
|
138
|
+
|
|
139
|
+
expect(r.deletes.agent_telemetry).toBe(0);
|
|
140
|
+
expect(count("agent_telemetry")).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("sweepRetention — task_events / audit_log", () => {
|
|
145
|
+
test("deletes task_events older than taskEventsDays", () => {
|
|
146
|
+
insertTaskEvent(0);
|
|
147
|
+
insertTaskEvent(15);
|
|
148
|
+
insertTaskEvent(31);
|
|
149
|
+
insertTaskEvent(45);
|
|
150
|
+
|
|
151
|
+
const r = sweepRetention(STRICT_CFG);
|
|
152
|
+
|
|
153
|
+
expect(r.deletes.task_events).toBe(2);
|
|
154
|
+
expect(count("task_events")).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("deletes audit_log older than auditLogDays (longer horizon)", () => {
|
|
158
|
+
insertAudit(0);
|
|
159
|
+
insertAudit(30); // young
|
|
160
|
+
insertAudit(89); // just within 90d
|
|
161
|
+
insertAudit(91); // past
|
|
162
|
+
insertAudit(365); // way past
|
|
163
|
+
|
|
164
|
+
const r = sweepRetention(STRICT_CFG);
|
|
165
|
+
|
|
166
|
+
expect(r.deletes.audit_log).toBe(2);
|
|
167
|
+
expect(count("audit_log")).toBe(3);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("sweepRetention — inbox (acked only)", () => {
|
|
172
|
+
test("deletes acked inbox older than ackedInboxDays, never unacked", () => {
|
|
173
|
+
insertInbox({ acked: 1, daysAgo: 0 }); // acked fresh — keep
|
|
174
|
+
insertInbox({ acked: 1, daysAgo: 30 }); // acked old — delete
|
|
175
|
+
insertInbox({ acked: 0, daysAgo: 30 }); // unacked old — KEEP (could still be in-flight)
|
|
176
|
+
insertInbox({ acked: 0, daysAgo: 365 });// unacked ancient — KEEP
|
|
177
|
+
|
|
178
|
+
const r = sweepRetention(STRICT_CFG);
|
|
179
|
+
|
|
180
|
+
expect(r.deletes.inbox).toBe(1);
|
|
181
|
+
expect(count("inbox")).toBe(3);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("does not touch unacked rows even at extreme ages (operator concern)", () => {
|
|
185
|
+
// Why this test exists: a misread DELETE that forgot the
|
|
186
|
+
// `acked = 1` predicate would silently drop in-flight deliveries
|
|
187
|
+
// for agents temporarily offline. That's a correctness bug, not a
|
|
188
|
+
// retention bug — pin it.
|
|
189
|
+
for (let i = 0; i < 10; i++) insertInbox({ acked: 0, daysAgo: 365 });
|
|
190
|
+
|
|
191
|
+
sweepRetention(STRICT_CFG);
|
|
192
|
+
|
|
193
|
+
expect(count("inbox")).toBe(10);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("sweepRetention — tasks (terminal status only)", () => {
|
|
198
|
+
test("deletes terminal-status tasks older than horizon (replied with no child also reaped)", () => {
|
|
199
|
+
insertTask({ status: "completed", daysAgo: 35 }); // delete (safe-terminal)
|
|
200
|
+
insertTask({ status: "cancelled", daysAgo: 35 }); // delete (safe-terminal)
|
|
201
|
+
insertTask({ status: "failed", daysAgo: 35 }); // delete (safe-terminal)
|
|
202
|
+
insertTask({ status: "expired", daysAgo: 35 }); // delete (safe-terminal)
|
|
203
|
+
insertTask({ status: "completed", daysAgo: 10 }); // young — keep
|
|
204
|
+
insertTask({ status: "running", daysAgo: 365 }); // active — KEEP
|
|
205
|
+
insertTask({ status: "delivered", daysAgo: 365 }); // active — KEEP
|
|
206
|
+
insertTask({ status: "replied", daysAgo: 365 }); // replied + no children → delete (round-2 fix)
|
|
207
|
+
|
|
208
|
+
const r = sweepRetention(STRICT_CFG);
|
|
209
|
+
|
|
210
|
+
expect(r.deletes.tasks).toBe(5);
|
|
211
|
+
expect(count("tasks")).toBe(3);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// 通信牛 #282 CHANGE_REQ regression — terminal-task sweep MUST use
|
|
215
|
+
// COALESCE(completed_at, created_at), not created_at alone. A task
|
|
216
|
+
// that was created long ago but completed today should survive the
|
|
217
|
+
// full retention window after completion. Sweeping by created_at
|
|
218
|
+
// alone reaps it the instant it enters terminal state → operators
|
|
219
|
+
// get zero post-terminal retention.
|
|
220
|
+
test("60d-old created task completed today is KEPT (uses completed_at, not created_at)", () => {
|
|
221
|
+
insertTask({
|
|
222
|
+
status: "completed",
|
|
223
|
+
daysAgo: 60, // created 60 days ago (way past horizon)
|
|
224
|
+
completedDaysAgo: 0, // completed today (within horizon)
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const r = sweepRetention(STRICT_CFG);
|
|
228
|
+
|
|
229
|
+
expect(r.deletes.tasks).toBe(0);
|
|
230
|
+
expect(count("tasks")).toBe(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("legacy row with NULL completed_at falls back to created_at (back-compat)", () => {
|
|
234
|
+
// Old DB rows that pre-date the completed_at column. The COALESCE
|
|
235
|
+
// gracefully falls back so we don't permanently leak legacy data.
|
|
236
|
+
insertTask({
|
|
237
|
+
status: "completed",
|
|
238
|
+
daysAgo: 60, // way past 30d horizon
|
|
239
|
+
completedDaysAgo: null, // explicit NULL (legacy)
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const r = sweepRetention(STRICT_CFG);
|
|
243
|
+
|
|
244
|
+
expect(r.deletes.tasks).toBe(1);
|
|
245
|
+
expect(count("tasks")).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("recently-completed but past-horizon-completion gets reaped", () => {
|
|
249
|
+
insertTask({
|
|
250
|
+
status: "completed",
|
|
251
|
+
daysAgo: 60,
|
|
252
|
+
completedDaysAgo: 35, // completed 35d ago — past 30d horizon
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const r = sweepRetention(STRICT_CFG);
|
|
256
|
+
|
|
257
|
+
expect(r.deletes.tasks).toBe(1);
|
|
258
|
+
expect(count("tasks")).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Independent reviewer flag on #282: the original PR excluded
|
|
262
|
+
// `replied` entirely from the sweep, which missed the goal. Every
|
|
263
|
+
// fulfilled task lives at status='replied' (no code path writes
|
|
264
|
+
// 'completed'), so the tasks table's main growth source would
|
|
265
|
+
// never be reclaimed. Fix: child-ref-aware delete — reap old
|
|
266
|
+
// replied rows that no other task references as parent_task_id.
|
|
267
|
+
//
|
|
268
|
+
// These three tests pin the corrected behaviour.
|
|
269
|
+
|
|
270
|
+
test("replied with active child task is KEPT (chain ancestor safety)", () => {
|
|
271
|
+
const parent = insertTask({
|
|
272
|
+
status: "replied",
|
|
273
|
+
daysAgo: 365,
|
|
274
|
+
completedDaysAgo: 365,
|
|
275
|
+
});
|
|
276
|
+
// Active child still references parent via parent_task_id —
|
|
277
|
+
// chainReplyToParent could walk through parent at any moment.
|
|
278
|
+
db.run(
|
|
279
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, parent_task_id)
|
|
280
|
+
VALUES (?1, 'a', 'b', 'normal', 'delivered', 'x', 'reply', datetime('now', '-365 days'), ?2)`,
|
|
281
|
+
[uuidv4(), parent]
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const r = sweepRetention(STRICT_CFG);
|
|
285
|
+
|
|
286
|
+
expect(r.deletes.tasks).toBe(0);
|
|
287
|
+
expect(count("tasks")).toBe(2);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("replied with NO child references AND past horizon is DELETED", () => {
|
|
291
|
+
insertTask({ status: "replied", daysAgo: 365, completedDaysAgo: 60 });
|
|
292
|
+
|
|
293
|
+
const r = sweepRetention(STRICT_CFG);
|
|
294
|
+
|
|
295
|
+
expect(r.deletes.tasks).toBe(1);
|
|
296
|
+
expect(count("tasks")).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("replied with NO child references AND within horizon is KEPT", () => {
|
|
300
|
+
// Just-completed replied with no children — needs to live for
|
|
301
|
+
// the full retention window in case dashboard/audit wants to see
|
|
302
|
+
// recent activity.
|
|
303
|
+
insertTask({ status: "replied", daysAgo: 5, completedDaysAgo: 5 });
|
|
304
|
+
|
|
305
|
+
const r = sweepRetention(STRICT_CFG);
|
|
306
|
+
|
|
307
|
+
expect(r.deletes.tasks).toBe(0);
|
|
308
|
+
expect(count("tasks")).toBe(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("replied with child that's been ITSELF swept stays sweepable next round", () => {
|
|
312
|
+
// Two-round scenario: parent + terminal child. Round 1: child
|
|
313
|
+
// (cancelled, past horizon) gets reaped → parent now has no
|
|
314
|
+
// children. Round 2: parent (replied, past horizon, no children)
|
|
315
|
+
// gets reaped. Idempotent + composable.
|
|
316
|
+
const parent = insertTask({
|
|
317
|
+
status: "replied",
|
|
318
|
+
daysAgo: 365,
|
|
319
|
+
completedDaysAgo: 60,
|
|
320
|
+
});
|
|
321
|
+
db.run(
|
|
322
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, completed_at, parent_task_id)
|
|
323
|
+
VALUES (?1, 'a', 'b', 'normal', 'cancelled', 'x', 'reply', datetime('now', '-365 days'), datetime('now', '-60 days'), ?2)`,
|
|
324
|
+
[uuidv4(), parent]
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const r1 = sweepRetention(STRICT_CFG);
|
|
328
|
+
// Round 1: only the cancelled child can be reaped (parent still
|
|
329
|
+
// has it as a referencing row at the time the DELETE NOT EXISTS
|
|
330
|
+
// is evaluated). DELETE order in SQLite is single-statement, so
|
|
331
|
+
// the child DELETE happens first (terminal-safe pass), then the
|
|
332
|
+
// replied pass sees no children → parent goes too.
|
|
333
|
+
// Conservative assertion: at minimum the child is gone.
|
|
334
|
+
expect(r1.deletes.tasks).toBeGreaterThanOrEqual(1);
|
|
335
|
+
|
|
336
|
+
// Re-run: should converge with zero rows regardless of whether
|
|
337
|
+
// round 1 took both or just the child.
|
|
338
|
+
sweepRetention(STRICT_CFG);
|
|
339
|
+
expect(count("tasks")).toBe(0);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("sweepRetention — VACUUM hook", () => {
|
|
344
|
+
test("returns wal_checkpoint pages-moved and incremental freed-pages", () => {
|
|
345
|
+
// Just exercise the codepath — Bun's sqlite layer may or may not
|
|
346
|
+
// return values the operator cares about depending on PRAGMA
|
|
347
|
+
// settings. The point is that the call doesn't error and the
|
|
348
|
+
// shape is what the operator log expects.
|
|
349
|
+
insertTelemetry({ daysAgo: 100 });
|
|
350
|
+
insertTelemetry({ daysAgo: 100 });
|
|
351
|
+
|
|
352
|
+
const r = sweepRetention(STRICT_CFG);
|
|
353
|
+
|
|
354
|
+
expect(r.vacuum.errored).toBe(false);
|
|
355
|
+
// walCheckpointPagesMoved may be 0 if there's nothing in the WAL.
|
|
356
|
+
expect(r.vacuum.walCheckpointPagesMoved).not.toBeUndefined();
|
|
357
|
+
// incrementalFreedPages may be 0 if auto_vacuum wasn't INCREMENTAL
|
|
358
|
+
// — that's the no-op path documented in retention.ts.
|
|
359
|
+
expect(r.vacuum.incrementalFreedPages).not.toBeUndefined();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("readRetentionConfig — env-var overrides", () => {
|
|
364
|
+
test("falls back to defaults when env vars unset", () => {
|
|
365
|
+
delete process.env.COMMHUB_RETENTION_TELEMETRY_DAYS;
|
|
366
|
+
delete process.env.COMMHUB_RETENTION_TASK_EVENTS_DAYS;
|
|
367
|
+
|
|
368
|
+
const cfg = readRetentionConfig();
|
|
369
|
+
|
|
370
|
+
expect(cfg.telemetryDays).toBe(7);
|
|
371
|
+
expect(cfg.taskEventsDays).toBe(30);
|
|
372
|
+
expect(cfg.auditLogDays).toBe(90);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("env override wins", () => {
|
|
376
|
+
process.env.COMMHUB_RETENTION_TELEMETRY_DAYS = "3";
|
|
377
|
+
process.env.COMMHUB_RETENTION_AUDIT_DAYS = "180";
|
|
378
|
+
|
|
379
|
+
const cfg = readRetentionConfig();
|
|
380
|
+
|
|
381
|
+
expect(cfg.telemetryDays).toBe(3);
|
|
382
|
+
expect(cfg.auditLogDays).toBe(180);
|
|
383
|
+
|
|
384
|
+
delete process.env.COMMHUB_RETENTION_TELEMETRY_DAYS;
|
|
385
|
+
delete process.env.COMMHUB_RETENTION_AUDIT_DAYS;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("non-finite env falls back silently (no operator footgun)", () => {
|
|
389
|
+
process.env.COMMHUB_RETENTION_TELEMETRY_DAYS = "not-a-number";
|
|
390
|
+
const cfg = readRetentionConfig();
|
|
391
|
+
expect(cfg.telemetryDays).toBe(7);
|
|
392
|
+
delete process.env.COMMHUB_RETENTION_TELEMETRY_DAYS;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("negative env value passes through (operator opt-out)", () => {
|
|
396
|
+
process.env.COMMHUB_RETENTION_INBOX_ACKED_DAYS = "-1";
|
|
397
|
+
const cfg = readRetentionConfig();
|
|
398
|
+
expect(cfg.ackedInboxDays).toBe(-1);
|
|
399
|
+
delete process.env.COMMHUB_RETENTION_INBOX_ACKED_DAYS;
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("agent_telemetry index split (round 2/4 review ② — query plan)", () => {
|
|
404
|
+
// The combined idx_agent_telemetry_host_time(network_id, hostname,
|
|
405
|
+
// ip, created_at) was dropped in favour of:
|
|
406
|
+
// - idx_agent_telemetry_hostname_time(network_id, hostname, created_at)
|
|
407
|
+
// - idx_agent_telemetry_ip_time(network_id, ip, created_at)
|
|
408
|
+
// (idx_agent_telemetry_alias_time is unchanged.)
|
|
409
|
+
//
|
|
410
|
+
// These tests assert the indexes the server-health endpoint relies
|
|
411
|
+
// on actually exist after schema migration. Query-plan inspection
|
|
412
|
+
// (EXPLAIN QUERY PLAN) lives below to prove the OR-disjunction can
|
|
413
|
+
// now use an index UNION instead of full scan.
|
|
414
|
+
|
|
415
|
+
test("split indexes exist; old combined is gone", () => {
|
|
416
|
+
const idx = db.all<{ name: string }>(
|
|
417
|
+
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'agent_telemetry' ORDER BY name"
|
|
418
|
+
);
|
|
419
|
+
const names = idx.map((r) => r.name);
|
|
420
|
+
expect(names).toContain("idx_agent_telemetry_alias_time");
|
|
421
|
+
expect(names).toContain("idx_agent_telemetry_hostname_time");
|
|
422
|
+
expect(names).toContain("idx_agent_telemetry_ip_time");
|
|
423
|
+
expect(names).not.toContain("idx_agent_telemetry_host_time");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("EXPLAIN QUERY PLAN for server-health (hostname OR ip) uses split indexes via index UNION", () => {
|
|
427
|
+
// Mirror the actual query from index.ts /api/server-health/:host
|
|
428
|
+
// (post-addNetworkScope, network_id pinned via direct equality —
|
|
429
|
+
// NOT COALESCE; addNetworkScope appends `AND network_id = ?` and
|
|
430
|
+
// that's what gets indexed).
|
|
431
|
+
insertTelemetry({ hostname: "h1", ip: "10.0.0.1", daysAgo: 0 });
|
|
432
|
+
const plan = db.all<{ detail: string }>(
|
|
433
|
+
`EXPLAIN QUERY PLAN
|
|
434
|
+
SELECT created_at FROM agent_telemetry
|
|
435
|
+
WHERE (hostname = ?1 OR ip = ?2)
|
|
436
|
+
AND created_at >= ?3
|
|
437
|
+
AND network_id = ?4`,
|
|
438
|
+
"h1",
|
|
439
|
+
"10.0.0.1",
|
|
440
|
+
"2026-01-01",
|
|
441
|
+
"default"
|
|
442
|
+
);
|
|
443
|
+
const planText = plan.map((p) => p.detail).join("\n");
|
|
444
|
+
// The split + the OR optimizer should produce a UNION-style plan
|
|
445
|
+
// that uses BOTH new indexes, not a full SCAN.
|
|
446
|
+
expect(planText).toContain("idx_agent_telemetry_hostname_time");
|
|
447
|
+
expect(planText).toContain("idx_agent_telemetry_ip_time");
|
|
448
|
+
expect(planText).not.toMatch(/^SCAN agent_telemetry$/m);
|
|
449
|
+
});
|
|
450
|
+
});
|