Files
AgentHub/server/routes/settings.js
T
nntrivi2001 e07993c536 perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact
- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
2026-10-16 10:20:38 +07:00

823 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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;