Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest

This commit is contained in:
2026-10-16 10:27:26 +07:00
79 changed files with 5395 additions and 1796 deletions
+3 -2
View File
@@ -439,7 +439,7 @@ All endpoints return JSON unless noted. Error responses use:
| `GET` | `/api/openapi.json` | Raw OpenAPI 3.0.3 spec |
| `GET` | `/api/docs` | Interactive **Swagger UI** (try-it-out request execution) |
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (via the `redoc` dependency, never a CDN — works offline) |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
@@ -1600,10 +1600,11 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version); 0 = off
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version), only while one runs (checked every 30 s); 0 = off
# Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
DASHBOARD_SYNC_LIVE_HOOK_MS=300000 # project sync leaves a transcript alone while hooks fed its session within this window; 0 = never defer
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
+7
View File
@@ -213,6 +213,13 @@ describe("OpenAPI / Swagger", () => {
const res = await fetch("/api/redoc/redoc.standalone.js");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /javascript/);
// The vendored file (server/vendor/redoc/), licence banner intact — the
// `redoc` npm package is no longer a dependency.
assert.match(
res.body,
/For license information please see redoc\.standalone\.js\.LICENSE\.txt/
);
assert.doesNotThrow(() => require("fs").statSync(require("../lib/redoc").redocBundlePath()));
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file Tests server/lib/backoff-poll.js — the poll that runs at its base pace
* while passes find work (or fail) and slows to its idle pace after a pass
* that resolved `false`, returning to the base pace once work shows up again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { startBackoffPoll, idleDefault } = require("../lib/backoff-poll");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
describe("startBackoffPoll", () => {
it("slows down after idle passes and speeds up again on work", async () => {
const answers = [true, false, false, true, "x"];
const at = [];
const t0 = Date.now();
const poll = startBackoffPoll(
() => {
at.push(Date.now() - t0);
const a = answers.shift();
if (a === "x") throw new Error("boom");
return a;
},
{ baseMs: 10, idleMs: 80 }
);
await wait(260);
poll.stop();
assert.ok(at.length >= 5, `ran ${at.length} times`);
const gaps = at.slice(1).map((t, i) => t - at[i]);
assert.ok(gaps[0] < 60, "work → base pace");
assert.ok(gaps[1] >= 75, "idle → idle pace");
assert.ok(gaps[2] >= 75, "still idle");
assert.ok(gaps[3] < 60, "work again → base pace");
});
it("idles at a minute by default, never faster than the base pace", () => {
assert.equal(idleDefault(15_000), 60_000);
assert.equal(idleDefault(120_000), 120_000);
});
});
+78 -1
View File
@@ -179,7 +179,9 @@ describe("ingestCodexUsage", () => {
});
it("settles an active session to completed once its journals go quiet", async () => {
const now = Date.now();
// A whole second: utimes rounds through a float, and a millisecond `now`
// came back from stat 1 ms early about half the time.
const now = Math.floor(Date.now() / 1000) * 1000;
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
@@ -536,3 +538,78 @@ describe("service tier", () => {
]);
});
});
describe("ingestCodexUsage — recent pass", () => {
const NOW = Date.parse("2026-09-29T03:00:00Z");
function writeAt(day, id, lines, mtime) {
const dir = path.join(process.env.CODEX_HOME, "sessions", ...day.split("/"));
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `rollout-${day.replace(/\//g, "-")}T01-00-00-${id}.jsonl`);
fs.writeFileSync(file, jsonl(lines));
fs.utimesSync(file, mtime, mtime);
return file;
}
it("stats only the newest day folders and the journals still growing", async () => {
const quiet = writeAt(
"2026/09/01",
"old1",
[meta("old1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 20 * 86_400_000)
);
const live = writeAt(
"2026/09/02",
"old2",
[meta("old2"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 60_000)
);
writeAt("2026/09/27", "a", [meta("a"), ctx("gpt-a")], new Date(NOW - 86_400_000));
writeAt("2026/09/28", "b", [meta("b"), ctx("gpt-a")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW }); // the full first pass
// Both old journals grow; only the one written within the hour is looked at.
fs.appendFileSync(quiet, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.appendFileSync(live, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.utimesSync(quiet, new Date(NOW - 1000), new Date(NOW - 1000));
fs.utimesSync(live, new Date(NOW - 1000), new Date(NOW - 1000));
// A new journal in today's folder is found too.
writeAt(
"2026/09/29",
"new1",
[meta("new1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("old2"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }], "left to a full pass");
assert.deepEqual(usage("new1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
await ingestCodexUsage({ now: NOW }); // the next full pass catches it up
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
});
it("finds a fork's parent in an older folder on a recent pass", async () => {
const parent = [
meta("p9"),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
tc([300, 0, 30], [200, 0, 20]),
];
writeAt("2026/08/01", "p9", parent, new Date(NOW - 50 * 86_400_000));
writeAt("2026/09/28", "x", [meta("x")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW });
writeAt(
"2026/09/29",
"f9",
[
meta("f9", { forked_from_id: "p9" }),
meta("p9"),
...parent.slice(1),
tc([350, 0, 35], [50, 0, 5]),
],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("f9"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @file Tests server/lib/db-retention.js and its Settings routes — a new
* database is INCREMENTAL auto_vacuum, retention is off by default and purges
* nothing then, when on it removes only finished sessions older than the
* cutoff (with their events, agents and token rows) in bounded batches, the
* free-page reclaim shrinks the freelist, GET/PUT /api/settings/retention
* validate and persist the setting, and POST /api/settings/compact needs
* confirm and runs a VACUUM in a worker. The database and settings file are
* temp paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-retention-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
delete process.env.AGENTHUB_RETENTION_DAYS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const retention = require("../lib/db-retention");
let server;
let BASE;
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
delete process.env.AGENTHUB_RETENTION_DAYS;
fs.rmSync(ROOT, { recursive: true, force: true });
});
async function call(method, urlPath, body) {
const res = await fetch(BASE + urlPath, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
}
const daysAgo = (n) => new Date(Date.now() - n * 86400 * 1000).toISOString();
function seed(id, status, startedAt, events = 3) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, ?, '/tmp', ?, ?)"
).run(id, id, status, startedAt, startedAt);
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES (?, 'PostToolUse', 's', ?, ?)"
);
for (let i = 0; i < events; i++)
ins.run(id, JSON.stringify({ pad: "x".repeat(2000) }), startedAt);
}
const exists = (id) => !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id);
const eventCount = (id) =>
db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n;
describe("auto_vacuum", () => {
it("a new database is INCREMENTAL", () => {
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
assert.equal(retention.spaceInfo(db).auto_vacuum, "incremental");
});
});
describe("retentionConfig", () => {
it("is off by default and for anything out of range", () => {
assert.equal(retention.retentionConfig({}).days, 0);
for (const v of ["", "0", "-3", "1.5", "abc", "99999"]) {
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: v }).days, 0, v);
}
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: "30" }).days, 30);
});
});
describe("scheduled maintenance", () => {
it("purges nothing while retention is off", async () => {
seed("ret-off-old", "completed", daysAgo(400));
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention, undefined);
assert.ok(exists("ret-off-old"));
db.prepare("DELETE FROM sessions WHERE id = 'ret-off-old'").run();
});
it("purges only finished sessions older than the cutoff, with their rows", async () => {
seed("ret-old-done", "completed", daysAgo(100));
seed("ret-old-err", "error", daysAgo(100));
seed("ret-old-active", "active", daysAgo(100));
seed("ret-new-done", "completed", daysAgo(5));
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES ('ret-old-done', 'm', 1)"
).run();
process.env.AGENTHUB_RETENTION_DAYS = "30";
try {
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention.purged_sessions, 2);
assert.equal(out.retention.purged_events, 6);
} finally {
delete process.env.AGENTHUB_RETENTION_DAYS;
}
assert.ok(!exists("ret-old-done"));
assert.ok(!exists("ret-old-err"));
assert.equal(eventCount("ret-old-done"), 0);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = 'ret-old-done'").get().n,
0
);
assert.ok(exists("ret-old-active"), "an active session is never purged");
assert.ok(exists("ret-new-done"), "a recent session is kept");
assert.equal(eventCount("ret-new-done"), 3);
});
it("works in bounded batches and stops at the per-run cap", async () => {
for (let i = 0; i < 7; i++) seed(`ret-batch-${i}`, "abandoned", daysAgo(50), 1);
const first = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(first.purged_sessions, 5);
const second = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(second.purged_sessions, 2);
const third = await retention.applyRetention({ db, days: 10 });
assert.equal(third.purged_sessions, 0);
});
it("returns free pages to the OS", async () => {
for (let i = 0; i < 40; i++) seed(`ret-big-${i}`, "completed", daysAgo(60), 20);
await retention.applyRetention({ db, days: 30 });
const before = db.pragma("freelist_count", { simple: true });
assert.ok(before > 50, `expected free pages, got ${before}`);
const out = await retention.reclaimFreePages({ db, minPages: 10, slice: 16 });
assert.ok(out.reclaimed_pages > 0);
assert.ok(db.pragma("freelist_count", { simple: true }) < before);
});
});
describe("routes", () => {
it("GET reports retention off by default", async () => {
const r = await call("GET", "/api/settings/retention");
assert.equal(r.status, 200);
assert.equal(r.body.days, 0);
assert.equal(r.body.compacting, false);
assert.equal(r.body.space.auto_vacuum, "incremental");
});
it("PUT validates and persists, 0 turns it off", async () => {
for (const bad of [-1, 1.5, "7", 99999, null]) {
const r = await call("PUT", "/api/settings/retention", { days: bad });
assert.equal(r.status, 400, String(bad));
}
let r = await call("PUT", "/api/settings/retention", { days: 90 });
assert.equal(r.status, 200);
assert.equal(r.body.days, 90);
assert.match(fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), /RETENTION_DAYS=90/);
r = await call("PUT", "/api/settings/retention", { days: 0 });
assert.equal(r.body.days, 0);
});
it("POST /compact needs confirm and then vacuums", async () => {
let r = await call("POST", "/api/settings/compact", {});
assert.equal(r.status, 400);
r = await call("POST", "/api/settings/compact", { confirm: true });
assert.equal(r.status, 202);
for (let i = 0; i < 200 && retention.isCompacting(); i++) {
await new Promise((res) => setTimeout(res, 50));
}
const last = retention.lastCompactResult();
assert.equal(last?.ok, true, last?.error);
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
});
});
+95 -5
View File
@@ -91,6 +91,69 @@ describe("node version gate", () => {
});
});
describe("last failure report (SessionStart systemMessage)", () => {
beforeEach(resetRuntime);
it("reports a recorded failure once, naming the log and /agenthub-doctor", () => {
boot.recordFailure(RT, "install-failed", "npm broke", path.join(RT, "npm-install.log"));
const first = boot.takeFailureReport(RT);
assert.match(first.systemMessage, /npm broke/);
assert.match(first.systemMessage, /npm-install\.log/);
assert.match(first.systemMessage, /\/agenthub-doctor/);
assert.equal(boot.takeFailureReport(RT), null);
});
it("does not re-report the same failure recorded again by a later retry", () => {
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
boot.takeFailureReport(RT);
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
assert.equal(boot.takeFailureReport(RT), null);
});
it("reports a different failure, and nothing once cleared", () => {
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
boot.takeFailureReport(RT);
boot.recordFailure(RT, "ui-build-failed", "vite broke", "y.log");
assert.match(boot.takeFailureReport(RT).systemMessage, /vite broke/);
boot.recordFailure(RT, "ui-build-failed", "vite broke", "y.log");
boot.clearFailure(RT);
assert.equal(boot.readFailure(RT), null);
assert.equal(boot.takeFailureReport(RT), null);
});
it("prints nothing with no failure on record", () => {
assert.equal(boot.takeFailureReport(RT), null);
});
});
describe("depsHash — only what npm installs", () => {
function root(pkg, lock) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-deps-"));
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
fs.writeFileSync(path.join(dir, "package-lock.json"), JSON.stringify(lock));
return dir;
}
const pkg = (version, deps) => ({ name: "agenthub", version, dependencies: deps });
const lock = (version, deps) => ({
name: "agenthub",
version,
packages: { "": { name: "agenthub", version, dependencies: deps }, "node_modules/a": {} },
});
it("ignores a release's version bump", () => {
// Every update reinstalled all runtime dependencies when only the version moved.
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.1", { a: "^1" }), lock("1.0.1", { a: "^1" }));
assert.equal(boot.depsHash(a), boot.depsHash(b));
});
it("changes when a dependency does", () => {
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.0", { a: "^2" }), lock("1.0.0", { a: "^2" }));
assert.notEqual(boot.depsHash(a), boot.depsHash(b));
});
});
describe("fast path", () => {
beforeEach(resetRuntime);
@@ -527,7 +590,6 @@ describe("buildClient dependency install", () => {
assert.deepEqual(calls, [
"ci --no-audit --no-fund --include=dev --ignore-scripts",
"install --no-audit --no-fund --include=dev --ignore-scripts",
"node tsc -b",
"node vite.js build",
]);
});
@@ -567,8 +629,9 @@ describe("buildClient dependency install", () => {
it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => {
// On Windows a script or a .bin shim runs through cmd.exe and needs `node`
// on PATH; the server's PATH lacked it, so esbuild's postinstall failed and
// every update kept the previous UI. The build here must equal the
// client's own "build" script, run without npm.
// every update kept the previous UI. The build here is the vite half of
// the client's own "build" script, run without npm; the type check is the
// release's job, not every user's.
assert.equal(
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
.scripts.build,
@@ -577,8 +640,35 @@ describe("buildClient dependency install", () => {
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run")));
assert.deepEqual(calls.slice(-1), ["node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run") && !c.includes("tsc")));
});
it("keeps the installed tree while the client's manifests are unchanged", () => {
resetRuntime();
const root = fakeRoot();
const first = [];
buildClient({ rt: RT, root, exec: fakeExec(first) });
assert.ok(first[0].startsWith("ci"));
const again = [];
assert.equal(buildClient({ rt: RT, root, force: true, exec: fakeExec(again) }), "built");
assert.deepEqual(again, ["node vite.js build"]);
fs.writeFileSync(path.join(root, "client", "package.json"), '{"dependencies":{"x":"1"}}');
const changed = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(changed) });
assert.ok(changed[0].startsWith("ci"), "a changed manifest installs from clean");
});
it("installs from clean again after a build on the kept tree failed", () => {
resetRuntime();
const root = fakeRoot();
buildClient({ rt: RT, root, exec: fakeExec([]) });
const failing = (cmd, args, cwd) => (cmd === "node" ? false : fakeExec([])(cmd, args, cwd));
assert.equal(buildClient({ rt: RT, root, force: true, exec: failing }), "failed");
const next = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(next) });
assert.ok(next[0].startsWith("ci"));
});
});
+2
View File
@@ -50,6 +50,8 @@ function trackedFiles() {
// Generated from server/openapi-extra/* (which IS linted): the drift test
// below covers it, so linting both would only double-report one fix.
.filter((f) => f !== "openapi.yaml" && !f.endsWith("package-lock.json"))
// Third-party files copied unchanged (server/vendor/redoc): their links are upstream's.
.filter((f) => !f.includes("/vendor/"))
);
}
+82
View File
@@ -150,3 +150,85 @@ describe("syncDefaultProjects", () => {
);
});
});
describe("syncDefaultProjects — sessions hooks are feeding", () => {
const SNAP_DIR = path.join(process.env.DASHBOARD_DATA_DIR, "transcripts");
const more = (sessionId, cwd, ts) => ({
type: "assistant",
cwd,
sessionId,
timestamp: ts,
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "later" }],
usage: { input_tokens: 3, output_tokens: 2 },
},
});
const bumpMtime = (file) => {
const future = Date.now() / 1000 + 120;
fs.utimesSync(file, future, future);
};
const ageSession = (id) =>
dbModule.db
.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?")
.run(new Date(Date.now() - 10 * 60_000).toISOString(), id);
it("defers a live hook-fed imported session, then catches it up once quiet", async () => {
const ID = "33333333-cccc-4ccc-8ccc-333333333333";
const cache = new Map();
const file = writeSession("-live", ID, fixtureLines(ID, "/live"));
await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.ok(dbModule.stmts.getSession.get(ID), "imported first");
// A hook arrives: its event is the newest, and it touches the session.
dbModule.stmts.insertEvent.run(ID, `${ID}-main`, "PreToolUse", "Bash", "hook", "{}");
dbModule.stmts.touchSession.run(ID);
writeSession(
"-live",
ID,
fixtureLines(ID, "/live", [more(ID, "/live", "2026-04-18T12:09:00.000Z")])
);
bumpMtime(file);
const live = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(
live.changed.find((c) => c.sessionId === ID),
undefined,
"deferred while live"
);
assert.equal(cache.get(file), -1, "kept in the cache so the watcher ignores its appends");
ageSession(ID); // the hooks went quiet
const quiet = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.ok(
quiet.changed.find((c) => c.sessionId === ID && !c.isNew),
"caught up once quiet"
);
assert.notEqual(cache.get(file), -1);
});
it("never re-parses a hook-created session, only refreshes its snapshot once quiet", async () => {
const ID = "44444444-dddd-4ddd-8ddd-444444444444";
dbModule.stmts.insertSession.run(ID, "hooked", "active", "/hooked", null, null);
dbModule.stmts.insertEvent.run(ID, null, "SessionStart", null, "hook", "{}");
const file = writeSession("-hooked", ID, fixtureLines(ID, "/hooked"));
bumpMtime(file);
const snap = path.join(SNAP_DIR, `${ID}.jsonl`);
const cache = new Map();
await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(fs.existsSync(snap), false, "no snapshot copy while hooks are live");
ageSession(ID);
const before = dbModule.db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?");
const n0 = before.get(ID).n;
const r = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(
r.changed.find((c) => c.sessionId === ID),
undefined,
"nothing imported"
);
assert.equal(before.get(ID).n, n0, "no events written for a hook-created session");
assert.equal(fs.existsSync(snap), true, "snapshot taken once quiet");
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* @file Tests for the hook writer's per-transcript "already handled" marks
* (`transcriptMarks` in server/routes/hooks.js): a hook whose transcript is
* unchanged skips the token / compaction / API-error / turn-duration writes,
* a grown transcript is walked only from the last entry handled, and the rows
* left behind are exactly the ones a full re-walk would have written.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-marks-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const hooksRouter = require("../routes/hooks");
let server;
let BASE;
function post(body) {
return new Promise((resolve, reject) => {
const url = new URL("/api/hooks/event", BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
const line = (o) => JSON.stringify(o) + "\n";
const assistant = (id, inTok) => ({
type: "assistant",
timestamp: "2026-09-18T01:00:00.000Z",
message: {
id,
model: "claude-sonnet-5",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: inTok, output_tokens: 1 },
},
});
const turn = (ms, ts) => ({
type: "system",
subtype: "turn_duration",
durationMs: ms,
timestamp: ts,
});
const apiError = (text, ts) => ({
type: "assistant",
isApiErrorMessage: true,
error: "rate_limit",
timestamp: ts,
message: { content: [{ type: "text", text }] },
});
const compact = (uuid, ts) => ({ type: "user", isCompactSummary: true, uuid, timestamp: ts });
function rows(sid) {
const ev = db
.prepare(
"SELECT event_type, summary, created_at FROM events WHERE session_id = ? AND event_type IN ('TurnDuration','APIError','Compaction') ORDER BY event_type, created_at, summary"
)
.all(sid)
.map((r) =>
r.event_type === "APIError"
? `${r.event_type}|${r.summary}`
: `${r.event_type}|${r.created_at}|${r.summary}`
);
const tokens = db
.prepare("SELECT model, input_tokens, baseline_input FROM token_usage WHERE session_id = ?")
.all(sid);
return { ev, tokens };
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("transcript marks", () => {
it("skips an unchanged transcript and walks only the new tail of a grown one", async () => {
const sid = "abababab-1111-4111-8111-abababababab";
const cwd = path.join(TMP, "work");
fs.mkdirSync(cwd, { recursive: true });
const tpath = path.join(TMP, `${sid}.jsonl`);
fs.writeFileSync(
tpath,
line(assistant("m1", 5)) +
line(turn(1000, "2026-09-18T01:00:01.000Z")) +
line(apiError("slow down", "2026-09-18T01:00:02.000Z")) +
line(compact("c-1", "2026-09-18T01:00:03.000Z")) +
line(assistant("m2", 7)) +
line(turn(2000, "2026-09-18T01:00:04.000Z"))
);
const data = { session_id: sid, cwd, transcript_path: tpath };
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
const first = rows(sid);
assert.equal(first.ev.filter((e) => e.startsWith("TurnDuration")).length, 2);
assert.equal(first.ev.filter((e) => e.startsWith("APIError")).length, 1);
assert.equal(first.ev.filter((e) => e.startsWith("Compaction")).length, 1);
assert.ok(hooksRouter.transcriptMarks.size >= 1, "a mark is kept after commit");
// Proof of the skip: a row removed behind the writer's back is NOT put
// back by a hook whose transcript did not change (before, every hook
// re-walked the whole history and re-inserted it).
db.prepare(
"DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration' AND created_at = '2026-09-18T01:00:01.000Z'"
).run(sid);
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
assert.equal(rows(sid).ev.filter((e) => e.startsWith("TurnDuration")).length, 1);
db.prepare("DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration'").run(sid);
hooksRouter.transcriptMarks.clear();
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
assert.deepEqual(rows(sid), first, "a cleared mark re-walks the whole history, as before");
// Grow it: one turn, one new error, a repeat of the old error, a compaction.
fs.appendFileSync(
tpath,
line(assistant("m3", 11)) +
line(turn(3000, "2026-09-18T01:00:05.000Z")) +
line(apiError("slow down", "2026-09-18T01:00:06.000Z")) +
line(apiError("overloaded", "2026-09-18T01:00:07.000Z")) +
line(compact("c-2", "2026-09-18T01:00:08.000Z"))
);
assert.equal(await post({ hook_type: "Stop", data }), 200);
const grown = rows(sid);
// The same transcript walked from scratch must produce the same rows.
hooksRouter.transcriptMarks.clear();
assert.equal(await post({ hook_type: "Stop", data: { ...data, stop_hook_active: true } }), 200);
assert.deepEqual(rows(sid), grown, "the delta wrote exactly what a full walk writes");
assert.equal(grown.ev.filter((e) => e.startsWith("TurnDuration")).length, 3);
assert.equal(grown.ev.filter((e) => e.startsWith("APIError")).length, 2, "repeat deduped");
assert.equal(grown.ev.filter((e) => e.startsWith("Compaction")).length, 2);
assert.equal(grown.tokens.length, 1);
assert.equal(grown.tokens[0].input_tokens + grown.tokens[0].baseline_input, 23);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Tests server/update-scheduler.js — the default interval is an hour
* (the env override and its 60 s floor still apply), a scheduled tick with no
* browser connected runs no check, and a browser connecting after that gets
* one catch-up check (only when the last check is stale). The check, the
* connection count and the connection event are injected; nothing touches git
* or the network.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
delete process.env.DASHBOARD_UPDATE_CHECK;
const { startUpdateScheduler, intervalMs } = require("../update-scheduler");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let handle = null;
afterEach(() => {
handle?.stop();
handle = null;
delete process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS;
});
function harness(clients) {
const h = { checks: 0, clients, listeners: [] };
handle = startUpdateScheduler({
broadcast: () => {},
getConnectionCount: () => h.clients,
onConnection: (fn) => {
h.listeners.push(fn);
return () => {};
},
getStatus: async () => {
h.checks++;
return { update_available: false };
},
initialDelayMs: 10,
});
h.connect = () => h.listeners.forEach((fn) => fn());
return h;
}
describe("intervalMs", () => {
it("defaults to one hour and keeps the override with its floor", () => {
delete process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS;
assert.equal(intervalMs(), 60 * 60 * 1000);
process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS = "120000";
assert.equal(intervalMs(), 120000);
process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS = "1000";
assert.equal(intervalMs(), 60 * 60 * 1000);
});
});
describe("startUpdateScheduler", () => {
it("checks on the first tick when a browser is connected", async () => {
const h = harness(1);
await wait(40);
assert.equal(h.checks, 1);
});
it("skips the tick with no browser, then checks once when one connects", async () => {
const h = harness(0);
await wait(40);
assert.equal(h.checks, 0);
h.clients = 1;
h.connect();
h.connect(); // a second tab in the same window: still one check
await wait(3_300);
assert.equal(h.checks, 1);
h.connect(); // fresh check: no catch-up
await wait(3_300);
assert.equal(h.checks, 1);
});
});
+20
View File
@@ -465,3 +465,23 @@ describe("listBranches", () => {
assert.deepEqual(branches, []);
});
});
describe("git missing from PATH", () => {
it("answers EGITMISSING with the fix, not a bare ENOENT, when the folder exists", async () => {
const saved = process.env.PATH;
process.env.PATH = path.join(ROOT, "no-such-bin");
try {
await assert.rejects(
() => wt.git(ROOT, ["status"]),
(err) => err.code === "EGITMISSING" && /install/i.test(err.message)
);
// A missing FOLDER is the other ENOENT, and stays one.
await assert.rejects(
() => wt.git(path.join(ROOT, "gone"), ["status"]),
(err) => err.code === "ENOENT"
);
} finally {
process.env.PATH = saved;
}
});
});
+35 -1
View File
@@ -141,6 +141,14 @@ migrateLegacyDatabase(DB_PATH);
const db = new Database(DB_PATH);
// Freed pages go back to the OS only with auto_vacuum. On a NEW database this
// takes effect at once — it must come before `journal_mode = WAL`, which
// writes the header; an existing one is converted further down, by a VACUUM,
// only while it is small (`convertAutoVacuum`), else by Settings → Data →
// Compact. `maintainDatabase` then runs a bounded `incremental_vacuum`.
if (db.pragma("auto_vacuum", { simple: true }) === 0) {
db.pragma("auto_vacuum = INCREMENTAL");
}
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000");
@@ -1866,6 +1874,28 @@ for (const col of ["display_name", "email", "avatar_at"]) {
}
}
// An existing database made before auto_vacuum: the setting is stored only by
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
// at startup and under the migration lock, only for a file up to
// AUTO_VACUUM_CONVERT_MAX_BYTES (well under a second); a bigger one is
// converted by the manual Compact (server/lib/db-retention.js). Best effort: a
// second process holding a read open makes it fail, and the next start retries.
const AUTO_VACUUM_CONVERT_MAX_BYTES = 32 * 1024 * 1024;
function convertAutoVacuum() {
try {
if (db.pragma("auto_vacuum", { simple: true }) !== 0) return false;
const bytes =
db.pragma("page_count", { simple: true }) * db.pragma("page_size", { simple: true });
if (bytes > AUTO_VACUUM_CONVERT_MAX_BYTES) return false;
db.pragma("auto_vacuum = INCREMENTAL");
db.exec("VACUUM");
return db.pragma("auto_vacuum", { simple: true }) === 2;
} catch {
return false;
}
}
convertAutoVacuum();
migrationLock.exec("COMMIT");
migrationLock.close();
@@ -2535,7 +2565,9 @@ const stmts = {
* on a reader — the other dashboard's), after which the next write truncates
* the WAL to `journal_size_limit`; and `PRAGMA optimize`, which refreshes the
* planner's statistics only where they went stale. Never VACUUM: it rewrites
* the whole file under an exclusive lock.
* the whole file under an exclusive lock. The opt-in retention and the
* bounded `incremental_vacuum` run after this, from
* `server/lib/db-retention.js`'s `runScheduledMaintenance`.
*/
function maintainDatabase() {
const out = {};
@@ -2561,4 +2593,6 @@ module.exports = {
applyIntroPricing,
topUpDefaultPricing,
maintainDatabase,
convertAutoVacuum,
AUTO_VACUUM_CONVERT_MAX_BYTES,
};
+59 -13
View File
@@ -33,6 +33,20 @@ if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
// server/lib/settings-env.js. Only when this
// file IS the process: the test suite requires it dozens of times, and a
// developer's real settings file pins DASHBOARD_DATA_DIR at their live database.
/** What to tell the user when the dashboard cannot bind its port. The health
* probe above already turned away a port held by a running dashboard, so a
* port in use here belongs to some other program. */
function listenFailure(err, port) {
if (err?.code === "EADDRINUSE")
return (
`Port ${port} is already in use by another program, so AgentHub cannot start there. ` +
`Stop that program, or run AgentHub on another port with DASHBOARD_PORT (for example DASHBOARD_PORT=4821).`
);
if (err?.code === "EACCES")
return `Not allowed to listen on port ${port}. Pick a port above 1024 with DASHBOARD_PORT.`;
return `AgentHub could not start its server: ${err?.stack || err}`;
}
if (require.main === module) {
const settingsEnv = require("./lib/settings-env");
settingsEnv.loadEnvFiles({
@@ -337,8 +351,12 @@ function startServer(app, port) {
const host = resolveHost();
const boundLoopback = isLoopbackHostname(host);
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
// A port another program holds fails here, not as an uncaught 'error'
// event: the caller says what happened instead of a bare stack trace.
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
// Publish the live port so the Claude Code hook handler can find this
// server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken).
@@ -482,11 +500,17 @@ function startBackgroundServices() {
.catch(() => {});
// Hourly DB upkeep: a non-blocking checkpoint (the WAL then shrinks to
// journal_size_limit) and PRAGMA optimize. Off with DASHBOARD_DB_MAINTAIN_MS=0.
// journal_size_limit), PRAGMA optimize, the opt-in retention
// (AGENTHUB_RETENTION_DAYS, off by default) and a bounded incremental_vacuum
// (server/lib/db-retention.js). Off with DASHBOARD_DB_MAINTAIN_MS=0.
{
const every = Number(process.env.DASHBOARD_DB_MAINTAIN_MS ?? 60 * 60 * 1000);
if (every > 0) {
const t = setInterval(() => require("./db").maintainDatabase(), every);
const t = setInterval(() => {
require("./lib/db-retention")
.runScheduledMaintenance()
.catch(() => {});
}, every);
if (t.unref) t.unref();
}
}
@@ -807,18 +831,35 @@ function startBackgroundServices() {
}
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
// changed — and only while such a dashboard runs at all: alone on its data
// dir, this server only looks for one every LANE_PEER_CHECK_MS (the first
// call primes what it has seen, so a peer that starts later is compared
// against it). Off with DASHBOARD_LANE_SYNC_MS=0.
const LANE_SYNC_MS = Number(process.env.DASHBOARD_LANE_SYNC_MS ?? 2_000);
if (Number.isFinite(LANE_SYNC_MS) && LANE_SYNC_MS > 0) {
const { broadcastForeignLaneChanges } = require("./routes/lanes");
const laneSyncTimer = setInterval(() => {
const LANE_PEER_CHECK_MS = Math.max(LANE_SYNC_MS, 30_000);
let peersSeenAt = 0;
let hasPeers = false;
const syncLanes = () => {
try {
broadcastForeignLaneChanges();
} catch (err) {
console.warn("lane sync failed:", err.message);
}
}, LANE_SYNC_MS);
if (laneSyncTimer.unref) laneSyncTimer.unref();
};
syncLanes();
const laneSyncTick = () => {
if (Date.now() - peersSeenAt >= LANE_PEER_CHECK_MS) {
hasPeers = peersSharingDataDir().length > 0;
peersSeenAt = Date.now();
}
if (hasPeers) syncLanes();
const t = setTimeout(laneSyncTick, hasPeers ? LANE_SYNC_MS : LANE_PEER_CHECK_MS);
if (t.unref) t.unref();
};
const first = setTimeout(laneSyncTick, LANE_SYNC_MS);
if (first.unref) first.unref();
}
// Continuous discovery of sessions under ~/.claude/projects. The one-time
// legacy backfill above runs only once (marker-gated), so a project added
@@ -1210,12 +1251,17 @@ if (require.main === module) {
return;
}
const app = createApp();
startServer(app, PORT).then((server) => {
httpServer = server;
startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch();
});
startServer(app, PORT)
.then((server) => {
httpServer = server;
startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch();
})
.catch((err) => {
console.error(listenFailure(err, PORT));
process.exit(1);
});
});
// Graceful shutdown — close connections and DB cleanly
+18 -10
View File
@@ -425,6 +425,9 @@ function writeSession(f, conv, now) {
}
let running = null;
// Fresh sessions the last pass left for a later one to settle (the poll keeps
// its base pace while there are any).
let lastPending = 0;
/**
* One pass over every conversation file: a changed one is re-read, an
@@ -440,12 +443,16 @@ function ingestAntigravityUsage({
running = (async () => {
let read = 0;
let written = 0;
let pending = 0; // still-fresh sessions a later pass may have to settle
for (const f of conversationFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
const a = activity(f.id, f.mtime);
if (fresh(a, now)) continue;
if (fresh(a, now)) {
pending += 1;
continue;
}
const s = stmts.getSession.get(f.id);
if (s && s.tool === "antigravity" && s.status === "active") {
const at = new Date(a.last).toISOString();
@@ -486,6 +493,7 @@ function ingestAntigravityUsage({
}
await new Promise((r) => setImmediate(r));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
@@ -504,16 +512,16 @@ function startAntigravityUsagePoll(broadcast, broadcastLane) {
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
// Files an older build could not read get another try.
db.prepare("DELETE FROM antigravity_conversations WHERE session_id = ''").run();
const tick = () => {
ingestAntigravityUsage({ broadcast, broadcastLane })
.then((r) => {
// Slowed to once a minute after a pass that read nothing, wrote nothing and
// left no fresh session to settle; back to POLL_MS once one finds work.
require("./backoff-poll").startBackoffPoll(
() =>
ingestAntigravityUsage({ broadcast, broadcastLane }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
})
.catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
+43
View File
@@ -0,0 +1,43 @@
/**
* @file A background poll that slows down while it finds nothing to do: the
* next pass runs `baseMs` after a pass that did work (or failed), `idleMs`
* after one whose `tick` resolved `false` (nothing changed, nothing pending).
* One pass at a time — the next is scheduled only once the last settled — and
* every timer is `unref`'d. Used by the Cursor and Antigravity usage polls.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Default slow interval: 60 s, never faster than the base one. */
const idleDefault = (baseMs) => Math.max(baseMs, 60_000);
/**
* Run `tick` now, then again and again. `tick` resolves `false` for an idle
* pass; anything else (or a throw) keeps the base pace.
* @returns {{stop: () => void}}
*/
function startBackoffPoll(tick, { baseMs, idleMs = idleDefault(baseMs) }) {
let timer = null;
let stopped = false;
const schedule = (ms) => {
if (stopped) return;
timer = setTimeout(run, ms);
if (timer.unref) timer.unref();
};
function run() {
Promise.resolve()
.then(tick)
.then(
(busy) => schedule(busy === false ? idleMs : baseMs),
() => schedule(baseMs)
);
}
run();
return {
stop() {
stopped = true;
clearTimeout(timer);
},
};
}
module.exports = { startBackoffPoll, idleDefault };
+97 -4
View File
@@ -235,7 +235,9 @@ function threadNames(home = getCodexHome()) {
const names = new Map();
let text = "";
try {
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", { max: 16 << 20 });
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", {
max: 16 << 20,
});
} catch {
return names;
}
@@ -251,6 +253,17 @@ function threadNames(home = getCodexHome()) {
}
const getRollout = db.prepare("SELECT * FROM codex_rollouts WHERE path = ?");
// What a pass needs to skip an unchanged journal, for every journal at once:
// one query per pass instead of one (with its whole parse state) per file.
const rolloutStamps = db.prepare(
`SELECT path, size, mtime_ms, instr(state, '"more":true') > 0 AS more FROM codex_rollouts`
);
// Journals that may still be growing outside the newest day folders: written
// within RECENT_ROW_MS, or not yet read to the end.
const liveRolloutPaths = db.prepare(
`SELECT path FROM codex_rollouts WHERE mtime_ms > ? OR instr(state, '"more":true') > 0`
);
const threadPath = db.prepare("SELECT path FROM codex_rollouts WHERE thread_id = ? LIMIT 1");
const upsertRollout = db.prepare(
`INSERT INTO codex_rollouts (path, session_id, thread_id, size, mtime_ms, offset, state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -400,6 +413,59 @@ function writeSession(sessionId, names, now, origin = LOCAL) {
const running = new Map(); // home ("" = CODEX_HOME) → the pass in flight
// A RECENT pass (the poll's, between full ones) stats only the newest
// RECENT_DAY_DIRS day folders plus the journals the table says may still grow
// (RECENT_ROW_MS) — not every rollout Codex ever wrote. A journal in an older
// folder that wakes up again (a resumed thread quiet for over RECENT_ROW_MS)
// is picked up by the next full pass.
const RECENT_DAY_DIRS = 2;
const RECENT_ROW_MS = 60 * 60_000;
function numericDirs(dir) {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d+$/.test(e.name))
.map((e) => e.name)
.sort((a, b) => Number(b) - Number(a));
} catch {
return [];
}
}
function recentRolloutFiles(home, now) {
const root = path.join(home || getCodexHome(), "sessions");
const days = [];
outer: for (const y of numericDirs(root))
for (const m of numericDirs(path.join(root, y)))
for (const d of numericDirs(path.join(root, y, m))) {
days.push(path.join(root, y, m, d));
if (days.length >= RECENT_DAY_DIRS) break outer;
}
const paths = new Set();
for (const dir of days) {
try {
for (const e of fs.readdirSync(dir, { withFileTypes: true }))
if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) paths.add(path.join(dir, e.name));
} catch {
/* vanished mid-scan */
}
}
const prefix = root + path.sep;
for (const { path: p } of liveRolloutPaths.all(now - RECENT_ROW_MS))
if (p.startsWith(prefix)) paths.add(p);
const out = [];
for (const p of paths) {
try {
const stat = fs.statSync(p);
if (stat.isFile()) out.push({ path: p, mtime: stat.mtimeMs, size: stat.size });
} catch {
/* gone */
}
}
return out;
}
/**
* One pass over every journal. Yields to the event loop between files; a
* second call for the same home while one runs joins it. `home`, `source` and
@@ -413,21 +479,42 @@ function ingestCodexUsage({
home,
source = LOCAL.source,
activeMs = LOCAL.activeMs,
recent = false,
} = {}) {
const key = home || "";
if (running.has(key)) return running.get(key);
const pass = (async () => {
const files = allRolloutFiles(home);
const files = recent ? recentRolloutFiles(home, now) : allRolloutFiles(home);
const byThread = new Map();
for (const f of files) {
const m = FILE_NAME.exec(path.basename(f.path));
if (m) byThread.set(m[1], f.path);
}
// A recent pass lists only part of the tree: a fork's parent may live in
// an older folder, known to the table (or, failing that, to a full walk).
let walkedAll = !recent;
const parentPath = (id) => {
if (byThread.has(id)) return byThread.get(id);
if (walkedAll) return null;
const known = threadPath.get(id)?.path;
const root = path.join(home || getCodexHome(), "sessions") + path.sep;
if (known && known.startsWith(root) && fs.existsSync(known)) return known;
walkedAll = true;
for (const f of allRolloutFiles(home)) {
const m = FILE_NAME.exec(path.basename(f.path));
if (m && !byThread.has(m[1])) byThread.set(m[1], f.path);
}
return byThread.get(id) || null;
};
const stamps = new Map();
for (const r of rolloutStamps.all()) stamps.set(r.path, r);
const touched = new Set();
let read = 0;
for (const f of files) {
try {
const mtime = Math.floor(f.mtime);
const seen = stamps.get(f.path);
if (seen && seen.size === f.size && seen.mtime_ms === mtime && !seen.more) continue;
const row = getRollout.get(f.path);
let offset = row ? row.offset : 0;
let state = row ? JSON.parse(row.state) : {};
@@ -439,7 +526,8 @@ function ingestCodexUsage({
let skip = null;
const parent = offset === 0 ? firstLineMeta(f.path).forked_from_id : state.forkedFrom;
if (parent && !state.pastFork) {
if (byThread.has(parent)) skip = await parentTotals(byThread.get(parent));
const pp = parentPath(parent);
if (pp) skip = await parentTotals(pp);
else if (offset === 0)
console.warn(`[codex-usage] fork parent ${parent} not found; ${f.path} may overcount`);
}
@@ -508,10 +596,15 @@ function startCodexUsagePoll(broadcast) {
.memberHomes()
.map((h) => path.join(h, ".codex")),
];
// Every FULL_EVERY-th pass (and the first) walks every journal; the others
// only the recent ones (recentRolloutFiles).
const FULL_EVERY = Math.max(1, Math.round((5 * 60_000) / POLL_MS));
let passNo = 0;
const ingestAll = async () => {
const total = { sessions: 0 };
const recent = passNo++ % FULL_EVERY !== 0;
for (const home of homes()) {
const r = await ingestCodexUsage({ broadcast, home });
const r = await ingestCodexUsage({ broadcast, home, recent });
total.sessions += r?.sessions || 0;
}
return total;
+20 -16
View File
@@ -182,14 +182,7 @@ function writeSession(f, now) {
const endedAt = active ? null : lastWrite;
const name = firstPrompt(f.path) || existing?.name || `Cursor ${f.id.slice(0, 8)}`;
if (existing) {
updateSession.run(
name,
status,
endedAt,
lastWrite,
cwd,
f.id
);
updateSession.run(name, status, endedAt, lastWrite, cwd, f.id);
return "session_updated";
}
insertSession.run(
@@ -206,6 +199,9 @@ function writeSession(f, now) {
}
let running = null;
// Fresh sessions the last pass left for a later one to settle (the poll keeps
// its base pace while there are any).
let lastPending = 0;
/**
* One pass over every transcript: a changed one is re-read, an unchanged one
@@ -217,11 +213,15 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
running = (async () => {
let read = 0;
let written = 0;
let pending = 0; // still-fresh sessions a later pass may have to settle
for (const f of transcriptFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
if (fresh(f.mtime, now)) continue;
if (fresh(f.mtime, now)) {
pending += 1;
continue;
}
const s = stmts.getSession.get(f.id);
if (s && s.tool === "cursor" && s.status === "active") {
settle.run(new Date(f.mtime).toISOString(), s.id);
@@ -242,6 +242,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
}
await new Promise((r) => setImmediate(r));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
@@ -249,18 +250,21 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
return running;
}
/** First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS (default 15 s; 0 disables). */
/**
* First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS
* (default 15 s; 0 disables) — slowed to once a minute after a pass that read
* nothing, wrote nothing and left no fresh session to settle, and back to the
* base pace as soon as one finds work.
*/
function startCursorUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CURSOR_POLL_MS
? Number(process.env.DASHBOARD_CURSOR_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const tick = () => {
ingestCursorUsage({ broadcast }).catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
require("./backoff-poll").startBackoffPoll(
() => ingestCursorUsage({ broadcast }).then((r) => r.files + r.sessions + lastPending > 0),
{ baseMs: POLL_MS }
);
}
module.exports = {
+337
View File
@@ -0,0 +1,337 @@
/**
* @file Database size upkeep (Settings → Data → Session cleanup): the shared
* session purge, the OPT-IN automatic retention, the bounded reclaim of free
* pages, and the manual Compact.
*
* - `purgeSessionIds` is THE deletion of whole sessions — events, agents,
* token usage (daily and hourly) and the session row — used by both the
* manual `POST /api/settings/cleanup` and the automatic retention, so the two
* can never leave different leftovers.
* - Retention is OFF unless `AGENTHUB_RETENTION_DAYS` (settings file, 1–3650)
* is set; it never deletes data by default. When set, the hourly
* `runScheduledMaintenance` purges finished (completed/error/abandoned)
* sessions that STARTED more than that many days ago — the manual purge's
* rule — in transactions of `RETENTION_BATCH` sessions, yielding to the event
* loop between them, and at most `RETENTION_MAX_PER_RUN` per run, so a first
* run on a long history never blocks hooks for long. Each batch deletes by
* `idx_events_session`, never a scan of the events table.
* - With `auto_vacuum = INCREMENTAL` (server/db.js sets it on a new database
* and converts a small existing one at startup), the same run returns free
* pages to the OS once the freelist passes `RECLAIM_MIN_PAGES`, in slices of
* `RECLAIM_SLICE_PAGES`, at most `RECLAIM_MAX_PAGES` per run.
* - `runCompact` (`POST /api/settings/compact`) is the one full VACUUM: in a
* worker thread on its own connection, setting `auto_vacuum = INCREMENTAL`
* first so a large existing database is converted, then a TRUNCATE
* checkpoint so the file actually shrinks. It holds the write lock for its
* whole run (tens of seconds on a GB-size file), so hooks wait or are retried
* meanwhile — it only ever runs on a click. This file is also its worker's
* entry point.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
const KEYS = { days: "AGENTHUB_RETENTION_DAYS" };
const MAX_DAYS = 3650;
const RETENTION_BATCH = 25;
const RETENTION_MAX_PER_RUN = 5000;
const RECLAIM_MIN_PAGES = 1024;
const RECLAIM_SLICE_PAGES = 1024;
const RECLAIM_MAX_PAGES = 65536;
const PURGE_CHUNK = 500;
const PURGEABLE = "status IN ('completed','error','abandoned')";
const coded = (message, code) => Object.assign(new Error(message), { code });
const yieldLoop = () => new Promise((r) => setImmediate(r));
/** `{ days }` — 0 means off. Anything outside 1–MAX_DAYS reads as off. */
function retentionConfig(env = process.env) {
const raw = env[KEYS.days];
const n = Number(raw);
const days = raw !== undefined && raw !== "" && Number.isInteger(n) && n >= 1 && n <= MAX_DAYS;
return { days: days ? n : 0 };
}
/**
* Delete these sessions and everything hung off them, in chunks (a single
* IN (...) of every id would pass SQLite's bound-variable limit). Runs in the
* CALLER's transaction when there is one. Returns the rows removed.
*/
function purgeSessionIds(db, ids) {
const { forgetHourly } = require("./hourly-usage");
const out = { purged_sessions: 0, purged_events: 0, purged_agents: 0 };
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
out.purged_events += db
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
out.purged_agents += db
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk
);
forgetHourly(chunk);
out.purged_sessions += db
.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`)
.run(...chunk).changes;
}
return out;
}
/** What `purgeSessionIds` would delete, without deleting it (the dry run). */
function countSessionIds(db, ids) {
const out = { purged_sessions: ids.length, purged_events: 0, purged_agents: 0 };
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
out.purged_events += db
.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
out.purged_agents += db
.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
}
return out;
}
/** Finished sessions that started before `cutoff` (ISO), at most `limit`. */
function purgeableBefore(db, cutoff, limit = -1) {
return db
.prepare(`SELECT id, started_at FROM sessions WHERE ${PURGEABLE} AND started_at < ? LIMIT ?`)
.all(cutoff, limit);
}
/**
* Purge finished sessions older than `days`, a batch per transaction with a
* yield between batches. Resolves to the totals; never throws.
*/
async function applyRetention({
db = require("../db").db,
days,
now = Date.now(),
batch = RETENTION_BATCH,
maxSessions = RETENTION_MAX_PER_RUN,
} = {}) {
const out = { purged_sessions: 0, purged_events: 0, purged_agents: 0 };
if (!Number.isInteger(days) || days < 1) return out;
const cutoff = new Date(now - days * 86400 * 1000).toISOString();
try {
while (out.purged_sessions < maxSessions) {
const ids = purgeableBefore(
db,
cutoff,
Math.min(batch, maxSessions - out.purged_sessions)
).map((r) => r.id);
if (ids.length === 0) break;
const r = db.transaction(() => purgeSessionIds(db, ids))();
out.purged_sessions += r.purged_sessions;
out.purged_events += r.purged_events;
out.purged_agents += r.purged_agents;
if (r.purged_sessions === 0) break; // nothing removable: never spin
await yieldLoop();
}
} catch (err) {
out.error = err.message;
}
return out;
}
/** One `incremental_vacuum(n)`, stepped to the end on either driver. */
function incrementalVacuum(db, pages) {
const sql = `PRAGMA incremental_vacuum(${Math.max(1, Math.floor(pages))})`;
try {
db.prepare(sql).all(); // node:sqlite: one row per freed page
} catch (err) {
if (!/does not return data/i.test(err.message)) throw err;
db.pragma(`incremental_vacuum(${Math.max(1, Math.floor(pages))})`); // better-sqlite3
}
}
/**
* Hand free pages back to the OS, a slice at a time. A no-op unless the
* database is in INCREMENTAL auto_vacuum and its freelist is over `minPages`.
*/
async function reclaimFreePages({
db = require("../db").db,
minPages = RECLAIM_MIN_PAGES,
slice = RECLAIM_SLICE_PAGES,
maxPages = RECLAIM_MAX_PAGES,
} = {}) {
const out = { reclaimed_pages: 0 };
try {
if (db.pragma("auto_vacuum", { simple: true }) !== 2) return out;
let free = db.pragma("freelist_count", { simple: true });
if (free < minPages) return out;
while (free > 0 && out.reclaimed_pages < maxPages) {
incrementalVacuum(db, Math.min(slice, free, maxPages - out.reclaimed_pages));
const after = db.pragma("freelist_count", { simple: true });
if (after >= free) break;
out.reclaimed_pages += free - after;
free = after;
await yieldLoop();
}
} catch (err) {
out.error = err.message;
}
return out;
}
let lastRun = null;
let maintaining = null;
/**
* The hourly upkeep: `maintainDatabase` (checkpoint + optimize), then the
* opt-in retention, then the bounded free-page reclaim. A call while one runs
* joins it.
*/
function runScheduledMaintenance(opts = {}) {
if (maintaining) return maintaining;
maintaining = maintain(opts).finally(() => {
maintaining = null;
});
return maintaining;
}
async function maintain({ db } = {}) {
const dbm = require("../db");
const conn = db || dbm.db;
const started = new Date();
const out = { at: started.toISOString() };
try {
if (!db) dbm.maintainDatabase();
} catch {
/* maintainDatabase reports its own errors in its result */
}
const { days } = retentionConfig();
if (days) {
out.retention = await applyRetention({ db: conn, days });
if (out.retention.purged_sessions > 0) {
console.log(
`[retention] purged ${out.retention.purged_sessions} session(s) older than ${days} days`
);
}
}
out.reclaim = await reclaimFreePages({ db: conn });
out.ms = Date.now() - started;
lastRun = out;
return out;
}
/** Page counts for the Settings card. */
function spaceInfo(db = require("../db").db) {
const pageSize = db.pragma("page_size", { simple: true });
const pageCount = db.pragma("page_count", { simple: true });
const free = db.pragma("freelist_count", { simple: true });
const mode = db.pragma("auto_vacuum", { simple: true });
return {
auto_vacuum: mode === 2 ? "incremental" : mode === 1 ? "full" : "none",
page_size: pageSize,
page_count: pageCount,
freelist_pages: free,
free_bytes: free * pageSize,
};
}
let compacting = null;
let lastCompact = null;
function vacuumInWorker(dbPath) {
return new Promise((resolve, reject) => {
const w = new Worker(__filename, { workerData: { agenthubCompactWorker: true, dbPath } });
w.once("message", (m) => (m.error ? reject(coded(m.error.message, m.error.code)) : resolve(m)));
w.once("error", reject);
w.once(
"exit",
(code) => code !== 0 && reject(coded(`compact worker exited (${code})`, "ECOMPACT"))
);
});
}
/** Start (or join) the manual Compact. The outcome is `lastCompactResult()`. */
function runCompact() {
if (compacting) return compacting;
const fs = require("node:fs");
const { DB_PATH } = require("../db");
const size = () => {
try {
return fs.statSync(DB_PATH).size;
} catch {
return 0;
}
};
compacting = (async () => {
const started = Date.now();
const before = size();
try {
await vacuumInWorker(DB_PATH);
lastCompact = {
at: new Date(started).toISOString(),
ok: true,
size_before: before,
size_after: size(),
ms: Date.now() - started,
};
return lastCompact;
} catch (err) {
lastCompact = { at: new Date(started).toISOString(), ok: false, error: err.message };
throw err;
}
})().finally(() => {
compacting = null;
});
return compacting;
}
const isCompacting = () => compacting !== null;
const lastCompactResult = () => lastCompact;
const lastMaintenance = () => lastRun;
if (!isMainThread && workerData?.agenthubCompactWorker) {
// 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);
} catch {
const Database = require("../compat-sqlite");
conn = new Database(workerData.dbPath);
}
conn.pragma("busy_timeout = 30000");
conn.pragma("auto_vacuum = INCREMENTAL"); // stored by the VACUUM below
conn.exec("VACUUM");
try {
conn.pragma("wal_checkpoint(TRUNCATE)");
} catch {
/* a reader holds the WAL: the periodic checkpoint finishes it */
}
parentPort.postMessage({ ok: true });
} catch (err) {
parentPort.postMessage({ error: { message: err.message, code: err.code || "ECOMPACT" } });
} finally {
conn?.close();
}
}
module.exports = {
KEYS,
MAX_DAYS,
retentionConfig,
purgeSessionIds,
countSessionIds,
purgeableBefore,
applyRetention,
reclaimFreePages,
runScheduledMaintenance,
spaceInfo,
runCompact,
isCompacting,
lastCompactResult,
lastMaintenance,
};
+16 -8
View File
@@ -2,21 +2,29 @@
* @file Self-hosted ReDoc API reference. ReDoc renders the OpenAPI spec as a
* clean, three-panel reference document — a read-optimized complement to
* Swagger UI's interactive "try it out" console (both are served from the same
* `/api/openapi.json` spec). The ReDoc bundle ships with the `redoc`
* dependency and is served straight from `node_modules` rather than a CDN, so
* the docs render fully offline / air-gapped, consistent with the project's
* no-external-assets policy.
* `/api/openapi.json` spec). The ReDoc bundle is served from the repo itself
* rather than a CDN, so the docs render fully offline / air-gapped, consistent
* with the project's no-external-assets policy.
*
* The bundle is VENDORED (`server/vendor/redoc/`): `redoc.standalone.js` from
* the `redoc` npm package 2.5.4, byte-for-byte, with its MIT `LICENSE` and the
* bundled libraries' `redoc.standalone.js.LICENSE.txt`. The `redoc` package
* itself is not a dependency — the server only ever serves this one browser
* file, and the package pulled ~25 MB of Node-side dependencies into every
* runtime install. To update: `npm pack redoc@<version>`, copy
* `package/bundles/redoc.standalone.js` (+ `.LICENSE.txt`) over these files and
* bump the version named here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
/**
* Absolute path to the prebuilt ReDoc standalone bundle inside the installed
* `redoc` package. Resolved through Node's module resolution so it works
* regardless of hoisting / install layout. Throws if `redoc` is not installed.
* Absolute path to the vendored ReDoc standalone bundle.
* @returns {string}
*/
function redocBundlePath() {
return require.resolve("redoc/bundles/redoc.standalone.js");
return path.join(__dirname, "..", "vendor", "redoc", "redoc.standalone.js");
}
/**
+13
View File
@@ -115,6 +115,19 @@ async function git(
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
// ENOENT from execFile is ambiguous: a missing binary OR a missing cwd.
// With the folder present it is git itself that is absent — say so, with
// the fix, rather than `git status failed` (like ETMUXMISSING for tmux).
if (err.code === "ENOENT" && cmd === "git" && fs.existsSync(cwd)) {
const error = new Error(
"git is not installed (or not on the dashboard's PATH), and every git action needs it. " +
"Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " +
"then restart the dashboard."
);
error.code = "EGITMISSING";
error.git = { args, code: "EGITMISSING", stderr: "", stdout: "" };
throw error;
}
const error = new Error(`git ${args[0]} failed`);
error.code = err.code;
error.git = { args, code: err.code, stderr: err.stderr, stdout: err.stdout };
+114
View File
@@ -11,6 +11,9 @@
* - GET /api/settings/backup (Settings)
* - PUT /api/settings/backup (Settings)
* - POST /api/settings/backup/run (Settings)
* - GET /api/settings/retention (Settings)
* - PUT /api/settings/retention (Settings)
* - POST /api/settings/compact (Settings)
* - GET /api/workflows/runs (Workflows)
* - GET /api/workflows/runs/{runId} (Workflows)
* - GET /api/remote-sources (Remote Sources)
@@ -1225,6 +1228,117 @@ const paths = {
},
},
"/api/settings/retention": (() => {
const state = {
description: "Automatic retention setting, database space and the last Compact",
content: {
"application/json": {
schema: {
type: "object",
required: ["days", "max_days", "env_owned", "compacting"],
properties: {
days: {
type: "integer",
minimum: 0,
maximum: 3650,
description:
"Finished sessions older than this many days are purged by the hourly maintenance; 0 = off (the default)",
},
max_days: { type: "integer" },
env_owned: { type: "object", properties: { days: { type: "boolean" } } },
last_run: {
type: "object",
nullable: true,
description:
"This process's last hourly maintenance: {at, retention?: {purged_sessions, purged_events, purged_agents, error?}, reclaim: {reclaimed_pages, error?}, ms}",
},
space: {
type: "object",
nullable: true,
properties: {
auto_vacuum: { type: "string", enum: ["none", "full", "incremental"] },
page_size: { type: "integer" },
page_count: { type: "integer" },
freelist_pages: { type: "integer" },
free_bytes: { type: "integer" },
},
},
db_size: { type: "integer", description: "Database file size in bytes" },
compacting: { type: "boolean" },
last_compact: {
type: "object",
nullable: true,
description:
"This process's last Compact: {at, ok, size_before?, size_after?, ms?, error?}",
},
},
},
},
},
};
return {
get: {
tags: ["Settings"],
summary: "Automatic retention and database space",
operationId: "getRetention",
responses: { 200: state },
},
put: {
tags: ["Settings"],
summary: "Set the automatic retention",
description:
"Persisted to the settings file as AGENTHUB_RETENTION_DAYS. 0 turns it off. Same-origin only; in server mode an admin's.",
operationId: "setRetention",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["days"],
properties: { days: { type: "integer", minimum: 0, maximum: 3650 } },
},
},
},
},
responses: {
200: { ...state, description: "The new state" },
400: { description: "INVALID_INPUT: days is not a whole number from 0 to 3650" },
403: { description: "EBADORIGIN: another site asked" },
409: { description: "EENVOWNED: the environment set AGENTHUB_RETENTION_DAYS" },
500: { description: "ESAVE: the settings file could not be written" },
},
},
};
})(),
"/api/settings/compact": {
post: {
tags: ["Settings"],
summary: "Compact the database now (full VACUUM)",
description:
"Rewrites the database in a worker thread, switching it to incremental auto_vacuum, then truncates the WAL. Hook writes wait while it runs. Answers at once; poll GET /api/settings/retention while `compacting`. Same-origin only; in server mode an admin's.",
operationId: "compactDatabase",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["confirm"],
properties: { confirm: { type: "boolean", enum: [true] } },
},
},
},
},
responses: {
202: { description: "Started (or already running); the retention state" },
400: { description: "ECONFIRM: confirm: true is missing" },
403: { description: "EBADORIGIN: another site asked" },
},
},
},
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
+85 -8
View File
@@ -294,6 +294,46 @@ const turnDurationStampsStmt = db.prepare(
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
);
// What this process already handled of each session's transcript parse.
// `transcriptCache.extract` returns the SAME result object while the file is
// unchanged, and a grown file's result is the old arrays plus the new tail
// (errors/turnDurations keep their element objects, compaction entries their
// uuid). So an identical result skips the token, compaction, API-error and
// turn-duration writes outright — each is idempotent against the DB, so they
// would have written nothing new — and a grown one walks only the entries
// after the last one handled. Anything that does not line up (a full re-read,
// the session re-created, the head trimmed off a capped array) falls back to
// the whole array, exactly as before. Marks are applied only once the hook's
// transaction committed; bounded, oldest dropped first.
const TRANSCRIPT_MARKS_MAX = 500;
const transcriptMarks = new Map();
let pendingMarks = null;
function transcriptMarkFor(sessionId, transcriptPath, startedAt) {
const m = transcriptMarks.get(`${sessionId}\0${transcriptPath}`);
return m && m.startedAt === startedAt ? m : null;
}
function queueTranscriptMark(sessionId, transcriptPath, mark) {
if (pendingMarks) pendingMarks.push([`${sessionId}\0${transcriptPath}`, mark]);
}
function applyTranscriptMarks(marks) {
for (const [key, mark] of marks) {
transcriptMarks.delete(key);
transcriptMarks.set(key, mark);
if (transcriptMarks.size > TRANSCRIPT_MARKS_MAX)
transcriptMarks.delete(transcriptMarks.keys().next().value);
}
}
/** The entries of `arr` after `last` (found by `same`), or all of `arr`. */
function tailAfter(arr, last, same) {
if (!arr || last == null) return arr;
for (let i = arr.length - 1; i >= 0; i--) if (same(arr[i], last)) return arr.slice(i + 1);
return arr;
}
/** The session's `<id>-main` agent, created and broadcast. */
function createMainAgent(session) {
const mainAgentId = `${session.id}-main`;
@@ -944,6 +984,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
const result = transcriptCache.extract(transcriptPath);
if (result) {
const { tokensByModel, compaction, latestModel } = result;
// Already handled (see transcriptMarks): the same result skips the
// idempotent writes below, a grown one handles only its new entries.
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
const unchanged = !!mark && mark.result === result;
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
// Keep session.model in sync with the user's *current* model — the
// transcript's most recent assistant entry is the source of truth, since
@@ -972,8 +1017,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
// Register compaction agents and events.
// Each isCompactSummary entry in the JSONL = one compaction that occurred.
// Deduplicate by uuid so we only create once per compaction.
if (compaction) {
for (const entry of compaction.entries) {
if (compaction && !unchanged) {
const fresh = tailAfter(
compaction.entries,
mark?.compactionUuid,
(e, uuid) => e.uuid === uuid
);
for (const entry of fresh) {
const compactId = `${sessionId}-compact-${entry.uuid}`;
if (stmts.getAgent.get(compactId)) continue;
@@ -1024,7 +1074,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
}
if (tokensByModel) {
if (tokensByModel && !unchanged) {
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
// the 1h cache-write split and server-tool request counts.
for (const tokens of Object.values(tokensByModel)) {
@@ -1059,12 +1109,15 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
if (result.errors) {
const freshErrors = unchanged
? null
: tailAfter(result.errors, mark?.lastError, (a, b) => a === b);
if (freshErrors && freshErrors.length) {
let newErrorRecorded = false;
// Deduplicate against the errors already recorded, read ONCE per hook:
// `result.errors` is the transcript's whole history, not just new ones.
const recorded = new Set(apiErrorSummariesStmt.all(sessionId).map((r) => r.summary));
for (const apiErr of result.errors) {
for (const apiErr of freshErrors) {
const errSummary = `${apiErr.type}: ${apiErr.message}`;
if (recorded.has(errSummary)) continue;
recorded.add(errSummary);
@@ -1136,13 +1189,16 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
// Register turn duration events from transcript
if (result.turnDurations) {
const freshTurns = unchanged
? null
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
if (freshTurns && freshTurns.length) {
// `turnDurations` is every turn in the transcript (up to 1000), on
// every hook: dedupe against ONE read of the recorded timestamps (a
// covering-index scan) instead of a query per turn — 23 ms per hook on
// a 763-turn session before.
const recorded = new Set(turnDurationStampsStmt.all(sessionId).map((r) => r.created_at));
for (const td of result.turnDurations) {
for (const td of freshTurns) {
const tdTs = td.timestamp || new Date().toISOString();
if (recorded.has(tdTs)) continue;
recorded.add(tdTs);
@@ -1171,6 +1227,14 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
}
queueTranscriptMark(sessionId, transcriptPath, {
result,
startedAt: session.started_at,
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
lastError: lastOf(result.errors),
lastTurn: lastOf(result.turnDurations),
});
// Update session metadata with enriched data (thinking blocks, usage extras)
if (result.usageExtras || result.thinkingBlockCount > 0) {
const session = stmts.getSession.get(sessionId);
@@ -1250,7 +1314,19 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
* inside it described a row a later throw could still roll back.
*/
function processEvent(hookType, data, tool) {
return holdFrames(() => processEventTx(hookType, data, tool));
const outer = pendingMarks;
pendingMarks = [];
let marks = null;
try {
const out = holdFrames(() => processEventTx(hookType, data, tool));
marks = pendingMarks;
return out;
} finally {
pendingMarks = outer;
// Only what COMMITTED is marked handled: a rolled-back hook's entries are
// walked again by the next one.
if (marks && !db.inTransaction) applyTranscriptMarks(marks);
}
}
/** Run `fn` with frames held, then send them if it returned (nested: the
@@ -2176,6 +2252,7 @@ const watchdogTimer = setInterval(watchdogCheck, WATCHDOG_INTERVAL_MS);
if (watchdogTimer.unref) watchdogTimer.unref();
router.transcriptCache = transcriptCache;
router.transcriptMarks = transcriptMarks;
router.watchdogCheck = watchdogCheck;
router.livenessReap = livenessReap;
module.exports = router;
+4 -1
View File
@@ -1937,7 +1937,10 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EMEMBERGONE",
].includes(err.code)
? 409
: 500;
: // Not the request's fault and not a server bug: the host lacks git.
err.code === "EGITMISSING"
? 503
: 500;
// git's own stderr is the useful half of a failed push or pull (a
// rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know
+66 -41
View File
@@ -1,5 +1,5 @@
/**
* @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 and the daily database backup (server/lib/db-backup.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
* @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>
*/
@@ -28,6 +28,7 @@ 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 {
@@ -678,10 +679,66 @@ function pushTokenWrite(fn) {
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.
const PURGE_CHUNK = 500;
router.post("/cleanup", (req, res) => {
const { abandon_hours, purge_days } = req.body;
const dryRun = req.body.dry_run === true;
@@ -739,11 +796,7 @@ router.post("/cleanup", (req, res) => {
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 = db
.prepare(
"SELECT id FROM sessions WHERE status IN ('completed','error','abandoned') AND started_at < ?"
)
.all(cutoff);
const toDelete = retention.purgeableBefore(db, cutoff);
if (dryRun) {
// Exactly the ids the real run deletes: those already purgeable, plus
@@ -752,43 +805,15 @@ router.post("/cleanup", (req, res) => {
...toDelete.map((r) => r.id),
...wouldAbandon.filter((r) => r.started_at < cutoff).map((r) => r.id),
];
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
result.purged_events += db
.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
result.purged_agents += db
.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
}
result.purged_sessions = ids.length;
Object.assign(result, retention.countSessionIds(db, ids));
return res.json({ ok: true, ...result });
}
// One transaction, in chunks: a single IN (...) list of every id would
// pass SQLite's bound-variable limit on a large history.
db.transaction(() => {
const ids = toDelete.map((r) => r.id);
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
result.purged_events += db
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
result.purged_agents += db
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk
);
forgetHourly(chunk);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
}
result.purged_sessions = ids.length;
})();
// 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 });
+78 -10
View File
@@ -1,5 +1,11 @@
/**
* @file Periodic git upstream check and WebSocket broadcast when update availability changes.
* @file Periodic release/upstream check and WebSocket broadcast when update
* availability changes. Hourly by default (`DASHBOARD_UPDATE_CHECK_INTERVAL_MS`,
* floor 60 s); a tick with no browser connected is skipped — a checkout install
* runs `git fetch` on every check, and nobody would see the answer — and the
* first browser to connect after a skipped or stale check gets one promptly.
* The manual "Check now" (`POST /api/updates/check`, `agenthub update-check`)
* is not routed through here and always runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,11 +19,39 @@ function isUpdateCheckDisabled() {
function intervalMs() {
const n = Number.parseInt(process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS || "", 10);
if (Number.isFinite(n) && n >= 60_000) return n;
return 5 * 60 * 1000;
return 60 * 60 * 1000;
}
function startUpdateScheduler({ broadcast }) {
/** Delay before the catch-up check a reconnecting browser triggers. */
const CONNECT_CHECK_DELAY_MS = 3_000;
/**
* @param {object} opts
* @param {Function} opts.broadcast
* @param {() => number} [opts.getConnectionCount] browsers connected now
* (default: server/websocket.js's); 0 skips a scheduled tick.
* @param {(fn: Function) => Function} [opts.onConnection] subscribe to new
* browser connections; returns an unsubscribe.
* @param {Function} [opts.getStatus] the check itself (tests inject it).
* @param {number} [opts.initialDelayMs] first tick after start (default 8 s).
*/
function startUpdateScheduler({
broadcast,
getConnectionCount,
onConnection,
getStatus = getUpdatesStatus,
initialDelayMs = 8_000,
}) {
if (isUpdateCheckDisabled()) return { stop: () => {} };
if (!getConnectionCount || !onConnection) {
const ws = require("./websocket");
getConnectionCount = getConnectionCount || ws.getConnectionCount;
onConnection = onConnection || ws.onConnection;
}
const every = intervalMs();
let lastCheckAt = 0;
let inFlight = false;
let connectTimer = null;
let lastFingerprint = "";
let lastHadUpdate = false;
@@ -25,10 +59,24 @@ function startUpdateScheduler({ broadcast }) {
let initialTimer = null;
let intervalTimer = null;
async function tick() {
// A scheduled tick: skipped while no browser is connected.
function scheduledTick() {
if (stopped) return;
let clients = 1;
try {
const status = await getUpdatesStatus();
clients = getConnectionCount();
} catch {
/* unknown: check as before */
}
if (clients > 0) tick();
}
async function tick() {
if (stopped || inFlight) return;
inFlight = true;
lastCheckAt = Date.now();
try {
const status = await getStatus();
// Fingerprint must change whenever anything user-visible changes,
// including the *shape* of manual_command (e.g. user switches branch
// or adds an upstream remote — same remote_sha / commits_behind, but
@@ -72,15 +120,33 @@ function startUpdateScheduler({ broadcast }) {
}
} catch {
// Non-fatal — never block the server on update checks
} finally {
inFlight = false;
}
}
initialTimer = setTimeout(() => {
tick();
}, 8_000);
// A browser came back after the scheduled checks were skipped (or the last
// one is older than the interval): check once, shortly, so the banner is
// current without waiting up to an hour.
let unsubscribe = null;
try {
unsubscribe = onConnection(() => {
if (stopped || connectTimer || inFlight) return;
if (Date.now() - lastCheckAt < every) return;
connectTimer = setTimeout(() => {
connectTimer = null;
tick();
}, CONNECT_CHECK_DELAY_MS);
if (typeof connectTimer.unref === "function") connectTimer.unref();
});
} catch {
/* no WebSocket server: scheduled ticks only */
}
initialTimer = setTimeout(scheduledTick, initialDelayMs);
if (typeof initialTimer.unref === "function") initialTimer.unref();
intervalTimer = setInterval(tick, intervalMs());
intervalTimer = setInterval(scheduledTick, every);
if (typeof intervalTimer.unref === "function") intervalTimer.unref();
return {
@@ -88,8 +154,10 @@ function startUpdateScheduler({ broadcast }) {
stopped = true;
if (initialTimer) clearTimeout(initialTimer);
if (intervalTimer) clearInterval(intervalTimer);
if (connectTimer) clearTimeout(connectTimer);
if (typeof unsubscribe === "function") unsubscribe();
},
};
}
module.exports = { startUpdateScheduler };
module.exports = { startUpdateScheduler, intervalMs };
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015-present, Rebilly, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
+102
View File
@@ -0,0 +1,102 @@
/*!
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/*!
* ReDoc - OpenAPI/Swagger-generated API Reference Documentation
* -------------------------------------------------------------
* Version: "2.5.4"
* Repo: https://github.com/Redocly/redoc
*/
/*!
* Stickyfill -- `position: sticky` polyfill
* v. 1.1.1 | https://github.com/wilddeer/stickyfill
* Copyright Oleg Korsunsky | http://wd.dizaina.net/
*
* MIT License
*/
/*!
* perfect-scrollbar v1.5.3
* Copyright 2021 Hyunje Jun, MDBootstrap and Contributors
* Licensed under MIT
*/
/*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */
/*!***************************************************
* mark.js v8.11.1
* https://markjs.io/
* Copyright (c) 2014–2018, Julian Kühnel
* Released under the MIT license https://git.io/vwTVl
*****************************************************/
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* use-sync-external-store-shim.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* Prism: Lightweight, robust, elegant syntax highlighting
*
* @license MIT <https://opensource.org/licenses/MIT>
* @author Lea Verou <https://lea.verou.me>
* @namespace
* @public
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
+18
View File
@@ -48,6 +48,13 @@ function initWebSocket(server) {
});
wss.on("connection", (ws, req) => {
for (const fn of connectionListeners) {
try {
fn(ws);
} catch {
/* a listener never breaks a connection */
}
}
// Server mode: who this socket belongs to, for broadcast filtering, and
// the cookie to re-check it by (a logout or a disabled user ends it).
if (isServerMode()) {
@@ -252,6 +259,16 @@ function broadcast(type, data) {
});
}
/**
* Subscribe to new `/ws` browser connections (the update scheduler checks once
* when a browser returns after its ticks were skipped). Returns an unsubscribe.
*/
const connectionListeners = new Set();
function onConnection(fn) {
connectionListeners.add(fn);
return () => connectionListeners.delete(fn);
}
function getConnectionCount() {
if (!wss) return 0;
let count = 0;
@@ -306,6 +323,7 @@ module.exports = {
initPtyWebSocket,
broadcast,
getConnectionCount,
onConnection,
closeWebSocket,
sizeFromQuery,
};