fix: event-loop freezes, plugin-install boot crash, tmux attach/scrollbar, Codex transcripts

- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js,
  workflow-stats.js); idx_events_session restored, idx_events_tool added
- compat-sqlite gains function/pluck/readonly so a plugin install without a
  better-sqlite3 build boots again; antigravity-usage uses db.js's Database
- install-hooks adds no checkout hooks while the agenthub plugin is enabled
  (events were doubled)
- terminal attach runs the live tmux server's own binary; the pane scrollbar
  gets a visible 2-column style, and claude panes start with
  CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll
- Codex sessions' conversation in GET /api/sessions/:id/transcript
- Codex/Antigravity sessions claimed by their workspace without hooks
- UI-audit follow-ups, docs and OpenAPI
This commit is contained in:
2026-10-05 17:01:05 +07:00
parent 442f133930
commit 198fbb7980
78 changed files with 2716 additions and 873 deletions
@@ -148,6 +148,33 @@ describe("antigravity usage", () => {
assert.equal(agy.firstFileUri(msg(pb(1, msg(pb(2, "file:///a%20b/c"))))), "/a b/c");
});
it("decodes a blob given as a plain Uint8Array, as node:sqlite returns one", () => {
const u8 = (b) => new Uint8Array(b);
const u = agy.generationUsage(
u8(generation({ model: "gemini-3.8-flash", input: 5, output: 1 }))
);
assert.equal(u.model, "gemini-3.8-flash");
assert.equal(agy.firstFileUri(u8(msg(pb(1, msg(pb(2, "file:///a%20b/c")))))), "/a b/c");
});
it("opens agy's files with the driver db.js chose, read-only (node:sqlite when better-sqlite3 has no build)", () => {
const { Database: Chosen } = require("../db");
assert.equal(typeof Chosen, "function");
const Compat = require("../compat-sqlite");
const file = conversation("33333333-3333-4333-8333-333333333333", {
gens: [{ model: "gemini-3.1-pro", input: 1, output: 2 }],
});
const conn = new Compat(file, { readonly: true, fileMustExist: true });
assert.equal(conn.prepare("SELECT count(*) AS n FROM gen_metadata").get().n, 1);
assert.throws(() => conn.exec("DELETE FROM gen_metadata"), /readonly|read-only/i);
conn.close();
assert.throws(
() => new Compat(path.join(CONV, "missing.db"), { readonly: true, fileMustExist: true }),
/does not exist|unable to open/i
);
assert.equal(fs.existsSync(path.join(CONV, "missing.db")), false);
});
it("creates the session with its name, cwd, model and per-model tokens", async () => {
conversation(ID, {
cwd: "/work/my app",
+3 -3
View File
@@ -1813,10 +1813,10 @@ describe("Database Integrity", () => {
assert.ok(indexes.includes("idx_agents_session"));
assert.ok(indexes.includes("idx_agents_status"));
// (session_id, created_at) replaces the bare session_id index, a prefix of
// idx_events_session_type that only cost disk.
// (session_id, id) is NOT a prefix of idx_events_session_type: the hook
// duplicate guard reads a session's latest event by id through it.
assert.ok(indexes.includes("idx_events_session_created"));
assert.ok(!indexes.includes("idx_events_session"));
assert.ok(indexes.includes("idx_events_session"));
assert.ok(indexes.includes("idx_events_type"));
assert.ok(indexes.includes("idx_events_created"));
assert.ok(indexes.includes("idx_sessions_status"));
+159
View File
@@ -0,0 +1,159 @@
/**
* @file GET /api/sessions/:id/transcript for a Codex session: the conversation
* comes from the session's own rollout journal (`codex_rollouts`, root thread),
* mapped to the Claude transcript shape — user/assistant text, reasoning
* summaries as thinking, function and custom tool calls with their outputs,
* sub-agent messages as system — with the harness's developer messages and
* injected context (AGENTS.md, environment_context) left out.
* @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 fs = require("fs");
const os = require("os");
const path = require("path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-transcript-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
process.env.CODEX_HOME = path.join(TMP, "codex");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const ID = "01a10b66-ced6-7f30-af72-18e8a77e11f9";
let server;
let BASE;
const item = (ts, payload) => ({ timestamp: ts, type: "response_item", payload });
const text = (t, kind = "input_text") => ({ type: kind, text: t });
before(async () => {
const file = path.join(TMP, "codex", "sessions", "2026", "10", "05", `rollout-x-${ID}.jsonl`);
fs.mkdirSync(path.dirname(file), { recursive: true });
const lines = [
{ timestamp: "2026-10-05T09:30:49Z", type: "session_meta", payload: { id: ID, cwd: "/w" } },
item("2026-10-05T09:30:52Z", {
type: "message",
role: "developer",
content: [text("<permissions instructions>…")],
}),
item("2026-10-05T09:30:52Z", {
type: "message",
role: "user",
content: [
text("# AGENTS.md instructions for /w\n…"),
text("<environment_context>\n <cwd>/w</cwd>"),
],
}),
item("2026-10-05T09:30:53Z", { type: "message", role: "user", content: [text("Hi")] }),
item("2026-10-05T09:30:54Z", {
type: "reasoning",
summary: [{ type: "summary_text", text: "Greet back" }],
}),
item("2026-10-05T09:30:54Z", { type: "reasoning", summary: [], encrypted_content: "gAAA" }),
item("2026-10-05T09:30:54Z", {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"ls"}',
call_id: "call_1",
}),
item("2026-10-05T09:30:55Z", {
type: "function_call_output",
call_id: "call_1",
output: [text("Script completed\n"), text("a.txt\n")],
}),
item("2026-10-05T09:30:55Z", {
type: "custom_tool_call",
name: "apply_patch",
input: "*** Begin Patch",
call_id: "call_2",
}),
item("2026-10-05T09:30:55Z", {
type: "custom_tool_call_output",
call_id: "call_2",
output: "Done",
}),
item("2026-10-05T09:30:55Z", {
type: "message",
role: "assistant",
content: [text("Hi! What can I help you with?", "output_text")],
}),
item("2026-10-05T09:30:56Z", {
type: "agent_message",
author: "/root/task1",
content: [text("Message Type: FINAL_ANSWER\nPayload: ok")],
}),
];
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
db.prepare("INSERT INTO sessions (id, tool, status) VALUES (?, 'codex', 'completed')").run(ID);
db.prepare("INSERT INTO codex_rollouts (path, session_id, thread_id) VALUES (?, ?, ?)").run(
file,
ID,
ID
);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {}
fs.rmSync(TMP, { recursive: true, force: true });
});
const get = async (q = "") => (await fetch(`${BASE}/api/sessions/${ID}/transcript${q}`)).json();
describe("Codex session transcript", () => {
it("maps the rollout to the transcript shape, without the harness's injections", async () => {
const body = await get();
const shape = body.messages.map((m) => [
m.type,
m.sender,
m.content.map((c) => c.type).join("+"),
]);
assert.deepEqual(shape, [
["user", "user", "text"],
["assistant", "assistant", "thinking"],
["assistant", "assistant", "tool_use"],
["user", "tool", "tool_result"],
["assistant", "assistant", "tool_use"],
["user", "tool", "tool_result"],
["assistant", "assistant", "text"],
["user", "system", "text"],
]);
const [hi, think, call, out, , patchOut, reply] = body.messages;
assert.equal(hi.content[0].text, "Hi");
assert.equal(hi.timestamp, "2026-10-05T09:30:53Z");
assert.equal(think.content[0].text, "Greet back");
assert.deepEqual(call.content[0], {
type: "tool_use",
name: "exec_command",
id: "call_1",
input: { cmd: "ls" },
});
assert.deepEqual(out.content[0], {
type: "tool_result",
id: "call_1",
output: "Script completed\na.txt\n",
is_error: false,
});
assert.equal(patchOut.content[0].output, "Done");
assert.equal(reply.content[0].text, "Hi! What can I help you with?");
assert.equal(body.total, 8);
});
it("pages like a Claude transcript (limit, after, before)", async () => {
const latest = await get("?limit=2");
assert.equal(latest.messages.length, 2);
assert.equal(latest.has_more, true);
assert.equal(latest.messages[1].content[0].type, "text");
const after = await get(`?after=${latest.first_line}`);
assert.equal(after.messages.length, 1);
const before = await get(`?before=${latest.first_line}&limit=50`);
assert.equal(before.messages.length, 6);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Tests server/compat-sqlite.js, the node:sqlite stand-in a plugin
* install runs on (better-sqlite3 has no native build there): every
* better-sqlite3 API the server uses — `function`, `pluck`, `readonly` /
* `fileMustExist` — and a real `server/db.js` boot in a child process with
* better-sqlite3 blocked, since the rest of the suite only ever runs on
* better-sqlite3 and a missing method there crashes the dashboard at start.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const Database = require("../compat-sqlite");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-compat-"));
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("compat-sqlite", () => {
it("registers a SQL function like better-sqlite3's db.function", () => {
const db = new Database(":memory:");
db.function("twice", { deterministic: true }, (s) => `${s}${s}`);
db.function("one", () => 1);
assert.equal(db.prepare("SELECT twice('ab') AS v, one() AS o").get().v, "abab");
db.close();
});
it("pluck() returns the first column from get, all and iterate", () => {
const db = new Database(":memory:");
db.exec("CREATE TABLE t (a, b); INSERT INTO t VALUES (1, 'x'), (2, 'y');");
const s = db.prepare("SELECT a, b FROM t ORDER BY a").pluck();
assert.equal(s.get(), 1);
assert.deepEqual(s.all(), [1, 2]);
assert.deepEqual([...s.iterate()], [1, 2]);
assert.equal(db.prepare("SELECT a FROM t WHERE a = 9").pluck().get(), undefined);
db.close();
});
it("boots server/db.js and its trim/sweep paths with better-sqlite3 unavailable", () => {
const block = path.join(TMP, "no-bs3.js");
fs.writeFileSync(
block,
`const M = require("module"); const load = M._load;
M._load = function (r, ...a) { if (r === "better-sqlite3") throw new Error("blocked"); return load.call(this, r, ...a); };`
);
const script = `
const { db, Database } = require("./server/db");
if (Database !== require("./server/compat-sqlite")) throw new Error("not on compat");
const big = JSON.stringify({ tool_input: { content: "x".repeat(20000) } });
db.prepare("INSERT INTO sessions (id) VALUES ('s1')").run();
db.prepare("INSERT INTO events (session_id, event_type, tool_name, data) VALUES ('s1', 'PreToolUse', 'Write', ?)").run(big);
require("./server/lib/event-payload").sweepEventPayloads(db).then((r) => {
const len = db.prepare("SELECT length(trim_event_data(?)) AS n").get(big).n;
console.log(JSON.stringify({ swept: r.rows >= 0, trimmedShorter: len < big.length }));
process.exit(0);
});`;
const r = spawnSync(process.execPath, ["-r", block, "-e", script], {
cwd: path.join(__dirname, "..", ".."),
env: {
...process.env,
DASHBOARD_DB_PATH: path.join(TMP, "boot.db"),
AGENTHUB_PRICING_SYNC: "0",
},
encoding: "utf8",
timeout: 60_000,
});
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout.trim().split("\n").pop()), {
swept: true,
trimmedShorter: true,
});
});
});
+34
View File
@@ -49,4 +49,38 @@ describe("dashboard-runs", () => {
assert.equal(runs.getRun("agenthub-lane-5").status, "running");
assert.equal(runs.getRun("agenthub-lane-6").status, "abandoned");
});
it("gives a hookless tool's session the lane of the run it started in", () => {
const { db } = require("../db");
start("agenthub-lane-7-1", {
laneId: 7,
cwd: "/w",
tool: "antigravity",
startedAt: Date.parse("2026-10-05T08:49:57.145Z"),
});
runs.patchRun({
id: "agenthub-lane-7-1",
status: "killed",
endedAt: Date.parse("2026-10-05T08:50:10.740Z"),
});
const add = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, tool) VALUES (?, 'x', 'completed', ?, ?, ?)"
);
add.run("in", "/w", "2026-10-05T08:50:01Z", "antigravity");
add.run("before", "/w", "2026-10-05T08:40:00Z", "antigravity");
add.run("after", "/w", "2026-10-05T09:00:00Z", "antigravity");
add.run("elsewhere", "/x", "2026-10-05T08:50:01Z", "antigravity");
add.run("other-tool", "/w", "2026-10-05T08:50:01Z", "codex");
add.run("claude", "/w", "2026-10-05T08:50:01Z", "claude");
runs.claimToolSessions();
const lane = (id) => db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get(id).lane_id;
assert.equal(lane("in"), 7);
for (const id of ["before", "after", "elsewhere", "other-tool", "claude"]) {
assert.equal(lane(id), null, id);
}
// One session at a time also becomes its run's session.
assert.equal(runs.getRun("agenthub-lane-7-1").session_id, null);
runs.claimToolSessions("in");
assert.equal(runs.getRun("agenthub-lane-7-1").session_id, "in");
});
});
+31
View File
@@ -179,6 +179,37 @@ describe("data-transfer export/import round-trip", () => {
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='S3'").get().c, 4);
});
it("stores a restored event's payload trimmed, like every other event insert", () => {
const data = JSON.stringify({
tool_name: "Edit",
tool_response: { originalFile: "x".repeat(40_000) },
});
const c = importExportBundle(db, {
sessions: [
{
id: "S-trim",
status: "completed",
started_at: "2026-06-01T00:00:00.000Z",
updated_at: "2026-06-01T00:00:00.000Z",
source: "local",
tool: "claude",
},
],
events: [
{
session_id: "S-trim",
event_type: "PostToolUse",
data,
created_at: "2026-06-01T00:00:01.000Z",
},
],
});
assert.equal(c.events, 1);
const row = db.prepare("SELECT data FROM events WHERE session_id = 'S-trim'").get();
assert.ok(row.data.length < 1000, `stored ${row.data.length} chars`);
assert.deepEqual(JSON.parse(row.data)._trimmed, ["tool_response.originalFile"]);
});
it("rejects a non-export object", () => {
assert.throws(() => importExportBundle(db, { foo: "bar" }), /recognizable dashboard export/);
assert.throws(() => importExportBundle(db, { format: "something-else" }), /Unrecognized/);
+28
View File
@@ -98,6 +98,14 @@ describe("trimEventData", () => {
const junk = big(STR_CAP + 10, "{");
assert.equal(trimEventData(junk), junk);
});
it("returns a long payload it has nothing to trim byte for byte (no number rounding)", () => {
// 12345678901234567890 does not survive a JSON.parse/stringify round trip.
const json = `{"tool_name":"Bash","id":12345678901234567890,"tool_input":{"a":"${big(
STR_CAP - 10
)}","b":"${big(STR_CAP - 10)}"}}`;
assert.equal(trimEventData(json), json);
});
});
describe("event inserts", () => {
@@ -125,4 +133,24 @@ describe("event inserts", () => {
const again = await sweepEventPayloads(db);
assert.equal(again.rows, 0);
});
it("the sweep commits once a chunk's bytes pass the budget, and still trims every row", async () => {
db.prepare("INSERT OR IGNORE INTO sessions (id) VALUES ('s-sweep')").run();
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, data, created_at) VALUES ('s-sweep', 'PostToolUse', ?, '2026-01-01T00:00:00Z')"
);
for (let i = 0; i < 5; i++) ins.run(payload);
let txns = 0;
const counting = {
prepare: (sql) => db.prepare(sql),
transaction: (fn) => {
const t = db.transaction(fn);
return (...a) => (txns++, t(...a));
},
};
// Each payload is ~32 KB; a 1-byte budget closes the transaction after every row.
const r = await sweepEventPayloads(counting, { chunk: 200, maxBytes: 1 });
assert.equal(r.rows, 5);
assert.ok(txns >= 5, `expected a transaction per row, got ${txns}`);
});
});
+29
View File
@@ -104,6 +104,35 @@ describe("install-hooks host-only guard (#193)", () => {
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
});
it("with the agenthub plugin enabled, adds nothing and removes our duplicate entries", () => {
process.env.AGENTHUB_FORCE_HOST = "1";
const foreign = { hooks: [{ type: "command", command: "my-own-hook.sh" }], matcher: "*" };
const ours = {
hooks: [{ type: "command", command: 'node "/x/scripts/hook-handler.js" Stop' }],
};
fs.writeFileSync(
SETTINGS,
JSON.stringify({
enabledPlugins: { "agenthub@sg": true },
hooks: { PreToolUse: [foreign], Stop: [ours] },
})
);
assert.equal(installHooks(true), true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
assert.doesNotMatch(JSON.stringify(settings.hooks), /hook-handler\.js/);
assert.deepEqual(settings.hooks.PreToolUse, [foreign]);
assert.equal(settings.hooks.Stop, undefined);
assert.ok(fs.existsSync(`${SETTINGS}.agenthub-bak`), "backs the file up before writing");
});
it("installs as before when the plugin is disabled", () => {
process.env.AGENTHUB_FORCE_HOST = "1";
fs.writeFileSync(SETTINGS, JSON.stringify({ enabledPlugins: { "agenthub@sg": false } }));
assert.equal(installHooks(true), true);
const settings = JSON.parse(fs.readFileSync(SETTINGS, "utf8"));
assert.match(JSON.stringify(settings.hooks.PreToolUse), /hook-handler\.js/);
});
it("isInsideContainer honors the force flags", () => {
process.env.AGENTHUB_FORCE_CONTAINER = "1";
assert.equal(isInsideContainer(), true);
+21
View File
@@ -1988,4 +1988,25 @@ describe("start: slot auto and a session already live", () => {
});
assert.equal(fork.status, 200);
});
it("an explicit slot 0 never takes over a terminal tab that holds pane 0", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-slot0-"));
try {
const own = (await request("POST", "/api/lanes", { cwd: dir })).body.lane.id;
// A terminal tab started on a stopped workspace lands in pane 0 and, being
// no run, leaves `run_id` empty.
const shell = await request("POST", `/api/lanes/${own}/start`, {
slot: "auto",
shell: true,
});
assert.equal(shell.body.run_id, `agenthub-lane-${own}`);
for (const body of [{}, { slot: 0 }]) {
const r = await request("POST", `/api/lanes/${own}/start`, body);
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "ERUNLIVE");
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
+30 -11
View File
@@ -3,8 +3,10 @@
* an install its browser run console cannot work, because every run the
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
* and never FAIL: sessions, analytics, lanes and the CLI all work without the
* binary, so failing a doctor run over it would cry wolf. tmux is faked through
* the module's own exec seam, so the test never depends on the host having it.
* binary, so failing a doctor run over it would cry wolf. A tmux older than 3.7
* (no pane scrollbars) also warns, as does a new binary still talking to an
* older running server. tmux is faked through the module's own exec seam, so
* the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,11 +28,14 @@ after(() => {
delete process.env.CLAUDE_HOME;
});
/** Fake the tmux binary as present or absent, then read back the row. */
function rowWithTmux(present) {
tmux.__setExecImpl(() => {
if (!present) throw new Error("spawnSync tmux ENOENT");
return "tmux 3.4\n";
/** Fake the tmux binary (null = absent) and its running server (null = none),
* then read back the row. */
function rowWithTmux(client, server = null) {
tmux.__setExecImpl((args) => {
if (!client) throw new Error("spawnSync tmux ENOENT");
if (args[0] === "-V") return `tmux ${client}\n`;
if (!server) throw new Error("no server running");
return `${server}\n`;
});
const row = diagnose().find((r) => r.label === "tmux");
assert.ok(row, "the doctor must always emit a tmux row");
@@ -39,16 +44,30 @@ function rowWithTmux(present) {
describe("plugin-doctor: tmux row", () => {
it("warns, never fails, when tmux is not on PATH", () => {
const row = rowWithTmux(false);
const row = rowWithTmux(null);
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /install/i, "and how to fix it");
});
it("passes when tmux is available", () => {
const row = rowWithTmux(true);
it("passes when tmux has pane scrollbars (3.7+)", () => {
assert.equal(rowWithTmux("3.7c").level, "ok");
assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok");
});
assert.equal(row.level, "ok", row.detail);
it("warns, with the upgrade path, when tmux is older than 3.7", () => {
const row = rowWithTmux("3.4", "3.4");
assert.equal(row.level, "warn");
assert.match(row.detail, /3\.7/);
assert.match(row.detail, /brew upgrade tmux/);
});
it("warns when a new tmux is still talking to an older running server", () => {
const row = rowWithTmux("3.7c", "3.4");
assert.equal(row.level, "warn");
assert.match(row.detail, /server is 3\.4/);
});
});
+27
View File
@@ -69,6 +69,33 @@ describe("pty-attach", () => {
assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
});
it("attaches with the RUNNING tmux server's own binary, not the one on PATH", () => {
// A tmux upgraded while its server runs leaves a newer client on PATH that
// the old server answers with "open terminal failed: not a terminal".
tmux.__setExecImpl((args) => (args[0] === "display-message" ? `${process.pid}\n` : ""));
let file;
ptyAttach.__setSpawnImpl((f) => {
file = f;
return fakePty;
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
assert.equal(file, process.platform === "linux" ? `/proc/${process.pid}/exe` : "tmux");
});
it("falls back to tmux on PATH when the server's pid cannot be read", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") throw new Error("no server running");
return "";
});
let file;
ptyAttach.__setSpawnImpl((f) => {
file = f;
return fakePty;
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
assert.equal(file, "tmux");
});
it("still attaches even if the mouse-mode set-option call fails", () => {
tmux.__setExecImpl(() => {
throw new Error("no such session: agenthub-lane-1");
+11
View File
@@ -539,6 +539,17 @@ describe("pty-run", () => {
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
});
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
tmux.__reset();
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
});
describe("catalog tools", () => {
const aiTools = require("../lib/ai-tools");
const BIN = path.join(TMP, "bin");
+72
View File
@@ -0,0 +1,72 @@
/**
* @file Tests server/lib/read-pool.js: a read job runs in the worker on its own
* connection, identical calls in flight share one run, a job over the timeout
* is refused with EREADTIMEOUT and the next one gets a fresh worker, and
* AGENTHUB_READ_WORKER=0 runs the job inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const TEST_DB = path.join(os.tmpdir(), `dashboard-readpool-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { db } = require("../db");
const { runRead } = require("../lib/read-pool");
before(() => {
db.prepare("INSERT INTO sessions (id, status) VALUES ('rp-1', 'completed')").run();
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name) VALUES ('rp-1', 'PreToolUse', ?)"
);
for (const t of ["Read", "Edit", "Read", "Edit"]) ins.run(t);
});
afterEach(() => {
delete process.env.AGENTHUB_READ_TIMEOUT_MS;
delete process.env.AGENTHUB_READ_WORKER;
});
after(() => {
try {
db.close();
} catch {}
for (const s of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + s, { force: true });
});
describe("read-pool", () => {
it("runs a job in the worker and sees the main connection's committed rows", async () => {
const data = await runRead("workflows", { sections: ["toolFlow"] });
assert.deepEqual(data.toolFlow.transitions.map((t) => [t.source, t.target, t.value]).sort(), [
["Edit", "Read", 1],
["Read", "Edit", 2],
]);
});
it("shares one run between identical calls in flight", async () => {
const a = runRead("workflows", { sections: ["stats"] });
const b = runRead("workflows", { sections: ["stats"] });
assert.equal(a, b);
assert.notEqual(a, runRead("workflows", { sections: ["toolFlow"] }));
await Promise.all([a, b]);
});
it("refuses a job over the timeout, then recovers with a fresh worker", async () => {
process.env.AGENTHUB_READ_TIMEOUT_MS = "1";
await assert.rejects(runRead("workflows", { tag: "slow" }), { code: "EREADTIMEOUT" });
delete process.env.AGENTHUB_READ_TIMEOUT_MS;
const data = await runRead("workflows", { sections: ["stats"] });
assert.equal(data.stats.totalSessions, 1);
});
it("runs inline with AGENTHUB_READ_WORKER=0, and rejects an unknown job", async () => {
process.env.AGENTHUB_READ_WORKER = "0";
const data = await runRead("workflows", { sections: ["stats"], tag: "inline" });
assert.equal(data.stats.topFlow.source, "Read");
await assert.rejects(runRead("nope"), { code: "EBADJOB" });
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* @file settings-import.test.js
* @description Restoring a backup: `GET /api/settings/export` writes every table
* the restore reads (with the format stamp), and `POST /api/settings/import`
* takes that file as a multipart upload, restores it without overwriting
* anything, refuses a file that is not an export without echoing its text, and
* refuses a cross-origin caller.
* @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 TEST_DB = path.join(os.tmpdir(), `settings-import-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function upload(text, headers = {}) {
const form = new FormData();
form.append("file", new Blob([text], { type: "application/json" }), "backup.json");
return fetch(`${BASE}/api/settings/import`, { method: "POST", body: form, headers });
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
db.prepare("INSERT INTO sessions (id, name, status, started_at) VALUES (?, ?, ?, ?)").run(
"imp-1",
"Backed up",
"completed",
"2026-01-10T10:00:00.000Z"
);
db.prepare("INSERT INTO events (session_id, event_type, data) VALUES (?, ?, ?)").run(
"imp-1",
"Stop",
"{}"
);
});
after(() => {
if (server) server.close();
if (db) db.close();
for (const f of [TEST_DB, TEST_DB + "-wal", TEST_DB + "-shm"]) {
try {
fs.unlinkSync(f);
} catch {
// ignore
}
}
});
describe("backup and restore", () => {
let exported;
it("the export carries the format stamp and every table the restore reads", async () => {
const res = await fetch(`${BASE}/api/settings/export`);
assert.equal(res.status, 200);
exported = await res.text();
const bundle = JSON.parse(exported);
assert.equal(bundle.format, "agenthub-export");
assert.equal(bundle.version, 1);
for (const t of [
"sessions",
"agents",
"events",
"token_usage",
"workflows",
"dashboard_runs",
"alert_rules",
"model_pricing",
]) {
assert.ok(Array.isArray(bundle[t]), `missing ${t}`);
}
});
it("restores an uploaded export, skipping what is already there", async () => {
let res = await upload(exported);
let body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(body.sessions_imported, 0);
assert.equal(body.sessions_skipped, 1);
db.prepare("DELETE FROM events WHERE session_id = 'imp-1'").run();
db.prepare("DELETE FROM sessions WHERE id = 'imp-1'").run();
res = await upload(exported);
body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(body.sessions_imported, 1);
assert.equal(body.events, 1);
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 'imp-1'").get());
});
it("refuses a file that is not an export, without echoing its text", async () => {
const res = await upload("root:x:0:0:secret-line");
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.error.code, "INVALID_FORMAT");
assert.doesNotMatch(JSON.stringify(body), /secret-line/);
const notBundle = await upload(JSON.stringify({ foo: 1 }));
assert.equal(notBundle.status, 400);
});
it("asks for a file", async () => {
const res = await fetch(`${BASE}/api/settings/import`, { method: "POST" });
assert.equal(res.status, 400);
assert.equal((await res.json()).error.code, "NO_FILE");
});
it("refuses a cross-origin caller", async () => {
const res = await upload(exported, { Origin: "https://evil.example" });
assert.equal(res.status, 403);
});
});
+19
View File
@@ -52,6 +52,25 @@ describe("tmux wrapper", () => {
"opus",
]);
assert.deepEqual(calls[1], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
assert.deepEqual(calls[2], ["set-option", "-t", "=agenthub-lane-1:", "status", "off"]);
assert.deepEqual(calls[3], ["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars", "on"]);
assert.deepEqual(calls[4], [
"set-option",
"-t",
"=agenthub-lane-1:",
"pane-scrollbars-style",
"fg=brightwhite,bg=black,width=2,pad=0",
]);
});
it("newSession still succeeds on a tmux older than 3.7 (no pane-scrollbars)", () => {
tmux.__setExecImpl((args) => {
if (args.includes("pane-scrollbars")) throw new Error("invalid option: pane-scrollbars");
return "";
});
assert.doesNotThrow(() =>
tmux.newSession({ name: "agenthub-lane-1", cwd: "/tmp/repo", argv: ["claude"] })
);
});
it("sendText types the literal string and presses Enter, with no quoting", () => {
@@ -0,0 +1,81 @@
/**
* @file GET /api/workflows tool-to-tool transitions (`stats.topFlow`,
* `toolFlow.transitions`): each tool event pairs with the NEXT tool event of the
* same session, and the query stays linear in the session's event count — the
* old correlated `MIN(id)` subquery went quadratic once `idx_events_session`
* was dropped and froze the whole server on a large session.
* @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 fs = require("fs");
const os = require("os");
const path = require("path");
const TEST_DB = path.join(os.tmpdir(), `dashboard-toolflow-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function addSession(id, tools) {
db.prepare("INSERT INTO sessions (id, status) VALUES (?, 'completed')").run(id);
const ins = db.prepare("INSERT INTO events (session_id, event_type, tool_name) VALUES (?, ?, ?)");
db.transaction(() => {
for (const t of tools) ins.run(id, t ? "PreToolUse" : "Stop", t);
})();
}
before(async () => {
// A: Read → (Stop, no tool) → Edit → Read ; B: Read → Edit
addSession("flow-a", ["Read", null, "Edit", "Read"]);
addSession("flow-b", ["Read", "Edit"]);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
try {
db.close();
} catch {}
for (const s of ["", "-wal", "-shm"]) fs.rmSync(TEST_DB + s, { force: true });
});
describe("GET /api/workflows tool transitions", () => {
it("pairs each tool event with the next tool event of its own session", async () => {
const body = await (await fetch(`${BASE}/api/workflows`)).json();
const flow = Object.fromEntries(
body.toolFlow.transitions.map((t) => [`${t.source}>${t.target}`, t.value])
);
// Read>Edit twice (A skips its Stop, B), Edit>Read once; nothing crosses sessions.
assert.deepEqual(flow, { "Read>Edit": 2, "Edit>Read": 1 });
assert.deepEqual(body.stats.topFlow, { source: "Read", target: "Edit", count: 2 });
});
it("?sections= computes only the named parts, and refuses names it does not know", async () => {
const body = await (await fetch(`${BASE}/api/workflows?sections=stats,toolFlow,bogus`)).json();
assert.deepEqual(Object.keys(body), ["stats", "toolFlow"]);
assert.equal(body.stats.topFlow.source, "Read");
const bad = await fetch(`${BASE}/api/workflows?sections=bogus`);
assert.equal(bad.status, 400);
assert.equal((await bad.json()).error.code, "INVALID_SECTIONS");
});
it("stays fast on one large session", async () => {
const tools = ["Read", "Edit", "Bash", "Grep"];
addSession(
"flow-big",
Array.from({ length: 20000 }, (_, i) => tools[i % 4])
);
const t0 = Date.now();
const res = await fetch(`${BASE}/api/workflows`);
assert.equal(res.status, 200);
await res.json();
assert.ok(Date.now() - t0 < 3000, `took ${Date.now() - t0} ms`);
});
});