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:
@@ -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 };
|
||||
Reference in New Issue
Block a user