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.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+221
View File
@@ -0,0 +1,221 @@
/**
* @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,
};