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:
+3
-3
@@ -469,7 +469,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
||||
|
||||
**Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time. When neither title exists, the session's first user prompt (tool-result / meta / slash-command plumbing entries skipped, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task; a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn.
|
||||
|
||||
**Transcript stream** (`GET /api/sessions/:id/transcript`) returns `user` / `assistant` messages plus: synthetic `session_event` rename markers (from `custom-title`), local slash-command I/O surfaced from `system`/`local_command` lines (the `<command-name>` pill + `<local-command-stdout>`/`stderr` output, e.g. `/color`, `/rename`, custom commands), and **mid-turn queued user messages** surfaced from `attachment`/`queued_command` lines — a message typed while Claude was still working is journaled as `queue-operation` bookkeeping plus a `queued_command` attachment (never as a `user` line), so the attachment is rendered as a user message at the point the model actually received it. The queue is shared with harness injections, so queued lines are only attributed to the human when they aren't harness traffic: `<task-notification>`/`[SYSTEM NOTIFICATION` payloads and any non-`human` `origin.kind` render as `system` (harness notification attachments carry no `origin` field at all; typed messages carry `origin.kind = "human"`). Content-less `local_command` lines, other `system` subtypes, `queue-operation` lines, and every other attachment subtype are dropped.
|
||||
**Transcript stream** (`GET /api/sessions/:id/transcript`) returns `user` / `assistant` messages plus: synthetic `session_event` rename markers (from `custom-title`), local slash-command I/O surfaced from `system`/`local_command` lines (the `<command-name>` pill + `<local-command-stdout>`/`stderr` output, e.g. `/color`, `/rename`, custom commands), and **mid-turn queued user messages** surfaced from `attachment`/`queued_command` lines — a message typed while Claude was still working is journaled as `queue-operation` bookkeeping plus a `queued_command` attachment (never as a `user` line), so the attachment is rendered as a user message at the point the model actually received it. The queue is shared with harness injections, so queued lines are only attributed to the human when they aren't harness traffic: `<task-notification>`/`[SYSTEM NOTIFICATION` payloads and any non-`human` `origin.kind` render as `system` (harness notification attachments carry no `origin` field at all; typed messages carry `origin.kind = "human"`). Content-less `local_command` lines, other `system` subtypes, `queue-operation` lines, and every other attachment subtype are dropped. A **Codex** session (`tool = 'codex'`) streams its root thread's rollout journal instead (the path `codex_rollouts` records): `message` items as user/assistant text — developer messages and the injected AGENTS.md / `<environment_context>`-style blocks left out —, `reasoning` summaries as `thinking` (encrypted-only ones skipped), `function_call`/`custom_tool_call` as `tool_use` with their `*_output` as a `tool`-sender `tool_result`, and a sub-agent's `agent_message` as a `system` line; same paging, same shape (`codexMessage` in `server/routes/sessions.js`).
|
||||
|
||||
### Hook Ingestion
|
||||
|
||||
@@ -505,7 +505,7 @@ Request body shape:
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | ------------------------------------------------------------------- |
|
||||
| `GET` | `/api/workflows` | Aggregate workflow intelligence (`?status=active\|completed\|...`) |
|
||||
| `GET` | `/api/workflows` | Aggregate workflow intelligence (`?status=active\|completed\|...`, `?sections=a,b`); computed in the read worker, 503 `EREADTIMEOUT` past `AGENTHUB_READ_TIMEOUT_MS` |
|
||||
| `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) |
|
||||
|
||||
### Workspaces
|
||||
@@ -692,7 +692,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `POST` | `/api/settings/antigravity-hooks` | Install or reinstall it — PreInvocation, PostToolUse, Stop, never PreToolUse (`sameOriginGuard`; same 400 codes) |
|
||||
| `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) |
|
||||
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
|
||||
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole |
|
||||
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file` only, same-origin, ≤ 512 MB. Idempotent + non-destructive: sessions already present are skipped whole; restored payloads trimmed like every event insert |
|
||||
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
|
||||
|
||||
### Claude Config Explorer (`/api/cc-config`)
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
+28
-4
@@ -7,15 +7,20 @@
|
||||
* On older Node versions, require() will throw and the caller should
|
||||
* handle the error (e.g. show an informative message).
|
||||
*
|
||||
* @file This module exports a Database class that wraps node:sqlite's DatabaseSync to provide a better-sqlite3-like API.
|
||||
* @file This module exports a Database class that wraps node:sqlite's DatabaseSync to provide a better-sqlite3-like API: `pragma`, `transaction`, `function`, `stmt.pluck()` and the `readonly`/`fileMustExist` options. A plugin install has no better-sqlite3 build, so there this IS the driver.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
class Database {
|
||||
constructor(filePath) {
|
||||
this._db = new DatabaseSync(filePath);
|
||||
/** better-sqlite3's `readonly` and `fileMustExist` options, mapped. */
|
||||
constructor(filePath, { readonly = false, fileMustExist = false } = {}) {
|
||||
if (fileMustExist && !fs.existsSync(filePath)) {
|
||||
throw new Error(`unable to open database file: ${filePath} does not exist`);
|
||||
}
|
||||
this._db = new DatabaseSync(filePath, { readOnly: readonly });
|
||||
}
|
||||
|
||||
exec(sql) {
|
||||
@@ -36,7 +41,26 @@ class Database {
|
||||
}
|
||||
|
||||
prepare(sql) {
|
||||
return this._db.prepare(sql);
|
||||
const stmt = this._db.prepare(sql);
|
||||
// better-sqlite3's pluck(): get/all/iterate yield the first column only.
|
||||
stmt.pluck = () => {
|
||||
const first = (row) => (row === undefined ? undefined : Object.values(row)[0]);
|
||||
const { get, all, iterate } = stmt;
|
||||
stmt.get = (...a) => first(get.apply(stmt, a));
|
||||
stmt.all = (...a) => all.apply(stmt, a).map(first);
|
||||
stmt.iterate = function* (...a) {
|
||||
for (const row of iterate.apply(stmt, a)) yield first(row);
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/** better-sqlite3's db.function(name, [options], fn): a SQL function. */
|
||||
function(name, options, fn) {
|
||||
if (typeof options === "function") [fn, options] = [options, {}];
|
||||
this._db.function(name, options, fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
transaction(fn) {
|
||||
|
||||
+9
-2
@@ -317,8 +317,14 @@ db.exec(`
|
||||
-- read by the watchdog every 15 s and by every lane payload. Without it each
|
||||
-- read walked the rows' overflow chains (created_at sits after data).
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_created ON events(session_id, created_at);
|
||||
-- A prefix of idx_events_session_type: never needed, ~1% of the file.
|
||||
DROP INDEX IF EXISTS idx_events_session;
|
||||
-- (session_id, id): a session's events in insert order, NOT a prefix of
|
||||
-- idx_events_session_type (that one is not id-ordered). Every hook's
|
||||
-- duplicate guard reads a session's latest event through it; dropped, that
|
||||
-- read sorted the whole session per hook.
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, id);
|
||||
-- tool_name: the per-tool counts (analytics, workflows) and the events
|
||||
-- filter's tool list read it alone instead of scanning every event row.
|
||||
CREATE INDEX IF NOT EXISTS idx_events_tool ON events(tool_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||
@@ -2181,6 +2187,7 @@ module.exports = {
|
||||
db,
|
||||
stmts,
|
||||
DB_PATH,
|
||||
Database, // the driver class picked above, for other SQLite files (agy's)
|
||||
DEFAULT_PRICING,
|
||||
applyIntroPricing,
|
||||
topUpDefaultPricing,
|
||||
|
||||
@@ -557,6 +557,9 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("codex poll failed to start:", err.message);
|
||||
}
|
||||
// A Codex/Antigravity session imported before it could be tied to the
|
||||
// workspace pane it ran in gets that workspace now (dashboard-runs.js).
|
||||
require("./lib/dashboard-runs").claimToolSessions();
|
||||
// Codex sessions and token usage, from the same journals (codex-usage.js):
|
||||
// a first pass over every rollout in the background, then the same beat.
|
||||
try {
|
||||
@@ -1073,6 +1076,7 @@ if (require.main === module) {
|
||||
try {
|
||||
const { installHooks, isInsideContainer } = require("../scripts/install-hooks");
|
||||
if (installHooks(true)) {
|
||||
// With the agenthub plugin enabled this only removed duplicate entries.
|
||||
console.log("Claude Code hooks auto-configured.");
|
||||
} else if (isInsideContainer()) {
|
||||
console.log(
|
||||
|
||||
@@ -39,8 +39,10 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
const { db, stmts } = require("../db");
|
||||
// The driver db.js chose: better-sqlite3 has no build in a plugin install
|
||||
// (installed without scripts), and only db.js falls back to node:sqlite.
|
||||
const { db, stmts, Database } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const { getAgyCliDir } = require("./antigravity-home");
|
||||
const pricingSync = require("./pricing-sync");
|
||||
|
||||
@@ -56,6 +58,9 @@ const NOT_AGYS = /SQLITE_NOTADB|SQLITE_CORRUPT|no such table/;
|
||||
* a Buffer. Throws on a malformed buffer.
|
||||
*/
|
||||
function pbFields(buf) {
|
||||
// node:sqlite hands a BLOB over as a plain Uint8Array, whose toString()
|
||||
// ignores its encoding — model names and file URIs would decode as "1,2,3".
|
||||
if (!Buffer.isBuffer(buf)) buf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
const out = [];
|
||||
let i = 0;
|
||||
const varint = () => {
|
||||
@@ -359,6 +364,7 @@ function writeSession(f, conv, now) {
|
||||
lastWrite,
|
||||
JSON.stringify({ source: "antigravity-conversation" })
|
||||
);
|
||||
claimToolSessions(id); // its workspace, before the broadcast
|
||||
// Every usage row of an Antigravity session is this module's: rewritten whole.
|
||||
clearUsage.run(id);
|
||||
for (const [m, t] of Object.entries(conv.usage)) {
|
||||
|
||||
@@ -36,6 +36,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||
const { allRolloutFiles, FUTURE_SLACK_MS } = require("./codex-ingest");
|
||||
const pricingSync = require("./pricing-sync");
|
||||
@@ -320,6 +321,7 @@ function writeSession(sessionId, names, now) {
|
||||
lastWrite,
|
||||
JSON.stringify({ source: "codex-rollout" })
|
||||
);
|
||||
claimToolSessions(sessionId); // its workspace, before the broadcast
|
||||
// Every usage row of a Codex session is this module's, so it is rewritten
|
||||
// whole: a model gone from a re-read journal leaves no stale row behind.
|
||||
clearUsage.run(sessionId);
|
||||
|
||||
@@ -196,6 +196,45 @@ function laneForSession(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// The newest same-tool run in session `s`'s folder that was live when it
|
||||
// started (2 s of slack: agy stamps whole seconds).
|
||||
const runOf = (col, s) => `
|
||||
SELECT r.${col} FROM dashboard_runs r
|
||||
WHERE r.lane_id IS NOT NULL AND r.tool = ${s}.tool AND r.cwd = ${s}.cwd
|
||||
AND julianday(r.started_at) <= julianday(${s}.started_at) + 2.0 / 86400
|
||||
AND julianday(${s}.started_at) <= julianday(COALESCE(r.ended_at, 'now'))
|
||||
ORDER BY r.started_at DESC LIMIT 1`;
|
||||
const claimToolStmt = db.prepare(`
|
||||
UPDATE sessions SET lane_id = (${runOf("lane_id", "sessions")})
|
||||
WHERE lane_id IS NULL AND tool != 'claude' AND (@id IS NULL OR id = @id)
|
||||
AND (${runOf("lane_id", "sessions")}) IS NOT NULL
|
||||
`);
|
||||
const claimRunStmt = db.prepare(`
|
||||
UPDATE dashboard_runs SET session_id = @id
|
||||
WHERE session_id IS NULL
|
||||
AND id = (SELECT (${runOf("id", "s")}) FROM sessions s WHERE s.id = @id AND s.tool != 'claude')
|
||||
`);
|
||||
|
||||
/**
|
||||
* Stamp the workspace (`sessions.lane_id`) of a Codex / Antigravity session
|
||||
* that nothing bound: their panes learn their session only from opt-in hooks
|
||||
* (routes/hooks.js → bindRunSession), so without them a session the poll
|
||||
* imported belonged to no workspace and vanished from it once its tab closed.
|
||||
* Claude is left to `--session-id` + its hooks. `sessionId` null = every one;
|
||||
* a single session also becomes its run's `session_id` while that is empty,
|
||||
* so its live tab shows the session instead of a second "New session".
|
||||
* ponytail: matched by (tool, cwd, time), so an agy started by hand in the
|
||||
* same folder while a pane of that tool is live is counted as the pane's.
|
||||
*/
|
||||
function claimToolSessions(sessionId = null) {
|
||||
try {
|
||||
if (sessionId) claimRunStmt.run({ id: sessionId });
|
||||
return claimToolStmt.run({ id: sessionId }).changes;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const runningStmt = db.prepare(
|
||||
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||
);
|
||||
@@ -235,4 +274,5 @@ module.exports = {
|
||||
reconcileOrphans,
|
||||
routerForSession,
|
||||
laneForSession,
|
||||
claimToolSessions,
|
||||
};
|
||||
|
||||
@@ -85,12 +85,14 @@ function bindable(v) {
|
||||
/**
|
||||
* Make a prepared INSERT OR IGNORE that only writes the columns a table
|
||||
* actually has AND the row actually provides. `omit` drops columns even if
|
||||
* present (used to strip the non-portable events.id).
|
||||
* present (used to strip the non-portable events.id); `wrap` names a column
|
||||
* whose value goes through a SQL function (events.data through
|
||||
* `trim_event_data`, as every event insert must).
|
||||
*/
|
||||
function makeInserter(db, table, { omit = [] } = {}) {
|
||||
function makeInserter(db, table, { omit = [], wrap = {} } = {}) {
|
||||
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
|
||||
const quoted = cols.map((c) => `"${c}"`).join(", ");
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const placeholders = cols.map((c) => (wrap[c] ? `${wrap[c]}(?)` : "?")).join(", ");
|
||||
const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`);
|
||||
return (row) => stmt.run(cols.map((c) => bindable(row[c])));
|
||||
}
|
||||
@@ -175,7 +177,7 @@ function importExportBundle(db, bundle) {
|
||||
const insert = {
|
||||
sessions: makeInserter(db, "sessions"),
|
||||
agents: makeInserter(db, "agents"),
|
||||
events: makeInserter(db, "events", { omit: ["id"] }),
|
||||
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
|
||||
token_usage: makeInserter(db, "token_usage"),
|
||||
workflows: makeInserter(db, "workflows"),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs"),
|
||||
|
||||
+52
-29
@@ -30,26 +30,29 @@ const DROP_RESPONSE = {
|
||||
};
|
||||
const DROP_RESPONSE_FILE = { Read: ["content", "base64"] };
|
||||
|
||||
function capString(s) {
|
||||
function capString(s, hit) {
|
||||
if (s.length <= STR_CAP) return s;
|
||||
hit.n++;
|
||||
return `${s.slice(0, HEAD)}\n…[${s.length - HEAD - TAIL} chars trimmed]…\n${s.slice(-TAIL)}`;
|
||||
}
|
||||
|
||||
// Caps every string at any depth; `structuredPatch` is left whole.
|
||||
function capDeep(v, key) {
|
||||
if (typeof v === "string") return capString(v);
|
||||
function capDeep(v, key, hit) {
|
||||
if (typeof v === "string") return capString(v, hit);
|
||||
if (key === "structuredPatch" || v === null || typeof v !== "object") return v;
|
||||
if (Array.isArray(v)) return v.map((x) => capDeep(x));
|
||||
if (Array.isArray(v)) return v.map((x) => capDeep(x, undefined, hit));
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = capDeep(x, k);
|
||||
for (const [k, x] of Object.entries(v)) out[k] = capDeep(x, k, hit);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data A parsed hook payload.
|
||||
* @param {{n: number}} [hit] Counts every drop and cap made, so a caller can tell
|
||||
* "nothing to trim" apart.
|
||||
* @returns {object} A trimmed copy (the input is not mutated).
|
||||
*/
|
||||
function trimHookPayload(data) {
|
||||
function trimHookPayload(data, hit = { n: 0 }) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data;
|
||||
const out = { ...data };
|
||||
const dropped = new Set(Array.isArray(data._trimmed) ? data._trimmed : []);
|
||||
@@ -59,6 +62,7 @@ function trimHookPayload(data) {
|
||||
for (const k of DROP_RESPONSE[data.tool_name] || []) {
|
||||
if (typeof r[k] === "string" && r[k].length > 0) {
|
||||
delete r[k];
|
||||
hit.n++;
|
||||
dropped.add(`tool_response.${k}`);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +72,7 @@ function trimHookPayload(data) {
|
||||
for (const k of fileDrop) {
|
||||
if (typeof f[k] === "string" && f[k].length > 0) {
|
||||
delete f[k];
|
||||
hit.n++;
|
||||
dropped.add(`tool_response.file.${k}`);
|
||||
}
|
||||
}
|
||||
@@ -75,8 +80,8 @@ function trimHookPayload(data) {
|
||||
}
|
||||
out.tool_response = r;
|
||||
}
|
||||
if ("tool_response" in out) out.tool_response = capDeep(out.tool_response);
|
||||
if ("tool_input" in out) out.tool_input = capDeep(out.tool_input);
|
||||
if ("tool_response" in out) out.tool_response = capDeep(out.tool_response, undefined, hit);
|
||||
if ("tool_input" in out) out.tool_input = capDeep(out.tool_input, undefined, hit);
|
||||
if (dropped.size > 0) out._trimmed = [...dropped];
|
||||
return out;
|
||||
}
|
||||
@@ -84,7 +89,8 @@ function trimHookPayload(data) {
|
||||
/**
|
||||
* The SQL-side entry point (`trim_event_data(?)`, registered in `server/db.js`):
|
||||
* a JSON string in, a JSON string out. Short, null or unparseable input is
|
||||
* returned unchanged, and trimming is idempotent.
|
||||
* returned unchanged, and so is a payload with nothing to trim — re-serializing
|
||||
* it would round integers past 2^53. Trimming is idempotent.
|
||||
* @param {string|null} json
|
||||
* @returns {string|null}
|
||||
*/
|
||||
@@ -97,40 +103,57 @@ function trimEventData(json) {
|
||||
return json;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return json;
|
||||
return JSON.stringify(trimHookPayload(parsed));
|
||||
const hit = { n: 0 };
|
||||
const out = trimHookPayload(parsed, hit);
|
||||
// ponytail: a payload that IS trimmed is re-serialized whole, so a huge integer
|
||||
// elsewhere in it still rounds; splice the JSON text instead if one ever matters.
|
||||
return hit.n ? JSON.stringify(out) : json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims the rows stored before trimming existed, a chunk per write transaction
|
||||
* so a hook never waits long for the lock. Idempotent: a crash just resumes from
|
||||
* the start on the next run.
|
||||
* Trims the rows stored before trimming existed, a few rows per write
|
||||
* transaction so a hook never waits long for the lock: ids are picked first and
|
||||
* each row's payload is read alone, and a transaction ends after `chunk` rows or
|
||||
* once the payloads it read pass `maxBytes` — a Read of an image can hold MBs of
|
||||
* base64, and 200 of those in one go stalled the event loop. Idempotent: a crash
|
||||
* just resumes from the start on the next run.
|
||||
* @param {import("better-sqlite3").Database} db
|
||||
* @param {{chunk?: number}} [opts]
|
||||
* @param {{chunk?: number, maxBytes?: number}} [opts]
|
||||
* @returns {Promise<{rows: number, bytesSaved: number}>}
|
||||
*/
|
||||
function sweepEventPayloads(db, { chunk = 200 } = {}) {
|
||||
const select = db.prepare(
|
||||
"SELECT id, data FROM events WHERE id > ? AND length(data) > ? ORDER BY id LIMIT ?"
|
||||
);
|
||||
function sweepEventPayloads(db, { chunk = 200, maxBytes = 8 * 1024 * 1024 } = {}) {
|
||||
const pick = db
|
||||
.prepare("SELECT id FROM events WHERE id > ? AND length(data) > ? ORDER BY id LIMIT ?")
|
||||
.pluck();
|
||||
const read = db.prepare("SELECT data FROM events WHERE id = ?").pluck();
|
||||
const update = db.prepare("UPDATE events SET data = ? WHERE id = ?");
|
||||
const out = { rows: 0, bytesSaved: 0 };
|
||||
const trimChunk = db.transaction((rows) => {
|
||||
for (const r of rows) {
|
||||
const t = trimEventData(r.data);
|
||||
if (t === r.data) continue;
|
||||
update.run(t, r.id);
|
||||
out.rows++;
|
||||
out.bytesSaved += r.data.length - t.length;
|
||||
// Returns the last id it handled; the next pick starts after it.
|
||||
const trimChunk = db.transaction((ids) => {
|
||||
let bytes = 0;
|
||||
let last = ids[0];
|
||||
for (const id of ids) {
|
||||
last = id;
|
||||
const data = read.get(id);
|
||||
if (typeof data !== "string") continue;
|
||||
bytes += data.length;
|
||||
const t = trimEventData(data);
|
||||
if (t !== data) {
|
||||
update.run(t, id);
|
||||
out.rows++;
|
||||
out.bytesSaved += data.length - t.length;
|
||||
}
|
||||
if (bytes >= maxBytes) break;
|
||||
}
|
||||
return last;
|
||||
});
|
||||
let lastId = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const step = () => {
|
||||
try {
|
||||
const rows = select.all(lastId, SKIP_UNDER, chunk);
|
||||
if (rows.length === 0) return resolve(out);
|
||||
trimChunk(rows);
|
||||
lastId = rows[rows.length - 1].id;
|
||||
const ids = pick.all(lastId, SKIP_UNDER, chunk);
|
||||
if (ids.length === 0) return resolve(out);
|
||||
lastId = trimChunk(ids);
|
||||
setImmediate(step);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file pty-attach.js
|
||||
* @description Bridges one WebSocket connection to an `@lydell/node-pty`-backed
|
||||
* `tmux attach-session` process. Binary WS frames carry raw PTY bytes in
|
||||
* `tmux attach-session` process, run with the live tmux server's own binary
|
||||
* (`attachBinary`). Binary WS frames carry raw PTY bytes in
|
||||
* both directions; text WS frames carry small JSON control messages
|
||||
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
|
||||
* session ends). Multiple browser tabs each get their own PTY attach
|
||||
@@ -10,6 +11,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const tmux = require("./tmux");
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
|
||||
@@ -34,6 +36,26 @@ function __setSpawnImpl(fn) {
|
||||
spawnImpl = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The binary to run `tmux attach` with: the RUNNING server's own executable
|
||||
* (`/proc/<pid>/exe`, which still works after a package upgrade replaced the
|
||||
* file), else `tmux` on PATH. A client must match its server — tmux installed
|
||||
* in a newer version while the server runs makes PATH's client fail every
|
||||
* attach with "open terminal failed: not a terminal", though every other tmux
|
||||
* command still works. Linux only; elsewhere PATH's tmux as before.
|
||||
*/
|
||||
function attachBinary() {
|
||||
const pid = tmux.serverPid();
|
||||
if (!pid) return "tmux";
|
||||
const exe = `/proc/${pid}/exe`;
|
||||
try {
|
||||
fs.accessSync(exe, fs.constants.X_OK);
|
||||
return exe;
|
||||
} catch {
|
||||
return "tmux";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
||||
* `tmux attach-session -t <runId>` per call.
|
||||
@@ -48,7 +70,7 @@ function attach(ws, runId, { cols, rows }) {
|
||||
} catch {
|
||||
/* session gone or tmux unavailable — attach below will surface that */
|
||||
}
|
||||
const pty = spawnImpl("tmux", ["attach-session", "-t", `=${runId}`], {
|
||||
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", `=${runId}`], {
|
||||
name: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
|
||||
@@ -451,6 +451,11 @@ function spawnRun(args) {
|
||||
// journals and the router layer `-p <name>` loads live there — and the
|
||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||
// Claude Code inline even under `"tui": "fullscreen"`: on the alternate
|
||||
// screen tmux keeps no history and draws no pane scrollbar, so the
|
||||
// dashboard terminal had nothing to drag. The user's own terminals keep
|
||||
// their setting — this reaches only panes AgentHub starts.
|
||||
...(tool === "claude" ? { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" } : {}),
|
||||
...routerSpawn.env,
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file Runs the dashboard's heavy read-only aggregations in a worker thread
|
||||
* on its own SQLite connection, so a slow one never blocks the event loop that
|
||||
* serves every other page, hook and websocket. Identical requests in flight are
|
||||
* shared, a job over `AGENTHUB_READ_TIMEOUT_MS` (default 60 s) is answered with
|
||||
* `EREADTIMEOUT` and its worker replaced, and `AGENTHUB_READ_WORKER=0` — or a
|
||||
* worker that cannot start — runs the job inline as before. This file is also
|
||||
* the worker's entry point.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
|
||||
|
||||
/** Read-only jobs by name: (connection, args) → plain data. */
|
||||
const JOBS = {
|
||||
workflows: (conn, a) =>
|
||||
require("./workflow-stats").computeWorkflowData(conn, a.status || null, a.sections || null),
|
||||
};
|
||||
|
||||
function timeoutMs() {
|
||||
return Number(process.env.AGENTHUB_READ_TIMEOUT_MS) || 60_000;
|
||||
}
|
||||
|
||||
function codedError(message, code) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
// ponytail: one worker, so heavy reads queue behind each other (the event loop
|
||||
// stays free either way); a small pool if concurrent heavy reads ever pile up.
|
||||
let worker = null;
|
||||
let seq = 0;
|
||||
const pending = new Map(); // id → { resolve, reject, timer, w }
|
||||
const inflight = new Map(); // job+args → Promise
|
||||
|
||||
function spawn(dbPath) {
|
||||
const w = new Worker(__filename, { workerData: { agenthubReadWorker: true, dbPath } });
|
||||
w.on("message", ({ id, result, error }) => {
|
||||
const p = pending.get(id);
|
||||
if (!p) return;
|
||||
pending.delete(id);
|
||||
clearTimeout(p.timer);
|
||||
if (error) p.reject(codedError(error.message, error.code));
|
||||
else p.resolve(result);
|
||||
});
|
||||
const fail = (err) => {
|
||||
if (worker === w) worker = null;
|
||||
for (const [id, p] of pending) {
|
||||
if (p.w !== w) continue;
|
||||
pending.delete(id);
|
||||
clearTimeout(p.timer);
|
||||
p.reject(err);
|
||||
}
|
||||
};
|
||||
w.on("error", fail);
|
||||
w.on("exit", (code) => fail(codedError(`read worker exited (${code})`, "EREADWORKER")));
|
||||
w.unref(); // after the listeners: adding a "message" listener refs it again
|
||||
return w;
|
||||
}
|
||||
|
||||
function runInline(job, args) {
|
||||
const { db } = require("../db");
|
||||
return Promise.resolve().then(() => JOBS[job](db, args));
|
||||
}
|
||||
|
||||
function dispatch(job, args) {
|
||||
if (process.env.AGENTHUB_READ_WORKER === "0") return runInline(job, args);
|
||||
try {
|
||||
if (!worker) worker = spawn(require("../db").DB_PATH);
|
||||
} catch {
|
||||
return runInline(job, args);
|
||||
}
|
||||
const w = worker;
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const ms = timeoutMs();
|
||||
const timer = setTimeout(() => {
|
||||
// The stuck query keeps its thread until SQLite returns; the next job
|
||||
// gets a fresh worker. Jobs queued behind it are rejected by `exit`.
|
||||
pending.delete(id);
|
||||
if (worker === w) worker = null;
|
||||
w.terminate();
|
||||
reject(codedError(`read query took over ${ms} ms`, "EREADTIMEOUT"));
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
pending.set(id, { resolve, reject, timer, w });
|
||||
w.postMessage({ id, job, args });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a named read job off the event loop. Concurrent calls with the same job
|
||||
* and args share one run.
|
||||
*
|
||||
* @param {keyof JOBS} job
|
||||
* @param {object} [args] structured-cloneable
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function runRead(job, args = {}) {
|
||||
if (!JOBS[job]) return Promise.reject(codedError(`unknown read job ${job}`, "EBADJOB"));
|
||||
const key = `${job}:${JSON.stringify(args)}`;
|
||||
const hit = inflight.get(key);
|
||||
if (hit) return hit;
|
||||
const p = dispatch(job, args).finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
if (!isMainThread && workerData?.agenthubReadWorker) {
|
||||
// Same driver order as server/db.js: better-sqlite3, else node:sqlite.
|
||||
let conn;
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
conn = new Database(workerData.dbPath);
|
||||
} catch {
|
||||
const Database = require("../compat-sqlite");
|
||||
conn = new Database(workerData.dbPath);
|
||||
}
|
||||
conn.pragma("busy_timeout = 5000");
|
||||
conn.pragma("query_only = ON"); // this connection can never write
|
||||
parentPort.on("message", ({ id, job, args }) => {
|
||||
try {
|
||||
parentPort.postMessage({ id, result: JOBS[job](conn, args) });
|
||||
} catch (err) {
|
||||
parentPort.postMessage({ id, error: { message: err.message, code: err.code } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runRead };
|
||||
+53
-3
@@ -49,6 +49,16 @@ function hasSession(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The running tmux server's pid, or null when none answers. */
|
||||
function serverPid() {
|
||||
try {
|
||||
const pid = String(execImpl(["display-message", "-p", "#{pid}"])).trim();
|
||||
return /^\d+$/.test(pid) ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `-e NAME=value` pairs for tmux, in a stable order. */
|
||||
function envFlags(env) {
|
||||
const flags = [];
|
||||
@@ -77,8 +87,29 @@ function newSession({ name, cwd, argv, env }) {
|
||||
// Called on every attach (not just at creation) so a session started before
|
||||
// this option existed — or before a server restart picked up the change —
|
||||
// still gets it; `set-option` is idempotent, so re-running it is harmless.
|
||||
// The status bar goes off here too: the dashboard's tab bar already names the
|
||||
// pane, and tmux's green line only eats a row of the terminal.
|
||||
function enableMouse(name) {
|
||||
execImpl(["set-option", "-t", pane(name), "mouse", "on"]);
|
||||
execImpl(["set-option", "-t", pane(name), "status", "off"]);
|
||||
// A draggable scrollbar beside the pane (mouse mode makes it drag). Only a
|
||||
// tmux SERVER of 3.7+ knows the option; an older one refuses it, which is
|
||||
// fine — the pane just has none (`/agenthub-doctor` says how to upgrade).
|
||||
try {
|
||||
execImpl(["set-option", "-t", pane(name), "pane-scrollbars", "on"]);
|
||||
// tmux's default (`fg=white,bg=black,width=1`) is a one-column track in
|
||||
// ANSI black — the terminal theme's border colour, almost the background —
|
||||
// so it reads as no scrollbar at all. Two columns, bright slider.
|
||||
execImpl([
|
||||
"set-option",
|
||||
"-t",
|
||||
pane(name),
|
||||
"pane-scrollbars-style",
|
||||
"fg=brightwhite,bg=black,width=2,pad=0",
|
||||
]);
|
||||
} catch {
|
||||
/* tmux < 3.7 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,11 +205,27 @@ function listSessions(prefix) {
|
||||
}
|
||||
|
||||
function isTmuxAvailable() {
|
||||
return clientVersion() !== null;
|
||||
}
|
||||
|
||||
/** The tmux binary's version (`3.7c`, `next-3.8`), null when it is absent. */
|
||||
function clientVersion() {
|
||||
try {
|
||||
execImpl(["-V"]);
|
||||
return true;
|
||||
return execImpl(["-V"])
|
||||
.trim()
|
||||
.replace(/^tmux\s+/, "");
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The RUNNING server's version, null when none runs. A server started before
|
||||
* an upgrade keeps its old version until every tmux session has closed. */
|
||||
function serverVersion() {
|
||||
try {
|
||||
return execImpl(["display-message", "-p", "#{version}"]).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +240,9 @@ module.exports = {
|
||||
killSession,
|
||||
listSessions,
|
||||
isTmuxAvailable,
|
||||
clientVersion,
|
||||
serverVersion,
|
||||
serverPid,
|
||||
__setExecImpl,
|
||||
__reset,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* @file The aggregations behind GET /api/workflows, as pure reads over a given
|
||||
* SQLite connection — so they can run in the read worker
|
||||
* (`server/lib/read-worker.js`) on its own connection instead of blocking the
|
||||
* server's event loop, or inline on the main one as a fallback.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// ponytail: one module-level connection, set at the top of each
|
||||
// computeWorkflowData call; every query here is synchronous, so two calls never
|
||||
// interleave. Pass `db` through each function if that ever changes.
|
||||
let db;
|
||||
|
||||
// ── Helper: compute session duration in seconds ──
|
||||
function durationSec(s) {
|
||||
if (!s.started_at) return 0;
|
||||
const end = s.ended_at || new Date().toISOString();
|
||||
return Math.max(0, (new Date(end) - new Date(s.started_at)) / 1000);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Data-fetching functions
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Build a SQL WHERE clause for session status filtering.
|
||||
* Returns { clause, params } where clause is either empty or " AND s.status = ?".
|
||||
* Use `sessionAlias` to match the table alias used in your query (default "s").
|
||||
*/
|
||||
function statusClause(statusFilter, alias = "s") {
|
||||
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
|
||||
return { clause: ` AND ${alias}.status = ?`, params: [statusFilter] };
|
||||
}
|
||||
|
||||
/** Same but for agents table joins where we need to filter via session_id */
|
||||
function sessionIdFilter(statusFilter) {
|
||||
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
|
||||
return {
|
||||
clause: " AND session_id IN (SELECT id FROM sessions WHERE status = ?)",
|
||||
params: [statusFilter],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Each tool event paired with the next tool event of the same session, as
|
||||
* `(source, target)` rows. One window pass: a correlated `MIN(id) … id > ?`
|
||||
* subquery per event is quadratic in a session's size unless an index orders
|
||||
* a session's events by id, and it froze the server for minutes on a big one.
|
||||
*/
|
||||
function toolPairsSql(sessionClause) {
|
||||
return `SELECT source, target FROM (
|
||||
SELECT tool_name AS source,
|
||||
LEAD(tool_name) OVER (PARTITION BY session_id ORDER BY id) AS target
|
||||
FROM events WHERE tool_name IS NOT NULL${sessionClause}
|
||||
) WHERE target IS NOT NULL`;
|
||||
}
|
||||
|
||||
function getWorkflowStats(statusFilter, transitions) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
const totalAgents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE 1=1${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const totalSubagents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'subagent'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
|
||||
// Average subagents per session
|
||||
const avgSubagents = totalSessions > 0 ? +(totalSubagents / totalSessions).toFixed(1) : 0;
|
||||
|
||||
// Agent success rate
|
||||
const completedAgents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'completed'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const errorAgents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'error'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const finishedAgents = completedAgents + errorAgents;
|
||||
const successRate =
|
||||
finishedAgents > 0 ? +((completedAgents / finishedAgents) * 100).toFixed(1) : 100;
|
||||
|
||||
// Average max depth per session
|
||||
const depthRows = db
|
||||
.prepare(
|
||||
`WITH RECURSIVE agent_depth AS (
|
||||
SELECT id, session_id, parent_agent_id, 0 as depth FROM agents WHERE parent_agent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT a.id, a.session_id, a.parent_agent_id, ad.depth + 1
|
||||
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
|
||||
)
|
||||
SELECT session_id, MAX(depth) as max_depth FROM agent_depth
|
||||
WHERE 1=1${sf.clause}
|
||||
GROUP BY session_id`
|
||||
)
|
||||
.all(...sf.params);
|
||||
const avgDepth =
|
||||
depthRows.length > 0
|
||||
? +(depthRows.reduce((s, r) => s + r.max_depth, 0) / depthRows.length).toFixed(1)
|
||||
: 0;
|
||||
|
||||
// Average session duration
|
||||
const sessions = db
|
||||
.prepare(`SELECT started_at, ended_at FROM sessions s WHERE ended_at IS NOT NULL${ss.clause}`)
|
||||
.all(...ss.params);
|
||||
const totalDuration = sessions.reduce((s, sess) => s + durationSec(sess), 0);
|
||||
const avgDurationSec = sessions.length > 0 ? Math.round(totalDuration / sessions.length) : 0;
|
||||
|
||||
// Total compactions
|
||||
const totalCompactions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const avgCompactions = totalSessions > 0 ? +(totalCompactions / totalSessions).toFixed(1) : 0;
|
||||
|
||||
// Most common tool flow (top 2-tool sequence): the head of the ranked list
|
||||
const topFlow = transitions[0];
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
totalAgents,
|
||||
totalSubagents,
|
||||
avgSubagents,
|
||||
successRate,
|
||||
avgDepth,
|
||||
avgDurationSec,
|
||||
totalCompactions,
|
||||
avgCompactions,
|
||||
topFlow: topFlow
|
||||
? { source: topFlow.source, target: topFlow.target, count: topFlow.value }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getOrchestrationData(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Count sessions
|
||||
const sessionCount = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
|
||||
// Main agents count
|
||||
const mainCount = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'main'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
|
||||
// Subagent types with counts and parent info
|
||||
const subagentTypes = db
|
||||
.prepare(
|
||||
`SELECT subagent_type, COUNT(*) as count,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors
|
||||
FROM agents WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
|
||||
GROUP BY subagent_type ORDER BY count DESC`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Edges: parent_subagent_type -> child_subagent_type with frequency
|
||||
const edges = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COALESCE(p.subagent_type, 'main') as source,
|
||||
a.subagent_type as target,
|
||||
COUNT(*) as weight
|
||||
FROM agents a
|
||||
LEFT JOIN agents p ON a.parent_agent_id = p.id
|
||||
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
|
||||
GROUP BY source, target
|
||||
ORDER BY weight DESC`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Outcome counts
|
||||
const outcomes = db
|
||||
.prepare(
|
||||
`SELECT status, COUNT(*) as count FROM agents
|
||||
WHERE status IN ('completed', 'error')${sf.clause}
|
||||
GROUP BY status`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Compaction agents (context compressions per session)
|
||||
const compactions = db
|
||||
.prepare(
|
||||
`SELECT session_id, COUNT(*) as count
|
||||
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
|
||||
GROUP BY session_id`
|
||||
)
|
||||
.all(...sf.params);
|
||||
const totalCompactions = compactions.reduce((s, r) => s + r.count, 0);
|
||||
const sessionsWithCompactions = compactions.length;
|
||||
|
||||
return {
|
||||
sessionCount,
|
||||
mainCount,
|
||||
subagentTypes,
|
||||
edges,
|
||||
outcomes,
|
||||
compactions: { total: totalCompactions, sessions: sessionsWithCompactions },
|
||||
};
|
||||
}
|
||||
|
||||
/** Tool-to-tool transitions (next tool in same session), most frequent first. */
|
||||
function getToolTransitions(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT source, target, COUNT(*) as value
|
||||
FROM (${toolPairsSql(sf.clause)})
|
||||
GROUP BY source, target
|
||||
ORDER BY value DESC
|
||||
LIMIT 50`
|
||||
)
|
||||
.all(...sf.params);
|
||||
}
|
||||
|
||||
function getToolFlowData(statusFilter, transitions) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
|
||||
// Tool counts for sizing nodes
|
||||
const toolCounts = db
|
||||
.prepare(
|
||||
`SELECT tool_name, COUNT(*) as count FROM events
|
||||
WHERE tool_name IS NOT NULL${sf.clause}
|
||||
GROUP BY tool_name ORDER BY count DESC LIMIT 15`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
return { transitions, toolCounts };
|
||||
}
|
||||
|
||||
function getSubagentEffectiveness(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
|
||||
const types = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
a.subagent_type,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN a.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN a.status = 'error' THEN 1 ELSE 0 END) as errors,
|
||||
COUNT(DISTINCT a.session_id) as sessions
|
||||
FROM agents a
|
||||
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
|
||||
GROUP BY a.subagent_type
|
||||
ORDER BY total DESC
|
||||
LIMIT 12`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Get token usage per subagent type (approximate via session token totals)
|
||||
// Also get average duration per type
|
||||
const withMetrics = types.map((t) => {
|
||||
const durRow = db
|
||||
.prepare(
|
||||
// Clamp each row's duration to >= 0 before averaging. The #156 startup
|
||||
// repair only heals compaction rows; any other subagent type whose
|
||||
// ended_at < started_at (clock skew, replayed/synced transcripts) would
|
||||
// otherwise drag this average negative. MAX(0, …) mirrors the Math.max(0)
|
||||
// guard durationSec() already applies to every other duration here.
|
||||
`SELECT AVG(
|
||||
CASE WHEN ended_at IS NOT NULL THEN
|
||||
MAX(0, (julianday(ended_at) - julianday(started_at)) * 86400)
|
||||
ELSE NULL END
|
||||
) as avg_duration
|
||||
FROM agents WHERE subagent_type = ? AND type = 'subagent'${sf.clause}`
|
||||
)
|
||||
.get(t.subagent_type, ...sf.params);
|
||||
|
||||
// Weekly trend: count per day-of-week (Mon–Sun) over last 8 weeks.
|
||||
// SQLite strftime('%w') → 0=Sun, 1=Mon, ..., 6=Sat.
|
||||
// Frontend expects index 0=Mon → 6=Sun, so remap with (dow + 6) % 7.
|
||||
const trendRows = db
|
||||
.prepare(
|
||||
`SELECT CAST(strftime('%w', started_at) AS INTEGER) as dow, COUNT(*) as count
|
||||
FROM agents WHERE subagent_type = ? AND type = 'subagent'
|
||||
AND started_at >= date('now', '-56 days')${sf.clause}
|
||||
GROUP BY dow ORDER BY dow ASC`
|
||||
)
|
||||
.all(t.subagent_type, ...sf.params);
|
||||
|
||||
// Build 7-slot array: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
const trendByDay = [0, 0, 0, 0, 0, 0, 0];
|
||||
for (const row of trendRows) {
|
||||
const idx = (row.dow + 6) % 7; // Sun(0)→6, Mon(1)→0, Tue(2)→1, ...
|
||||
trendByDay[idx] = row.count;
|
||||
}
|
||||
|
||||
return {
|
||||
...t,
|
||||
successRate:
|
||||
t.completed + t.errors > 0
|
||||
? +((t.completed / (t.completed + t.errors)) * 100).toFixed(1)
|
||||
: 100,
|
||||
avgDuration: durRow?.avg_duration ? Math.round(durRow.avg_duration) : null,
|
||||
trend: trendByDay,
|
||||
};
|
||||
});
|
||||
|
||||
return withMetrics;
|
||||
}
|
||||
|
||||
function getWorkflowPatterns(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Get ordered subagent sequences per session
|
||||
const sessions = db
|
||||
.prepare(
|
||||
`SELECT session_id, GROUP_CONCAT(subagent_type, '→') as sequence
|
||||
FROM (
|
||||
SELECT session_id, subagent_type
|
||||
FROM agents
|
||||
WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
|
||||
ORDER BY session_id, started_at ASC
|
||||
)
|
||||
GROUP BY session_id
|
||||
HAVING COUNT(*) >= 2`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Count pattern frequencies
|
||||
const patternCounts = {};
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
for (const row of sessions) {
|
||||
const seq = row.sequence;
|
||||
patternCounts[seq] = (patternCounts[seq] || 0) + 1;
|
||||
}
|
||||
|
||||
// Also count 2-step and 3-step sub-patterns
|
||||
for (const row of sessions) {
|
||||
const steps = row.sequence.split("→");
|
||||
// 2-step windows
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
const sub = steps.slice(i, i + 2).join("→");
|
||||
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
|
||||
}
|
||||
// 3-step windows
|
||||
for (let i = 0; i < steps.length - 2; i++) {
|
||||
const sub = steps.slice(i, i + 3).join("→");
|
||||
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: keep only patterns where the full sequence count >= 2-step count
|
||||
// Sort by frequency, take top 10
|
||||
const sorted = Object.entries(patternCounts)
|
||||
.filter(([, count]) => count >= 2)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([pattern, count]) => ({
|
||||
steps: pattern.split("→"),
|
||||
count,
|
||||
percentage: totalSessions > 0 ? +((count / totalSessions) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Also track solo sessions (no subagents)
|
||||
const soloCount = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as c FROM sessions s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.type = 'subagent')${ss.clause}`
|
||||
)
|
||||
.get(...ss.params).c;
|
||||
|
||||
return {
|
||||
patterns: sorted,
|
||||
soloSessionCount: soloCount,
|
||||
soloPercentage: totalSessions > 0 ? +((soloCount / totalSessions) * 100).toFixed(1) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getModelDelegation(statusFilter) {
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Model usage for main agents
|
||||
const mainModels = db
|
||||
.prepare(
|
||||
`SELECT s.model, COUNT(DISTINCT a.id) as agent_count, COUNT(DISTINCT s.id) as session_count
|
||||
FROM agents a JOIN sessions s ON a.session_id = s.id
|
||||
WHERE a.type = 'main' AND s.model IS NOT NULL${ss.clause}
|
||||
GROUP BY s.model ORDER BY agent_count DESC`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
// Model usage for subagents (via session model — best approximation)
|
||||
const subagentModels = db
|
||||
.prepare(
|
||||
`SELECT s.model, COUNT(a.id) as agent_count
|
||||
FROM agents a JOIN sessions s ON a.session_id = s.id
|
||||
WHERE a.type = 'subagent' AND s.model IS NOT NULL${ss.clause}
|
||||
GROUP BY s.model ORDER BY agent_count DESC`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
// Token cost per model — filter via session_id on token_usage table
|
||||
const sfToken = sessionIdFilter(statusFilter);
|
||||
const tokensByModel = db
|
||||
.prepare(
|
||||
`SELECT model,
|
||||
SUM(input_tokens + baseline_input) as input_tokens,
|
||||
SUM(output_tokens + baseline_output) as output_tokens,
|
||||
SUM(cache_read_tokens + baseline_cache_read) as cache_read_tokens,
|
||||
SUM(cache_write_tokens + baseline_cache_write) as cache_write_tokens
|
||||
FROM token_usage WHERE 1=1${sfToken.clause}
|
||||
GROUP BY model ORDER BY (input_tokens + output_tokens) DESC`
|
||||
)
|
||||
.all(...sfToken.params);
|
||||
|
||||
return { mainModels, subagentModels, tokensByModel };
|
||||
}
|
||||
|
||||
function getErrorPropagation(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Error count by depth — include both agent-level errors (status = 'error')
|
||||
// AND session-level errors (session status = 'error' mapped to depth 0 for main agent).
|
||||
const errorsByDepth = db
|
||||
.prepare(
|
||||
`WITH RECURSIVE agent_depth AS (
|
||||
SELECT id, session_id, subagent_type, status, 0 as depth
|
||||
FROM agents WHERE parent_agent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT a.id, a.session_id, a.subagent_type, a.status, ad.depth + 1
|
||||
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
|
||||
)
|
||||
SELECT depth, COUNT(*) as count FROM agent_depth
|
||||
WHERE status = 'error'${sf.clause}
|
||||
GROUP BY depth ORDER BY depth ASC`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Also count sessions that ended in error but whose main agent wasn't marked error.
|
||||
// Map these to depth 0 (session-level errors: quota limits, crashes, etc.)
|
||||
const sessionErrorsNotInAgents = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as c FROM sessions s
|
||||
WHERE s.status = 'error'${ss.clause}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.status = 'error'
|
||||
)`
|
||||
)
|
||||
.get(...ss.params).c;
|
||||
|
||||
if (sessionErrorsNotInAgents > 0) {
|
||||
const existing = errorsByDepth.find((d) => d.depth === 0);
|
||||
if (existing) {
|
||||
existing.count += sessionErrorsNotInAgents;
|
||||
} else {
|
||||
errorsByDepth.unshift({ depth: 0, count: sessionErrorsNotInAgents });
|
||||
}
|
||||
}
|
||||
|
||||
// Error-prone subagent types — from agent errors + from error events on subagents
|
||||
const errorTypes = db
|
||||
.prepare(
|
||||
`SELECT subagent_type, COUNT(*) as count
|
||||
FROM agents WHERE status = 'error' AND subagent_type IS NOT NULL${sf.clause}
|
||||
GROUP BY subagent_type ORDER BY count DESC LIMIT 5`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Also capture error events (Stop with error summary, API errors from transcripts)
|
||||
const eventErrors = db
|
||||
.prepare(
|
||||
`SELECT e.summary, COUNT(*) as count
|
||||
FROM events e
|
||||
WHERE ((e.event_type = 'Stop' AND e.summary LIKE 'Error in%')
|
||||
OR e.event_type = 'APIError')${sf.clause.replace("session_id", "e.session_id")}
|
||||
GROUP BY e.summary ORDER BY count DESC LIMIT 10`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Error rate per session (sessions with error status OR sessions with error events)
|
||||
const sessionsWithErrors = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT id) as c FROM (
|
||||
SELECT id FROM sessions s WHERE s.status = 'error'${ss.clause}
|
||||
UNION
|
||||
SELECT DISTINCT session_id as id FROM agents WHERE status = 'error'${sf.clause}
|
||||
UNION
|
||||
SELECT DISTINCT session_id as id FROM events
|
||||
WHERE ((event_type = 'Stop' AND summary LIKE 'Error in%')
|
||||
OR event_type = 'APIError')${sf.clause}
|
||||
)`
|
||||
)
|
||||
.get(...ss.params, ...sf.params, ...sf.params).c;
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
|
||||
return {
|
||||
byDepth: errorsByDepth,
|
||||
byType: errorTypes,
|
||||
eventErrors,
|
||||
sessionsWithErrors,
|
||||
totalSessions,
|
||||
errorRate: totalSessions > 0 ? +((sessionsWithErrors / totalSessions) * 100).toFixed(1) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getConcurrencyData(statusFilter) {
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// For aggregate: average agent types per position in session timeline
|
||||
// Get agent start/end as fraction of session duration per session
|
||||
const lanes = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
a.id, a.name, a.type, a.subagent_type, a.status,
|
||||
a.started_at, a.ended_at, a.session_id,
|
||||
s.started_at as session_start, s.ended_at as session_end
|
||||
FROM agents a
|
||||
JOIN sessions s ON a.session_id = s.id
|
||||
WHERE s.ended_at IS NOT NULL${ss.clause}
|
||||
ORDER BY a.started_at ASC
|
||||
LIMIT 2000`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
// Build aggregate: for each subagent_type, average start% and end%
|
||||
const typeAgg = {};
|
||||
for (const lane of lanes) {
|
||||
const sessStart = new Date(lane.session_start).getTime();
|
||||
const sessEnd = new Date(lane.session_end).getTime();
|
||||
const sessDur = sessEnd - sessStart;
|
||||
if (sessDur <= 0) continue;
|
||||
|
||||
const agStart = new Date(lane.started_at).getTime();
|
||||
const agEnd = lane.ended_at ? new Date(lane.ended_at).getTime() : sessEnd;
|
||||
|
||||
const startPct = Math.max(0, Math.min(1, (agStart - sessStart) / sessDur));
|
||||
const endPct = Math.max(0, Math.min(1, (agEnd - sessStart) / sessDur));
|
||||
|
||||
const key = lane.type === "main" ? "Main Agent" : lane.subagent_type || "unknown";
|
||||
if (!typeAgg[key]) typeAgg[key] = { starts: [], ends: [], status: lane.status };
|
||||
typeAgg[key].starts.push(startPct);
|
||||
typeAgg[key].ends.push(endPct);
|
||||
}
|
||||
|
||||
// Average start/end per type
|
||||
const aggregateLanes = Object.entries(typeAgg)
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
avgStart: +(data.starts.reduce((s, v) => s + v, 0) / data.starts.length).toFixed(3),
|
||||
avgEnd: +(data.ends.reduce((s, v) => s + v, 0) / data.ends.length).toFixed(3),
|
||||
count: data.starts.length,
|
||||
}))
|
||||
.sort((a, b) => a.avgStart - b.avgStart);
|
||||
|
||||
return { aggregateLanes };
|
||||
}
|
||||
|
||||
function getSessionComplexity(statusFilter) {
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
s.id, s.name, s.status, s.started_at, s.ended_at, s.model,
|
||||
COUNT(a.id) as agent_count,
|
||||
SUM(CASE WHEN a.type = 'subagent' THEN 1 ELSE 0 END) as subagent_count
|
||||
FROM sessions s
|
||||
LEFT JOIN agents a ON a.session_id = s.id
|
||||
WHERE 1=1${ss.clause}
|
||||
GROUP BY s.id
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT 200`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
const sessions = rows.map((r) => {
|
||||
const dur = durationSec(r);
|
||||
// Get token count for this session
|
||||
const tokens = db
|
||||
.prepare(
|
||||
`SELECT SUM(input_tokens + baseline_input + output_tokens + baseline_output +
|
||||
cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write) as total
|
||||
FROM token_usage WHERE session_id = ?`
|
||||
)
|
||||
.get(r.id);
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
duration: Math.round(dur),
|
||||
agentCount: r.agent_count,
|
||||
subagentCount: r.subagent_count,
|
||||
totalTokens: tokens?.total || 0,
|
||||
model: r.model,
|
||||
};
|
||||
});
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function getCompactionImpact(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Total compactions
|
||||
const totalCompactions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
|
||||
// Total baseline tokens (tokens "recovered" through compaction)
|
||||
const recovered = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
SUM(baseline_input + baseline_output + baseline_cache_read + baseline_cache_write) as total
|
||||
FROM token_usage WHERE 1=1${sf.clause}`
|
||||
)
|
||||
.get(...sf.params);
|
||||
|
||||
// Compactions per session distribution
|
||||
const perSession = db
|
||||
.prepare(
|
||||
`SELECT session_id, COUNT(*) as compactions
|
||||
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
|
||||
GROUP BY session_id ORDER BY compactions DESC LIMIT 50`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Sessions with compactions vs without
|
||||
const sessionsWithCompactions = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT session_id) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`
|
||||
)
|
||||
.get(...sf.params).c;
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
|
||||
return {
|
||||
totalCompactions,
|
||||
tokensRecovered: recovered?.total || 0,
|
||||
perSession,
|
||||
sessionsWithCompactions,
|
||||
totalSessions,
|
||||
};
|
||||
}
|
||||
|
||||
function getAgentCooccurrence(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
|
||||
// Directed: which agent type runs AFTER which other type in the same session
|
||||
// a1 started before a2 → edge a1 → a2 with count
|
||||
const pairs = db
|
||||
.prepare(
|
||||
`SELECT a1.subagent_type as source, a2.subagent_type as target,
|
||||
COUNT(*) as weight
|
||||
FROM agents a1
|
||||
JOIN agents a2 ON a1.session_id = a2.session_id
|
||||
AND a1.started_at < a2.started_at
|
||||
AND a1.id != a2.id
|
||||
WHERE a1.type = 'subagent' AND a2.type = 'subagent'
|
||||
AND a1.subagent_type IS NOT NULL AND a2.subagent_type IS NOT NULL
|
||||
AND a1.subagent_type != 'compaction' AND a2.subagent_type != 'compaction'${sf.clause.replace("session_id", "a1.session_id")}
|
||||
GROUP BY a1.subagent_type, a2.subagent_type
|
||||
HAVING weight >= 2
|
||||
ORDER BY weight DESC
|
||||
LIMIT 40`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The bundle's sections, in response order. */
|
||||
const WORKFLOW_SECTIONS = [
|
||||
"stats",
|
||||
"orchestration",
|
||||
"toolFlow",
|
||||
"effectiveness",
|
||||
"patterns",
|
||||
"modelDelegation",
|
||||
"errorPropagation",
|
||||
"concurrency",
|
||||
"complexity",
|
||||
"compaction",
|
||||
"cooccurrence",
|
||||
];
|
||||
|
||||
/**
|
||||
* The GET /api/workflows bundle, or only the named sections of it.
|
||||
*
|
||||
* @param {object} conn SQLite connection (compat-sqlite Database)
|
||||
* @param {string|null} statusFilter "active"/"completed"/…, or null/"all"
|
||||
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
|
||||
* @returns {object}
|
||||
*/
|
||||
function computeWorkflowData(conn, statusFilter, sections = null) {
|
||||
db = conn;
|
||||
const want = (k) => !sections || sections.includes(k);
|
||||
// stats.topFlow is the head of toolFlow.transitions: one window pass for both.
|
||||
const transitions = want("stats") || want("toolFlow") ? getToolTransitions(statusFilter) : null;
|
||||
const build = {
|
||||
stats: () => getWorkflowStats(statusFilter, transitions),
|
||||
orchestration: () => getOrchestrationData(statusFilter),
|
||||
toolFlow: () => getToolFlowData(statusFilter, transitions),
|
||||
effectiveness: () => getSubagentEffectiveness(statusFilter),
|
||||
patterns: () => getWorkflowPatterns(statusFilter),
|
||||
modelDelegation: () => getModelDelegation(statusFilter),
|
||||
errorPropagation: () => getErrorPropagation(statusFilter),
|
||||
concurrency: () => getConcurrencyData(statusFilter),
|
||||
complexity: () => getSessionComplexity(statusFilter),
|
||||
compaction: () => getCompactionImpact(statusFilter),
|
||||
cooccurrence: () => getAgentCooccurrence(statusFilter),
|
||||
};
|
||||
const data = {};
|
||||
for (const k of WORKFLOW_SECTIONS) if (want(k)) data[k] = build[k]();
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = { computeWorkflowData, WORKFLOW_SECTIONS, sessionIdFilter, statusClause };
|
||||
@@ -1140,7 +1140,7 @@ const paths = {
|
||||
tags: ["Settings"],
|
||||
summary: "Restore (import) a previously exported data bundle",
|
||||
description:
|
||||
'Restores a bundle produced by GET /api/settings/export. Supply it either as `multipart/form-data` with a single `file` field (browser upload) or as a JSON body `{ "path": "<absolute path>" }` (the server reads the file from disk — used by the `agenthub import-data` CLI, and it also sidesteps the global 1 MB JSON body cap for large bundles). The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.',
|
||||
"Restores a bundle produced by GET /api/settings/export, uploaded as `multipart/form-data` with a single `file` field (the dashboard's Restore button and the `agenthub import-data` CLI both upload it; the server never reads a path named by a request). Same-origin only. At most 512 MB (`413 FILE_TOO_LARGE`); a file that is not JSON or not an export answers `400 INVALID_FORMAT` without echoing its text, no file `400 NO_FILE`. The restore is idempotent and NON-DESTRUCTIVE: it is session-atomic, so a session already present (matched by its UUID) is skipped whole together with its agents/events/token_usage/workflows, and independent config rows (dashboard_runs, alert_rules, model_pricing) are inserted only when absent. Restored event payloads are trimmed like every event insert (`trim_event_data`). Nothing existing is overwritten — ideal for consolidating several machines into one dashboard. The response reports per-table counts.",
|
||||
operationId: "importData",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1153,20 +1153,6 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"Absolute path (server-side) to an export .json file. `~` is expanded.",
|
||||
},
|
||||
},
|
||||
},
|
||||
example: { path: "/Users/you/agenthub-export-2026-06-26.json" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
@@ -1194,8 +1180,11 @@ const paths = {
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Missing file, invalid JSON, or unrecognized export format",
|
||||
description:
|
||||
"Missing file (NO_FILE), invalid JSON or unrecognized export format (INVALID_FORMAT)",
|
||||
},
|
||||
403: { description: "Cross-origin request (EBADORIGIN)" },
|
||||
413: { description: "The file is larger than 512 MB (FILE_TOO_LARGE)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1620,9 +1609,19 @@ const paths = {
|
||||
tags: ["Workflows"],
|
||||
summary: "Get workflow intelligence aggregates",
|
||||
description:
|
||||
"Returns the full workflow-intelligence aggregate powering the Workflows analytics page — 11 sections in one payload: `stats` (headline counters: sessions, agents, subagents, success rate, avg depth/duration, compactions, top tool flow), `orchestration` (subagent-type breakdown + delegation edges + outcomes), `toolFlow` (tool-to-tool transitions + tool counts), `effectiveness` (per-subagent-type success rate, avg duration, weekly trend), `patterns` (frequent subagent sequences + solo-session share), `modelDelegation` (model usage for main/sub agents + tokens by model), `errorPropagation` (errors by depth/type + error rate), `concurrency` (averaged agent swim-lane start/end), `complexity` (per-session agent/token/duration rows), `compaction` (compaction counts + tokens recovered), and `cooccurrence` (directed subagent-after-subagent pairs). The optional `status` query filter scopes every section to sessions of one status. Errors use the SHORT `{ error: { message } }` shape.",
|
||||
"Returns the full workflow-intelligence aggregate powering the Workflows analytics page — 11 sections in one payload: `stats` (headline counters: sessions, agents, subagents, success rate, avg depth/duration, compactions, top tool flow), `orchestration` (subagent-type breakdown + delegation edges + outcomes), `toolFlow` (tool-to-tool transitions + tool counts), `effectiveness` (per-subagent-type success rate, avg duration, weekly trend), `patterns` (frequent subagent sequences + solo-session share), `modelDelegation` (model usage for main/sub agents + tokens by model), `errorPropagation` (errors by depth/type + error rate), `concurrency` (averaged agent swim-lane start/end), `complexity` (per-session agent/token/duration rows), `compaction` (compaction counts + tokens recovered), and `cooccurrence` (directed subagent-after-subagent pairs). The optional `status` query filter scopes every section to sessions of one status, and `sections` computes and returns only the named ones (`stats.topFlow` is the head of `toolFlow.transitions`, one pass for both). The bundle is computed in a worker thread on its own read-only connection, so it never stalls other requests; identical requests in flight share one run, and one over `AGENTHUB_READ_TIMEOUT_MS` (default 60 s) answers 503 `EREADTIMEOUT`. Errors use the SHORT `{ error: { message } }` shape.",
|
||||
operationId: "getWorkflowIntelligence",
|
||||
parameters: [{ $ref: "#/components/parameters/WorkflowStatusQuery" }],
|
||||
parameters: [
|
||||
{ $ref: "#/components/parameters/WorkflowStatusQuery" },
|
||||
{
|
||||
name: "sections",
|
||||
in: "query",
|
||||
required: false,
|
||||
description:
|
||||
"Comma-separated subset of the bundle to compute: stats, orchestration, toolFlow, effectiveness, patterns, modelDelegation, errorPropagation, concurrency, complexity, compaction, cooccurrence. Unknown names are ignored; none known is a 400.",
|
||||
schema: { type: "string", example: "stats,toolFlow" },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workflow aggregate data",
|
||||
@@ -1633,6 +1632,20 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`sections` names no known section",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: {
|
||||
code: "INVALID_SECTIONS",
|
||||
message: "sections must name some of: stats, …",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
@@ -1642,6 +1655,17 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
503: {
|
||||
description: "The aggregate ran past `AGENTHUB_READ_TIMEOUT_MS`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: {
|
||||
error: { code: "EREADTIMEOUT", message: "read query took over 60000 ms" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -1347,7 +1347,7 @@ function createOpenApiSpec() {
|
||||
"Per-table counts from restoring an export bundle. Session-scoped counts are rows newly inserted; sessions_skipped counts sessions already present (skipped whole to stay idempotent).",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
source: { type: "string", description: "Uploaded filename or server-side path read." },
|
||||
source: { type: "string", description: "The uploaded file's name." },
|
||||
format: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
|
||||
@@ -1895,8 +1895,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
const picked = pickSlot(current.id);
|
||||
if (picked === null) return { allBusy: true };
|
||||
slot = picked;
|
||||
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
|
||||
const live = liveId ? runs.getRun(liveId) : null;
|
||||
// By the pane's own name, slot 0 too: a terminal tab in pane 0 is no
|
||||
// run, so `run_id` does not name it, and a start there would take it over.
|
||||
const live = runs.getRun(runs.runIdForLane(current.id, slot || 0));
|
||||
if (live && live.status === "running") {
|
||||
return { conflict: true };
|
||||
}
|
||||
|
||||
@@ -677,7 +677,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
// the live file is gone once Claude Code prunes it under cleanupPeriodDays
|
||||
// (default 30 days), but the snapshot taken at import time survives.
|
||||
let jsonlPath;
|
||||
if (agentId && agentId !== "main") {
|
||||
// A Codex session's conversation is its root thread's rollout journal
|
||||
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
|
||||
const isCodex = session.tool === "codex";
|
||||
if (isCodex) {
|
||||
jsonlPath = db
|
||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||
.get(req.params.id, req.params.id)?.path;
|
||||
} else if (agentId && agentId !== "main") {
|
||||
jsonlPath =
|
||||
getSubagentTranscriptPath(req.params.id, session.cwd, agentId, runId) ||
|
||||
findSubagentTranscriptPath(req.params.id, agentId, runId) ||
|
||||
@@ -707,6 +714,10 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
const renders = isCodex
|
||||
? (e) => e.type === "response_item"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
@@ -870,14 +881,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
if (!foundStart) {
|
||||
if (lineNum <= afterLine) continue;
|
||||
foundStart = true;
|
||||
}
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -904,7 +915,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
if (!renders(entry)) continue;
|
||||
if (lineNum >= beforeLine) {
|
||||
// Reached the boundary — stop reading
|
||||
rl.close();
|
||||
@@ -912,7 +923,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -934,9 +945,9 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
|
||||
@@ -963,9 +974,9 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -998,6 +1009,80 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Context Codex injects as a user message: AGENTS.md and the `<snake_case>`
|
||||
// blocks (environment_context, user_instructions, …).
|
||||
const CODEX_INJECTED = /^(# AGENTS\.md instructions for |<[a-z]+(?:_[a-z]+)+[\s>])/;
|
||||
|
||||
const codexText = (parts) =>
|
||||
Array.isArray(parts)
|
||||
? parts.map((p) => (p && typeof p.text === "string" ? p.text : "")).join("")
|
||||
: typeof parts === "string"
|
||||
? parts
|
||||
: "";
|
||||
|
||||
/**
|
||||
* One Codex rollout `response_item` → a transcript message in the Claude shape
|
||||
* the client renders, or null. Developer messages, injected context and
|
||||
* encrypted-only reasoning are left out.
|
||||
*/
|
||||
function codexMessage(entry, num) {
|
||||
const p = entry.payload || {};
|
||||
const at = entry.timestamp || null;
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||
const out = (o) => truncate(codexText(o), 10240);
|
||||
switch (p.type) {
|
||||
case "message": {
|
||||
if (p.role !== "user" && p.role !== "assistant") return null;
|
||||
const content = (p.content || [])
|
||||
.map((c) => (c && typeof c.text === "string" ? c.text : ""))
|
||||
.filter((t) => t.trim() && !(p.role === "user" && CODEX_INJECTED.test(t.trimStart())))
|
||||
.map((t) => ({ type: "text", text: truncate(t, 10240) }));
|
||||
return content.length ? msg(p.role, p.role, content) : null;
|
||||
}
|
||||
case "reasoning": {
|
||||
const t = codexText(p.summary);
|
||||
return t.trim()
|
||||
? msg("assistant", "assistant", [{ type: "thinking", text: truncate(t, 10240) }])
|
||||
: null;
|
||||
}
|
||||
case "function_call":
|
||||
case "custom_tool_call": {
|
||||
let input = p.input;
|
||||
if (p.type === "function_call") {
|
||||
try {
|
||||
input = JSON.parse(p.arguments);
|
||||
} catch {
|
||||
input = { arguments: p.arguments };
|
||||
}
|
||||
} else input = { input };
|
||||
return msg("assistant", "assistant", [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: p.name || "unknown",
|
||||
id: p.call_id || null,
|
||||
input: truncateObj(input, 10240),
|
||||
},
|
||||
]);
|
||||
}
|
||||
case "function_call_output":
|
||||
case "custom_tool_call_output": {
|
||||
const o =
|
||||
p.output && !Array.isArray(p.output) && typeof p.output === "object"
|
||||
? p.output.content
|
||||
: p.output;
|
||||
return msg("user", "tool", [
|
||||
{ type: "tool_result", id: p.call_id || null, output: out(o), is_error: false },
|
||||
]);
|
||||
}
|
||||
case "agent_message": {
|
||||
const t = codexText(p.content);
|
||||
return t.trim() ? msg("user", "system", [{ type: "text", text: truncate(t, 10240) }]) : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
|
||||
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
|
||||
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
|
||||
|
||||
function getDbSize() {
|
||||
try {
|
||||
@@ -64,6 +65,17 @@ function getHookStatus() {
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
];
|
||||
// The enabled plugin runs the hooks from its own plugin.json, so
|
||||
// settings.json rightly holds none of ours (installHooks removes them).
|
||||
const { pluginProvidesHooks } = require("../../scripts/install-hooks");
|
||||
if (pluginProvidesHooks(settings)) {
|
||||
return {
|
||||
installed: true,
|
||||
source: "plugin",
|
||||
path: getSettingsPath(),
|
||||
hooks: Object.fromEntries(hookTypes.map((ht) => [ht, true])),
|
||||
};
|
||||
}
|
||||
const hooks = {};
|
||||
for (const ht of hookTypes) {
|
||||
const entries = settings.hooks?.[ht] || [];
|
||||
@@ -288,7 +300,10 @@ router.get("/export", async (req, res) => {
|
||||
};
|
||||
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
|
||||
try {
|
||||
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
|
||||
await write(
|
||||
`{"format":${JSON.stringify(EXPORT_FORMAT)},"version":${EXPORT_VERSION}` +
|
||||
field("exported_at", new Date().toISOString())
|
||||
);
|
||||
await write(
|
||||
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
|
||||
);
|
||||
@@ -311,6 +326,16 @@ router.get("/export", async (req, res) => {
|
||||
}
|
||||
await write("]");
|
||||
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
|
||||
await write(field("workflows", db.prepare("SELECT * FROM workflows").all()));
|
||||
await write(
|
||||
field(
|
||||
"dashboard_runs",
|
||||
db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all()
|
||||
)
|
||||
);
|
||||
await write(
|
||||
field("alert_rules", db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all())
|
||||
);
|
||||
await write(field("model_pricing", stmts.listPricing.all()) + "}");
|
||||
res.end();
|
||||
} catch (err) {
|
||||
@@ -319,6 +344,62 @@ router.get("/export", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/import — restore a file written by GET /export, uploaded
|
||||
// as multipart `file`. Non-destructive (data-transfer.js: a session already
|
||||
// here is skipped whole, nothing is overwritten). The whole file is parsed in
|
||||
// memory, hence the size cap; a bad file is answered without echoing its text.
|
||||
// ponytail: one JSON.parse of the whole bundle; stream-parse if backups outgrow 512 MB.
|
||||
const IMPORT_MAX_BYTES = 512 * 1024 * 1024;
|
||||
router.post("/import", sameOriginGuard, (req, res) => {
|
||||
const multer = require("multer");
|
||||
const upload = multer({
|
||||
dest: os.tmpdir(),
|
||||
limits: { files: 1, fileSize: IMPORT_MAX_BYTES, fields: 8 },
|
||||
}).single("file");
|
||||
upload(req, res, (uploadErr) => {
|
||||
const file = req.file;
|
||||
const done = (status, body) => {
|
||||
if (file) fs.rm(file.path, { force: true }, () => {});
|
||||
res.status(status).json(body);
|
||||
};
|
||||
if (uploadErr) {
|
||||
const tooBig = uploadErr.code === "LIMIT_FILE_SIZE";
|
||||
return done(tooBig ? 413 : 400, {
|
||||
error: {
|
||||
code: tooBig ? "FILE_TOO_LARGE" : "BAD_UPLOAD",
|
||||
message: tooBig ? "the backup is larger than 512 MB" : uploadErr.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!file) {
|
||||
return done(400, { error: { code: "NO_FILE", message: "attach the export as `file`" } });
|
||||
}
|
||||
let bundle;
|
||||
try {
|
||||
bundle = JSON.parse(fs.readFileSync(file.path, "utf8"));
|
||||
} catch {
|
||||
return done(400, {
|
||||
error: { code: "INVALID_FORMAT", message: "Not a valid export file (not JSON)." },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const counts = importExportBundle(db, bundle);
|
||||
done(200, {
|
||||
ok: true,
|
||||
source: file.originalname,
|
||||
format: bundle.format ?? null,
|
||||
...counts,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "INVALID_FORMAT") {
|
||||
return done(400, { error: { code: err.code, message: err.message } });
|
||||
}
|
||||
console.warn("import failed:", err.message);
|
||||
done(500, { error: { code: "IMPORT_FAILED", message: err.message } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The two editable home directories share one shape: GET answers the value and
|
||||
// whether the real environment set it (`env_owned` — then a saved value is
|
||||
// overridden again at the next start), PUT validates, applies and persists
|
||||
|
||||
+25
-677
@@ -1,40 +1,40 @@
|
||||
/**
|
||||
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard.
|
||||
* @file Express router for workflow intelligence endpoints, providing aggregated insights into workflow orchestration, tool usage patterns, subagent effectiveness, error propagation, concurrency, and session complexity. It queries the database for various metrics and patterns related to agents, sessions, and events, and returns a comprehensive JSON response for frontend visualization on the dashboard. The GET / bundle is computed by `server/lib/workflow-stats.js` in the read worker (`server/lib/read-pool.js`), off the event loop.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const { runRead } = require("../lib/read-pool");
|
||||
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Helper: compute session duration in seconds ──
|
||||
function durationSec(s) {
|
||||
if (!s.started_at) return 0;
|
||||
const end = s.ended_at || new Date().toISOString();
|
||||
return Math.max(0, (new Date(end) - new Date(s.started_at)) / 1000);
|
||||
}
|
||||
|
||||
// ── GET / — Aggregate workflow intelligence ──
|
||||
router.get("/", (req, res) => {
|
||||
// Runs in the read worker: the bundle scans every event and takes seconds on a
|
||||
// large database, which inline would stall every other request meanwhile.
|
||||
// `?sections=a,b` computes only those parts of it (System Health needs seven).
|
||||
router.get("/", async (req, res) => {
|
||||
// Optional status filter: "active", "completed", or omit for all
|
||||
const statusFilter = req.query.status || null;
|
||||
let sections = null;
|
||||
if (typeof req.query.sections === "string") {
|
||||
sections = req.query.sections.split(",").filter((k) => WORKFLOW_SECTIONS.includes(k));
|
||||
if (sections.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "INVALID_SECTIONS",
|
||||
message: `sections must name some of: ${WORKFLOW_SECTIONS.join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Optional status filter: "active", "completed", or omit for all
|
||||
const statusFilter = req.query.status || null;
|
||||
const data = {
|
||||
stats: getWorkflowStats(statusFilter),
|
||||
orchestration: getOrchestrationData(statusFilter),
|
||||
toolFlow: getToolFlowData(statusFilter),
|
||||
effectiveness: getSubagentEffectiveness(statusFilter),
|
||||
patterns: getWorkflowPatterns(statusFilter),
|
||||
modelDelegation: getModelDelegation(statusFilter),
|
||||
errorPropagation: getErrorPropagation(statusFilter),
|
||||
concurrency: getConcurrencyData(statusFilter),
|
||||
complexity: getSessionComplexity(statusFilter),
|
||||
compaction: getCompactionImpact(statusFilter),
|
||||
cooccurrence: getAgentCooccurrence(statusFilter),
|
||||
};
|
||||
res.json(data);
|
||||
res.json(await runRead("workflows", { status: statusFilter, sections }));
|
||||
} catch (err) {
|
||||
if (err.code === "EREADTIMEOUT") {
|
||||
return res.status(503).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.status(500).json({ error: { message: err.message } });
|
||||
}
|
||||
});
|
||||
@@ -160,658 +160,6 @@ router.get("/session/:id", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Data-fetching functions
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Build a SQL WHERE clause for session status filtering.
|
||||
* Returns { clause, params } where clause is either empty or " AND s.status = ?".
|
||||
* Use `sessionAlias` to match the table alias used in your query (default "s").
|
||||
*/
|
||||
function statusClause(statusFilter, alias = "s") {
|
||||
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
|
||||
return { clause: ` AND ${alias}.status = ?`, params: [statusFilter] };
|
||||
}
|
||||
|
||||
/** Same but for agents table joins where we need to filter via session_id */
|
||||
function sessionIdFilter(statusFilter) {
|
||||
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
|
||||
return {
|
||||
clause: " AND session_id IN (SELECT id FROM sessions WHERE status = ?)",
|
||||
params: [statusFilter],
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkflowStats(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
const totalAgents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE 1=1${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const totalSubagents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'subagent'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
|
||||
// Average subagents per session
|
||||
const avgSubagents = totalSessions > 0 ? +(totalSubagents / totalSessions).toFixed(1) : 0;
|
||||
|
||||
// Agent success rate
|
||||
const completedAgents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'completed'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const errorAgents = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'error'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const finishedAgents = completedAgents + errorAgents;
|
||||
const successRate =
|
||||
finishedAgents > 0 ? +((completedAgents / finishedAgents) * 100).toFixed(1) : 100;
|
||||
|
||||
// Average max depth per session
|
||||
const depthRows = db
|
||||
.prepare(
|
||||
`WITH RECURSIVE agent_depth AS (
|
||||
SELECT id, session_id, parent_agent_id, 0 as depth FROM agents WHERE parent_agent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT a.id, a.session_id, a.parent_agent_id, ad.depth + 1
|
||||
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
|
||||
)
|
||||
SELECT session_id, MAX(depth) as max_depth FROM agent_depth
|
||||
WHERE 1=1${sf.clause}
|
||||
GROUP BY session_id`
|
||||
)
|
||||
.all(...sf.params);
|
||||
const avgDepth =
|
||||
depthRows.length > 0
|
||||
? +(depthRows.reduce((s, r) => s + r.max_depth, 0) / depthRows.length).toFixed(1)
|
||||
: 0;
|
||||
|
||||
// Average session duration
|
||||
const sessions = db
|
||||
.prepare(`SELECT started_at, ended_at FROM sessions s WHERE ended_at IS NOT NULL${ss.clause}`)
|
||||
.all(...ss.params);
|
||||
const totalDuration = sessions.reduce((s, sess) => s + durationSec(sess), 0);
|
||||
const avgDurationSec = sessions.length > 0 ? Math.round(totalDuration / sessions.length) : 0;
|
||||
|
||||
// Total compactions
|
||||
const totalCompactions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
const avgCompactions = totalSessions > 0 ? +(totalCompactions / totalSessions).toFixed(1) : 0;
|
||||
|
||||
// Most common tool flow (top 2-tool sequence)
|
||||
const topFlow = db
|
||||
.prepare(
|
||||
`SELECT e1.tool_name as source, e2.tool_name as target, COUNT(*) as c
|
||||
FROM events e1
|
||||
JOIN events e2 ON e2.session_id = e1.session_id AND e2.id = (
|
||||
SELECT MIN(e3.id) FROM events e3
|
||||
WHERE e3.session_id = e1.session_id AND e3.id > e1.id AND e3.tool_name IS NOT NULL
|
||||
)
|
||||
WHERE e1.tool_name IS NOT NULL AND e2.tool_name IS NOT NULL${sf.clause.replace("session_id", "e1.session_id")}
|
||||
GROUP BY e1.tool_name, e2.tool_name
|
||||
ORDER BY c DESC LIMIT 1`
|
||||
)
|
||||
.get(...sf.params);
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
totalAgents,
|
||||
totalSubagents,
|
||||
avgSubagents,
|
||||
successRate,
|
||||
avgDepth,
|
||||
avgDurationSec,
|
||||
totalCompactions,
|
||||
avgCompactions,
|
||||
topFlow: topFlow ? { source: topFlow.source, target: topFlow.target, count: topFlow.c } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getOrchestrationData(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Count sessions
|
||||
const sessionCount = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
|
||||
// Main agents count
|
||||
const mainCount = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE type = 'main'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
|
||||
// Subagent types with counts and parent info
|
||||
const subagentTypes = db
|
||||
.prepare(
|
||||
`SELECT subagent_type, COUNT(*) as count,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors
|
||||
FROM agents WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
|
||||
GROUP BY subagent_type ORDER BY count DESC`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Edges: parent_subagent_type -> child_subagent_type with frequency
|
||||
const edges = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COALESCE(p.subagent_type, 'main') as source,
|
||||
a.subagent_type as target,
|
||||
COUNT(*) as weight
|
||||
FROM agents a
|
||||
LEFT JOIN agents p ON a.parent_agent_id = p.id
|
||||
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
|
||||
GROUP BY source, target
|
||||
ORDER BY weight DESC`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Outcome counts
|
||||
const outcomes = db
|
||||
.prepare(
|
||||
`SELECT status, COUNT(*) as count FROM agents
|
||||
WHERE status IN ('completed', 'error')${sf.clause}
|
||||
GROUP BY status`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Compaction agents (context compressions per session)
|
||||
const compactions = db
|
||||
.prepare(
|
||||
`SELECT session_id, COUNT(*) as count
|
||||
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
|
||||
GROUP BY session_id`
|
||||
)
|
||||
.all(...sf.params);
|
||||
const totalCompactions = compactions.reduce((s, r) => s + r.count, 0);
|
||||
const sessionsWithCompactions = compactions.length;
|
||||
|
||||
return {
|
||||
sessionCount,
|
||||
mainCount,
|
||||
subagentTypes,
|
||||
edges,
|
||||
outcomes,
|
||||
compactions: { total: totalCompactions, sessions: sessionsWithCompactions },
|
||||
};
|
||||
}
|
||||
|
||||
function getToolFlowData(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
|
||||
// Tool-to-tool transitions (next tool in same session)
|
||||
const transitions = db
|
||||
.prepare(
|
||||
`SELECT e1.tool_name as source, e2.tool_name as target, COUNT(*) as value
|
||||
FROM events e1
|
||||
JOIN events e2 ON e2.session_id = e1.session_id AND e2.id = (
|
||||
SELECT MIN(e3.id) FROM events e3
|
||||
WHERE e3.session_id = e1.session_id AND e3.id > e1.id AND e3.tool_name IS NOT NULL
|
||||
)
|
||||
WHERE e1.tool_name IS NOT NULL AND e2.tool_name IS NOT NULL${sf.clause.replace("session_id", "e1.session_id")}
|
||||
GROUP BY e1.tool_name, e2.tool_name
|
||||
ORDER BY value DESC
|
||||
LIMIT 50`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Tool counts for sizing nodes
|
||||
const toolCounts = db
|
||||
.prepare(
|
||||
`SELECT tool_name, COUNT(*) as count FROM events
|
||||
WHERE tool_name IS NOT NULL${sf.clause}
|
||||
GROUP BY tool_name ORDER BY count DESC LIMIT 15`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
return { transitions, toolCounts };
|
||||
}
|
||||
|
||||
function getSubagentEffectiveness(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
|
||||
const types = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
a.subagent_type,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN a.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN a.status = 'error' THEN 1 ELSE 0 END) as errors,
|
||||
COUNT(DISTINCT a.session_id) as sessions
|
||||
FROM agents a
|
||||
WHERE a.type = 'subagent' AND a.subagent_type IS NOT NULL${sf.clause.replace("session_id", "a.session_id")}
|
||||
GROUP BY a.subagent_type
|
||||
ORDER BY total DESC
|
||||
LIMIT 12`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Get token usage per subagent type (approximate via session token totals)
|
||||
// Also get average duration per type
|
||||
const withMetrics = types.map((t) => {
|
||||
const durRow = db
|
||||
.prepare(
|
||||
// Clamp each row's duration to >= 0 before averaging. The #156 startup
|
||||
// repair only heals compaction rows; any other subagent type whose
|
||||
// ended_at < started_at (clock skew, replayed/synced transcripts) would
|
||||
// otherwise drag this average negative. MAX(0, …) mirrors the Math.max(0)
|
||||
// guard durationSec() already applies to every other duration here.
|
||||
`SELECT AVG(
|
||||
CASE WHEN ended_at IS NOT NULL THEN
|
||||
MAX(0, (julianday(ended_at) - julianday(started_at)) * 86400)
|
||||
ELSE NULL END
|
||||
) as avg_duration
|
||||
FROM agents WHERE subagent_type = ? AND type = 'subagent'${sf.clause}`
|
||||
)
|
||||
.get(t.subagent_type, ...sf.params);
|
||||
|
||||
// Weekly trend: count per day-of-week (Mon–Sun) over last 8 weeks.
|
||||
// SQLite strftime('%w') → 0=Sun, 1=Mon, ..., 6=Sat.
|
||||
// Frontend expects index 0=Mon → 6=Sun, so remap with (dow + 6) % 7.
|
||||
const trendRows = db
|
||||
.prepare(
|
||||
`SELECT CAST(strftime('%w', started_at) AS INTEGER) as dow, COUNT(*) as count
|
||||
FROM agents WHERE subagent_type = ? AND type = 'subagent'
|
||||
AND started_at >= date('now', '-56 days')${sf.clause}
|
||||
GROUP BY dow ORDER BY dow ASC`
|
||||
)
|
||||
.all(t.subagent_type, ...sf.params);
|
||||
|
||||
// Build 7-slot array: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
const trendByDay = [0, 0, 0, 0, 0, 0, 0];
|
||||
for (const row of trendRows) {
|
||||
const idx = (row.dow + 6) % 7; // Sun(0)→6, Mon(1)→0, Tue(2)→1, ...
|
||||
trendByDay[idx] = row.count;
|
||||
}
|
||||
|
||||
return {
|
||||
...t,
|
||||
successRate:
|
||||
t.completed + t.errors > 0
|
||||
? +((t.completed / (t.completed + t.errors)) * 100).toFixed(1)
|
||||
: 100,
|
||||
avgDuration: durRow?.avg_duration ? Math.round(durRow.avg_duration) : null,
|
||||
trend: trendByDay,
|
||||
};
|
||||
});
|
||||
|
||||
return withMetrics;
|
||||
}
|
||||
|
||||
function getWorkflowPatterns(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Get ordered subagent sequences per session
|
||||
const sessions = db
|
||||
.prepare(
|
||||
`SELECT session_id, GROUP_CONCAT(subagent_type, '→') as sequence
|
||||
FROM (
|
||||
SELECT session_id, subagent_type
|
||||
FROM agents
|
||||
WHERE type = 'subagent' AND subagent_type IS NOT NULL${sf.clause}
|
||||
ORDER BY session_id, started_at ASC
|
||||
)
|
||||
GROUP BY session_id
|
||||
HAVING COUNT(*) >= 2`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Count pattern frequencies
|
||||
const patternCounts = {};
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
for (const row of sessions) {
|
||||
const seq = row.sequence;
|
||||
patternCounts[seq] = (patternCounts[seq] || 0) + 1;
|
||||
}
|
||||
|
||||
// Also count 2-step and 3-step sub-patterns
|
||||
for (const row of sessions) {
|
||||
const steps = row.sequence.split("→");
|
||||
// 2-step windows
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
const sub = steps.slice(i, i + 2).join("→");
|
||||
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
|
||||
}
|
||||
// 3-step windows
|
||||
for (let i = 0; i < steps.length - 2; i++) {
|
||||
const sub = steps.slice(i, i + 3).join("→");
|
||||
patternCounts[sub] = (patternCounts[sub] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: keep only patterns where the full sequence count >= 2-step count
|
||||
// Sort by frequency, take top 10
|
||||
const sorted = Object.entries(patternCounts)
|
||||
.filter(([, count]) => count >= 2)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([pattern, count]) => ({
|
||||
steps: pattern.split("→"),
|
||||
count,
|
||||
percentage: totalSessions > 0 ? +((count / totalSessions) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Also track solo sessions (no subagents)
|
||||
const soloCount = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as c FROM sessions s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.type = 'subagent')${ss.clause}`
|
||||
)
|
||||
.get(...ss.params).c;
|
||||
|
||||
return {
|
||||
patterns: sorted,
|
||||
soloSessionCount: soloCount,
|
||||
soloPercentage: totalSessions > 0 ? +((soloCount / totalSessions) * 100).toFixed(1) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getModelDelegation(statusFilter) {
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Model usage for main agents
|
||||
const mainModels = db
|
||||
.prepare(
|
||||
`SELECT s.model, COUNT(DISTINCT a.id) as agent_count, COUNT(DISTINCT s.id) as session_count
|
||||
FROM agents a JOIN sessions s ON a.session_id = s.id
|
||||
WHERE a.type = 'main' AND s.model IS NOT NULL${ss.clause}
|
||||
GROUP BY s.model ORDER BY agent_count DESC`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
// Model usage for subagents (via session model — best approximation)
|
||||
const subagentModels = db
|
||||
.prepare(
|
||||
`SELECT s.model, COUNT(a.id) as agent_count
|
||||
FROM agents a JOIN sessions s ON a.session_id = s.id
|
||||
WHERE a.type = 'subagent' AND s.model IS NOT NULL${ss.clause}
|
||||
GROUP BY s.model ORDER BY agent_count DESC`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
// Token cost per model — filter via session_id on token_usage table
|
||||
const sfToken = sessionIdFilter(statusFilter);
|
||||
const tokensByModel = db
|
||||
.prepare(
|
||||
`SELECT model,
|
||||
SUM(input_tokens + baseline_input) as input_tokens,
|
||||
SUM(output_tokens + baseline_output) as output_tokens,
|
||||
SUM(cache_read_tokens + baseline_cache_read) as cache_read_tokens,
|
||||
SUM(cache_write_tokens + baseline_cache_write) as cache_write_tokens
|
||||
FROM token_usage WHERE 1=1${sfToken.clause}
|
||||
GROUP BY model ORDER BY (input_tokens + output_tokens) DESC`
|
||||
)
|
||||
.all(...sfToken.params);
|
||||
|
||||
return { mainModels, subagentModels, tokensByModel };
|
||||
}
|
||||
|
||||
function getErrorPropagation(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Error count by depth — include both agent-level errors (status = 'error')
|
||||
// AND session-level errors (session status = 'error' mapped to depth 0 for main agent).
|
||||
const errorsByDepth = db
|
||||
.prepare(
|
||||
`WITH RECURSIVE agent_depth AS (
|
||||
SELECT id, session_id, subagent_type, status, 0 as depth
|
||||
FROM agents WHERE parent_agent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT a.id, a.session_id, a.subagent_type, a.status, ad.depth + 1
|
||||
FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id
|
||||
)
|
||||
SELECT depth, COUNT(*) as count FROM agent_depth
|
||||
WHERE status = 'error'${sf.clause}
|
||||
GROUP BY depth ORDER BY depth ASC`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Also count sessions that ended in error but whose main agent wasn't marked error.
|
||||
// Map these to depth 0 (session-level errors: quota limits, crashes, etc.)
|
||||
const sessionErrorsNotInAgents = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as c FROM sessions s
|
||||
WHERE s.status = 'error'${ss.clause}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM agents a WHERE a.session_id = s.id AND a.status = 'error'
|
||||
)`
|
||||
)
|
||||
.get(...ss.params).c;
|
||||
|
||||
if (sessionErrorsNotInAgents > 0) {
|
||||
const existing = errorsByDepth.find((d) => d.depth === 0);
|
||||
if (existing) {
|
||||
existing.count += sessionErrorsNotInAgents;
|
||||
} else {
|
||||
errorsByDepth.unshift({ depth: 0, count: sessionErrorsNotInAgents });
|
||||
}
|
||||
}
|
||||
|
||||
// Error-prone subagent types — from agent errors + from error events on subagents
|
||||
const errorTypes = db
|
||||
.prepare(
|
||||
`SELECT subagent_type, COUNT(*) as count
|
||||
FROM agents WHERE status = 'error' AND subagent_type IS NOT NULL${sf.clause}
|
||||
GROUP BY subagent_type ORDER BY count DESC LIMIT 5`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Also capture error events (Stop with error summary, API errors from transcripts)
|
||||
const eventErrors = db
|
||||
.prepare(
|
||||
`SELECT e.summary, COUNT(*) as count
|
||||
FROM events e
|
||||
WHERE ((e.event_type = 'Stop' AND e.summary LIKE 'Error in%')
|
||||
OR e.event_type = 'APIError')${sf.clause.replace("session_id", "e.session_id")}
|
||||
GROUP BY e.summary ORDER BY count DESC LIMIT 10`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Error rate per session (sessions with error status OR sessions with error events)
|
||||
const sessionsWithErrors = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT id) as c FROM (
|
||||
SELECT id FROM sessions s WHERE s.status = 'error'${ss.clause}
|
||||
UNION
|
||||
SELECT DISTINCT session_id as id FROM agents WHERE status = 'error'${sf.clause}
|
||||
UNION
|
||||
SELECT DISTINCT session_id as id FROM events
|
||||
WHERE ((event_type = 'Stop' AND summary LIKE 'Error in%')
|
||||
OR event_type = 'APIError')${sf.clause}
|
||||
)`
|
||||
)
|
||||
.get(...ss.params, ...sf.params, ...sf.params).c;
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
|
||||
return {
|
||||
byDepth: errorsByDepth,
|
||||
byType: errorTypes,
|
||||
eventErrors,
|
||||
sessionsWithErrors,
|
||||
totalSessions,
|
||||
errorRate: totalSessions > 0 ? +((sessionsWithErrors / totalSessions) * 100).toFixed(1) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getConcurrencyData(statusFilter) {
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// For aggregate: average agent types per position in session timeline
|
||||
// Get agent start/end as fraction of session duration per session
|
||||
const lanes = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
a.id, a.name, a.type, a.subagent_type, a.status,
|
||||
a.started_at, a.ended_at, a.session_id,
|
||||
s.started_at as session_start, s.ended_at as session_end
|
||||
FROM agents a
|
||||
JOIN sessions s ON a.session_id = s.id
|
||||
WHERE s.ended_at IS NOT NULL${ss.clause}
|
||||
ORDER BY a.started_at ASC
|
||||
LIMIT 2000`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
// Build aggregate: for each subagent_type, average start% and end%
|
||||
const typeAgg = {};
|
||||
for (const lane of lanes) {
|
||||
const sessStart = new Date(lane.session_start).getTime();
|
||||
const sessEnd = new Date(lane.session_end).getTime();
|
||||
const sessDur = sessEnd - sessStart;
|
||||
if (sessDur <= 0) continue;
|
||||
|
||||
const agStart = new Date(lane.started_at).getTime();
|
||||
const agEnd = lane.ended_at ? new Date(lane.ended_at).getTime() : sessEnd;
|
||||
|
||||
const startPct = Math.max(0, Math.min(1, (agStart - sessStart) / sessDur));
|
||||
const endPct = Math.max(0, Math.min(1, (agEnd - sessStart) / sessDur));
|
||||
|
||||
const key = lane.type === "main" ? "Main Agent" : lane.subagent_type || "unknown";
|
||||
if (!typeAgg[key]) typeAgg[key] = { starts: [], ends: [], status: lane.status };
|
||||
typeAgg[key].starts.push(startPct);
|
||||
typeAgg[key].ends.push(endPct);
|
||||
}
|
||||
|
||||
// Average start/end per type
|
||||
const aggregateLanes = Object.entries(typeAgg)
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
avgStart: +(data.starts.reduce((s, v) => s + v, 0) / data.starts.length).toFixed(3),
|
||||
avgEnd: +(data.ends.reduce((s, v) => s + v, 0) / data.ends.length).toFixed(3),
|
||||
count: data.starts.length,
|
||||
}))
|
||||
.sort((a, b) => a.avgStart - b.avgStart);
|
||||
|
||||
return { aggregateLanes };
|
||||
}
|
||||
|
||||
function getSessionComplexity(statusFilter) {
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
s.id, s.name, s.status, s.started_at, s.ended_at, s.model,
|
||||
COUNT(a.id) as agent_count,
|
||||
SUM(CASE WHEN a.type = 'subagent' THEN 1 ELSE 0 END) as subagent_count
|
||||
FROM sessions s
|
||||
LEFT JOIN agents a ON a.session_id = s.id
|
||||
WHERE 1=1${ss.clause}
|
||||
GROUP BY s.id
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT 200`
|
||||
)
|
||||
.all(...ss.params);
|
||||
|
||||
const sessions = rows.map((r) => {
|
||||
const dur = durationSec(r);
|
||||
// Get token count for this session
|
||||
const tokens = db
|
||||
.prepare(
|
||||
`SELECT SUM(input_tokens + baseline_input + output_tokens + baseline_output +
|
||||
cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write) as total
|
||||
FROM token_usage WHERE session_id = ?`
|
||||
)
|
||||
.get(r.id);
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
duration: Math.round(dur),
|
||||
agentCount: r.agent_count,
|
||||
subagentCount: r.subagent_count,
|
||||
totalTokens: tokens?.total || 0,
|
||||
model: r.model,
|
||||
};
|
||||
});
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function getCompactionImpact(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
const ss = statusClause(statusFilter);
|
||||
|
||||
// Total compactions
|
||||
const totalCompactions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`)
|
||||
.get(...sf.params).c;
|
||||
|
||||
// Total baseline tokens (tokens "recovered" through compaction)
|
||||
const recovered = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
SUM(baseline_input + baseline_output + baseline_cache_read + baseline_cache_write) as total
|
||||
FROM token_usage WHERE 1=1${sf.clause}`
|
||||
)
|
||||
.get(...sf.params);
|
||||
|
||||
// Compactions per session distribution
|
||||
const perSession = db
|
||||
.prepare(
|
||||
`SELECT session_id, COUNT(*) as compactions
|
||||
FROM agents WHERE subagent_type = 'compaction'${sf.clause}
|
||||
GROUP BY session_id ORDER BY compactions DESC LIMIT 50`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
// Sessions with compactions vs without
|
||||
const sessionsWithCompactions = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT session_id) as c FROM agents WHERE subagent_type = 'compaction'${sf.clause}`
|
||||
)
|
||||
.get(...sf.params).c;
|
||||
const totalSessions = db
|
||||
.prepare(`SELECT COUNT(*) as c FROM sessions s WHERE 1=1${ss.clause}`)
|
||||
.get(...ss.params).c;
|
||||
|
||||
return {
|
||||
totalCompactions,
|
||||
tokensRecovered: recovered?.total || 0,
|
||||
perSession,
|
||||
sessionsWithCompactions,
|
||||
totalSessions,
|
||||
};
|
||||
}
|
||||
|
||||
function getAgentCooccurrence(statusFilter) {
|
||||
const sf = sessionIdFilter(statusFilter);
|
||||
|
||||
// Directed: which agent type runs AFTER which other type in the same session
|
||||
// a1 started before a2 → edge a1 → a2 with count
|
||||
const pairs = db
|
||||
.prepare(
|
||||
`SELECT a1.subagent_type as source, a2.subagent_type as target,
|
||||
COUNT(*) as weight
|
||||
FROM agents a1
|
||||
JOIN agents a2 ON a1.session_id = a2.session_id
|
||||
AND a1.started_at < a2.started_at
|
||||
AND a1.id != a2.id
|
||||
WHERE a1.type = 'subagent' AND a2.type = 'subagent'
|
||||
AND a1.subagent_type IS NOT NULL AND a2.subagent_type IS NOT NULL
|
||||
AND a1.subagent_type != 'compaction' AND a2.subagent_type != 'compaction'${sf.clause.replace("session_id", "a1.session_id")}
|
||||
GROUP BY a1.subagent_type, a2.subagent_type
|
||||
HAVING weight >= 2
|
||||
ORDER BY weight DESC
|
||||
LIMIT 40`
|
||||
)
|
||||
.all(...sf.params);
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// ── Build agent tree from flat list ──
|
||||
function buildAgentTree(agents) {
|
||||
const map = {};
|
||||
|
||||
Reference in New Issue
Block a user