b7f3735b29
- 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.
222 lines
7.4 KiB
JavaScript
222 lines
7.4 KiB
JavaScript
/**
|
||
* @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,
|
||
};
|