Files
AgentHub/client/src/lib/replay.ts
T
nntrivi2001 4aae61297f 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.
2026-10-06 17:12:28 +07:00

125 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file replay.ts
* @description Pure derivations behind the session Replay tab: events in
* playback order, the lifecycle markers on its mini-map, and the state a
* session was in at a cursor (status, tool in flight, tool counts).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { AgentStatus, DashboardEvent } from "./types";
import { statusFromEventType } from "./event-grouping";
export const REPLAY_SPEEDS = [0.5, 1, 2, 5] as const;
/** One event per this many ms at 1× — playback walks the sequence, not the
* wall clock, so an hour-long pause does not stall the replay. */
export const REPLAY_STEP_MS = 500;
/** Event types drawn on the mini-map, in legend order. */
export const LIFECYCLE_TYPES = [
"SessionStart",
"UserPromptSubmit",
"Compaction",
"SubagentStop",
"APIError",
"Stop",
] as const;
/** Oldest first; `id` breaks timestamp ties. */
export function chronological(events: DashboardEvent[]): DashboardEvent[] {
return [...events].sort(
(a, b) => a.created_at.localeCompare(b.created_at) || (a.id ?? 0) - (b.id ?? 0)
);
}
export interface ReplayMarker {
index: number;
type: string;
/** Position on the mini-map, 0–100, matching the slider's scale. */
pct: number;
}
export function lifecycleMarkers(events: DashboardEvent[]): ReplayMarker[] {
const kinds = new Set<string>(LIFECYCLE_TYPES);
const last = Math.max(1, events.length - 1);
const out: ReplayMarker[] = [];
events.forEach((e, index) => {
if (kinds.has(e.event_type)) out.push({ index, type: e.event_type, pct: (index / last) * 100 });
});
return out;
}
export interface ReplayState {
status: AgentStatus | null;
/** The newest tool started and not yet finished at the cursor. */
currentTool: string | null;
/** Tool calls (PreToolUse) so far, most used first. */
toolCounts: { tool: string; count: number }[];
prompts: number;
errors: number;
compactions: number;
/** From the first event to the cursor's. */
elapsedMs: number;
}
/** The session as it stood after `events[cursor]` (events in playback order). */
export function replayState(events: DashboardEvent[], cursor: number): ReplayState {
const end = Math.min(cursor, events.length - 1);
const open: { agent: string; tool: string }[] = [];
const counts = new Map<string, number>();
let prompts = 0;
let errors = 0;
let compactions = 0;
for (let i = 0; i <= end; i++) {
const e = events[i]!;
const agent = e.agent_id ?? "";
switch (e.event_type) {
case "PreToolUse":
if (e.tool_name) {
open.push({ agent, tool: e.tool_name });
counts.set(e.tool_name, (counts.get(e.tool_name) ?? 0) + 1);
}
break;
case "PostToolUse":
case "PostToolUseFailure": {
for (let j = open.length - 1; j >= 0; j--) {
if (open[j]!.agent === agent && open[j]!.tool === e.tool_name) {
open.splice(j, 1);
break;
}
}
if (e.event_type === "PostToolUseFailure") errors++;
break;
}
case "Stop":
case "SubagentStop":
case "SessionEnd":
// A turn that ended leaves nothing of that agent in flight.
for (let j = open.length - 1; j >= 0; j--) if (open[j]!.agent === agent) open.splice(j, 1);
break;
case "UserPromptSubmit":
prompts++;
break;
case "APIError":
errors++;
break;
case "Compaction":
compactions++;
break;
}
}
const first = events[0];
const at = end >= 0 ? events[end] : undefined;
return {
status: at ? statusFromEventType(at.event_type) : null,
currentTool: open[open.length - 1]?.tool ?? null,
toolCounts: [...counts]
.map(([tool, count]) => ({ tool, count }))
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)),
prompts,
errors,
compactions,
elapsedMs:
first && at ? Math.max(0, Date.parse(at.created_at) - Date.parse(first.created_at)) : 0,
};
}