57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
847 lines
29 KiB
JavaScript
847 lines
29 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.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const { Router } = require("express");
|
||
const { db, stmts } = require("../db");
|
||
|
||
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) => {
|
||
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);
|
||
} 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 } });
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 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 = {};
|
||
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;
|