/** * @file Scheduled database backups (Settings → Data → Backup). Once a day, at * a chosen local hour of the server, the dashboard's SQLite database is copied * into a folder the admin picked: `VACUUM INTO` on a READ-ONLY connection in a * worker thread — a consistent snapshot that writes from other connections * never restart, compacted (1.25 GB → ~0.7 GB), while the event loop keeps * serving (≈30 s at that size). The file name is server-made, * `agenthub-YYYYMMDD-HHMMSS.db`, written as `.partial` and renamed once * complete. Rotation deletes ONLY regular files matching that exact name, oldest * first beyond `keep` — nothing else in the folder, never through a symlink. * Settings live in the settings file: AGENTHUB_BACKUP_DIR (empty = off), * AGENTHUB_BACKUP_HOUR (0–23, default 2), AGENTHUB_BACKUP_KEEP (1–365, default * 7). A missed day (server down at the hour) runs at the next start; a failed * one is retried the next day or by "Back up now", never every minute. The * scheduler ticks every DASHBOARD_BACKUP_TICK_MS (default 60 s, 0 = off). * Restoring is manual: stop AgentHub, copy a backup over the database, start. * This file is also the worker's entry point. * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads"); const KEYS = { dir: "AGENTHUB_BACKUP_DIR", hour: "AGENTHUB_BACKUP_HOUR", keep: "AGENTHUB_BACKUP_KEEP", }; const NAME_RE = /^agenthub-(\d{8})-(\d{6})\.db$/; const coded = (message, code) => Object.assign(new Error(message), { code }); function int(value, lo, hi, fallback) { const n = Number(value); return value !== undefined && value !== "" && Number.isInteger(n) && n >= lo && n <= hi ? n : fallback; } function backupConfig(env = process.env) { return { dir: env[KEYS.dir] || "", hour: int(env[KEYS.hour], 0, 23, 2), keep: int(env[KEYS.keep], 1, 365, 7), }; } /** Absolute, existing, writable directory → its resolved path; else INVALID_PATH. */ function validateBackupDir(input) { const invalid = (message) => coded(message, "INVALID_PATH"); const trimmed = String(input).trim(); if (/[\x00-\x1f]/.test(trimmed)) throw invalid("the folder may not contain control characters"); const expanded = trimmed.replace(/^~(?=$|\/)/, os.homedir()); if (!path.isAbsolute(expanded)) throw invalid("the backup folder must be an absolute path"); const resolved = path.resolve(expanded); let st; try { st = fs.statSync(resolved); } catch { throw invalid(`Directory does not exist: ${resolved}`); } if (!st.isDirectory()) throw invalid(`Not a directory: ${resolved}`); try { fs.accessSync(resolved, fs.constants.W_OK); } catch { throw invalid(`AgentHub cannot write to ${resolved}`); } return resolved; } /** Local `YYYYMMDD-HHMMSS`. */ function stamp(d) { const p = (n) => String(n).padStart(2, "0"); return ( `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` + `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}` ); } /** AgentHub's own backups in `dir`, newest first. Symlinks and look-alikes are not ours. */ function listBackups(dir) { return fs .readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isFile() && NAME_RE.test(e.name)) .map((e) => { const st = fs.statSync(path.join(dir, e.name)); return { name: e.name, size: st.size, mtime: st.mtime.toISOString() }; }) .sort((a, b) => (a.name < b.name ? 1 : -1)); } /** Delete our backups beyond the newest `keep`; returns the names removed. */ function rotate(dir, keep) { const old = listBackups(dir).slice(keep); for (const f of old) fs.unlinkSync(path.join(dir, f.name)); return old.map((f) => f.name); } /** Whether the scheduler should back up now. `attemptedDay` = YYYYMMDD already tried. */ function dueNow(now, cfg, files, attemptedDay) { if (!cfg.dir || now.getHours() < cfg.hour) return false; const today = stamp(now).slice(0, 8); if (attemptedDay === today) return false; return !files.some((f) => NAME_RE.exec(f.name)?.[1] === today); } function vacuumInto(dbPath, out) { return new Promise((resolve, reject) => { const w = new Worker(__filename, { workerData: { agenthubBackupWorker: true, dbPath, out } }); w.once("message", (m) => (m.error ? reject(coded(m.error.message, m.error.code)) : resolve())); w.once("error", reject); w.once( "exit", (code) => code !== 0 && reject(coded(`backup worker exited (${code})`, "EBACKUP")) ); }); } let running = null; let last = null; function runBackup() { if (running) return running; running = (async () => { const { dir: configured, keep } = backupConfig(); if (!configured) throw coded("no backup folder is set", "ENOBACKUPDIR"); const started = new Date(); let partial = null; try { const dir = validateBackupDir(configured); // it may have been unmounted since const name = `agenthub-${stamp(started)}.db`; partial = path.join(dir, `${name}.partial`); await vacuumInto(require("../db").DB_PATH, partial); fs.renameSync(partial, path.join(dir, name)); partial = null; const removed = rotate(dir, keep); const size = fs.statSync(path.join(dir, name)).size; last = { at: started.toISOString(), ok: true, file: name, size, ms: Date.now() - started, removed, }; return last; } catch (err) { if (partial) fs.rmSync(partial, { force: true }); last = { at: started.toISOString(), ok: false, error: err.message }; throw err; } })().finally(() => { running = null; }); return running; } const isRunning = () => running !== null; const lastResult = () => last; function startBackupScheduler() { const every = Number(process.env.DASHBOARD_BACKUP_TICK_MS ?? 60_000); if (!Number.isFinite(every) || every <= 0) return; let attemptedDay = null; const t = setInterval(() => { const cfg = backupConfig(); if (!cfg.dir || running) return; const now = new Date(); let files = []; try { files = listBackups(cfg.dir); } catch { /* folder gone: runBackup reports it */ } if (!dueNow(now, cfg, files, attemptedDay)) return; attemptedDay = stamp(now).slice(0, 8); runBackup().then( (r) => console.log(`[backup] ${r.file} (${(r.size / 1048576).toFixed(0)} MB, ${r.ms} ms)`), (err) => console.warn(`[backup] failed: ${err.message}`) ); }, every); if (t.unref) t.unref(); } if (!isMainThread && workerData?.agenthubBackupWorker) { // Same driver order as server/db.js: better-sqlite3, else node:sqlite. let conn; try { try { const Database = require("better-sqlite3"); conn = new Database(workerData.dbPath, { readonly: true }); } catch { const Database = require("../compat-sqlite"); conn = new Database(workerData.dbPath, { readonly: true }); } conn.pragma("busy_timeout = 5000"); conn.prepare("VACUUM INTO ?").run(workerData.out); parentPort.postMessage({ ok: true }); } catch (err) { parentPort.postMessage({ error: { message: err.message, code: err.code || "EBACKUP" } }); } finally { conn?.close(); } } module.exports = { KEYS, backupConfig, validateBackupDir, listBackups, rotate, dueNow, runBackup, isRunning, lastResult, startBackupScheduler, };