4aae61297f
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.
160 lines
5.9 KiB
JavaScript
160 lines
5.9 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|