ca821008e2
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max 2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli). Server - /api/analytics: the events-table scans run in the read worker (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args - /api/workflows/skill-usage: +event_type and a tool_name range instead of LIKE, so it uses idx_events_tool (784 ms → a few ms) - idx_events_session_type_tool (session_id, event_type, tool_name, created_at) replaces idx_events_session_type: covering reads for session stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB cache_size and 256 MB mmap_size on both connections - WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable connection; a failed checkpoint restores the automatic one (hook COMMIT p95 109 ms → 15 ms at 100 hooks/s) - tmux: one list-sessions answers a whole GET /api/lanes and the heal beat (liveSessions), shared for 1 s and cleared by this process's new/kill — GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat skips tmux when nothing runs; attach options chained into 2 spawns - hooks: TurnDuration/APIError deduped against one read per hook instead of a query per transcript entry (23 ms/hook on a 763-turn session); one lane scan per hook; hourly upserts skip rows that did not grow; frames are sent after the transaction commits (and after the pane binding); new_event carries its row id; a split pane's (slot 1–9) hooks no longer rebind the lane's session_id - watchdog liveness probe runs ps asynchronously; transcript newline scan via indexOf; startup subagent backfill fingerprints the files it already checked - pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB) - static: hashed assets served from their pre-compressed .br/.gz sibling Client - hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes, lane cards, git header, changes panel, settings); no per-tab hourly /updates/check (the server already checks) - refetch storms throttled (workspace tree/session list 3 s, /api/run only on a run-key change, notification pane lookup only when shown); WS reconnect jitter; terminal retry backoff 1.5 → 30 s, stops on 1008 - heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame; GET in-flight dedupe in request() - lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi (only while enabled); only the active locale loads; Workspace chunk prefetched on a /workspace deep link - Sagi: no work while disabled, pupil via CSS variables, memo avatar - build: .br/.gz siblings, sourcemap hidden; service worker no longer caches /assets/ (cache v3 drops the old one) Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook exports, so a suite run from a linked worktree's pre-commit can no longer commit into the real repository or set core.bare. Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README, docs/plans/perf/README.md (audit, checklist, results).
292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
/**
|
|
* @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 { parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
|
|
|
|
const router = Router();
|
|
|
|
// ── GET / — Aggregate workflow intelligence ──
|
|
// 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 filters: status ("active", "completed", or omit for all), the AI
|
|
// tool (`?tool=`, `sessions.tool`) and `?from=&to=` (sessions started in
|
|
// those local days).
|
|
const statusFilter = req.query.status || null;
|
|
const tool = parseTool(req);
|
|
if (tool === false) return res.status(400).json(BAD_TOOL);
|
|
const range = parseRange(req);
|
|
if (range === false) return res.status(400).json(BAD_RANGE);
|
|
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 {
|
|
res.json(
|
|
await runRead(
|
|
"workflows",
|
|
{ status: statusFilter, tool, range, sections },
|
|
// Every open Workflows page shares one run (~1.5 s on a large DB).
|
|
{ maxAgeMs: 30_000 }
|
|
)
|
|
);
|
|
} 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 } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* How much each skill and each plugin is actually used.
|
|
*
|
|
* Skill calls are read off `Skill` tool events, one row per invocation:
|
|
* `PreToolUse` only, because every call also emits a `PostToolUse` and counting
|
|
* both would double every number. The skill's own name carries its plugin as a
|
|
* `plugin:skill` prefix; a bare name is a built-in or personal skill and is
|
|
* grouped under `null`.
|
|
*
|
|
* A plugin's total also includes calls to the MCP tools it ships
|
|
* (`mcp__plugin_<plugin>__<tool>`) — a plugin used entirely through its MCP
|
|
* server would otherwise report zero usage.
|
|
*/
|
|
router.get("/skill-usage", (req, res) => {
|
|
try {
|
|
const tool = parseTool(req);
|
|
if (tool === false) return res.status(400).json(BAD_TOOL);
|
|
const range = parseRange(req);
|
|
if (range === false) return res.status(400).json(BAD_RANGE);
|
|
const sf = sessionIdFilter({ status: req.query.status || null, tool, range });
|
|
|
|
const skills = db
|
|
.prepare(
|
|
`SELECT json_extract(data, '$.tool_input.skill') AS name,
|
|
COUNT(*) AS count,
|
|
COUNT(DISTINCT session_id) AS sessions
|
|
FROM events
|
|
WHERE tool_name = 'Skill' AND +event_type = 'PreToolUse'
|
|
AND json_extract(data, '$.tool_input.skill') IS NOT NULL${sf.clause}
|
|
GROUP BY name ORDER BY count DESC LIMIT 30`
|
|
)
|
|
.all(...sf.params)
|
|
.map((r) => {
|
|
const i = r.name.indexOf(":");
|
|
return { ...r, plugin: i > 0 ? r.name.slice(0, i) : null };
|
|
});
|
|
|
|
// A range on tool_name ("`" sorts right after "_") instead of LIKE, and
|
|
// `+event_type` to keep the planner off idx_events_type: both make the
|
|
// query use idx_events_tool (784 ms → a few ms on 253K events).
|
|
const mcpCalls = db
|
|
.prepare(
|
|
`SELECT tool_name, COUNT(*) AS count FROM events
|
|
WHERE tool_name >= 'mcp__plugin_' AND tool_name < 'mcp__plugin\`'
|
|
AND +event_type = 'PreToolUse'${sf.clause}
|
|
GROUP BY tool_name`
|
|
)
|
|
.all(...sf.params);
|
|
|
|
const byPlugin = new Map();
|
|
const bump = (name, field, n) => {
|
|
const e = byPlugin.get(name) || { name, skills: 0, skillCalls: 0, mcpCalls: 0 };
|
|
e[field] += n;
|
|
byPlugin.set(name, e);
|
|
};
|
|
for (const s of skills) {
|
|
if (!s.plugin) continue;
|
|
bump(s.plugin, "skills", 1);
|
|
bump(s.plugin, "skillCalls", s.count);
|
|
}
|
|
for (const m of mcpCalls) {
|
|
// mcp__plugin_<plugin>_<server>__<tool>. Both halves may contain
|
|
// underscores, so the split is only unambiguous when the plugin names its
|
|
// server after itself — the overwhelmingly common case (`agenthub_agenthub`,
|
|
// `vercel_vercel`), which the dedupe below collapses back to one name.
|
|
// ponytail: a plugin whose server has its own name reports under
|
|
// "<plugin>_<server>"; read the real pairing off the MCP config if that
|
|
// ever matters.
|
|
const seg = m.tool_name.slice("mcp__plugin_".length).split("__")[0];
|
|
const doubled = seg.match(/^(.+)_\1$/);
|
|
const plugin = doubled ? doubled[1] : seg;
|
|
if (plugin) bump(plugin, "mcpCalls", m.count);
|
|
}
|
|
const plugins = [...byPlugin.values()].sort(
|
|
(a, b) => b.skillCalls + b.mcpCalls - (a.skillCalls + a.mcpCalls)
|
|
);
|
|
|
|
res.json({ skills, plugins });
|
|
} catch (err) {
|
|
res.status(500).json({ error: { message: err.message } });
|
|
}
|
|
});
|
|
|
|
// ── GET /session/:id — Single session drill-in ──
|
|
router.get("/session/:id", (req, res) => {
|
|
try {
|
|
const sessionId = req.params.id;
|
|
const session = stmts.getSession.get(sessionId);
|
|
if (!session) return res.status(404).json({ error: { message: "Session not found" } });
|
|
|
|
const agents = stmts.listAgentsBySession.all(sessionId);
|
|
const events = db
|
|
.prepare("SELECT * FROM events WHERE session_id = ? ORDER BY created_at ASC, id ASC")
|
|
.all(sessionId);
|
|
|
|
// Build agent tree
|
|
const tree = buildAgentTree(agents);
|
|
|
|
// Build tool timeline
|
|
const toolTimeline = events
|
|
.filter((e) => e.tool_name)
|
|
.map((e) => ({
|
|
id: e.id,
|
|
tool_name: e.tool_name,
|
|
event_type: e.event_type,
|
|
agent_id: e.agent_id,
|
|
created_at: e.created_at,
|
|
summary: e.summary,
|
|
}));
|
|
|
|
// Agent swim lanes
|
|
const swimLanes = agents.map((a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
type: a.type,
|
|
subagent_type: a.subagent_type,
|
|
status: a.status,
|
|
started_at: a.started_at,
|
|
ended_at: a.ended_at,
|
|
parent_agent_id: a.parent_agent_id,
|
|
}));
|
|
|
|
res.json({ session, tree, toolTimeline, swimLanes, events: events.slice(0, 500) });
|
|
} catch (err) {
|
|
res.status(500).json({ error: { message: err.message } });
|
|
}
|
|
});
|
|
|
|
// ── Build agent tree from flat list ──
|
|
function buildAgentTree(agents) {
|
|
const map = {};
|
|
const roots = [];
|
|
for (const a of agents) {
|
|
map[a.id] = {
|
|
id: a.id,
|
|
name: a.name,
|
|
type: a.type,
|
|
subagent_type: a.subagent_type,
|
|
status: a.status,
|
|
task: a.task,
|
|
started_at: a.started_at,
|
|
ended_at: a.ended_at,
|
|
children: [],
|
|
};
|
|
}
|
|
for (const a of agents) {
|
|
if (a.parent_agent_id && map[a.parent_agent_id]) {
|
|
map[a.parent_agent_id].children.push(map[a.id]);
|
|
} else {
|
|
roots.push(map[a.id]);
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
// ── Workflow-tool runs (issue #167) ───────────────────────────────────────
|
|
// Distinct from the analytics above: these are fleets spawned by the Claude
|
|
// Code "Workflow" tool, ingested from on-disk run journals into the workflows
|
|
// table (see server/lib/workflow-ingest.js). Routed under /runs so they never
|
|
// collide with the analytics root or /session/:id.
|
|
|
|
// Parse the JSON-blob columns into arrays for the client.
|
|
function hydrateWorkflow(row) {
|
|
if (!row) return row;
|
|
let phases = [];
|
|
let progress = [];
|
|
try {
|
|
phases = row.phases ? JSON.parse(row.phases) : [];
|
|
} catch {
|
|
phases = [];
|
|
}
|
|
try {
|
|
progress = row.progress ? JSON.parse(row.progress) : [];
|
|
} catch {
|
|
progress = [];
|
|
}
|
|
return { ...row, phases, progress };
|
|
}
|
|
|
|
// GET /runs — list workflow runs (filter by status or session_id), paginated.
|
|
router.get("/runs", (req, res) => {
|
|
try {
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 1000);
|
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
|
const status = req.query.status && req.query.status !== "all" ? req.query.status : null;
|
|
const sessionId = req.query.session_id || null;
|
|
|
|
let rows;
|
|
if (sessionId) {
|
|
rows = stmts.listWorkflowsBySessionFilter.all(sessionId, limit, offset);
|
|
} else if (status) {
|
|
rows = stmts.listWorkflowsByStatus.all(status, limit, offset);
|
|
} else {
|
|
rows = stmts.listWorkflows.all(limit, offset);
|
|
}
|
|
|
|
const total = status
|
|
? stmts.countWorkflowsByStatus.get(status).n
|
|
: stmts.countWorkflows.get().n;
|
|
const counts = {};
|
|
for (const r of stmts.workflowStatusCounts.all()) counts[r.status] = r.n;
|
|
|
|
res.json({ runs: rows.map(hydrateWorkflow), total, counts, limit, offset });
|
|
} catch (err) {
|
|
res.status(500).json({ error: { code: "WORKFLOW_LIST_FAILED", message: err.message } });
|
|
}
|
|
});
|
|
|
|
// GET /runs/:runId — one run with its linked inner agents + their events.
|
|
router.get("/runs/:runId", (req, res) => {
|
|
try {
|
|
const wf = stmts.getWorkflow.get(req.params.runId);
|
|
if (!wf) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: { code: "WORKFLOW_NOT_FOUND", message: "Workflow run not found" } });
|
|
}
|
|
const agents = stmts.listAgentsByWorkflow.all(req.params.runId);
|
|
// Events attributed to this run's inner agents (chronological).
|
|
let events = [];
|
|
if (agents.length > 0) {
|
|
const ids = agents.map((a) => a.id);
|
|
const placeholders = ids.map(() => "?").join(",");
|
|
events = db
|
|
.prepare(
|
|
`SELECT * FROM events WHERE agent_id IN (${placeholders}) ORDER BY created_at ASC, id ASC LIMIT 5000`
|
|
)
|
|
.all(...ids);
|
|
}
|
|
res.json({ workflow: hydrateWorkflow(wf), agents, events });
|
|
} catch (err) {
|
|
res.status(500).json({ error: { code: "WORKFLOW_DETAIL_FAILED", message: err.message } });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|