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:
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user