/** * @file Tests server/lib/db-retention.js and its Settings routes — a new * database is INCREMENTAL auto_vacuum, retention is off by default and purges * nothing then, when on it removes only finished sessions older than the * cutoff (with their events, agents and token rows) in bounded batches, the * free-page reclaim shrinks the freelist, GET/PUT /api/settings/retention * validate and persist the setting, and POST /api/settings/compact needs * confirm and runs a VACUUM in a worker. The database and settings file are * temp paths. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const fs = require("node:fs"); const path = require("node:path"); const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-retention-")); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db"); process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; delete process.env.AGENTHUB_RETENTION_DAYS; const { createApp, startServer } = require("../index"); const { db } = require("../db"); const retention = require("../lib/db-retention"); let server; let BASE; before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); delete process.env.AGENTHUB_RETENTION_DAYS; fs.rmSync(ROOT, { recursive: true, force: true }); }); async function call(method, urlPath, body) { const res = await fetch(BASE + urlPath, { method, headers: body ? { "content-type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json() }; } const daysAgo = (n) => new Date(Date.now() - n * 86400 * 1000).toISOString(); function seed(id, status, startedAt, events = 3) { db.prepare( "INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, ?, '/tmp', ?, ?)" ).run(id, id, status, startedAt, startedAt); const ins = db.prepare( "INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES (?, 'PostToolUse', 's', ?, ?)" ); for (let i = 0; i < events; i++) ins.run(id, JSON.stringify({ pad: "x".repeat(2000) }), startedAt); } const exists = (id) => !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id); const eventCount = (id) => db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n; describe("auto_vacuum", () => { it("a new database is INCREMENTAL", () => { assert.equal(db.pragma("auto_vacuum", { simple: true }), 2); assert.equal(retention.spaceInfo(db).auto_vacuum, "incremental"); }); }); describe("retentionConfig", () => { it("is off by default and for anything out of range", () => { assert.equal(retention.retentionConfig({}).days, 0); for (const v of ["", "0", "-3", "1.5", "abc", "99999"]) { assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: v }).days, 0, v); } assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: "30" }).days, 30); }); }); describe("scheduled maintenance", () => { it("purges nothing while retention is off", async () => { seed("ret-off-old", "completed", daysAgo(400)); const out = await retention.runScheduledMaintenance({ db }); assert.equal(out.retention, undefined); assert.ok(exists("ret-off-old")); db.prepare("DELETE FROM sessions WHERE id = 'ret-off-old'").run(); }); it("purges only finished sessions older than the cutoff, with their rows", async () => { seed("ret-old-done", "completed", daysAgo(100)); seed("ret-old-err", "error", daysAgo(100)); seed("ret-old-active", "active", daysAgo(100)); seed("ret-new-done", "completed", daysAgo(5)); db.prepare( "INSERT INTO token_usage (session_id, model, input_tokens) VALUES ('ret-old-done', 'm', 1)" ).run(); process.env.AGENTHUB_RETENTION_DAYS = "30"; try { const out = await retention.runScheduledMaintenance({ db }); assert.equal(out.retention.purged_sessions, 2); assert.equal(out.retention.purged_events, 6); } finally { delete process.env.AGENTHUB_RETENTION_DAYS; } assert.ok(!exists("ret-old-done")); assert.ok(!exists("ret-old-err")); assert.equal(eventCount("ret-old-done"), 0); assert.equal( db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = 'ret-old-done'").get().n, 0 ); assert.ok(exists("ret-old-active"), "an active session is never purged"); assert.ok(exists("ret-new-done"), "a recent session is kept"); assert.equal(eventCount("ret-new-done"), 3); }); it("works in bounded batches and stops at the per-run cap", async () => { for (let i = 0; i < 7; i++) seed(`ret-batch-${i}`, "abandoned", daysAgo(50), 1); const first = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 }); assert.equal(first.purged_sessions, 5); const second = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 }); assert.equal(second.purged_sessions, 2); const third = await retention.applyRetention({ db, days: 10 }); assert.equal(third.purged_sessions, 0); }); it("returns free pages to the OS", async () => { for (let i = 0; i < 40; i++) seed(`ret-big-${i}`, "completed", daysAgo(60), 20); await retention.applyRetention({ db, days: 30 }); const before = db.pragma("freelist_count", { simple: true }); assert.ok(before > 50, `expected free pages, got ${before}`); const out = await retention.reclaimFreePages({ db, minPages: 10, slice: 16 }); assert.ok(out.reclaimed_pages > 0); assert.ok(db.pragma("freelist_count", { simple: true }) < before); }); }); describe("routes", () => { it("GET reports retention off by default", async () => { const r = await call("GET", "/api/settings/retention"); assert.equal(r.status, 200); assert.equal(r.body.days, 0); assert.equal(r.body.compacting, false); assert.equal(r.body.space.auto_vacuum, "incremental"); }); it("PUT validates and persists, 0 turns it off", async () => { for (const bad of [-1, 1.5, "7", 99999, null]) { const r = await call("PUT", "/api/settings/retention", { days: bad }); assert.equal(r.status, 400, String(bad)); } let r = await call("PUT", "/api/settings/retention", { days: 90 }); assert.equal(r.status, 200); assert.equal(r.body.days, 90); assert.match(fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), /RETENTION_DAYS=90/); r = await call("PUT", "/api/settings/retention", { days: 0 }); assert.equal(r.body.days, 0); }); it("POST /compact needs confirm and then vacuums", async () => { let r = await call("POST", "/api/settings/compact", {}); assert.equal(r.status, 400); r = await call("POST", "/api/settings/compact", { confirm: true }); assert.equal(r.status, 202); for (let i = 0; i < 200 && retention.isCompacting(); i++) { await new Promise((res) => setTimeout(res, 50)); } const last = retention.lastCompactResult(); assert.equal(last?.ok, true, last?.error); assert.equal(db.pragma("auto_vacuum", { simple: true }), 2); }); });