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

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

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+109 -1
View File
@@ -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 },
]);
});
});