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:
+28
-5
@@ -459,13 +459,22 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
||||
| `POST` | `/api/agents` | Create agent (idempotent by `id`) |
|
||||
| `PATCH` | `/api/agents/:id` | Update agent |
|
||||
| `GET` | `/api/events` | List events (`session_id`, `limit`, `offset`); each row also carries `session_name`/`session_cwd`/`session_lane_id`/`agent_name`/`agent_subagent_type` (REST only, `new_event` unchanged) |
|
||||
| `GET` | `/api/snapshots` | Session snapshots, newest first, without their payload |
|
||||
| `POST` | `/api/snapshots` | `{session_id, redact?: {file_paths?}}` → freeze the session into an immutable row under a random token (same-origin) |
|
||||
| `GET` | `/api/snapshots/:token` | `{snapshot, payload}` — the frozen session, agents, events, stats and cost |
|
||||
| `DELETE`| `/api/snapshots/:id` | Delete a snapshot (same-origin) |
|
||||
| `GET` | `/api/query` | Query Explorer: sessions / events / agents filtered, sorted from an allowlist, paged; `format=csv\|json` exports the whole filter (≤ 50,000 rows) — `server/routes/query.js` |
|
||||
| `GET` | `/api/reports` | Scheduled report definitions with their latest run (`server/lib/reports.js`) |
|
||||
| `POST`/`PUT`/`DELETE` | `/api/reports[/:id]` | Create / update / delete a report definition (same-origin; delete also removes its runs and files) |
|
||||
| `POST` | `/api/reports/:id/run` | Run a report now (same-origin) |
|
||||
| `GET` | `/api/reports/:id/runs[/:runId/:format]` | The run log; one run's HTML/JSON file, path built from the ids only |
|
||||
| `GET` | `/api/stats` | Dashboard aggregate counters |
|
||||
| `GET` | `/api/analytics` | Analytics aggregates for charts/trends |
|
||||
| `GET` | `/api/metrics` | Prometheus / OpenMetrics exposition (text; v0.0.4) |
|
||||
|
||||
**Prometheus metrics (`GET /api/metrics`).** Exposes the dashboard's live counters — `agenthub_sessions`/`agenthub_agents` by status, `agenthub_events_total`, `agenthub_tokens_total` by kind, `agenthub_websocket_clients`, `agenthub_remote_sources` by enabled state, `agenthub_process_uptime_seconds`/`agenthub_process_resident_memory_bytes`, and `agenthub_build_info{version}` — in the Prometheus v0.0.4 text-exposition format for scraping into Prometheus / Grafana (`server/routes/metrics.js`). Values come from the same `server/db.js` prepared statements the REST API uses, so they match the UI; status series are enumerated so a gauge never drops out of the exposition at zero. The route is read-only and, being under `/api`, sits behind both the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard: a non-loopback scraper (e.g. Prometheus in Docker via `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS` or it gets `403 EBADHOST`, and must send the token when one is set.
|
||||
**Prometheus metrics (`GET /api/metrics`).** Exposes the dashboard's live counters — `agenthub_sessions`/`agenthub_agents` by status, `agenthub_events_total`, `agenthub_tokens_total` by kind, `agenthub_cost_usd_total` by tool and model, `agenthub_websocket_clients`, `agenthub_remote_sources` by enabled state, `agenthub_process_uptime_seconds`/`agenthub_process_resident_memory_bytes`, and `agenthub_build_info{version}` — in the Prometheus v0.0.4 text-exposition format for scraping into Prometheus / Grafana (`server/routes/metrics.js`). Values come from the same `server/db.js` prepared statements the REST API uses, so they match the UI; status series are enumerated so a gauge never drops out of the exposition at zero. The route is read-only and, being under `/api`, sits behind both the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard: a non-loopback scraper (e.g. Prometheus in Docker via `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS` or it gets `403 EBADHOST`, and must send the token when one is set.
|
||||
|
||||
**Data scope (`?sources=`).** `GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` all accept an optional `sources` query param — a comma-separated list of source ids (`local` plus any remote source id, see [Remote Data Sources](#remote-data-sources)) — that narrows the result to sessions with a matching `sessions.source`. It is parsed by `server/lib/source-filter.js` into SQL predicates; `/api/stats` and `/api/analytics` route to the source-scoped aggregates in `server/lib/scoped-stats.js` only when a scope is present, leaving the unscoped fast paths unchanged. `GET /api/sessions/facets` additionally returns a `sources` facet enumerating the known source ids.
|
||||
**Data scope (`?sources=`, `?tool=`).** `GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` all accept an optional `sources` query param — a comma-separated list of source ids (`local` plus any remote source id, see [Remote Data Sources](#remote-data-sources)) — that narrows the result to sessions with a matching `sessions.source`. It is parsed by `server/lib/source-filter.js` into SQL predicates; `/api/stats` and `/api/analytics` route to the source-scoped aggregates in `server/lib/scoped-stats.js` only when a scope is present, leaving the unscoped fast paths unchanged. `GET /api/sessions/facets` additionally returns a `sources` facet enumerating the known source ids. `?tool=<id>` (`sessions.tool`) narrows `/api/stats`, `/api/analytics`, `/api/workflows` and `/api/pricing/cost` the same way — `parseScope` folds both into one predicate over `sessions`; `/api/analytics`'s `total_cost` is computed exactly like `/api/pricing/cost`'s. `?from=&to=` (local `YYYY-MM-DD`, inclusive, with `tz_offset`; `parseRange`) narrows `/api/analytics` (sessions/agents by `started_at`, events by `created_at`, tokens and cost from the days in the range; the daily series keep 365 days), `/api/pricing/cost` (everything but `daily_costs`) and `/api/workflows` (sessions started in the range); a bad date is `400 INVALID_INPUT`. `computeAnalytics` / `computeCost` are the same computations as functions, used by scheduled reports.
|
||||
|
||||
**Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time. When neither title exists, the session's first user prompt (tool-result / meta / slash-command plumbing entries skipped, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task; a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn.
|
||||
|
||||
@@ -476,6 +485,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------ | ---------------------------------------------- |
|
||||
| `POST` | `/api/hooks/event` | Ingest one Claude Code hook event envelope |
|
||||
| `POST` | `/api/hooks/ingest-batch` | Hooks PUSHED from another machine (one SSH cannot reach). `Authorization: Bearer <token>` from Settings → Data → **Push ingest** — 503 `EPUSHDISABLED` until a token exists, 401 on a wrong one. Body `{source, events: [{hook_type, data, tool?}]}`: `source` 1-64 of `[A-Za-z0-9._-]` (sessions are tagged `push:<source>`), 1-500 events, 1 MB per request. Each event goes through the same path as `/event`, with the sender's `transcript_path`s dropped, no lane/pane binding, and a session another source owns refused (`EFOREIGN`, per item). Answers `{ok, source, stored, results[]}` |
|
||||
|
||||
Request body shape:
|
||||
|
||||
@@ -496,8 +506,10 @@ Request body shape:
|
||||
| `GET` | `/api/pricing` | List pricing rules |
|
||||
| `PUT` | `/api/pricing` | Create/update a pricing rule |
|
||||
| `DELETE` | `/api/pricing/:pattern` | Delete pricing rule |
|
||||
| `GET` | `/api/pricing/cost` | Total cost across all sessions |
|
||||
| `GET` | `/api/pricing/cost` | Total cost across all sessions (`?lane=`, `?tool=`, `?from=&to=`, `by_tool`) |
|
||||
| `GET` | `/api/pricing/cost/:id` | Cost breakdown for one session |
|
||||
| `GET` | `/api/budgets/status` | Enabled `budget` alert rules with current-period spend, %, projection |
|
||||
| `POST` | `/api/alerts/rules/preview` | Dry-run an alert rule over recorded history (read-only) |
|
||||
|
||||
`PUT /api/pricing` also accepts optional **time-limited introductory rates** (`intro_*_per_mtok` + an `intro_until` `YYYY-MM-DD` cutoff): usage on/before the cutoff is priced at the intro rate, after it at the standard rate. Intro columns are written only when the caller sends them, so a standard-rate edit never disturbs a promo. Every rate field present must be a non-negative finite number — `NaN`/negative values are rejected with `400 INVALID_INPUT` before anything is written. The agent-list endpoints (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (0 for main agents, whose cost is the session total).
|
||||
|
||||
@@ -619,6 +631,11 @@ An `antigravity` workspace carries no pipeline either.
|
||||
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
|
||||
`antigravity_conversations` holds the mtime read for each file.
|
||||
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
|
||||
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
||||
@@ -632,7 +649,7 @@ An `antigravity` workspace carries no pipeline either.
|
||||
|
||||
### Remote Data Sources
|
||||
|
||||
Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default.
|
||||
Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history — and, per source, opt-in Codex history (`sync_codex`, from `remote_codex_home`, default `~/.codex`: its `sessions/` mirrored with mtimes kept and read by the same `server/lib/codex-usage.js` pass as local Codex journals, under the source's tag; a sync where only one of the two CLIs is found is `ok` with the other's failure in `last_error`) — from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default.
|
||||
|
||||
> **Cursor on remotes (informational):** The same note applies on synced machines — if Cursor on a remote host writes to `~/.claude`, those sessions are imported too. AgentHub reads the paths, not the app name.
|
||||
|
||||
@@ -677,7 +694,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------ | ------------------------------------------------ |
|
||||
| `GET` | `/api/settings/info` | System info, DB stats, hooks status, cache stats. Also powers the Dashboard Health tab (server uptime, memory, CPU, DB record counts, WAL/journal mode, transcript cache hit/miss rates) |
|
||||
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage, and empty `codex_rollouts`/`antigravity_conversations` so those sessions import again |
|
||||
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage, and empty `codex_rollouts`/`antigravity_conversations`/`cursor_transcripts` so those sessions import again. Alert rules, webhook targets, pricing and `session_snapshots` survive |
|
||||
| `POST` | `/api/settings/reimport` | Re-import legacy sessions from `~/.claude/` |
|
||||
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
|
||||
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
|
||||
@@ -694,6 +711,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
|
||||
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file` only, same-origin, ≤ 512 MB. Idempotent + non-destructive: sessions already present are skipped whole; restored payloads trimmed like every event insert |
|
||||
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
|
||||
| `GET` | `/api/settings/privacy` | `{redact_secrets, ignore_cwd[], env_owned: {redact_secrets, ignore_cwd}}` (`server/lib/privacy.js`) |
|
||||
| `PUT` | `/api/settings/privacy` | `{redact_secrets?: boolean, ignore_cwd?: string[]}` → persisted to the settings file as `AGENTHUB_REDACT_SECRETS` / `AGENTHUB_IGNORE_CWD` (`sameOriginGuard`). 400 `EBADPATH` (relative, a root, a control char or the list separator), 409 `EENVOWNED`, 500 `ESAVE` |
|
||||
| `GET` | `/api/settings/push-token` | `{enabled, env_owned}` — never the token |
|
||||
| `POST` | `/api/settings/push-token` | Make a new push-ingest token (the old one stops working) and return it this ONCE as `token`; only its SHA-256 is stored (`AGENTHUB_PUSH_TOKEN_SHA256`). `sameOriginGuard`; 409 `EENVOWNED` |
|
||||
| `DELETE` | `/api/settings/push-token` | Revoke it: push ingest off (`sameOriginGuard`) |
|
||||
|
||||
### Claude Config Explorer (`/api/cc-config`)
|
||||
|
||||
@@ -1575,6 +1597,7 @@ DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the
|
||||
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
|
||||
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
|
||||
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
|
||||
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
|
||||
|
||||
# Remote Data Sources (SSH pull; see the Remote Data Sources section)
|
||||
DASHBOARD_REMOTE_SYNC_MS=15000 # Remote-source sync poll interval (ms); 0 disables the poller
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file Regression test for the alert_rules rebuild that drops its 4-type
|
||||
* `rule_type` CHECK: an existing DB keeps every rule and every fired alert
|
||||
* (alert_events cascades on rule delete, so the rebuild must run with foreign
|
||||
* keys off), and the new types can be stored afterwards.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "alert-mig-")));
|
||||
const DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.DASHBOARD_DB_PATH = DB_PATH;
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
|
||||
// The schema an older build created.
|
||||
{
|
||||
const Database = require("better-sqlite3");
|
||||
const old = new Database(DB_PATH);
|
||||
old.pragma("foreign_keys = ON");
|
||||
old.exec(`
|
||||
CREATE TABLE alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL CHECK(rule_type IN ('event_pattern','inactivity','status_duration','token_threshold')),
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cooldown_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE alert_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
agent_id TEXT,
|
||||
message TEXT NOT NULL,
|
||||
details TEXT,
|
||||
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
acknowledged_at TEXT,
|
||||
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('r1', 'Old', 'inactivity', '{"minutes":5}');
|
||||
INSERT INTO alert_events (rule_id, rule_name, rule_type, message) VALUES ('r1', 'Old', 'inactivity', 'fired');
|
||||
`);
|
||||
old.close();
|
||||
}
|
||||
|
||||
const { db, stmts } = require("../db");
|
||||
|
||||
it("keeps rules and fired alerts, and accepts the new types", () => {
|
||||
assert.equal(db.prepare("SELECT name FROM alert_rules WHERE id = 'r1'").get().name, "Old");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events WHERE rule_id = 'r1'").get().c, 1);
|
||||
stmts.insertAlertRule.run("r2", "Budget", "budget", '{"period":"day","limit_usd":1}', 1, 0);
|
||||
assert.equal(stmts.getAlertRule.get("r2").rule_type, "budget");
|
||||
// The cascade still works after the rename.
|
||||
stmts.deleteAlertRule.run("r1");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events").get().c, 0);
|
||||
assert.equal(db.pragma("foreign_keys", { simple: true }), 1);
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @file The Analytics date range: `parseRange` (local dates → UTC instants,
|
||||
* 400 on bad input), `?from=&to=` on GET /api/pricing/cost (totals, breakdown
|
||||
* and by_tool by spend day; daily_costs whole), GET /api/analytics (counts by
|
||||
* started_at / created_at, tokens and cost from the days in the range) and
|
||||
* GET /api/workflows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "analytics-range-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const { parseRange, inRange } = require("../lib/source-filter");
|
||||
|
||||
const M = 1_000_000;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
async function get(url) {
|
||||
const res = await fetch(`${base}${url}`);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
function session(id, startedAt, tool = "claude") {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
|
||||
).run(id, id, startedAt, startedAt, tool);
|
||||
}
|
||||
const usage = (id, input) =>
|
||||
stmts.setTokenUsage.run(
|
||||
id,
|
||||
"range-model",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
input,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const hour = (id, h, input) =>
|
||||
stmts.upsertHourlyUsage.run(
|
||||
id,
|
||||
h,
|
||||
"range-model",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
input,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const event = (sid, at, tool) =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES (?, 'PreToolUse', ?, ?)"
|
||||
)
|
||||
.run(sid, tool, at);
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('range-model%', 'R', 1, 0, 0, 0)"
|
||||
).run();
|
||||
// "a": started 2026-09-01, 1 M on the 1st, 2 M on the 10th (hourly rows).
|
||||
session("a", "2026-09-01T10:00:00.000Z");
|
||||
usage("a", 3 * M);
|
||||
hour("a", "2026-09-01T10", 1 * M);
|
||||
hour("a", "2026-09-10T10", 2 * M);
|
||||
// "b": a codex session started 2026-09-20 with 4 M and no hourly rows.
|
||||
session("b", "2026-09-20T10:00:00.000Z", "codex");
|
||||
usage("b", 4 * M);
|
||||
event("a", "2026-09-01T11:00:00.000Z", "Read");
|
||||
event("a", "2026-09-10T11:00:00.000Z", "Edit");
|
||||
event("b", "2026-09-20T11:00:00.000Z", "Bash");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRange", () => {
|
||||
const q = (query) => parseRange({ query });
|
||||
it("is null with no range and false on bad input", () => {
|
||||
assert.equal(q({}), null);
|
||||
assert.equal(q({ from: "", to: "" }), null);
|
||||
assert.equal(q({ from: "2026-02-30" }), false);
|
||||
assert.equal(q({ from: "09/01/2026" }), false);
|
||||
assert.equal(q({ from: "2026-09-10", to: "2026-09-01" }), false);
|
||||
assert.equal(q({ to: ["2026-09-01"] }), false);
|
||||
assert.equal(q({ to: "9999-12-31" }), false);
|
||||
});
|
||||
it("turns local dates into UTC instants with tz_offset, end exclusive", () => {
|
||||
assert.deepEqual(q({ from: "2026-09-01", to: "2026-09-02", tz_offset: "-420" }), {
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-02",
|
||||
start: "2026-08-31T17:00:00.000Z",
|
||||
end: "2026-09-02T17:00:00.000Z",
|
||||
});
|
||||
const open = q({ to: "2026-09-02" });
|
||||
assert.equal(open.start, null);
|
||||
assert.equal(open.end, "2026-09-03T00:00:00.000Z");
|
||||
assert.ok(inRange(open, "2026-01-01"));
|
||||
assert.ok(!inRange(open, "2026-09-03"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/pricing/cost?from=&to=", () => {
|
||||
it("prices only the spend days in the range and keeps the whole daily series", async () => {
|
||||
const all = await get("/api/pricing/cost?tz_offset=0");
|
||||
assert.equal(all.body.total_cost, 7);
|
||||
const r = await get("/api/pricing/cost?tz_offset=0&from=2026-09-05&to=2026-09-25");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.total_cost, 6); // 2 M on the 10th + b's 4 M on the 20th
|
||||
assert.deepEqual(
|
||||
r.body.by_tool.map((x) => [x.tool, x.cost]),
|
||||
[
|
||||
["codex", 4],
|
||||
["claude", 2],
|
||||
]
|
||||
);
|
||||
assert.equal(r.body.breakdown[0].input_tokens, 6 * M);
|
||||
assert.deepEqual(r.body.daily_costs, all.body.daily_costs);
|
||||
});
|
||||
it("answers 400 for a bad range", async () => {
|
||||
const r = await get("/api/pricing/cost?from=2026-13-01");
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/analytics?from=&to=", () => {
|
||||
it("leaves the unranged answer as it was", async () => {
|
||||
const r = await get("/api/analytics?tz_offset=0");
|
||||
assert.equal(r.body.overview.total_sessions, 2);
|
||||
assert.equal(r.body.overview.total_events, 3);
|
||||
assert.equal(r.body.tokens.total_input, 7 * M);
|
||||
assert.equal(r.body.total_cost, 7);
|
||||
});
|
||||
it("counts sessions and events in the range, tokens and cost by spend day", async () => {
|
||||
const r = await get("/api/analytics?tz_offset=0&from=2026-09-05&to=2026-09-15");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.overview.total_sessions, 0); // "a" started before
|
||||
assert.equal(r.body.overview.total_events, 1);
|
||||
assert.deepEqual(r.body.tool_usage, [{ tool_name: "Edit", count: 1 }]);
|
||||
assert.equal(r.body.tokens.total_input, 2 * M);
|
||||
assert.equal(r.body.total_cost, 2);
|
||||
// The daily chart keeps its own window.
|
||||
assert.equal(r.body.daily_events.length, 3);
|
||||
});
|
||||
it("combines with ?tool= and answers 400 for a bad range", async () => {
|
||||
const r = await get("/api/analytics?tz_offset=0&tool=codex&from=2026-09-01&to=2026-09-30");
|
||||
assert.equal(r.body.overview.total_sessions, 1);
|
||||
assert.equal(r.body.total_cost, 4);
|
||||
assert.equal((await get("/api/analytics?to=nope")).status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/workflows?from=&to=", () => {
|
||||
it("counts the sessions started in the range", async () => {
|
||||
const r = await get("/api/workflows?from=2026-09-15&to=2026-09-30");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.stats.totalSessions, 1);
|
||||
assert.equal((await get("/api/workflows?from=x")).status, 400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file Tests for spend budgets and dollar alerts: period bounds in local time,
|
||||
* the `budget` and `cost_threshold` rule types (validation, firing once per
|
||||
* threshold per period), GET /api/budgets/status and the read-only rule
|
||||
* preview POST /api/alerts/rules/preview.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "budgets-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const { periodBounds, budgetSpend } = require("../lib/budgets");
|
||||
const alerts = require("../lib/alerts");
|
||||
|
||||
const M = 1_000_000;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function call(method, url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
`${base}${url}`,
|
||||
{ method, headers: body ? { "content-type": "application/json" } : {} },
|
||||
(res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
function session(id, tool, startedAt, updatedAt = startedAt) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
|
||||
).run(id, id, startedAt, updatedAt, tool);
|
||||
}
|
||||
const spend = (id, input) =>
|
||||
stmts.setTokenUsage.run(
|
||||
id,
|
||||
"budget-model",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
input,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('budget-model%', 'B', 1, 0, 0, 0)"
|
||||
).run();
|
||||
});
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
beforeEach(() => {
|
||||
db.exec(
|
||||
"DELETE FROM alert_events; DELETE FROM alert_rules; DELETE FROM token_usage; DELETE FROM sessions;"
|
||||
);
|
||||
alerts.invalidateRuleCache();
|
||||
});
|
||||
|
||||
describe("periodBounds", () => {
|
||||
// 2026-10-05 20:00 UTC = 2026-10-06 03:00 in UTC+7 (tz_offset -420), a Tuesday.
|
||||
const now = Date.parse("2026-10-05T20:00:00Z");
|
||||
it("starts a day at local midnight", () => {
|
||||
const b = periodBounds("day", now, -420);
|
||||
assert.equal(b.start, "2026-10-06");
|
||||
assert.equal(b.end, "2026-10-06");
|
||||
assert.equal(b.startUtc, "2026-10-05T17:00:00.000Z");
|
||||
assert.equal(b.days, 1);
|
||||
assert.ok(Math.abs(b.elapsed - 3 / 24) < 1e-9);
|
||||
});
|
||||
it("starts a week on Monday and a month on the 1st", () => {
|
||||
assert.equal(periodBounds("week", now, -420).start, "2026-10-05");
|
||||
assert.equal(periodBounds("week", now, -420).end, "2026-10-11");
|
||||
const m = periodBounds("month", now, -420);
|
||||
assert.equal(m.start, "2026-10-01");
|
||||
assert.equal(m.end, "2026-10-31");
|
||||
assert.equal(m.days, 31);
|
||||
});
|
||||
it("walks back whole periods, which count as fully elapsed", () => {
|
||||
const m = periodBounds("month", now, 0, 1);
|
||||
assert.equal(m.start, "2026-09-01");
|
||||
assert.equal(m.end, "2026-09-30");
|
||||
assert.equal(m.elapsed, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget rules", () => {
|
||||
it("validates the config", async () => {
|
||||
const bad = await call("POST", "/api/alerts/rules", {
|
||||
name: "b",
|
||||
rule_type: "budget",
|
||||
config: { period: "year", limit_usd: 5 },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const ok = await call("POST", "/api/alerts/rules", {
|
||||
name: "b",
|
||||
rule_type: "budget",
|
||||
config: { period: "day", limit_usd: 5, tool: "codex", tz_offset: -420 },
|
||||
});
|
||||
assert.equal(ok.status, 201);
|
||||
assert.deepEqual(ok.body.rule.config, {
|
||||
period: "day",
|
||||
limit_usd: 5,
|
||||
thresholds: [80, 100],
|
||||
tool: "codex",
|
||||
tz_offset: -420,
|
||||
});
|
||||
});
|
||||
|
||||
it("measures the period's spend in scope and projects it", () => {
|
||||
const now = Date.now();
|
||||
session("today", "codex", iso(now - 60_000));
|
||||
spend("today", 2 * M); // $2
|
||||
session("other-tool", "claude", iso(now - 60_000));
|
||||
spend("other-tool", 9 * M);
|
||||
session("yesterday", "codex", iso(now - 3 * 86_400_000));
|
||||
spend("yesterday", 7 * M);
|
||||
const s = budgetSpend(
|
||||
{ period: "day", limit_usd: 4, thresholds: [50], tool: "codex" },
|
||||
{ now }
|
||||
);
|
||||
assert.equal(s.spend, 2);
|
||||
assert.equal(s.pct, 50);
|
||||
assert.ok(s.projected >= 2);
|
||||
});
|
||||
|
||||
it("fires each crossed threshold once per period, and the status lists it", async () => {
|
||||
const now = Date.now();
|
||||
session("s1", "claude", iso(now - 60_000));
|
||||
spend("s1", 9 * M); // $9 of $10
|
||||
const created = await call("POST", "/api/alerts/rules", {
|
||||
name: "Daily",
|
||||
rule_type: "budget",
|
||||
config: { period: "day", limit_usd: 10, thresholds: [50, 80, 100] },
|
||||
});
|
||||
alerts.sweepTimeRules();
|
||||
alerts.sweepTimeRules();
|
||||
const fired = db.prepare("SELECT details FROM alert_events ORDER BY id").all();
|
||||
assert.deepEqual(
|
||||
fired.map((f) => JSON.parse(f.details).threshold),
|
||||
[50, 80]
|
||||
);
|
||||
const status = await call("GET", "/api/budgets/status");
|
||||
const b = status.body.budgets.find((x) => x.id === created.body.rule.id);
|
||||
assert.equal(b.spend, 9);
|
||||
assert.equal(b.pct, 90);
|
||||
assert.deepEqual(b.crossed, [50, 80]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cost_threshold", () => {
|
||||
it("fires when a session's cost reaches the threshold", async () => {
|
||||
session("pricey", "claude", iso(Date.now()));
|
||||
spend("pricey", 3 * M);
|
||||
await call("POST", "/api/alerts/rules", {
|
||||
name: "Expensive",
|
||||
rule_type: "cost_threshold",
|
||||
config: { usd: 2.5 },
|
||||
});
|
||||
alerts.evaluateEvent({ session_id: "pricey", event_type: "Stop" });
|
||||
const a = db.prepare("SELECT message, details FROM alert_events").get();
|
||||
assert.match(a.message, /\$3\.00/);
|
||||
assert.equal(JSON.parse(a.details).cost_usd, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rule preview", () => {
|
||||
it("replays an event pattern with its count window and cooldown, firing nothing", async () => {
|
||||
session("p1", "claude", iso(Date.now() - 3_600_000));
|
||||
const t0 = Date.now() - 30 * 60_000;
|
||||
for (const [i, m] of [0, 1, 2, 20].entries()) {
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('p1', 'PostToolUse', 'Bash', ?, ?)"
|
||||
).run(`e${i}`, iso(t0 + m * 60_000));
|
||||
}
|
||||
const r = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "event_pattern",
|
||||
config: { tool_name: "Bash", count: 2, window_minutes: 5 },
|
||||
cooldown_seconds: 300,
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
// e1 (2 in window) fires; e2 is inside the cooldown; e20 is alone in its window.
|
||||
assert.equal(r.body.fires, 1);
|
||||
assert.equal(r.body.sessions, 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events").get().c, 0);
|
||||
});
|
||||
it("lists past budget periods and refuses what cannot be replayed", async () => {
|
||||
const b = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "budget",
|
||||
config: { period: "week", limit_usd: 1 },
|
||||
});
|
||||
assert.equal(b.body.periods.length, 6);
|
||||
const s = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "status_duration",
|
||||
config: { status: "working", minutes: 5 },
|
||||
});
|
||||
assert.equal(s.body.supported, false);
|
||||
const bad = await call("POST", "/api/alerts/rules/preview", {
|
||||
rule_type: "budget",
|
||||
config: { period: "week", limit_usd: 1 },
|
||||
days: 0,
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
* includes reasoning), duplicate totals, a mid-session model switch, a fork's
|
||||
* copied history skipped, a sub-agent thread folded into its root session,
|
||||
* incremental reads, a deleted session not resurrected, and a Claude row never
|
||||
* touched. CODEX_HOME and the DB live in a temp dir.
|
||||
* touched; a remote source's mirror read with its own source tag, home and
|
||||
* activity window; a folder the user ignores never written. CODEX_HOME and the
|
||||
* DB live in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -427,4 +429,110 @@ describe("ingestCodexUsage", () => {
|
||||
const r = await ingestCodexUsage({ now: Date.now() });
|
||||
assert.deepEqual(r, { files: 0, sessions: 0 });
|
||||
});
|
||||
|
||||
describe("a remote source's mirror", () => {
|
||||
const MIRROR = path.join(ROOT, "remote-sources", "src_aaa", "codex");
|
||||
const remote = { home: MIRROR, source: "src_aaa", activeMs: 600_000 };
|
||||
function writeMirrored(id, lines, mtime) {
|
||||
const day = path.join(MIRROR, "sessions", "2026", "09", "29");
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const file = path.join(day, `rollout-2026-09-29T01-00-00-${id}.jsonl`);
|
||||
fs.writeFileSync(file, jsonl(lines));
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
}
|
||||
beforeEach(() => fs.rmSync(MIRROR, { recursive: true, force: true }));
|
||||
|
||||
it("writes its sessions and tokens tagged with the source, named from its own index", async () => {
|
||||
writeMirrored(
|
||||
"r1",
|
||||
[meta("r1"), ctx("gpt-a"), tc([10, 4, 2], [10, 4, 2])],
|
||||
new Date("2026-09-29T02:00:00Z")
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(MIRROR, "session_index.jsonl"),
|
||||
JSON.stringify({ id: "r1", thread_name: "Remote work" }) + "\n"
|
||||
);
|
||||
const r = await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z"), ...remote });
|
||||
assert.equal(r.sessions, 1);
|
||||
const s = stmts.getSession.get("r1");
|
||||
assert.equal(s.source, "src_aaa");
|
||||
assert.equal(s.tool, "codex");
|
||||
assert.equal(s.name, "Remote work");
|
||||
assert.deepEqual(usage("r1"), [{ model: "gpt-a", i: 6, r: 4, o: 2 }]);
|
||||
// The local pass never sees the mirror, and leaves the row alone.
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("r1").source, "src_aaa");
|
||||
});
|
||||
|
||||
it("never writes over a session another source owns", async () => {
|
||||
writeRollout("dup", [meta("dup"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
writeMirrored(
|
||||
"dup",
|
||||
[meta("dup"), ctx("gpt-b"), tc([50, 0, 9], [50, 0, 9])],
|
||||
new Date("2026-09-29T02:30:00Z")
|
||||
);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z"), ...remote });
|
||||
assert.equal(stmts.getSession.get("dup").source, "local");
|
||||
assert.equal(stmts.getSession.get("dup").model, "gpt-a");
|
||||
});
|
||||
|
||||
it("keeps its own activity window, and only its own settle completes it", async () => {
|
||||
const t0 = Date.parse("2026-09-29T03:00:00Z");
|
||||
writeMirrored(
|
||||
"r2",
|
||||
[meta("r2"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
|
||||
new Date(t0 - 300_000)
|
||||
);
|
||||
await ingestCodexUsage({ now: t0, ...remote });
|
||||
assert.equal(stmts.getSession.get("r2").status, "active"); // 5 min < 10 min window
|
||||
settleActive(() => {}, t0 + 900_000); // the local settle: not its row
|
||||
assert.equal(stmts.getSession.get("r2").status, "active");
|
||||
settleActive(() => {}, t0 + 900_000, remote);
|
||||
assert.equal(stmts.getSession.get("r2").status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
it("never writes a session whose folder the user ignores", async () => {
|
||||
process.env.AGENTHUB_IGNORE_CWD = "/w";
|
||||
try {
|
||||
writeRollout("ig", [meta("ig"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
|
||||
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
assert.equal(stmts.getSession.get("ig"), undefined);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_IGNORE_CWD;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("service tier", () => {
|
||||
const tier = (t) => ({
|
||||
timestamp: "2026-09-29T01:00:01.500Z",
|
||||
type: "event_msg",
|
||||
payload: { type: "thread_settings_applied", thread_settings: { service_tier: t } },
|
||||
});
|
||||
it("books fast/priority as speed fast and flex as service_tier flex", async () => {
|
||||
writeRollout("tier1", [
|
||||
meta("tier1"),
|
||||
ctx("gpt-a"),
|
||||
tc([10, 0, 1], [10, 0, 1]),
|
||||
tier("fast"),
|
||||
tc([30, 0, 3], [20, 0, 2]),
|
||||
tier("flex"),
|
||||
tc([60, 0, 6], [30, 0, 3]),
|
||||
tier("default"),
|
||||
tc([100, 0, 10], [40, 0, 4]),
|
||||
]);
|
||||
await ingestCodexUsage({ broadcast: () => {}, now: Date.parse("2026-09-29T03:00:00Z") });
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT speed, service_tier AS t, input_tokens AS i FROM token_usage WHERE session_id = 'tier1' ORDER BY i"
|
||||
)
|
||||
.all();
|
||||
assert.deepEqual(rows, [
|
||||
{ speed: "fast", t: "standard", i: 20 },
|
||||
{ speed: "standard", t: "flex", i: 30 },
|
||||
{ speed: "standard", t: "standard", i: 50 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file Tests for the AI-tool data scope (`?tool=` on cost / analytics / stats /
|
||||
* workflows), the per-tool cost split `by_tool`, the Prometheus cost metric, the
|
||||
* flex tier and OpenAI priority rates synced into the fast columns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cost-by-tool-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const { toRow } = require("../lib/pricing-sync");
|
||||
|
||||
const M = 1_000_000;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function call(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get(`${base}${url}`, (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () =>
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
text,
|
||||
body: res.headers["content-type"]?.includes("json") ? JSON.parse(text) : null,
|
||||
})
|
||||
);
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function session(id, tool) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z', ?)"
|
||||
).run(id, id, tool);
|
||||
}
|
||||
const usage = (id, model, input) =>
|
||||
stmts.setTokenUsage.run(id, model, "standard", "global", "standard", input, 0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
// $1 / $2 per million input tokens.
|
||||
for (const [pattern, rate] of [
|
||||
["tooltest-claude%", 1],
|
||||
["tooltest-gpt%", 2],
|
||||
]) {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES (?, ?, ?, 0, 0, 0)"
|
||||
).run(pattern, pattern, rate);
|
||||
}
|
||||
session("c1", "claude");
|
||||
usage("c1", "tooltest-claude-1", 3 * M);
|
||||
// Pre-compaction usage lives in the baseline columns; every cost path counts it.
|
||||
db.prepare("UPDATE token_usage SET baseline_input = ? WHERE session_id = 'c1'").run(1 * M);
|
||||
session("x1", "codex");
|
||||
usage("x1", "tooltest-gpt-1", 1 * M);
|
||||
});
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("?tool= and by_tool", () => {
|
||||
it("splits the total per tool, most expensive first", async () => {
|
||||
const r = await call("/api/pricing/cost");
|
||||
assert.equal(r.body.total_cost, 6);
|
||||
assert.deepEqual(r.body.by_tool, [
|
||||
{ tool: "claude", cost: 4 },
|
||||
{ tool: "codex", cost: 2 },
|
||||
]);
|
||||
});
|
||||
it("narrows cost, analytics, stats and workflows to one tool", async () => {
|
||||
const cost = await call("/api/pricing/cost?tool=codex");
|
||||
assert.equal(cost.body.total_cost, 2);
|
||||
const an = await call("/api/analytics?tool=codex");
|
||||
assert.equal(an.body.total_cost, 2);
|
||||
assert.equal(an.body.overview.total_sessions, 1);
|
||||
const st = await call("/api/stats?tool=codex");
|
||||
assert.equal(st.body.total_sessions, 1);
|
||||
const wf = await call("/api/workflows?tool=claude");
|
||||
assert.equal(wf.status, 200);
|
||||
});
|
||||
it("analytics total_cost equals /api/pricing/cost, baselines included", async () => {
|
||||
const an = await call("/api/analytics");
|
||||
const cost = await call("/api/pricing/cost");
|
||||
assert.equal(an.body.total_cost, cost.body.total_cost);
|
||||
});
|
||||
it("answers 400 for a malformed tool", async () => {
|
||||
for (const url of [
|
||||
"/api/pricing/cost?tool=a%20b",
|
||||
"/api/analytics?tool=%3Bdrop",
|
||||
"/api/stats?tool=a%2Fb",
|
||||
"/api/workflows?tool=a%27",
|
||||
]) {
|
||||
assert.equal((await call(url)).status, 400, url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub_cost_usd_total", () => {
|
||||
it("exposes cost per tool and model", async () => {
|
||||
const r = await call("/api/metrics");
|
||||
assert.match(r.text, /agenthub_cost_usd_total\{tool="claude",model="tooltest-claude-1"\} 4\n/);
|
||||
assert.match(r.text, /agenthub_cost_usd_total\{tool="codex",model="tooltest-gpt-1"\} 2\n/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiers", () => {
|
||||
it("prices the flex tier at half, like batch", () => {
|
||||
const rules = [{ model_pattern: "m%", input_per_mtok: 2 }];
|
||||
const row = {
|
||||
model: "m1",
|
||||
input_tokens: M,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
assert.equal(calculateCost([row], rules).total_cost, 2);
|
||||
assert.equal(calculateCost([{ ...row, service_tier: "flex" }], rules).total_cost, 1);
|
||||
});
|
||||
it("syncs an OpenAI model's priority rates into the fast columns", () => {
|
||||
const row = toRow("gpt-5.6", {
|
||||
litellm_provider: "openai",
|
||||
input_cost_per_token: 4e-6,
|
||||
output_cost_per_token: 2e-5,
|
||||
input_cost_per_token_priority: 8e-6,
|
||||
output_cost_per_token_priority: 4e-5,
|
||||
});
|
||||
assert.equal(row.fastIn, 8);
|
||||
assert.equal(row.fastOut, 40);
|
||||
const plain = toRow("gpt-5.6", {
|
||||
litellm_provider: "openai",
|
||||
input_cost_per_token: 4e-6,
|
||||
output_cost_per_token: 2e-5,
|
||||
});
|
||||
assert.equal(plain.fastIn, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file Tests server/lib/cursor-usage.js — Cursor sessions from its agent
|
||||
* transcripts: a session created as `tool = 'cursor'` with its name, cwd (the
|
||||
* project slug walked back onto the filesystem) and no tokens, an unchanged
|
||||
* file never re-read, a quiet one settled to completed, a session the user
|
||||
* deleted staying deleted, and a non-cursor row with the same id never
|
||||
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
|
||||
* synthetic, in the shape Cursor writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const cursor = require("../lib/cursor-usage");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
|
||||
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
|
||||
fs.mkdirSync(PROJECT, { recursive: true });
|
||||
const slugOf = (p) => p.replace(/^\/+/, "").replace(/[^A-Za-z0-9]/g, "-");
|
||||
|
||||
function transcript(
|
||||
id,
|
||||
{ slug = slugOf(PROJECT), prompt = "Review the campaign code\nplease" } = {}
|
||||
) {
|
||||
const dir = path.join(process.env.AGENTHUB_CURSOR_DIR, "projects", slug, "agent-transcripts", id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${id}.jsonl`);
|
||||
const lines = [
|
||||
{
|
||||
role: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<timestamp>Thursday, Jun 11, 2026, 4:53 PM (UTC+7)</timestamp>\n<user_query>\n${prompt}\n</user_query>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ role: "assistant", message: { content: [{ type: "tool_use", name: "Read", input: {} }] } },
|
||||
{ type: "turn_ended", status: "success" },
|
||||
];
|
||||
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
function age(file, ms) {
|
||||
const t = new Date(Date.now() - ms);
|
||||
fs.utimesSync(file, t, t);
|
||||
}
|
||||
|
||||
const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
|
||||
);
|
||||
});
|
||||
|
||||
describe("cursor usage", () => {
|
||||
it("walks a project slug back to an existing directory, else null", () => {
|
||||
assert.equal(cursor.cwdFromSlug(slugOf(PROJECT)), PROJECT);
|
||||
assert.equal(cursor.cwdFromSlug(slugOf(`${PROJECT}-gone`)), null);
|
||||
assert.equal(cursor.cwdFromSlug("empty-window"), null);
|
||||
assert.equal(cursor.cwdFromSlug(""), null);
|
||||
});
|
||||
|
||||
it("creates the session with its name and cwd, and no tokens", async () => {
|
||||
transcript(ID);
|
||||
assert.deepEqual(await cursor.ingestCursorUsage(), { files: 1, sessions: 1 });
|
||||
const s = session(ID);
|
||||
assert.equal(s.tool, "cursor");
|
||||
assert.equal(s.name, "Review the campaign code");
|
||||
assert.equal(s.cwd, PROJECT);
|
||||
assert.equal(s.model, null);
|
||||
assert.equal(s.status, "active");
|
||||
assert.equal(s.transcript_path, null);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM token_usage").get().c, 0);
|
||||
// Unchanged: never re-read.
|
||||
assert.deepEqual(await cursor.ingestCursorUsage(), { files: 0, sessions: 0 });
|
||||
});
|
||||
|
||||
it("settles a quiet session to completed, and reads an old one completed", async () => {
|
||||
const file = transcript(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
const seen = db.prepare("SELECT mtime_ms FROM cursor_transcripts").get().mtime_ms;
|
||||
assert.deepEqual(await cursor.ingestCursorUsage({ now: seen + 5 * 60_000 }), {
|
||||
files: 0,
|
||||
sessions: 1,
|
||||
});
|
||||
assert.equal(session(ID).status, "completed");
|
||||
assert.equal(session(ID).ended_at, new Date(seen).toISOString());
|
||||
|
||||
db.exec("DELETE FROM sessions; DELETE FROM cursor_transcripts;");
|
||||
age(file, 10 * 60_000);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).status, "completed");
|
||||
});
|
||||
|
||||
it("keeps a deleted session deleted until Cursor writes again", async () => {
|
||||
const file = transcript(ID);
|
||||
age(file, 10 * 60_000);
|
||||
await cursor.ingestCursorUsage();
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID), undefined);
|
||||
fs.appendFileSync(file, JSON.stringify({ role: "user", message: { content: [] } }) + "\n");
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).tool, "cursor");
|
||||
});
|
||||
|
||||
it("never touches a session of another tool with the same id", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool) VALUES (?, 'mine', 'active', 'claude')"
|
||||
).run(ID);
|
||||
transcript(ID, { slug: "empty-window" });
|
||||
await cursor.ingestCursorUsage();
|
||||
const s = session(ID);
|
||||
assert.equal(s.tool, "claude");
|
||||
assert.equal(s.name, "mine");
|
||||
});
|
||||
|
||||
it("names a session with no prompt by its id, with no cwd", async () => {
|
||||
transcript(ID, { slug: "1778550192773", prompt: "" });
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
|
||||
assert.equal(session(ID).cwd, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file Runs every MCP tool (`mcp/server.js`) against a REAL in-test dashboard
|
||||
* (throwaway DB and CLAUDE_HOME), so a tool whose path or query drifts from
|
||||
* the actual API fails here rather than in a user's session. The MCP server is
|
||||
* spawned asynchronously because the dashboard lives in this process.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const TMP = path.join(os.tmpdir(), `agenthub-mcp-live-${Date.now()}-${process.pid}`);
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
let server;
|
||||
let child;
|
||||
let rpc;
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
const url = `http://127.0.0.1:${server.address().port}`;
|
||||
child = spawn(process.execPath, [path.resolve(__dirname, "..", "..", "mcp", "server.js")], {
|
||||
env: { ...process.env, AGENTHUB_URL: url },
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
const waiting = new Map();
|
||||
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
waiting.get(msg.id)?.(msg);
|
||||
});
|
||||
let next = 1;
|
||||
rpc = (method, params) => {
|
||||
const id = next++;
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
return new Promise((resolve) => waiting.set(id, resolve));
|
||||
};
|
||||
await rpc("initialize", { protocolVersion: "2025-06-18" });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
child?.stdin.end();
|
||||
server?.close();
|
||||
db?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("every tool answers JSON from the real API", async () => {
|
||||
const { result } = await rpc("tools/list", {});
|
||||
const args = {
|
||||
get_session_cost: { session_id: "none" },
|
||||
get_lane_cost: { lane: 1 },
|
||||
get_session: { session_id: "none" },
|
||||
get_session_stats: { session_id: "none" },
|
||||
};
|
||||
for (const { name } of result.tools) {
|
||||
const res = await rpc("tools/call", { name, arguments: args[name] || {} });
|
||||
const out = res.result.content[0].text;
|
||||
if (name === "get_session" || name === "get_session_stats") {
|
||||
assert.match(out, /HTTP 404: Session not found/, name);
|
||||
} else if (name === "get_budget_status" && res.result.isError) {
|
||||
// Budgets come from another phase; until then the route is missing.
|
||||
assert.match(out, /Not available on this dashboard/);
|
||||
} else {
|
||||
assert.equal(res.result.isError, undefined, `${name}: ${out.slice(0, 200)}`);
|
||||
JSON.parse(out);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file Tests for the read-only MCP server (`mcp/server.js`): spawns it as a
|
||||
* child over stdio against a fake in-test dashboard API and drives the real
|
||||
* JSON-RPC exchange — initialize, tools/list, tools/call — plus the error
|
||||
* paths: a route the dashboard lacks (404, or the production SPA page), an API
|
||||
* error, an unreachable dashboard, unknown tools/methods and bad JSON.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const SERVER = path.resolve(__dirname, "..", "..", "mcp", "server.js");
|
||||
|
||||
let api;
|
||||
let apiUrl;
|
||||
const seen = [];
|
||||
|
||||
/** Start the MCP server against `url`; returns `rpc(msg)` and `close()`. */
|
||||
function startMcp(url) {
|
||||
const child = spawn(process.execPath, [SERVER], {
|
||||
env: { ...process.env, AGENTHUB_URL: url, DASHBOARD_TOKEN: "tok" },
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
const waiting = new Map();
|
||||
const unsolicited = [];
|
||||
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
const w = waiting.get(msg.id);
|
||||
if (w) {
|
||||
waiting.delete(msg.id);
|
||||
w(msg);
|
||||
} else unsolicited.push(msg);
|
||||
});
|
||||
let next = 1;
|
||||
return {
|
||||
unsolicited,
|
||||
rpc(method, params) {
|
||||
const id = next++;
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
return new Promise((resolve) => waiting.set(id, resolve));
|
||||
},
|
||||
raw(line) {
|
||||
child.stdin.write(line + "\n");
|
||||
},
|
||||
close() {
|
||||
child.stdin.end();
|
||||
return new Promise((resolve) => child.on("exit", resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const text = (res) => res.result.content[0].text;
|
||||
|
||||
describe("mcp/server.js", () => {
|
||||
let mcp;
|
||||
|
||||
before(async () => {
|
||||
api = http.createServer((req, res) => {
|
||||
seen.push({ url: req.url, auth: req.headers.authorization });
|
||||
const send = (status, body, type = "application/json") => {
|
||||
res.writeHead(status, { "Content-Type": type });
|
||||
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
||||
};
|
||||
const p = req.url.split("?")[0];
|
||||
if (p === "/api/stats") return send(200, { total_sessions: 3 });
|
||||
if (p === "/api/sessions/s%2F1") return send(200, { session: { id: "s/1" } });
|
||||
if (p === "/api/sessions/gone") {
|
||||
return send(404, { error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] });
|
||||
if (p === "/api/analytics") return send(500, { error: { message: "boom" } });
|
||||
if (p === "/api/lanes") return send(200, "<!doctype html><html></html>", "text/html");
|
||||
return send(404, "Cannot GET " + p, "text/html");
|
||||
});
|
||||
await new Promise((r) => api.listen(0, "127.0.0.1", r));
|
||||
apiUrl = `http://127.0.0.1:${api.address().port}`;
|
||||
mcp = startMcp(apiUrl);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await mcp.close();
|
||||
api.close();
|
||||
});
|
||||
|
||||
it("initializes, echoing a supported protocol version", async () => {
|
||||
const res = await mcp.rpc("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "0" },
|
||||
});
|
||||
assert.equal(res.result.protocolVersion, "2025-03-26");
|
||||
assert.deepEqual(res.result.capabilities, { tools: {} });
|
||||
assert.equal(res.result.serverInfo.name, "agenthub");
|
||||
// A notification gets no answer.
|
||||
mcp.raw(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
|
||||
const unknownVersion = await mcp.rpc("initialize", { protocolVersion: "1999-01-01" });
|
||||
assert.match(unknownVersion.result.protocolVersion, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.notEqual(unknownVersion.result.protocolVersion, "1999-01-01");
|
||||
});
|
||||
|
||||
it("lists ~15 read-only tools with object schemas", async () => {
|
||||
const res = await mcp.rpc("tools/list", {});
|
||||
const names = res.result.tools.map((t) => t.name);
|
||||
for (const n of [
|
||||
"get_stats",
|
||||
"get_total_cost",
|
||||
"get_session_cost",
|
||||
"list_sessions",
|
||||
"get_session",
|
||||
"get_session_stats",
|
||||
"get_analytics",
|
||||
"get_workflows",
|
||||
"list_alerts",
|
||||
"list_lanes",
|
||||
"get_lane_cost",
|
||||
"list_pricing",
|
||||
"get_health",
|
||||
"get_info",
|
||||
"get_budget_status",
|
||||
]) {
|
||||
assert.ok(names.includes(n), n);
|
||||
}
|
||||
for (const t of res.result.tools) {
|
||||
assert.equal(t.inputSchema.type, "object");
|
||||
assert.equal(t.annotations.readOnlyHint, true);
|
||||
}
|
||||
});
|
||||
|
||||
it("calls a tool: pretty JSON text, bearer token, query passed through", async () => {
|
||||
seen.length = 0;
|
||||
const res = await mcp.rpc("tools/call", {
|
||||
name: "get_total_cost",
|
||||
arguments: { tz_offset: -420, lane: 7, tool: "codex" },
|
||||
});
|
||||
assert.equal(res.result.isError, undefined);
|
||||
assert.deepEqual(JSON.parse(text(res)), { total_cost: 1.5, daily_costs: [] });
|
||||
assert.equal(seen[0].url, "/api/pricing/cost?tz_offset=-420&lane=7&tool=codex");
|
||||
assert.equal(seen[0].auth, "Bearer tok");
|
||||
});
|
||||
|
||||
it("defaults tz_offset to this machine's and encodes path ids", async () => {
|
||||
seen.length = 0;
|
||||
await mcp.rpc("tools/call", { name: "get_stats", arguments: {} });
|
||||
assert.equal(seen[0].url, `/api/stats?tz_offset=${new Date().getTimezoneOffset()}`);
|
||||
const res = await mcp.rpc("tools/call", {
|
||||
name: "get_session",
|
||||
arguments: { session_id: "s/1" },
|
||||
});
|
||||
assert.equal(JSON.parse(text(res)).session.id, "s/1");
|
||||
assert.equal(seen[1].url, "/api/sessions/s%2F1?events=0");
|
||||
});
|
||||
|
||||
it("reports a missing route as not available (404 and SPA page)", async () => {
|
||||
const budgets = await mcp.rpc("tools/call", { name: "get_budget_status", arguments: {} });
|
||||
assert.equal(budgets.result.isError, true);
|
||||
assert.match(text(budgets), /Not available on this dashboard/);
|
||||
const spa = await mcp.rpc("tools/call", { name: "list_lanes", arguments: {} });
|
||||
assert.match(text(spa), /Not available on this dashboard/);
|
||||
});
|
||||
|
||||
it("returns API errors and bad calls as isError results", async () => {
|
||||
const notFound = await mcp.rpc("tools/call", {
|
||||
name: "get_session_stats",
|
||||
arguments: { session_id: "gone" },
|
||||
});
|
||||
// JSON 404 from a real route is the API's own answer, not a missing route.
|
||||
const gone = await mcp.rpc("tools/call", {
|
||||
name: "get_session",
|
||||
arguments: { session_id: "gone" },
|
||||
});
|
||||
assert.match(text(gone), /HTTP 404: Session not found/);
|
||||
assert.equal(notFound.result.isError, true);
|
||||
const boom = await mcp.rpc("tools/call", { name: "get_analytics", arguments: {} });
|
||||
assert.equal(boom.result.isError, true);
|
||||
assert.match(text(boom), /HTTP 500: boom/);
|
||||
const missing = await mcp.rpc("tools/call", { name: "get_session_cost", arguments: {} });
|
||||
assert.match(text(missing), /Missing required argument\(s\): session_id/);
|
||||
const unknown = await mcp.rpc("tools/call", { name: "drop_tables", arguments: {} });
|
||||
assert.match(text(unknown), /Unknown tool/);
|
||||
});
|
||||
|
||||
it("answers unknown methods and bad JSON with JSON-RPC errors", async () => {
|
||||
const res = await mcp.rpc("resources/list", {});
|
||||
assert.equal(res.error.code, -32601);
|
||||
const before = mcp.unsolicited.length;
|
||||
mcp.raw("{not json");
|
||||
await mcp.rpc("ping", {}); // ordering barrier
|
||||
assert.equal(mcp.unsolicited[before].error.code, -32700);
|
||||
});
|
||||
|
||||
it("says so when the dashboard is down", async () => {
|
||||
const dead = http.createServer();
|
||||
await new Promise((r) => dead.listen(0, "127.0.0.1", r));
|
||||
const port = dead.address().port;
|
||||
await new Promise((r) => dead.close(r));
|
||||
const down = startMcp(`http://127.0.0.1:${port}`);
|
||||
const res = await down.rpc("tools/call", { name: "get_health", arguments: {} });
|
||||
assert.equal(res.result.isError, true);
|
||||
assert.match(text(res), /not reachable/);
|
||||
await down.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file Static checks for the Prometheus/Grafana bundle (`monitoring/`,
|
||||
* `docker-compose.monitoring.yml`): every YAML and dashboard JSON parses, the
|
||||
* compose file mounts paths that exist, and every `agenthub_*` metric a board
|
||||
* or recording rule queries is one `server/routes/metrics.js` exports — except
|
||||
* `agenthub_cost_usd_total`, which those panels mark as required from another
|
||||
* phase. Docker is never run.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const MON = path.join(ROOT, "monitoring");
|
||||
const readYaml = (p) => yaml.load(fs.readFileSync(path.join(ROOT, p), "utf8"));
|
||||
const BOARDS = fs
|
||||
.readdirSync(path.join(MON, "grafana", "dashboards"))
|
||||
.map((f) => path.join(MON, "grafana", "dashboards", f));
|
||||
|
||||
const exported = new Set(
|
||||
fs
|
||||
.readFileSync(path.join(ROOT, "server", "routes", "metrics.js"), "utf8")
|
||||
.match(/"agenthub_[a-z_]+"/g)
|
||||
.map((s) => s.slice(1, -1))
|
||||
);
|
||||
const OPTIONAL = new Set(["agenthub_cost_usd_total"]);
|
||||
const metricsIn = (expr) => expr.match(/\bagenthub_[a-z_]+\b/g) || [];
|
||||
|
||||
it("compose, Prometheus and provisioning YAML parse and point at real files", () => {
|
||||
const compose = readYaml("docker-compose.monitoring.yml");
|
||||
assert.deepEqual(Object.keys(compose.services).sort(), ["grafana", "prometheus"]);
|
||||
for (const svc of Object.values(compose.services)) {
|
||||
for (const v of svc.volumes) {
|
||||
const src = v.split(":")[0];
|
||||
if (src.startsWith("./")) assert.ok(fs.existsSync(path.join(ROOT, src)), src);
|
||||
}
|
||||
}
|
||||
const prom = readYaml("monitoring/prometheus/prometheus.yml");
|
||||
assert.equal(prom.scrape_configs[0].metrics_path, "/api/metrics");
|
||||
for (const f of prom.rule_files) assert.ok(fs.existsSync(path.join(MON, "prometheus", f)), f);
|
||||
readYaml("monitoring/grafana/provisioning/datasources/datasource.yml");
|
||||
readYaml("monitoring/grafana/provisioning/dashboards/provider.yml");
|
||||
});
|
||||
|
||||
it("recording rules query only exported metrics", () => {
|
||||
const rules = readYaml("monitoring/prometheus/agenthub-rules.yml");
|
||||
const all = rules.groups.flatMap((g) => g.rules);
|
||||
assert.ok(all.length > 0);
|
||||
for (const r of all) {
|
||||
assert.match(r.record, /^agenthub:/);
|
||||
for (const m of metricsIn(r.expr))
|
||||
assert.ok(exported.has(m) || OPTIONAL.has(m), `${r.record}: ${m}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("three boards: valid JSON, unique uid and panel ids, exported metrics only", () => {
|
||||
assert.equal(BOARDS.length, 3);
|
||||
const uids = new Set();
|
||||
let costPanels = 0;
|
||||
for (const file of BOARDS) {
|
||||
const b = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.ok(b.uid && !uids.has(b.uid), file);
|
||||
uids.add(b.uid);
|
||||
const ids = b.panels.map((p) => p.id);
|
||||
assert.equal(new Set(ids).size, ids.length, `${file}: duplicate panel id`);
|
||||
for (const p of b.panels) {
|
||||
assert.equal(p.datasource.uid, "${ds}");
|
||||
for (const t of p.targets) {
|
||||
for (const m of metricsIn(t.expr)) {
|
||||
assert.ok(exported.has(m) || OPTIONAL.has(m), `${b.uid} / ${p.title}: ${m}`);
|
||||
if (OPTIONAL.has(m)) {
|
||||
assert.match(p.description || "", /Requires agenthub_cost_usd_total/, p.title);
|
||||
costPanels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(costPanels >= 4, "cost panels present");
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file Tests server/lib/privacy.js — the secret patterns (each shape redacted,
|
||||
* look-alikes such as `max_tokens: 5` left alone), secret-named JSON keys, the
|
||||
* payload entry point being a byte-identical no-op while off or when nothing
|
||||
* matched, `trim_event_data` redacting before it trims, and the ignored-folder
|
||||
* list's path-boundary match and validation.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const privacy = require("../lib/privacy");
|
||||
const { trimEventData } = require("../lib/event-payload");
|
||||
|
||||
const R = privacy.REDACTED;
|
||||
|
||||
describe("redactText", () => {
|
||||
const cases = [
|
||||
["anthropic key", "key sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv here", `key ${R} here`],
|
||||
["openai key", "OPENAI sk-proj-AbCdEfGhIjKlMnOpQrStUvWx", `OPENAI ${R}`],
|
||||
["github token", "ghp_0123456789abcdefghijABCDEFGHIJ", R],
|
||||
["aws key id", "id AKIAABCDEFGHIJKLMNOP", `id ${R}`],
|
||||
["slack", "xoxb-1234567890-abcdef", R],
|
||||
[
|
||||
"bearer header",
|
||||
"curl -H 'Authorization: Bearer abc.def-ghi_jkl'",
|
||||
`curl -H 'Authorization: Bearer ${R}'`,
|
||||
],
|
||||
["password assignment", "mysql password=hunter22 -h db", `mysql password=${R} -h db`],
|
||||
["env token", "export GITHUB_TOKEN=abcd1234efgh", `export GITHUB_TOKEN=${R}`],
|
||||
["json-ish key", '{"api_key": "abcd1234"}', `{"api_key": ${R}}`],
|
||||
[
|
||||
"private key",
|
||||
"a\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA\n-----END OPENSSH PRIVATE KEY-----\nz",
|
||||
`a\n${R}\nz`,
|
||||
],
|
||||
];
|
||||
for (const [name, input, want] of cases) {
|
||||
it(`redacts ${name}`, () => assert.equal(privacy.redactText(input), want));
|
||||
}
|
||||
|
||||
it("leaves look-alikes alone", () => {
|
||||
for (const s of [
|
||||
"max_tokens: 4096",
|
||||
"token_count=5 input_tokens: 9",
|
||||
"the author wrote a secretive note",
|
||||
"sk-short",
|
||||
"Bearer of bad news",
|
||||
]) {
|
||||
assert.equal(privacy.redactText(s), s);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = privacy.redactText("password=hunter22 and ghp_0123456789abcdefghijABCDEFGHIJ");
|
||||
assert.equal(privacy.redactText(once), once);
|
||||
});
|
||||
|
||||
it("stays linear on a pathological run", () => {
|
||||
const t = Date.now();
|
||||
privacy.redactText("a-".repeat(200_000));
|
||||
assert.ok(Date.now() - t < 2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactValue", () => {
|
||||
it("redacts any string under a secret-named key, whatever it looks like", () => {
|
||||
const out = privacy.redactValue({
|
||||
tool_input: { env: { DB_PASSWORD: "x", API_KEY: "y", max_tokens: "5", authorization: "z" } },
|
||||
author: "me",
|
||||
});
|
||||
assert.deepEqual(out, {
|
||||
tool_input: { env: { DB_PASSWORD: R, API_KEY: R, max_tokens: "5", authorization: R } },
|
||||
author: "me",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactEventJson / trim_event_data", () => {
|
||||
let saved;
|
||||
beforeEach(() => {
|
||||
saved = process.env[privacy.REDACT_KEY];
|
||||
});
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env[privacy.REDACT_KEY];
|
||||
else process.env[privacy.REDACT_KEY] = saved;
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
session_id: "s",
|
||||
tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' x" },
|
||||
});
|
||||
|
||||
it("is a byte-identical no-op while off", () => {
|
||||
delete process.env[privacy.REDACT_KEY];
|
||||
assert.equal(privacy.redactEventJson(payload), payload);
|
||||
assert.equal(trimEventData(payload), payload);
|
||||
});
|
||||
|
||||
it("redacts and counts when on, and leaves a clean payload byte-identical", () => {
|
||||
process.env[privacy.REDACT_KEY] = "1";
|
||||
const out = JSON.parse(trimEventData(payload));
|
||||
assert.equal(out.tool_input.command, `curl -H 'Authorization: Bearer ${R}' x`);
|
||||
assert.equal(out._redacted, 1);
|
||||
const clean = JSON.stringify({ session_id: "s", tool_input: { command: "ls" } });
|
||||
assert.equal(trimEventData(clean), clean);
|
||||
});
|
||||
|
||||
it("redacts a private key before the trim could cut it in two", () => {
|
||||
process.env[privacy.REDACT_KEY] = "1";
|
||||
const key = `-----BEGIN RSA PRIVATE KEY-----\n${"A".repeat(20_000)}\n-----END RSA PRIVATE KEY-----`;
|
||||
const json = JSON.stringify({ tool_name: "Read", tool_response: { file: { text: key } } });
|
||||
const out = trimEventData(json);
|
||||
assert.ok(!out.includes("AAAAAAAAAA"));
|
||||
assert.ok(out.includes(R));
|
||||
});
|
||||
|
||||
it("leaves summaries alone while off", () => {
|
||||
delete process.env[privacy.REDACT_KEY];
|
||||
assert.equal(privacy.redactSummary("password=hunter22"), "password=hunter22");
|
||||
process.env[privacy.REDACT_KEY] = "1";
|
||||
assert.equal(privacy.redactSummary("password=hunter22"), `password=${R}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignored folders", () => {
|
||||
const env = (list) => ({ [privacy.IGNORE_KEY]: list.join(path.delimiter) });
|
||||
|
||||
it("matches the folder and what lies under it, never a sibling sharing the prefix", () => {
|
||||
const e = env(["/work/secret", "/tmp/x/"]);
|
||||
assert.equal(privacy.isIgnoredCwd("/work/secret", e), true);
|
||||
assert.equal(privacy.isIgnoredCwd("/work/secret/app", e), true);
|
||||
assert.equal(privacy.isIgnoredCwd("/work/secret-two", e), false);
|
||||
assert.equal(privacy.isIgnoredCwd("/tmp/x/y", e), true);
|
||||
assert.equal(privacy.isIgnoredCwd("/work", e), false);
|
||||
assert.equal(privacy.isIgnoredCwd(undefined, e), false);
|
||||
assert.equal(privacy.isIgnoredCwd("/anything", {}), false);
|
||||
});
|
||||
|
||||
it("never treats a root or a relative entry as a folder to ignore", () => {
|
||||
assert.deepEqual(privacy.ignoredCwds(env(["/", "rel/dir", "/ok"])).length, 2);
|
||||
assert.equal(privacy.isIgnoredCwd("/home/u", env(["/"])), false);
|
||||
});
|
||||
|
||||
it("validates the Settings list", () => {
|
||||
assert.deepEqual(privacy.validateIgnoreList(["/a/b/", " /a/b ", "", "/c"]), ["/a/b", "/c"]);
|
||||
assert.throws(() => privacy.validateIgnoreList(["relative"]), { code: "EBADPATH" });
|
||||
assert.throws(() => privacy.validateIgnoreList(["/"]), { code: "EBADPATH" });
|
||||
assert.throws(() => privacy.validateIgnoreList([`/a${path.delimiter}/b`]), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
assert.throws(() => privacy.validateIgnoreList(["/a\nb"]), { code: "EBADPATH" });
|
||||
assert.throws(() => privacy.validateIgnoreList("/a"), { code: "EBADPATH" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @file Tests the push path and the privacy settings end to end:
|
||||
* `POST /api/hooks/ingest-batch` (off until a token exists, bearer token
|
||||
* checked, sessions tagged `push:<source>`, a session another source owns
|
||||
* refused, the sender's transcript paths dropped, batch limits) with the token
|
||||
* routes `/api/settings/push-token` (shown once, stored only as a hash,
|
||||
* rotate/revoke), and `/api/settings/privacy` (ignored folders dropping hooks,
|
||||
* opt-in secret redaction of stored payloads and summaries, validation, the
|
||||
* same-origin guard). DB, homes and the settings file live in a temp dir.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-push-ingest-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, "codex");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
||||
delete process.env.AGENTHUB_PUSH_TOKEN_SHA256;
|
||||
delete process.env.AGENTHUB_REDACT_SECRETS;
|
||||
delete process.env.AGENTHUB_IGNORE_CWD;
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const express = require("express");
|
||||
|
||||
const { db, stmts } = require("../db");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
before(async () => {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use("/api/hooks", require("../routes/hooks"));
|
||||
app.use("/api/settings", require("../routes/settings"));
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
db.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function call(method, p, body, headers = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
const bearer = (t) => ({ Authorization: `Bearer ${t}` });
|
||||
const batch = (token, body) => call("POST", "/api/hooks/ingest-batch", body, bearer(token));
|
||||
const hook = (session_id, hook_type = "SessionStart", extra = {}) => ({
|
||||
hook_type,
|
||||
data: { session_id, cwd: "/remote/proj", ...extra },
|
||||
});
|
||||
|
||||
describe("push ingest", () => {
|
||||
let token;
|
||||
|
||||
it("is off (503) until a token exists", async () => {
|
||||
const r = await batch("anything", { source: "laptop", events: [hook("p0")] });
|
||||
assert.equal(r.status, 503);
|
||||
assert.equal(r.body.error.code, "EPUSHDISABLED");
|
||||
assert.deepEqual((await call("GET", "/api/settings/push-token")).body, {
|
||||
enabled: false,
|
||||
env_owned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a new token once and stores only its hash", async () => {
|
||||
const r = await call("POST", "/api/settings/push-token");
|
||||
assert.equal(r.status, 200);
|
||||
token = r.body.token;
|
||||
assert.match(token, /^ahp_[A-Za-z0-9_-]{40,}$/);
|
||||
const status = await call("GET", "/api/settings/push-token");
|
||||
assert.deepEqual(status.body, { enabled: true, env_owned: false });
|
||||
const file = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8");
|
||||
assert.ok(!file.includes(token));
|
||||
assert.match(file, /^AGENTHUB_PUSH_TOKEN_SHA256=[0-9a-f]{64}$/m);
|
||||
});
|
||||
|
||||
it("refuses a missing or wrong token", async () => {
|
||||
assert.equal(
|
||||
(await batch("ahp_wrong", { source: "laptop", events: [hook("p0")] })).status,
|
||||
401
|
||||
);
|
||||
const r = await call("POST", "/api/hooks/ingest-batch", { source: "laptop", events: [] });
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
|
||||
it("stores the batch tagged with its source and drops the sender's transcript paths", async () => {
|
||||
const r = await batch(token, {
|
||||
source: "laptop",
|
||||
events: [
|
||||
hook("p1", "SessionStart", { transcript_path: "/etc/passwd" }),
|
||||
hook("p1", "UserPromptSubmit", { prompt: "hello there" }),
|
||||
{ hook_type: "bad type!", data: {} },
|
||||
],
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.source, "push:laptop");
|
||||
assert.equal(r.body.stored, 2);
|
||||
assert.deepEqual(r.body.results[2], { ok: false, error: "INVALID_INPUT" });
|
||||
const s = stmts.getSession.get("p1");
|
||||
assert.equal(s.source, "push:laptop");
|
||||
assert.ok(!s.transcript_path);
|
||||
const data = db.prepare("SELECT data FROM events WHERE session_id = 'p1'").all();
|
||||
assert.ok(data.every((e) => !e.data.includes("/etc/passwd")));
|
||||
});
|
||||
|
||||
it("never touches a session another source owns", async () => {
|
||||
await call("POST", "/api/hooks/event", hook("local1"));
|
||||
assert.equal(stmts.getSession.get("local1").source, "local");
|
||||
const before = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'")
|
||||
.get().n;
|
||||
const r = await batch(token, { source: "laptop", events: [hook("local1", "Stop")] });
|
||||
assert.deepEqual(r.body.results[0], { ok: false, error: "EFOREIGN" });
|
||||
const after = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'")
|
||||
.get().n;
|
||||
assert.equal(after, before);
|
||||
// Nor one a different pushing machine created.
|
||||
const other = await batch(token, { source: "desk", events: [hook("p1", "Stop")] });
|
||||
assert.deepEqual(other.body.results[0], { ok: false, error: "EFOREIGN" });
|
||||
});
|
||||
|
||||
it("checks the source name and the batch size", async () => {
|
||||
assert.equal((await batch(token, { source: "../x", events: [hook("p2")] })).status, 400);
|
||||
assert.equal((await batch(token, { source: "laptop", events: [] })).status, 400);
|
||||
const many = Array.from({ length: 501 }, (_, i) => hook(`m${i}`));
|
||||
assert.equal((await batch(token, { source: "laptop", events: many })).status, 400);
|
||||
});
|
||||
|
||||
it("stops the old token on rotate, and everything on revoke", async () => {
|
||||
const r = await call("POST", "/api/settings/push-token");
|
||||
assert.notEqual(r.body.token, token);
|
||||
assert.equal((await batch(token, { source: "laptop", events: [hook("p3")] })).status, 401);
|
||||
assert.equal(
|
||||
(await batch(r.body.token, { source: "laptop", events: [hook("p3")] })).status,
|
||||
200
|
||||
);
|
||||
const off = await call("DELETE", "/api/settings/push-token");
|
||||
assert.deepEqual(off.body, { ok: true, enabled: false, env_owned: false });
|
||||
assert.equal(
|
||||
(await batch(r.body.token, { source: "laptop", events: [hook("p4")] })).status,
|
||||
503
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a token write from another site", async () => {
|
||||
const r = await call("POST", "/api/settings/push-token", undefined, {
|
||||
Origin: "https://evil.example",
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("privacy settings", () => {
|
||||
it("starts off", async () => {
|
||||
const r = await call("GET", "/api/settings/privacy");
|
||||
assert.deepEqual(r.body, {
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops every hook from an ignored folder, and keeps its siblings", async () => {
|
||||
const r = await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/private/work/"] });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.ignore_cwd, ["/private/work"]);
|
||||
const dropped = await call("POST", "/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "ig1", cwd: "/private/work/app" },
|
||||
});
|
||||
assert.deepEqual(dropped.body, { ok: true, ignored: true });
|
||||
assert.equal(stmts.getSession.get("ig1"), undefined);
|
||||
await call("POST", "/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "ig2", cwd: "/private/work-other" },
|
||||
});
|
||||
assert.ok(stmts.getSession.get("ig2"));
|
||||
});
|
||||
|
||||
it("redacts stored payloads and summaries once turned on", async () => {
|
||||
const r = await call("PUT", "/api/settings/privacy", { redact_secrets: true });
|
||||
assert.equal(r.body.redact_secrets, true);
|
||||
await call("POST", "/api/hooks/event", {
|
||||
hook_type: "PreToolUse",
|
||||
data: {
|
||||
session_id: "rd1",
|
||||
cwd: "/w",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' https://x" },
|
||||
},
|
||||
});
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT summary, data FROM events WHERE session_id = 'rd1' AND event_type = 'PreToolUse'"
|
||||
)
|
||||
.all();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.ok(!rows[0].data.includes("abcdefgh12345678"));
|
||||
assert.ok(!String(rows[0].summary).includes("abcdefgh12345678"));
|
||||
assert.ok(rows[0].data.includes("[redacted]"));
|
||||
await call("PUT", "/api/settings/privacy", { redact_secrets: false, ignore_cwd: [] });
|
||||
});
|
||||
|
||||
it("refuses a bad list, a bad flag and another site", async () => {
|
||||
assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["rel"] })).status, 400);
|
||||
assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/"] })).status, 400);
|
||||
assert.equal(
|
||||
(await call("PUT", "/api/settings/privacy", { redact_secrets: "yes" })).status,
|
||||
400
|
||||
);
|
||||
const r = await call(
|
||||
"PUT",
|
||||
"/api/settings/privacy",
|
||||
{ redact_secrets: true },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @file The Query Explorer, GET /api/query: entity switch, filters (text, tool,
|
||||
* status, date range, cwd), the sort ALLOWLIST (a column or direction outside
|
||||
* it is a 400, never SQL), pagination, and the CSV/JSON exports — RFC 4180
|
||||
* quoting and the formula-injection guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "query-explorer-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const { csvCell } = require("../routes/query");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
async function get(url) {
|
||||
const res = await fetch(`${base}${url}`);
|
||||
const text = await res.text();
|
||||
let body = text;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
/* CSV */
|
||||
}
|
||||
return { status: res.status, headers: res.headers, body };
|
||||
}
|
||||
|
||||
function session(id, name, status, tool, cwd, startedAt) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(id, name, status, tool, cwd, startedAt, startedAt);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
session("q1", "=HYPERLINK(evil)", "completed", "claude", "/w/app", "2026-09-01T10:00:00.000Z");
|
||||
session("q2", 'says "hi", ok', "active", "codex", "/w/app/sub", "2026-09-05T10:00:00.000Z");
|
||||
session("q3", "100%_done", "error", "claude", "/w/apple", "2026-09-10T10:00:00.000Z");
|
||||
db.prepare(
|
||||
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES ('ag1', 'q1', 'main', 'main', 'completed', '2026-09-01T10:00:00.000Z')"
|
||||
).run();
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('q2', 'PreToolUse', 'Bash', 'line1\nline2', '2026-09-05T11:00:00.000Z')"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ids = (r) => r.body.rows.map((x) => x.id);
|
||||
|
||||
describe("GET /api/query", () => {
|
||||
it("lists sessions newest first by default, paged", async () => {
|
||||
const r = await get("/api/query");
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.entity, "sessions");
|
||||
assert.deepEqual(ids(r), ["q3", "q2", "q1"]);
|
||||
assert.equal(r.body.total, 3);
|
||||
const p = await get("/api/query?limit=1&offset=1");
|
||||
assert.deepEqual(ids(p), ["q2"]);
|
||||
assert.equal(p.body.total, 3);
|
||||
});
|
||||
|
||||
it("filters by text (wildcards literal), tool, status, range and cwd subtree", async () => {
|
||||
assert.deepEqual(ids(await get("/api/query?q=%25_")), ["q3"]);
|
||||
assert.deepEqual(ids(await get("/api/query?tool=codex")), ["q2"]);
|
||||
assert.deepEqual(ids(await get("/api/query?status=error")), ["q3"]);
|
||||
assert.deepEqual(ids(await get("/api/query?from=2026-09-02&to=2026-09-06")), ["q2"]);
|
||||
assert.deepEqual(ids(await get("/api/query?cwd=/w/app&sort=started_at&dir=asc")), ["q1", "q2"]);
|
||||
});
|
||||
|
||||
it("sorts only by an allowlisted column and direction", async () => {
|
||||
assert.deepEqual(ids(await get("/api/query?sort=name&dir=asc")), ["q3", "q1", "q2"]);
|
||||
for (const bad of [
|
||||
"sort=id;DROP TABLE sessions",
|
||||
"sort=summary",
|
||||
"dir=sideways",
|
||||
"entity=lanes",
|
||||
"status=bogus",
|
||||
"tool=../x",
|
||||
"from=2026-02-31",
|
||||
"format=xml",
|
||||
]) {
|
||||
const r = await get(`/api/query?${bad}`);
|
||||
assert.equal(r.status, 400, bad);
|
||||
}
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n, 3);
|
||||
});
|
||||
|
||||
it("queries events and agents with their own columns", async () => {
|
||||
const e = await get("/api/query?entity=events&q=Bash");
|
||||
assert.equal(e.body.total, 1);
|
||||
assert.equal(e.body.rows[0].tool, "codex");
|
||||
assert.equal((await get("/api/query?entity=events&status=active")).status, 400);
|
||||
const a = await get("/api/query?entity=agents&status=completed");
|
||||
assert.deepEqual(ids(a), ["ag1"]);
|
||||
});
|
||||
|
||||
it("exports CSV with quoting and the formula guard", async () => {
|
||||
const r = await get("/api/query?format=csv&sort=started_at&dir=asc");
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.headers.get("content-type"), /text\/csv/);
|
||||
assert.match(r.headers.get("content-disposition"), /attachment; filename="agenthub-sessions-/);
|
||||
assert.equal(r.headers.get("x-truncated"), "0");
|
||||
const lines = r.body.replace(/^\uFEFF/, "").split("\r\n");
|
||||
assert.equal(lines[0], "id,name,status,tool,model,cwd,started_at,ended_at");
|
||||
assert.ok(lines[1].startsWith("q1,'=HYPERLINK(evil),completed"));
|
||||
assert.ok(lines[2].startsWith('q2,"says ""hi"", ok",active'));
|
||||
const ev = await get("/api/query?entity=events&format=csv");
|
||||
assert.ok(ev.body.includes('"line1\nline2"'));
|
||||
});
|
||||
|
||||
it("exports JSON with the total and the cap", async () => {
|
||||
const r = await get("/api/query?format=json&tool=claude");
|
||||
assert.equal(r.body.total, 2);
|
||||
assert.equal(r.body.truncated, false);
|
||||
assert.equal(r.body.cap, 50_000);
|
||||
assert.equal(r.body.rows.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("csvCell", () => {
|
||||
it("guards every formula lead and leaves numbers and plain text alone", () => {
|
||||
for (const lead of ["=", "+", "-", "@", "\t"]) assert.equal(csvCell(`${lead}1`)[0], "'");
|
||||
assert.equal(csvCell(-5), "-5");
|
||||
assert.equal(csvCell(null), "");
|
||||
assert.equal(csvCell("plain"), "plain");
|
||||
assert.equal(csvCell("-1,2"), '"\'-1,2"');
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,22 @@ describe("remote-sync validateSourceInput", () => {
|
||||
assert.equal(v.remoteHome, "~/.claude");
|
||||
});
|
||||
|
||||
it("defaults Codex off and places its home beside Claude's (WSL included)", () => {
|
||||
const v = remoteSync.validateSourceInput({ label: "L", host: "h" });
|
||||
assert.equal(v.syncCodex, 0);
|
||||
assert.equal(v.remoteCodexHome, null);
|
||||
assert.equal(remoteSync.normalizedCodexHome({ remote_home: null }), "~/.codex");
|
||||
assert.equal(remoteSync.normalizedCodexHome({ remote_home: "wsl:~/.claude" }), "wsl:~/.codex");
|
||||
assert.equal(
|
||||
remoteSync.normalizedCodexHome({ remote_home: "~/.claude", remote_codex_home: "/opt/cx" }),
|
||||
"/opt/cx"
|
||||
);
|
||||
assert.equal(
|
||||
remoteSync.wslTarRemoteCmd("~/.codex", "sessions"),
|
||||
"wsl.exe -e sh -c 'tar -cC ~/.codex/sessions .'"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a config-alias host with no user", () => {
|
||||
const v = remoteSync.validateSourceInput({ label: "x", host: "mybox" });
|
||||
assert.equal(v.host, "mybox");
|
||||
@@ -412,6 +428,27 @@ describe("/api/remote-sources CRUD", () => {
|
||||
assert.equal(res.body.source.ssh_port, 22); // unchanged
|
||||
});
|
||||
|
||||
it("stores the Codex opt-in and its home, and a patch of one keeps the other", async () => {
|
||||
const res = await post("/api/remote-sources", {
|
||||
label: "Codex box",
|
||||
host: "son@codexbox",
|
||||
sync_codex: true,
|
||||
remote_codex_home: "~/.codex-work",
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.source.sync_codex, true);
|
||||
assert.equal(res.body.source.remote_codex_home, "~/.codex-work");
|
||||
const id = res.body.source.id;
|
||||
const off = await patch(`/api/remote-sources/${id}`, { sync_codex: false });
|
||||
assert.equal(off.body.source.sync_codex, false);
|
||||
assert.equal(off.body.source.remote_codex_home, "~/.codex-work");
|
||||
const bad = await patch(`/api/remote-sources/${id}`, { remote_codex_home: "~/x;rm -rf /" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "INVALID_REMOTE_CODEX_HOME");
|
||||
await del(`/api/remote-sources/${id}`);
|
||||
});
|
||||
|
||||
it("404s for an unknown id", async () => {
|
||||
const res = await patch("/api/remote-sources/src_nope", { label: "x" });
|
||||
assert.equal(res.status, 404);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @file Scheduled reports: definition validation and CRUD behind the
|
||||
* same-origin guard, `lastSlot`/`reportWindow` in local time, a run writing
|
||||
* escaped HTML and JSON under <data dir>/reports/<id>/, the download route
|
||||
* reading only what a run recorded, and the scheduler claiming each slot once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "reports-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db, stmts } = require("../db");
|
||||
const reports = require("../lib/reports");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
async function call(method, url, body, headers = {}) {
|
||||
const res = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed = text;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
/* a file */
|
||||
}
|
||||
return { status: res.status, headers: res.headers, body: parsed };
|
||||
}
|
||||
|
||||
const DEF = {
|
||||
name: "Weekly <b>&</b>",
|
||||
window_days: 7,
|
||||
frequency: "weekly",
|
||||
hour: 8,
|
||||
weekday: 1,
|
||||
tz_offset: -420,
|
||||
formats: ["html", "json"],
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('rep-model%', 'R', 1, 0, 0, 0)"
|
||||
).run();
|
||||
const day = new Date(Date.now() - 2 * 86_400_000).toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES ('rs', 'rs', 'completed', ?, ?, 'claude')"
|
||||
).run(day, day);
|
||||
stmts.setTokenUsage.run(
|
||||
"rs",
|
||||
"rep-model<x>",
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
2e6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES ('rs', 'PreToolUse', '<script>', ?)"
|
||||
).run(day);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("schedule math", () => {
|
||||
const at = (iso) => Date.parse(iso);
|
||||
it("finds the latest daily and weekly slot in local time", () => {
|
||||
const daily = { frequency: "daily", hour: 8, weekday: 1, tz_offset: -420 };
|
||||
// 2026-10-05 is a Monday. 08:00 at UTC+7 = 01:00Z.
|
||||
assert.equal(
|
||||
new Date(reports.lastSlot(daily, at("2026-10-05T02:00:00Z"))).toISOString(),
|
||||
"2026-10-05T01:00:00.000Z"
|
||||
);
|
||||
assert.equal(
|
||||
new Date(reports.lastSlot(daily, at("2026-10-05T00:30:00Z"))).toISOString(),
|
||||
"2026-10-04T01:00:00.000Z"
|
||||
);
|
||||
const weekly = { ...daily, frequency: "weekly", weekday: 5 }; // Friday
|
||||
assert.equal(
|
||||
new Date(reports.lastSlot(weekly, at("2026-10-05T02:00:00Z"))).toISOString(),
|
||||
"2026-10-02T01:00:00.000Z"
|
||||
);
|
||||
});
|
||||
it("covers the window_days full local days before the run day", () => {
|
||||
const w = reports.reportWindow({ window_days: 7, tz_offset: -420 }, at("2026-10-04T18:00:00Z"));
|
||||
assert.deepEqual(w, { from: "2026-09-28", to: "2026-10-04" }); // 01:00 on the 5th locally
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/reports", () => {
|
||||
let id;
|
||||
it("validates and creates a definition", async () => {
|
||||
for (const bad of [
|
||||
{ ...DEF, name: "" },
|
||||
{ ...DEF, window_days: 0 },
|
||||
{ ...DEF, frequency: "hourly" },
|
||||
{ ...DEF, hour: 24 },
|
||||
{ ...DEF, formats: ["pdf"] },
|
||||
{ ...DEF, formats: [] },
|
||||
{ ...DEF, tz_offset: 9999 },
|
||||
]) {
|
||||
assert.equal((await call("POST", "/api/reports", bad)).status, 400, JSON.stringify(bad));
|
||||
}
|
||||
const r = await call("POST", "/api/reports", DEF);
|
||||
assert.equal(r.status, 201);
|
||||
id = r.body.report.id;
|
||||
assert.deepEqual(r.body.report.formats, ["html", "json"]);
|
||||
assert.equal(r.body.report.enabled, true);
|
||||
assert.ok(r.body.report.last_slot, "the slot already past is claimed");
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await call("POST", "/api/reports", DEF, { origin: "https://evil.example" });
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("updates part of a definition", async () => {
|
||||
const r = await call("PUT", `/api/reports/${id}`, { enabled: false });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.report.enabled, false);
|
||||
assert.equal(r.body.report.name, DEF.name);
|
||||
assert.equal((await call("PUT", `/api/reports/${id}`, { hour: -1 })).status, 400);
|
||||
assert.equal((await call("PUT", "/api/reports/999", { enabled: true })).status, 404);
|
||||
});
|
||||
|
||||
it("runs now, writing escaped HTML and JSON, and serves them by id", async () => {
|
||||
const r = await call("POST", `/api/reports/${id}/run`);
|
||||
assert.equal(r.status, 200);
|
||||
const run = r.body.run;
|
||||
assert.equal(run.status, "ok", run.error);
|
||||
assert.deepEqual(run.files, ["html", "json"]);
|
||||
assert.equal(run.trigger, "manual");
|
||||
|
||||
const json = await call("GET", `/api/reports/${id}/runs/${run.id}/json`);
|
||||
assert.equal(json.status, 200);
|
||||
assert.equal(json.body.sessions.total, 1);
|
||||
assert.equal(json.body.cost.total, 2);
|
||||
assert.deepEqual(json.body.cost.by_model, [{ model: "rep-model<x>", cost: 2 }]);
|
||||
assert.deepEqual(json.body.top_tools, [{ tool_name: "<script>", count: 1 }]);
|
||||
|
||||
const html = await call("GET", `/api/reports/${id}/runs/${run.id}/html`);
|
||||
assert.equal(html.status, 200);
|
||||
assert.match(html.headers.get("content-disposition"), /attachment/);
|
||||
assert.ok(!html.body.includes("<script>"));
|
||||
assert.ok(html.body.includes("<script>"));
|
||||
assert.ok(html.body.includes("Weekly <b>&</b>"));
|
||||
assert.ok(fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
|
||||
|
||||
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id}/pdf`)).status, 404);
|
||||
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id + 9}/json`)).status, 404);
|
||||
assert.equal((await call("GET", `/api/reports/${id}/runs/..%2F..%2Fx/json`)).status, 404);
|
||||
|
||||
const list = await call("GET", "/api/reports");
|
||||
assert.equal(list.body.reports[0].last_run.id, run.id);
|
||||
const runs = await call("GET", `/api/reports/${id}/runs`);
|
||||
assert.equal(runs.body.runs.length, 1);
|
||||
});
|
||||
|
||||
it("deletes a definition with its runs and files", async () => {
|
||||
const r = await call("DELETE", `/api/reports/${id}`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 0);
|
||||
assert.ok(!fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
|
||||
assert.equal((await call("DELETE", `/api/reports/${id}`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduler", () => {
|
||||
it("runs a due report once per slot", async () => {
|
||||
const r = await call("POST", "/api/reports", { ...DEF, frequency: "daily", formats: ["json"] });
|
||||
const id = r.body.report.id;
|
||||
const now = Date.now();
|
||||
assert.equal(reports.tick(now), 0, "the slot claimed at creation is not run");
|
||||
const tomorrow = now + 86_400_000;
|
||||
assert.equal(reports.tick(tomorrow), 1);
|
||||
assert.equal(reports.tick(tomorrow), 0);
|
||||
const runs = db.prepare("SELECT * FROM report_runs WHERE report_id = ?").all(id);
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].trigger, "schedule");
|
||||
});
|
||||
|
||||
it("survives Clear Data", async () => {
|
||||
await call("POST", "/api/settings/clear-data");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_definitions").get().n, 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file Route tests for /api/snapshots: capture freezes a session (events
|
||||
* oldest first, stats, cost), redaction replaces paths before anything is
|
||||
* stored, rows are immutable, writes are same-origin only, and snapshots
|
||||
* survive Clear Data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-snapshots-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
const CWD = "/work/secret-project";
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, started_at, transcript_path) VALUES (?, ?, 'completed', ?, ?, ?)"
|
||||
).run("s1", "Snap me", CWD, "2026-10-01T10:00:00.000Z", `${CWD}/t.jsonl`);
|
||||
db.prepare(
|
||||
"INSERT INTO agents (id, session_id, name, type) VALUES ('a1', 's1', 'main', 'main')"
|
||||
).run();
|
||||
const ev = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES ('s1', 'a1', ?, ?, ?, ?, ?)"
|
||||
);
|
||||
ev.run("SessionStart", null, "start", JSON.stringify({ cwd: CWD }), "2026-10-01T10:00:00.000Z");
|
||||
ev.run(
|
||||
"PreToolUse",
|
||||
"Read",
|
||||
`Read ${CWD}/src/a.js`,
|
||||
JSON.stringify({
|
||||
cwd: CWD,
|
||||
tool_input: { file_path: `${CWD}/src/a.js` },
|
||||
command: `ls ${CWD}/src`,
|
||||
}),
|
||||
"2026-10-01T10:00:01.000Z"
|
||||
);
|
||||
ev.run("Stop", null, "stop", "{}", "2026-10-01T10:00:02.000Z");
|
||||
db.prepare(
|
||||
"INSERT INTO token_usage (session_id, model, input_tokens, output_tokens) VALUES ('s1', 'claude-sonnet-4-5', 1000000, 0)"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await new Promise((r) => server.close(r));
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("/api/snapshots", () => {
|
||||
let plain;
|
||||
let redacted;
|
||||
|
||||
it("freezes a session under an unguessable token", async () => {
|
||||
const r = await request("POST", "/api/snapshots", { session_id: "s1" });
|
||||
assert.equal(r.status, 201);
|
||||
plain = r.body.snapshot;
|
||||
assert.match(plain.token, /^[A-Za-z0-9_-]{32}$/);
|
||||
const got = await request("GET", `/api/snapshots/${plain.token}`);
|
||||
assert.equal(got.status, 200);
|
||||
const p = got.body.payload;
|
||||
assert.equal(p.session.id, "s1");
|
||||
assert.equal(p.session.cwd, CWD);
|
||||
assert.deepEqual(
|
||||
p.events.map((e) => e.event_type),
|
||||
["SessionStart", "PreToolUse", "Stop"]
|
||||
);
|
||||
assert.equal(p.agents.length, 1);
|
||||
assert.equal(p.stats.total_events, 3);
|
||||
assert.ok(p.cost.total_cost > 0);
|
||||
assert.equal(p.redacted.file_paths, false);
|
||||
});
|
||||
|
||||
it("redacts paths at capture", async () => {
|
||||
const r = await request("POST", "/api/snapshots", {
|
||||
session_id: "s1",
|
||||
redact: { file_paths: true },
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
redacted = r.body.snapshot;
|
||||
const stored = db
|
||||
.prepare("SELECT payload FROM session_snapshots WHERE id = ?")
|
||||
.get(redacted.id).payload;
|
||||
assert.ok(!stored.includes(CWD), "no raw cwd in the stored row");
|
||||
const p = JSON.parse(stored);
|
||||
assert.equal(p.session.cwd, "[redacted]");
|
||||
assert.equal(p.session.transcript_path, "[redacted]");
|
||||
const read = JSON.parse(p.events[1].data);
|
||||
assert.equal(read.tool_input.file_path, "[redacted]");
|
||||
assert.equal(read.command, "ls <cwd>/src");
|
||||
assert.equal(p.events[1].summary, "Read <cwd>/src/a.js");
|
||||
});
|
||||
|
||||
it("lists without payloads, newest first", async () => {
|
||||
const r = await request("GET", "/api/snapshots");
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.snapshots.map((s) => s.id),
|
||||
[redacted.id, plain.id]
|
||||
);
|
||||
const [first] = r.body.snapshots;
|
||||
assert.equal(first.session_name, "Snap me");
|
||||
assert.equal(first.redacted, true);
|
||||
assert.equal(first.event_count, 3);
|
||||
assert.equal(first.payload, undefined);
|
||||
});
|
||||
|
||||
it("refuses an update in the database", () => {
|
||||
assert.throws(
|
||||
() => db.prepare("UPDATE session_snapshots SET payload = '{}' WHERE id = ?").run(plain.id),
|
||||
/immutable/
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 400 / 404 on bad input", async () => {
|
||||
assert.equal((await request("POST", "/api/snapshots", {})).status, 400);
|
||||
assert.equal((await request("POST", "/api/snapshots", { session_id: "nope" })).status, 404);
|
||||
assert.equal((await request("GET", "/api/snapshots/short")).status, 404);
|
||||
assert.equal((await request("DELETE", "/api/snapshots/99999")).status, 404);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const h = { Origin: "https://evil.example" };
|
||||
assert.equal((await request("POST", "/api/snapshots", { session_id: "s1" }, h)).status, 403);
|
||||
assert.equal((await request("DELETE", `/api/snapshots/${plain.id}`, undefined, h)).status, 403);
|
||||
});
|
||||
|
||||
it("survives Clear Data, and deletes", async () => {
|
||||
assert.equal((await request("POST", "/api/settings/clear-data", {})).status, 200);
|
||||
const got = await request("GET", `/api/snapshots/${plain.token}`);
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.body.payload.session.id, "s1");
|
||||
assert.equal((await request("DELETE", `/api/snapshots/${plain.id}`)).status, 200);
|
||||
assert.equal((await request("GET", `/api/snapshots/${plain.token}`)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @file Tests for the Claude Code statusline (`statusline/statusline.js`): the
|
||||
* line formatter, today's cost from a fake dashboard (cache hit, cache of a
|
||||
* failure, the 1-second timeout), the script end to end on stdin, and
|
||||
* `agenthub statusline install|uninstall` against a temp HOME — never the
|
||||
* real ~/.claude/settings.json.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const { format, contextPercent, todayCost, localDate } = require(
|
||||
path.join(ROOT, "statusline", "statusline.js")
|
||||
);
|
||||
const { isOurs, statuslineCommand } = require(path.join(ROOT, "statusline", "install.js"));
|
||||
|
||||
const plain = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-statusline-test-"));
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
/** Spawn a node script asynchronously (a fake server may live in this process). */
|
||||
function run(args, { env = {}, input = "" } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, args, { env: { ...process.env, ...env } });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
child.on("close", (code) => resolve({ code, out, err }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
describe("format", () => {
|
||||
it("renders model · branch · context bar · session $ · today $", () => {
|
||||
const line = format(
|
||||
{
|
||||
model: { id: "claude-opus-5-5", display_name: "Opus 5.5" },
|
||||
context_window: { used_percentage: 34 },
|
||||
cost: { total_cost_usd: 1.234 },
|
||||
},
|
||||
{ branch: "main", today: 45.678 }
|
||||
);
|
||||
assert.equal(plain(line), "Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68");
|
||||
});
|
||||
|
||||
it("omits whatever is missing", () => {
|
||||
assert.equal(plain(format({ model: { display_name: "Sonnet" } }, { today: null })), "Sonnet");
|
||||
assert.equal(format({}), "");
|
||||
assert.equal(format(null), "");
|
||||
assert.equal(plain(format({ cost: { total_cost_usd: 0 } })), "$0.00");
|
||||
});
|
||||
|
||||
it("computes context % from usage when used_percentage is absent, clamped", () => {
|
||||
const ctx = {
|
||||
context_window_size: 200_000,
|
||||
current_usage: { input_tokens: 10_000, cache_read_input_tokens: 40_000, output_tokens: 999 },
|
||||
};
|
||||
assert.equal(contextPercent(ctx), 25);
|
||||
assert.equal(contextPercent({}), null);
|
||||
assert.match(plain(format({ context_window: { used_percentage: 140 } })), /██████████ 100%/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("todayCost", () => {
|
||||
let api;
|
||||
let url;
|
||||
let hits = 0;
|
||||
let slow = false;
|
||||
let lastUrl = "";
|
||||
before(async () => {
|
||||
api = http.createServer((req, res) => {
|
||||
hits++;
|
||||
lastUrl = req.url;
|
||||
if (slow) return; // never answers — the timeout must fire
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
daily_costs: [
|
||||
{ date: localDate(), cost: 3.5 },
|
||||
{ date: "2000-01-01", cost: 9 },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
await new Promise((r) => api.listen(0, "127.0.0.1", r));
|
||||
url = `http://127.0.0.1:${api.address().port}`;
|
||||
});
|
||||
after(() => {
|
||||
api.closeAllConnections();
|
||||
api.close();
|
||||
});
|
||||
|
||||
it("reads today's row and caches it", async () => {
|
||||
const file = path.join(TMP, "cache-a.json");
|
||||
assert.equal(await todayCost({ url, file }), 3.5);
|
||||
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}`);
|
||||
assert.equal(await todayCost({ url, file }), 3.5);
|
||||
assert.equal(hits, 1);
|
||||
// A different dashboard URL is a cache miss.
|
||||
await todayCost({ url: url.replace("127.0.0.1", "localhost"), file });
|
||||
assert.equal(hits, 2);
|
||||
});
|
||||
|
||||
it("gives up after 1 s and caches the miss", async () => {
|
||||
slow = true;
|
||||
hits = 0;
|
||||
const file = path.join(TMP, "cache-b.json");
|
||||
const t0 = Date.now();
|
||||
assert.equal(await todayCost({ url, file }), null);
|
||||
assert.ok(Date.now() - t0 < 2_000);
|
||||
assert.equal(await todayCost({ url, file }), null);
|
||||
assert.equal(hits, 1);
|
||||
slow = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusline.js on stdin", () => {
|
||||
it("prints one line, and nothing (exit 0) on garbage", async () => {
|
||||
const env = { AGENTHUB_URL: "http://127.0.0.1:9", TMPDIR: TMP };
|
||||
const ok = await run([path.join(ROOT, "statusline", "statusline.js")], {
|
||||
env,
|
||||
input: JSON.stringify({
|
||||
model: { display_name: "Opus" },
|
||||
cwd: ROOT,
|
||||
cost: { total_cost_usd: 2 },
|
||||
}),
|
||||
});
|
||||
assert.equal(ok.code, 0);
|
||||
assert.match(plain(ok.out), /^Opus · \S+ · \$2\.00\n$/);
|
||||
const bad = await run([path.join(ROOT, "statusline", "statusline.js")], {
|
||||
env,
|
||||
input: "{nope",
|
||||
});
|
||||
assert.equal(bad.code, 0);
|
||||
assert.equal(bad.out, "\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub statusline install|uninstall", () => {
|
||||
const HOME = path.join(TMP, "home");
|
||||
const settings = path.join(HOME, ".claude", "settings.json");
|
||||
const env = { HOME, CLAUDE_HOME: "", NO_COLOR: "1" };
|
||||
const cli = (...a) => run([path.join(ROOT, "bin", "agenthub.js"), "statusline", ...a], { env });
|
||||
const read = () => JSON.parse(fs.readFileSync(settings, "utf8"));
|
||||
const backups = () =>
|
||||
fs.readdirSync(path.dirname(settings)).filter((f) => f.includes("agenthub-backup"));
|
||||
|
||||
it("installs into a fresh HOME, then is idempotent", async () => {
|
||||
const r = await cli("install");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(read().statusLine.command, statuslineCommand());
|
||||
assert.ok(isOurs(read().statusLine));
|
||||
assert.equal(backups().length, 0); // nothing to back up
|
||||
const again = await cli("install");
|
||||
assert.match(again.out, /already set/);
|
||||
assert.equal(backups().length, 0);
|
||||
});
|
||||
|
||||
it("refuses a foreign statusLine without --force, keeps other keys, backs up", async () => {
|
||||
fs.writeFileSync(
|
||||
settings,
|
||||
JSON.stringify({ model: "opus", statusLine: { type: "command", command: "~/my-line.sh" } })
|
||||
);
|
||||
const refused = await cli("install");
|
||||
assert.equal(refused.code, 1);
|
||||
assert.match(refused.err, /not AgentHub's/);
|
||||
assert.equal(read().statusLine.command, "~/my-line.sh");
|
||||
assert.equal((await cli("uninstall")).code, 1);
|
||||
assert.equal(read().statusLine.command, "~/my-line.sh");
|
||||
|
||||
const forced = await cli("install", "--force");
|
||||
assert.equal(forced.code, 0, forced.err);
|
||||
assert.ok(isOurs(read().statusLine));
|
||||
assert.equal(read().model, "opus");
|
||||
const [bak] = backups();
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(path.dirname(settings), bak))).statusLine.command,
|
||||
"~/my-line.sh"
|
||||
);
|
||||
});
|
||||
|
||||
it("uninstalls its own entry only", async () => {
|
||||
const r = await cli("uninstall");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(read().statusLine, undefined);
|
||||
assert.equal(read().model, "opus");
|
||||
assert.match((await cli("uninstall")).out, /No statusLine/);
|
||||
});
|
||||
|
||||
it("uses the stable launcher for a plugin-cache install", () => {
|
||||
const home = path.join(TMP, "launcher-home");
|
||||
fs.mkdirSync(path.join(home, ".local", "bin"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".local", "bin", "agenthub"), "#!/bin/sh\n");
|
||||
const root = path.join(home, ".claude", "plugins", "cache", "agenthub", "agenthub", "1.0.0");
|
||||
const cmd = statuslineCommand(root, home);
|
||||
assert.equal(cmd, `"${path.join(home, ".local", "bin", "agenthub")}" statusline`);
|
||||
assert.ok(isOurs({ command: cmd }));
|
||||
assert.equal(isOurs({ command: "bash ~/statusline.sh" }), false);
|
||||
});
|
||||
});
|
||||
+119
-7
@@ -159,6 +159,10 @@ db.function(
|
||||
{ deterministic: true },
|
||||
require("./lib/event-payload").trimEventData
|
||||
);
|
||||
// ...and its summary through `redact_text(?)`, which is the identity unless the
|
||||
// user turned on secret redaction (`server/lib/privacy.js`; the payload's own
|
||||
// redaction is chained inside `trim_event_data`).
|
||||
db.function("redact_text", { deterministic: true }, require("./lib/privacy").redactSummary);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
@@ -344,12 +348,13 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dashboard_runs_session ON dashboard_runs(session_id);
|
||||
|
||||
-- Rules-based alerting engine. Rules are evaluated server-side: event-driven
|
||||
-- types (event_pattern, token_threshold) on hook ingest, time-based types
|
||||
-- (inactivity, status_duration) on a periodic sweep in server/lib/alerts.js.
|
||||
-- types (event_pattern, token_threshold, cost_threshold) on hook ingest,
|
||||
-- time-based types (inactivity, status_duration, budget) on a periodic
|
||||
-- sweep in server/lib/alerts.js.
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL CHECK(rule_type IN ('event_pattern','inactivity','status_duration','token_threshold')),
|
||||
rule_type TEXT NOT NULL, -- validated by validateRuleConfig (server/lib/alerts.js)
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cooldown_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
@@ -431,6 +436,55 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_created ON webhook_deliveries(created_at DESC);
|
||||
|
||||
-- Frozen copies of a session (server/routes/snapshots.js): {session, agents,
|
||||
-- events, stats, cost} as one JSON payload, read back by an unguessable
|
||||
-- token. Immutable once written (trigger below). session_id has no FK on
|
||||
-- purpose: a snapshot outlives its session and survives Clear Data, like
|
||||
-- alert_rules.
|
||||
CREATE TABLE IF NOT EXISTS session_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS session_snapshots_immutable
|
||||
BEFORE UPDATE ON session_snapshots
|
||||
BEGIN SELECT RAISE(ABORT, 'session snapshots are immutable'); END;
|
||||
|
||||
-- Scheduled reports (server/lib/reports.js): a definition says what window
|
||||
-- of days to summarise and when (local hour, and weekday for weekly, at the
|
||||
-- browser's tz_offset); last_slot is the scheduled instant last claimed,
|
||||
-- so two dashboards on one DB never both run it. Each run is logged in
|
||||
-- report_runs; its files live under <data dir>/reports/<report id>/<stamp>.
|
||||
-- Both survive Clear Data, like alert_rules and session_snapshots.
|
||||
CREATE TABLE IF NOT EXISTS report_definitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
window_days INTEGER NOT NULL DEFAULT 7,
|
||||
frequency TEXT NOT NULL DEFAULT 'weekly' CHECK(frequency IN ('daily','weekly')),
|
||||
hour INTEGER NOT NULL DEFAULT 8,
|
||||
weekday INTEGER NOT NULL DEFAULT 1,
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
formats TEXT NOT NULL DEFAULT '["html"]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_slot TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS report_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
trigger TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
window_from TEXT,
|
||||
window_to TEXT,
|
||||
stamp TEXT,
|
||||
files TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_runs_report ON report_runs(report_id, id);
|
||||
|
||||
-- Workflow-tool runs: fleets of sub-agents spawned by the Claude Code
|
||||
-- "Workflow" tool (and self-paced /loop). These emit NO hooks; the source of
|
||||
-- truth is the on-disk run journal (~/.claude/projects/<enc-cwd>/<sessionId>/
|
||||
@@ -1267,6 +1321,17 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
// Which Cursor agent transcripts have been read, at what mtime
|
||||
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cursor_transcripts (
|
||||
path TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Remote data sources: other machines whose Claude Code history this dashboard
|
||||
// pulls in over SSH. Config only — NO secrets are stored here: authentication
|
||||
// always defers to the host's own SSH stack (~/.ssh/config, ssh-agent, keys,
|
||||
@@ -1290,6 +1355,19 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
`);
|
||||
// Migrate: a source may also pull Codex history (`sync_codex`, opt-in) from
|
||||
// `remote_codex_home` (NULL = ~/.codex). Additive; existing sources stay
|
||||
// Claude-only.
|
||||
try {
|
||||
db.prepare("SELECT sync_codex FROM remote_sources LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE remote_sources ADD COLUMN sync_codex INTEGER NOT NULL DEFAULT 0").run();
|
||||
}
|
||||
try {
|
||||
db.prepare("SELECT remote_codex_home FROM remote_sources LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE remote_sources ADD COLUMN remote_codex_home TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate webhook_targets for first-class providers. Earlier installs created
|
||||
// the table with a 4-value `type` CHECK (slack/discord/teams/generic) and no
|
||||
@@ -1330,6 +1408,38 @@ db.exec(`
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate alert_rules off its 4-type `rule_type` CHECK (cost_threshold and
|
||||
// budget came later; `validateRuleConfig` is the one list now). Rebuilt with
|
||||
// foreign keys off, so alert_events — which references it with ON DELETE
|
||||
// CASCADE — keeps every row through the DROP.
|
||||
{
|
||||
const meta = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='alert_rules'")
|
||||
.get();
|
||||
if (meta?.sql?.includes("CHECK(rule_type IN")) {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
CREATE TABLE alert_rules_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cooldown_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
INSERT INTO alert_rules_new (id, name, rule_type, config, enabled, cooldown_seconds, created_at, updated_at)
|
||||
SELECT id, name, rule_type, config, enabled, cooldown_seconds, created_at, updated_at FROM alert_rules;
|
||||
DROP TABLE alert_rules;
|
||||
ALTER TABLE alert_rules_new RENAME TO alert_rules;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate: replace legacy idle/connected agent statuses with waiting/working
|
||||
// and update the CHECK constraint to the 4-status model.
|
||||
// SQLite doesn't support ALTER CHECK, so we detect the old constraint and
|
||||
@@ -1584,8 +1694,8 @@ const stmts = {
|
||||
),
|
||||
getRemoteSource: db.prepare("SELECT * FROM remote_sources WHERE id = ?"),
|
||||
insertRemoteSource: db.prepare(
|
||||
`INSERT INTO remote_sources (id, label, host, ssh_port, identity_file, remote_home, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO remote_sources (id, label, host, ssh_port, identity_file, remote_home, enabled, sync_codex, remote_codex_home)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
),
|
||||
updateRemoteSource: db.prepare(
|
||||
`UPDATE remote_sources SET
|
||||
@@ -1595,6 +1705,8 @@ const stmts = {
|
||||
identity_file = ?,
|
||||
remote_home = ?,
|
||||
enabled = COALESCE(?, enabled),
|
||||
sync_codex = COALESCE(?, sync_codex),
|
||||
remote_codex_home = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?`
|
||||
),
|
||||
@@ -1702,7 +1814,7 @@ const stmts = {
|
||||
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 seconds')`
|
||||
),
|
||||
insertEvent: db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
|
||||
),
|
||||
// Same insert with an EXPLICIT created_at, for events recovered from a
|
||||
// transcript: their real time is in the file, not the moment we happened to
|
||||
@@ -1711,7 +1823,7 @@ const stmts = {
|
||||
// re-parse re-insert the whole turn history (587k rows across this DB, 75%
|
||||
// of the events table, one session holding 68,747 copies of the same turns).
|
||||
insertEventAt: db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
|
||||
),
|
||||
listEvents: db.prepare("SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"),
|
||||
listEventsBySession: db.prepare(
|
||||
|
||||
@@ -84,6 +84,9 @@ const gitCredentialsRouter = require("./routes/git-credentials");
|
||||
const aiToolsRouter = require("./routes/ai-tools");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const budgetsRouter = require("./routes/budgets");
|
||||
const queryRouter = require("./routes/query");
|
||||
const reportsRouter = require("./routes/reports");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
const remoteSourcesRouter = require("./routes/remote-sources");
|
||||
const metricsRouter = require("./routes/metrics");
|
||||
@@ -92,6 +95,7 @@ const projectsRouter = require("./routes/projects");
|
||||
const laneSourcesRouter = require("./routes/lane-sources");
|
||||
const locksRouter = require("./routes/locks");
|
||||
const petsRouter = require("./routes/pets");
|
||||
const snapshotsRouter = require("./routes/snapshots");
|
||||
|
||||
const APP_VERSION = (() => {
|
||||
try {
|
||||
@@ -140,7 +144,11 @@ function createApp() {
|
||||
app.use("/api/projects", projectsRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/pets", petsRouter);
|
||||
app.use("/api/snapshots", snapshotsRouter);
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
app.use("/api/budgets", budgetsRouter);
|
||||
app.use("/api/query", queryRouter);
|
||||
app.use("/api/reports", reportsRouter);
|
||||
app.use("/api/webhooks", webhooksRouter);
|
||||
app.use("/api/remote-sources", remoteSourcesRouter);
|
||||
app.use("/api/metrics", metricsRouter);
|
||||
@@ -361,6 +369,9 @@ function startBackgroundServices() {
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled reports (Settings → Reports). Off with DASHBOARD_REPORTS_MS=0.
|
||||
require("./lib/reports").startReportScheduler();
|
||||
|
||||
// Rates for models seen in token_usage with no pricing row of their own.
|
||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||
// The AI Tools catalog of the newest release, when it is newer than this build.
|
||||
@@ -578,6 +589,12 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("antigravity usage poll failed to start:", err.message);
|
||||
}
|
||||
// Cursor sessions (no tokens: its transcripts carry none), from its agent transcripts.
|
||||
try {
|
||||
require("./lib/cursor-usage").startCursorUsagePoll(broadcast);
|
||||
} catch (err) {
|
||||
console.warn("cursor usage poll failed to start:", err.message);
|
||||
}
|
||||
// Codex/Antigravity hooks the user installed forward through a script whose
|
||||
// content names this build; point it here (the command in the tool's
|
||||
// hooks.json, which Codex trusts, stays the same).
|
||||
|
||||
+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}
|
||||
*/
|
||||
|
||||
@@ -141,7 +141,7 @@ const paths = {
|
||||
{
|
||||
name: "tool",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["claude", "codex", "antigravity"] },
|
||||
schema: { type: "string", enum: ["claude", "codex", "antigravity", "cursor"] },
|
||||
description:
|
||||
"Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.",
|
||||
example: "claude",
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ function createOpenApiSpec() {
|
||||
tool: {
|
||||
type: "string",
|
||||
description:
|
||||
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) or `antigravity` (agy's conversation files and its opt-in hooks).",
|
||||
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) `antigravity` (agy's conversation files and its opt-in hooks) or `cursor` (Cursor's agent transcripts — sessions only, no tokens).",
|
||||
},
|
||||
agent_count: { type: "integer", nullable: true },
|
||||
last_activity: { type: "string", format: "date-time", nullable: true },
|
||||
|
||||
+30
-1
@@ -9,7 +9,12 @@ const { Router } = require("express");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { stmts } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { RULE_TYPES, validateRuleConfig, invalidateRuleCache } = require("../lib/alerts");
|
||||
const {
|
||||
RULE_TYPES,
|
||||
validateRuleConfig,
|
||||
invalidateRuleCache,
|
||||
previewRule,
|
||||
} = require("../lib/alerts");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -61,6 +66,30 @@ router.post("/rules", (req, res) => {
|
||||
res.status(201).json({ rule: serializeRule(stmts.getAlertRule.get(id)) });
|
||||
});
|
||||
|
||||
// POST /api/alerts/rules/preview - What a rule WOULD have fired over the last
|
||||
// `days` (1–90, default 7) of recorded history. Read-only: nothing is saved,
|
||||
// fired or broadcast.
|
||||
router.post("/rules/preview", (req, res) => {
|
||||
const { rule_type, config, days, cooldown_seconds } = req.body || {};
|
||||
const validated = validateRuleConfig(rule_type, config);
|
||||
if (!validated.ok) {
|
||||
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
|
||||
}
|
||||
const window = days == null ? 7 : Number(days);
|
||||
if (!Number.isInteger(window) || window < 1 || window > 90) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "days must be an integer in 1..90" },
|
||||
});
|
||||
}
|
||||
const cooldown = cooldown_seconds == null ? 300 : cooldown_seconds;
|
||||
if (!Number.isInteger(cooldown) || cooldown < 0) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
|
||||
});
|
||||
}
|
||||
res.json(previewRule(rule_type, validated.config, { days: window, cooldownSeconds: cooldown }));
|
||||
});
|
||||
|
||||
// PATCH /api/alerts/rules/:id - Update an alert rule (partial)
|
||||
router.patch("/rules/:id", (req, res) => {
|
||||
const existing = stmts.getAlertRule.get(req.params.id);
|
||||
|
||||
+79
-53
@@ -1,77 +1,88 @@
|
||||
/**
|
||||
* @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more. It queries the database for various metrics and returns them in a structured JSON format for frontend consumption.
|
||||
* @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more, narrowed by data scope, AI tool and an optional `from`/`to` date range. `computeAnalytics` is the same bundle for scheduled reports.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources } = require("../lib/source-filter");
|
||||
const {
|
||||
parseScope,
|
||||
parseTool,
|
||||
BAD_TOOL,
|
||||
parseRange,
|
||||
inRange,
|
||||
BAD_RANGE,
|
||||
} = require("../lib/source-filter");
|
||||
const scoped = require("../lib/scoped-stats");
|
||||
|
||||
const { calculateCost } = require("./pricing");
|
||||
const { calculateCost, dailyTokenRows, costWhere } = require("./pricing");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
|
||||
// Negate it to get the SQLite modifier: 420 → '-420 minutes'
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
// Every session, for the scoped path when only a date range narrows.
|
||||
const ALL = { where: "1=1", params: [] };
|
||||
const USAGE_TOKEN_COLS = {
|
||||
total_input: "input_tokens",
|
||||
total_output: "output_tokens",
|
||||
total_cache_read: "cache_read_tokens",
|
||||
total_cache_write: "cache_write_tokens",
|
||||
};
|
||||
|
||||
// Data-scope: restrict every metric to a subset of source machines when the
|
||||
// user has chosen one; otherwise use the cached prepared statements.
|
||||
const sources = parseSources(req);
|
||||
const tokenTotals = sources ? scoped.tokenTotals(db, sources) : stmts.getTokenTotals.get();
|
||||
const toolUsage = sources ? scoped.toolUsageCounts(db, sources) : stmts.toolUsageCounts.all();
|
||||
const dailyEvents = sources
|
||||
? scoped.dailyEventCounts(db, sources, tzModifier)
|
||||
/**
|
||||
* The analytics bundle for a data scope (`parseScope`'s predicate or null) and
|
||||
* a date range (`parseRange`'s result or null). Also used by scheduled reports.
|
||||
* With a range, counts follow it and tokens/cost come from the cost rows of the
|
||||
* days in it (`token_usage` has no date); the daily charts keep 365 days.
|
||||
*/
|
||||
function computeAnalytics({ scope = null, range = null, tzModifier = "+0 minutes" } = {}) {
|
||||
const sc = scope || (range ? ALL : null);
|
||||
const toolUsage = sc ? scoped.toolUsageCounts(db, sc, range) : stmts.toolUsageCounts.all();
|
||||
const dailyEvents = sc
|
||||
? scoped.dailyEventCounts(db, sc, tzModifier)
|
||||
: stmts.dailyEventCounts.all(tzModifier);
|
||||
const dailySessions = sources
|
||||
? scoped.dailySessionCounts(db, sources, tzModifier)
|
||||
const dailySessions = sc
|
||||
? scoped.dailySessionCounts(db, sc, tzModifier)
|
||||
: stmts.dailySessionCounts.all(tzModifier);
|
||||
const agentTypes = sources
|
||||
? scoped.agentTypeDistribution(db, sources)
|
||||
const agentTypes = sc
|
||||
? scoped.agentTypeDistribution(db, sc, range)
|
||||
: stmts.agentTypeDistribution.all();
|
||||
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
|
||||
const agentsByStatus = sources
|
||||
? scoped.agentStatusCounts(db, sources)
|
||||
const overview = sc ? scoped.statsOverview(db, sc, range) : stmts.stats.get();
|
||||
const agentsByStatus = sc
|
||||
? scoped.agentStatusCounts(db, sc, range)
|
||||
: stmts.agentStatusCounts.all();
|
||||
const sessionsByStatus = sources
|
||||
? scoped.sessionStatusCounts(db, sources)
|
||||
const sessionsByStatus = sc
|
||||
? scoped.sessionStatusCounts(db, sc, range)
|
||||
: stmts.sessionStatusCounts.all();
|
||||
const totalSubagents = sources
|
||||
? scoped.totalSubagentCount(db, sources)
|
||||
const totalSubagents = sc
|
||||
? scoped.totalSubagentCount(db, sc, range)
|
||||
: stmts.totalSubagentCount.get();
|
||||
const eventTypes = sources ? scoped.eventTypeCounts(db, sources) : stmts.eventTypeCounts.all();
|
||||
const avgEvents = sources
|
||||
? scoped.avgEventsPerSession(db, sources)
|
||||
const eventTypes = sc ? scoped.eventTypeCounts(db, sc, range) : stmts.eventTypeCounts.all();
|
||||
const avgEvents = sc
|
||||
? scoped.avgEventsPerSession(db, sc, range)
|
||||
: stmts.avgEventsPerSession.get();
|
||||
|
||||
// Calculate total cost across all sessions
|
||||
const pricingRules = stmts.listPricing.all();
|
||||
// Join the owning session's start date so each bucket is priced at the rate
|
||||
// effective when it was used (date-effective promo rates, e.g. Sonnet 5 intro).
|
||||
const allTokenUsage = sources
|
||||
? scoped.scopedTokenUsageWithDate(db, sources)
|
||||
: db
|
||||
.prepare(
|
||||
"SELECT tu.*, DATE(s.started_at) as date FROM token_usage tu JOIN sessions s ON s.id = tu.session_id"
|
||||
)
|
||||
.all();
|
||||
|
||||
let totalCost = 0;
|
||||
for (const usage of allTokenUsage) {
|
||||
const { total_cost } = calculateCost([usage], pricingRules);
|
||||
totalCost += total_cost;
|
||||
// Total cost exactly as GET /api/pricing/cost computes it (baselines, the
|
||||
// per-day rate, the same scope and range), so the two numbers always agree.
|
||||
// A session that started after the range cannot have spent in it.
|
||||
const extra = range?.end ? [{ clause: "s.started_at < ?", params: [range.end] }] : [];
|
||||
const where = costWhere({ scope, extra });
|
||||
const rows = dailyTokenRows(tzModifier, where.whereClause, where.params).filter((r) =>
|
||||
inRange(range, r.date)
|
||||
);
|
||||
const totalCost = calculateCost(rows, stmts.listPricing.all()).total_cost;
|
||||
let tokens;
|
||||
if (range) {
|
||||
tokens = {};
|
||||
for (const [k, c] of Object.entries(USAGE_TOKEN_COLS)) {
|
||||
tokens[k] = Math.round(rows.reduce((n, r) => n + r[c], 0));
|
||||
}
|
||||
} else {
|
||||
const t = scope ? scoped.tokenTotals(db, scope) : stmts.getTokenTotals.get();
|
||||
tokens = Object.fromEntries(Object.keys(USAGE_TOKEN_COLS).map((k) => [k, t?.[k] ?? 0]));
|
||||
}
|
||||
|
||||
res.json({
|
||||
tokens: {
|
||||
total_input: tokenTotals?.total_input ?? 0,
|
||||
total_output: tokenTotals?.total_output ?? 0,
|
||||
total_cache_read: tokenTotals?.total_cache_read ?? 0,
|
||||
total_cache_write: tokenTotals?.total_cache_write ?? 0,
|
||||
},
|
||||
return {
|
||||
tokens,
|
||||
total_cost: totalCost,
|
||||
tool_usage: toolUsage,
|
||||
daily_events: dailyEvents,
|
||||
@@ -83,7 +94,22 @@ router.get("/", (req, res) => {
|
||||
overview,
|
||||
agents_by_status: Object.fromEntries(agentsByStatus.map((r) => [r.status, r.count])),
|
||||
sessions_by_status: Object.fromEntries(sessionsByStatus.map((r) => [r.status, r.count])),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
|
||||
// Negate it to get the SQLite modifier: 420 → '-420 minutes'
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
|
||||
// Data-scope: restrict every metric to a subset of source machines and/or one
|
||||
// AI tool when the user has chosen one, and to `?from=&to=` (local dates).
|
||||
if (parseTool(req) === false) return res.status(400).json(BAD_TOOL);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return res.status(400).json(BAD_RANGE);
|
||||
res.json(computeAnalytics({ scope: parseScope(req), range, tzModifier }));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.computeAnalytics = computeAnalytics;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file Express router for spend budgets. A budget is an alert rule of type
|
||||
* `budget` (created, edited and deleted through /api/alerts/rules); this router
|
||||
* only reads: `GET /api/budgets/status` lists every enabled budget with its
|
||||
* current-period spend, percent of the limit, projected end-of-period spend and
|
||||
* the thresholds reached (server/lib/budgets.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { budgetStatus } = require("../lib/budgets");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/budgets/status
|
||||
router.get("/status", (_req, res) => {
|
||||
res.json({ budgets: budgetStatus() });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+142
-17
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
|
||||
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,8 @@ const { getPipeline } = require("../lib/pipelines");
|
||||
const path = require("path");
|
||||
const { threadOf } = require("../lib/codex-usage");
|
||||
const { agyCliDirs } = require("../lib/antigravity-home");
|
||||
const { isIgnoredCwd } = require("../lib/privacy");
|
||||
const { checkPushToken, pushTokenStatus } = require("../lib/push-token");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -1335,23 +1337,60 @@ function bindRunSession(runId, sessionId, hookType, source, tool) {
|
||||
if (row.lane_id != null) stmts.claimSessionLane.run(row.lane_id, sessionId);
|
||||
}
|
||||
|
||||
router.post("/event", (req, res) => {
|
||||
let { hook_type, data } = req.body;
|
||||
// A pushed hook's transcript paths name files on the SENDER's machine; read
|
||||
// here they would be this machine's files, so they never get that far.
|
||||
const PUSH_STRIPPED = ["transcript_path", "agent_transcript_path", "transcriptPath"];
|
||||
|
||||
/**
|
||||
* One hook through ingestion, shared by `POST /event` (this machine's CLIs)
|
||||
* and `POST /ingest-batch` (another machine's, `pushSource` = its
|
||||
* `sessions.source` tag). Answers `{status, body}` for the response, plus the
|
||||
* stored event and the (mapped) hook when one was stored. A pushed hook never
|
||||
* touches a session another source owns, a lane or a dashboard pane.
|
||||
*/
|
||||
function ingestHook(body, pushSource = null) {
|
||||
let { hook_type, data } = body || {};
|
||||
if (!hook_type || !data) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
|
||||
});
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: { code: "INVALID_INPUT", message: "hook_type and data are required" } },
|
||||
};
|
||||
}
|
||||
if (pushSource) {
|
||||
data = { ...data };
|
||||
for (const k of PUSH_STRIPPED) delete data[k];
|
||||
}
|
||||
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, req.body.tool) ? req.body.tool : "claude";
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return res.json({ ok: true, ignored: true });
|
||||
if (!data || typeof data !== "object") return ignored;
|
||||
const mapped = HOOK_TOOLS[tool](hook_type, data);
|
||||
if (!mapped) return res.json({ ok: true, ignored: true });
|
||||
if (!mapped) return ignored;
|
||||
({ hookType: hook_type, data } = mapped);
|
||||
}
|
||||
// A folder the user asked AgentHub to ignore (Settings → Privacy): dropped
|
||||
// whole, before anything is stored.
|
||||
if (isIgnoredCwd(data.cwd)) return ignored;
|
||||
|
||||
// A pushed session id is the sender's to choose: it may only extend a
|
||||
// session its own source created, never one of this machine's or another's.
|
||||
let pushedNew = false;
|
||||
if (pushSource) {
|
||||
const owner =
|
||||
typeof data.session_id === "string" && data.session_id
|
||||
? stmts.getSession.get(data.session_id)
|
||||
: null;
|
||||
if (owner && owner.source !== pushSource) {
|
||||
return {
|
||||
status: 409,
|
||||
body: { error: { code: "EFOREIGN", message: "session belongs to another source" } },
|
||||
};
|
||||
}
|
||||
pushedNew = !owner;
|
||||
}
|
||||
|
||||
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
|
||||
// + a leftover checkout install, two marketplaces), or two servers on one DB
|
||||
@@ -1364,7 +1403,7 @@ router.post("/event", (req, res) => {
|
||||
data.session_id &&
|
||||
stmts.findRecentDuplicateEvent.get(data.session_id, hook_type, JSON.stringify(data))
|
||||
) {
|
||||
return res.json({ ok: true, duplicate: true });
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
|
||||
@@ -1377,16 +1416,32 @@ router.post("/event", (req, res) => {
|
||||
|
||||
const result = processEvent(hook_type, data, tool);
|
||||
if (!result) {
|
||||
return res.status(400).json({
|
||||
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
|
||||
});
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: { code: "MISSING_SESSION", message: "session_id is required in data" } },
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
||||
touchLaneFromHook(hook_type, data, tool);
|
||||
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
if (pushSource) {
|
||||
// Tagged in the same tick processEvent created it, so no local liveness
|
||||
// sweep ever sees it as this machine's.
|
||||
if (pushedNew) {
|
||||
stmts.setSessionSource.run(pushSource, data.session_id);
|
||||
broadcast("session_updated", stmts.getSession.get(data.session_id));
|
||||
}
|
||||
} else {
|
||||
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
||||
touchLaneFromHook(hook_type, data, tool);
|
||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
}
|
||||
return { status: 200, body: { ok: true, event: result }, result, hook_type, data, tool };
|
||||
}
|
||||
|
||||
res.json({ ok: true, event: result });
|
||||
router.post("/event", (req, res) => {
|
||||
const out = ingestHook(req.body);
|
||||
res.status(out.status).json(out.body);
|
||||
if (!out.result) return;
|
||||
const { result, hook_type, data, tool } = out;
|
||||
|
||||
// Evaluate event-driven alert rules after the ingest transaction committed
|
||||
// and the response is on its way — alerting must never slow down or fail
|
||||
@@ -1483,6 +1538,76 @@ router.post("/event", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/hooks/ingest-batch ────────────────────────────────────────────
|
||||
// Hooks from a machine this dashboard cannot reach over SSH, pushed with the
|
||||
// bearer token Settings → Data → Push ingest makes (server/lib/push-token.js).
|
||||
// Off — 503 — until a token exists. Body: {source, events: [{hook_type, data,
|
||||
// tool?}]}; each event goes through ingestHook exactly as POST /event would,
|
||||
// with its session tagged `push:<source>`. Under /hooks, so DASHBOARD_TOKEN
|
||||
// does not gate it: this token does. The global 1 MB JSON limit bounds a batch.
|
||||
const MAX_PUSH_EVENTS = 500;
|
||||
const PUSH_SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const HOOK_TYPE_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
||||
|
||||
router.post("/ingest-batch", (req, res) => {
|
||||
const auth = req.headers.authorization;
|
||||
const given = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
if (!checkPushToken(given)) {
|
||||
if (!pushTokenStatus().enabled) {
|
||||
return res.status(503).json({
|
||||
error: { code: "EPUSHDISABLED", message: "push ingest is off: create a token in Settings" },
|
||||
});
|
||||
}
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: { code: "EUNAUTHORIZED", message: "missing or invalid push token" } });
|
||||
}
|
||||
const { source, events } = req.body || {};
|
||||
if (typeof source !== "string" || !PUSH_SOURCE_RE.test(source)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSOURCE", message: "source must be 1-64 letters, digits, . _ -" },
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_PUSH_EVENTS) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADBATCH", message: `events must be 1-${MAX_PUSH_EVENTS} hook payloads` },
|
||||
});
|
||||
}
|
||||
const tag = `push:${source}`;
|
||||
const stored = [];
|
||||
const results = events.map((ev) => {
|
||||
if (
|
||||
!ev ||
|
||||
typeof ev.hook_type !== "string" ||
|
||||
!HOOK_TYPE_RE.test(ev.hook_type) ||
|
||||
!ev.data ||
|
||||
typeof ev.data !== "object" ||
|
||||
Array.isArray(ev.data)
|
||||
) {
|
||||
return { ok: false, error: "INVALID_INPUT" };
|
||||
}
|
||||
try {
|
||||
const out = ingestHook({ hook_type: ev.hook_type, data: ev.data, tool: ev.tool }, tag);
|
||||
if (out.result) {
|
||||
stored.push(out.result);
|
||||
return { ok: true };
|
||||
}
|
||||
return out.status === 200 ? out.body : { ok: false, error: out.body.error.code };
|
||||
} catch (err) {
|
||||
console.warn("[hooks] ingest-batch event failed:", err.message);
|
||||
return { ok: false, error: "EINGEST" };
|
||||
}
|
||||
});
|
||||
res.json({ ok: true, source: tag, stored: stored.length, results });
|
||||
for (const result of stored) {
|
||||
try {
|
||||
evaluateEvent(result);
|
||||
} catch {
|
||||
/* non-fatal, as on POST /event */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Watchdog: detect API errors in active sessions ─────────────────────────
|
||||
// Claude CLI doesn't fire a hook after API errors (401, rate limit, etc.) —
|
||||
// the session just sits there with the error in the transcript but no Stop
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { calculateCost, dailyTokenRows } = require("./pricing");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -192,6 +193,34 @@ router.get("/", (_req, res) => {
|
||||
]
|
||||
);
|
||||
|
||||
// Cumulative cost in USD by AI tool and model, priced exactly as
|
||||
// GET /api/pricing/cost prices it (the per-day rate). The code-execution
|
||||
// container time, billed once per org, has no model and is not included.
|
||||
const rules = stmts.listPricing.all();
|
||||
const rowsByTool = new Map();
|
||||
for (const r of dailyTokenRows("+0 minutes", "", [])) {
|
||||
const tool = r.tool || "claude";
|
||||
if (!rowsByTool.has(tool)) rowsByTool.set(tool, []);
|
||||
rowsByTool.get(tool).push(r);
|
||||
}
|
||||
const costSamples = [];
|
||||
for (const [tool, rows] of rowsByTool) {
|
||||
const byModel = new Map();
|
||||
for (const b of calculateCost(rows, rules).breakdown) {
|
||||
byModel.set(b.model, (byModel.get(b.model) || 0) + b.cost);
|
||||
}
|
||||
for (const [model, value] of byModel) {
|
||||
costSamples.push({ labels: { tool, model }, value: Math.round(value * 10000) / 10000 });
|
||||
}
|
||||
}
|
||||
appendMetric(
|
||||
out,
|
||||
"agenthub_cost_usd_total",
|
||||
"Cumulative token cost in USD across all sessions, by AI tool and model.",
|
||||
"counter",
|
||||
costSamples
|
||||
);
|
||||
|
||||
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
|
||||
res.send(out.join("\n") + "\n");
|
||||
});
|
||||
|
||||
+116
-40
@@ -5,7 +5,14 @@
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const {
|
||||
parseScope,
|
||||
parseTool,
|
||||
BAD_TOOL,
|
||||
parseRange,
|
||||
inRange,
|
||||
BAD_RANGE,
|
||||
} = require("../lib/source-filter");
|
||||
const { quotas } = require("../lib/quota");
|
||||
const {
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
@@ -26,7 +33,7 @@ const round4 = (n) => Math.round(n * 10000) / 10000;
|
||||
* - Fast mode: premium input/output rates; cache rates scale with the fast
|
||||
* input base (the standard caching multipliers ride on top of fast pricing).
|
||||
* - Data residency "us": 1.1x across every category.
|
||||
* - Batch tier: 50% off across every category.
|
||||
* - Batch tier (and OpenAI's flex tier): 50% off across every category.
|
||||
* Older buckets default to speed=standard / geo=global / tier=standard, so they
|
||||
* resolve to exactly the standard rates — historical sessions price unchanged.
|
||||
*/
|
||||
@@ -65,7 +72,7 @@ function ratesForBucket(rule, row, asOf) {
|
||||
r5m *= m;
|
||||
r1h *= m;
|
||||
}
|
||||
if (row.service_tier === "batch") {
|
||||
if (row.service_tier === "batch" || row.service_tier === "flex") {
|
||||
const m = BATCH_DISCOUNT_MULTIPLIER;
|
||||
rIn *= m;
|
||||
rOut *= m;
|
||||
@@ -241,12 +248,13 @@ const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
|
||||
* rows fall on; whatever the total holds beyond them (sessions recorded before
|
||||
* that table, sub-agent files, Codex/Antigravity) goes to the session's start
|
||||
* date, as it always did. Hours above the total (never expected) are scaled
|
||||
* down to it, so the days always add up to the total.
|
||||
* down to it, so the days always add up to the total. Each row also carries
|
||||
* its session's `tool`, for the per-tool split.
|
||||
*/
|
||||
function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
const totals = db
|
||||
.prepare(
|
||||
`SELECT tu.session_id, DATE(s.started_at, ?) as date,
|
||||
`SELECT tu.session_id, DATE(s.started_at, ?) as date, s.tool,
|
||||
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
|
||||
tu.input_tokens + tu.baseline_input as input_tokens,
|
||||
tu.output_tokens + tu.baseline_output as output_tokens,
|
||||
@@ -283,10 +291,14 @@ function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
|
||||
const out = new Map();
|
||||
const add = (date, bucket, values) => {
|
||||
const k = [date, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
|
||||
const k = [date, bucket.tool, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
|
||||
let row = out.get(k);
|
||||
if (!row) {
|
||||
row = { date, ...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])) };
|
||||
row = {
|
||||
date,
|
||||
tool: bucket.tool,
|
||||
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
|
||||
};
|
||||
for (const c of USAGE_COLS) row[c] = 0;
|
||||
out.set(k, row);
|
||||
}
|
||||
@@ -464,49 +476,110 @@ router.delete("/:pattern", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost - Get total cost across all sessions (`?lane=<id>`: the
|
||||
// sessions that workspace owns).
|
||||
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
|
||||
// origin machines (see server/lib/source-filter.js), matching the sessions /
|
||||
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
|
||||
/**
|
||||
* The `WHERE` over `token_usage tu JOIN sessions s` for a session predicate
|
||||
* (`parseScope`'s `{where, params}`, or null), a workspace id (the sessions it
|
||||
* owns — the rule of GET /api/sessions?lane=) and `extra` clauses on `s`.
|
||||
*/
|
||||
function costWhere({ scope = null, lane, extra = [] } = {}) {
|
||||
const params = scope ? [...scope.params] : [];
|
||||
const where = scope ? [`s.id IN (SELECT id FROM sessions WHERE ${scope.where})`] : [];
|
||||
if (lane !== undefined && lane !== null) {
|
||||
where.push(
|
||||
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
for (const e of extra) {
|
||||
where.push(e.clause);
|
||||
params.push(...e.params);
|
||||
}
|
||||
return { whereClause: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
|
||||
}
|
||||
|
||||
/**
|
||||
* `costWhere` for a cost request (`?sources=`, `?tool=`, `?lane=`). Null when
|
||||
* the request is malformed (the 400 has already been sent).
|
||||
*/
|
||||
function costScope(req, res) {
|
||||
const lane = req.query.lane;
|
||||
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
|
||||
res.status(400).json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||
return null;
|
||||
}
|
||||
if (parseTool(req) === false) {
|
||||
res.status(400).json(BAD_TOOL);
|
||||
return null;
|
||||
}
|
||||
return costWhere({ scope: parseScope(req), lane });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost per AI tool (`sessions.tool`), most expensive first. Each tool is priced
|
||||
* on its own rows, so the code-execution free allowance applies per tool and
|
||||
* the parts can sum to slightly less than `total_cost` when it is exceeded.
|
||||
*/
|
||||
function costByTool(dailyTokens, rules) {
|
||||
const byTool = new Map();
|
||||
for (const r of dailyTokens) {
|
||||
const t = r.tool || "claude";
|
||||
if (!byTool.has(t)) byTool.set(t, []);
|
||||
byTool.get(t).push(r);
|
||||
}
|
||||
return [...byTool.entries()]
|
||||
.map(([tool, rows]) => ({ tool, cost: calculateCost(rows, rules).total_cost }))
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cost response for a `costWhere` result and a date range (`parseRange`,
|
||||
* or null). The range narrows the total, breakdown and `by_tool` to the local
|
||||
* days in it; `daily_costs` stays the whole series, so the trend charts and
|
||||
* the spend pace keep their own windows. Also used by scheduled reports.
|
||||
*/
|
||||
function computeCost(
|
||||
{ whereClause = "", params = [] } = {},
|
||||
range = null,
|
||||
tzModifier = "+0 minutes"
|
||||
) {
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
const inside = range ? dailyTokens.filter((r) => inRange(range, r.date)) : dailyTokens;
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
// Coverage equals the undated aggregate — token_usage cascades with sessions,
|
||||
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
|
||||
const result = calculateCost(inside, rules);
|
||||
const daily_costs = calculateDailyCosts(dailyTokens, rules);
|
||||
return { ...result, daily_costs, by_tool: costByTool(inside, rules) };
|
||||
}
|
||||
|
||||
// GET /api/pricing/cost - Get total cost across all sessions.
|
||||
// Honors the global data-scope — `?sources=<csv>` (origin machines, see
|
||||
// server/lib/source-filter.js) and `?tool=<id>` — matching the sessions / stats
|
||||
// / analytics endpoints so the Dashboard "total cost" tracks the selector, plus
|
||||
// `?lane=<id>` (the sessions that workspace owns), `?from=&to=` (local
|
||||
// dates, see computeCost) and `?date=` (one local day, its `daily_costs` too —
|
||||
// the status bar's Today). `by_tool` splits the total.
|
||||
router.get("/cost", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
const lane = req.query.lane;
|
||||
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||
}
|
||||
const date = req.query.date;
|
||||
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
|
||||
}
|
||||
const scope = sourceColumnClause(parseSources(req), "s.source");
|
||||
const params = [...scope.params];
|
||||
const where = scope.clause ? [scope.clause] : [];
|
||||
if (lane !== undefined) {
|
||||
// The sessions the workspace owns — the same rule as GET /api/sessions?lane=.
|
||||
where.push(
|
||||
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
|
||||
let dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return res.status(400).json(BAD_RANGE);
|
||||
const scope = costScope(req, res);
|
||||
if (!scope) return;
|
||||
// `date=`: only that local day (the status bar's Today and its per-model list).
|
||||
if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date);
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
// Coverage equals the undated aggregate — token_usage cascades with sessions,
|
||||
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
|
||||
const result = calculateCost(dailyTokens, rules);
|
||||
const daily_costs = calculateDailyCosts(dailyTokens, rules);
|
||||
res.json({ ...result, daily_costs });
|
||||
if (date !== undefined) {
|
||||
const day = computeCost(scope, { from: date, to: date }, tzModifier);
|
||||
return res.json({ ...day, daily_costs: day.daily_costs.filter((d) => d.date === date) });
|
||||
}
|
||||
res.json(computeCost(scope, range, tzModifier));
|
||||
});
|
||||
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
@@ -568,5 +641,8 @@ function attachAgentCosts(agents) {
|
||||
|
||||
module.exports = router;
|
||||
module.exports.calculateCost = calculateCost;
|
||||
module.exports.dailyTokenRows = dailyTokenRows;
|
||||
module.exports.costWhere = costWhere;
|
||||
module.exports.computeCost = computeCost;
|
||||
module.exports.agentOwnCost = agentOwnCost;
|
||||
module.exports.attachAgentCosts = attachAgentCosts;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @file Query Explorer: `GET /api/query?entity=sessions|events|agents` filters
|
||||
* one table (free text, AI tool, status, local date range, cwd, data scope),
|
||||
* sorts it by a column from a per-entity ALLOWLIST, and answers one page as
|
||||
* JSON — or, with `format=csv|json`, the whole filtered set (capped at
|
||||
* `EXPORT_CAP` rows) as a download. No column, direction or table name is ever
|
||||
* taken from the request into SQL; every value is a bound parameter.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { db } = require("../db");
|
||||
const { parseScope, parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const EXPORT_CAP = 50_000;
|
||||
const PAGE_MAX = 500;
|
||||
|
||||
/**
|
||||
* Per entity: the FROM (always joined to its session as `s`), the selected
|
||||
* columns (also the CSV header), the sortable ones mapped to SQL, the timestamp
|
||||
* a date range bounds, the free-text columns, and whether it has a status.
|
||||
*/
|
||||
const ENTITIES = {
|
||||
sessions: {
|
||||
from: "sessions s",
|
||||
columns: {
|
||||
id: "s.id",
|
||||
name: "s.name",
|
||||
status: "s.status",
|
||||
tool: "s.tool",
|
||||
model: "s.model",
|
||||
cwd: "s.cwd",
|
||||
started_at: "s.started_at",
|
||||
ended_at: "s.ended_at",
|
||||
},
|
||||
sort: ["started_at", "ended_at", "name", "status", "tool", "model", "cwd"],
|
||||
time: "s.started_at",
|
||||
text: ["s.id", "s.name", "s.cwd"],
|
||||
status: "s.status",
|
||||
statuses: ["active", "completed", "error", "abandoned"],
|
||||
tiebreak: "s.id",
|
||||
},
|
||||
events: {
|
||||
from: "events e JOIN sessions s ON s.id = e.session_id",
|
||||
columns: {
|
||||
id: "e.id",
|
||||
session_id: "e.session_id",
|
||||
agent_id: "e.agent_id",
|
||||
event_type: "e.event_type",
|
||||
tool_name: "e.tool_name",
|
||||
summary: "e.summary",
|
||||
tool: "s.tool",
|
||||
cwd: "s.cwd",
|
||||
created_at: "e.created_at",
|
||||
},
|
||||
sort: ["created_at", "event_type", "tool_name", "session_id", "tool"],
|
||||
time: "e.created_at",
|
||||
text: ["e.summary", "e.event_type", "e.tool_name", "e.session_id"],
|
||||
status: null,
|
||||
statuses: [],
|
||||
tiebreak: "e.id",
|
||||
},
|
||||
agents: {
|
||||
from: "agents a JOIN sessions s ON s.id = a.session_id",
|
||||
columns: {
|
||||
id: "a.id",
|
||||
session_id: "a.session_id",
|
||||
name: "a.name",
|
||||
type: "a.type",
|
||||
subagent_type: "a.subagent_type",
|
||||
status: "a.status",
|
||||
task: "a.task",
|
||||
tool: "s.tool",
|
||||
cwd: "s.cwd",
|
||||
started_at: "a.started_at",
|
||||
ended_at: "a.ended_at",
|
||||
},
|
||||
sort: ["started_at", "ended_at", "name", "type", "subagent_type", "status", "tool"],
|
||||
time: "a.started_at",
|
||||
text: ["a.id", "a.name", "a.task", "a.subagent_type"],
|
||||
status: "a.status",
|
||||
statuses: ["working", "waiting", "completed", "error"],
|
||||
tiebreak: "a.id",
|
||||
},
|
||||
};
|
||||
|
||||
const likeEscape = (s) => s.replace(/[\\%_]/g, "\\$&");
|
||||
/** Send a 400 and return null, for `parseQuery`'s early exits. */
|
||||
function bad(res, message) {
|
||||
res.status(400).json({ error: { code: "INVALID_INPUT", message } });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the request into `{spec, entity, where, params, order}`, or send
|
||||
* the 400 and return null.
|
||||
*/
|
||||
function parseQuery(req, res) {
|
||||
const q = req.query;
|
||||
const entity = q.entity === undefined ? "sessions" : q.entity;
|
||||
const spec = Object.prototype.hasOwnProperty.call(ENTITIES, entity) ? ENTITIES[entity] : null;
|
||||
if (!spec) return bad(res, "entity must be sessions, events or agents");
|
||||
if (parseTool(req) === false) return bad(res, BAD_TOOL.error.message);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return bad(res, BAD_RANGE.error.message);
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
const scope = parseScope(req);
|
||||
if (scope) {
|
||||
where.push(`s.id IN (SELECT id FROM sessions WHERE ${scope.where})`);
|
||||
params.push(...scope.params);
|
||||
}
|
||||
if (range?.start) {
|
||||
where.push(`${spec.time} >= ?`);
|
||||
params.push(range.start);
|
||||
}
|
||||
if (range?.end) {
|
||||
where.push(`${spec.time} < ?`);
|
||||
params.push(range.end);
|
||||
}
|
||||
const text = typeof q.q === "string" ? q.q.trim() : "";
|
||||
if (text) {
|
||||
const like = `%${likeEscape(text)}%`;
|
||||
where.push(`(${spec.text.map((c) => `${c} LIKE ? ESCAPE '\\'`).join(" OR ")})`);
|
||||
params.push(...spec.text.map(() => like));
|
||||
}
|
||||
if (q.status !== undefined && q.status !== "") {
|
||||
if (!spec.statuses.includes(q.status)) {
|
||||
return bad(res, `status must be one of: ${spec.statuses.join(", ") || "(none)"}`);
|
||||
}
|
||||
where.push(`${spec.status} = ?`);
|
||||
params.push(q.status);
|
||||
}
|
||||
if (typeof q.cwd === "string" && q.cwd.trim()) {
|
||||
// The folder itself or anything under it (path boundary, no wildcards).
|
||||
const cwd = q.cwd.trim().replace(/\/+$/, "");
|
||||
where.push("(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\')");
|
||||
params.push(cwd, `${likeEscape(cwd)}/%`);
|
||||
}
|
||||
|
||||
const sort = q.sort === undefined || q.sort === "" ? spec.sort[0] : q.sort;
|
||||
if (!spec.sort.includes(sort)) return bad(res, `sort must be one of: ${spec.sort.join(", ")}`);
|
||||
const dir = q.dir === undefined || q.dir === "" ? "desc" : q.dir;
|
||||
if (dir !== "asc" && dir !== "desc") return bad(res, "dir must be asc or desc");
|
||||
// Both pieces come from the allowlist above, never from the request text.
|
||||
const sqlDir = dir === "asc" ? "ASC" : "DESC";
|
||||
const order = `${spec.columns[sort]} ${sqlDir}, ${spec.tiebreak} ${sqlDir}`;
|
||||
|
||||
return {
|
||||
spec,
|
||||
entity,
|
||||
where: where.length ? `WHERE ${where.join(" AND ")}` : "",
|
||||
params,
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
function select(p, limit, offset) {
|
||||
const cols = Object.entries(p.spec.columns)
|
||||
.map(([name, sql]) => `${sql} AS ${name}`)
|
||||
.join(", ");
|
||||
return db
|
||||
.prepare(`SELECT ${cols} FROM ${p.spec.from} ${p.where} ORDER BY ${p.order} LIMIT ? OFFSET ?`)
|
||||
.all(...p.params, limit, offset);
|
||||
}
|
||||
|
||||
function count(p) {
|
||||
return db.prepare(`SELECT COUNT(*) AS n FROM ${p.spec.from} ${p.where}`).get(...p.params).n;
|
||||
}
|
||||
|
||||
/**
|
||||
* One CSV cell (RFC 4180): quoted when it holds a quote, comma or line break.
|
||||
* A text cell starting with = + - @ (or a tab / CR) gets a leading `'`, so a
|
||||
* spreadsheet never runs it as a formula.
|
||||
*/
|
||||
function csvCell(v) {
|
||||
if (v === null || v === undefined) return "";
|
||||
let s = String(v);
|
||||
if (typeof v === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
function toCsv(columns, rows) {
|
||||
const lines = [columns.map(csvCell).join(",")];
|
||||
for (const r of rows) lines.push(columns.map((c) => csvCell(r[c])).join(","));
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
// GET /api/query — a page, or an export with `format=csv|json`.
|
||||
router.get("/", (req, res) => {
|
||||
const p = parseQuery(req, res);
|
||||
if (!p) return;
|
||||
const format = req.query.format;
|
||||
const total = count(p);
|
||||
|
||||
if (format === "csv" || format === "json") {
|
||||
const rows = select(p, EXPORT_CAP, 0);
|
||||
const truncated = total > rows.length;
|
||||
const name = `agenthub-${p.entity}-${new Date().toISOString().slice(0, 10)}.${format}`;
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||
res.setHeader("X-Total-Count", String(total));
|
||||
res.setHeader("X-Truncated", truncated ? "1" : "0");
|
||||
if (format === "json") {
|
||||
return res.json({ entity: p.entity, total, truncated, cap: EXPORT_CAP, rows });
|
||||
}
|
||||
res.type("text/csv; charset=utf-8");
|
||||
// The BOM makes spreadsheet apps read the file as UTF-8.
|
||||
return res.send("\uFEFF" + toCsv(Object.keys(p.spec.columns), rows));
|
||||
}
|
||||
if (format !== undefined && format !== "") return bad(res, "format must be csv or json");
|
||||
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), PAGE_MAX);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
res.json({
|
||||
entity: p.entity,
|
||||
columns: Object.keys(p.spec.columns),
|
||||
sortable: p.spec.sort,
|
||||
statuses: p.spec.statuses,
|
||||
rows: select(p, limit, offset),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
export_cap: EXPORT_CAP,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.csvCell = csvCell;
|
||||
module.exports.EXPORT_CAP = EXPORT_CAP;
|
||||
@@ -11,6 +11,9 @@
|
||||
* POST /api/remote-sources/:id/test — probe SSH connectivity
|
||||
* POST /api/remote-sources/:id/sync — sync now
|
||||
*
|
||||
* A source may also pull Codex history (`sync_codex`, opt-in; from
|
||||
* `remote_codex_home`, default ~/.codex) — see syncCodex in remote-sync.js.
|
||||
*
|
||||
* No secrets are stored or accepted here — authentication defers entirely to the
|
||||
* host's SSH stack. All inputs are validated in remote-sync.validateSourceInput
|
||||
* before touching the DB or any command.
|
||||
@@ -31,6 +34,7 @@ const {
|
||||
syncSource,
|
||||
syncAllEnabled,
|
||||
stagingDir,
|
||||
codexStagingDir,
|
||||
} = require("../lib/remote-sync");
|
||||
|
||||
const router = Router();
|
||||
@@ -54,6 +58,8 @@ function serialize(row, sessionCount = 0) {
|
||||
ssh_port: row.ssh_port,
|
||||
identity_file: row.identity_file,
|
||||
remote_home: row.remote_home,
|
||||
sync_codex: !!row.sync_codex,
|
||||
remote_codex_home: row.remote_codex_home ?? null,
|
||||
enabled: !!row.enabled,
|
||||
status: row.status,
|
||||
last_error: row.last_error,
|
||||
@@ -106,7 +112,9 @@ router.post("/", (req, res) => {
|
||||
v.sshPort ?? null,
|
||||
v.identityFile ?? null,
|
||||
v.remoteHome ?? null,
|
||||
enabled
|
||||
enabled,
|
||||
v.syncCodex ?? 0,
|
||||
v.remoteCodexHome ?? null
|
||||
);
|
||||
const row = stmts.getRemoteSource.get(id);
|
||||
broadcast("remote_source.status", { id, status: row.status });
|
||||
@@ -147,6 +155,8 @@ router.patch("/:id", (req, res) => {
|
||||
v.identityFile !== undefined ? v.identityFile : existing.identity_file,
|
||||
v.remoteHome !== undefined ? v.remoteHome : existing.remote_home,
|
||||
v.enabled === undefined ? null : v.enabled,
|
||||
v.syncCodex === undefined ? null : v.syncCodex,
|
||||
v.remoteCodexHome !== undefined ? v.remoteCodexHome : existing.remote_codex_home,
|
||||
req.params.id
|
||||
);
|
||||
const row = stmts.getRemoteSource.get(req.params.id);
|
||||
@@ -176,9 +186,10 @@ router.delete("/:id", (req, res) => {
|
||||
db.prepare("UPDATE sessions SET source = 'local' WHERE source = ?").run(req.params.id);
|
||||
}
|
||||
stmts.deleteRemoteSource.run(req.params.id);
|
||||
// Reclaim the mirrored staging dir.
|
||||
// Reclaim the mirrored staging dirs (Claude projects + Codex sessions).
|
||||
try {
|
||||
fs.rmSync(stagingDir(req.params.id), { recursive: true, force: true });
|
||||
fs.rmSync(codexStagingDir(req.params.id), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file Express router for scheduled reports (`server/lib/reports.js`): CRUD of
|
||||
* report definitions, `POST /:id/run` to run one now, the run log, and the
|
||||
* download of a run's file — whose path is built from the report and run ids
|
||||
* only, never from the request. Every write is same-origin only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const { Router } = require("express");
|
||||
const { db } = require("../db");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const reports = require("../lib/reports");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ID_RE = /^\d+$/;
|
||||
const bad = (res, message) => res.status(400).json({ error: { code: "INVALID_INPUT", message } });
|
||||
const notFound = (res) =>
|
||||
res.status(404).json({ error: { code: "NOT_FOUND", message: "Report not found" } });
|
||||
|
||||
const getRow = (id) =>
|
||||
ID_RE.test(String(id))
|
||||
? db.prepare("SELECT * FROM report_definitions WHERE id = ?").get(Number(id))
|
||||
: undefined;
|
||||
|
||||
const lastRun = db.prepare(
|
||||
"SELECT * FROM report_runs WHERE report_id = ? ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
|
||||
// The slot a definition is due at now counts as claimed, so creating, editing
|
||||
// or re-enabling a report never runs it for an hour already past.
|
||||
const claimedSlot = (v) => new Date(reports.lastSlot(v, Date.now())).toISOString();
|
||||
|
||||
// GET /api/reports — every definition with its latest run.
|
||||
router.get("/", (_req, res) => {
|
||||
const rows = db.prepare("SELECT * FROM report_definitions ORDER BY id").all();
|
||||
res.json({
|
||||
reports: rows.map((r) => {
|
||||
const run = lastRun.get(r.id);
|
||||
return { ...reports.view(r), last_run: run ? reports.viewRun(run) : null };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/reports — create.
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
const { value, error } = reports.validate(req.body || {});
|
||||
if (error) return bad(res, error);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO report_definitions
|
||||
(name, window_days, frequency, hour, weekday, tz_offset, formats, enabled, last_slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
value.name,
|
||||
value.window_days,
|
||||
value.frequency,
|
||||
value.hour,
|
||||
value.weekday,
|
||||
value.tz_offset,
|
||||
JSON.stringify(value.formats),
|
||||
value.enabled ? 1 : 0,
|
||||
claimedSlot(value)
|
||||
);
|
||||
res.status(201).json({ report: reports.view(getRow(info.lastInsertRowid)) });
|
||||
});
|
||||
|
||||
// PUT /api/reports/:id — update; omitted fields keep their value.
|
||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
const row = getRow(req.params.id);
|
||||
if (!row) return notFound(res);
|
||||
const { value, error } = reports.validate({ ...reports.view(row), ...(req.body || {}) });
|
||||
if (error) return bad(res, error);
|
||||
db.prepare(
|
||||
`UPDATE report_definitions SET name = ?, window_days = ?, frequency = ?, hour = ?,
|
||||
weekday = ?, tz_offset = ?, formats = ?, enabled = ?, last_slot = ? WHERE id = ?`
|
||||
).run(
|
||||
value.name,
|
||||
value.window_days,
|
||||
value.frequency,
|
||||
value.hour,
|
||||
value.weekday,
|
||||
value.tz_offset,
|
||||
JSON.stringify(value.formats),
|
||||
value.enabled ? 1 : 0,
|
||||
claimedSlot(value),
|
||||
row.id
|
||||
);
|
||||
res.json({ report: reports.view(getRow(row.id)) });
|
||||
});
|
||||
|
||||
// DELETE /api/reports/:id — the definition, its runs and their files.
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
const row = getRow(req.params.id);
|
||||
if (!row) return notFound(res);
|
||||
db.prepare("DELETE FROM report_definitions WHERE id = ?").run(row.id);
|
||||
fs.rmSync(reports.reportDir(row.id), { recursive: true, force: true });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/reports/:id/run — run now (also when disabled).
|
||||
router.post("/:id/run", sameOriginGuard, (req, res) => {
|
||||
const row = getRow(req.params.id);
|
||||
if (!row) return notFound(res);
|
||||
res.json({ run: reports.runReport(reports.view(row), "manual") });
|
||||
});
|
||||
|
||||
// GET /api/reports/:id/runs — the run log, newest first (last 100).
|
||||
router.get("/:id/runs", (req, res) => {
|
||||
const row = getRow(req.params.id);
|
||||
if (!row) return notFound(res);
|
||||
const runs = db
|
||||
.prepare("SELECT * FROM report_runs WHERE report_id = ? ORDER BY id DESC LIMIT 100")
|
||||
.all(row.id);
|
||||
res.json({ runs: runs.map(reports.viewRun) });
|
||||
});
|
||||
|
||||
// GET /api/reports/:id/runs/:runId/:format — download one file of a run.
|
||||
router.get("/:id/runs/:runId/:format", (req, res) => {
|
||||
const { id, runId, format } = req.params;
|
||||
if (!ID_RE.test(id) || !ID_RE.test(runId)) return notFound(res);
|
||||
const file = reports.runFile(id, runId, format);
|
||||
if (!file) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Report file not found" } });
|
||||
}
|
||||
res.download(file, `report-${id}-${runId}.${format}`);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -338,12 +338,15 @@ router.get("/:id", (req, res) => {
|
||||
* All aggregation runs in SQL so we don't ship 14k+ event rows to the client.
|
||||
*/
|
||||
router.get("/:id/stats", (req, res) => {
|
||||
const sessionId = req.params.id;
|
||||
const session = stmts.getSession.get(sessionId);
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
res.json(sessionStats(req.params.id));
|
||||
});
|
||||
|
||||
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
||||
function sessionStats(sessionId) {
|
||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
||||
@@ -385,7 +388,7 @@ router.get("/:id/stats", (req, res) => {
|
||||
else if (row.type === "subagent") agentCounts.subagent = row.count;
|
||||
}
|
||||
|
||||
res.json({
|
||||
return {
|
||||
session_id: sessionId,
|
||||
total_events: totalEvents,
|
||||
events_by_type: eventsByType,
|
||||
@@ -398,8 +401,8 @@ router.get("/:id/stats", (req, res) => {
|
||||
agents: agentCounts,
|
||||
subagent_types: subagentTypes.filter((r) => r.subagent_type !== "compaction"),
|
||||
tokens,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const { id, name, cwd, model, metadata } = req.body;
|
||||
@@ -1096,5 +1099,6 @@ function truncateObj(obj, maxLen) {
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sessionStats = sessionStats;
|
||||
// Exported for unit tests — sender attribution is correctness-critical.
|
||||
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions. This allows the frontend to manage and maintain the agent monitoring system effectively.
|
||||
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,9 @@ const APP_VERSION = (() => {
|
||||
|
||||
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
||||
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||
const { envOwned } = require("../lib/settings-env");
|
||||
const { envOwned, applySetting } = require("../lib/settings-env");
|
||||
const privacy = require("../lib/privacy");
|
||||
const pushToken = require("../lib/push-token");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
|
||||
|
||||
@@ -171,10 +173,11 @@ function clearAll() {
|
||||
db.prepare("DELETE FROM events").run();
|
||||
db.prepare("DELETE FROM agents").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
// What the Codex/Antigravity polls have read: kept, it would stop them ever
|
||||
// importing those sessions again (and bring one back half, on its next write).
|
||||
// What the Codex/Antigravity/Cursor polls have read: kept, it would stop them
|
||||
// ever importing those sessions again (and bring one back half, on its next write).
|
||||
db.prepare("DELETE FROM codex_rollouts").run();
|
||||
db.prepare("DELETE FROM antigravity_conversations").run();
|
||||
db.prepare("DELETE FROM cursor_transcripts").run();
|
||||
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
|
||||
// *rules* survive: they're user configuration, like model_pricing.
|
||||
db.prepare("DELETE FROM alert_events").run();
|
||||
@@ -184,6 +187,11 @@ function clearAll() {
|
||||
// Workflow runs belong to the sessions; with foreign keys off they would
|
||||
// not cascade and stay as orphans.
|
||||
db.prepare("DELETE FROM workflows").run();
|
||||
// session_snapshots survive: a snapshot is a frozen copy the user chose to
|
||||
// keep, like alert rules — deleted only from Settings → Snapshots.
|
||||
// report_definitions and report_runs survive too: a definition is user
|
||||
// configuration and a run's files are reports already delivered — removed
|
||||
// only from Settings → Reports.
|
||||
}
|
||||
|
||||
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
||||
@@ -438,6 +446,68 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
|
||||
// into the current home at its spawn, so a move needs no repair here.
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
// GET/PUT /api/settings/privacy — opt-in secret redaction of stored payloads
|
||||
// and the folders whose hooks are dropped (server/lib/privacy.js). Both persist
|
||||
// to the settings file; one the environment set is `env_owned` and refused.
|
||||
function privacyState() {
|
||||
return {
|
||||
redact_secrets: privacy.redactionEnabled(),
|
||||
ignore_cwd: privacy.ignoredCwds(),
|
||||
env_owned: {
|
||||
redact_secrets: envOwned.has(privacy.REDACT_KEY),
|
||||
ignore_cwd: envOwned.has(privacy.IGNORE_KEY),
|
||||
},
|
||||
};
|
||||
}
|
||||
router.get("/privacy", (_req, res) => res.json(privacyState()));
|
||||
router.put("/privacy", sameOriginGuard, (req, res) => {
|
||||
const body = req.body || {};
|
||||
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
const writes = [];
|
||||
if (body.redact_secrets !== undefined) {
|
||||
if (typeof body.redact_secrets !== "boolean")
|
||||
return fail(400, "INVALID_INPUT", "redact_secrets must be a boolean");
|
||||
writes.push([privacy.REDACT_KEY, body.redact_secrets ? "1" : "0"]);
|
||||
}
|
||||
if (body.ignore_cwd !== undefined) {
|
||||
let list;
|
||||
try {
|
||||
list = privacy.validateIgnoreList(body.ignore_cwd);
|
||||
} catch (err) {
|
||||
return fail(400, err.code || "EBADPATH", err.message);
|
||||
}
|
||||
writes.push([privacy.IGNORE_KEY, list.join(path.delimiter)]);
|
||||
}
|
||||
const owned = writes.find(([k]) => envOwned.has(k));
|
||||
if (owned) return fail(409, "EENVOWNED", `${owned[0]} is set by the environment`);
|
||||
try {
|
||||
for (const [k, v] of writes) applySetting(k, v);
|
||||
} catch (err) {
|
||||
return fail(500, "ESAVE", err.message);
|
||||
}
|
||||
res.json({ ok: true, ...privacyState() });
|
||||
});
|
||||
|
||||
// GET/POST/DELETE /api/settings/push-token — the token for POST
|
||||
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
|
||||
// POST makes a new one (the old one stops working) and returns it this once.
|
||||
router.get("/push-token", (_req, res) => res.json(pushToken.pushTokenStatus()));
|
||||
function pushTokenWrite(fn) {
|
||||
return (_req, res) => {
|
||||
try {
|
||||
const token = fn();
|
||||
res.json({ ok: true, ...(token && { token }), ...pushToken.pushTokenStatus() });
|
||||
} catch (err) {
|
||||
const owned = err.code === "EENVOWNED";
|
||||
res
|
||||
.status(owned ? 409 : 500)
|
||||
.json({ error: { code: owned ? err.code : "ESAVE", message: err.message } });
|
||||
}
|
||||
};
|
||||
}
|
||||
router.post("/push-token", sameOriginGuard, pushTokenWrite(pushToken.rotatePushToken));
|
||||
router.delete("/push-token", sameOriginGuard, pushTokenWrite(pushToken.revokePushToken));
|
||||
|
||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data.
|
||||
// `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything);
|
||||
// `dry_run: true` answers the same counts and changes nothing.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file Session snapshots: `POST /api/snapshots` freezes a session — its row,
|
||||
* agents, events (oldest first), the SessionOverview stats and the token cost —
|
||||
* into one immutable `session_snapshots` row read back by an unguessable token
|
||||
* (`GET /api/snapshots/:token`). File paths can be redacted at capture, so
|
||||
* nothing redacted is ever stored. Writes are same-origin only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { calculateCost, attachAgentCosts } = require("./pricing");
|
||||
const { sessionStats } = require("./sessions");
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Keys whose string value is a path on this machine — replaced whole. */
|
||||
const PATH_KEYS = new Set([
|
||||
"cwd",
|
||||
"transcript_path",
|
||||
"agent_transcript_path",
|
||||
"file_path",
|
||||
"filePath",
|
||||
"notebook_path",
|
||||
"path",
|
||||
]);
|
||||
const REDACTED = "[redacted]";
|
||||
/** 24 random bytes, base64url. */
|
||||
const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* A redactor for one session: path-valued keys become `[redacted]`, and the
|
||||
* session's cwd / the home dir inside any other string (a Bash command, a
|
||||
* summary) become `<cwd>` / `~`.
|
||||
*/
|
||||
function pathRedactor(cwd) {
|
||||
const swaps = [
|
||||
[cwd, "<cwd>"],
|
||||
[os.homedir(), "~"],
|
||||
]
|
||||
.filter(([p]) => typeof p === "string" && p.length > 1)
|
||||
.map(([p, to]) => [new RegExp(`${escapeRe(p)}(?![\\w.-])`, "g"), to]);
|
||||
const str = (s) => swaps.reduce((acc, [re, to]) => acc.replace(re, to), s);
|
||||
const walk = (v, key) => {
|
||||
if (typeof v === "string") return PATH_KEYS.has(key) ? REDACTED : str(v);
|
||||
if (Array.isArray(v)) return v.map((x) => walk(x));
|
||||
if (v && typeof v === "object") {
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = walk(x, k);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
// A column holding JSON text (events.data, *.metadata) is redacted as JSON.
|
||||
const json = (text) => {
|
||||
if (typeof text !== "string") return text;
|
||||
try {
|
||||
return JSON.stringify(walk(JSON.parse(text)));
|
||||
} catch {
|
||||
return str(text);
|
||||
}
|
||||
};
|
||||
return { walk, json };
|
||||
}
|
||||
|
||||
function capture(sessionId, redactPaths) {
|
||||
const session = stmts.getSession.get(sessionId);
|
||||
if (!session) return null;
|
||||
const agents = attachAgentCosts(stmts.listAgentsBySession.all(sessionId));
|
||||
const events = stmts.listEventsBySession.all(sessionId).reverse();
|
||||
const cost = calculateCost(
|
||||
stmts.getTokensBySession.all(sessionId),
|
||||
stmts.listPricing.all(),
|
||||
session.started_at ? String(session.started_at).slice(0, 10) : undefined
|
||||
);
|
||||
const payload = {
|
||||
version: 1,
|
||||
captured_at: new Date().toISOString(),
|
||||
redacted: { file_paths: redactPaths },
|
||||
session,
|
||||
agents,
|
||||
events,
|
||||
stats: sessionStats(sessionId),
|
||||
cost,
|
||||
};
|
||||
if (!redactPaths) return payload;
|
||||
const r = pathRedactor(session.cwd);
|
||||
const row = (x) => {
|
||||
const out = r.walk(x);
|
||||
if ("data" in x) out.data = r.json(x.data);
|
||||
if ("metadata" in x) out.metadata = r.json(x.metadata);
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
...payload,
|
||||
session: row(session),
|
||||
agents: agents.map(row),
|
||||
events: events.map(row),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/snapshots — every snapshot, newest first, without its payload.
|
||||
router.get("/", (_req, res) => {
|
||||
const snapshots = db
|
||||
.prepare(
|
||||
`SELECT id, token, session_id, created_at,
|
||||
json_extract(payload, '$.session.name') AS session_name,
|
||||
json_extract(payload, '$.redacted.file_paths') AS redacted,
|
||||
json_array_length(payload, '$.events') AS event_count,
|
||||
length(payload) AS bytes
|
||||
FROM session_snapshots ORDER BY id DESC`
|
||||
)
|
||||
.all()
|
||||
.map((s) => ({ ...s, redacted: s.redacted === 1 }));
|
||||
res.json({ snapshots });
|
||||
});
|
||||
|
||||
// POST /api/snapshots {session_id, redact?: {file_paths?: boolean}}
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
const sessionId = req.body?.session_id;
|
||||
if (typeof sessionId !== "string" || !sessionId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "session_id is required" } });
|
||||
}
|
||||
const payload = capture(sessionId, req.body?.redact?.file_paths === true);
|
||||
if (!payload) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
const token = crypto.randomBytes(24).toString("base64url");
|
||||
const { lastInsertRowid } = db
|
||||
.prepare("INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, ?)")
|
||||
.run(token, sessionId, JSON.stringify(payload));
|
||||
const row = db
|
||||
.prepare("SELECT id, token, session_id, created_at FROM session_snapshots WHERE id = ?")
|
||||
.get(lastInsertRowid);
|
||||
res.status(201).json({ snapshot: row });
|
||||
});
|
||||
|
||||
// GET /api/snapshots/:token — the frozen payload, sent as stored.
|
||||
router.get("/:token", (req, res) => {
|
||||
const token = req.params.token;
|
||||
const row = TOKEN_RE.test(token)
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT id, token, session_id, created_at, payload FROM session_snapshots WHERE token = ?"
|
||||
)
|
||||
.get(token)
|
||||
: null;
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
|
||||
}
|
||||
const { payload, ...meta } = row;
|
||||
res.type("application/json").send(`{"snapshot":${JSON.stringify(meta)},"payload":${payload}}`);
|
||||
});
|
||||
|
||||
// DELETE /api/snapshots/:id
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { changes } = Number.isInteger(id)
|
||||
? db.prepare("DELETE FROM session_snapshots WHERE id = ?").run(id)
|
||||
: { changes: 0 };
|
||||
if (!changes) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.pathRedactor = pathRedactor;
|
||||
+13
-11
@@ -6,7 +6,7 @@
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { parseSources } = require("../lib/source-filter");
|
||||
const { parseScope, parseTool, BAD_TOOL } = require("../lib/source-filter");
|
||||
const scoped = require("../lib/scoped-stats");
|
||||
|
||||
const router = Router();
|
||||
@@ -18,19 +18,21 @@ router.get("/", (req, res) => {
|
||||
const toLocal = `${-offsetMin} minutes`; // shift UTC → local
|
||||
const toUTC = `${offsetMin} minutes`; // shift local → UTC
|
||||
|
||||
// Data-scope: when the user restricts to a subset of source machines, compute
|
||||
// every count against that subset; otherwise use the cached prepared stmts.
|
||||
const sources = parseSources(req);
|
||||
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
|
||||
const agentsByStatus = sources
|
||||
? scoped.agentStatusCounts(db, sources)
|
||||
// Data-scope: when the user restricts to a subset of source machines and/or
|
||||
// one AI tool, compute every count against that subset; otherwise use the
|
||||
// cached prepared stmts.
|
||||
if (parseTool(req) === false) return res.status(400).json(BAD_TOOL);
|
||||
const scope = parseScope(req);
|
||||
const overview = scope ? scoped.statsOverview(db, scope) : stmts.stats.get();
|
||||
const agentsByStatus = scope
|
||||
? scoped.agentStatusCounts(db, scope)
|
||||
: stmts.agentStatusCounts.all();
|
||||
const sessionsByStatus = sources
|
||||
? scoped.sessionStatusCounts(db, sources)
|
||||
const sessionsByStatus = scope
|
||||
? scoped.sessionStatusCounts(db, scope)
|
||||
: stmts.sessionStatusCounts.all();
|
||||
|
||||
const eventsToday = sources
|
||||
? scoped.countEventsToday(db, sources, toLocal, toUTC)
|
||||
const eventsToday = scope
|
||||
? scoped.countEventsToday(db, scope, toLocal, toUTC)
|
||||
: stmts.countEventsToday.get(toLocal, toUTC);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -7,6 +7,7 @@ const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const { runRead } = require("../lib/read-pool");
|
||||
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
|
||||
const { parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -15,8 +16,14 @@ const router = Router();
|
||||
// large database, which inline would stall every other request meanwhile.
|
||||
// `?sections=a,b` computes only those parts of it (System Health needs seven).
|
||||
router.get("/", async (req, res) => {
|
||||
// Optional status filter: "active", "completed", or omit for all
|
||||
// Optional filters: status ("active", "completed", or omit for all), the AI
|
||||
// tool (`?tool=`, `sessions.tool`) and `?from=&to=` (sessions started in
|
||||
// those local days).
|
||||
const statusFilter = req.query.status || null;
|
||||
const tool = parseTool(req);
|
||||
if (tool === false) return res.status(400).json(BAD_TOOL);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return res.status(400).json(BAD_RANGE);
|
||||
let sections = null;
|
||||
if (typeof req.query.sections === "string") {
|
||||
sections = req.query.sections.split(",").filter((k) => WORKFLOW_SECTIONS.includes(k));
|
||||
@@ -30,7 +37,7 @@ router.get("/", async (req, res) => {
|
||||
}
|
||||
}
|
||||
try {
|
||||
res.json(await runRead("workflows", { status: statusFilter, sections }));
|
||||
res.json(await runRead("workflows", { status: statusFilter, tool, range, sections }));
|
||||
} catch (err) {
|
||||
if (err.code === "EREADTIMEOUT") {
|
||||
return res.status(503).json({ error: { code: err.code, message: err.message } });
|
||||
@@ -54,7 +61,11 @@ router.get("/", async (req, res) => {
|
||||
*/
|
||||
router.get("/skill-usage", (req, res) => {
|
||||
try {
|
||||
const sf = sessionIdFilter(req.query.status || null);
|
||||
const tool = parseTool(req);
|
||||
if (tool === false) return res.status(400).json(BAD_TOOL);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return res.status(400).json(BAD_RANGE);
|
||||
const sf = sessionIdFilter({ status: req.query.status || null, tool, range });
|
||||
|
||||
const skills = db
|
||||
.prepare(
|
||||
|
||||
Reference in New Issue
Block a user