feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)

Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+142 -17
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,6 +24,8 @@ const { getPipeline } = require("../lib/pipelines");
const path = require("path");
const { threadOf } = require("../lib/codex-usage");
const { agyCliDirs } = require("../lib/antigravity-home");
const { isIgnoredCwd } = require("../lib/privacy");
const { checkPushToken, pushTokenStatus } = require("../lib/push-token");
const router = Router();
@@ -1335,23 +1337,60 @@ function bindRunSession(runId, sessionId, hookType, source, tool) {
if (row.lane_id != null) stmts.claimSessionLane.run(row.lane_id, sessionId);
}
router.post("/event", (req, res) => {
let { hook_type, data } = req.body;
// A pushed hook's transcript paths name files on the SENDER's machine; read
// here they would be this machine's files, so they never get that far.
const PUSH_STRIPPED = ["transcript_path", "agent_transcript_path", "transcriptPath"];
/**
* One hook through ingestion, shared by `POST /event` (this machine's CLIs)
* and `POST /ingest-batch` (another machine's, `pushSource` = its
* `sessions.source` tag). Answers `{status, body}` for the response, plus the
* stored event and the (mapped) hook when one was stored. A pushed hook never
* touches a session another source owns, a lane or a dashboard pane.
*/
function ingestHook(body, pushSource = null) {
let { hook_type, data } = body || {};
if (!hook_type || !data) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
});
return {
status: 400,
body: { error: { code: "INVALID_INPUT", message: "hook_type and data are required" } },
};
}
if (pushSource) {
data = { ...data };
for (const k of PUSH_STRIPPED) delete data[k];
}
const ignored = { status: 200, body: { ok: true, ignored: true } };
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
// Code — an allowlist, never a value stored as sent.
const tool = Object.hasOwn(HOOK_TOOLS, req.body.tool) ? req.body.tool : "claude";
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
if (tool !== "claude") {
if (!data || typeof data !== "object") return res.json({ ok: true, ignored: true });
if (!data || typeof data !== "object") return ignored;
const mapped = HOOK_TOOLS[tool](hook_type, data);
if (!mapped) return res.json({ ok: true, ignored: true });
if (!mapped) return ignored;
({ hookType: hook_type, data } = mapped);
}
// A folder the user asked AgentHub to ignore (Settings → Privacy): dropped
// whole, before anything is stored.
if (isIgnoredCwd(data.cwd)) return ignored;
// A pushed session id is the sender's to choose: it may only extend a
// session its own source created, never one of this machine's or another's.
let pushedNew = false;
if (pushSource) {
const owner =
typeof data.session_id === "string" && data.session_id
? stmts.getSession.get(data.session_id)
: null;
if (owner && owner.source !== pushSource) {
return {
status: 409,
body: { error: { code: "EFOREIGN", message: "session belongs to another source" } },
};
}
pushedNew = !owner;
}
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
// + a leftover checkout install, two marketplaces), or two servers on one DB
@@ -1364,7 +1403,7 @@ router.post("/event", (req, res) => {
data.session_id &&
stmts.findRecentDuplicateEvent.get(data.session_id, hook_type, JSON.stringify(data))
) {
return res.json({ ok: true, duplicate: true });
return { status: 200, body: { ok: true, duplicate: true } };
}
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
@@ -1377,16 +1416,32 @@ router.post("/event", (req, res) => {
const result = processEvent(hook_type, data, tool);
if (!result) {
return res.status(400).json({
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
});
return {
status: 400,
body: { error: { code: "MISSING_SESSION", message: "session_id is required in data" } },
};
}
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data, tool);
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
if (pushSource) {
// Tagged in the same tick processEvent created it, so no local liveness
// sweep ever sees it as this machine's.
if (pushedNew) {
stmts.setSessionSource.run(pushSource, data.session_id);
broadcast("session_updated", stmts.getSession.get(data.session_id));
}
} else {
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data, tool);
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
}
return { status: 200, body: { ok: true, event: result }, result, hook_type, data, tool };
}
res.json({ ok: true, event: result });
router.post("/event", (req, res) => {
const out = ingestHook(req.body);
res.status(out.status).json(out.body);
if (!out.result) return;
const { result, hook_type, data, tool } = out;
// Evaluate event-driven alert rules after the ingest transaction committed
// and the response is on its way — alerting must never slow down or fail
@@ -1483,6 +1538,76 @@ router.post("/event", (req, res) => {
}
});
// ── POST /api/hooks/ingest-batch ────────────────────────────────────────────
// Hooks from a machine this dashboard cannot reach over SSH, pushed with the
// bearer token Settings → Data → Push ingest makes (server/lib/push-token.js).
// Off — 503 — until a token exists. Body: {source, events: [{hook_type, data,
// tool?}]}; each event goes through ingestHook exactly as POST /event would,
// with its session tagged `push:<source>`. Under /hooks, so DASHBOARD_TOKEN
// does not gate it: this token does. The global 1 MB JSON limit bounds a batch.
const MAX_PUSH_EVENTS = 500;
const PUSH_SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const HOOK_TYPE_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
router.post("/ingest-batch", (req, res) => {
const auth = req.headers.authorization;
const given = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!checkPushToken(given)) {
if (!pushTokenStatus().enabled) {
return res.status(503).json({
error: { code: "EPUSHDISABLED", message: "push ingest is off: create a token in Settings" },
});
}
return res
.status(401)
.json({ error: { code: "EUNAUTHORIZED", message: "missing or invalid push token" } });
}
const { source, events } = req.body || {};
if (typeof source !== "string" || !PUSH_SOURCE_RE.test(source)) {
return res.status(400).json({
error: { code: "EBADSOURCE", message: "source must be 1-64 letters, digits, . _ -" },
});
}
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_PUSH_EVENTS) {
return res.status(400).json({
error: { code: "EBADBATCH", message: `events must be 1-${MAX_PUSH_EVENTS} hook payloads` },
});
}
const tag = `push:${source}`;
const stored = [];
const results = events.map((ev) => {
if (
!ev ||
typeof ev.hook_type !== "string" ||
!HOOK_TYPE_RE.test(ev.hook_type) ||
!ev.data ||
typeof ev.data !== "object" ||
Array.isArray(ev.data)
) {
return { ok: false, error: "INVALID_INPUT" };
}
try {
const out = ingestHook({ hook_type: ev.hook_type, data: ev.data, tool: ev.tool }, tag);
if (out.result) {
stored.push(out.result);
return { ok: true };
}
return out.status === 200 ? out.body : { ok: false, error: out.body.error.code };
} catch (err) {
console.warn("[hooks] ingest-batch event failed:", err.message);
return { ok: false, error: "EINGEST" };
}
});
res.json({ ok: true, source: tag, stored: stored.length, results });
for (const result of stored) {
try {
evaluateEvent(result);
} catch {
/* non-fatal, as on POST /event */
}
}
});
// ── Watchdog: detect API errors in active sessions ─────────────────────────
// Claude CLI doesn't fire a hook after API errors (401, rate limit, etc.) —
// the session just sits there with the error in the transcript but no Stop