Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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/"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Vendored
+22
@@ -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.
|
||||
|
||||
+1851
File diff suppressed because one or more lines are too long
+102
@@ -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.
|
||||
*/
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user