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:
+218
-7
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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.
@@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user