@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,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
+ });