e07993c536
- 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).
823 lines
32 KiB
JavaScript
823 lines
32 KiB
JavaScript
/**
|
||
* @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;
|