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

This commit is contained in:
2026-10-16 10:27:26 +07:00
79 changed files with 5395 additions and 1796 deletions
+7
View File
@@ -213,6 +213,13 @@ describe("OpenAPI / Swagger", () => {
const res = await fetch("/api/redoc/redoc.standalone.js");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /javascript/);
// The vendored file (server/vendor/redoc/), licence banner intact — the
// `redoc` npm package is no longer a dependency.
assert.match(
res.body,
/For license information please see redoc\.standalone\.js\.LICENSE\.txt/
);
assert.doesNotThrow(() => require("fs").statSync(require("../lib/redoc").redocBundlePath()));
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file Tests server/lib/backoff-poll.js — the poll that runs at its base pace
* while passes find work (or fail) and slows to its idle pace after a pass
* that resolved `false`, returning to the base pace once work shows up again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { startBackoffPoll, idleDefault } = require("../lib/backoff-poll");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
describe("startBackoffPoll", () => {
it("slows down after idle passes and speeds up again on work", async () => {
const answers = [true, false, false, true, "x"];
const at = [];
const t0 = Date.now();
const poll = startBackoffPoll(
() => {
at.push(Date.now() - t0);
const a = answers.shift();
if (a === "x") throw new Error("boom");
return a;
},
{ baseMs: 10, idleMs: 80 }
);
await wait(260);
poll.stop();
assert.ok(at.length >= 5, `ran ${at.length} times`);
const gaps = at.slice(1).map((t, i) => t - at[i]);
assert.ok(gaps[0] < 60, "work → base pace");
assert.ok(gaps[1] >= 75, "idle → idle pace");
assert.ok(gaps[2] >= 75, "still idle");
assert.ok(gaps[3] < 60, "work again → base pace");
});
it("idles at a minute by default, never faster than the base pace", () => {
assert.equal(idleDefault(15_000), 60_000);
assert.equal(idleDefault(120_000), 120_000);
});
});
+78 -1
View File
@@ -179,7 +179,9 @@ describe("ingestCodexUsage", () => {
});
it("settles an active session to completed once its journals go quiet", async () => {
const now = Date.now();
// A whole second: utimes rounds through a float, and a millisecond `now`
// came back from stat 1 ms early about half the time.
const now = Math.floor(Date.now() / 1000) * 1000;
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
@@ -536,3 +538,78 @@ describe("service tier", () => {
]);
});
});
describe("ingestCodexUsage — recent pass", () => {
const NOW = Date.parse("2026-09-29T03:00:00Z");
function writeAt(day, id, lines, mtime) {
const dir = path.join(process.env.CODEX_HOME, "sessions", ...day.split("/"));
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `rollout-${day.replace(/\//g, "-")}T01-00-00-${id}.jsonl`);
fs.writeFileSync(file, jsonl(lines));
fs.utimesSync(file, mtime, mtime);
return file;
}
it("stats only the newest day folders and the journals still growing", async () => {
const quiet = writeAt(
"2026/09/01",
"old1",
[meta("old1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 20 * 86_400_000)
);
const live = writeAt(
"2026/09/02",
"old2",
[meta("old2"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 60_000)
);
writeAt("2026/09/27", "a", [meta("a"), ctx("gpt-a")], new Date(NOW - 86_400_000));
writeAt("2026/09/28", "b", [meta("b"), ctx("gpt-a")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW }); // the full first pass
// Both old journals grow; only the one written within the hour is looked at.
fs.appendFileSync(quiet, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.appendFileSync(live, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.utimesSync(quiet, new Date(NOW - 1000), new Date(NOW - 1000));
fs.utimesSync(live, new Date(NOW - 1000), new Date(NOW - 1000));
// A new journal in today's folder is found too.
writeAt(
"2026/09/29",
"new1",
[meta("new1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("old2"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }], "left to a full pass");
assert.deepEqual(usage("new1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
await ingestCodexUsage({ now: NOW }); // the next full pass catches it up
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
});
it("finds a fork's parent in an older folder on a recent pass", async () => {
const parent = [
meta("p9"),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
tc([300, 0, 30], [200, 0, 20]),
];
writeAt("2026/08/01", "p9", parent, new Date(NOW - 50 * 86_400_000));
writeAt("2026/09/28", "x", [meta("x")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW });
writeAt(
"2026/09/29",
"f9",
[
meta("f9", { forked_from_id: "p9" }),
meta("p9"),
...parent.slice(1),
tc([350, 0, 35], [50, 0, 5]),
],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("f9"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @file Tests server/lib/db-retention.js and its Settings routes — a new
* database is INCREMENTAL auto_vacuum, retention is off by default and purges
* nothing then, when on it removes only finished sessions older than the
* cutoff (with their events, agents and token rows) in bounded batches, the
* free-page reclaim shrinks the freelist, GET/PUT /api/settings/retention
* validate and persist the setting, and POST /api/settings/compact needs
* confirm and runs a VACUUM in a worker. The database and settings file are
* temp paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-retention-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
delete process.env.AGENTHUB_RETENTION_DAYS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const retention = require("../lib/db-retention");
let server;
let BASE;
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
delete process.env.AGENTHUB_RETENTION_DAYS;
fs.rmSync(ROOT, { recursive: true, force: true });
});
async function call(method, urlPath, body) {
const res = await fetch(BASE + urlPath, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
}
const daysAgo = (n) => new Date(Date.now() - n * 86400 * 1000).toISOString();
function seed(id, status, startedAt, events = 3) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, ?, '/tmp', ?, ?)"
).run(id, id, status, startedAt, startedAt);
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES (?, 'PostToolUse', 's', ?, ?)"
);
for (let i = 0; i < events; i++)
ins.run(id, JSON.stringify({ pad: "x".repeat(2000) }), startedAt);
}
const exists = (id) => !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id);
const eventCount = (id) =>
db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n;
describe("auto_vacuum", () => {
it("a new database is INCREMENTAL", () => {
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
assert.equal(retention.spaceInfo(db).auto_vacuum, "incremental");
});
});
describe("retentionConfig", () => {
it("is off by default and for anything out of range", () => {
assert.equal(retention.retentionConfig({}).days, 0);
for (const v of ["", "0", "-3", "1.5", "abc", "99999"]) {
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: v }).days, 0, v);
}
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: "30" }).days, 30);
});
});
describe("scheduled maintenance", () => {
it("purges nothing while retention is off", async () => {
seed("ret-off-old", "completed", daysAgo(400));
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention, undefined);
assert.ok(exists("ret-off-old"));
db.prepare("DELETE FROM sessions WHERE id = 'ret-off-old'").run();
});
it("purges only finished sessions older than the cutoff, with their rows", async () => {
seed("ret-old-done", "completed", daysAgo(100));
seed("ret-old-err", "error", daysAgo(100));
seed("ret-old-active", "active", daysAgo(100));
seed("ret-new-done", "completed", daysAgo(5));
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES ('ret-old-done', 'm', 1)"
).run();
process.env.AGENTHUB_RETENTION_DAYS = "30";
try {
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention.purged_sessions, 2);
assert.equal(out.retention.purged_events, 6);
} finally {
delete process.env.AGENTHUB_RETENTION_DAYS;
}
assert.ok(!exists("ret-old-done"));
assert.ok(!exists("ret-old-err"));
assert.equal(eventCount("ret-old-done"), 0);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = 'ret-old-done'").get().n,
0
);
assert.ok(exists("ret-old-active"), "an active session is never purged");
assert.ok(exists("ret-new-done"), "a recent session is kept");
assert.equal(eventCount("ret-new-done"), 3);
});
it("works in bounded batches and stops at the per-run cap", async () => {
for (let i = 0; i < 7; i++) seed(`ret-batch-${i}`, "abandoned", daysAgo(50), 1);
const first = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(first.purged_sessions, 5);
const second = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(second.purged_sessions, 2);
const third = await retention.applyRetention({ db, days: 10 });
assert.equal(third.purged_sessions, 0);
});
it("returns free pages to the OS", async () => {
for (let i = 0; i < 40; i++) seed(`ret-big-${i}`, "completed", daysAgo(60), 20);
await retention.applyRetention({ db, days: 30 });
const before = db.pragma("freelist_count", { simple: true });
assert.ok(before > 50, `expected free pages, got ${before}`);
const out = await retention.reclaimFreePages({ db, minPages: 10, slice: 16 });
assert.ok(out.reclaimed_pages > 0);
assert.ok(db.pragma("freelist_count", { simple: true }) < before);
});
});
describe("routes", () => {
it("GET reports retention off by default", async () => {
const r = await call("GET", "/api/settings/retention");
assert.equal(r.status, 200);
assert.equal(r.body.days, 0);
assert.equal(r.body.compacting, false);
assert.equal(r.body.space.auto_vacuum, "incremental");
});
it("PUT validates and persists, 0 turns it off", async () => {
for (const bad of [-1, 1.5, "7", 99999, null]) {
const r = await call("PUT", "/api/settings/retention", { days: bad });
assert.equal(r.status, 400, String(bad));
}
let r = await call("PUT", "/api/settings/retention", { days: 90 });
assert.equal(r.status, 200);
assert.equal(r.body.days, 90);
assert.match(fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), /RETENTION_DAYS=90/);
r = await call("PUT", "/api/settings/retention", { days: 0 });
assert.equal(r.body.days, 0);
});
it("POST /compact needs confirm and then vacuums", async () => {
let r = await call("POST", "/api/settings/compact", {});
assert.equal(r.status, 400);
r = await call("POST", "/api/settings/compact", { confirm: true });
assert.equal(r.status, 202);
for (let i = 0; i < 200 && retention.isCompacting(); i++) {
await new Promise((res) => setTimeout(res, 50));
}
const last = retention.lastCompactResult();
assert.equal(last?.ok, true, last?.error);
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
});
});
+95 -5
View File
@@ -91,6 +91,69 @@ describe("node version gate", () => {
});
});
describe("last failure report (SessionStart systemMessage)", () => {
beforeEach(resetRuntime);
it("reports a recorded failure once, naming the log and /agenthub-doctor", () => {
boot.recordFailure(RT, "install-failed", "npm broke", path.join(RT, "npm-install.log"));
const first = boot.takeFailureReport(RT);
assert.match(first.systemMessage, /npm broke/);
assert.match(first.systemMessage, /npm-install\.log/);
assert.match(first.systemMessage, /\/agenthub-doctor/);
assert.equal(boot.takeFailureReport(RT), null);
});
it("does not re-report the same failure recorded again by a later retry", () => {
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
boot.takeFailureReport(RT);
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
assert.equal(boot.takeFailureReport(RT), null);
});
it("reports a different failure, and nothing once cleared", () => {
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
boot.takeFailureReport(RT);
boot.recordFailure(RT, "ui-build-failed", "vite broke", "y.log");
assert.match(boot.takeFailureReport(RT).systemMessage, /vite broke/);
boot.recordFailure(RT, "ui-build-failed", "vite broke", "y.log");
boot.clearFailure(RT);
assert.equal(boot.readFailure(RT), null);
assert.equal(boot.takeFailureReport(RT), null);
});
it("prints nothing with no failure on record", () => {
assert.equal(boot.takeFailureReport(RT), null);
});
});
describe("depsHash — only what npm installs", () => {
function root(pkg, lock) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-deps-"));
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
fs.writeFileSync(path.join(dir, "package-lock.json"), JSON.stringify(lock));
return dir;
}
const pkg = (version, deps) => ({ name: "agenthub", version, dependencies: deps });
const lock = (version, deps) => ({
name: "agenthub",
version,
packages: { "": { name: "agenthub", version, dependencies: deps }, "node_modules/a": {} },
});
it("ignores a release's version bump", () => {
// Every update reinstalled all runtime dependencies when only the version moved.
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.1", { a: "^1" }), lock("1.0.1", { a: "^1" }));
assert.equal(boot.depsHash(a), boot.depsHash(b));
});
it("changes when a dependency does", () => {
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.0", { a: "^2" }), lock("1.0.0", { a: "^2" }));
assert.notEqual(boot.depsHash(a), boot.depsHash(b));
});
});
describe("fast path", () => {
beforeEach(resetRuntime);
@@ -527,7 +590,6 @@ describe("buildClient dependency install", () => {
assert.deepEqual(calls, [
"ci --no-audit --no-fund --include=dev --ignore-scripts",
"install --no-audit --no-fund --include=dev --ignore-scripts",
"node tsc -b",
"node vite.js build",
]);
});
@@ -567,8 +629,9 @@ describe("buildClient dependency install", () => {
it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => {
// On Windows a script or a .bin shim runs through cmd.exe and needs `node`
// on PATH; the server's PATH lacked it, so esbuild's postinstall failed and
// every update kept the previous UI. The build here must equal the
// client's own "build" script, run without npm.
// every update kept the previous UI. The build here is the vite half of
// the client's own "build" script, run without npm; the type check is the
// release's job, not every user's.
assert.equal(
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
.scripts.build,
@@ -577,8 +640,35 @@ describe("buildClient dependency install", () => {
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run")));
assert.deepEqual(calls.slice(-1), ["node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run") && !c.includes("tsc")));
});
it("keeps the installed tree while the client's manifests are unchanged", () => {
resetRuntime();
const root = fakeRoot();
const first = [];
buildClient({ rt: RT, root, exec: fakeExec(first) });
assert.ok(first[0].startsWith("ci"));
const again = [];
assert.equal(buildClient({ rt: RT, root, force: true, exec: fakeExec(again) }), "built");
assert.deepEqual(again, ["node vite.js build"]);
fs.writeFileSync(path.join(root, "client", "package.json"), '{"dependencies":{"x":"1"}}');
const changed = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(changed) });
assert.ok(changed[0].startsWith("ci"), "a changed manifest installs from clean");
});
it("installs from clean again after a build on the kept tree failed", () => {
resetRuntime();
const root = fakeRoot();
buildClient({ rt: RT, root, exec: fakeExec([]) });
const failing = (cmd, args, cwd) => (cmd === "node" ? false : fakeExec([])(cmd, args, cwd));
assert.equal(buildClient({ rt: RT, root, force: true, exec: failing }), "failed");
const next = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(next) });
assert.ok(next[0].startsWith("ci"));
});
});
+2
View File
@@ -50,6 +50,8 @@ function trackedFiles() {
// Generated from server/openapi-extra/* (which IS linted): the drift test
// below covers it, so linting both would only double-report one fix.
.filter((f) => f !== "openapi.yaml" && !f.endsWith("package-lock.json"))
// Third-party files copied unchanged (server/vendor/redoc): their links are upstream's.
.filter((f) => !f.includes("/vendor/"))
);
}
+82
View File
@@ -150,3 +150,85 @@ describe("syncDefaultProjects", () => {
);
});
});
describe("syncDefaultProjects — sessions hooks are feeding", () => {
const SNAP_DIR = path.join(process.env.DASHBOARD_DATA_DIR, "transcripts");
const more = (sessionId, cwd, ts) => ({
type: "assistant",
cwd,
sessionId,
timestamp: ts,
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "later" }],
usage: { input_tokens: 3, output_tokens: 2 },
},
});
const bumpMtime = (file) => {
const future = Date.now() / 1000 + 120;
fs.utimesSync(file, future, future);
};
const ageSession = (id) =>
dbModule.db
.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?")
.run(new Date(Date.now() - 10 * 60_000).toISOString(), id);
it("defers a live hook-fed imported session, then catches it up once quiet", async () => {
const ID = "33333333-cccc-4ccc-8ccc-333333333333";
const cache = new Map();
const file = writeSession("-live", ID, fixtureLines(ID, "/live"));
await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.ok(dbModule.stmts.getSession.get(ID), "imported first");
// A hook arrives: its event is the newest, and it touches the session.
dbModule.stmts.insertEvent.run(ID, `${ID}-main`, "PreToolUse", "Bash", "hook", "{}");
dbModule.stmts.touchSession.run(ID);
writeSession(
"-live",
ID,
fixtureLines(ID, "/live", [more(ID, "/live", "2026-04-18T12:09:00.000Z")])
);
bumpMtime(file);
const live = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(
live.changed.find((c) => c.sessionId === ID),
undefined,
"deferred while live"
);
assert.equal(cache.get(file), -1, "kept in the cache so the watcher ignores its appends");
ageSession(ID); // the hooks went quiet
const quiet = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.ok(
quiet.changed.find((c) => c.sessionId === ID && !c.isNew),
"caught up once quiet"
);
assert.notEqual(cache.get(file), -1);
});
it("never re-parses a hook-created session, only refreshes its snapshot once quiet", async () => {
const ID = "44444444-dddd-4ddd-8ddd-444444444444";
dbModule.stmts.insertSession.run(ID, "hooked", "active", "/hooked", null, null);
dbModule.stmts.insertEvent.run(ID, null, "SessionStart", null, "hook", "{}");
const file = writeSession("-hooked", ID, fixtureLines(ID, "/hooked"));
bumpMtime(file);
const snap = path.join(SNAP_DIR, `${ID}.jsonl`);
const cache = new Map();
await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(fs.existsSync(snap), false, "no snapshot copy while hooks are live");
ageSession(ID);
const before = dbModule.db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?");
const n0 = before.get(ID).n;
const r = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(
r.changed.find((c) => c.sessionId === ID),
undefined,
"nothing imported"
);
assert.equal(before.get(ID).n, n0, "no events written for a hook-created session");
assert.equal(fs.existsSync(snap), true, "snapshot taken once quiet");
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* @file Tests for the hook writer's per-transcript "already handled" marks
* (`transcriptMarks` in server/routes/hooks.js): a hook whose transcript is
* unchanged skips the token / compaction / API-error / turn-duration writes,
* a grown transcript is walked only from the last entry handled, and the rows
* left behind are exactly the ones a full re-walk would have written.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-marks-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const hooksRouter = require("../routes/hooks");
let server;
let BASE;
function post(body) {
return new Promise((resolve, reject) => {
const url = new URL("/api/hooks/event", BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
const line = (o) => JSON.stringify(o) + "\n";
const assistant = (id, inTok) => ({
type: "assistant",
timestamp: "2026-09-18T01:00:00.000Z",
message: {
id,
model: "claude-sonnet-5",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: inTok, output_tokens: 1 },
},
});
const turn = (ms, ts) => ({
type: "system",
subtype: "turn_duration",
durationMs: ms,
timestamp: ts,
});
const apiError = (text, ts) => ({
type: "assistant",
isApiErrorMessage: true,
error: "rate_limit",
timestamp: ts,
message: { content: [{ type: "text", text }] },
});
const compact = (uuid, ts) => ({ type: "user", isCompactSummary: true, uuid, timestamp: ts });
function rows(sid) {
const ev = db
.prepare(
"SELECT event_type, summary, created_at FROM events WHERE session_id = ? AND event_type IN ('TurnDuration','APIError','Compaction') ORDER BY event_type, created_at, summary"
)
.all(sid)
.map((r) =>
r.event_type === "APIError"
? `${r.event_type}|${r.summary}`
: `${r.event_type}|${r.created_at}|${r.summary}`
);
const tokens = db
.prepare("SELECT model, input_tokens, baseline_input FROM token_usage WHERE session_id = ?")
.all(sid);
return { ev, tokens };
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("transcript marks", () => {
it("skips an unchanged transcript and walks only the new tail of a grown one", async () => {
const sid = "abababab-1111-4111-8111-abababababab";
const cwd = path.join(TMP, "work");
fs.mkdirSync(cwd, { recursive: true });
const tpath = path.join(TMP, `${sid}.jsonl`);
fs.writeFileSync(
tpath,
line(assistant("m1", 5)) +
line(turn(1000, "2026-09-18T01:00:01.000Z")) +
line(apiError("slow down", "2026-09-18T01:00:02.000Z")) +
line(compact("c-1", "2026-09-18T01:00:03.000Z")) +
line(assistant("m2", 7)) +
line(turn(2000, "2026-09-18T01:00:04.000Z"))
);
const data = { session_id: sid, cwd, transcript_path: tpath };
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
const first = rows(sid);
assert.equal(first.ev.filter((e) => e.startsWith("TurnDuration")).length, 2);
assert.equal(first.ev.filter((e) => e.startsWith("APIError")).length, 1);
assert.equal(first.ev.filter((e) => e.startsWith("Compaction")).length, 1);
assert.ok(hooksRouter.transcriptMarks.size >= 1, "a mark is kept after commit");
// Proof of the skip: a row removed behind the writer's back is NOT put
// back by a hook whose transcript did not change (before, every hook
// re-walked the whole history and re-inserted it).
db.prepare(
"DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration' AND created_at = '2026-09-18T01:00:01.000Z'"
).run(sid);
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
assert.equal(rows(sid).ev.filter((e) => e.startsWith("TurnDuration")).length, 1);
db.prepare("DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration'").run(sid);
hooksRouter.transcriptMarks.clear();
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
assert.deepEqual(rows(sid), first, "a cleared mark re-walks the whole history, as before");
// Grow it: one turn, one new error, a repeat of the old error, a compaction.
fs.appendFileSync(
tpath,
line(assistant("m3", 11)) +
line(turn(3000, "2026-09-18T01:00:05.000Z")) +
line(apiError("slow down", "2026-09-18T01:00:06.000Z")) +
line(apiError("overloaded", "2026-09-18T01:00:07.000Z")) +
line(compact("c-2", "2026-09-18T01:00:08.000Z"))
);
assert.equal(await post({ hook_type: "Stop", data }), 200);
const grown = rows(sid);
// The same transcript walked from scratch must produce the same rows.
hooksRouter.transcriptMarks.clear();
assert.equal(await post({ hook_type: "Stop", data: { ...data, stop_hook_active: true } }), 200);
assert.deepEqual(rows(sid), grown, "the delta wrote exactly what a full walk writes");
assert.equal(grown.ev.filter((e) => e.startsWith("TurnDuration")).length, 3);
assert.equal(grown.ev.filter((e) => e.startsWith("APIError")).length, 2, "repeat deduped");
assert.equal(grown.ev.filter((e) => e.startsWith("Compaction")).length, 2);
assert.equal(grown.tokens.length, 1);
assert.equal(grown.tokens[0].input_tokens + grown.tokens[0].baseline_input, 23);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Tests server/update-scheduler.js — the default interval is an hour
* (the env override and its 60 s floor still apply), a scheduled tick with no
* browser connected runs no check, and a browser connecting after that gets
* one catch-up check (only when the last check is stale). The check, the
* connection count and the connection event are injected; nothing touches git
* or the network.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
delete process.env.DASHBOARD_UPDATE_CHECK;
const { startUpdateScheduler, intervalMs } = require("../update-scheduler");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let handle = null;
afterEach(() => {
handle?.stop();
handle = null;
delete process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS;
});
function harness(clients) {
const h = { checks: 0, clients, listeners: [] };
handle = startUpdateScheduler({
broadcast: () => {},
getConnectionCount: () => h.clients,
onConnection: (fn) => {
h.listeners.push(fn);
return () => {};
},
getStatus: async () => {
h.checks++;
return { update_available: false };
},
initialDelayMs: 10,
});
h.connect = () => h.listeners.forEach((fn) => fn());
return h;
}
describe("intervalMs", () => {
it("defaults to one hour and keeps the override with its floor", () => {
delete process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS;
assert.equal(intervalMs(), 60 * 60 * 1000);
process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS = "120000";
assert.equal(intervalMs(), 120000);
process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS = "1000";
assert.equal(intervalMs(), 60 * 60 * 1000);
});
});
describe("startUpdateScheduler", () => {
it("checks on the first tick when a browser is connected", async () => {
const h = harness(1);
await wait(40);
assert.equal(h.checks, 1);
});
it("skips the tick with no browser, then checks once when one connects", async () => {
const h = harness(0);
await wait(40);
assert.equal(h.checks, 0);
h.clients = 1;
h.connect();
h.connect(); // a second tab in the same window: still one check
await wait(3_300);
assert.equal(h.checks, 1);
h.connect(); // fresh check: no catch-up
await wait(3_300);
assert.equal(h.checks, 1);
});
});
+20
View File
@@ -465,3 +465,23 @@ describe("listBranches", () => {
assert.deepEqual(branches, []);
});
});
describe("git missing from PATH", () => {
it("answers EGITMISSING with the fix, not a bare ENOENT, when the folder exists", async () => {
const saved = process.env.PATH;
process.env.PATH = path.join(ROOT, "no-such-bin");
try {
await assert.rejects(
() => wt.git(ROOT, ["status"]),
(err) => err.code === "EGITMISSING" && /install/i.test(err.message)
);
// A missing FOLDER is the other ENOENT, and stays one.
await assert.rejects(
() => wt.git(path.join(ROOT, "gone"), ["status"]),
(err) => err.code === "ENOENT"
);
} finally {
process.env.PATH = saved;
}
});
});