/** * @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders), the push-ingest token, the daily database backup (server/lib/db-backup.js) and the opt-in retention + Compact (server/lib/db-retention.js). This allows the frontend to manage and maintain the agent monitoring system effectively. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const fs = require("fs"); const path = require("path"); const os = require("os"); const v8 = require("v8"); const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db"); const { getConnectionCount } = require("../websocket"); const { transcriptCache } = require("./hooks"); const { forgetHourly } = require("../lib/hourly-usage"); const router = Router(); const APP_VERSION = (() => { try { return require("../../package.json").version || "0.0.0"; } catch { return "0.0.0"; } })(); const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home"); const { getCodexHome, setCodexHome } = require("../lib/codex-home"); const { envOwned, applySetting } = require("../lib/settings-env"); const privacy = require("../lib/privacy"); const dbBackup = require("../lib/db-backup"); const retention = require("../lib/db-retention"); const pushToken = require("../lib/push-token"); const pricingSync = require("../lib/pricing-sync"); const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle, exportableRun, } = require("../lib/data-transfer"); const { sessionWhere, bySessionWhere, memberId } = require("../lib/scope"); function getDbSize() { try { const stat = fs.statSync(DB_PATH); return stat.size; } catch { return 0; } } // `req` scopes the counts to a server-mode member's own rows (sessions, // agents, events, token usage); pricing is shared configuration, counted whole. function getTableCounts(req) { const own = sessionWhere(req); const bySession = bySessionWhere(req); const count = (sql, w) => db.prepare(`${sql} WHERE ${w.where}`).get(...w.params).c; return { sessions: count("SELECT COUNT(*) as c FROM sessions", own), agents: count("SELECT COUNT(*) as c FROM agents", bySession), events: count("SELECT COUNT(*) as c FROM events", bySession), model_pricing: db.prepare("SELECT COUNT(*) as c FROM model_pricing").get().c, token_usage: count("SELECT COUNT(DISTINCT session_id) as c FROM token_usage", bySession), }; } function getHookStatus() { try { if (!fs.existsSync(getSettingsPath())) { return { installed: false, path: getSettingsPath(), hooks: {} }; } const raw = fs.readFileSync(getSettingsPath(), "utf8"); const settings = JSON.parse(raw); // The enabled plugin runs the hooks from its own plugin.json, so // settings.json rightly holds none of ours (installHooks removes them). const { HOOK_TYPES: hookTypes, pluginProvidesHooks } = require("../../scripts/install-hooks"); if (pluginProvidesHooks(settings)) { return { installed: true, source: "plugin", path: getSettingsPath(), hooks: Object.fromEntries(hookTypes.map((ht) => [ht, true])), }; } const hooks = {}; for (const ht of hookTypes) { const entries = settings.hooks?.[ht] || []; hooks[ht] = entries.some( (e) => (e.command && e.command.includes("hook-handler.js")) || (Array.isArray(e.hooks) && e.hooks.some((h) => h.command && h.command.includes("hook-handler.js"))) ); } const installed = Object.values(hooks).every(Boolean); return { installed, path: getSettingsPath(), hooks }; } catch { return { installed: false, path: getSettingsPath(), hooks: {} }; } } // GET /api/settings/info — system info, db stats, hook status router.get("/info", (req, res) => { const dbSize = getDbSize(); const counts = getTableCounts(req); const hookStatus = getHookStatus(); // Advanced SQLite info const pragmas = { journal_mode: db.pragma("journal_mode", { simple: true }), synchronous: db.pragma("synchronous", { simple: true }), auto_vacuum: db.pragma("auto_vacuum", { simple: true }), encoding: db.pragma("encoding", { simple: true }), foreign_keys: db.pragma("foreign_keys", { simple: true }), busy_timeout: db.pragma("busy_timeout", { simple: true }), }; // Recent activity load (events in last 5, 15, 60 minutes) const ownEvents = bySessionWhere(req); const getCount = (ms) => { const d = new Date(Date.now() - ms).toISOString(); return db .prepare(`SELECT COUNT(*) as c FROM events WHERE created_at > ? AND ${ownEvents.where}`) .get(d, ...ownEvents.params).c; }; const load_stats = { m5: getCount(5 * 60 * 1000), m15: getCount(15 * 60 * 1000), h1: getCount(60 * 60 * 1000), }; // A developer (server mode) gets the numbers, never a path of the host's: // the transcript cache is keyed by EVERY user's transcript path (their // folders, their session ids), and the DB/settings paths are the host's. const developer = memberId(req) != null; const cache = transcriptCache.stats(); res.json({ db: { path: developer ? null : DB_PATH, size: dbSize, counts, pragmas, load_stats, }, hooks: developer ? { installed: hookStatus.installed } : hookStatus, server: { version: APP_VERSION, uptime: process.uptime(), node_version: process.version, platform: process.platform, ws_connections: getConnectionCount(), // heapTotal is only what V8 has grown the heap to SO FAR, so used/total // sits at 70-90% on a perfectly healthy process. heapLimit is the ceiling // the process actually dies at — the denominator a "heap used %" needs. memory: { ...process.memoryUsage(), heapLimit: v8.getHeapStatistics().heap_size_limit }, cpu_load: os.loadavg(), arch: os.arch(), total_mem: os.totalmem(), free_mem: os.freemem(), cpus: os.cpus().length, }, transcript_cache: developer ? { ...cache, keys: undefined } : cache, }); }); // POST /api/settings/clear-data — delete all sessions, agents, events, tokens router.post("/clear-data", (_req, res) => { const counts = getTableCounts(); // foreign_keys cannot change inside a transaction, so it is turned off // around one: all of it lands, or none of it (a crash midway used to leave // events without sessions). db.pragma("foreign_keys = OFF"); try { db.transaction(clearAll)(); } finally { db.pragma("foreign_keys = ON"); } res.json({ ok: true, cleared: counts }); }); function clearAll() { db.prepare("DELETE FROM token_usage").run(); db.prepare("DELETE FROM token_usage_hourly").run(); forgetHourly(); db.prepare("DELETE FROM events").run(); db.prepare("DELETE FROM agents").run(); db.prepare("DELETE FROM sessions").run(); // What the Codex/Antigravity/Cursor polls have read: kept, it would stop them // ever importing those sessions again (and bring one back half, on its next write). db.prepare("DELETE FROM codex_rollouts").run(); db.prepare("DELETE FROM antigravity_conversations").run(); db.prepare("DELETE FROM cursor_transcripts").run(); // Fired alerts reference the cleared sessions — wipe the feed too. Alert // *rules* survive: they're user configuration, like model_pricing. db.prepare("DELETE FROM alert_events").run(); // Webhook delivery log is an audit trail of those fired alerts — wipe it too. // Webhook *targets* survive, like alert rules and pricing. db.prepare("DELETE FROM webhook_deliveries").run(); // Workflow runs belong to the sessions; with foreign keys off they would // not cascade and stay as orphans. db.prepare("DELETE FROM workflows").run(); // session_snapshots survive: a snapshot is a frozen copy the user chose to // keep, like alert rules — deleted only from Settings → Snapshots & reports. // report_definitions and report_runs survive too: a definition is user // configuration and a run's files are reports already delivered — removed // only from Settings → Snapshots & reports. } // POST /api/settings/reimport — re-import legacy sessions from ~/.claude/ router.post("/reimport", async (_req, res) => { try { const { importAllSessions } = require("../../scripts/import-history"); const dbModule = require("../db"); const result = await importAllSessions(dbModule); res.json({ ok: true, ...result }); } catch (err) { res.status(500).json({ error: { code: "IMPORT_FAILED", message: err.message }, }); } }); // POST /api/settings/reinstall-hooks — reinstall Claude Code hooks router.post("/reinstall-hooks", (_req, res) => { try { const { installHooks } = require("../../scripts/install-hooks"); const success = installHooks(true); const hookStatus = getHookStatus(); res.json({ ok: success, hooks: hookStatus }); } catch (err) { res.status(500).json({ error: { code: "HOOK_INSTALL_FAILED", message: err.message }, }); } }); // GET/POST/DELETE /api/settings/codex-hooks — AgentHub's hooks in Codex's // `$CODEX_HOME/hooks.json`: status, install (or reinstall), remove. Only ever // on a click; Codex itself asks the user to trust them (server/lib/codex-hooks.js). const codexHooks = require("../lib/codex-hooks"); const { sameOriginGuard } = require("../lib/same-origin"); function codexHooksRoute(fn) { return (_req, res) => { try { res.json(fn()); } catch (err) { const client = ["EBADJSON", "EUNSUPPORTED", "EFOREIGN"].includes(err.code); res.status(client ? 400 : 500).json({ error: { code: client ? err.code : "ESAVE", message: err.message }, }); } }; } router.get("/codex-hooks", codexHooksRoute(codexHooks.status)); // Writing makes Codex run a command (once trusted), so no other site may ask. router.post("/codex-hooks", sameOriginGuard, codexHooksRoute(codexHooks.install)); router.delete("/codex-hooks", sameOriginGuard, codexHooksRoute(codexHooks.remove)); // GET/POST/DELETE /api/settings/antigravity-hooks — AgentHub's named hook in // `~/.gemini/config/hooks.json` (server/lib/antigravity-hooks.js). agy runs it // with no trust step, so the write routes refuse another site the same way. const antigravityHooks = require("../lib/antigravity-hooks"); router.get("/antigravity-hooks", codexHooksRoute(antigravityHooks.status)); router.post("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.install)); router.delete("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.remove)); // GET/PUT /api/settings/autostart — start the dashboard at boot/login through // the per-user service (server/lib/autostart.js). Off by default; a PUT only // enables or disables the unit, it never starts or stops the server. const autostart = require("../lib/autostart"); router.get("/autostart", (_req, res) => res.json(autostart.status())); router.put("/autostart", sameOriginGuard, (req, res) => { if (typeof req.body?.enabled !== "boolean") { return res .status(400) .json({ error: { code: "EBADVALUE", message: "`enabled` must be a boolean" } }); } try { res.json(autostart.setEnabled(req.body.enabled)); } catch (err) { const status = { EUNAVAILABLE: 400, EFOREIGN: 409 }[err.code] || 500; res.status(status).json({ error: { code: err.code || "ESERVICE", message: err.message } }); } }); // GET/PUT /api/settings/tray — AgentHub's icon in the OS status area // (server/lib/tray.js). On by default; a PUT starts or stops the icon process. const tray = require("../lib/tray"); router.get("/tray", (_req, res) => res.json(tray.status())); router.put("/tray", sameOriginGuard, (req, res) => { if (typeof req.body?.enabled !== "boolean") { return res .status(400) .json({ error: { code: "EBADVALUE", message: "`enabled` must be a boolean" } }); } res.json(tray.setEnabled(req.body.enabled)); }); // POST /api/settings/quit — Quit AgentHub (server/lib/quit.js): kill every // lane pane, then stop this server once the answer is out. It stays stopped // (the bootstrap starts none) until a server starts again — `agenthub start`, // or boot/login through an enabled autostart unit. const quit = require("../lib/quit"); router.post("/quit", sameOriginGuard, (_req, res) => { quit.markQuit(); const runs = require("../lib/pty-run"); let killed = 0; for (const run of runs.listRuns()) if (runs.killRun(run.id)) killed++; res.on("finish", () => quit.stopServer()); res.json({ ok: true, killed }); }); // POST /api/settings/uninstall {confirm: true} — Uninstall AgentHub // (scripts/uninstall.js) in a detached worker, because it stops this server. // Under systemd it starts in a scope of its own, outside the unit's cgroup — // an older unit still on KillMode=control-group would reap it mid-run; launchd // leaves it alone (AbandonProcessGroup). // Never in server mode — a shared dashboard is removed from the host's shell. router.post("/uninstall", sameOriginGuard, (req, res) => { if (require("../lib/auth").isServerMode()) { return res.status(403).json({ error: { code: "ESERVERMODE", message: "Run `agenthub uninstall` on the host instead" }, }); } if (req.body?.confirm !== true) { return res .status(400) .json({ error: { code: "ECONFIRM", message: "`confirm: true` is required" } }); } const script = path.join(__dirname, "..", "..", "scripts", "uninstall.js"); const svc = require("../../scripts/service-manager"); const argv = [process.execPath, script, "--yes"]; const [cmd, ...args] = svc.kind === "systemd" && svc.systemctlAvailable() ? ["systemd-run", "--user", "--scope", "--quiet", "--collect", "--", ...argv] : argv; require("child_process") .spawn(cmd, args, { cwd: os.tmpdir(), // never the plugin root, which it deletes detached: true, stdio: "ignore", env: process.env, }) .unref(); res.status(202).json({ ok: true, log: require("../../scripts/uninstall").logPath() }); }); // POST /api/settings/reset-pricing — reset pricing to defaults. The seed is // Claude's; a model priced by the automatic sync (an OpenAI one, from Codex // sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it. // `{scope: "claude"}` (the Claude tab's button) resets only `claude-…` rows, // so another vendor's edits and a custom router row survive it. const RESET_SYNC_MS = 3000; router.post("/reset-pricing", async (req, res) => { if (req.body?.scope === "claude") { db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-%'").run(); db.prepare("DELETE FROM model_pricing_dismissed WHERE model_pattern LIKE 'claude-%'").run(); } else { db.prepare("DELETE FROM model_pricing").run(); db.prepare("DELETE FROM model_pricing_dismissed").run(); } const seedPricing = db.prepare( "INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); for (const [pattern, name, inp, out, cr, cw, cw1h, fin, fout] of DEFAULT_PRICING) { seedPricing.run(pattern, name, inp, out, cr, cw, cw1h, fin, fout); } // Re-apply time-limited intro rates (e.g. Sonnet 5) — the seed above only // carries standard rates, so without this a reset silently drops the promo. applyIntroPricing(db); if (pricingSync.syncEnabled()) { // The rows just deleted include current models nobody has used yet; the // cached list counts as applied, so without this they wait for tomorrow. pricingSync.invalidate(); await Promise.race([ pricingSync.syncPricing(db), new Promise((r) => setTimeout(r, RESET_SYNC_MS).unref()), ]); } const pricing = stmts.listPricing.all(); res.json({ ok: true, pricing }); }); // GET /api/settings/export — export all data as JSON. Same document as ever, // but written as it is read: `events` alone is GBs on a long-lived install, // and one `.all()` + `res.json` held it in memory twice (past V8's string // limit). Events go out in keyset pages, waiting for the socket to drain. const EXPORT_PAGE = 500; router.get("/export", async (req, res) => { res.setHeader("Content-Type", "application/json"); res.setHeader( "Content-Disposition", `attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"` ); let gone = false; req.on("close", () => (gone = true)); const write = async (chunk) => { if (!res.write(chunk)) await new Promise((r) => res.once("drain", r)); }; const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`; try { await write( `{"format":${JSON.stringify(EXPORT_FORMAT)},"version":${EXPORT_VERSION}` + field("exported_at", new Date().toISOString()) ); await write( field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all()) ); await write(field("agents", db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all())); await write(`,"events":[`); const first = db.prepare( `SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}` ); const next = db.prepare( `SELECT * FROM events WHERE created_at < ? OR (created_at = ? AND id < ?) ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}` ); let page = first.all(); let sep = ""; while (page.length && !gone) { await write(sep + page.map((e) => JSON.stringify(e)).join(",")); sep = ","; const last = page[page.length - 1]; page = next.all(last.created_at, last.created_at, last.id); } await write("]"); await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all())); await write(field("workflows", db.prepare("SELECT * FROM workflows").all())); await write( field( "dashboard_runs", db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all().map(exportableRun) ) ); await write( field("alert_rules", db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all()) ); await write(field("model_pricing", stmts.listPricing.all()) + "}"); res.end(); } catch (err) { console.warn("export failed:", err.message); res.destroy(err); } }); // POST /api/settings/import — restore a file written by GET /export, uploaded // as multipart `file`. Non-destructive (data-transfer.js: a session already // here is skipped whole, nothing is overwritten). The whole file is parsed in // memory, hence the size cap; a bad file is answered without echoing its text. // ponytail: one JSON.parse of the whole bundle; stream-parse if backups outgrow 512 MB. const IMPORT_MAX_BYTES = 512 * 1024 * 1024; router.post("/import", sameOriginGuard, (req, res) => { const multer = require("multer"); const upload = multer({ dest: os.tmpdir(), limits: { files: 1, fileSize: IMPORT_MAX_BYTES, fields: 8 }, }).single("file"); upload(req, res, (uploadErr) => { const file = req.file; const done = (status, body) => { if (file) fs.rm(file.path, { force: true }, () => {}); res.status(status).json(body); }; if (uploadErr) { const tooBig = uploadErr.code === "LIMIT_FILE_SIZE"; return done(tooBig ? 413 : 400, { error: { code: tooBig ? "FILE_TOO_LARGE" : "BAD_UPLOAD", message: tooBig ? "the backup is larger than 512 MB" : uploadErr.message, }, }); } if (!file) { return done(400, { error: { code: "NO_FILE", message: "attach the export as `file`" } }); } let bundle; try { bundle = JSON.parse(fs.readFileSync(file.path, "utf8")); } catch { return done(400, { error: { code: "INVALID_FORMAT", message: "Not a valid export file (not JSON)." }, }); } try { const counts = importExportBundle(db, bundle); done(200, { ok: true, source: file.originalname, format: bundle.format ?? null, ...counts, }); } catch (err) { if (err.code === "INVALID_FORMAT") { return done(400, { error: { code: err.code, message: err.message } }); } console.warn("import failed:", err.message); done(500, { error: { code: "IMPORT_FAILED", message: err.message } }); } }); }); // The two editable home directories share one shape: GET answers the value and // whether the real environment set it (`env_owned` — then a saved value is // overridden again at the next start), PUT validates, applies and persists // (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other // bad path, 500 ESAVE = the settings file could not be written. function homeRoutes(route, key, field, get, set) { router.get(route, (_req, res) => { res.json({ [field]: get(), env_owned: envOwned.has(key) }); }); // A home decides which config a pane runs (CODEX_HOME: MCP servers, notify // commands), so no other site may set one. router.put(route, sameOriginGuard, (req, res) => { const newPath = req.body && req.body.path; if (!newPath || typeof newPath !== "string") { return res.status(400).json({ error: { code: "INVALID_PATH", message: "path is required and must be a string" }, }); } let resolved; try { resolved = set(newPath); } catch (err) { // A bad path is 400; anything else (the settings file unwritable) is ours. const bad = err.code === "EBADHOME" || err.code === "INVALID_PATH"; return res.status(bad ? 400 : 500).json({ error: { code: bad ? err.code : "ESAVE", message: err.message }, }); } res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key) }); }); } // GET/PUT /api/settings/claude-home homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome); // GET/PUT /api/settings/codex-home — a session's AI Proxy layer is written // into the current home at its spawn, so a move needs no repair here. homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome); // GET/PUT /api/settings/privacy — opt-in secret redaction of stored payloads // and the folders whose hooks are dropped (server/lib/privacy.js). Both persist // to the settings file; one the environment set is `env_owned` and refused. function privacyState() { return { redact_secrets: privacy.redactionEnabled(), ignore_cwd: privacy.ignoredCwds(), env_owned: { redact_secrets: envOwned.has(privacy.REDACT_KEY), ignore_cwd: envOwned.has(privacy.IGNORE_KEY), }, }; } router.get("/privacy", (_req, res) => res.json(privacyState())); router.put("/privacy", sameOriginGuard, (req, res) => { const body = req.body || {}; const fail = (status, code, message) => res.status(status).json({ error: { code, message } }); const writes = []; if (body.redact_secrets !== undefined) { if (typeof body.redact_secrets !== "boolean") return fail(400, "INVALID_INPUT", "redact_secrets must be a boolean"); writes.push([privacy.REDACT_KEY, body.redact_secrets ? "1" : "0"]); } if (body.ignore_cwd !== undefined) { let list; try { list = privacy.validateIgnoreList(body.ignore_cwd); } catch (err) { return fail(400, err.code || "EBADPATH", err.message); } writes.push([privacy.IGNORE_KEY, list.join(path.delimiter)]); } const owned = writes.find(([k]) => envOwned.has(k)); if (owned) return fail(409, "EENVOWNED", `${owned[0]} is set by the environment`); try { for (const [k, v] of writes) applySetting(k, v); } catch (err) { return fail(500, "ESAVE", err.message); } res.json({ ok: true, ...privacyState() }); }); // GET/PUT /api/settings/backup, POST /api/settings/backup/run — the daily // database backup (server/lib/db-backup.js). The folder, hour and count persist // to the settings file; one the environment set is `env_owned` and refused. A // run answers 202 at once (a large database takes tens of seconds) and the // page polls GET while `running`. function backupState() { const cfg = dbBackup.backupConfig(); let files = []; let dir_error = null; if (cfg.dir) { try { files = dbBackup.listBackups(cfg.dir); } catch (err) { dir_error = err.message; } } return { ...cfg, running: dbBackup.isRunning(), last: dbBackup.lastResult(), files, dir_error, env_owned: Object.fromEntries( Object.entries(dbBackup.KEYS).map(([field, key]) => [field, envOwned.has(key)]) ), }; } router.get("/backup", (_req, res) => res.json(backupState())); router.put("/backup", sameOriginGuard, (req, res) => { const body = req.body || {}; const fail = (code, message) => res.status(400).json({ error: { code, message } }); const writes = []; if (body.dir !== undefined) { if (typeof body.dir !== "string") return fail("INVALID_PATH", "dir must be a string"); let dir = ""; if (body.dir.trim()) { try { dir = dbBackup.validateBackupDir(body.dir); } catch (err) { return fail(err.code || "INVALID_PATH", err.message); } } writes.push([dbBackup.KEYS.dir, dir]); } for (const [field, lo, hi] of [ ["hour", 0, 23], ["keep", 1, 365], ]) { if (body[field] === undefined) continue; const n = body[field]; if (!Number.isInteger(n) || n < lo || n > hi) { return fail("INVALID_INPUT", `${field} must be a whole number from ${lo} to ${hi}`); } writes.push([dbBackup.KEYS[field], String(n)]); } const owned = writes.find(([k]) => envOwned.has(k)); if (owned) { return res .status(409) .json({ error: { code: "EENVOWNED", message: `${owned[0]} is set by the environment` } }); } try { for (const [k, v] of writes) applySetting(k, v); } catch (err) { return res.status(500).json({ error: { code: "ESAVE", message: err.message } }); } res.json({ ok: true, ...backupState() }); }); router.post("/backup/run", sameOriginGuard, (_req, res) => { if (!dbBackup.backupConfig().dir) { return res .status(400) .json({ error: { code: "ENOBACKUPDIR", message: "choose a backup folder first" } }); } dbBackup.runBackup().catch(() => {}); // the outcome is `last` in GET res.status(202).json({ ok: true, ...backupState() }); }); // GET/POST/DELETE /api/settings/push-token — the token for POST // /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it; // POST makes a new one (the old one stops working) and returns it this once. router.get("/push-token", (_req, res) => res.json(pushToken.pushTokenStatus())); function pushTokenWrite(fn) { return (_req, res) => { try { const token = fn(); res.json({ ok: true, ...(token && { token }), ...pushToken.pushTokenStatus() }); } catch (err) { const owned = err.code === "EENVOWNED"; res .status(owned ? 409 : 500) .json({ error: { code: owned ? err.code : "ESAVE", message: err.message } }); } }; } router.post("/push-token", sameOriginGuard, pushTokenWrite(pushToken.rotatePushToken)); router.delete("/push-token", sameOriginGuard, pushTokenWrite(pushToken.revokePushToken)); // GET/PUT /api/settings/retention, POST /api/settings/compact — database size // upkeep (server/lib/db-retention.js). Retention is OFF by default: `days` 0 // turns it off, 1–3650 makes the hourly maintenance purge finished sessions // older than that, the manual purge's rule. It persists to the settings file; // one the environment set is `env_owned` and refused. Compact (a full VACUUM // in a worker) answers 202 and the page polls GET while `compacting`. function retentionState() { let space = null; try { space = retention.spaceInfo(db); } catch { /* pragma unavailable: the card shows no numbers */ } return { ...retention.retentionConfig(), max_days: retention.MAX_DAYS, env_owned: { days: envOwned.has(retention.KEYS.days) }, last_run: retention.lastMaintenance(), space, db_size: getDbSize(), compacting: retention.isCompacting(), last_compact: retention.lastCompactResult(), }; } router.get("/retention", (_req, res) => res.json(retentionState())); router.put("/retention", sameOriginGuard, (req, res) => { const days = req.body?.days; if (!Number.isInteger(days) || days < 0 || days > retention.MAX_DAYS) { return res.status(400).json({ error: { code: "INVALID_INPUT", message: `days must be a whole number from 0 (off) to ${retention.MAX_DAYS}`, }, }); } if (envOwned.has(retention.KEYS.days)) { return res.status(409).json({ error: { code: "EENVOWNED", message: `${retention.KEYS.days} is set by the environment` }, }); } try { applySetting(retention.KEYS.days, days === 0 ? "" : String(days)); } catch (err) { return res.status(500).json({ error: { code: "ESAVE", message: err.message } }); } res.json({ ok: true, ...retentionState() }); }); router.post("/compact", sameOriginGuard, (req, res) => { if (req.body?.confirm !== true) { return res.status(400).json({ error: { code: "ECONFIRM", message: "compacting needs confirm: true" }, }); } retention.runCompact().catch(() => {}); // the outcome is `last_compact` in GET res.status(202).json({ ok: true, ...retentionState() }); }); // POST /api/settings/cleanup — abandon stale sessions, purge old data. // `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything); // `dry_run: true` answers the same counts and changes nothing. router.post("/cleanup", (req, res) => { const { abandon_hours, purge_days } = req.body; const dryRun = req.body.dry_run === true; if (purge_days !== undefined && purge_days !== null) { if (!Number.isInteger(purge_days) || purge_days < 1) { return res.status(400).json({ error: { code: "INVALID_PURGE_DAYS", message: "purge_days must be a whole number of days, 1 or more", }, }); } } const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 }; if (dryRun) result.dry_run = true; // A dry run abandons nothing, so the purge below would not see the sessions a // real run abandons first and then purges; it adds them back from this list. let wouldAbandon = []; if (abandon_hours && typeof abandon_hours === "number" && abandon_hours > 0) { // Mark active sessions with no recent events as abandoned const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString(); const stale = db .prepare( `SELECT s.id, s.started_at FROM sessions s WHERE s.status = 'active' AND s.started_at < ? AND NOT EXISTS ( SELECT 1 FROM events e WHERE e.session_id = s.id AND e.created_at > ? )` ) .all(cutoff, cutoff); const abandon = db.prepare( "UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" ); if (dryRun) { result.abandoned = stale.length; wouldAbandon = stale; } else { // Also complete any lingering agents const complete = db.prepare( "UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')" ); db.transaction(() => { for (const row of stale) { abandon.run(row.id); complete.run(row.id); } })(); result.abandoned = stale.length; } } if (purge_days && typeof purge_days === "number" && purge_days > 0) { const cutoff = new Date(Date.now() - purge_days * 86400 * 1000).toISOString(); // Only purge completed/error/abandoned sessions, never active const toDelete = retention.purgeableBefore(db, cutoff); if (dryRun) { // Exactly the ids the real run deletes: those already purgeable, plus // the stale ones it abandons first that are also older than the cutoff. const ids = [ ...toDelete.map((r) => r.id), ...wouldAbandon.filter((r) => r.started_at < cutoff).map((r) => r.id), ]; Object.assign(result, retention.countSessionIds(db, ids)); return res.json({ ok: true, ...result }); } // One transaction; the deletion itself is shared with the automatic // retention (server/lib/db-retention.js). const ids = toDelete.map((r) => r.id); const purged = db.transaction(() => retention.purgeSessionIds(db, ids))(); Object.assign(result, purged, { purged_sessions: ids.length }); } res.json({ ok: true, ...result }); }); module.exports = router;