Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user