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
+218 -7
View File
@@ -1,16 +1,26 @@
/**
* @file Rules-based alerting engine. Evaluates user-defined alert rules against
* live activity: event-driven rules (event_pattern, token_threshold) run on
* every hook ingest, time-based rules (inactivity, status_duration) run on a
* periodic sweep. Fired alerts are persisted to alert_events with per-scope
* cooldown dedup and broadcast to clients as `alert_triggered`.
* live activity: event-driven rules (event_pattern, token_threshold,
* cost_threshold) run on every hook ingest, time-based rules (inactivity,
* status_duration, budget) run on a periodic sweep. Fired alerts are persisted
* to alert_events with per-scope cooldown dedup — a budget instead fires once
* per threshold per period — and broadcast to clients as `alert_triggered`.
* `previewRule` replays a rule over recorded history without firing anything.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db, stmts } = require("../db");
const { broadcast } = require("../websocket");
const RULE_TYPES = ["event_pattern", "inactivity", "status_duration", "token_threshold"];
const RULE_TYPES = [
"event_pattern",
"inactivity",
"status_duration",
"token_threshold",
"cost_threshold",
"budget",
];
const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const AGENT_STATUSES = ["working", "waiting"];
// Enabled-rules cache. Hook ingest is hot — re-querying alert_rules on every
@@ -98,6 +108,47 @@ function validateRuleConfig(ruleType, config) {
}
return { ok: true, config: { total_tokens: total } };
}
case "cost_threshold": {
const usd = num(cfg.usd);
if (!usd) return { ok: false, error: "usd must be a positive number" };
return { ok: true, config: { usd } };
}
case "budget": {
const { PERIODS } = require("./budgets");
if (!PERIODS.includes(cfg.period)) {
return { ok: false, error: `period must be one of: ${PERIODS.join(", ")}` };
}
const limit = num(cfg.limit_usd);
if (!limit) return { ok: false, error: "limit_usd must be a positive number" };
const thresholds = cfg.thresholds == null ? [80, 100] : cfg.thresholds;
if (
!Array.isArray(thresholds) ||
thresholds.length === 0 ||
thresholds.length > 10 ||
!thresholds.every((t) => Number.isInteger(t) && t > 0 && t <= 1000)
) {
return { ok: false, error: "thresholds must be 1–10 integer percents in 1..1000" };
}
const out = { period: cfg.period, limit_usd: limit, thresholds: [...new Set(thresholds)] };
if (cfg.tool != null) {
if (typeof cfg.tool !== "string" || !TOOL_RE.test(cfg.tool)) {
return { ok: false, error: "tool must be an AI tool id" };
}
out.tool = cfg.tool;
}
if (cfg.lane != null) {
if (!Number.isInteger(cfg.lane) || cfg.lane <= 0) {
return { ok: false, error: "lane must be a workspace id" };
}
out.lane = cfg.lane;
}
const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset;
if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) {
return { ok: false, error: "tz_offset must be minutes within ±840" };
}
out.tz_offset = tz;
return { ok: true, config: out };
}
default:
return { ok: false, error: "unsupported rule_type" };
}
@@ -108,8 +159,11 @@ function validateRuleConfig(ruleType, config) {
* its cooldown window. Persists the alert row and broadcasts it. Returns the
* inserted row, or null when suppressed by cooldown.
*/
function fireAlert(rule, { sessionId = null, agentId = null, message, details = null }) {
const last = stmts.lastAlertFor.get(rule.id, sessionId, agentId);
function fireAlert(
rule,
{ sessionId = null, agentId = null, message, details = null, ignoreCooldown = false }
) {
const last = ignoreCooldown ? null : stmts.lastAlertFor.get(rule.id, sessionId, agentId);
if (last) {
const elapsedMs = Date.now() - new Date(last.triggered_at).getTime();
if (elapsedMs < rule.cooldown_seconds * 1000) return null;
@@ -184,6 +238,23 @@ function matchesPattern(event, cfg) {
// query for the rest of the event stream.
const TOKEN_BEARING_EVENTS = new Set(["PostToolUse", "Stop", "SubagentStop", "SessionEnd"]);
// A budget threshold already fired in this period (details carry both).
const budgetFiredStmt = db.prepare(
`SELECT 1 FROM alert_events WHERE rule_id = ?
AND json_extract(details, '$.period_start') = ? AND json_extract(details, '$.threshold') = ?`
);
/** A session's cost, priced as of its start date like GET /api/pricing/cost/:id. */
function sessionCost(sessionId, rules) {
const { calculateCost } = require("../routes/pricing");
const started = stmts.getSession.get(sessionId)?.started_at;
return calculateCost(
stmts.getTokensBySession.all(sessionId),
rules || stmts.listPricing.all(),
started ? String(started).slice(0, 10) : undefined
).total_cost;
}
// Sweep queries are static — prepare once at module load instead of on every
// 60s tick. The time window arrives as a strftime modifier parameter.
const staleSessionsStmt = db.prepare(
@@ -248,6 +319,15 @@ function evaluateEvent(event) {
message: `${rule.name}: session used ${total.toLocaleString()} tokens (threshold ${rule.config.total_tokens.toLocaleString()})`,
details: { total_tokens: total, threshold: rule.config.total_tokens },
});
} else if (rule.rule_type === "cost_threshold") {
if (!TOKEN_BEARING_EVENTS.has(event.event_type)) continue;
const cost = sessionCost(event.session_id);
if (cost < rule.config.usd) continue;
fireAlert(rule, {
sessionId: event.session_id,
message: `${rule.name}: session cost $${cost.toFixed(2)} (threshold $${rule.config.usd.toFixed(2)})`,
details: { cost_usd: cost, threshold_usd: rule.config.usd },
});
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" evaluation failed:`, err?.message || err);
@@ -297,6 +377,8 @@ function sweepTimeRules() {
details: { status: rule.config.status, minutes: rule.config.minutes },
});
}
} else if (rule.rule_type === "budget") {
sweepBudget(rule);
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" sweep failed:`, err?.message || err);
@@ -304,6 +386,133 @@ function sweepTimeRules() {
}
}
/** Fires each newly crossed threshold of one budget rule, once per period. */
function sweepBudget(rule, now = Date.now()) {
const { budgetSpend, crossed } = require("./budgets");
const s = budgetSpend(rule.config, { now });
for (const threshold of crossed(rule.config, s.pct)) {
if (budgetFiredStmt.get(rule.id, s.period_start, threshold)) continue;
fireAlert(rule, {
ignoreCooldown: true,
message: `${rule.name}: ${threshold}% of the ${rule.config.period} budget — $${s.spend.toFixed(2)} of $${rule.config.limit_usd.toFixed(2)}`,
details: {
period_start: s.period_start,
threshold,
spend: s.spend,
limit_usd: rule.config.limit_usd,
projected: s.projected,
},
});
}
}
const PREVIEW_SAMPLES = 10;
/**
* Replays a rule over recorded history — what it WOULD have fired — without
* writing or broadcasting anything. `days` bounds the window (default 7).
* `status_duration` cannot be replayed (agent status history is not stored)
* and answers `supported: false`.
* @returns {{supported: boolean, window_days: number, fires: number,
* sessions: number, samples: Array<object>, periods?: Array<object>}}
*/
function previewRule(ruleType, config, { days = 7, cooldownSeconds = 300, now = Date.now() } = {}) {
const since = new Date(now - days * 86_400_000).toISOString();
const out = { supported: true, window_days: days, fires: 0, sessions: 0, samples: [] };
const fired = new Set();
const fire = (sessionId, at, detail) => {
out.fires++;
fired.add(sessionId);
if (out.samples.length < PREVIEW_SAMPLES)
out.samples.push({ session_id: sessionId, at, ...detail });
};
if (ruleType === "event_pattern") {
const where = ["created_at >= ?"];
const params = [since];
if (config.event_type) {
where.push("event_type = ?");
params.push(config.event_type);
}
if (config.tool_name) {
where.push("tool_name = ?");
params.push(config.tool_name);
}
if (config.summary_contains) {
where.push("LOWER(COALESCE(summary, '')) LIKE ?");
params.push(`%${config.summary_contains.toLowerCase()}%`);
}
const windowMs = (config.window_minutes || 0) * 60_000;
const recent = new Map(); // session → match times inside the window
const lastFire = new Map();
const rows = db
.prepare(
`SELECT session_id, created_at FROM events WHERE ${where.join(" AND ")} ORDER BY created_at, id`
)
.iterate(...params);
for (const r of rows) {
const t = Date.parse(r.created_at);
let seen = 1;
if (config.count > 1) {
const list = (recent.get(r.session_id) || []).filter((x) => t - x <= windowMs);
list.push(t);
recent.set(r.session_id, list);
seen = list.length;
if (seen < config.count) continue;
}
const last = lastFire.get(r.session_id);
if (last !== undefined && t - last < cooldownSeconds * 1000) continue;
lastFire.set(r.session_id, t);
fire(r.session_id, r.created_at, { observed_count: seen });
}
} else if (ruleType === "inactivity") {
// A gap longer than `minutes` between two events of one session.
const gapMs = config.minutes * 60_000;
const rows = db
.prepare(
`SELECT session_id, created_at,
LAG(created_at) OVER (PARTITION BY session_id ORDER BY created_at, id) AS prev
FROM events WHERE created_at >= ?`
)
.iterate(since);
for (const r of rows) {
if (!r.prev) continue;
const gap = Date.parse(r.created_at) - Date.parse(r.prev);
if (gap > gapMs) fire(r.session_id, r.prev, { gap_minutes: Math.round(gap / 60_000) });
}
} else if (ruleType === "token_threshold" || ruleType === "cost_threshold") {
const rules = stmts.listPricing.all();
const sessions = db
.prepare("SELECT id, updated_at FROM sessions WHERE updated_at >= ? ORDER BY updated_at")
.all(since);
for (const s of sessions) {
if (ruleType === "token_threshold") {
const t = stmts.sessionTokenTotals.get(s.id);
const total = t.input_tokens + t.output_tokens + t.cache_read_tokens + t.cache_write_tokens;
if (total >= config.total_tokens) fire(s.id, s.updated_at, { total_tokens: total });
} else {
const cost = sessionCost(s.id, rules);
if (cost >= config.usd) fire(s.id, s.updated_at, { cost_usd: cost });
}
}
} else if (ruleType === "budget") {
// The current period and the five before it.
const { budgetSpend, crossed } = require("./budgets");
const rules = stmts.listPricing.all();
out.periods = [];
for (let back = 5; back >= 0; back--) {
const s = budgetSpend(config, { now, back, rules });
const hit = crossed(config, s.pct);
out.fires += hit.length;
out.periods.push({ ...s, crossed: hit });
}
} else {
out.supported = false;
}
out.sessions = fired.size;
return out;
}
// Periodic sweep for the time-based rules. unref'd so it never keeps the
// process (or the test runner) alive — same pattern as the hooks watchdog.
const SWEEP_INTERVAL_MS = 60_000;
@@ -317,4 +526,6 @@ module.exports = {
sweepTimeRules,
fireAlert,
invalidateRuleCache,
previewRule,
sweepBudget,
};
+118
View File
@@ -0,0 +1,118 @@
/**
* @file Spend budgets. A budget is an alert rule of type `budget` (config:
* `period` day | week | month, `limit_usd`, `thresholds` in percent, optional
* `tool` and `lane` scope, `tz_offset` = the browser's getTimezoneOffset() so a
* period starts at the user's local midnight). This module computes a budget's
* spend in its current period — priced exactly like GET /api/pricing/cost, by
* the day the tokens were spent — the pace-based projection to the period's
* end, and the status list behind GET /api/budgets/status. Firing lives in
* `alerts.js`, once per threshold per period.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { stmts } = require("../db");
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
const PERIODS = ["day", "week", "month"];
const DAY_MS = 86_400_000;
const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
/**
* The period containing `now`, in the user's local time.
* @param {"day"|"week"|"month"} period
* @param {number} now epoch ms
* @param {number} tzOffset minutes, as getTimezoneOffset() (UTC+7 → -420)
* @param {number} [back] periods before the current one (0 = current)
* @returns {{start: string, end: string, startUtc: string, days: number, elapsed: number}}
* `start`/`end` local dates (inclusive), `startUtc` the start as an ISO
* instant, `elapsed` the fraction of the period gone (1 for a past period).
*/
function periodBounds(period, now, tzOffset = 0, back = 0) {
const local = new Date(now - tzOffset * 60_000); // read with getUTC*
let startLocal;
let endLocal; // exclusive
if (period === "day") {
startLocal = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate() - back);
endLocal = startLocal + DAY_MS;
} else if (period === "week") {
const dow = (local.getUTCDay() + 6) % 7; // Monday = 0
startLocal = Date.UTC(
local.getUTCFullYear(),
local.getUTCMonth(),
local.getUTCDate() - dow - 7 * back
);
endLocal = startLocal + 7 * DAY_MS;
} else {
startLocal = Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - back, 1);
endLocal = Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - back + 1, 1);
}
const span = endLocal - startLocal;
return {
start: ymd(startLocal),
end: ymd(endLocal - DAY_MS),
startUtc: new Date(startLocal + tzOffset * 60_000).toISOString(),
days: Math.round(span / DAY_MS),
elapsed: back > 0 ? 1 : Math.min(1, Math.max(0, (local.getTime() - startLocal) / span)),
};
}
/**
* Spend of a budget config in one period (current by default).
* @returns {{period_start: string, period_end: string, spend: number,
* projected: number, pct: number}}
*/
function budgetSpend(cfg, { now = Date.now(), back = 0, rules } = {}) {
const tz = Number.isFinite(cfg.tz_offset) ? cfg.tz_offset : 0;
const b = periodBounds(cfg.period, now, tz, back);
// A session that spent in the period was touched in it, so older ones are
// skipped before their hours are read.
const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }];
const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null;
const where = costWhere({ scope, lane: cfg.lane, extra });
const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter(
(r) => r.date >= b.start && r.date <= b.end
);
const spend = calculateCost(rows, rules || stmts.listPricing.all()).total_cost;
// The pace so far, carried to the period's end; at least an hour of pace so
// the first minutes of a period do not project a fortune.
const projected = spend / Math.max(b.elapsed, 1 / (b.days * 24));
const round = (n) => Math.round(n * 10000) / 10000;
return {
period_start: b.start,
period_end: b.end,
spend: round(spend),
projected: round(back > 0 ? spend : projected),
pct: cfg.limit_usd > 0 ? round((spend / cfg.limit_usd) * 100) : 0,
};
}
/** The thresholds a spend has reached, lowest first. */
function crossed(cfg, pct) {
return [...cfg.thresholds].sort((a, b) => a - b).filter((t) => pct >= t);
}
/**
* Every enabled budget rule with its current-period spend.
* @returns {Array<object>} rule id/name/config + `budgetSpend` + `crossed`.
*/
function budgetStatus({ now = Date.now() } = {}) {
const rules = stmts.listPricing.all();
return stmts.listEnabledAlertRules
.all()
.filter((r) => r.rule_type === "budget")
.map((r) => {
let cfg;
try {
cfg = JSON.parse(r.config || "{}");
} catch {
return null;
}
if (!PERIODS.includes(cfg.period) || !(cfg.limit_usd > 0)) return null;
const s = budgetSpend(cfg, { now, rules });
return { id: r.id, name: r.name, config: cfg, ...s, crossed: crossed(cfg, s.pct) };
})
.filter(Boolean);
}
module.exports = { PERIODS, periodBounds, budgetSpend, budgetStatus, crossed };
+6 -3
View File
@@ -34,8 +34,11 @@ const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the age
const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass
const MAX_DEPTH = 4; // sessions/<YYYY>/<MM>/<DD>/<file>
/** Every rollout journal under sessions/ ({path, mtime, size}), unsorted. */
function allRolloutFiles() {
/**
* Every rollout journal under `<home>/sessions/` ({path, mtime, size}),
* unsorted. `home` is CODEX_HOME unless a remote source's mirror names its own.
*/
function allRolloutFiles(home = getCodexHome()) {
const out = [];
const walk = (dir, depth) => {
let entries = [];
@@ -58,7 +61,7 @@ function allRolloutFiles() {
}
}
};
walk(path.join(getCodexHome(), "sessions"), 0);
walk(path.join(home, "sessions"), 0);
return out;
}
+84 -30
View File
@@ -9,7 +9,9 @@
* - line 1 `session_meta`: `id` = the thread, `session_id` = the ROOT session
* (a sub-agent thread's parent; absent on old CLIs, then `id`), `cwd`,
* `timestamp`, `forked_from_id` on a fork;
* - `turn_context.model` is the model for the lines that follow;
* - `turn_context.model` is the model for the lines that follow, and
* `thread_settings_applied.thread_settings.service_tier` the tier (fast /
* priority → `speed = fast`, flex → `service_tier = flex`, else standard);
* - `token_count` events carry a cumulative `total_token_usage` and its delta
* `last_token_usage` — `input_tokens` INCLUDES `cached_input_tokens`,
* `output_tokens` includes reasoning. The same total is often sent twice.
@@ -29,6 +31,13 @@
* minutes) keeps the status, end and last-active time the hooks gave it; this
* module still writes its usage, model and name. Hooks that stopped reporting
* (removed, no longer trusted) hand the status back to the journal.
*
* A remote source (server/lib/remote-sync.js) mirrors another machine's
* `sessions/` and `session_index.jsonl` into its staging dir and runs the same
* pass over it (`home`), with its own `source` tag: a session row is only ever
* written by passes of the source that created it, and the local settle never
* touches a remote row. A session whose folder the user ignores
* (`server/lib/privacy.js`) is never written.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -40,6 +49,7 @@ const { claimToolSessions } = require("./dashboard-runs");
const { getCodexHome, codexHomes } = require("./codex-home");
const { allRolloutFiles, FUTURE_SLACK_MS } = require("./codex-ingest");
const pricingSync = require("./pricing-sync");
const { isIgnoredCwd } = require("./privacy");
const ACTIVE_MS = 120_000; // same window codex-ingest.js calls a lane running
// Read at most this much of one journal per pass: a sync read of a 100 MB
@@ -48,12 +58,26 @@ const MAX_CHUNK = 16 * 1024 * 1024;
const NEEDLES = [
'"session_meta"',
'"turn_context"',
'"thread_settings_applied"',
'"token_count"',
'"user_message"', // a prompt, older CLIs
'"UserMessage"', // a prompt, as an item_completed event (0.149+)
];
const FILE_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
// A usage bucket is keyed `model` (the default tier — and every state saved
// before tiers were read) or `model␁speed␁service_tier`.
const SEP = "\u0001";
function usageKey(model, tier) {
if (tier === "fast" || tier === "priority") return `${model}${SEP}fast${SEP}standard`;
if (tier === "flex") return `${model}${SEP}standard${SEP}flex`;
return model;
}
function splitUsageKey(key) {
const [model, speed = "standard", serviceTier = "standard"] = key.split(SEP);
return { model, speed, serviceTier };
}
function totalKey(t) {
return [
t.input_tokens,
@@ -102,6 +126,8 @@ function parseRolloutChunk(text, state = {}, skip = null) {
s.forkedFrom = p.forked_from_id || null;
} else if (o.type === "turn_context") {
if (p.model) s.model = p.model;
} else if (o.type === "event_msg" && p.type === "thread_settings_applied") {
s.tier = p.thread_settings?.service_tier || null;
} else if (o.type === "event_msg" && !s.firstPrompt && promptOf(p)) {
// The session's label when Codex has no thread name for it — the same
// first-line, 60-character rule hooks.js applies to a Claude prompt.
@@ -117,7 +143,7 @@ function parseRolloutChunk(text, state = {}, skip = null) {
if (skip) s.pastFork = true;
}
const l = p.info.last_token_usage || {};
const u = (s.usage[s.model || "unknown"] ||= {
const u = (s.usage[usageKey(s.model || "unknown", s.tier)] ||= {
input: 0,
cacheRead: 0,
cacheWrite: 0,
@@ -199,12 +225,12 @@ function threadOf(file) {
return thread;
}
/** `thread_name` per id from `$CODEX_HOME/session_index.jsonl` (last wins). */
function threadNames() {
/** `thread_name` per id from `<home>/session_index.jsonl` (last wins). */
function threadNames(home = getCodexHome()) {
const names = new Map();
let text = "";
try {
text = fs.readFileSync(path.join(getCodexHome(), "session_index.jsonl"), "utf8");
text = fs.readFileSync(path.join(home, "session_index.jsonl"), "utf8");
} catch {
return names;
}
@@ -234,8 +260,8 @@ const rolloutsOf = db.prepare(
"SELECT thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
);
const insertCodexSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex')`
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex', ?)`
);
const updateCodexSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = ?, ended_at = ?, updated_at = ?,
@@ -257,21 +283,29 @@ function hooksCurrent(sessionId, newestMtime) {
}
// A journal written "in the future" (a clock that runs ahead) is not proof of
// activity — codex-ingest.js treats it the same way.
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
const fresh = (mtime, now, activeMs = ACTIVE_MS) =>
mtime - now < FUTURE_SLACK_MS && now - mtime < activeMs;
// This machine's journals. A remote source passes its id and a longer window:
// its mirror is only as fresh as its last sync.
const LOCAL = { source: "local", activeMs: ACTIVE_MS };
/** Re-derive one root session's row and usage from all its threads. */
function writeSession(sessionId, names, now) {
function writeSession(sessionId, names, now, origin = LOCAL) {
const rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
if (rows.length === 0) return null;
const existing = stmts.getSession.get(sessionId);
if (existing && existing.tool !== "codex") return null;
// Another machine's row (or this machine's, seen from a remote pass).
if (existing && (existing.source || "local") !== origin.source) return null;
// The root thread's own file, when it has been read: a sub-agent file read
// first must not fix the session's cwd, start or model.
const ownRoot = rows.find((r) => r.thread_id === sessionId);
const root = ownRoot || rows[0];
if (isIgnoredCwd(root.state.cwd)) return null;
const newest = rows.reduce((a, b) => (b.mtime_ms > a.mtime_ms ? b : a));
const active = rows.some((r) => fresh(r.mtime_ms, now));
const active = rows.some((r) => fresh(r.mtime_ms, now, origin.activeMs));
const name =
names.get(sessionId) ||
root.state.firstPrompt ||
@@ -319,19 +353,21 @@ function writeSession(sessionId, names, now) {
root.state.startedAt || new Date(now).toISOString(),
endedAt,
lastWrite,
JSON.stringify({ source: "codex-rollout" })
JSON.stringify({ source: "codex-rollout" }),
origin.source
);
claimToolSessions(sessionId); // its workspace, before the broadcast
// Every usage row of a Codex session is this module's, so it is rewritten
// whole: a model gone from a re-read journal leaves no stale row behind.
clearUsage.run(sessionId);
for (const [m, t] of Object.entries(totals)) {
for (const [key, t] of Object.entries(totals)) {
const { model: m, speed, serviceTier } = splitUsageKey(key);
stmts.setTokenUsage.run(
sessionId,
m,
"standard",
speed,
"global",
"standard",
serviceTier,
t.input,
t.output,
t.cacheRead,
@@ -346,17 +382,26 @@ function writeSession(sessionId, names, now) {
return existing ? "session_updated" : "session_created";
}
let running = null;
const running = new Map(); // home ("" = CODEX_HOME) → the pass in flight
/**
* One pass over every journal. Yields to the event loop between files; a
* second call while one runs joins it.
* second call for the same home while one runs joins it. `home`, `source` and
* `activeMs` are a remote source's (remote-sync.js); the defaults are this
* machine's CODEX_HOME.
* @returns {Promise<{files: number, sessions: number}>} files read, sessions written
*/
function ingestCodexUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
const files = allRolloutFiles();
function ingestCodexUsage({
broadcast = () => {},
now = Date.now(),
home,
source = LOCAL.source,
activeMs = LOCAL.activeMs,
} = {}) {
const key = home || "";
if (running.has(key)) return running.get(key);
const pass = (async () => {
const files = allRolloutFiles(home);
const byThread = new Map();
for (const f of files) {
const m = FILE_NAME.exec(path.basename(f.path));
@@ -403,10 +448,10 @@ function ingestCodexUsage({ broadcast = () => {}, now = Date.now() } = {}) {
await new Promise((r) => setImmediate(r));
}
let written = 0;
const names = touched.size ? threadNames() : new Map();
const names = touched.size ? threadNames(home) : new Map();
for (const sid of touched) {
try {
const type = writeSession(sid, names, now);
const type = writeSession(sid, names, now, { source, activeMs });
if (!type) continue;
written += 1;
broadcast(type, stmts.getSession.get(sid));
@@ -416,9 +461,10 @@ function ingestCodexUsage({ broadcast = () => {}, now = Date.now() } = {}) {
}
return { files: read, sessions: written };
})().finally(() => {
running = null;
running.delete(key);
});
return running;
running.set(key, pass);
return pass;
}
/**
@@ -447,20 +493,28 @@ function startCodexUsagePoll(broadcast) {
if (timer.unref) timer.unref();
}
/** Codex sessions still `active` whose every journal has gone quiet. */
function settleActive(broadcast, now = Date.now()) {
/**
* Codex sessions of one source (this machine's by default) still `active`
* whose every journal has gone quiet.
*/
function settleActive(
broadcast,
now = Date.now(),
{ home, source = LOCAL.source, activeMs = LOCAL.activeMs } = {}
) {
const quiet = db
.prepare(
`SELECT s.id FROM sessions s WHERE s.tool = 'codex' AND s.status = 'active'
`SELECT s.id FROM sessions s WHERE s.tool = 'codex' AND s.status = 'active' AND s.source = ?
AND NOT EXISTS (SELECT 1 FROM codex_rollouts r WHERE r.session_id = s.id
AND r.mtime_ms > ? AND r.mtime_ms < ?)`
)
.all(now - ACTIVE_MS, now + FUTURE_SLACK_MS);
.all(source, now - activeMs, now + FUTURE_SLACK_MS);
if (quiet.length === 0) return;
const names = threadNames();
const names = threadNames(home);
for (const { id } of quiet) {
if (hooksCurrent(id, newestRollout.get(id)?.m || 0)) continue;
if (writeSession(id, names, now)) broadcast("session_updated", stmts.getSession.get(id));
if (writeSession(id, names, now, { source, activeMs }))
broadcast("session_updated", stmts.getSession.get(id));
}
}
+260
View File
@@ -0,0 +1,260 @@
/**
* @file Cursor sessions, read from the agent transcripts Cursor writes under
* `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl` (the
* Cursor CLI, `cursor-agent`, and the Cursor editor's agent both write there).
* This is how a Cursor session reaches `sessions`, as `tool = 'cursor'`.
*
* NO tokens and NO model: a transcript line is only `{role, message:{content}}`
* (plus `{type:"turn_ended"}`) — no usage, no model id, no timestamps — so a
* Cursor session gets no `token_usage` rows and no cost. Nothing is guessed.
*
* - the session id is the transcript's file name;
* - the name is the first `<user_query>` line (60 chars);
* - the start is the file's birth time (its mtime where the filesystem keeps
* none), the last activity its mtime: active within 2 minutes, else
* completed, ended at that write;
* - the cwd is the project slug walked back onto the filesystem — the slug
* turns every `/` and every other non-alphanumeric character into `-`, so it
* is resolved only when exactly such a path still exists, else left empty.
* A file is re-read when its mtime changes, and the mtime per file lives in
* `cursor_transcripts`, so a session the user deleted stays deleted until
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts } = require("../db");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const HEAD_BYTES = 256 * 1024;
/** `~/.cursor`, or `AGENTHUB_CURSOR_DIR`; read at call time. */
function getCursorDir() {
return process.env.AGENTHUB_CURSOR_DIR || path.join(os.homedir(), ".cursor");
}
const norm = (s) => s.replace(/[^A-Za-z0-9]/g, "-");
/**
* The existing directory whose path slugs to `slug`, or null. Walks from `/`,
* trying every entry whose own slug is a prefix of what is left.
*/
function cwdFromSlug(slug, root = "/") {
const walk = (dir, rest, depth) => {
if (!rest) {
try {
return fs.statSync(dir).isDirectory() ? dir : null;
} catch {
return null;
}
}
if (depth > 40) return null;
let names;
try {
names = fs.readdirSync(dir);
} catch {
return null;
}
for (const name of names) {
const n = norm(name);
if (rest !== n && !rest.startsWith(`${n}-`)) continue;
const hit = walk(path.join(dir, name), rest.slice(n.length + 1), depth + 1);
if (hit) return hit;
}
return null;
};
const want = norm(slug);
return want ? walk(root, want, 0) : null;
}
/** The first prompt's first line (60 chars) from the transcript's head, or null. */
function firstPrompt(file) {
let text;
try {
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, HEAD_BYTES));
fs.readSync(fd, buf, 0, buf.length, 0);
text = buf.toString("utf8");
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
for (const line of text.split("\n")) {
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (o?.role !== "user" || !Array.isArray(o.message?.content)) continue;
for (const part of o.message.content) {
if (part?.type !== "text" || typeof part.text !== "string") continue;
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(part.text);
const prompt = (q ? q[1] : part.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
if (prompt) return prompt.split("\n")[0].slice(0, 60);
}
}
return null;
}
/** Every agent transcript, with its project slug, birth and mtime. */
function transcriptFiles() {
const projects = path.join(getCursorDir(), "projects");
let slugs;
try {
slugs = fs.readdirSync(projects);
} catch {
return [];
}
const out = [];
for (const slug of slugs) {
const dir = path.join(projects, slug, "agent-transcripts");
let ids;
try {
ids = fs.readdirSync(dir);
} catch {
continue;
}
for (const id of ids) {
if (!ID.test(id)) continue;
const file = path.join(dir, id, `${id}.jsonl`);
try {
const st = fs.statSync(file);
if (!st.isFile()) continue;
const mtime = Math.floor(st.mtimeMs);
out.push({ path: file, id, slug, mtime, birth: Math.floor(st.birthtimeMs) || mtime });
} catch {
/* no transcript (yet) */
}
}
}
return out;
}
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
const upsertSeen = db.prepare(
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id,
mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'cursor')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, ended_at = ?, updated_at = ?, cwd = COALESCE(cwd, ?)
WHERE id = ? AND tool = 'cursor'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'cursor'"
);
// A write "in the future" (a clock that runs ahead) is not proof of activity.
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
/** Write one transcript's session row. @returns the broadcast type, or null. */
function writeSession(f, now) {
const existing = stmts.getSession.get(f.id);
if (existing && existing.tool !== "cursor") return null;
const lastWrite = new Date(f.mtime).toISOString();
const active = fresh(f.mtime, now);
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const name = firstPrompt(f.path) || existing?.name || `Cursor ${f.id.slice(0, 8)}`;
if (existing) {
updateSession.run(
name,
status,
endedAt,
lastWrite,
existing.cwd ? null : cwdFromSlug(f.slug),
f.id
);
return "session_updated";
}
insertSession.run(
f.id,
name,
status,
cwdFromSlug(f.slug),
new Date(Math.min(f.birth, f.mtime)).toISOString(),
endedAt,
lastWrite,
JSON.stringify({ source: "cursor-transcript" })
);
return "session_created";
}
let running = null;
/**
* One pass over every transcript: a changed one is re-read, an unchanged one
* only has an `active` session settled once it went quiet.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
for (const f of transcriptFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
if (fresh(f.mtime, now)) continue;
const s = stmts.getSession.get(f.id);
if (s && s.tool === "cursor" && s.status === "active") {
settle.run(new Date(f.mtime).toISOString(), s.id);
broadcast("session_updated", stmts.getSession.get(s.id));
written += 1;
}
continue;
}
read += 1;
const type = writeSession(f, now);
upsertSeen.run(f.path, f.id, f.mtime);
if (type) {
written += 1;
broadcast(type, stmts.getSession.get(f.id));
}
} catch (err) {
console.warn(`[cursor-usage] skipped ${f.path}: ${err.message}`);
}
await new Promise((r) => setImmediate(r));
}
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/** First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS (default 15 s; 0 disables). */
function startCursorUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CURSOR_POLL_MS
? Number(process.env.DASHBOARD_CURSOR_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const tick = () => {
ingestCursorUsage({ broadcast }).catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
}
module.exports = {
getCursorDir,
cwdFromSlug,
firstPrompt,
ingestCursorUsage,
startCursorUsagePoll,
};
+27
View File
@@ -0,0 +1,27 @@
/**
* @file Where the running dashboard answers, for a process that is NOT the
* dashboard: the `agenthub` CLI, the MCP server (`mcp/server.js`) and the
* statusline (`statusline/statusline.js`). An explicit CLAUDE_DASHBOARD_PORT /
* DASHBOARD_PORT wins (the hook handler's contract), then the live-server
* discovery file `~/.claude/.agenthub.json` (`server-info.js`, PID-checked),
* then the default port. Never throws.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
/** Base URL of the live dashboard, e.g. `http://127.0.0.1:4820`. */
function dashboardUrl() {
const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT;
if (envPort) return `http://127.0.0.1:${envPort}`;
try {
const { resolveDashboardPort } = require(path.join(__dirname, "server-info.js"));
const port = resolveDashboardPort();
if (port) return `http://127.0.0.1:${port}`;
} catch {
/* discovery unavailable — fall through to the default */
}
return "http://127.0.0.1:4820";
}
module.exports = { dashboardUrl };
+8 -1
View File
@@ -8,10 +8,14 @@
* snapshot of it under `<data dir>/transcripts/`), which the Conversation tab
* reads. Kept: every field outside `tool_input`/`tool_response`, every number and
* flag, `structuredPatch` (the +/- line counts), and each long string's head and
* tail. Dropped fields are named in `_trimmed`, so a view can say so.
* tail. Dropped fields are named in `_trimmed`, so a view can say so. When
* the user turned on secret redaction (`server/lib/privacy.js`), the payload is
* redacted first, before anything is cut.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { redactEventJson } = require("./privacy");
// Strings inside tool_input / tool_response longer than this keep head + tail.
const STR_CAP = 8192;
const HEAD = 6000;
@@ -95,6 +99,9 @@ function trimHookPayload(data, hit = { n: 0 }) {
* @returns {string|null}
*/
function trimEventData(json) {
// Opt-in secret redaction runs first, on the whole text: trimmed first, a
// private key cut in two would no longer match its pattern.
json = redactEventJson(json);
if (typeof json !== "string" || json.length <= SKIP_UNDER) return json;
let parsed;
try {
+15 -6
View File
@@ -15,10 +15,14 @@
* codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id
* (`clinepass/…`, `Highest`) is left to the user.
*
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
* modelled — Codex's context window stays under 272k and runs on the default
* tier; add a service_tier bucket if that changes. Gemini's >200k-token rate
* is not modelled either (the base rate is used).
* A non-Claude model's PRIORITY rates (`*_cost_per_token_priority`) fill the
* fast columns: Codex's fast/priority service tier is booked as `speed = fast`
* (`codex-usage.js`), and its flex tier as `service_tier = 'flex'` (half price,
* like batch, in `ratesForBucket`).
* ponytail: OpenAI's long-context (>272k) and Gemini's >200k-token rates are
* not modelled — Codex's context window (258k) stays under 272k and no current
* Claude model has such a tier; it needs a context-size bucket in token_usage
* (a primary-key rebuild) if that changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -103,6 +107,11 @@ function toRow(fam, e) {
if (input === 0 && output === 0) return null;
const fast = claude && e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
const prio = claude
? []
: [per(e.input_cost_per_token_priority), per(e.output_cost_per_token_priority)];
const prioOk =
prio.length > 0 && prio.every((v) => Number.isFinite(v) && v > 0 && v <= MAX_PER_MTOK);
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
const r = (n) => Math.round(n * 1e6) / 1e6;
return {
@@ -113,8 +122,8 @@ function toRow(fam, e) {
read: r(read),
w5m: r(w5m),
w1h: r(w1h),
fastIn: fastOk ? r(input * fast) : 0,
fastOut: fastOk ? r(output * fast) : 0,
fastIn: fastOk ? r(input * fast) : prioOk ? r(prio[0]) : 0,
fastOut: fastOk ? r(output * fast) : prioOk ? r(prio[1]) : 0,
};
}
+178
View File
@@ -0,0 +1,178 @@
/**
* @file privacy.js
* @description Ingest-time privacy controls, both opt-in and off by default:
*
* 1. Secret redaction (`AGENTHUB_REDACT_SECRETS=1`): every event payload and
* summary passes through here on its way into SQLite (`trim_event_data(?)` /
* `redact_text(?)`, registered in `server/db.js`), and a secret-shaped value
* is replaced by `[redacted]` — API keys and tokens of known shapes, bearer
* headers, PEM private keys, `password=…`/`TOKEN: …` assignments, and every
* string under a secret-named JSON key. Only what is STORED is redacted: the
* hook route still reads the raw payload in memory, and the transcript on
* disk is the CLI's own file, untouched.
* 2. Ignored folders (`AGENTHUB_IGNORE_CWD`, `path.delimiter`-separated —
* a colon on Linux/macOS): a hook whose `cwd` is one of them or under one
* (path-boundary match, never a bare string prefix) is dropped whole, and the
* history import and the Codex journal reader skip such sessions too.
*
* Both live in the settings file (`settings-env.js`) like every other value the
* dashboard saves, so the real environment can pin them (`env_owned`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const REDACT_KEY = "AGENTHUB_REDACT_SECRETS";
const IGNORE_KEY = "AGENTHUB_IGNORE_CWD";
const REDACTED = "[redacted]";
// Whole-match secret shapes. Each is distinctive enough that a hit is a secret.
const SECRET_PATTERNS = [
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
/\bsk-ant-[A-Za-z0-9_-]{16,}/g, // Anthropic
/\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g, // OpenAI-style
/\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub tokens
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
/\bglpat-[A-Za-z0-9_-]{20,}/g, // GitLab
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, // AWS access key id
/\bAIza[0-9A-Za-z_-]{35}/g, // Google API key
/\bnpm_[A-Za-z0-9]{36}\b/g,
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT
];
// The value after a label: the label stays, so the text still reads.
const LABELLED_PATTERNS = [
/(\bBearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
/(\bBasic\s+)[A-Za-z0-9+/=]{8,}/g,
// password=…, DB_PASSWORD: …, "api_key": "…", GITHUB_TOKEN=… — not
// max_tokens / token_count, whose name does not END in the word. The name's
// prefix is bounded: unbounded, "a-a-a-…" backtracks quadratically.
/(\b[A-Za-z0-9_-]{0,40}(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)["']?\s*[=:]\s*)("[^"\n]*"|'[^'\n]*'|[^\s"',;&]+)/gi,
];
// A JSON key whose string value is a secret whatever it looks like.
const SECRET_KEY =
/^(?:.*[_-])?(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|authorization)$/i;
function redactionEnabled(env = process.env) {
return env[REDACT_KEY] === "1";
}
/** `text` with every secret-shaped run replaced; the input when none. */
function redactText(text) {
if (typeof text !== "string" || text.length < 8) return text;
let out = text;
for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
for (const re of LABELLED_PATTERNS) {
out = out.replace(re, (_m, label) => `${label}${REDACTED}`);
}
return out;
}
/** A redacted copy of a parsed payload, and how many strings changed. */
function redactValue(value, key = null, counter = { n: 0 }) {
if (typeof value === "string") {
const next = key && SECRET_KEY.test(key) && value ? REDACTED : redactText(value);
if (next !== value) counter.n++;
return next;
}
if (Array.isArray(value)) return value.map((v) => redactValue(v, null, counter));
if (value && typeof value === "object") {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k, counter);
return out;
}
return value;
}
/**
* The SQL-side entry for `events.data` (chained in front of the trim in
* `event-payload.js`): JSON in, JSON out. Unchanged — byte for byte — when
* redaction is off, nothing matched, or the text is not a JSON object.
*/
function redactEventJson(json) {
if (typeof json !== "string" || !redactionEnabled()) return json;
let parsed;
try {
parsed = JSON.parse(json);
} catch {
return redactText(json);
}
if (!parsed || typeof parsed !== "object") return json;
const counter = { n: 0 };
const out = redactValue(parsed, null, counter);
if (counter.n === 0) return json;
if (!Array.isArray(out)) out._redacted = counter.n;
return JSON.stringify(out);
}
/** `redact_text(?)` for `events.summary`: a no-op while redaction is off. */
function redactSummary(text) {
return redactionEnabled() ? redactText(text) : text;
}
function expandHome(p) {
return p.replace(/^~(?=[/\\]|$)/, os.homedir());
}
/** The ignored folders, absolute and without a trailing separator. */
function ignoredCwds(env = process.env) {
return String(env[IGNORE_KEY] || "")
.split(path.delimiter)
.map((p) => p.trim())
.filter(Boolean)
.map((p) => path.resolve(expandHome(p)))
.filter((p) => path.isAbsolute(p) && p !== path.parse(p).root);
}
/** Whether `cwd` is an ignored folder or lies under one. */
function isIgnoredCwd(cwd, env = process.env) {
// Not absolute here (a Windows path pushed to a POSIX server) cannot be
// under an entry, and resolving it would graft it onto this process's cwd.
if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return false;
const list = ignoredCwds(env);
if (list.length === 0) return false;
const c = path.resolve(cwd);
return list.some((p) => c === p || c.startsWith(p + path.sep));
}
/**
* Check a list of folders from the Settings form. Each must be absolute (after
* `~` expansion), not a filesystem root (that would drop every hook), and free
* of control characters and the list separator. Returns them resolved.
*/
function validateIgnoreList(list) {
const bad = (message) => Object.assign(new Error(message), { code: "EBADPATH" });
if (!Array.isArray(list)) throw bad("ignore_cwd must be an array of paths");
if (list.length > 100) throw bad("at most 100 ignored folders");
const out = [];
for (const raw of list) {
if (typeof raw !== "string") throw bad("every ignored folder must be a string");
const p = raw.trim();
if (!p) continue;
// eslint-disable-next-line no-control-regex -- intentional control-char reject
if (p.length > 1024 || /[\x00-\x1f]/.test(p) || p.includes(path.delimiter)) {
throw bad(`not a usable path: ${p.slice(0, 80)}`);
}
const resolved = path.resolve(expandHome(p));
if (!path.isAbsolute(expandHome(p))) throw bad(`must be an absolute path: ${p}`);
if (resolved === path.parse(resolved).root) throw bad("a filesystem root cannot be ignored");
if (!out.includes(resolved)) out.push(resolved);
}
return out;
}
module.exports = {
REDACT_KEY,
IGNORE_KEY,
REDACTED,
SECRET_PATTERNS,
redactionEnabled,
redactText,
redactValue,
redactEventJson,
redactSummary,
ignoredCwds,
isIgnoredCwd,
validateIgnoreList,
};
+63
View File
@@ -0,0 +1,63 @@
/**
* @file push-token.js
* @description The bearer token that opens `POST /api/hooks/ingest-batch` — the
* push path for a machine the dashboard cannot reach over SSH (routes/hooks.js).
*
* The server makes the token (32 random bytes) and shows it ONCE, in the answer
* to the request that created it; what persists is only its SHA-256, as
* `AGENTHUB_PUSH_TOKEN_SHA256` in AgentHub's settings file (settings-env.js), so
* no route, file or log can hand the token back. No hash = push ingest off,
* which is the default. A check hashes the presented token and compares the two
* digests in constant time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const { applySetting, envOwned } = require("./settings-env");
const KEY = "AGENTHUB_PUSH_TOKEN_SHA256";
const HEX64 = /^[0-9a-f]{64}$/;
const sha256 = (s) => crypto.createHash("sha256").update(s).digest();
/** The stored digest, or null while push ingest is off. */
function storedDigest() {
const v = String(process.env[KEY] || "").trim();
return HEX64.test(v) ? Buffer.from(v, "hex") : null;
}
/** `{enabled, env_owned}` — never the token, which is not stored. */
function pushTokenStatus() {
return { enabled: storedDigest() !== null, env_owned: envOwned.has(KEY) };
}
/** Whether `given` is the current token. Constant time; false while off. */
function checkPushToken(given) {
const want = storedDigest();
if (!want || typeof given !== "string" || given.length === 0 || given.length > 512) return false;
return crypto.timingSafeEqual(sha256(given), want);
}
function refuseEnvOwned() {
if (envOwned.has(KEY)) {
throw Object.assign(new Error(`${KEY} is set by the environment; change it there`), {
code: "EENVOWNED",
});
}
}
/** Make a new token (the old one stops working) and return it — the only time it is seen. */
function rotatePushToken() {
refuseEnvOwned();
const token = `ahp_${crypto.randomBytes(32).toString("base64url")}`;
applySetting(KEY, sha256(token).toString("hex"));
return token;
}
/** Turn push ingest off. */
function revokePushToken() {
refuseEnvOwned();
applySetting(KEY, "");
}
module.exports = { KEY, pushTokenStatus, checkPushToken, rotatePushToken, revokePushToken };
+5 -1
View File
@@ -14,7 +14,11 @@ const { Worker, isMainThread, workerData, parentPort } = require("node:worker_th
/** Read-only jobs by name: (connection, args) → plain data. */
const JOBS = {
workflows: (conn, a) =>
require("./workflow-stats").computeWorkflowData(conn, a.status || null, a.sections || null),
require("./workflow-stats").computeWorkflowData(
conn,
{ status: a.status || null, tool: a.tool || null, range: a.range || null },
a.sections || null
),
};
function timeoutMs() {
Binary file not shown.
+319
View File
@@ -0,0 +1,319 @@
/**
* @file Scheduled reports. A definition (`report_definitions`) names a window
* of days, a frequency (daily / weekly on a weekday), a local hour at the
* browser's `tz_offset`, and the formats to write (html, json). A run builds
* the same numbers as GET /api/analytics and GET /api/pricing/cost over the
* window — sessions, agents, events, tokens, cost by model and by AI tool, top
* tools — plus the current budget status, writes them to
* `<data dir>/reports/<report id>/<stamp>.<format>` and logs the run in
* `report_runs`. File paths are built from the report id and a server-made
* stamp only. The scheduler is an unref'd interval started by the server's
* background services (`DASHBOARD_REPORTS_MS`, default 60 s, 0 = off).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { db } = require("../db");
const { getDataDir } = require("./claude-home");
const { parseRange } = require("./source-filter");
const { budgetStatus } = require("./budgets");
const { computeAnalytics } = require("../routes/analytics");
const { computeCost } = require("../routes/pricing");
const DAY_MS = 86_400_000;
const FORMATS = ["html", "json"];
const FREQUENCIES = ["daily", "weekly"];
const reportDir = (id) => path.join(getDataDir(), "reports", String(Number(id)));
const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
/** A stored row as the API shows it. */
function view(row) {
if (!row) return null;
let formats;
try {
formats = JSON.parse(row.formats);
} catch {
formats = ["html"];
}
return { ...row, formats, enabled: !!row.enabled };
}
function viewRun(row) {
let files;
try {
files = JSON.parse(row.files);
} catch {
files = [];
}
return { ...row, files };
}
const int = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
/**
* Validate a definition body. Returns `{value}` or `{error}`.
* @param {object} b
*/
function validate(b) {
const name = typeof b.name === "string" ? b.name.trim() : "";
if (!name || name.length > 200) return { error: "name is required (≤ 200 characters)" };
if (!int(b.window_days, 1, 366)) return { error: "window_days must be an integer 1–366" };
if (!FREQUENCIES.includes(b.frequency)) return { error: "frequency must be daily or weekly" };
if (!int(b.hour, 0, 23)) return { error: "hour must be an integer 0–23" };
const weekday = b.weekday === undefined ? 1 : b.weekday;
if (!int(weekday, 0, 6)) return { error: "weekday must be an integer 0–6 (0 = Sunday)" };
const tz = b.tz_offset === undefined ? 0 : b.tz_offset;
if (!int(tz, -840, 840)) return { error: "tz_offset must be minutes, -840–840" };
const formats = Array.isArray(b.formats) ? [...new Set(b.formats)] : [];
if (formats.length === 0 || !formats.every((f) => FORMATS.includes(f))) {
return { error: "formats must be a non-empty list of html, json" };
}
if (b.enabled !== undefined && typeof b.enabled !== "boolean") {
return { error: "enabled must be a boolean" };
}
return {
value: {
name,
window_days: b.window_days,
frequency: b.frequency,
hour: b.hour,
weekday,
tz_offset: tz,
formats,
enabled: b.enabled !== false,
},
};
}
/**
* The most recent scheduled instant at or before `now` (epoch ms): today's
* `hour` in the definition's local time (daily), or the latest `weekday` at
* that hour (weekly).
*/
function lastSlot(def, now) {
const tz = def.tz_offset * 60_000;
const local = new Date(now - tz); // read with getUTC*
let slot = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate(), def.hour);
const weekly = def.frequency === "weekly";
if (weekly) slot -= ((local.getUTCDay() - def.weekday + 7) % 7) * DAY_MS;
if (slot > local.getTime()) slot -= (weekly ? 7 : 1) * DAY_MS;
return slot + tz;
}
/** The report window: the `window_days` full local days before `now`'s day. */
function reportWindow(def, now) {
const local = new Date(now - def.tz_offset * 60_000);
const today = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate());
return { from: ymd(today - def.window_days * DAY_MS), to: ymd(today - DAY_MS) };
}
/** The report content for a definition at `now`. */
function buildReport(def, now = Date.now()) {
const win = reportWindow(def, now);
const range = parseRange({ query: { ...win, tz_offset: String(def.tz_offset) } });
const tzModifier = `${-def.tz_offset} minutes`;
const a = computeAnalytics({ range, tzModifier });
const c = computeCost({}, range, tzModifier);
const byModel = new Map();
for (const b of c.breakdown) byModel.set(b.model, (byModel.get(b.model) || 0) + b.cost);
const round = (n) => Math.round(n * 10000) / 10000;
return {
report_id: def.id,
name: def.name,
generated_at: new Date(now).toISOString(),
window: { ...win, days: def.window_days, tz_offset: def.tz_offset },
sessions: { total: a.overview.total_sessions, by_status: a.sessions_by_status },
agents: { total: a.overview.total_agents, subagents: a.total_subagents },
events: { total: a.overview.total_events, avg_per_session: a.avg_events_per_session },
tokens: a.tokens,
cost: {
total: c.total_cost,
by_model: [...byModel.entries()]
.map(([model, cost]) => ({ model, cost: round(cost) }))
.sort((x, y) => y.cost - x.cost),
by_tool: c.by_tool,
unpriced_models: c.unpriced_models.map((u) => u.model),
},
top_tools: a.tool_usage.slice(0, 10),
// The budgets' CURRENT period, whatever the window.
budgets: budgetStatus({ now }).map((b) => ({
name: b.name,
period: b.config.period,
period_start: b.period_start,
period_end: b.period_end,
limit_usd: b.config.limit_usd,
spend: b.spend,
pct: b.pct,
projected: b.projected,
})),
};
}
const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (ch) => ESC[ch]);
const usd = (n) => `$${Number(n || 0).toFixed(2)}`;
function table(head, rows) {
if (rows.length === 0) return "<p class=muted>—</p>";
const th = head.map((h) => `<th>${esc(h)}</th>`).join("");
const tr = rows.map((r) => `<tr>${r.map((v) => `<td>${esc(v)}</td>`).join("")}</tr>`).join("");
return `<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`;
}
/** The report as a self-contained HTML page; every value is escaped. */
function renderHtml(r) {
const t = r.tokens;
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(r.name)}</title>
<style>body{font:14px system-ui,sans-serif;max-width:860px;margin:2rem auto;padding:0 1rem;color:#222}
table{border-collapse:collapse;width:100%;margin:.5rem 0 1.5rem}td,th{border-bottom:1px solid #ddd;padding:4px 8px;text-align:left}
th{color:#555;font-weight:600}.muted{color:#777}h2{margin-top:2rem;font-size:16px}</style></head><body>
<h1>${esc(r.name)}</h1>
<p class=muted>${esc(r.window.from)} → ${esc(r.window.to)} (${esc(r.window.days)} days) · generated ${esc(r.generated_at)}</p>
<h2>Summary</h2>
${table(
["Sessions", "Agents", "Events", "Total cost"],
[[r.sessions.total, r.agents.total, r.events.total, usd(r.cost.total)]]
)}
<h2>Tokens</h2>
${table(
["Input", "Output", "Cache read", "Cache write"],
[[t.total_input, t.total_output, t.total_cache_read, t.total_cache_write]]
)}
<h2>Cost by model</h2>
${table(
["Model", "Cost"],
r.cost.by_model.map((m) => [m.model, usd(m.cost)])
)}
<h2>Cost by AI tool</h2>
${table(
["Tool", "Cost"],
r.cost.by_tool.map((m) => [m.tool, usd(m.cost)])
)}
<h2>Top tools</h2>
${table(
["Tool", "Calls"],
r.top_tools.map((x) => [x.tool_name, x.count])
)}
<h2>Budgets (current period)</h2>
${table(
["Budget", "Period", "Spend", "Limit", "%", "Projected"],
r.budgets.map((b) => [
b.name,
`${b.period_start} → ${b.period_end}`,
usd(b.spend),
usd(b.limit_usd),
b.pct,
usd(b.projected),
])
)}
${r.cost.unpriced_models.length ? `<p class=muted>Unpriced models (not counted): ${esc(r.cost.unpriced_models.join(", "))}</p>` : ""}
</body></html>
`;
}
/**
* Run a report now: build it, write each format, log the run. Never throws —
* a failure is a run with `status = 'error'`.
* @returns {object} the run row
*/
function runReport(def, trigger, now = Date.now()) {
const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
const win = reportWindow(def, now);
const files = [];
let error = null;
try {
const content = buildReport(def, now);
const dir = reportDir(def.id);
fs.mkdirSync(dir, { recursive: true });
for (const f of def.formats) {
const body = f === "json" ? JSON.stringify(content, null, 2) : renderHtml(content);
fs.writeFileSync(path.join(dir, `${stamp}.${f}`), body);
files.push(f);
}
} catch (err) {
error = err.message || String(err);
}
const info = db
.prepare(
`INSERT INTO report_runs (report_id, trigger, status, window_from, window_to, stamp, files, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
def.id,
trigger,
error ? "error" : "ok",
win.from,
win.to,
stamp,
JSON.stringify(files),
error
);
return viewRun(db.prepare("SELECT * FROM report_runs WHERE id = ?").get(info.lastInsertRowid));
}
/** Absolute path of a run's file, or null. Built from ids and the stored stamp. */
function runFile(reportId, runId, format) {
if (!FORMATS.includes(format)) return null;
const run = db
.prepare("SELECT * FROM report_runs WHERE id = ? AND report_id = ?")
.get(Number(runId), Number(reportId));
if (!run || !run.stamp || !viewRun(run).files.includes(format)) return null;
const file = path.join(reportDir(reportId), `${run.stamp}.${format}`);
return fs.existsSync(file) ? file : null;
}
/**
* Run every enabled report whose latest slot is newer than the one it last
* claimed. The claim is one conditional UPDATE, so of two dashboards sharing
* the database only one runs a slot.
* @returns {number} reports run
*/
function tick(now = Date.now()) {
let ran = 0;
for (const row of db.prepare("SELECT * FROM report_definitions WHERE enabled = 1").all()) {
const def = view(row);
const slot = new Date(lastSlot(def, now)).toISOString();
const claimed = db
.prepare(
"UPDATE report_definitions SET last_slot = ? WHERE id = ? AND (last_slot IS NULL OR last_slot < ?)"
)
.run(slot, def.id, slot).changes;
if (claimed) {
runReport(def, "schedule", now);
ran++;
}
}
return ran;
}
function startReportScheduler() {
const every = Number(process.env.DASHBOARD_REPORTS_MS ?? 60_000);
if (!(every > 0)) return;
const t = setInterval(() => {
try {
tick();
} catch (err) {
console.warn("scheduled reports failed:", err?.message || err);
}
}, every);
if (t.unref) t.unref();
}
module.exports = {
FORMATS,
reportDir,
view,
viewRun,
validate,
lastSlot,
reportWindow,
buildReport,
renderHtml,
runReport,
runFile,
tick,
startReportScheduler,
};
+93 -71
View File
@@ -11,69 +11,98 @@
* path; the unfiltered default keeps using db.js's prepared statements, so the
* common zero-config case pays nothing for this feature.
*
* Every function takes a non-empty `sources` string array. The predicate is
* either `source IN (...)` (queries over `sessions`) or a `session_id IN
* (SELECT id FROM sessions WHERE source IN (...))` subquery (queries over
* events / agents / token_usage), always via bound parameters.
* Every function takes a `scope` = `{where, params}`, a predicate over
* `sessions` with unqualified columns. It is applied either directly (queries
* over `sessions`) or as a `session_id IN (SELECT id FROM sessions WHERE …)`
* subquery (queries over events / agents / token_usage), always via bound
* parameters.
*
* The analytics ones also take an optional `range` (`parseRange` in
* source-filter.js): sessions and agents count by `started_at`, events by
* `created_at`. The daily charts keep their own 365-day window.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** `?,?,…` for N sources. */
function ph(sources) {
return sources.map(() => "?").join(",");
/** Subquery restricting a `session_id` column to the scope's sessions. */
function sessionSubquery(scope) {
return `SELECT id FROM sessions WHERE ${scope.where}`;
}
/** Subquery restricting a `session_id` column to the chosen sources. */
function sessionSubquery(sources) {
return `SELECT id FROM sessions WHERE source IN (${ph(sources)})`;
/** ` AND <col> >= ? AND <col> < ?` for a range (`col` is always a constant). */
function within(col, range) {
const sql = [];
const params = [];
if (range?.start) {
sql.push(` AND ${col} >= ?`);
params.push(range.start);
}
if (range?.end) {
sql.push(` AND ${col} < ?`);
params.push(range.end);
}
return { sql: sql.join(""), params };
}
function statsOverview(db, sources) {
const sq = sessionSubquery(sources);
const p = ph(sources);
const row = db
function statsOverview(db, scope, range) {
const sq = sessionSubquery(scope);
const p = scope.params;
const s = within("started_at", range);
const e = within("created_at", range);
return db
.prepare(
`SELECT
(SELECT COUNT(*) FROM sessions WHERE source IN (${p})) as total_sessions,
(SELECT COUNT(*) FROM sessions WHERE status = 'active' AND source IN (${p})) as active_sessions,
(SELECT COUNT(*) FROM agents WHERE status IN ('working','waiting') AND session_id IN (${sq})) as active_agents,
(SELECT COUNT(*) FROM agents WHERE session_id IN (${sq})) as total_agents,
(SELECT COUNT(*) FROM events WHERE session_id IN (${sq})) as total_events`
(SELECT COUNT(*) FROM sessions WHERE ${scope.where}${s.sql}) as total_sessions,
(SELECT COUNT(*) FROM sessions WHERE status = 'active' AND ${scope.where}${s.sql}) as active_sessions,
(SELECT COUNT(*) FROM agents WHERE status IN ('working','waiting') AND session_id IN (${sq})${s.sql}) as active_agents,
(SELECT COUNT(*) FROM agents WHERE session_id IN (${sq})${s.sql}) as total_agents,
(SELECT COUNT(*) FROM events WHERE session_id IN (${sq})${e.sql}) as total_events`
)
.get(...sources, ...sources, ...sources, ...sources, ...sources);
return row;
.get(
...p,
...s.params,
...p,
...s.params,
...p,
...s.params,
...p,
...s.params,
...p,
...e.params
);
}
function agentStatusCounts(db, sources) {
function agentStatusCounts(db, scope, range) {
const s = within("started_at", range);
return db
.prepare(
`SELECT status, COUNT(*) as count FROM agents WHERE session_id IN (${sessionSubquery(
sources
)}) GROUP BY status`
scope
)})${s.sql} GROUP BY status`
)
.all(...sources);
.all(...scope.params, ...s.params);
}
function sessionStatusCounts(db, sources) {
function sessionStatusCounts(db, scope, range) {
const s = within("started_at", range);
return db
.prepare(
`SELECT status, COUNT(*) as count FROM sessions WHERE source IN (${ph(sources)}) GROUP BY status`
`SELECT status, COUNT(*) as count FROM sessions WHERE ${scope.where}${s.sql} GROUP BY status`
)
.all(...sources);
.all(...scope.params, ...s.params);
}
function countEventsToday(db, sources, toLocal, toUTC) {
function countEventsToday(db, scope, toLocal, toUTC) {
return db
.prepare(
`SELECT COUNT(*) as count FROM events
WHERE created_at >= datetime('now', ?, 'start of day', ?)
AND session_id IN (${sessionSubquery(sources)})`
AND session_id IN (${sessionSubquery(scope)})`
)
.get(toLocal, toUTC, ...sources);
.get(toLocal, toUTC, ...scope.params);
}
function tokenTotals(db, sources) {
function tokenTotals(db, scope) {
return db
.prepare(
`SELECT
@@ -85,93 +114,87 @@ function tokenTotals(db, sources) {
COALESCE(SUM(web_search_requests + baseline_web_search), 0) as total_web_search,
COALESCE(SUM(web_fetch_requests + baseline_web_fetch), 0) as total_web_fetch,
COALESCE(SUM(code_execution_requests + baseline_code_execution), 0) as total_code_execution
FROM token_usage WHERE session_id IN (${sessionSubquery(sources)})`
FROM token_usage WHERE session_id IN (${sessionSubquery(scope)})`
)
.get(...sources);
.get(...scope.params);
}
function toolUsageCounts(db, sources) {
function toolUsageCounts(db, scope, range) {
const e = within("created_at", range);
return db
.prepare(
`SELECT tool_name, COUNT(*) as count FROM events
WHERE tool_name IS NOT NULL AND session_id IN (${sessionSubquery(sources)})
WHERE tool_name IS NOT NULL AND session_id IN (${sessionSubquery(scope)})${e.sql}
GROUP BY tool_name ORDER BY count DESC LIMIT 20`
)
.all(...sources);
.all(...scope.params, ...e.params);
}
function dailyEventCounts(db, sources, tzModifier) {
function dailyEventCounts(db, scope, tzModifier) {
return db
.prepare(
`SELECT DATE(created_at, ?) as date, COUNT(*) as count FROM events
WHERE created_at >= DATE('now', '-365 days') AND session_id IN (${sessionSubquery(sources)})
WHERE created_at >= DATE('now', '-365 days') AND session_id IN (${sessionSubquery(scope)})
GROUP BY 1 ORDER BY date ASC`
)
.all(tzModifier, ...sources);
.all(tzModifier, ...scope.params);
}
function dailySessionCounts(db, sources, tzModifier) {
function dailySessionCounts(db, scope, tzModifier) {
return db
.prepare(
`SELECT DATE(started_at, ?) as date, COUNT(*) as count FROM sessions
WHERE started_at >= DATE('now', '-365 days') AND source IN (${ph(sources)})
WHERE started_at >= DATE('now', '-365 days') AND ${scope.where}
GROUP BY 1 ORDER BY date ASC`
)
.all(tzModifier, ...sources);
.all(tzModifier, ...scope.params);
}
function agentTypeDistribution(db, sources) {
function agentTypeDistribution(db, scope, range) {
const s = within("started_at", range);
return db
.prepare(
`SELECT subagent_type, COUNT(*) as count FROM agents
WHERE type = 'subagent' AND subagent_type IS NOT NULL AND session_id IN (${sessionSubquery(
sources
)})
scope
)})${s.sql}
GROUP BY subagent_type ORDER BY count DESC`
)
.all(...sources);
.all(...scope.params, ...s.params);
}
function totalSubagentCount(db, sources) {
function totalSubagentCount(db, scope, range) {
const s = within("started_at", range);
return db
.prepare(
`SELECT COUNT(*) as count FROM agents WHERE type = 'subagent' AND session_id IN (${sessionSubquery(
sources
)})`
scope
)})${s.sql}`
)
.get(...sources);
.get(...scope.params, ...s.params);
}
function eventTypeCounts(db, sources) {
function eventTypeCounts(db, scope, range) {
const e = within("created_at", range);
return db
.prepare(
`SELECT event_type, COUNT(*) as count FROM events
WHERE session_id IN (${sessionSubquery(sources)})
WHERE session_id IN (${sessionSubquery(scope)})${e.sql}
GROUP BY event_type ORDER BY count DESC`
)
.all(...sources);
.all(...scope.params, ...e.params);
}
function avgEventsPerSession(db, sources) {
const sq = sessionSubquery(sources);
function avgEventsPerSession(db, scope, range) {
const s = within("started_at", range);
const e = within("created_at", range);
return db
.prepare(
`SELECT ROUND(CAST(COUNT(*) AS REAL) /
MAX(1, (SELECT COUNT(*) FROM sessions WHERE source IN (${ph(sources)}))), 1) as avg
FROM events WHERE session_id IN (${sq})`
MAX(1, (SELECT COUNT(*) FROM sessions WHERE ${scope.where}${s.sql})), 1) as avg
FROM events WHERE session_id IN (${sessionSubquery(scope)})${e.sql}`
)
.get(...sources, ...sources);
}
/** token_usage rows joined to their session start date, scoped to sources. */
function scopedTokenUsageWithDate(db, sources) {
return db
.prepare(
`SELECT tu.*, DATE(s.started_at) as date
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
WHERE s.source IN (${ph(sources)})`
)
.all(...sources);
.get(...scope.params, ...s.params, ...scope.params, ...e.params);
}
module.exports = {
@@ -187,5 +210,4 @@ module.exports = {
totalSubagentCount,
eventTypeCounts,
avgEventsPerSession,
scopedTokenUsageWithDate,
};
+107 -1
View File
@@ -11,6 +11,13 @@
* `sessions.source`, or, for tables that only carry a `session_id`, as a
* subquery so complex aggregate SQL (stats, analytics) needs only one extra AND.
*
* `?tool=<id>` (an AI Tools catalog id, matched against `sessions.tool`) narrows
* the same way; `parseScope` folds both into one predicate over `sessions`.
*
* `?from=&to=` (local `YYYY-MM-DD`, inclusive, read with `tz_offset`) is a date
* range: `parseRange` turns it into the UTC instants a timestamp column is
* compared with, and `inRange` tests a local date (a cost row's `date`).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -63,4 +70,103 @@ function sessionIdInSourcesClause(sources, sessionIdCol) {
};
}
module.exports = { parseSources, sourceColumnClause, sessionIdInSourcesClause };
// An AI Tools catalog id shape; the value is only ever a bound parameter.
const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
/**
* Parse `?tool=`: null when absent, false when malformed (the route answers 400).
* @param {import("express").Request} req
* @returns {string|null|false}
*/
function parseTool(req) {
const raw = req.query ? req.query.tool : undefined;
if (raw === undefined || raw === "") return null;
return typeof raw === "string" && TOOL_RE.test(raw) ? raw : false;
}
/**
* The data scope (sources and/or tool) as ONE predicate over `sessions` with
* unqualified columns, or null for "everything". Callers check `parseTool` for
* false first.
* @param {import("express").Request} req
* @returns {{where:string, params:string[]}|null}
*/
function parseScope(req) {
const sources = parseSources(req);
const tool = parseTool(req);
const where = [];
const params = [];
if (sources) {
where.push(`source IN (${sources.map(() => "?").join(",")})`);
params.push(...sources);
}
if (tool) {
where.push("tool = ?");
params.push(tool);
}
return where.length ? { where: where.join(" AND "), params } : null;
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 86_400_000;
/** A real calendar date in `YYYY-MM-DD` form (2026-02-30 is not), before
* year 9999 (whose next day no longer prints as a comparable ISO string). */
function isDate(s) {
if (typeof s !== "string" || !DATE_RE.test(s) || s >= "9999") return false;
const ms = Date.parse(`${s}T00:00:00Z`);
return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === s;
}
/**
* Parse `?from=&to=`: null when both are absent, false when either is not a
* date or `from` is after `to` (the route answers 400). Either end may be open.
* `start`/`end` are the local midnights of `from` and of the day after `to`,
* as UTC ISO instants (`end` exclusive), for `col >= start AND col < end`.
* @param {import("express").Request} req
* @returns {{from:string|null, to:string|null, start:string|null, end:string|null}|null|false}
*/
function parseRange(req) {
const q = req.query || {};
const from = q.from === undefined || q.from === "" ? null : q.from;
const to = q.to === undefined || q.to === "" ? null : q.to;
if (from === null && to === null) return null;
if ((from !== null && !isDate(from)) || (to !== null && !isDate(to))) return false;
if (from !== null && to !== null && from > to) return false;
const tz = parseInt(q.tz_offset, 10);
const off = Number.isFinite(tz) ? tz * 60_000 : 0;
const instant = (day, plus) =>
new Date(Date.parse(`${day}T00:00:00Z`) + plus * DAY_MS + off).toISOString();
return {
from,
to,
start: from === null ? null : instant(from, 0),
end: to === null ? null : instant(to, 1),
};
}
/** Whether a local `YYYY-MM-DD` falls in a `parseRange` result (null = all). */
function inRange(range, date) {
return !range || ((!range.from || date >= range.from) && (!range.to || date <= range.to));
}
/** The 400 every ranged route answers for a malformed `?from=`/`?to=`. */
const BAD_RANGE = {
error: { code: "INVALID_INPUT", message: "from/to must be YYYY-MM-DD dates, from <= to" },
};
/** The 400 every scoped route answers for a malformed `?tool=`. */
const BAD_TOOL = { error: { code: "INVALID_INPUT", message: "tool must be an AI Tools id" } };
module.exports = {
parseSources,
parseTool,
parseScope,
BAD_TOOL,
isDate,
parseRange,
inRange,
BAD_RANGE,
sourceColumnClause,
sessionIdInSourcesClause,
};
+31 -11
View File
@@ -23,21 +23,40 @@ function durationSec(s) {
// ═══════════════════════════════════════════════════
/**
* 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").
* Build a SQL WHERE clause for the session filters `{status, tool, range}`
* (`range` = `parseRange`'s result: sessions started in it).
* Returns { clause, params } where clause is empty or " AND s.status = ? …".
* Use `alias` to match the sessions 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] };
function statusClause(filter, alias = "s") {
const parts = [];
const params = [];
if (filter?.status && filter.status !== "all") {
parts.push(`${alias}.status = ?`);
params.push(filter.status);
}
if (filter?.tool) {
parts.push(`${alias}.tool = ?`);
params.push(filter.tool);
}
if (filter?.range?.start) {
parts.push(`${alias}.started_at >= ?`);
params.push(filter.range.start);
}
if (filter?.range?.end) {
parts.push(`${alias}.started_at < ?`);
params.push(filter.range.end);
}
return { clause: parts.map((p) => ` AND ${p}`).join(""), params };
}
/** Same but for agents table joins where we need to filter via session_id */
function sessionIdFilter(statusFilter) {
if (!statusFilter || statusFilter === "all") return { clause: "", params: [] };
function sessionIdFilter(filter) {
const ss = statusClause(filter, "sessions");
if (!ss.clause) return { clause: "", params: [] };
return {
clause: " AND session_id IN (SELECT id FROM sessions WHERE status = ?)",
params: [statusFilter],
clause: ` AND session_id IN (SELECT id FROM sessions WHERE 1=1${ss.clause})`,
params: ss.params,
};
}
@@ -691,7 +710,8 @@ const WORKFLOW_SECTIONS = [
* The GET /api/workflows bundle, or only the named sections of it.
*
* @param {object} conn SQLite connection (compat-sqlite Database)
* @param {string|null} statusFilter "active"/"completed"/…, or null/"all"
* @param {{status?: string|null, tool?: string|null, range?: object|null}|null} statusFilter
* the session filters (see `statusClause`); null = every session
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
* @returns {object}
*/