Files
AgentHub/server/lib/db-backup.js
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

222 lines
7.4 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 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 `<name>.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ĩ <vinnt@smartgift.vn>
*/
"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,
};