feat: Claude Code Monitor — lanes, pipelines and a merged workspace
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.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file Express router for managing agents, providing endpoints to list, retrieve, create, and update agents. It interacts with the database using prepared statements and broadcasts changes to connected WebSocket clients for real-time updates.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { attachAgentCosts } = require("./pricing");
|
||||
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const rawLimit = parseInt(req.query.limit);
|
||||
const limit = rawLimit > 0 ? rawLimit : 10000;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
const status = req.query.status;
|
||||
const session_id = req.query.session_id;
|
||||
const sources = parseSources(req);
|
||||
|
||||
let rows;
|
||||
if (session_id) {
|
||||
// A session belongs to exactly one source, so no extra source filter needed.
|
||||
rows = stmts.listAgentsBySession.all(session_id);
|
||||
} else if (sources) {
|
||||
// Data-scope active: build a dynamic query restricting agents to sessions
|
||||
// from the chosen machines. `agents` carries only session_id → subquery.
|
||||
const scope = sessionIdInSourcesClause(sources, "session_id");
|
||||
const clauses = [scope.clause];
|
||||
const params = [...scope.params];
|
||||
if (status) {
|
||||
clauses.push("status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY started_at DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params, limit, offset);
|
||||
} else if (status) {
|
||||
rows = stmts.listAgentsByStatus.all(status, limit, offset);
|
||||
} else {
|
||||
rows = stmts.listAgents.all(limit, offset);
|
||||
}
|
||||
|
||||
// Attach each agent's OWN cost (from its metadata token buckets) so subagent
|
||||
// cards can show their real cost instead of the session total.
|
||||
res.json({ agents: attachAgentCosts(rows), limit, offset });
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const agent = stmts.getAgent.get(req.params.id);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Agent not found" } });
|
||||
}
|
||||
res.json({ agent });
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const { id, session_id, name, type, subagent_type, status, task, parent_agent_id, metadata } =
|
||||
req.body;
|
||||
if (!id || !session_id || !name) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "id, session_id, and name are required" },
|
||||
});
|
||||
}
|
||||
|
||||
const existing = stmts.getAgent.get(id);
|
||||
if (existing) {
|
||||
return res.json({ agent: existing, created: false });
|
||||
}
|
||||
|
||||
stmts.insertAgent.run(
|
||||
id,
|
||||
session_id,
|
||||
name,
|
||||
type || "main",
|
||||
subagent_type || null,
|
||||
status || "waiting",
|
||||
task || null,
|
||||
parent_agent_id || null,
|
||||
metadata ? JSON.stringify(metadata) : null
|
||||
);
|
||||
|
||||
const agent = stmts.getAgent.get(id);
|
||||
broadcast("agent_created", agent);
|
||||
res.status(201).json({ agent, created: true });
|
||||
});
|
||||
|
||||
router.patch("/:id", (req, res) => {
|
||||
const { name, status, task, current_tool, ended_at, metadata } = req.body;
|
||||
const existing = stmts.getAgent.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Agent not found" } });
|
||||
}
|
||||
|
||||
stmts.updateAgent.run(
|
||||
name || null,
|
||||
status || null,
|
||||
task || null,
|
||||
current_tool !== undefined ? current_tool : existing.current_tool,
|
||||
ended_at || null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
const agent = stmts.getAgent.get(req.params.id);
|
||||
broadcast("agent_updated", agent);
|
||||
res.json({ agent });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file Express router for the rules-based alerting engine: CRUD for alert
|
||||
* rules, the fired-alert feed with pagination and unacked filtering, and
|
||||
* acknowledge endpoints. Rule evaluation itself lives in server/lib/alerts.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { stmts } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { RULE_TYPES, validateRuleConfig, invalidateRuleCache } = require("../lib/alerts");
|
||||
|
||||
const router = Router();
|
||||
|
||||
function serializeRule(row) {
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(row.config || "{}");
|
||||
} catch {
|
||||
/* surface as empty config rather than failing the request */
|
||||
}
|
||||
return { ...row, config, enabled: row.enabled === 1 };
|
||||
}
|
||||
|
||||
// GET /api/alerts/rules - List all alert rules
|
||||
router.get("/rules", (_req, res) => {
|
||||
res.json({ rules: stmts.listAlertRules.all().map(serializeRule) });
|
||||
});
|
||||
|
||||
// POST /api/alerts/rules - Create an alert rule
|
||||
router.post("/rules", (req, res) => {
|
||||
const { name, rule_type, config, enabled, cooldown_seconds } = req.body || {};
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "name is required" },
|
||||
});
|
||||
}
|
||||
const validated = validateRuleConfig(rule_type, config);
|
||||
if (!validated.ok) {
|
||||
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
|
||||
}
|
||||
const cooldown =
|
||||
cooldown_seconds == null ? 300 : Number.isInteger(cooldown_seconds) ? cooldown_seconds : -1;
|
||||
if (cooldown < 0) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
|
||||
});
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
stmts.insertAlertRule.run(
|
||||
id,
|
||||
name.trim(),
|
||||
rule_type,
|
||||
JSON.stringify(validated.config),
|
||||
enabled === false ? 0 : 1,
|
||||
cooldown
|
||||
);
|
||||
invalidateRuleCache();
|
||||
res.status(201).json({ rule: serializeRule(stmts.getAlertRule.get(id)) });
|
||||
});
|
||||
|
||||
// PATCH /api/alerts/rules/:id - Update an alert rule (partial)
|
||||
router.patch("/rules/:id", (req, res) => {
|
||||
const existing = stmts.getAlertRule.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } });
|
||||
}
|
||||
const { name, config, enabled, cooldown_seconds } = req.body || {};
|
||||
|
||||
if (name != null && (typeof name !== "string" || !name.trim())) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "name must be a non-empty string" },
|
||||
});
|
||||
}
|
||||
let configJson = null;
|
||||
if (config != null) {
|
||||
// rule_type is immutable — validate the new config against the stored type
|
||||
const validated = validateRuleConfig(existing.rule_type, config);
|
||||
if (!validated.ok) {
|
||||
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
|
||||
}
|
||||
configJson = JSON.stringify(validated.config);
|
||||
}
|
||||
if (cooldown_seconds != null && (!Number.isInteger(cooldown_seconds) || cooldown_seconds < 0)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
|
||||
});
|
||||
}
|
||||
|
||||
stmts.updateAlertRule.run(
|
||||
name != null ? name.trim() : null,
|
||||
configJson,
|
||||
enabled == null ? null : enabled ? 1 : 0,
|
||||
cooldown_seconds ?? null,
|
||||
req.params.id
|
||||
);
|
||||
invalidateRuleCache();
|
||||
res.json({ rule: serializeRule(stmts.getAlertRule.get(req.params.id)) });
|
||||
});
|
||||
|
||||
// DELETE /api/alerts/rules/:id - Delete an alert rule (its alert history
|
||||
// cascades away with it — the FK is ON DELETE CASCADE)
|
||||
router.delete("/rules/:id", (req, res) => {
|
||||
const existing = stmts.getAlertRule.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } });
|
||||
}
|
||||
stmts.deleteAlertRule.run(req.params.id);
|
||||
invalidateRuleCache();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/alerts - Fired-alert feed, newest first. ?unacked=true filters to
|
||||
// unacknowledged alerts; limit/offset paginate.
|
||||
router.get("/", (req, res) => {
|
||||
// Clamp to sane bounds — negative values would make SQLite's LIMIT/OFFSET
|
||||
// misbehave (a negative LIMIT means "no limit").
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 50, 200));
|
||||
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||
const unackedOnly = req.query.unacked === "true";
|
||||
|
||||
const alerts = unackedOnly
|
||||
? stmts.listUnackedAlertEvents.all(limit, offset)
|
||||
: stmts.listAlertEvents.all(limit, offset);
|
||||
const total = unackedOnly
|
||||
? stmts.countUnackedAlertEvents.get().count
|
||||
: stmts.countAlertEvents.get().count;
|
||||
const unacked = stmts.countUnackedAlertEvents.get().count;
|
||||
|
||||
res.json({ alerts, total, unacked, limit, offset });
|
||||
});
|
||||
|
||||
// POST /api/alerts/:id/ack - Acknowledge one alert
|
||||
router.post("/:id(\\d+)/ack", (req, res) => {
|
||||
const alert = stmts.getAlertEvent.get(req.params.id);
|
||||
if (!alert) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert not found" } });
|
||||
}
|
||||
stmts.ackAlertEvent.run(req.params.id);
|
||||
const updated = stmts.getAlertEvent.get(req.params.id);
|
||||
broadcast("alert_updated", updated);
|
||||
res.json({ alert: updated });
|
||||
});
|
||||
|
||||
// POST /api/alerts/ack-all - Acknowledge every unacked alert
|
||||
router.post("/ack-all", (_req, res) => {
|
||||
const info = stmts.ackAllAlertEvents.run();
|
||||
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
|
||||
res.json({ ok: true, acknowledged: info.changes });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.RULE_TYPES = RULE_TYPES;
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more. It queries the database for various metrics and returns them in a structured JSON format for frontend consumption.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources } = require("../lib/source-filter");
|
||||
const scoped = require("../lib/scoped-stats");
|
||||
|
||||
const { calculateCost } = require("./pricing");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
|
||||
// Negate it to get the SQLite modifier: 420 → '-420 minutes'
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
|
||||
// Data-scope: restrict every metric to a subset of source machines when the
|
||||
// user has chosen one; otherwise use the cached prepared statements.
|
||||
const sources = parseSources(req);
|
||||
const tokenTotals = sources ? scoped.tokenTotals(db, sources) : stmts.getTokenTotals.get();
|
||||
const toolUsage = sources ? scoped.toolUsageCounts(db, sources) : stmts.toolUsageCounts.all();
|
||||
const dailyEvents = sources
|
||||
? scoped.dailyEventCounts(db, sources, tzModifier)
|
||||
: stmts.dailyEventCounts.all(tzModifier);
|
||||
const dailySessions = sources
|
||||
? scoped.dailySessionCounts(db, sources, tzModifier)
|
||||
: stmts.dailySessionCounts.all(tzModifier);
|
||||
const agentTypes = sources
|
||||
? scoped.agentTypeDistribution(db, sources)
|
||||
: stmts.agentTypeDistribution.all();
|
||||
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
|
||||
const agentsByStatus = sources
|
||||
? scoped.agentStatusCounts(db, sources)
|
||||
: stmts.agentStatusCounts.all();
|
||||
const sessionsByStatus = sources
|
||||
? scoped.sessionStatusCounts(db, sources)
|
||||
: stmts.sessionStatusCounts.all();
|
||||
const totalSubagents = sources
|
||||
? scoped.totalSubagentCount(db, sources)
|
||||
: stmts.totalSubagentCount.get();
|
||||
const eventTypes = sources ? scoped.eventTypeCounts(db, sources) : stmts.eventTypeCounts.all();
|
||||
const avgEvents = sources
|
||||
? scoped.avgEventsPerSession(db, sources)
|
||||
: stmts.avgEventsPerSession.get();
|
||||
|
||||
// Calculate total cost across all sessions
|
||||
const pricingRules = stmts.listPricing.all();
|
||||
// Join the owning session's start date so each bucket is priced at the rate
|
||||
// effective when it was used (date-effective promo rates, e.g. Sonnet 5 intro).
|
||||
const allTokenUsage = sources
|
||||
? scoped.scopedTokenUsageWithDate(db, sources)
|
||||
: db
|
||||
.prepare(
|
||||
"SELECT tu.*, DATE(s.started_at) as date FROM token_usage tu JOIN sessions s ON s.id = tu.session_id"
|
||||
)
|
||||
.all();
|
||||
|
||||
let totalCost = 0;
|
||||
for (const usage of allTokenUsage) {
|
||||
const { total_cost } = calculateCost([usage], pricingRules);
|
||||
totalCost += total_cost;
|
||||
}
|
||||
|
||||
res.json({
|
||||
tokens: {
|
||||
total_input: tokenTotals?.total_input ?? 0,
|
||||
total_output: tokenTotals?.total_output ?? 0,
|
||||
total_cache_read: tokenTotals?.total_cache_read ?? 0,
|
||||
total_cache_write: tokenTotals?.total_cache_write ?? 0,
|
||||
},
|
||||
total_cost: totalCost,
|
||||
tool_usage: toolUsage,
|
||||
daily_events: dailyEvents,
|
||||
daily_sessions: dailySessions,
|
||||
agent_types: agentTypes,
|
||||
event_types: eventTypes,
|
||||
avg_events_per_session: avgEvents?.avg ?? 0,
|
||||
total_subagents: totalSubagents?.count ?? 0,
|
||||
overview,
|
||||
agents_by_status: Object.fromEntries(agentsByStatus.map((r) => [r.status, r.count])),
|
||||
sessions_by_status: Object.fromEntries(sessionsByStatus.map((r) => [r.status, r.count])),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file cc-config.js
|
||||
* @description HTTP surface for inspecting and (carefully) mutating Claude
|
||||
* Code configuration: skills, subagents, slash commands, output styles,
|
||||
* plugins, marketplaces, MCP servers, hooks, settings, memory, keybindings,
|
||||
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
|
||||
* page.
|
||||
*
|
||||
* Read paths cover every surface. Write paths exist only for low-risk
|
||||
* text-file artifacts (skills, agents, commands, output styles, memory, and
|
||||
* per-project auto-memory files) plus the structured keybindings.json editor,
|
||||
* and always create a timestamped backup before mutating. Plugins, MCP servers,
|
||||
* and the live settings.json files stay read-only because they are written
|
||||
* concurrently by the running Claude Code CLI.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const cc = require("../lib/cc-discovery");
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
const router = Router();
|
||||
|
||||
function emitChanged(payload) {
|
||||
try {
|
||||
broadcast("cc_config_changed", { source: "dashboard", ...payload });
|
||||
} catch {
|
||||
/* websocket may not be initialised in unit tests */
|
||||
}
|
||||
}
|
||||
|
||||
// Map mutate-error codes → HTTP status. Anything unmapped is a 500.
|
||||
const ERR_TO_STATUS = {
|
||||
EBADTYPE: 400,
|
||||
EBADSCOPE: 400,
|
||||
EBADNAME: 400,
|
||||
EBADPROJECT: 400,
|
||||
EBADCONTENT: 400,
|
||||
ETOOLARGE: 413,
|
||||
EOUTOFROOT: 400,
|
||||
ENOTFOUND: 404,
|
||||
};
|
||||
|
||||
function mutateError(res, err) {
|
||||
const status = ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
function scopeOf(req) {
|
||||
const s = String(req.query.scope || "all");
|
||||
return s === "user" || s === "project" ? s : "all";
|
||||
}
|
||||
|
||||
function cwdOf(req) {
|
||||
// The dashboard server's own cwd is the natural "project" — but allow
|
||||
// override via ?cwd= so the user can inspect another working dir without
|
||||
// restarting the server.
|
||||
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
||||
return c || process.cwd();
|
||||
}
|
||||
|
||||
router.get("/overview", (req, res) => {
|
||||
res.json(cc.readOverview({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/skills", (req, res) => {
|
||||
res.json({ items: cc.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/agents", (req, res) => {
|
||||
res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/commands", (req, res) => {
|
||||
res.json({ items: cc.readCommands({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/output-styles", (req, res) => {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/plugins", (_req, res) => {
|
||||
res.json(cc.readPlugins());
|
||||
});
|
||||
|
||||
router.get("/mcp", (req, res) => {
|
||||
res.json(cc.readMcpServers({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/hooks", (req, res) => {
|
||||
res.json({ items: cc.readHooks({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/settings", (req, res) => {
|
||||
res.json({ items: cc.readSettings({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/memory", (req, res) => {
|
||||
res.json({ items: cc.readMemory({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/marketplaces", (_req, res) => {
|
||||
res.json(cc.readMarketplaces());
|
||||
});
|
||||
|
||||
router.get("/keybindings", (_req, res) => {
|
||||
res.json(cc.readKeybindings());
|
||||
});
|
||||
|
||||
// PUT /api/cc-config/keybindings — structured overwrite of the user's
|
||||
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
|
||||
// action }] }] }. Backs the file up first and preserves top-level metadata.
|
||||
router.put("/keybindings", (req, res) => {
|
||||
const { groups } = req.body || {};
|
||||
if (!Array.isArray(groups)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.writeKeybindings({ groups });
|
||||
emitChanged({ action: "write", type: "keybindings", name: null, project: null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/statusline", (_req, res) => {
|
||||
res.json(cc.readStatusline());
|
||||
});
|
||||
|
||||
router.get("/hook-scripts", (_req, res) => {
|
||||
res.json(cc.readHookScripts());
|
||||
});
|
||||
|
||||
// GET /api/cc-config/file?path=/abs/path — return body of a single file.
|
||||
// Path must resolve under CLAUDE_HOME, project .claude/, or be project CLAUDE.md.
|
||||
router.get("/file", (req, res) => {
|
||||
const p = req.query.path;
|
||||
if (typeof p !== "string" || !p) {
|
||||
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
|
||||
}
|
||||
const result = cc.readFileSafe(p, { cwd: cwdOf(req) });
|
||||
if (result.error)
|
||||
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Phase-2 mutation endpoints ─────────────────────────────────────────
|
||||
//
|
||||
// PUT /api/cc-config/file — create or overwrite. Body: { scope, type, name?, content }
|
||||
// DELETE /api/cc-config/file — delete. Body: { scope, type, name? }
|
||||
// GET /api/cc-config/backups[?scope=&type=] — list backups
|
||||
//
|
||||
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
|
||||
// not mutable here. See cc-mutate.js for the rationale.
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
const { scope, type, name, content, project } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.writeArtifact({ scope, type, name, content, project, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
const { scope, type, name, project } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/backups", (req, res) => {
|
||||
const scope =
|
||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||
const type = typeof req.query.type === "string" ? req.query.type : undefined;
|
||||
res.json({ items: ccMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file Express router for event endpoints. Supports listing events with
|
||||
* multi-dimensional filtering (event type, tool name, agent, session, text
|
||||
* search, date range) plus pagination. Also exposes a `/facets` endpoint that
|
||||
* returns the distinct event_type and tool_name values currently in the DB,
|
||||
* so the UI can populate filter dropdowns without hardcoding them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { db } = require("../db");
|
||||
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const MAX_LIMIT = 500;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
function parseCsv(value) {
|
||||
if (value == null) return null;
|
||||
const raw = Array.isArray(value) ? value.join(",") : String(value);
|
||||
const parts = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts : null;
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function clampInt(raw, fallback, min, max) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return fallback;
|
||||
return Math.min(Math.max(n, min), max);
|
||||
}
|
||||
|
||||
// Builds the WHERE clause + param array for a given filter set. Used by both
|
||||
// the list and count queries so they stay in sync.
|
||||
function buildWhere(filters) {
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
|
||||
const inClause = (field, values) => {
|
||||
clauses.push(`${field} IN (${values.map(() => "?").join(",")})`);
|
||||
params.push(...values);
|
||||
};
|
||||
|
||||
if (filters.event_type) inClause("event_type", filters.event_type);
|
||||
if (filters.tool_name) inClause("tool_name", filters.tool_name);
|
||||
if (filters.agent_id) inClause("agent_id", filters.agent_id);
|
||||
if (filters.session_id) inClause("session_id", filters.session_id);
|
||||
|
||||
if (filters.q) {
|
||||
clauses.push("(summary LIKE ? OR tool_name LIKE ? OR data LIKE ?)");
|
||||
const pattern = `%${filters.q}%`;
|
||||
params.push(pattern, pattern, pattern);
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
clauses.push("created_at >= ?");
|
||||
params.push(filters.from);
|
||||
}
|
||||
if (filters.to) {
|
||||
clauses.push("created_at <= ?");
|
||||
params.push(filters.to);
|
||||
}
|
||||
|
||||
// Data-scope: restrict to events whose session was collected from a chosen
|
||||
// set of machines. `events` carries only session_id, so filter via subquery.
|
||||
const scope = sessionIdInSourcesClause(filters.sources, "session_id");
|
||||
if (scope.clause) {
|
||||
clauses.push(scope.clause);
|
||||
params.push(...scope.params);
|
||||
}
|
||||
|
||||
return {
|
||||
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/events?event_type=a,b&tool_name=Bash&q=curl&from=...&to=...&limit=50&offset=0
|
||||
router.get("/", (req, res) => {
|
||||
const limit = clampInt(req.query.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);
|
||||
const offset = clampInt(req.query.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const filters = {
|
||||
event_type: parseCsv(req.query.event_type),
|
||||
tool_name: parseCsv(req.query.tool_name),
|
||||
agent_id: parseCsv(req.query.agent_id),
|
||||
session_id: parseCsv(req.query.session_id),
|
||||
q: typeof req.query.q === "string" && req.query.q.trim() !== "" ? req.query.q.trim() : null,
|
||||
from: parseDate(req.query.from),
|
||||
to: parseDate(req.query.to),
|
||||
sources: parseSources(req),
|
||||
};
|
||||
|
||||
const { sql: whereSql, params: whereParams } = buildWhere(filters);
|
||||
|
||||
const listSql = `SELECT * FROM events ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`;
|
||||
const countSql = `SELECT COUNT(*) as count FROM events ${whereSql}`;
|
||||
|
||||
const events = db.prepare(listSql).all(...whereParams, limit, offset);
|
||||
const { count: total } = db.prepare(countSql).get(...whereParams);
|
||||
|
||||
res.json({ events, limit, offset, total });
|
||||
});
|
||||
|
||||
// GET /api/events/facets — distinct event_type / tool_name values in the DB.
|
||||
router.get("/facets", (_req, res) => {
|
||||
const eventTypes = db
|
||||
.prepare(
|
||||
"SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL ORDER BY event_type"
|
||||
)
|
||||
.all()
|
||||
.map((r) => r.event_type);
|
||||
|
||||
const toolNames = db
|
||||
.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL ORDER BY tool_name")
|
||||
.all()
|
||||
.map((r) => r.tool_name);
|
||||
|
||||
res.json({ event_types: eventTypes, tool_names: toolNames });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* @file Express router for the Import History feature.
|
||||
*
|
||||
* Three entry points, all of which funnel into the exact same parser +
|
||||
* `importSession` pipeline the server uses for live ingestion — guaranteeing
|
||||
* that imported tokens, per-model breakdowns, cost calculations, compactions,
|
||||
* subagents, tool events, API errors, and turn durations line up bit-for-bit
|
||||
* with sessions captured in real time.
|
||||
*
|
||||
* GET /api/import/guide — OS-aware instructions + default paths
|
||||
* POST /api/import/rescan — re-scan the default ~/.claude/projects dir
|
||||
* POST /api/import/scan-path — scan an arbitrary absolute directory path
|
||||
* POST /api/import/upload — multipart: JSONLs and/or archives
|
||||
*
|
||||
* Progress is broadcast over the existing websocket as `import.progress`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const { broadcast } = require("../websocket");
|
||||
const {
|
||||
importAllSessions,
|
||||
importFromDirectory,
|
||||
collectJsonlFiles,
|
||||
} = require("../../scripts/import-history");
|
||||
const {
|
||||
mkTempDir,
|
||||
rmTempDir,
|
||||
extractInto,
|
||||
detectKind,
|
||||
ExtractionLimitError,
|
||||
} = require("../lib/archive");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const { getClaudeHome, getProjectsDir } = require("../lib/claude-home");
|
||||
|
||||
// Upload limits — deliberately generous because transcripts can be large.
|
||||
// Configurable at runtime via env for deployments that need tighter bounds.
|
||||
const MAX_UPLOAD_BYTES = parseInt(
|
||||
process.env.CCAM_IMPORT_MAX_BYTES || String(1024 * 1024 * 1024), // 1 GB default
|
||||
10
|
||||
);
|
||||
const MAX_UPLOAD_FILES = parseInt(process.env.CCAM_IMPORT_MAX_FILES || "2000", 10);
|
||||
|
||||
/**
|
||||
* Lazily build a multer upload middleware. Kept lazy so the server still
|
||||
* boots if `multer` isn't installed yet — only /upload fails in that case.
|
||||
*
|
||||
* Each request gets its own staging directory created on the `req` object
|
||||
* during the first call to `destination`. Multer invokes `destination` once
|
||||
* per uploaded file, all within the same request, so a sentinel on `req`
|
||||
* avoids creating multiple dirs per request while guaranteeing isolation
|
||||
* across concurrent requests.
|
||||
*/
|
||||
function getUploader() {
|
||||
let multer;
|
||||
try {
|
||||
multer = require("multer");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
if (!req._ccamUploadDir) req._ccamUploadDir = mkTempDir("ccam-upload-");
|
||||
cb(null, req._ccamUploadDir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
// Preserve the original name for kind-detection later, but prefix with
|
||||
// a random token so collisions between two uploads with the same name
|
||||
// don't clobber each other.
|
||||
const rand = require("crypto").randomBytes(4).toString("hex");
|
||||
cb(null, `${rand}__${file.originalname}`);
|
||||
},
|
||||
});
|
||||
return multer({
|
||||
storage,
|
||||
limits: {
|
||||
files: MAX_UPLOAD_FILES,
|
||||
fileSize: MAX_UPLOAD_BYTES,
|
||||
fields: 32,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const kind = detectKind(file.originalname);
|
||||
if (kind === "unknown") {
|
||||
// Track rejected filenames on the request so we can surface the count
|
||||
// in the response — users wonder why their upload "partially worked".
|
||||
if (!req._ccamRejected) req._ccamRejected = [];
|
||||
req._ccamRejected.push(file.originalname);
|
||||
cb(null, false);
|
||||
} else {
|
||||
cb(null, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle progress broadcasts so we don't flood the websocket on large imports.
|
||||
*/
|
||||
function makeProgressBroadcaster(importId) {
|
||||
let lastSent = 0;
|
||||
return (progress) => {
|
||||
const now = Date.now();
|
||||
if (progress.phase === "complete" || now - lastSent > 150) {
|
||||
lastSent = now;
|
||||
broadcast("import.progress", { importId, ...progress });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function countsSummary(counters) {
|
||||
return {
|
||||
imported: counters.imported || 0,
|
||||
skipped: counters.skipped || 0,
|
||||
backfilled: counters.backfilled || 0,
|
||||
errors: counters.errors || 0,
|
||||
sessions_seen: counters.sessionsSeen || 0,
|
||||
files_scanned: counters.filesScanned || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/import/guide — step-by-step instructions the UI renders verbatim.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
router.get("/guide", (_req, res) => {
|
||||
const platform = process.platform;
|
||||
const claudeHome = getClaudeHome();
|
||||
const claudeHomeDisplay = claudeHome.replace(os.homedir(), "~");
|
||||
const projectsDisplay = path.join(claudeHomeDisplay, "projects");
|
||||
const defaults = {
|
||||
darwin: projectsDisplay,
|
||||
linux: projectsDisplay,
|
||||
win32: projectsDisplay.replace(/\//g, "\\"),
|
||||
};
|
||||
const archiveBase = claudeHomeDisplay;
|
||||
const archiveCmd = {
|
||||
darwin: `tar -czf claude-history.tar.gz -C ${archiveBase} projects`,
|
||||
linux: `tar -czf claude-history.tar.gz -C ${archiveBase} projects`,
|
||||
win32: `tar -czf claude-history.tar.gz -C "${projectsDisplay.replace(/\//g, "\\")}" projects`,
|
||||
};
|
||||
const exists = fs.existsSync(getProjectsDir());
|
||||
let projectCount = 0;
|
||||
let fileCount = 0;
|
||||
if (exists) {
|
||||
try {
|
||||
const dirs = fs
|
||||
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
projectCount = dirs.length;
|
||||
for (const d of dirs) {
|
||||
try {
|
||||
fileCount += fs
|
||||
.readdirSync(path.join(getProjectsDir(), d.name))
|
||||
.filter((f) => f.endsWith(".jsonl")).length;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
platform,
|
||||
default_projects_dir: getProjectsDir(),
|
||||
default_projects_dir_display: defaults[platform] || getProjectsDir(),
|
||||
default_projects_dir_exists: exists,
|
||||
default_projects_dir_stats: { projects: projectCount, jsonl_files: fileCount },
|
||||
archive_command: archiveCmd[platform] || archiveCmd.linux,
|
||||
supported_extensions: [".jsonl", ".meta.json", ".zip", ".tar", ".tar.gz", ".tgz", ".gz"],
|
||||
max_upload_bytes: MAX_UPLOAD_BYTES,
|
||||
max_upload_files: MAX_UPLOAD_FILES,
|
||||
steps: [
|
||||
{
|
||||
id: "locate",
|
||||
title: "Locate your Claude Code history",
|
||||
body: `Claude Code stores every session as a JSONL transcript under ${defaults[platform] || defaults.linux}. Each subdirectory is named after the working directory where the session started (with slashes replaced by dashes).`,
|
||||
},
|
||||
{
|
||||
id: "archive",
|
||||
title: "Bundle it for transfer (optional)",
|
||||
body: `If you're importing from another machine, archive the whole projects folder first:\n\n ${archiveCmd[platform] || archiveCmd.linux}\n\nMove claude-history.tar.gz to this machine however you like (AirDrop, scp, USB, cloud storage).`,
|
||||
},
|
||||
{
|
||||
id: "choose",
|
||||
title: "Pick an import mode",
|
||||
body: "Rescan default: re-read ~/.claude/projects on this machine and import anything new. From folder: point the dashboard at any directory you've extracted history into. Upload: drag-drop JSONL files or an archive directly into the browser.",
|
||||
},
|
||||
{
|
||||
id: "verify",
|
||||
title: "Verify tokens and cost",
|
||||
body: "Imports are idempotent: re-running is always safe. Token counts are deduplicated per session ID, with compaction baselines preserved so cost never double-counts. After import, open Analytics → Cost to confirm the breakdown.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// POST /api/import/rescan — default ~/.claude/projects directory.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
router.post("/rescan", async (_req, res) => {
|
||||
const importId = `rescan-${Date.now()}`;
|
||||
try {
|
||||
broadcast("import.progress", { importId, phase: "start", source: "default" });
|
||||
const dbModule = require("../db");
|
||||
const result = await importAllSessions(dbModule);
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "complete",
|
||||
source: "default",
|
||||
counters: result,
|
||||
});
|
||||
res.json({ ok: true, source: "default", ...result });
|
||||
} catch (err) {
|
||||
broadcast("import.progress", { importId, phase: "error", error: err.message });
|
||||
res.status(500).json({ error: { code: "IMPORT_FAILED", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// POST /api/import/scan-path — arbitrary absolute directory.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
router.post("/scan-path", async (req, res) => {
|
||||
const importId = `scan-${Date.now()}`;
|
||||
const rawPath = (req.body && req.body.path) || "";
|
||||
if (typeof rawPath !== "string" || !rawPath.trim()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "`path` is required" } });
|
||||
}
|
||||
|
||||
// Expand ~ to the user's home directory for convenience.
|
||||
const expanded = rawPath.startsWith("~") ? path.join(os.homedir(), rawPath.slice(1)) : rawPath;
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "`path` must be an absolute path" },
|
||||
});
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(expanded);
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
error: { code: "PATH_NOT_FOUND", message: `Path does not exist: ${expanded}` },
|
||||
});
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return res.status(400).json({
|
||||
error: { code: "NOT_A_DIRECTORY", message: `Path is not a directory: ${expanded}` },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const onProgress = makeProgressBroadcaster(importId);
|
||||
broadcast("import.progress", { importId, phase: "start", source: "path", path: expanded });
|
||||
const dbModule = require("../db");
|
||||
const counters = await importFromDirectory(dbModule, expanded, { onProgress });
|
||||
const summary = countsSummary(counters);
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "complete",
|
||||
source: "path",
|
||||
counters: summary,
|
||||
});
|
||||
res.json({ ok: true, source: "path", path: expanded, ...summary });
|
||||
} catch (err) {
|
||||
broadcast("import.progress", { importId, phase: "error", error: err.message });
|
||||
res.status(500).json({ error: { code: "IMPORT_FAILED", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// POST /api/import/upload — multipart: JSONL files and/or archives.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
const uploader = getUploader();
|
||||
const uploadMiddleware = uploader
|
||||
? uploader.array("files", MAX_UPLOAD_FILES)
|
||||
: (_req, _res, next) => next();
|
||||
|
||||
router.post("/upload", uploadMiddleware, async (req, res) => {
|
||||
const importId = `upload-${Date.now()}`;
|
||||
if (!uploader) {
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
code: "UPLOADER_UNAVAILABLE",
|
||||
message: "File upload requires `multer`. Run `npm install` to pick up new deps.",
|
||||
},
|
||||
});
|
||||
}
|
||||
const files = Array.isArray(req.files) ? req.files : [];
|
||||
const rejectedNames = Array.isArray(req._ccamRejected) ? req._ccamRejected : [];
|
||||
const reqUploadDir = req._ccamUploadDir || null;
|
||||
|
||||
if (files.length === 0) {
|
||||
// Clean up the upload dir if multer created one before rejecting all files.
|
||||
if (reqUploadDir) rmTempDir(reqUploadDir);
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "NO_FILES",
|
||||
message:
|
||||
rejectedNames.length > 0
|
||||
? `No supported files in upload. ${rejectedNames.length} file(s) rejected (unsupported extension).`
|
||||
: "No files received",
|
||||
},
|
||||
rejected_files: rejectedNames,
|
||||
});
|
||||
}
|
||||
|
||||
const workDir = mkTempDir("ccam-import-work-");
|
||||
let extractedCount = 0;
|
||||
let skippedEntries = 0;
|
||||
|
||||
try {
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "extract",
|
||||
source: "upload",
|
||||
total: files.length,
|
||||
processed: 0,
|
||||
});
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
try {
|
||||
const result = await extractInto(f.path, workDir, f.originalname);
|
||||
extractedCount += result.extracted;
|
||||
skippedEntries += result.skipped;
|
||||
} catch (err) {
|
||||
if (err instanceof ExtractionLimitError) {
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "error",
|
||||
error: err.message,
|
||||
});
|
||||
return res.status(413).json({
|
||||
error: { code: err.code, message: err.message },
|
||||
offending_file: f.originalname,
|
||||
});
|
||||
}
|
||||
skippedEntries += 1;
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "extract_error",
|
||||
current: f.originalname,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "extract",
|
||||
source: "upload",
|
||||
processed: i + 1,
|
||||
total: files.length,
|
||||
current: f.originalname,
|
||||
});
|
||||
}
|
||||
|
||||
// Even if extraction yielded zero files, the user may have uploaded a single
|
||||
// JSONL that was copied directly — `collectJsonlFiles` will find it.
|
||||
const jsonlPresent = collectJsonlFiles(workDir).length;
|
||||
if (jsonlPresent === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "NO_JSONL",
|
||||
message:
|
||||
"No .jsonl files were found in the uploaded content. Supported inputs: .jsonl, .meta.json, .zip, .tar, .tar.gz, .tgz, .gz.",
|
||||
},
|
||||
extracted: extractedCount,
|
||||
skipped_entries: skippedEntries,
|
||||
});
|
||||
}
|
||||
|
||||
const onProgress = makeProgressBroadcaster(importId);
|
||||
const dbModule = require("../db");
|
||||
const counters = await importFromDirectory(dbModule, workDir, { onProgress });
|
||||
const summary = countsSummary(counters);
|
||||
|
||||
broadcast("import.progress", {
|
||||
importId,
|
||||
phase: "complete",
|
||||
source: "upload",
|
||||
counters: summary,
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
source: "upload",
|
||||
files_received: files.length,
|
||||
rejected_files: rejectedNames,
|
||||
entries_extracted: extractedCount,
|
||||
entries_skipped: skippedEntries,
|
||||
...summary,
|
||||
});
|
||||
} catch (err) {
|
||||
broadcast("import.progress", { importId, phase: "error", error: err.message });
|
||||
res.status(500).json({ error: { code: "IMPORT_FAILED", message: err.message } });
|
||||
} finally {
|
||||
// Always reclaim disk: the per-request staging dir, the extraction work
|
||||
// dir, and any loose multer files (usually subsumed by the staging dir,
|
||||
// but we unlink explicitly in case multer kept them elsewhere).
|
||||
rmTempDir(workDir);
|
||||
for (const f of files) {
|
||||
try {
|
||||
fs.unlinkSync(f.path);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
if (reqUploadDir) rmTempDir(reqUploadDir);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* @file Express router for lanes — the durable per-working-directory unit of
|
||||
* parallel agent work. Read endpoints join each lane with its most recent event
|
||||
* timestamp so liveness can be computed without a separate heartbeat, and every
|
||||
* mutation re-broadcasts the lane over the existing WebSocket as `lane_update`.
|
||||
* Orchestration is deliberately absent: the driving Claude session declares its
|
||||
* own stage (`POST /:id/stage`); the dashboard never guesses a transition.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { listPipelines } = require("../lib/pipelines");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/run-spawner");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
addWorktree,
|
||||
gitFacts,
|
||||
isGitRepo,
|
||||
listBranches,
|
||||
removeWorktree,
|
||||
resetWorktree,
|
||||
resolveBase,
|
||||
slugify,
|
||||
} = require("../lib/worktree");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
|
||||
const router = Router();
|
||||
const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
|
||||
|
||||
/** Seconds since this lane's session last emitted an event; null if never. */
|
||||
function lastEventAge(lane) {
|
||||
if (!lane.session_id) return null;
|
||||
const row = db
|
||||
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
|
||||
.get(lane.session_id);
|
||||
if (!row || !row.last) return null;
|
||||
const t = Date.parse(row.last);
|
||||
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
|
||||
}
|
||||
|
||||
function payload(lane) {
|
||||
return lanesLib.lanePayload(lane, lastEventAge(lane));
|
||||
}
|
||||
|
||||
/** Push the current state of one lane to every connected client. */
|
||||
function broadcastLane(id) {
|
||||
const lane = lanesLib.getLane(id);
|
||||
if (lane) broadcast("lane_update", { lane: payload(lane) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lane holding a run that has just finished. Registered as a
|
||||
* callback because the spawner must not require this router back: it is
|
||||
* already required FROM here, and broadcastLane needs this file's payload().
|
||||
*
|
||||
* No lane lock: the read, the guard and the write are one synchronous
|
||||
* better-sqlite3 sequence with no `await` between them, so nothing can
|
||||
* interleave. Matching run_id is what keeps a lane that has already moved on to
|
||||
* a different run untouched.
|
||||
*/
|
||||
runs.setRunExitHandler(({ runId }) => {
|
||||
const lane = lanesLib.listLanes().find((l) => l.run_id === runId);
|
||||
if (!lane) return;
|
||||
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
|
||||
broadcastLane(lane.id);
|
||||
});
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const lanes = lanesLib.listLanes().map(payload);
|
||||
res.json({
|
||||
lanes,
|
||||
counts: {
|
||||
total: lanes.length,
|
||||
running: lanes.filter((l) => l.status === "running").length,
|
||||
needs_you: lanes.filter((l) => l.needs_action).length,
|
||||
dead: lanes.filter((l) => l.liveness === "dead").length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Registered before "/:id" so the literal path is not swallowed by the param.
|
||||
router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() }));
|
||||
|
||||
/**
|
||||
* Local branches of a candidate source repo, for the "Add lane" picker: pick
|
||||
* a repo, then pick which branch to fork the new worktree from, instead of
|
||||
* typing a branch name and hoping it exists. Same validation as `/worktree`
|
||||
* (below), since a repo this can't resolve branches for can't be provisioned
|
||||
* from either. Read-only, so no same-origin guard.
|
||||
*/
|
||||
router.get("/branches", async (req, res) => {
|
||||
const sourceRepo = typeof req.query.repo === "string" ? req.query.repo : "";
|
||||
if (!sourceRepo || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" },
|
||||
});
|
||||
}
|
||||
if (!(await isGitRepo(sourceRepo))) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { branches, current } = await listBranches(sourceRepo);
|
||||
res.json({ branches, current });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
|
||||
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
|
||||
* ever creating a duplicate. Also registered before "/:id".
|
||||
*/
|
||||
router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
const body = req.body || {};
|
||||
const owner = lanesLib.resolveLaneByCwd(body.cwd);
|
||||
if (owner) return res.json({ lane: payload(owner), created: false });
|
||||
try {
|
||||
const lane = lanesLib.createLane({ cwd: body.cwd, title: body.title || "" });
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({ lane: payload(lane), created: true });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
|
||||
// re-read and return THEIR lane rather than reporting a conflict.
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
const winner = lanesLib.resolveLaneByCwd(body.cwd);
|
||||
if (winner) return res.json({ lane: payload(winner), created: false });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
res.json({ lane: payload(lane) });
|
||||
});
|
||||
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const lane = lanesLib.createLane(req.body || {});
|
||||
broadcastLane(lane.id);
|
||||
res.status(201).json({ lane: payload(lane) });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADCWD") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
|
||||
}
|
||||
res.status(500).json({ error: { message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
if (!lanesLib.getLane(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
}
|
||||
let lane;
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` is invalid input, not a server fault — every sibling route
|
||||
// answers 400 here, so this one must too instead of throwing into Express.
|
||||
if (err.code === "EBADKIND") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
res.json({ lane: payload(lane) });
|
||||
});
|
||||
|
||||
router.post("/:id/stage", sameOriginGuard, (req, res) => {
|
||||
if (!lanesLib.getLane(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
}
|
||||
const lane = lanesLib.setStage(req.params.id, req.body || {});
|
||||
broadcastLane(lane.id);
|
||||
res.json({ lane: payload(lane) });
|
||||
});
|
||||
|
||||
router.get("/:id/preflight", async (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
const action = String(req.query.action || "");
|
||||
if (!["reset", "remove", "purge"].includes(action)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
|
||||
}
|
||||
try {
|
||||
res.json(await preflight(lane, action));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A lane's working-copy facts: branch, short HEAD, that commit's subject, and
|
||||
* the uncommitted counts. Read-only, so no same-origin guard — that guard
|
||||
* exists for the destructive actions.
|
||||
*
|
||||
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
|
||||
* times, and that payload is polled and re-broadcast on every hook-driven
|
||||
* lane_update. Any failure — no such directory, not a repo, git itself
|
||||
* erroring — is reported as `available: false` rather than a 500, because a
|
||||
* lane pointing at a plain directory is a normal state, not a fault.
|
||||
*
|
||||
* The `/:id/:action` catch-all below cannot shadow this one — that route is a
|
||||
* POST and Express matches on method as well as path. Verified by moving this
|
||||
* registration after it: the suite stayed green.
|
||||
*/
|
||||
router.get("/:id/git", async (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
try {
|
||||
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
|
||||
} catch {
|
||||
res.json({ available: false });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a dashboard-managed worktree lane. Git work happens after the 202
|
||||
* response because provisioning a large repository can take seconds.
|
||||
*/
|
||||
router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const sourceRepo = body.sourceRepo;
|
||||
if (
|
||||
typeof sourceRepo !== "string" ||
|
||||
!path.isAbsolute(sourceRepo) ||
|
||||
!fs.existsSync(sourceRepo) ||
|
||||
!(await isGitRepo(sourceRepo))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADSOURCEREPO",
|
||||
message: "sourceRepo must be an existing absolute git repository",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let slug;
|
||||
try {
|
||||
slug = slugify(body.slug || body.title);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
const resolvedSourceRepo = path.resolve(sourceRepo);
|
||||
const repoName = path.basename(resolvedSourceRepo);
|
||||
const originalSlug = slug;
|
||||
let dir = path.join(LANES_ROOT, `${repoName}__${slug}`);
|
||||
let attempts = 0;
|
||||
while (fs.existsSync(dir) && attempts < MAX_WORKTREE_DIRECTORY_ATTEMPTS) {
|
||||
attempts += 1;
|
||||
const suffix = attempts + 1;
|
||||
slug = `${originalSlug}-${suffix}`;
|
||||
dir = path.join(LANES_ROOT, `${repoName}__${slug}`);
|
||||
}
|
||||
if (fs.existsSync(dir)) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: "EWORKTREEDIRCOLLISION",
|
||||
message: "could not allocate a unique worktree directory after 50 attempts",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
|
||||
const branch = `${branchPrefix}${slug}`;
|
||||
let lane;
|
||||
try {
|
||||
lane = lanesLib.createLane({
|
||||
title: body.title || "",
|
||||
cwd: dir,
|
||||
branch,
|
||||
kind: "managed",
|
||||
source_repo: resolvedSourceRepo,
|
||||
base_branch: body.base || null,
|
||||
slug,
|
||||
});
|
||||
lane = lanesLib.updateLane(lane.id, { status: "provisioning" });
|
||||
} catch (err) {
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
res.status(202).json({ lane: payload(lane) });
|
||||
|
||||
void withLaneLock(lane.id, async () => {
|
||||
try {
|
||||
const baseBranch = await resolveBase(
|
||||
resolvedSourceRepo,
|
||||
body.base || process.env.LANE_BASE_BRANCH || "main"
|
||||
);
|
||||
await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch });
|
||||
// base_branch is a provisioning fact, not a patchable field — see PATCHABLE.
|
||||
lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch });
|
||||
lanesLib.updateLane(lane.id, { status: "idle", notes: null });
|
||||
} catch (err) {
|
||||
lanesLib.updateLane(lane.id, {
|
||||
status: "failed",
|
||||
notes: err.git?.stderr || err.message,
|
||||
});
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]);
|
||||
// The modes the spawner accepts, same as POST /api/run.
|
||||
const RUN_MODES = new Set(["headless", "conversation"]);
|
||||
const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]);
|
||||
const RUN_EXIT_POLL_MS = 50;
|
||||
// killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough
|
||||
// time for that escalation and for Node to receive the child's real exit.
|
||||
const RUN_EXIT_TIMEOUT_MS = 7500;
|
||||
|
||||
function lifecycleError(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function expectedFields(action) {
|
||||
return action === "purge"
|
||||
? ["sessions", "events", "tokenRows"]
|
||||
: ["head", "dirty", "untracked", "unpushed"];
|
||||
}
|
||||
|
||||
/** Refuse a destructive action when the facts shown in its preflight have moved. */
|
||||
function assertExpectedPreflight(action, current, expected) {
|
||||
const fields = expectedFields(action);
|
||||
if (
|
||||
!expected ||
|
||||
typeof expected !== "object" ||
|
||||
Array.isArray(expected) ||
|
||||
fields.some((field) => !Object.hasOwn(expected, field))
|
||||
) {
|
||||
throw lifecycleError(
|
||||
"EEXPECT",
|
||||
`${action} requires a complete expect object with: ${fields.join(", ")}`
|
||||
);
|
||||
}
|
||||
const changed = fields.some((field) => current[field] !== expected[field]);
|
||||
if (changed) {
|
||||
const err = lifecycleError("ESTALE", "lane state changed since preflight");
|
||||
err.expected = expected;
|
||||
err.current = current;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Kill a lane run and wait for the child's real `exit` event before touching its cwd. */
|
||||
async function stopLaneRun(lane) {
|
||||
if (!lane.run_id) return;
|
||||
try {
|
||||
runs.killRun(lane.run_id);
|
||||
} catch {
|
||||
/* a concurrently completed run is already safe */
|
||||
}
|
||||
|
||||
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
|
||||
let run = runs.getRun(lane.run_id);
|
||||
while (run && !run.actualExitedAt) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw lifecycleError(
|
||||
"ERUNTIMEOUT",
|
||||
`lane run ${lane.run_id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||||
);
|
||||
}
|
||||
await wait(RUN_EXIT_POLL_MS);
|
||||
run = runs.getRun(lane.run_id);
|
||||
}
|
||||
lanesLib.updateLane(lane.id, { run_id: null });
|
||||
}
|
||||
|
||||
function sendLifecycleError(res, err) {
|
||||
if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["ESTALE", "EUNPUSHED"].includes(err.code)) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
...(err.code === "ESTALE" ? { expected: err.expected, current: err.current } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (err.git) {
|
||||
return res.status(500).json({
|
||||
error: { code: err.code, message: err.message, stderr: err.git.stderr },
|
||||
});
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane control. Deliberately thin: every action maps onto one existing
|
||||
* run-spawner call. There is no queue, no chaining, no gate evaluation — the
|
||||
* dashboard drives a lane, it does not orchestrate a pipeline.
|
||||
*/
|
||||
router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
const { action } = req.params;
|
||||
if (!ACTIONS.has(action)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
|
||||
}
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
const body = req.body || {};
|
||||
|
||||
if (DESTRUCTIVE_ACTIONS.has(action)) {
|
||||
if (body.confirm !== true) {
|
||||
return res.status(400).json({
|
||||
error: { code: "ECONFIRM", message: `${action} requires confirm: true` },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, async () => {
|
||||
const lockedLane = lanesLib.getLane(lane.id);
|
||||
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
|
||||
|
||||
await stopLaneRun(lockedLane);
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
const facts = await preflight(current, action);
|
||||
assertExpectedPreflight(action, facts, body.expect);
|
||||
if (
|
||||
(action === "reset" || (action === "remove" && current.kind === "managed")) &&
|
||||
facts.unpushed > 0 &&
|
||||
body.force !== true
|
||||
) {
|
||||
throw lifecycleError(
|
||||
"EUNPUSHED",
|
||||
`${action} requires force: true when commits are unpushed`
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "reset") {
|
||||
await resetWorktree(current);
|
||||
return { lane: lanesLib.clearLane(current.id) };
|
||||
}
|
||||
if (action === "remove") {
|
||||
// Forgetting an adopted lane only removes dashboard metadata. The
|
||||
// filesystem destroy guard is deliberately reached only for managed
|
||||
// worktrees, where removal can actually touch a directory.
|
||||
if (current.kind === "managed") await removeWorktree(current);
|
||||
lanesLib.deleteLane(current.id);
|
||||
return { removed: current.id };
|
||||
}
|
||||
return { purged: lanesLib.purgeLaneSessions(current.id) };
|
||||
});
|
||||
|
||||
if (action === "remove") {
|
||||
broadcast("lane_update", { removed: result.removed });
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
if (action === "purge") {
|
||||
broadcastLane(lane.id);
|
||||
return res.json({ ok: true, purged: result.purged });
|
||||
}
|
||||
broadcastLane(lane.id);
|
||||
return res.json({ lane: payload(result.lane) });
|
||||
} catch (err) {
|
||||
if (err.code === "ENOLANE") {
|
||||
return res.status(404).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return sendLifecycleError(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "start": {
|
||||
// Same two modes POST /api/run accepts. Unlike that route, an unknown
|
||||
// value is refused rather than silently coerced to a conversation.
|
||||
if (body.mode != null && !RUN_MODES.has(body.mode)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADMODE", message: `mode must be one of: headless, conversation` },
|
||||
});
|
||||
}
|
||||
// Overwriting run_id while its child is alive orphans that child: a later
|
||||
// reset would kill and await only the RECORDED run, then `git clean -fd`
|
||||
// the directory the orphan is still writing into — the exact hazard
|
||||
// actualExitedAt exists to close. Stop the first run before starting a
|
||||
// second. The check and the spawn happen under the per-lane lock so that
|
||||
// atomicity is guaranteed rather than an accident of this code having no
|
||||
// `await` between them — a future edit that adds one must not reopen the
|
||||
// race.
|
||||
const outcome = await withLaneLock(lane.id, async () => {
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
// A concurrent `remove` could have deleted the row while this request
|
||||
// waited for the lock — the lock makes that race visible instead of
|
||||
// spawning a run for a lane that no longer exists.
|
||||
if (!current) return { missing: true };
|
||||
const live = current.run_id ? runs.getRun(current.run_id) : null;
|
||||
if (live && (live.status === "spawning" || live.status === "running")) {
|
||||
return { conflict: true };
|
||||
}
|
||||
const handle = runs.spawnRun({
|
||||
mode: body.mode || "conversation",
|
||||
laneId: current.id,
|
||||
prompt: body.prompt || "",
|
||||
cwd: current.cwd,
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
effort: body.effort,
|
||||
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
||||
});
|
||||
lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
return {};
|
||||
});
|
||||
if (outcome.missing) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
}
|
||||
if (outcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "stop": {
|
||||
// A lane with no live run is already stopped — say so, don't 500.
|
||||
if (lane.run_id) {
|
||||
try {
|
||||
runs.killRun(lane.run_id);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
lanesLib.updateLane(lane.id, { status: "idle", run_id: null });
|
||||
break;
|
||||
}
|
||||
case "message": {
|
||||
if (!lane.run_id) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ENORUN", message: "lane has no live run" } });
|
||||
}
|
||||
// Check that the recorded run is actually live (spawning or running).
|
||||
// If a run finished recently, its run_id is still recorded but sendInput
|
||||
// would throw ENOTRUNNING. Return 409 so the client knows it's not a server error.
|
||||
const run = runs.getRun(lane.run_id);
|
||||
if (!run || (run.status !== "spawning" && run.status !== "running")) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ENORUN", message: "lane has no live run" } });
|
||||
}
|
||||
runs.sendInput(lane.run_id, String(body.text || ""));
|
||||
lanesLib.updateLane(lane.id, { needs_action: null });
|
||||
break;
|
||||
}
|
||||
case "clear":
|
||||
lanesLib.clearLane(lane.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
broadcastLane(lane.id);
|
||||
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
|
||||
});
|
||||
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
const ok = lanesLib.deleteLane(req.params.id);
|
||||
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
broadcast("lane_update", { removed: Number(req.params.id) });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.broadcastLane = broadcastLane;
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @file metrics.js
|
||||
* @description Prometheus / OpenMetrics text-exposition endpoint (GET /api/metrics)
|
||||
* so this monitoring dashboard can itself be scraped into Prometheus / Grafana.
|
||||
*
|
||||
* The dashboard already tracks everything an operator wants on a wall board —
|
||||
* live sessions, agent states, event throughput, token burn, connected realtime
|
||||
* clients — but only over its own websocket + REST surface. This route re-exposes
|
||||
* those same counters in the standard Prometheus text format (v0.0.4) so they can
|
||||
* flow into an existing observability stack alongside the rest of a team's infra.
|
||||
*
|
||||
* All values are read straight from the same prepared statements the REST API
|
||||
* uses (`server/db.js`), so the numbers line up exactly with the UI. The endpoint
|
||||
* is READ-ONLY, allocates nothing persistent, and — being mounted under `/api` —
|
||||
* sits behind the same guards as every other route: the DNS-rebinding Host-header
|
||||
* guard and the optional `DASHBOARD_TOKEN` guard. A scraper that reaches the
|
||||
* server as anything other than loopback (e.g. Prometheus in Docker via
|
||||
* `host.docker.internal`) must therefore be allowlisted with
|
||||
* `DASHBOARD_ALLOWED_HOSTS` (and send the token when one is set), so an instance
|
||||
* never leaks operational data to an unexpected origin. The turnkey Prometheus +
|
||||
* Grafana bundle in `monitoring/` documents the exact setup.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Resolved once at load — the dashboard version, surfaced as a build-info label
|
||||
// (the canonical Prometheus idiom for exposing a version string as a metric).
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
return require("../../package.json").version || "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
})();
|
||||
|
||||
// Statuses are enumerated (not just whatever the DB currently holds) so a metric
|
||||
// series never silently disappears when its count hits zero — a gauge that drops
|
||||
// out of the exposition breaks rate()/alerting downstream.
|
||||
const SESSION_STATUSES = ["active", "completed", "error", "abandoned"];
|
||||
const AGENT_STATUSES = ["working", "waiting", "completed", "error"];
|
||||
|
||||
/** Escape a Prometheus label value (backslash, double-quote, newline). */
|
||||
function escapeLabelValue(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one metric family (HELP + TYPE header, then one line per sample) into
|
||||
* the `out` line array. Each sample is `{ value, labels? }`. Non-finite values
|
||||
* are coerced to 0 so a bad read can never emit an unparseable exposition line.
|
||||
*/
|
||||
function appendMetric(out, name, help, type, samples) {
|
||||
out.push(`# HELP ${name} ${help}`);
|
||||
out.push(`# TYPE ${name} ${type}`);
|
||||
for (const sample of samples) {
|
||||
const labels = sample.labels
|
||||
? "{" +
|
||||
Object.entries(sample.labels)
|
||||
.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`)
|
||||
.join(",") +
|
||||
"}"
|
||||
: "";
|
||||
const value = Number.isFinite(sample.value) ? sample.value : 0;
|
||||
out.push(`${name}${labels} ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/metrics — Prometheus exposition of the dashboard's live counters.
|
||||
router.get("/", (_req, res) => {
|
||||
const out = [];
|
||||
|
||||
appendMetric(out, "ccam_up", "1 when the dashboard API is serving this scrape.", "gauge", [
|
||||
{ value: 1 },
|
||||
]);
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_build_info",
|
||||
"Dashboard build info; the value is always 1, the version rides on the label.",
|
||||
"gauge",
|
||||
[{ labels: { version: APP_VERSION }, value: 1 }]
|
||||
);
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_process_uptime_seconds",
|
||||
"Uptime of the dashboard server process in seconds.",
|
||||
"gauge",
|
||||
[{ value: Math.round(process.uptime()) }]
|
||||
);
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_process_resident_memory_bytes",
|
||||
"Resident set size (RSS) of the dashboard server process in bytes.",
|
||||
"gauge",
|
||||
[{ value: process.memoryUsage().rss }]
|
||||
);
|
||||
|
||||
// Sessions by status.
|
||||
const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count]));
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_sessions",
|
||||
"Number of sessions by lifecycle status.",
|
||||
"gauge",
|
||||
SESSION_STATUSES.map((status) => ({
|
||||
labels: { status },
|
||||
value: sessionCounts.get(status) || 0,
|
||||
}))
|
||||
);
|
||||
|
||||
// Agents by status.
|
||||
const agentCounts = new Map(stmts.agentStatusCounts.all().map((r) => [r.status, r.count]));
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_agents",
|
||||
"Number of agents (main + subagents) by status.",
|
||||
"gauge",
|
||||
AGENT_STATUSES.map((status) => ({ labels: { status }, value: agentCounts.get(status) || 0 }))
|
||||
);
|
||||
|
||||
// Event throughput (monotonic — a counter).
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_events_total",
|
||||
"Total hook and synthetic events recorded since the database was created.",
|
||||
"counter",
|
||||
[{ value: stmts.countEvents.get().count }]
|
||||
);
|
||||
|
||||
// Connected realtime (WebSocket) clients.
|
||||
let clients = 0;
|
||||
try {
|
||||
clients = getConnectionCount();
|
||||
} catch {
|
||||
/* websocket not up (e.g. under test without a server) — report 0 */
|
||||
}
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_websocket_clients",
|
||||
"Currently connected realtime (WebSocket) dashboard clients.",
|
||||
"gauge",
|
||||
[{ value: clients }]
|
||||
);
|
||||
|
||||
// Remote Data Sources, split by whether background auto-sync is enabled.
|
||||
let enabledSources = 0;
|
||||
let totalSources = 0;
|
||||
try {
|
||||
const rows = stmts.listRemoteSources.all();
|
||||
totalSources = rows.length;
|
||||
enabledSources = rows.filter((r) => r.enabled).length;
|
||||
} catch {
|
||||
/* remote_sources table absent on a very old DB — report 0 */
|
||||
}
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_remote_sources",
|
||||
"Configured Remote Data Sources, split by auto-sync enabled state.",
|
||||
"gauge",
|
||||
[
|
||||
{ labels: { enabled: "true" }, value: enabledSources },
|
||||
{ labels: { enabled: "false" }, value: totalSources - enabledSources },
|
||||
]
|
||||
);
|
||||
|
||||
// Cumulative token usage by kind (baseline_* preserves pre-compaction totals,
|
||||
// matching how the pricing endpoints total usage).
|
||||
const tokens = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COALESCE(SUM(input_tokens + baseline_input), 0) AS input,
|
||||
COALESCE(SUM(output_tokens + baseline_output), 0) AS output,
|
||||
COALESCE(SUM(cache_read_tokens + baseline_cache_read), 0) AS cache_read,
|
||||
COALESCE(SUM(cache_write_tokens + baseline_cache_write), 0) AS cache_write
|
||||
FROM token_usage`
|
||||
)
|
||||
.get();
|
||||
appendMetric(
|
||||
out,
|
||||
"ccam_tokens_total",
|
||||
"Cumulative token usage across all sessions, by kind.",
|
||||
"counter",
|
||||
[
|
||||
{ labels: { kind: "input" }, value: tokens.input },
|
||||
{ labels: { kind: "output" }, value: tokens.output },
|
||||
{ labels: { kind: "cache_read" }, value: tokens.cache_read },
|
||||
{ labels: { kind: "cache_write" }, value: tokens.cache_write },
|
||||
]
|
||||
);
|
||||
|
||||
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
|
||||
res.send(out.join("\n") + "\n");
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* @file Express router for managing pricing rules and calculating costs based on token usage. It provides endpoints to list, create/update, and delete pricing rules, as well as calculate total costs across all sessions or for a specific session. The cost calculation matches token usage against the most specific applicable pricing rule based on model patterns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const {
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
CODE_EXEC_PER_HOUR,
|
||||
CODE_EXEC_FREE_HOURS,
|
||||
estimateCodeExecHours,
|
||||
DATA_RESIDENCY_US_MULTIPLIER,
|
||||
BATCH_DISCOUNT_MULTIPLIER,
|
||||
} = require("../lib/pricing-constants");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const round4 = (n) => Math.round(n * 10000) / 10000;
|
||||
|
||||
/**
|
||||
* Resolve the effective per-MTok rates for a token bucket, applying the pricing
|
||||
* modifiers carried on the bucket (fast mode, US data residency, Batch API).
|
||||
* - Fast mode: premium input/output rates; cache rates scale with the fast
|
||||
* input base (the standard caching multipliers ride on top of fast pricing).
|
||||
* - Data residency "us": 1.1x across every category.
|
||||
* - Batch tier: 50% off across every category.
|
||||
* Older buckets default to speed=standard / geo=global / tier=standard, so they
|
||||
* resolve to exactly the standard rates — historical sessions price unchanged.
|
||||
*/
|
||||
function ratesForBucket(rule, row, asOf) {
|
||||
const r = rule || {};
|
||||
|
||||
// Time-limited introductory rates. Prefer the usage row's own date (so
|
||||
// historical usage keeps the rate it was billed at and future usage picks up
|
||||
// the standard rate), else the caller-provided asOf, else today. Dates are
|
||||
// compared as YYYY-MM-DD strings (both intro_until and the daily date use that
|
||||
// shape), so slice any full ISO timestamp to its day.
|
||||
const day = String(row.date || asOf || new Date().toISOString()).slice(0, 10);
|
||||
const useIntro = !!r.intro_until && day <= r.intro_until;
|
||||
const pick = (introVal, stdVal) => (useIntro && (introVal || 0) > 0 ? introVal : stdVal || 0);
|
||||
|
||||
let rIn = pick(r.intro_input_per_mtok, r.input_per_mtok);
|
||||
let rOut = pick(r.intro_output_per_mtok, r.output_per_mtok);
|
||||
let rRead = pick(r.intro_cache_read_per_mtok, r.cache_read_per_mtok);
|
||||
let r5m = pick(r.intro_cache_write_per_mtok, r.cache_write_per_mtok);
|
||||
let r1h = pick(r.intro_cache_write_1h_per_mtok, r.cache_write_1h_per_mtok);
|
||||
|
||||
if (row.speed === "fast" && (r.fast_input_per_mtok || 0) > 0) {
|
||||
const baseIn = r.input_per_mtok || 0;
|
||||
const factor = baseIn > 0 ? r.fast_input_per_mtok / baseIn : 1;
|
||||
rIn = r.fast_input_per_mtok;
|
||||
rOut = (r.fast_output_per_mtok || 0) > 0 ? r.fast_output_per_mtok : rOut * factor;
|
||||
rRead *= factor;
|
||||
r5m *= factor;
|
||||
r1h *= factor;
|
||||
}
|
||||
if (row.inference_geo === "us") {
|
||||
const m = DATA_RESIDENCY_US_MULTIPLIER;
|
||||
rIn *= m;
|
||||
rOut *= m;
|
||||
rRead *= m;
|
||||
r5m *= m;
|
||||
r1h *= m;
|
||||
}
|
||||
if (row.service_tier === "batch") {
|
||||
const m = BATCH_DISCOUNT_MULTIPLIER;
|
||||
rIn *= m;
|
||||
rOut *= m;
|
||||
rRead *= m;
|
||||
r5m *= m;
|
||||
r1h *= m;
|
||||
}
|
||||
return { rIn, rOut, rRead, r5m, r1h };
|
||||
}
|
||||
|
||||
// Calculate cost for a set of token buckets against pricing rules. Each bucket
|
||||
// is (model, speed, inference_geo, service_tier) with token counts plus the 1h
|
||||
// cache-write split and server-tool request counts. Cost = token cost (rate-
|
||||
// modified) + web-search surcharge ($10/1k) + estimated code-execution time
|
||||
// (free when used with web search/fetch; org free-hours allowance applied once).
|
||||
function calculateCost(tokenRows, pricingRules, asOf) {
|
||||
const sortedRules = [...pricingRules].sort(
|
||||
(a, b) => b.model_pattern.length - a.model_pattern.length
|
||||
);
|
||||
|
||||
let tokenCost = 0;
|
||||
let webSearchCost = 0;
|
||||
let codeExecHours = 0;
|
||||
// Breakdown is aggregated per (model, speed, geo, tier) tuple, not per input
|
||||
// row. This lets callers feed date-split rows (e.g. the daily-usage query,
|
||||
// one row per date × model) so each row is priced at its own date's rate,
|
||||
// while the breakdown still collapses to one entry per model. For callers that
|
||||
// already pass one row per tuple (aggregate total, per-session), it's a no-op.
|
||||
const breakdownMap = new Map();
|
||||
// Track buckets that matched NO pricing rule. Their cost is $0, which would
|
||||
// silently under-report the true total — surface them so the number is honest
|
||||
// and the user knows to add a rule (e.g. a brand-new model id).
|
||||
const unpriced = new Map();
|
||||
|
||||
for (const row of tokenRows) {
|
||||
const rule = sortedRules.find((p) => {
|
||||
const pattern = p.model_pattern.replace(/%/g, ".*");
|
||||
return new RegExp("^" + pattern + "$").test(row.model);
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
const u = unpriced.get(row.model) || {
|
||||
model: row.model,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
u.input_tokens += row.input_tokens || 0;
|
||||
u.output_tokens += row.output_tokens || 0;
|
||||
u.cache_read_tokens += row.cache_read_tokens || 0;
|
||||
u.cache_write_tokens += row.cache_write_tokens || 0;
|
||||
unpriced.set(row.model, u);
|
||||
}
|
||||
|
||||
const { rIn, rOut, rRead, r5m, r1h } = ratesForBucket(rule, row, asOf);
|
||||
const cw1h = row.cache_write_1h_tokens || 0;
|
||||
const cw5m = Math.max(0, (row.cache_write_tokens || 0) - cw1h);
|
||||
const tCost =
|
||||
(row.input_tokens / 1e6) * rIn +
|
||||
(row.output_tokens / 1e6) * rOut +
|
||||
(row.cache_read_tokens / 1e6) * rRead +
|
||||
(cw5m / 1e6) * r5m +
|
||||
(cw1h / 1e6) * r1h;
|
||||
|
||||
const wsCost = ((row.web_search_requests || 0) / 1000) * WEB_SEARCH_PER_1K_SEARCHES;
|
||||
const ceHours = estimateCodeExecHours(
|
||||
row.code_execution_requests,
|
||||
row.web_search_requests,
|
||||
row.web_fetch_requests
|
||||
);
|
||||
|
||||
tokenCost += tCost;
|
||||
webSearchCost += wsCost;
|
||||
codeExecHours += ceHours;
|
||||
|
||||
const key = `${row.model}|${row.speed || "standard"}|${row.inference_geo || "global"}|${row.service_tier || "standard"}`;
|
||||
const agg = breakdownMap.get(key) || {
|
||||
model: row.model,
|
||||
speed: row.speed || "standard",
|
||||
inference_geo: row.inference_geo || "global",
|
||||
service_tier: row.service_tier || "standard",
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
cache_write_1h_tokens: 0,
|
||||
web_search_requests: 0,
|
||||
web_fetch_requests: 0,
|
||||
code_execution_requests: 0,
|
||||
_cost: 0,
|
||||
matched_rule: rule?.model_pattern || null,
|
||||
};
|
||||
agg.input_tokens += row.input_tokens || 0;
|
||||
agg.output_tokens += row.output_tokens || 0;
|
||||
agg.cache_read_tokens += row.cache_read_tokens || 0;
|
||||
agg.cache_write_tokens += row.cache_write_tokens || 0;
|
||||
agg.cache_write_1h_tokens += cw1h;
|
||||
agg.web_search_requests += row.web_search_requests || 0;
|
||||
agg.web_fetch_requests += row.web_fetch_requests || 0;
|
||||
agg.code_execution_requests += row.code_execution_requests || 0;
|
||||
agg._cost += tCost + wsCost;
|
||||
breakdownMap.set(key, agg);
|
||||
}
|
||||
const breakdown = [...breakdownMap.values()].map(({ _cost, ...b }) => ({
|
||||
...b,
|
||||
cost: round4(_cost),
|
||||
}));
|
||||
|
||||
// Code execution is billed by container-time, estimated at the 5-minute
|
||||
// minimum per request. Apply the org free-hours allowance once, then charge
|
||||
// the remainder — so normal usage (well under the allowance) costs $0.
|
||||
const chargedHours = Math.max(0, codeExecHours - CODE_EXEC_FREE_HOURS);
|
||||
const codeExecCost = chargedHours * CODE_EXEC_PER_HOUR;
|
||||
const total = tokenCost + webSearchCost + codeExecCost;
|
||||
|
||||
return {
|
||||
total_cost: round4(total),
|
||||
breakdown,
|
||||
feature_costs: {
|
||||
web_search_cost: round4(webSearchCost),
|
||||
web_fetch_cost: 0,
|
||||
code_execution_cost: round4(codeExecCost),
|
||||
code_execution_hours_estimated: round4(codeExecHours),
|
||||
code_execution_free_hours: CODE_EXEC_FREE_HOURS,
|
||||
},
|
||||
// Models with usage but no matching pricing rule (cost not counted).
|
||||
unpriced_models: [...unpriced.values()],
|
||||
};
|
||||
}
|
||||
|
||||
function calculateDailyCosts(dailyTokenRows, pricingRules) {
|
||||
const rowsByDate = new Map();
|
||||
for (const row of dailyTokenRows) {
|
||||
const rows = rowsByDate.get(row.date) || [];
|
||||
rows.push({
|
||||
model: row.model,
|
||||
speed: row.speed,
|
||||
inference_geo: row.inference_geo,
|
||||
service_tier: row.service_tier,
|
||||
input_tokens: row.input_tokens,
|
||||
output_tokens: row.output_tokens,
|
||||
cache_read_tokens: row.cache_read_tokens,
|
||||
cache_write_tokens: row.cache_write_tokens,
|
||||
cache_write_1h_tokens: row.cache_write_1h_tokens,
|
||||
web_search_requests: row.web_search_requests,
|
||||
web_fetch_requests: row.web_fetch_requests,
|
||||
code_execution_requests: row.code_execution_requests,
|
||||
});
|
||||
rowsByDate.set(row.date, rows);
|
||||
}
|
||||
|
||||
return [...rowsByDate.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, rows]) => ({ date, cost: calculateCost(rows, pricingRules, date).total_cost }));
|
||||
}
|
||||
|
||||
// GET /api/pricing - List all pricing rules
|
||||
router.get("/", (_req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
res.json({ pricing: rules });
|
||||
});
|
||||
|
||||
// PUT /api/pricing - Create or update a pricing rule
|
||||
router.put("/", (req, res) => {
|
||||
const {
|
||||
model_pattern,
|
||||
display_name,
|
||||
input_per_mtok,
|
||||
output_per_mtok,
|
||||
cache_read_per_mtok,
|
||||
cache_write_per_mtok,
|
||||
cache_write_1h_per_mtok,
|
||||
fast_input_per_mtok,
|
||||
fast_output_per_mtok,
|
||||
// Time-limited introductory rates (all optional). A caller that omits every
|
||||
// intro field leaves any existing promo untouched (see introProvided below).
|
||||
intro_input_per_mtok,
|
||||
intro_output_per_mtok,
|
||||
intro_cache_read_per_mtok,
|
||||
intro_cache_write_per_mtok,
|
||||
intro_cache_write_1h_per_mtok,
|
||||
intro_until,
|
||||
} = req.body;
|
||||
if (!model_pattern || !display_name) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "model_pattern and display_name are required" },
|
||||
});
|
||||
}
|
||||
|
||||
// Every rate field must be a non-negative finite number when present. A
|
||||
// typo'd value (Number("abc") → NaN) or a negative rate would otherwise be
|
||||
// written straight into model_pricing and silently corrupt every downstream
|
||||
// cost calculation until someone noticed and fixed the row by hand.
|
||||
const RATE_FIELDS = [
|
||||
"input_per_mtok",
|
||||
"output_per_mtok",
|
||||
"cache_read_per_mtok",
|
||||
"cache_write_per_mtok",
|
||||
"cache_write_1h_per_mtok",
|
||||
"fast_input_per_mtok",
|
||||
"fast_output_per_mtok",
|
||||
"intro_input_per_mtok",
|
||||
"intro_output_per_mtok",
|
||||
"intro_cache_read_per_mtok",
|
||||
"intro_cache_write_per_mtok",
|
||||
"intro_cache_write_1h_per_mtok",
|
||||
];
|
||||
for (const field of RATE_FIELDS) {
|
||||
const raw = req.body[field];
|
||||
if (raw === undefined || raw === null || raw === "") continue;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: `${field} must be a non-negative number` },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Absent/empty rates default to 0; numeric strings are coerced so a rate can
|
||||
// never be bound into the DB as text.
|
||||
const num = (v) => (v === undefined || v === null || v === "" ? 0 : Number(v));
|
||||
|
||||
// Only touch the intro columns when the caller actually sent at least one
|
||||
// intro field. This keeps the endpoint backward-compatible: existing clients
|
||||
// that PUT just the standard rates never clobber a promo, while the Settings
|
||||
// UI (which always sends the full intro block) is authoritative for it.
|
||||
const introKeys = [
|
||||
"intro_input_per_mtok",
|
||||
"intro_output_per_mtok",
|
||||
"intro_cache_read_per_mtok",
|
||||
"intro_cache_write_per_mtok",
|
||||
"intro_cache_write_1h_per_mtok",
|
||||
"intro_until",
|
||||
];
|
||||
const introProvided = introKeys.some((k) => req.body[k] !== undefined);
|
||||
|
||||
// Normalize / validate intro_until: an empty value clears the promo (NULL);
|
||||
// a present value must be a YYYY-MM-DD date so the date-string comparisons in
|
||||
// ratesForBucket stay correct.
|
||||
let normalizedIntroUntil = null;
|
||||
if (introProvided) {
|
||||
const raw = typeof intro_until === "string" ? intro_until.trim() : intro_until;
|
||||
if (raw) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "intro_until must be a YYYY-MM-DD date" },
|
||||
});
|
||||
}
|
||||
normalizedIntroUntil = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const writeRule = db.transaction(() => {
|
||||
stmts.upsertPricing.run(
|
||||
model_pattern,
|
||||
display_name,
|
||||
num(input_per_mtok),
|
||||
num(output_per_mtok),
|
||||
num(cache_read_per_mtok),
|
||||
num(cache_write_per_mtok),
|
||||
num(cache_write_1h_per_mtok),
|
||||
num(fast_input_per_mtok),
|
||||
num(fast_output_per_mtok)
|
||||
);
|
||||
if (introProvided) {
|
||||
// A cleared promo (no date) zeroes the intro rates too so a stale value
|
||||
// can't resurface if a date is re-added later without re-entering rates.
|
||||
const keepRates = !!normalizedIntroUntil;
|
||||
stmts.setIntroPricing.run(
|
||||
keepRates ? num(intro_input_per_mtok) : 0,
|
||||
keepRates ? num(intro_output_per_mtok) : 0,
|
||||
keepRates ? num(intro_cache_read_per_mtok) : 0,
|
||||
keepRates ? num(intro_cache_write_per_mtok) : 0,
|
||||
keepRates ? num(intro_cache_write_1h_per_mtok) : 0,
|
||||
normalizedIntroUntil,
|
||||
model_pattern
|
||||
);
|
||||
}
|
||||
});
|
||||
writeRule();
|
||||
|
||||
const rule = stmts.getPricing.get(model_pattern);
|
||||
res.json({ pricing: rule });
|
||||
});
|
||||
|
||||
// DELETE /api/pricing/:pattern - Delete a pricing rule
|
||||
router.delete("/:pattern", (req, res) => {
|
||||
// Express has already percent-decoded route params, so a client that
|
||||
// properly encodeURIComponent()s a pattern like "claude-opus-4-6%" hands us
|
||||
// the raw "%" here — a second decodeURIComponent() then throws URIError
|
||||
// (malformed escape) and the route 500s. Try the legacy double-decode for
|
||||
// backward compatibility, but fall back to the already-decoded value.
|
||||
let pattern;
|
||||
try {
|
||||
pattern = decodeURIComponent(req.params.pattern);
|
||||
} catch {
|
||||
pattern = req.params.pattern;
|
||||
}
|
||||
const existing = stmts.getPricing.get(pattern);
|
||||
if (!existing) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "NOT_FOUND", message: "Pricing rule not found" } });
|
||||
}
|
||||
stmts.deletePricing.run(pattern);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost - Get total cost across all sessions.
|
||||
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
|
||||
// origin machines (see server/lib/source-filter.js), matching the sessions /
|
||||
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
|
||||
router.get("/cost", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
const { clause, params } = sourceColumnClause(parseSources(req), "s.source");
|
||||
const whereClause = clause ? `WHERE ${clause}` : "";
|
||||
|
||||
const dailyTokens = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
DATE(s.started_at, ?) as date,
|
||||
tu.model as model,
|
||||
tu.speed as speed,
|
||||
tu.inference_geo as inference_geo,
|
||||
tu.service_tier as service_tier,
|
||||
SUM(tu.input_tokens + tu.baseline_input) as input_tokens,
|
||||
SUM(tu.output_tokens + tu.baseline_output) as output_tokens,
|
||||
SUM(tu.cache_read_tokens + tu.baseline_cache_read) as cache_read_tokens,
|
||||
SUM(tu.cache_write_tokens + tu.baseline_cache_write) as cache_write_tokens,
|
||||
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) as cache_write_1h_tokens,
|
||||
SUM(tu.web_search_requests + tu.baseline_web_search) as web_search_requests,
|
||||
SUM(tu.web_fetch_requests + tu.baseline_web_fetch) as web_fetch_requests,
|
||||
SUM(tu.code_execution_requests + tu.baseline_code_execution) as code_execution_requests
|
||||
FROM token_usage tu
|
||||
JOIN sessions s ON s.id = tu.session_id
|
||||
${whereClause}
|
||||
GROUP BY 1, tu.model, tu.speed, tu.inference_geo, tu.service_tier`
|
||||
)
|
||||
.all(tzModifier, ...params);
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
// Coverage equals the undated aggregate — token_usage cascades with sessions,
|
||||
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
|
||||
const result = calculateCost(dailyTokens, rules);
|
||||
const daily_costs = calculateDailyCosts(dailyTokens, rules);
|
||||
res.json({ ...result, daily_costs });
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
|
||||
router.get("/cost/:sessionId", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
|
||||
const tokenRows = stmts.getTokensBySession.all(req.params.sessionId);
|
||||
const rules = stmts.listPricing.all();
|
||||
const started = db
|
||||
.prepare("SELECT DATE(started_at, ?) as date FROM sessions WHERE id = ?")
|
||||
.get(tzModifier, req.params.sessionId);
|
||||
// Price the session as of its start date so a session that ran during a promo
|
||||
// window keeps that rate (e.g. Sonnet 5 intro through 2026-08-31).
|
||||
const result = calculateCost(tokenRows, rules, started?.date);
|
||||
const daily_costs = started ? [{ date: started.date, cost: result.total_cost }] : [];
|
||||
res.json({ ...result, daily_costs });
|
||||
});
|
||||
|
||||
/**
|
||||
* Compute a single agent's own cost from the token buckets stashed in its
|
||||
* metadata by the importer (agent.metadata.tokens). Returns 0 when the agent has
|
||||
* no per-agent usage recorded (e.g. main agents — whose cost is the session
|
||||
* total — compaction pseudo-agents, or live subagents not yet backfilled from
|
||||
* their transcript). Priced with the agent's start date so a promo/standard
|
||||
* cutover is respected, exactly like session cost.
|
||||
*/
|
||||
function agentOwnCost(agent, pricingRules) {
|
||||
if (!agent || !agent.metadata) return 0;
|
||||
let meta;
|
||||
try {
|
||||
meta = JSON.parse(agent.metadata);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const rows = Array.isArray(meta.tokens) ? meta.tokens : null;
|
||||
if (!rows || rows.length === 0) return 0;
|
||||
const asOf = agent.started_at ? String(agent.started_at).slice(0, 10) : undefined;
|
||||
return calculateCost(rows, pricingRules, asOf).total_cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a shallow copy of each agent row with a computed `cost` field — the
|
||||
* agent's OWN cost (see agentOwnCost). Pricing rules are read once for the whole
|
||||
* batch. Used by the agent-list endpoints so subagent cards can show their real
|
||||
* cost instead of the session total.
|
||||
*/
|
||||
function attachAgentCosts(agents) {
|
||||
if (!Array.isArray(agents) || agents.length === 0) return agents;
|
||||
const rules = stmts.listPricing.all();
|
||||
return agents.map((a) => ({ ...a, cost: agentOwnCost(a, rules) }));
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.calculateCost = calculateCost;
|
||||
module.exports.agentOwnCost = agentOwnCost;
|
||||
module.exports.attachAgentCosts = attachAgentCosts;
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Express router for managing push notifications, providing endpoints to retrieve the VAPID public key, subscribe/unsubscribe to push notifications, and send push notifications to all subscribers. It interacts with the database to store subscription details and uses a push library to send notifications.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { getPublicKey, sendPushToAll } = require("../lib/push");
|
||||
const { db } = require("../db");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/vapid-public-key", (_req, res) => {
|
||||
res.json({ publicKey: getPublicKey() });
|
||||
});
|
||||
|
||||
router.post("/subscribe", (req, res) => {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return res.status(400).json({ error: { message: "Missing required fields" } });
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO push_subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)"
|
||||
).run(endpoint, keys.p256dh, keys.auth);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete("/subscribe", (req, res) => {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: { message: "Missing endpoint" } });
|
||||
}
|
||||
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/send", async (req, res) => {
|
||||
const { title, body } = req.body;
|
||||
if (!title || !body) {
|
||||
return res.status(400).json({ error: { message: "Missing title or body" } });
|
||||
}
|
||||
try {
|
||||
// `result` tells the caller which surfaces actually fired:
|
||||
// { native: true|false, pushed: <count>, failed: <count> }
|
||||
// so a silent "no subscribers, no Electron host" no-op stops looking like
|
||||
// success on the client side.
|
||||
const result = await sendPushToAll(db, title, body);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file Express router for remote data sources — other machines whose Claude
|
||||
* Code history this dashboard pulls in over SSH (see server/lib/remote-sync.js).
|
||||
*
|
||||
* GET /api/remote-sources — list configured sources + status
|
||||
* POST /api/remote-sources — add a source
|
||||
* PATCH /api/remote-sources/:id — edit a source (partial)
|
||||
* DELETE /api/remote-sources/:id — remove a source (config + staging);
|
||||
* ?purge=true also deletes its imported
|
||||
* sessions (destructive, opt-in)
|
||||
* POST /api/remote-sources/:id/test — probe SSH connectivity
|
||||
* POST /api/remote-sources/:id/sync — sync now
|
||||
*
|
||||
* No secrets are stored or accepted here — authentication defers entirely to the
|
||||
* host's SSH stack. All inputs are validated in remote-sync.validateSourceInput
|
||||
* before touching the DB or any command.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
|
||||
const { stmts, db } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const {
|
||||
validateSourceInput,
|
||||
ValidationError,
|
||||
testConnection,
|
||||
syncSource,
|
||||
syncAllEnabled,
|
||||
stagingDir,
|
||||
} = require("../lib/remote-sync");
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Shape a DB row for the API: bool `enabled`, parsed counts, and the live number
|
||||
* of sessions currently attributed to this source (`session_count`) so the UI
|
||||
* can show how much data each machine has contributed.
|
||||
*/
|
||||
function serialize(row, sessionCount = 0) {
|
||||
let lastCounts = null;
|
||||
try {
|
||||
lastCounts = row.last_sync_counts ? JSON.parse(row.last_sync_counts) : null;
|
||||
} catch {
|
||||
lastCounts = null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
host: row.host,
|
||||
ssh_port: row.ssh_port,
|
||||
identity_file: row.identity_file,
|
||||
remote_home: row.remote_home,
|
||||
enabled: !!row.enabled,
|
||||
status: row.status,
|
||||
last_error: row.last_error,
|
||||
last_sync_at: row.last_sync_at,
|
||||
last_sync_counts: lastCounts,
|
||||
session_count: sessionCount,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map of source id -> current session count, in one grouped query. */
|
||||
function sessionCountsBySource() {
|
||||
const rows = db.prepare("SELECT source, COUNT(*) AS c FROM sessions GROUP BY source").all();
|
||||
const map = new Map();
|
||||
for (const r of rows) map.set(r.source, r.c);
|
||||
return map;
|
||||
}
|
||||
|
||||
function handleValidation(res, err) {
|
||||
if (err instanceof ValidationError) {
|
||||
res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET / — list all sources, each with its live session count.
|
||||
router.get("/", (_req, res) => {
|
||||
const rows = stmts.listRemoteSources.all();
|
||||
const counts = sessionCountsBySource();
|
||||
res.json({ sources: rows.map((r) => serialize(r, counts.get(r.id) || 0)) });
|
||||
});
|
||||
|
||||
// POST / — create a source.
|
||||
router.post("/", (req, res) => {
|
||||
let v;
|
||||
try {
|
||||
v = validateSourceInput(req.body || {}, false);
|
||||
} catch (err) {
|
||||
if (handleValidation(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
const id = `src_${crypto.randomBytes(6).toString("hex")}`;
|
||||
const enabled = v.enabled === undefined ? 1 : v.enabled;
|
||||
stmts.insertRemoteSource.run(
|
||||
id,
|
||||
v.label,
|
||||
v.host,
|
||||
v.sshPort ?? null,
|
||||
v.identityFile ?? null,
|
||||
v.remoteHome ?? null,
|
||||
enabled
|
||||
);
|
||||
const row = stmts.getRemoteSource.get(id);
|
||||
broadcast("remote_source.status", { id, status: row.status });
|
||||
res.status(201).json({ source: serialize(row) });
|
||||
if (enabled) {
|
||||
syncSource(require("../db"), row, { broadcast }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync-all — sync every enabled source now (sequential; per-source
|
||||
// failures are isolated). Defined before the /:id routes; "sync-all" is a
|
||||
// single path segment so it never collides with "/:id/sync".
|
||||
router.post("/sync-all", async (_req, res) => {
|
||||
const results = await syncAllEnabled(require("../db"), { broadcast });
|
||||
res.json({ ok: true, synced: results.length, results });
|
||||
});
|
||||
|
||||
// PATCH /:id — partial update.
|
||||
router.patch("/:id", (req, res) => {
|
||||
const existing = stmts.getRemoteSource.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Source not found" } });
|
||||
}
|
||||
let v;
|
||||
try {
|
||||
v = validateSourceInput(req.body || {}, true);
|
||||
} catch (err) {
|
||||
if (handleValidation(res, err)) return;
|
||||
throw err;
|
||||
}
|
||||
// COALESCE-based stmt keeps unspecified fields; port/identity/home are written
|
||||
// verbatim (nullable), so a PATCH that omits them leaves them unchanged only
|
||||
// when we pass the existing value through.
|
||||
stmts.updateRemoteSource.run(
|
||||
v.label ?? null,
|
||||
v.host ?? null,
|
||||
v.sshPort !== undefined ? v.sshPort : existing.ssh_port,
|
||||
v.identityFile !== undefined ? v.identityFile : existing.identity_file,
|
||||
v.remoteHome !== undefined ? v.remoteHome : existing.remote_home,
|
||||
v.enabled === undefined ? null : v.enabled,
|
||||
req.params.id
|
||||
);
|
||||
const row = stmts.getRemoteSource.get(req.params.id);
|
||||
broadcast("remote_source.status", { id: row.id, status: row.status });
|
||||
res.json({ source: serialize(row) });
|
||||
if (v.enabled === 1 && !existing.enabled) {
|
||||
syncSource(require("../db"), row, { broadcast }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /:id — remove config + staging dir. ?purge=true also deletes the
|
||||
// sessions this source imported (destructive, opt-in).
|
||||
router.delete("/:id", (req, res) => {
|
||||
const existing = stmts.getRemoteSource.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Source not found" } });
|
||||
}
|
||||
const purge = req.query.purge === "true" || req.query.purge === "1";
|
||||
let purged = 0;
|
||||
if (purge) {
|
||||
// FK ON DELETE CASCADE removes the sessions' agents/events/token_usage too.
|
||||
const info = db.prepare("DELETE FROM sessions WHERE source = ?").run(req.params.id);
|
||||
purged = info.changes || 0;
|
||||
} else {
|
||||
// Keep the imported rows but detach them from the (now gone) source id so
|
||||
// they fall back to the local view instead of a dangling filter value.
|
||||
db.prepare("UPDATE sessions SET source = 'local' WHERE source = ?").run(req.params.id);
|
||||
}
|
||||
stmts.deleteRemoteSource.run(req.params.id);
|
||||
// Reclaim the mirrored staging dir.
|
||||
try {
|
||||
fs.rmSync(stagingDir(req.params.id), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
broadcast("remote_source.status", { id: req.params.id, status: "deleted" });
|
||||
res.json({ ok: true, purged });
|
||||
});
|
||||
|
||||
// POST /:id/test — probe connectivity (does not import).
|
||||
router.post("/:id/test", async (req, res) => {
|
||||
const row = stmts.getRemoteSource.get(req.params.id);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Source not found" } });
|
||||
}
|
||||
const result = await testConnection(row);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /:id/sync — sync now.
|
||||
router.post("/:id/sync", async (req, res) => {
|
||||
const row = stmts.getRemoteSource.get(req.params.id);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Source not found" } });
|
||||
}
|
||||
try {
|
||||
const result = await syncSource(require("../db"), row, { broadcast });
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { code: "SYNC_FAILED", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @file run.js
|
||||
* @description HTTP routes for the dashboard's Run feature. Spawns and
|
||||
* supervises `claude` processes (headless one-shot or multi-turn
|
||||
* conversation), streams structured envelopes to the client over the
|
||||
* existing WebSocket, and exposes a tiny CRUD-ish surface for run management.
|
||||
*
|
||||
* Security model:
|
||||
* - Local-first dashboard. The dashboard server is expected to bind to
|
||||
* localhost (or the user's intranet at most). To prevent a malicious
|
||||
* webpage from drive-by spawning processes via CSRF, we enforce a
|
||||
* same-origin / loopback-Origin check on every route here. curl from the
|
||||
* terminal (no Origin header) is allowed; browser requests must come from
|
||||
* a localhost-ish origin.
|
||||
* - cwd is sanitised: must be absolute and exist as a directory at request
|
||||
* time. Anything else is rejected.
|
||||
* - Concurrency cap (RUN_MAX_CONCURRENT, default 10) prevents runaway spawn.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const runs = require("../lib/run-spawner");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
||||
|
||||
/**
|
||||
* Loopback-Origin guard. Browser requests carry Origin; if it's not localhost,
|
||||
* we reject. Server/CLI requests (curl) typically don't carry Origin and pass.
|
||||
*
|
||||
* Referer is checked as a fallback for older browsers / fetch with credentials
|
||||
* disabled — the same loopback-host rule applies.
|
||||
*/
|
||||
function sameOriginGuard(req, res, next) {
|
||||
const checkHost = (raw) => {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return ALLOWED_ORIGIN_HOSTS.has(u.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
if (!checkHost(origin)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
|
||||
});
|
||||
}
|
||||
return next();
|
||||
}
|
||||
const referer = req.headers.referer;
|
||||
if (referer && !checkHost(referer)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
|
||||
});
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.use(sameOriginGuard);
|
||||
|
||||
function isExistingDir(p) {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitiseCwd(input) {
|
||||
if (input == null || input === "") return process.cwd();
|
||||
if (typeof input !== "string") {
|
||||
const e = new Error("cwd must be a string");
|
||||
e.code = "EBADCWD";
|
||||
throw e;
|
||||
}
|
||||
if (!path.isAbsolute(input)) {
|
||||
const e = new Error("cwd must be an absolute path");
|
||||
e.code = "EBADCWD";
|
||||
throw e;
|
||||
}
|
||||
const resolved = path.resolve(input);
|
||||
if (!isExistingDir(resolved)) {
|
||||
const e = new Error(`cwd does not exist: ${resolved}`);
|
||||
e.code = "EBADCWD";
|
||||
throw e;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({
|
||||
items: runs.listRuns(),
|
||||
maxConcurrent: runs.getMaxConcurrent(),
|
||||
activeCount: runs.liveCount(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Persistent history of every run spawned via the dashboard. Survives the
|
||||
* 5-minute in-memory reap so the user can see (and resume) past runs days
|
||||
* after they finished. Backed by the `dashboard_runs` sqlite table.
|
||||
*
|
||||
* Optional ?limit=<n> caps results (default 50, max 500). The most recent
|
||||
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
|
||||
* lane, which is what the Workspace page's per-lane history shows.
|
||||
*/
|
||||
router.get("/history", (req, res) => {
|
||||
let dr = null;
|
||||
try {
|
||||
dr = require("../lib/dashboard-runs");
|
||||
} catch {
|
||||
return res.json({ items: [] });
|
||||
}
|
||||
const limit = Number.parseInt(String(req.query.limit || "50"), 10);
|
||||
const laneId = Number.parseInt(String(req.query.laneId ?? ""), 10);
|
||||
const items = dr.listRuns({
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
laneId: Number.isFinite(laneId) ? laneId : null,
|
||||
});
|
||||
// Cross-reference with live handles so the UI can mark which history
|
||||
// entries are still attached / running.
|
||||
const liveIds = new Set();
|
||||
for (const h of runs.listRuns()) {
|
||||
if (h.id && (h.status === "running" || h.status === "spawning")) liveIds.add(h.id);
|
||||
}
|
||||
res.json({
|
||||
items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Suggest plausible working directories. Pulls from:
|
||||
* - "dashboard": the dashboard server's cwd
|
||||
* - "home": $HOME (the Run page's default cwd and first autocomplete group —
|
||||
* a neutral spawn location that doesn't inherit this repo's project
|
||||
* context, issue #202)
|
||||
* - "recent": distinct cwds Claude Code has been used in, sourced from the
|
||||
* dashboard's own sessions table. Filtered to dirs that still exist.
|
||||
*
|
||||
* Optional ?q=<substring> filter applied client-side as well.
|
||||
*/
|
||||
router.get("/cwds", (_req, res) => {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const push = (kind, p, label) => {
|
||||
if (!p) return;
|
||||
const abs = path.resolve(p);
|
||||
if (seen.has(abs)) return;
|
||||
if (!isExistingDir(abs)) return;
|
||||
seen.add(abs);
|
||||
out.push({ kind, path: abs, label: label || path.basename(abs) || abs });
|
||||
};
|
||||
|
||||
push("dashboard", process.cwd(), "Dashboard server");
|
||||
push("home", require("node:os").homedir(), "Home");
|
||||
|
||||
// Pull recent cwds from the sessions DB (best-effort; if the DB isn't
|
||||
// ready or has a different schema, just return what we have).
|
||||
try {
|
||||
const { db } = require("../db");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT cwd, MAX(started_at) AS last_at FROM sessions
|
||||
WHERE cwd IS NOT NULL AND cwd <> ''
|
||||
GROUP BY cwd ORDER BY last_at DESC LIMIT 30`
|
||||
)
|
||||
.all();
|
||||
for (const row of rows) {
|
||||
push("recent", row.cwd, path.basename(row.cwd));
|
||||
}
|
||||
} catch {
|
||||
/* ignore — DB may not be ready in tests */
|
||||
}
|
||||
|
||||
res.json({ items: out });
|
||||
});
|
||||
|
||||
/**
|
||||
* File autocomplete for the prompt editor's `@` references. Walks `cwd`
|
||||
* (must be inside the cwd allowlist via sanitiseCwd), returns up to 40
|
||||
* matching paths relative to that cwd. Skips dotdirs, node_modules, build,
|
||||
* dist, .git, etc. Substring-matches against `q` case-insensitively.
|
||||
*/
|
||||
router.get("/files", (req, res) => {
|
||||
let cwd;
|
||||
try {
|
||||
cwd = sanitiseCwd(req.query.cwd);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : "";
|
||||
const SKIP_DIRS = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
".cache",
|
||||
".vite",
|
||||
"coverage",
|
||||
".turbo",
|
||||
"target",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
]);
|
||||
const MAX_RESULTS = 40;
|
||||
const MAX_VISITED = 5000;
|
||||
const results = [];
|
||||
let visited = 0;
|
||||
const walk = (dir, rel) => {
|
||||
if (results.length >= MAX_RESULTS || visited >= MAX_VISITED) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
if (results.length >= MAX_RESULTS || visited >= MAX_VISITED) return;
|
||||
visited++;
|
||||
if (ent.name.startsWith(".") && ent.name !== ".env" && ent.name !== ".gitignore") continue;
|
||||
if (ent.isDirectory()) {
|
||||
if (SKIP_DIRS.has(ent.name)) continue;
|
||||
walk(path.join(dir, ent.name), rel ? `${rel}/${ent.name}` : ent.name);
|
||||
} else if (ent.isFile()) {
|
||||
const relPath = rel ? `${rel}/${ent.name}` : ent.name;
|
||||
if (!q || relPath.toLowerCase().includes(q)) {
|
||||
results.push(relPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(cwd, "");
|
||||
results.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||
res.json({ items: results.slice(0, MAX_RESULTS) });
|
||||
});
|
||||
|
||||
router.get("/binary", (_req, res) => {
|
||||
// Surface whether `claude` is on PATH so the UI can show a helpful error
|
||||
// before the user clicks Run. We don't actually invoke it — just let the
|
||||
// user know the spawn will work.
|
||||
const which = require("node:child_process").spawnSync(
|
||||
process.platform === "win32" ? "where" : "which",
|
||||
["claude"],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
const stdout = (which.stdout || "").trim();
|
||||
res.json({
|
||||
found: which.status === 0 && stdout.length > 0,
|
||||
path: stdout || null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const body = req.body || {};
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt : "";
|
||||
const mode = body.mode === "headless" ? "headless" : "conversation";
|
||||
const model = typeof body.model === "string" && body.model ? body.model : null;
|
||||
const resumeSessionId =
|
||||
typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null;
|
||||
const effort = typeof body.effort === "string" && body.effort ? body.effort : null;
|
||||
const permissionMode =
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
? body.permissionMode
|
||||
: "acceptEdits";
|
||||
// Resuming a conversation can spawn with an empty prompt — claude waits
|
||||
// on stdin until the user types a follow-up. Headless and fresh
|
||||
// conversation runs still need a prompt to do anything.
|
||||
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
|
||||
return res.status(400).json({ error: { code: "EBADPROMPT", message: "prompt is required" } });
|
||||
}
|
||||
let cwd;
|
||||
try {
|
||||
cwd = sanitiseCwd(body.cwd);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
try {
|
||||
const handle = runs.spawnRun({
|
||||
prompt,
|
||||
mode,
|
||||
cwd,
|
||||
model,
|
||||
permissionMode,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
return res.status(201).json(runs.getRun(handle.id));
|
||||
} catch (err) {
|
||||
if (err.code === "ECONCURRENCY") {
|
||||
return res.status(429).json({
|
||||
error: { code: err.code, message: err.message },
|
||||
running: err.running || [],
|
||||
});
|
||||
}
|
||||
if (err.code && err.code.startsWith("E")) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: "EINTERNAL", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/message", (req, res) => {
|
||||
const body = req.body || {};
|
||||
const text = typeof body.text === "string" ? body.text : "";
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: { code: "EBADINPUT", message: "text is required" } });
|
||||
}
|
||||
try {
|
||||
const result = runs.sendInput(req.params.id, text);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
const status = err.code === "ENOTFOUND" ? 404 : 400;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
// ?envelopes=1 includes the in-memory envelope history so the UI can
|
||||
// re-attach to an active run started elsewhere and see what it missed.
|
||||
const includeEnvelopes = req.query.envelopes === "1";
|
||||
const handle = runs.getRun(req.params.id, { includeEnvelopes });
|
||||
if (!handle) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
return res.json(handle);
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const ok = runs.killRun(req.params.id);
|
||||
if (!ok) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.__sameOriginGuard = sameOriginGuard;
|
||||
module.exports.__sanitiseCwd = sanitiseCwd;
|
||||
// Shared with routes/lanes.js: lane actions spawn processes through the same
|
||||
// run-spawner, so they must sit behind the same loopback/same-origin check.
|
||||
module.exports.sameOriginGuard = sameOriginGuard;
|
||||
@@ -0,0 +1,964 @@
|
||||
/**
|
||||
* @file Express router for session endpoints, allowing creation, retrieval, and updating of sessions with optional pagination and filtering by status. It also computes costs for sessions based on token usage and pricing rules, and broadcasts session changes to connected WebSocket clients for real-time updates.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
const { stmts, db } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { calculateCost, attachAgentCosts } = require("./pricing");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const {
|
||||
getClaudeHome,
|
||||
getProjectsDir,
|
||||
getTranscriptPath,
|
||||
getSubagentTranscriptPath,
|
||||
getSnapshotTranscriptPath,
|
||||
getSnapshotSubagentTranscriptPath,
|
||||
findTranscriptPath,
|
||||
findSubagentTranscriptPath,
|
||||
} = require("../lib/claude-home");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// JSONL entry types the transcript reader turns into renderable messages.
|
||||
// `user`/`assistant` are the conversation. `custom-title` is the metadata line
|
||||
// written by /rename, `claude -n`, and the picker's Ctrl+R — surfaced as an
|
||||
// inline rename marker so a rename is visible even when there is no command
|
||||
// line (e.g. `claude -n` at startup). `system` carries local slash-command I/O
|
||||
// in newer Claude Code builds — `system`/`local_command` lines hold the TUI
|
||||
// markup (`<command-name>`, `<local-command-stdout>`, …) in a top-level
|
||||
// `content` string, so /color, /rename, /clear, and custom commands render as
|
||||
// command pills + their captured output; every other `system` subtype
|
||||
// (turn_duration, stop_hook_summary, away_summary, …) is dropped as noise.
|
||||
// (ai-title is intentionally excluded: it repeats on nearly every turn and
|
||||
// would flood the stream; it drives the session NAME instead, not the chat.)
|
||||
// `attachment` is included ONLY for its `queued_command` subtype: a message the
|
||||
// human typed mid-turn (while Claude was still working) is written to the JSONL
|
||||
// as `queue-operation` bookkeeping lines plus a `queued_command` attachment at
|
||||
// the point the model actually saw it — there is NO `type:"user"` line for it,
|
||||
// so without this the Conversation tab silently drops mid-turn messages. Every
|
||||
// other attachment subtype (task_reminder, hook_success, skill_listing, …) is
|
||||
// harness noise and stays hidden.
|
||||
const TRANSCRIPT_RENDER_TYPES = new Set([
|
||||
"user",
|
||||
"assistant",
|
||||
"custom-title",
|
||||
"system",
|
||||
"attachment",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only the first non-empty line from a JSONL file using streaming.
|
||||
* Avoids loading the entire file into memory.
|
||||
*/
|
||||
async function readFirstLine(filePath) {
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
for await (const line of rl) {
|
||||
rl.close();
|
||||
rl.removeAllListeners();
|
||||
return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 50, 10000);
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
const status = req.query.status;
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const cwd = req.query.cwd;
|
||||
const sortBy = req.query.sort_by || "time"; // "time", "duration", "price"
|
||||
const sortDesc = req.query.sort_desc !== "false";
|
||||
|
||||
let where = [];
|
||||
let params = [];
|
||||
|
||||
if (q) {
|
||||
const like = `%${q}%`;
|
||||
where.push("(s.id LIKE ? OR s.name LIKE ? OR s.cwd LIKE ?)");
|
||||
params.push(like, like, like);
|
||||
}
|
||||
if (status) {
|
||||
where.push("s.status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (cwd) {
|
||||
where.push("s.cwd = ?");
|
||||
params.push(cwd);
|
||||
}
|
||||
// Data-scope filter: restrict to sessions collected from a chosen set of
|
||||
// machines (local + any configured remote sources). Absent = all sources.
|
||||
const sourceFilter = sourceColumnClause(parseSources(req));
|
||||
if (sourceFilter.clause) {
|
||||
where.push(sourceFilter.clause);
|
||||
params.push(...sourceFilter.params);
|
||||
}
|
||||
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const total = db.prepare(`SELECT COUNT(*) as c FROM sessions s ${whereSql}`).get(...params).c;
|
||||
|
||||
let rows = [];
|
||||
|
||||
if (sortBy === "price") {
|
||||
const allRows = db
|
||||
.prepare(
|
||||
`SELECT s.*, COUNT(a.id) as agent_count, s.updated_at as last_activity
|
||||
FROM sessions s LEFT JOIN agents a ON a.session_id = s.id
|
||||
${whereSql}
|
||||
GROUP BY s.id`
|
||||
)
|
||||
.all(...params);
|
||||
|
||||
if (allRows.length > 0) {
|
||||
const rules = stmts.listPricing.all();
|
||||
|
||||
for (let i = 0; i < allRows.length; i += 900) {
|
||||
const chunk = allRows.slice(i, i + 900);
|
||||
const ids = chunk.map((r) => r.id);
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const chunkTokens = db
|
||||
.prepare(
|
||||
`SELECT session_id, model,
|
||||
input_tokens + baseline_input as input_tokens,
|
||||
output_tokens + baseline_output as output_tokens,
|
||||
cache_read_tokens + baseline_cache_read as cache_read_tokens,
|
||||
cache_write_tokens + baseline_cache_write as cache_write_tokens
|
||||
FROM token_usage WHERE session_id IN (${placeholders})`
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
const tokensBySession = {};
|
||||
for (const t of chunkTokens) {
|
||||
if (!tokensBySession[t.session_id]) tokensBySession[t.session_id] = [];
|
||||
tokensBySession[t.session_id].push(t);
|
||||
}
|
||||
|
||||
for (const row of chunk) {
|
||||
const sessionTokens = tokensBySession[row.id];
|
||||
row.cost = sessionTokens
|
||||
? calculateCost(sessionTokens, rules, row.started_at).total_cost
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
allRows.sort((a, b) => {
|
||||
return sortDesc ? b.cost - a.cost : a.cost - b.cost;
|
||||
});
|
||||
rows = allRows.slice(offset, offset + limit);
|
||||
}
|
||||
} else {
|
||||
let orderSql = "s.updated_at DESC";
|
||||
if (sortBy === "time") {
|
||||
orderSql = `s.updated_at ${sortDesc ? "DESC" : "ASC"}`;
|
||||
} else if (sortBy === "duration") {
|
||||
orderSql = `(julianday(COALESCE(s.ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) - julianday(s.started_at)) ${sortDesc ? "DESC" : "ASC"}`;
|
||||
}
|
||||
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT s.*, COUNT(a.id) as agent_count, s.updated_at as last_activity
|
||||
FROM sessions s LEFT JOIN agents a ON a.session_id = s.id
|
||||
${whereSql}
|
||||
GROUP BY s.id ORDER BY ${orderSql} LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params, limit, offset);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const ids = rows.map((r) => r.id);
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const allTokens = db
|
||||
.prepare(
|
||||
`SELECT session_id, model,
|
||||
input_tokens + baseline_input as input_tokens,
|
||||
output_tokens + baseline_output as output_tokens,
|
||||
cache_read_tokens + baseline_cache_read as cache_read_tokens,
|
||||
cache_write_tokens + baseline_cache_write as cache_write_tokens
|
||||
FROM token_usage WHERE session_id IN (${placeholders})`
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
const rules = stmts.listPricing.all();
|
||||
const tokensBySession = {};
|
||||
for (const t of allTokens) {
|
||||
if (!tokensBySession[t.session_id]) tokensBySession[t.session_id] = [];
|
||||
tokensBySession[t.session_id].push(t);
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const sessionTokens = tokensBySession[row.id];
|
||||
row.cost = sessionTokens
|
||||
? calculateCost(sessionTokens, rules, row.started_at).total_cost
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ sessions: rows, limit, offset, total });
|
||||
});
|
||||
|
||||
router.get("/facets", (_req, res) => {
|
||||
const rows = db
|
||||
.prepare("SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' ORDER BY cwd")
|
||||
.all();
|
||||
// Distinct origins present in the data, so the UI can offer a source facet /
|
||||
// scope selector. Always includes at least 'local' (the column default).
|
||||
const sources = stmts.distinctSessionSources.all().map((r) => r.source);
|
||||
res.json({ cwds: rows.map((r) => r.cwd), sources });
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
// Each agent's OWN cost (from its metadata token buckets) so subagent cards on
|
||||
// the session-detail tree show their real cost, not the session total.
|
||||
const agents = attachAgentCosts(stmts.listAgentsBySession.all(req.params.id));
|
||||
const events = stmts.listEventsBySession.all(req.params.id);
|
||||
// Workflow-tool runs launched within this session (issue #167). Parse the
|
||||
// JSON-blob columns so the client gets arrays, not strings.
|
||||
const workflows = stmts.listWorkflowsBySession.all(req.params.id).map((w) => {
|
||||
let phases = [];
|
||||
let progress = [];
|
||||
try {
|
||||
phases = w.phases ? JSON.parse(w.phases) : [];
|
||||
} catch {
|
||||
phases = [];
|
||||
}
|
||||
try {
|
||||
progress = w.progress ? JSON.parse(w.progress) : [];
|
||||
} catch {
|
||||
progress = [];
|
||||
}
|
||||
return { ...w, phases, progress };
|
||||
});
|
||||
res.json({ session, agents, events, workflows });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:id/stats — Aggregated counts for the SessionOverview panel.
|
||||
*
|
||||
* Returns at-a-glance metrics used by the Agents tab on the Session detail page.
|
||||
* All aggregation runs in SQL so we don't ship 14k+ event rows to the client.
|
||||
*/
|
||||
router.get("/:id/stats", (req, res) => {
|
||||
const sessionId = req.params.id;
|
||||
const session = stmts.getSession.get(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
||||
const errors = stmts.sessionErrorCount.get(sessionId)?.count ?? 0;
|
||||
const timeRange = stmts.sessionEventTimeRange.get(sessionId) || {};
|
||||
const subagentTypes = stmts.sessionAgentTypeCounts.all(sessionId);
|
||||
const agentStatusRows = stmts.sessionAgentStatusCounts.all(sessionId);
|
||||
const tokens = stmts.sessionTokenTotals.get(sessionId) || {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
|
||||
// Aggregate agent counts by category
|
||||
const agentCounts = {
|
||||
total: 0,
|
||||
main: 0,
|
||||
subagent: 0,
|
||||
compaction: 0,
|
||||
by_status: {},
|
||||
};
|
||||
for (const row of agentStatusRows) {
|
||||
agentCounts.total += row.count;
|
||||
agentCounts.by_status[row.status] = row.count;
|
||||
}
|
||||
// Compactions: count agents whose subagent_type === 'compaction'
|
||||
const compactionRow = subagentTypes.find((r) => r.subagent_type === "compaction");
|
||||
agentCounts.compaction = compactionRow?.count ?? 0;
|
||||
// Main vs sub: count by type in SQL (avoids loading all agents)
|
||||
const typeCounts = db
|
||||
.prepare(`SELECT type, COUNT(*) as count FROM agents WHERE session_id = ? GROUP BY type`)
|
||||
.all(sessionId);
|
||||
for (const row of typeCounts) {
|
||||
if (row.type === "main") agentCounts.main = row.count;
|
||||
else if (row.type === "subagent") agentCounts.subagent = row.count;
|
||||
}
|
||||
|
||||
res.json({
|
||||
session_id: sessionId,
|
||||
total_events: totalEvents,
|
||||
events_by_type: eventsByType,
|
||||
tools_used: tools,
|
||||
error_count: errors,
|
||||
first_event_at: timeRange.first_at ?? null,
|
||||
last_event_at: timeRange.last_at ?? null,
|
||||
agents: agentCounts,
|
||||
subagent_types: subagentTypes.filter((r) => r.subagent_type !== "compaction"),
|
||||
tokens,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const { id, name, cwd, model, metadata } = req.body;
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: { code: "INVALID_INPUT", message: "id is required" } });
|
||||
}
|
||||
|
||||
const existing = stmts.getSession.get(id);
|
||||
if (existing) {
|
||||
return res.json({ session: existing, created: false });
|
||||
}
|
||||
|
||||
stmts.insertSession.run(
|
||||
id,
|
||||
name || null,
|
||||
"active",
|
||||
cwd || null,
|
||||
model || null,
|
||||
metadata ? JSON.stringify(metadata) : null
|
||||
);
|
||||
const session = stmts.getSession.get(id);
|
||||
broadcast("session_created", session);
|
||||
res.status(201).json({ session, created: true });
|
||||
});
|
||||
|
||||
router.patch("/:id", (req, res) => {
|
||||
const { name, status, ended_at, metadata } = req.body;
|
||||
const existing = stmts.getSession.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
stmts.updateSession.run(
|
||||
name || null,
|
||||
status || null,
|
||||
ended_at || null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
broadcast("session_updated", session);
|
||||
res.json({ session });
|
||||
});
|
||||
|
||||
// GET /:id/transcripts — List available transcript files for a session (main + sub-agents)
|
||||
router.get("/:id/transcripts", async (req, res) => {
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// Query database agent list for db_agent_id association
|
||||
const dbAgents = stmts.listAgentsBySession.all(req.params.id) || [];
|
||||
|
||||
// Main session transcript (live, else the durable import-time snapshot)
|
||||
const mainPath =
|
||||
getTranscriptPath(req.params.id, session.cwd) ||
|
||||
findTranscriptPath(req.params.id) ||
|
||||
getSnapshotTranscriptPath(req.params.id);
|
||||
if (mainPath && fs.existsSync(mainPath)) {
|
||||
// Main agent database ID format: <sessionId>-main
|
||||
const mainDbAgent = dbAgents.find((a) => a.type === "main");
|
||||
result.push({
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: mainDbAgent ? mainDbAgent.id : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sub-agent transcript files
|
||||
const encoded = session.cwd ? session.cwd.replace(/[^a-zA-Z0-9]/g, "-") : null;
|
||||
const subagentDirs = [];
|
||||
|
||||
// Direct path
|
||||
if (encoded) {
|
||||
const directDir = path.join(getProjectsDir(), encoded, req.params.id, "subagents");
|
||||
if (fs.existsSync(directDir)) subagentDirs.push(directDir);
|
||||
}
|
||||
|
||||
// Fallback: scan all project directories when direct path doesn't exist
|
||||
if (subagentDirs.length === 0) {
|
||||
const projectsDir = path.join(getClaudeHome(), "projects");
|
||||
if (fs.existsSync(projectsDir)) {
|
||||
try {
|
||||
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const candidate = path.join(projectsDir, d.name, req.params.id, "subagents");
|
||||
if (fs.existsSync(candidate)) subagentDirs.push(candidate);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of subagentDirs) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
// File name format: agent-<shortId>.jsonl
|
||||
const shortId = file.replace(/^agent-/, "").replace(/\.jsonl$/, "");
|
||||
// Try reading meta.json for agent type info
|
||||
let meta = null;
|
||||
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
|
||||
if (fs.existsSync(metaPath)) {
|
||||
try {
|
||||
meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const isCompact = shortId.startsWith("acompact-");
|
||||
const transcriptName = isCompact
|
||||
? "Context Compaction"
|
||||
: meta?.description || meta?.agentType || shortId;
|
||||
const transcriptSubagentType = meta?.agentType || null;
|
||||
|
||||
// Read first-line timestamp from JSONL for time-based matching
|
||||
let transcriptTimestamp = null;
|
||||
try {
|
||||
const jsonlPath = path.join(dir, file);
|
||||
const firstLine = await readFirstLine(jsonlPath);
|
||||
if (firstLine) {
|
||||
const entry = JSON.parse(firstLine);
|
||||
transcriptTimestamp = entry.timestamp || null;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: shortId,
|
||||
name: transcriptName,
|
||||
type: isCompact ? "compaction" : "subagent",
|
||||
subagent_type: transcriptSubagentType,
|
||||
has_transcript: true,
|
||||
db_agent_id: null, // matched later after all transcripts are collected
|
||||
_timestamp: transcriptTimestamp,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Match database agents to transcripts using best-effort strategies
|
||||
// Strategy: sort both sides by time within each type, then match by index order.
|
||||
// This works because agents and transcripts are created in chronological order.
|
||||
|
||||
// Step 1: Sort all non-main transcripts by timestamp
|
||||
for (const t of result) {
|
||||
if (t.type === "main") continue;
|
||||
// Store parseable timestamp for sorting
|
||||
t._sortTime = t._timestamp ? new Date(t._timestamp).getTime() : Infinity;
|
||||
}
|
||||
|
||||
// Step 2: Sort DB agents by started_at within each subagent_type
|
||||
const agentsByType = {};
|
||||
for (const a of dbAgents) {
|
||||
const key = a.subagent_type || a.type;
|
||||
if (!agentsByType[key]) agentsByType[key] = [];
|
||||
agentsByType[key].push(a);
|
||||
}
|
||||
for (const key of Object.keys(agentsByType)) {
|
||||
agentsByType[key].sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
|
||||
}
|
||||
|
||||
// Step 3: Sort transcripts by type+time, then match by index within each type group
|
||||
// Group transcripts by their effective type key
|
||||
const transcriptsByType = {};
|
||||
for (const t of result) {
|
||||
if (t.type === "main") continue;
|
||||
// Compaction transcripts have subagent_type=null, use type as key
|
||||
const key = t.subagent_type || t.type;
|
||||
if (!transcriptsByType[key]) transcriptsByType[key] = [];
|
||||
transcriptsByType[key].push(t);
|
||||
}
|
||||
// Sort each group by timestamp
|
||||
for (const key of Object.keys(transcriptsByType)) {
|
||||
transcriptsByType[key].sort((a, b) => (a._sortTime || Infinity) - (b._sortTime || Infinity));
|
||||
}
|
||||
|
||||
// Step 4: Match by index within each type group
|
||||
// First try db_agent_id exact match, then fall back to positional match
|
||||
for (const key of Object.keys(transcriptsByType)) {
|
||||
const tGroup = transcriptsByType[key];
|
||||
const aGroup = agentsByType[key] || [];
|
||||
const usedAgentIds = new Set();
|
||||
|
||||
for (let i = 0; i < tGroup.length; i++) {
|
||||
const t = tGroup[i];
|
||||
|
||||
// Try exact db_agent_id match first (for non-compact sub-agents with meta.json data)
|
||||
if (t.db_agent_id) {
|
||||
usedAgentIds.add(t.db_agent_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Positional match: i-th transcript → i-th agent in the same type group
|
||||
if (i < aGroup.length && !usedAgentIds.has(aGroup[i].id)) {
|
||||
t.db_agent_id = aGroup[i].id;
|
||||
usedAgentIds.add(aGroup[i].id);
|
||||
}
|
||||
// If no agent at this position, db_agent_id stays null — client will show "info missing"
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up internal fields before sending response
|
||||
for (const t of result) {
|
||||
delete t._timestamp;
|
||||
delete t._sortTime;
|
||||
}
|
||||
|
||||
// Sort transcripts: main first, then by time ascending (consistent with agents list order)
|
||||
result.sort((a, b) => {
|
||||
if (a.type === "main") return -1;
|
||||
if (b.type === "main") return 1;
|
||||
const aAgent = dbAgents.find((ag) => ag.id === a.db_agent_id);
|
||||
const bAgent = dbAgents.find((ag) => ag.id === b.db_agent_id);
|
||||
const aTime = aAgent?.started_at ? new Date(aAgent.started_at).getTime() : 0;
|
||||
const bTime = bAgent?.started_at ? new Date(bAgent.started_at).getTime() : 0;
|
||||
if (aTime && bTime) return aTime - bTime;
|
||||
if (aTime) return -1;
|
||||
if (bTime) return 1;
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
|
||||
res.json({ transcripts: result });
|
||||
});
|
||||
|
||||
// GET /:id/transcript — Read session JSONL transcript, return structured message list
|
||||
// Query params:
|
||||
// agent_id: file-level short ID ("main" or "ad18a79192af10ed1", "acompact-xxx")
|
||||
// run_id: Workflow run id ("wf_...") — disambiguates a workflow inner agent's
|
||||
// nested transcript (subagents/workflows/<run_id>/agent-<agent_id>.jsonl)
|
||||
// limit: max messages to return (default 50, max 200)
|
||||
// after: JSONL line number, only return messages after this line (incremental mode)
|
||||
// before: JSONL line number, only return messages before this line (history mode)
|
||||
// offset: legacy pagination offset (compatible, mutually exclusive with after/before)
|
||||
router.get("/:id/transcript", async (req, res) => {
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
const agentId = req.query.agent_id || null;
|
||||
const runId = req.query.run_id || null;
|
||||
// Subagent transcripts (anything but the main session file) need different
|
||||
// sender attribution: their first user line is the orchestrator's task.
|
||||
const isSubagentFile = !!(agentId && agentId !== "main");
|
||||
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
||||
const afterLine = req.query.after ? parseInt(req.query.after) : null;
|
||||
const beforeLine = req.query.before ? parseInt(req.query.before) : null;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
// Determine the JSONL file path to read. Prefer the live file under
|
||||
// ~/.claude/projects, then fall back to the dashboard's durable snapshot —
|
||||
// the live file is gone once Claude Code prunes it under cleanupPeriodDays
|
||||
// (default 30 days), but the snapshot taken at import time survives.
|
||||
let jsonlPath;
|
||||
if (agentId && agentId !== "main") {
|
||||
jsonlPath =
|
||||
getSubagentTranscriptPath(req.params.id, session.cwd, agentId, runId) ||
|
||||
findSubagentTranscriptPath(req.params.id, agentId, runId) ||
|
||||
getSnapshotSubagentTranscriptPath(req.params.id, agentId, runId);
|
||||
} else {
|
||||
jsonlPath =
|
||||
getTranscriptPath(req.params.id, session.cwd) ||
|
||||
findTranscriptPath(req.params.id) ||
|
||||
getSnapshotTranscriptPath(req.params.id);
|
||||
}
|
||||
|
||||
if (!jsonlPath || !fs.existsSync(jsonlPath)) {
|
||||
return res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Stream-parse JSONL with early termination for efficiency.
|
||||
// Instead of loading all messages into memory, we use pagination-aware
|
||||
// strategies to stop reading as soon as we have enough data.
|
||||
const messages = [];
|
||||
let lineNum = 0;
|
||||
let total = 0; // total valid messages seen (exact for early-terminated streams, indicates >= actual)
|
||||
let hasMore = false;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(jsonlPath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, 10240) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, 10240) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, 10240) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, 10240) });
|
||||
} else if (block.type === "tool_result") {
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, 10240) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
input: truncateObj(block.input, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
if (afterLine !== null) {
|
||||
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop
|
||||
let foundStart = false;
|
||||
for await (const line of rl) {
|
||||
lineNum++;
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
|
||||
if (!foundStart) {
|
||||
if (lineNum <= afterLine) continue;
|
||||
foundStart = true;
|
||||
}
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
if (messages.length >= limit) {
|
||||
// Check if there's at least one more valid message
|
||||
hasMore = true;
|
||||
rl.close();
|
||||
rl.removeAllListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If we exhausted the stream without hitting limit, hasMore stays false
|
||||
} else if (beforeLine !== null) {
|
||||
// History mode: collect messages with line < beforeLine using a sliding window.
|
||||
// hasMore here means "more *older* messages exist before what we're returning"
|
||||
// — the only way to know that is if we shifted any out of the window
|
||||
// (total > limit). Hitting the boundary tells us nothing about older history.
|
||||
for await (const line of rl) {
|
||||
lineNum++;
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
if (lineNum >= beforeLine) {
|
||||
// Reached the boundary — stop reading
|
||||
rl.close();
|
||||
rl.removeAllListeners();
|
||||
break;
|
||||
}
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
// Sliding window: only keep the last `limit` messages
|
||||
if (messages.length > limit) {
|
||||
messages.shift();
|
||||
}
|
||||
}
|
||||
if (total > limit) hasMore = true;
|
||||
} else if (offset > 0) {
|
||||
// Legacy offset pagination: skip `offset` valid messages, then collect `limit`
|
||||
let skipped = 0;
|
||||
for await (const line of rl) {
|
||||
lineNum++;
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
|
||||
if (skipped < offset) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
messages.push(message);
|
||||
if (messages.length >= limit) {
|
||||
hasMore = true; // assume more exist
|
||||
rl.close();
|
||||
rl.removeAllListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Default: return the latest N messages (chat-flow mode) using a sliding window
|
||||
for await (const line of rl) {
|
||||
lineNum++;
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!TRANSCRIPT_RENDER_TYPES.has(entry.type)) continue;
|
||||
|
||||
const message = parseMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
// Sliding window: only keep the last `limit` messages in memory
|
||||
if (messages.length > limit) {
|
||||
messages.shift();
|
||||
}
|
||||
}
|
||||
// If we shifted any messages out, there are more
|
||||
hasMore = total > limit;
|
||||
}
|
||||
|
||||
const lastLine = messages.length > 0 ? messages[messages.length - 1].line : 0;
|
||||
const firstLine = messages.length > 0 ? messages[0].line : 0;
|
||||
|
||||
// Remove internal line field from messages
|
||||
for (const m of messages) {
|
||||
delete m.line;
|
||||
}
|
||||
|
||||
res.json({
|
||||
messages,
|
||||
total,
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
// Exported for unit tests — sender attribution is correctness-critical.
|
||||
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions. This allows the frontend to manage and maintain the agent monitoring system effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { transcriptCache } = require("./hooks");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
return require("../../package.json").version || "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
})();
|
||||
|
||||
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
||||
const CLAUDE_SETTINGS_PATH = getSettingsPath();
|
||||
|
||||
function getDbSize() {
|
||||
try {
|
||||
const stat = fs.statSync(DB_PATH);
|
||||
return stat.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getTableCounts() {
|
||||
const tables = ["sessions", "agents", "events", "model_pricing"];
|
||||
const counts = {};
|
||||
for (const t of tables) {
|
||||
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
|
||||
}
|
||||
counts.token_usage = db
|
||||
.prepare("SELECT COUNT(DISTINCT session_id) as c FROM token_usage")
|
||||
.get().c;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function getHookStatus() {
|
||||
try {
|
||||
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
||||
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
||||
}
|
||||
const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
|
||||
const settings = JSON.parse(raw);
|
||||
const hookTypes = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
];
|
||||
const hooks = {};
|
||||
for (const ht of hookTypes) {
|
||||
const entries = settings.hooks?.[ht] || [];
|
||||
hooks[ht] = entries.some(
|
||||
(e) =>
|
||||
(e.command && e.command.includes("hook-handler.js")) ||
|
||||
(Array.isArray(e.hooks) &&
|
||||
e.hooks.some((h) => h.command && h.command.includes("hook-handler.js")))
|
||||
);
|
||||
}
|
||||
const installed = Object.values(hooks).every(Boolean);
|
||||
return { installed, path: CLAUDE_SETTINGS_PATH, hooks };
|
||||
} catch {
|
||||
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/settings/info — system info, db stats, hook status
|
||||
router.get("/info", (req, res) => {
|
||||
const dbSize = getDbSize();
|
||||
const counts = getTableCounts();
|
||||
const hookStatus = getHookStatus();
|
||||
|
||||
// Advanced SQLite info
|
||||
const pragmas = {
|
||||
journal_mode: db.pragma("journal_mode", { simple: true }),
|
||||
synchronous: db.pragma("synchronous", { simple: true }),
|
||||
auto_vacuum: db.pragma("auto_vacuum", { simple: true }),
|
||||
encoding: db.pragma("encoding", { simple: true }),
|
||||
foreign_keys: db.pragma("foreign_keys", { simple: true }),
|
||||
busy_timeout: db.pragma("busy_timeout", { simple: true }),
|
||||
};
|
||||
|
||||
// Recent activity load (events in last 5, 15, 60 minutes)
|
||||
const getCount = (ms) => {
|
||||
const d = new Date(Date.now() - ms).toISOString();
|
||||
return db.prepare("SELECT COUNT(*) as c FROM events WHERE created_at > ?").get(d).c;
|
||||
};
|
||||
|
||||
const load_stats = {
|
||||
m5: getCount(5 * 60 * 1000),
|
||||
m15: getCount(15 * 60 * 1000),
|
||||
h1: getCount(60 * 60 * 1000),
|
||||
};
|
||||
|
||||
res.json({
|
||||
db: {
|
||||
path: DB_PATH,
|
||||
size: dbSize,
|
||||
counts,
|
||||
pragmas,
|
||||
load_stats,
|
||||
},
|
||||
hooks: hookStatus,
|
||||
server: {
|
||||
version: APP_VERSION,
|
||||
uptime: process.uptime(),
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
ws_connections: getConnectionCount(),
|
||||
memory: process.memoryUsage(),
|
||||
cpu_load: os.loadavg(),
|
||||
arch: os.arch(),
|
||||
total_mem: os.totalmem(),
|
||||
free_mem: os.freemem(),
|
||||
cpus: os.cpus().length,
|
||||
},
|
||||
transcript_cache: transcriptCache.stats(),
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
|
||||
router.post("/clear-data", (_req, res) => {
|
||||
const counts = getTableCounts();
|
||||
db.pragma("foreign_keys = OFF");
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM events").run();
|
||||
db.prepare("DELETE FROM agents").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
|
||||
// *rules* survive: they're user configuration, like model_pricing.
|
||||
db.prepare("DELETE FROM alert_events").run();
|
||||
// Webhook delivery log is an audit trail of those fired alerts — wipe it too.
|
||||
// Webhook *targets* survive, like alert rules and pricing.
|
||||
db.prepare("DELETE FROM webhook_deliveries").run();
|
||||
db.pragma("foreign_keys = ON");
|
||||
res.json({ ok: true, cleared: counts });
|
||||
});
|
||||
|
||||
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
||||
router.post("/reimport", async (_req, res) => {
|
||||
try {
|
||||
const { importAllSessions } = require("../../scripts/import-history");
|
||||
const dbModule = require("../db");
|
||||
const result = await importAllSessions(dbModule);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "IMPORT_FAILED", message: err.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/reinstall-hooks — reinstall Claude Code hooks
|
||||
router.post("/reinstall-hooks", (_req, res) => {
|
||||
try {
|
||||
const { installHooks } = require("../../scripts/install-hooks");
|
||||
const success = installHooks(true);
|
||||
const hookStatus = getHookStatus();
|
||||
res.json({ ok: success, hooks: hookStatus });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "HOOK_INSTALL_FAILED", message: err.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/reset-pricing — reset pricing to defaults
|
||||
router.post("/reset-pricing", (_req, res) => {
|
||||
db.prepare("DELETE FROM model_pricing").run();
|
||||
|
||||
const seedPricing = db.prepare(
|
||||
"INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const [pattern, name, inp, out, cr, cw, cw1h, fin, fout] of DEFAULT_PRICING) {
|
||||
seedPricing.run(pattern, name, inp, out, cr, cw, cw1h, fin, fout);
|
||||
}
|
||||
// Re-apply time-limited intro rates (e.g. Sonnet 5) — the seed above only
|
||||
// carries standard rates, so without this a reset silently drops the promo.
|
||||
applyIntroPricing(db);
|
||||
|
||||
const pricing = stmts.listPricing.all();
|
||||
res.json({ ok: true, pricing });
|
||||
});
|
||||
|
||||
// GET /api/settings/export — export all data as JSON
|
||||
router.get("/export", (_req, res) => {
|
||||
const sessions = db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all();
|
||||
const agents = db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all();
|
||||
const events = db.prepare("SELECT * FROM events ORDER BY created_at DESC").all();
|
||||
const tokenUsage = db.prepare("SELECT * FROM token_usage").all();
|
||||
const pricing = stmts.listPricing.all();
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"`
|
||||
);
|
||||
res.json({
|
||||
exported_at: new Date().toISOString(),
|
||||
sessions,
|
||||
agents,
|
||||
events,
|
||||
token_usage: tokenUsage,
|
||||
model_pricing: pricing,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/settings/claude-home — get current CLAUDE_HOME path
|
||||
router.get("/claude-home", (_req, res) => {
|
||||
res.json({ claude_home: getClaudeHome() });
|
||||
});
|
||||
|
||||
// PUT /api/settings/claude-home — update CLAUDE_HOME path
|
||||
router.put("/claude-home", (req, res) => {
|
||||
const { path: newPath } = req.body;
|
||||
if (!newPath || typeof newPath !== "string") {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_PATH", message: "path is required and must be a string" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resolved = setClaudeHome(newPath);
|
||||
res.json({ ok: true, claude_home: resolved });
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
error: { code: "INVALID_PATH", message: err.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||
router.post("/cleanup", (req, res) => {
|
||||
const { abandon_hours, purge_days } = req.body;
|
||||
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
|
||||
|
||||
if (abandon_hours && typeof abandon_hours === "number" && abandon_hours > 0) {
|
||||
// Mark active sessions with no recent events as abandoned
|
||||
const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString();
|
||||
const stale = db
|
||||
.prepare(
|
||||
`SELECT s.id FROM sessions s
|
||||
WHERE s.status = 'active'
|
||||
AND s.started_at < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM events e WHERE e.session_id = s.id AND e.created_at > ?
|
||||
)`
|
||||
)
|
||||
.all(cutoff, cutoff);
|
||||
|
||||
for (const row of stale) {
|
||||
db.prepare(
|
||||
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(row.id);
|
||||
// Also complete any lingering agents
|
||||
db.prepare(
|
||||
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
|
||||
).run(row.id);
|
||||
}
|
||||
result.abandoned = stale.length;
|
||||
}
|
||||
|
||||
if (purge_days && typeof purge_days === "number" && purge_days > 0) {
|
||||
const cutoff = new Date(Date.now() - purge_days * 86400 * 1000).toISOString();
|
||||
// Only purge completed/error/abandoned sessions, never active
|
||||
const toDelete = db
|
||||
.prepare(
|
||||
"SELECT id FROM sessions WHERE status IN ('completed','error','abandoned') AND started_at < ?"
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
const ids = toDelete.map((r) => r.id);
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
|
||||
result.purged_events = db
|
||||
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
|
||||
.run(...ids).changes;
|
||||
result.purged_agents = db
|
||||
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
||||
.run(...ids).changes;
|
||||
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
||||
result.purged_sessions = toDelete.length;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, ...result });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @file Express router for stats endpoints, providing aggregated statistics about agents, sessions, events, and WebSocket connections. It queries the database for various counts and statuses, and returns a comprehensive overview in JSON format for frontend display on the dashboard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { parseSources } = require("../lib/source-filter");
|
||||
const scoped = require("../lib/scoped-stats");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const offsetMin = Number.isFinite(rawOffset) ? rawOffset : 0;
|
||||
const toLocal = `${-offsetMin} minutes`; // shift UTC → local
|
||||
const toUTC = `${offsetMin} minutes`; // shift local → UTC
|
||||
|
||||
// Data-scope: when the user restricts to a subset of source machines, compute
|
||||
// every count against that subset; otherwise use the cached prepared stmts.
|
||||
const sources = parseSources(req);
|
||||
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
|
||||
const agentsByStatus = sources
|
||||
? scoped.agentStatusCounts(db, sources)
|
||||
: stmts.agentStatusCounts.all();
|
||||
const sessionsByStatus = sources
|
||||
? scoped.sessionStatusCounts(db, sources)
|
||||
: stmts.sessionStatusCounts.all();
|
||||
|
||||
const eventsToday = sources
|
||||
? scoped.countEventsToday(db, sources, toLocal, toUTC)
|
||||
: stmts.countEventsToday.get(toLocal, toUTC);
|
||||
|
||||
res.json({
|
||||
...overview,
|
||||
events_today: eventsToday?.count ?? 0,
|
||||
ws_connections: getConnectionCount(),
|
||||
agents_by_status: Object.fromEntries(agentsByStatus.map((r) => [r.status, r.count])),
|
||||
sessions_by_status: Object.fromEntries(sessionsByStatus.map((r) => [r.status, r.count])),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @file HTTP routes for dashboard upstream-update detection. The dashboard never
|
||||
* restarts itself — users copy the printed command and run it in their terminal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { getUpdatesStatus } = require("../lib/update-check");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/status", async (_req, res) => {
|
||||
try {
|
||||
const status = await getUpdatesStatus();
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "UPDATE_STATUS_FAILED", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/check", async (_req, res) => {
|
||||
try {
|
||||
const status = await getUpdatesStatus();
|
||||
try {
|
||||
const { broadcast } = require("../websocket");
|
||||
broadcast("update_status", status);
|
||||
} catch {
|
||||
// WS not initialized (e.g. in isolated tests) — safe to ignore.
|
||||
}
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "UPDATE_CHECK_FAILED", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @file Express router for universal webhook targets across 14 providers
|
||||
* (Slack, Discord, Teams, Google Chat, Mattermost, Rocket.Chat, Telegram,
|
||||
* PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, generic).
|
||||
* Provides target CRUD, a synchronous "send test" probe, a per-target delivery
|
||||
* log, and redacted provider metadata for the UI. Secrets are never returned —
|
||||
* URLs are masked and secret config / header values are redacted in every
|
||||
* response. Delivery + provider definitions live in server/lib/.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { stmts } = require("../db");
|
||||
const { invalidateWebhookCache, normalizeTarget, sendTest } = require("../lib/webhooks");
|
||||
const {
|
||||
PROVIDERS,
|
||||
WEBHOOK_TYPES,
|
||||
isGenericFamily,
|
||||
resolveUrl,
|
||||
urlRequired,
|
||||
publicProviders,
|
||||
} = require("../lib/webhook-providers");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Serialization (redacted) ──────────────────────────────────────────────
|
||||
|
||||
// Reveal the host + last 4 chars so a user can recognize which webhook this is
|
||||
// without exposing any embedded secret token.
|
||||
function maskUrl(url) {
|
||||
if (!url) return "…";
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const tail = url.length > 4 ? url.slice(-4) : "";
|
||||
return `${u.protocol}//${u.host}/…${tail}`;
|
||||
} catch {
|
||||
return "…";
|
||||
}
|
||||
}
|
||||
|
||||
// Custom header values can carry auth tokens — return only the keys, masked.
|
||||
function redactHeaders(headers) {
|
||||
if (!headers || typeof headers !== "object") return null;
|
||||
const keys = Object.keys(headers);
|
||||
if (keys.length === 0) return null;
|
||||
const out = {};
|
||||
for (const k of keys) out[k] = "••••";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mask provider config fields flagged secret (routing keys, api keys, tokens);
|
||||
// show the rest (chat_id, region, severity, …).
|
||||
function redactConfig(type, config) {
|
||||
if (!config || typeof config !== "object") return null;
|
||||
const fields = PROVIDERS[type]?.fields || [];
|
||||
const secretKeys = new Set(fields.filter((f) => f.secret).map((f) => f.key));
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(config)) out[k] = secretKeys.has(k) ? "••••" : v;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
function serializeTarget(row) {
|
||||
const t = normalizeTarget(row);
|
||||
let last = null;
|
||||
try {
|
||||
last = stmts.lastWebhookDeliveryForTarget.get(t.id) || null;
|
||||
} catch {
|
||||
/* delivery log read is best-effort */
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
enabled: t.enabled,
|
||||
url_preview: maskUrl(resolveUrl(t)),
|
||||
has_secret: !!t.secret,
|
||||
headers: isGenericFamily(t.type) ? redactHeaders(t.headers) : null,
|
||||
config: redactConfig(t.type, t.config),
|
||||
rule_ids: t.rule_ids && t.rule_ids.length ? t.rule_ids : null,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
last_delivery: last
|
||||
? {
|
||||
status: last.status,
|
||||
status_code: last.status_code,
|
||||
attempts: last.attempts,
|
||||
error: last.error,
|
||||
created_at: last.created_at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────
|
||||
|
||||
function bad(res, message) {
|
||||
return res.status(400).json({ error: { code: "INVALID_INPUT", message } });
|
||||
}
|
||||
|
||||
function validateUrl(url, type) {
|
||||
if (typeof url !== "string" || !url.trim()) return { ok: false, error: "url is required" };
|
||||
let u;
|
||||
try {
|
||||
u = new URL(url.trim());
|
||||
} catch {
|
||||
return { ok: false, error: "url must be a valid URL" };
|
||||
}
|
||||
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
||||
return { ok: false, error: "url must use http or https" };
|
||||
}
|
||||
// Most providers' endpoints are https-only; only the generic family with
|
||||
// https:false (generic, n8n) permits http (for local/self-hosted testing).
|
||||
const allowHttp = PROVIDERS[type]?.https === false;
|
||||
if (!allowHttp && u.protocol !== "https:") {
|
||||
return { ok: false, error: `${type} webhook URL must use https` };
|
||||
}
|
||||
return { ok: true, url: url.trim() };
|
||||
}
|
||||
|
||||
// Validate (and normalize) provider config, merging supplied values over a base
|
||||
// (the existing config on PATCH) so a single field can change without re-sending
|
||||
// secrets. Returns { ok, value } where value is the full config object or null.
|
||||
function validateConfig(type, input, base = {}) {
|
||||
const fields = PROVIDERS[type]?.fields || [];
|
||||
if (input != null && (typeof input !== "object" || Array.isArray(input))) {
|
||||
return { ok: false, error: "config must be an object" };
|
||||
}
|
||||
const supplied = input || {};
|
||||
const out = {};
|
||||
for (const f of fields) {
|
||||
// Effective value: a non-empty supplied value wins, else fall back to base.
|
||||
let v = supplied[f.key];
|
||||
if (v == null || v === "") v = base[f.key];
|
||||
|
||||
if (v == null || v === "") {
|
||||
if (f.default != null) {
|
||||
out[f.key] = f.default;
|
||||
continue;
|
||||
}
|
||||
if (f.required) return { ok: false, error: `${f.label} is required` };
|
||||
continue;
|
||||
}
|
||||
if (f.type === "enum") {
|
||||
if (!f.options.includes(v)) {
|
||||
return { ok: false, error: `${f.label} must be one of: ${f.options.join(", ")}` };
|
||||
}
|
||||
} else {
|
||||
if (typeof v !== "string") return { ok: false, error: `${f.label} must be a string` };
|
||||
v = v.trim();
|
||||
if (!v) {
|
||||
if (f.required) return { ok: false, error: `${f.label} is required` };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out[f.key] = v;
|
||||
}
|
||||
return { ok: true, value: Object.keys(out).length ? out : null };
|
||||
}
|
||||
|
||||
function validateHeaders(headers) {
|
||||
if (headers == null) return { ok: true, value: null };
|
||||
if (typeof headers !== "object" || Array.isArray(headers)) {
|
||||
return { ok: false, error: "headers must be an object of string values" };
|
||||
}
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (typeof k !== "string" || !k.trim()) {
|
||||
return { ok: false, error: "header names must be non-empty strings" };
|
||||
}
|
||||
if (typeof v !== "string") return { ok: false, error: `header "${k}" value must be a string` };
|
||||
out[k] = v;
|
||||
}
|
||||
return { ok: true, value: Object.keys(out).length ? out : null };
|
||||
}
|
||||
|
||||
function validateRuleIds(ruleIds) {
|
||||
if (ruleIds == null) return { ok: true, value: null };
|
||||
if (!Array.isArray(ruleIds)) return { ok: false, error: "rule_ids must be an array" };
|
||||
for (const id of ruleIds) {
|
||||
if (typeof id !== "string" || !id.trim()) {
|
||||
return { ok: false, error: "rule_ids must be non-empty strings" };
|
||||
}
|
||||
}
|
||||
return { ok: true, value: ruleIds.length ? ruleIds : null };
|
||||
}
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/webhooks/providers — redacted provider catalog for the UI
|
||||
router.get("/providers", (_req, res) => {
|
||||
res.json({ providers: publicProviders() });
|
||||
});
|
||||
|
||||
// GET /api/webhooks — list targets (redacted)
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ targets: stmts.listWebhookTargets.all().map(serializeTarget) });
|
||||
});
|
||||
|
||||
// POST /api/webhooks — create a target
|
||||
router.post("/", (req, res) => {
|
||||
const { name, type, url, enabled, secret, headers, rule_ids, config } = req.body || {};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) return bad(res, "name is required");
|
||||
if (!WEBHOOK_TYPES.includes(type)) {
|
||||
return bad(res, `type must be one of: ${WEBHOOK_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
// URL: required for some providers, derived/defaulted for others (Telegram,
|
||||
// Opsgenie, PagerDuty). Stored as "" when not user-supplied.
|
||||
let storedUrl = "";
|
||||
if (urlRequired(type)) {
|
||||
const u = validateUrl(url, type);
|
||||
if (!u.ok) return bad(res, u.error);
|
||||
storedUrl = u.url;
|
||||
} else if (url != null && String(url).trim()) {
|
||||
const u = validateUrl(url, type);
|
||||
if (!u.ok) return bad(res, u.error);
|
||||
storedUrl = u.url;
|
||||
}
|
||||
|
||||
const cfg = validateConfig(type, config);
|
||||
if (!cfg.ok) return bad(res, cfg.error);
|
||||
|
||||
// secret + custom headers only apply to the generic family.
|
||||
const generic = isGenericFamily(type);
|
||||
const h = validateHeaders(generic ? headers : null);
|
||||
if (!h.ok) return bad(res, h.error);
|
||||
const r = validateRuleIds(rule_ids);
|
||||
if (!r.ok) return bad(res, r.error);
|
||||
|
||||
let sec = null;
|
||||
if (generic && secret != null) {
|
||||
if (typeof secret !== "string") return bad(res, "secret must be a string");
|
||||
sec = secret.trim() || null;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
stmts.insertWebhookTarget.run(
|
||||
id,
|
||||
name.trim(),
|
||||
type,
|
||||
storedUrl,
|
||||
enabled === false ? 0 : 1,
|
||||
sec,
|
||||
h.value ? JSON.stringify(h.value) : null,
|
||||
r.value ? JSON.stringify(r.value) : null,
|
||||
cfg.value ? JSON.stringify(cfg.value) : null
|
||||
);
|
||||
invalidateWebhookCache();
|
||||
res.status(201).json({ target: serializeTarget(stmts.getWebhookTarget.get(id)) });
|
||||
});
|
||||
|
||||
// PATCH /api/webhooks/:id — partial update. url/secret/headers/rule_ids/config
|
||||
// are only changed when their key is present in the body (omit = leave as-is).
|
||||
router.patch("/:id", (req, res) => {
|
||||
const existing = stmts.getWebhookTarget.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
||||
}
|
||||
const body = req.body || {};
|
||||
const { name, url, enabled, secret, headers, rule_ids, config } = body;
|
||||
const generic = isGenericFamily(existing.type);
|
||||
|
||||
if (name != null && (typeof name !== "string" || !name.trim())) {
|
||||
return bad(res, "name must be a non-empty string");
|
||||
}
|
||||
|
||||
let urlVal = null;
|
||||
if (url != null) {
|
||||
const u = validateUrl(url, existing.type);
|
||||
if (!u.ok) return bad(res, u.error);
|
||||
urlVal = u.url;
|
||||
}
|
||||
|
||||
// config: merge supplied fields over the existing config, then re-validate,
|
||||
// so e.g. region can change without re-sending the api_key.
|
||||
let configSet = 0;
|
||||
let configVal = null;
|
||||
if ("config" in body) {
|
||||
let base = {};
|
||||
try {
|
||||
base = existing.config ? JSON.parse(existing.config) : {};
|
||||
} catch {
|
||||
base = {};
|
||||
}
|
||||
const cfg = validateConfig(existing.type, config, base);
|
||||
if (!cfg.ok) return bad(res, cfg.error);
|
||||
configSet = 1;
|
||||
configVal = cfg.value ? JSON.stringify(cfg.value) : null;
|
||||
}
|
||||
|
||||
let secretSet = 0;
|
||||
let secretVal = null;
|
||||
if ("secret" in body && generic) {
|
||||
if (secret !== null && typeof secret !== "string") {
|
||||
return bad(res, "secret must be a string or null");
|
||||
}
|
||||
secretSet = 1;
|
||||
secretVal = secret ? String(secret).trim() || null : null;
|
||||
}
|
||||
|
||||
let headersSet = 0;
|
||||
let headersVal = null;
|
||||
if ("headers" in body && generic) {
|
||||
const h = validateHeaders(headers);
|
||||
if (!h.ok) return bad(res, h.error);
|
||||
headersSet = 1;
|
||||
headersVal = h.value ? JSON.stringify(h.value) : null;
|
||||
}
|
||||
|
||||
let ruleSet = 0;
|
||||
let ruleVal = null;
|
||||
if ("rule_ids" in body) {
|
||||
const r = validateRuleIds(rule_ids);
|
||||
if (!r.ok) return bad(res, r.error);
|
||||
ruleSet = 1;
|
||||
ruleVal = r.value ? JSON.stringify(r.value) : null;
|
||||
}
|
||||
|
||||
stmts.updateWebhookTarget.run(
|
||||
name != null ? name.trim() : null,
|
||||
urlVal,
|
||||
enabled == null ? null : enabled ? 1 : 0,
|
||||
secretSet,
|
||||
secretVal,
|
||||
headersSet,
|
||||
headersVal,
|
||||
ruleSet,
|
||||
ruleVal,
|
||||
configSet,
|
||||
configVal,
|
||||
req.params.id
|
||||
);
|
||||
invalidateWebhookCache();
|
||||
res.json({ target: serializeTarget(stmts.getWebhookTarget.get(req.params.id)) });
|
||||
});
|
||||
|
||||
// DELETE /api/webhooks/:id — delete a target (its delivery log cascades away)
|
||||
router.delete("/:id", (req, res) => {
|
||||
const existing = stmts.getWebhookTarget.get(req.params.id);
|
||||
if (!existing) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
||||
}
|
||||
stmts.deleteWebhookTarget.run(req.params.id);
|
||||
invalidateWebhookCache();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/webhooks/:id/test — send a synthetic alert and report the result.
|
||||
// Always 200 (the request itself succeeded); `ok` carries the delivery result.
|
||||
router.post("/:id/test", async (req, res) => {
|
||||
const row = stmts.getWebhookTarget.get(req.params.id);
|
||||
if (!row) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
||||
}
|
||||
const result = await sendTest(normalizeTarget(row));
|
||||
res.json({
|
||||
ok: result.ok,
|
||||
status: result.status ?? null,
|
||||
attempts: result.attempts,
|
||||
error: result.error || null,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/webhooks/:id/deliveries — recent delivery log for a target
|
||||
router.get("/:id/deliveries", (req, res) => {
|
||||
const row = stmts.getWebhookTarget.get(req.params.id);
|
||||
if (!row) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
||||
}
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 20, 200));
|
||||
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||
const deliveries = stmts.listWebhookDeliveriesForTarget.all(req.params.id, limit, offset);
|
||||
res.json({ deliveries, limit, offset });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.WEBHOOK_TYPES = WEBHOOK_TYPES;
|
||||
@@ -0,0 +1,846 @@
|
||||
/**
|
||||
* @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;
|
||||
Reference in New Issue
Block a user