Skip to main content

av_harness/
dashboard.rs

1//! Read-only operator dashboard.
2//!
3//! Serves a single-page HTML/CSS/JS application at `/dashboard` and a small
4//! JSON API under `/api/v1/dashboard/*` that lets the page render live
5//! sessions, per-session details, and aggregate stats. The assets are
6//! bundled into the binary via [`include_str!`] so there is no filesystem
7//! dependency at runtime.
8//!
9//! Trust posture: the dashboard exposes the same session metadata that
10//! already lands on disk and in `/metrics`. It is not authenticated by
11//! itself — front it with the same ingress-level control operators use
12//! for `/metrics`. There is no mutating endpoint here; every route is
13//! `GET`.
14
15use crate::pipeline::AppState;
16use crate::session::Workflow;
17use av_core::time::elapsed_us;
18use axum::extract::{Path, State};
19use axum::http::{header, HeaderName, HeaderValue, StatusCode};
20use axum::response::{IntoResponse, Response};
21use axum::Json;
22use serde::Serialize;
23use serde_json::{json, Value};
24use std::sync::atomic::Ordering;
25use std::time::Instant;
26
27const INDEX_HTML: &str = include_str!("../dashboard/index.html");
28const STYLE_CSS: &str = include_str!("../dashboard/style.css");
29const APP_JS: &str = include_str!("../dashboard/app.js");
30
31/// Sorted, capped list of session summaries. `?limit=N` clamped to
32/// `MAX_LIST_LIMIT`; the default is [`DEFAULT_LIST_LIMIT`].
33pub const DEFAULT_LIST_LIMIT: usize = 50;
34/// Upper bound on the `?limit=N` query parameter for the sessions list.
35pub const MAX_LIST_LIMIT: usize = 500;
36
37/// Record latency + outcome for a dashboard request. Kept next to the
38/// handlers so every endpoint funnels through one registration point —
39/// a new endpoint that calls `record()` is instrumented automatically,
40/// and one that doesn't shows up as a missing series in the
41/// dashboard-metrics tests (render() itself never panics on absent keys).
42///
43/// R59 hot-path: metric handles are pre-resolved into
44/// `HotMetrics.dashboard_request_counters` /
45/// `dashboard_request_duration_histograms` — same rationale as R58's
46/// request-metrics middleware pre-resolution. Prior code did 2
47/// per-endpoint-hit `format!` allocs + 4 shared-Registry mutex ops.
48fn record(
49    state: &AppState,
50    endpoint: crate::pipeline::DashboardEndpoint,
51    status: crate::pipeline::DashboardStatus,
52    started: Instant,
53) {
54    // Safe: `DashboardEndpoint::index()` returns `0..3` and
55    // `DashboardStatus::index()` returns `0..2`; `HotMetrics`
56    // allocates arrays of exactly those sizes at boot.
57    #[allow(clippy::indexing_slicing)]
58    {
59        state.hot_metrics.dashboard_request_duration_histograms[endpoint.index()]
60            .observe_us(elapsed_us(started));
61        state.hot_metrics.dashboard_request_counters[endpoint.index()][status.index()].inc();
62    }
63}
64
65#[derive(Serialize)]
66pub(crate) struct SessionSummary {
67    pub id: String,
68    pub workflow: &'static str,
69    pub last_activity_ms: u64,
70    pub open: bool,
71    pub closed: bool,
72    pub artifact_committed: bool,
73    pub close_complete: bool,
74    pub capture_failed: bool,
75    pub active_streams: u64,
76    pub pending_jobs: u64,
77    pub stop_reason_id: u8,
78    pub stop_reason: &'static str,
79    pub tool_calls: u64,
80    pub tool_allowed: u64,
81    pub tool_blocked: u64,
82    pub prompt_tokens: u64,
83    pub completion_tokens: u64,
84    pub cached_tokens: u64,
85    pub cost_usd_micros: u64,
86    pub identity: Identity,
87    pub has_receipt: bool,
88}
89
90#[derive(Serialize)]
91pub(crate) struct Identity {
92    pub instance_uid: String,
93    pub charter: String,
94    pub version: String,
95    pub ttl_remaining_s: Option<u64>,
96}
97
98impl SessionSummary {
99    pub(crate) fn from_session(session: &crate::session::Session) -> Self {
100        let latest = session.current_identity();
101        let stop_id = u8::try_from(session.recorded_stop_reason_id()).unwrap_or(0);
102        let stop = av_events::StopReason::from_id(stop_id);
103        Self {
104            id: session.id.clone(),
105            workflow: session.workflow.as_str(),
106            last_activity_ms: session.last_activity_ms.load(Ordering::Acquire),
107            open: !session.is_closed(),
108            closed: session.closed.load(Ordering::Acquire) != 0,
109            artifact_committed: session.artifact_committed_flag(),
110            close_complete: session.close_complete_flag(),
111            capture_failed: session.capture_failed(),
112            active_streams: session.active_streams_count(),
113            pending_jobs: session.pending_jobs_count(),
114            stop_reason_id: stop_id,
115            stop_reason: stop.caption(),
116            tool_calls: session.totals.tool_calls.load(Ordering::Acquire),
117            tool_allowed: session.totals.tool_allowed.load(Ordering::Acquire),
118            tool_blocked: session.totals.tool_blocked.load(Ordering::Acquire),
119            prompt_tokens: session.totals.prompt_tokens.load(Ordering::Acquire),
120            completion_tokens: session.totals.completion_tokens.load(Ordering::Acquire),
121            cached_tokens: session.totals.cached_tokens.load(Ordering::Acquire),
122            cost_usd_micros: session.totals.cost_usd_micros.load(Ordering::Acquire),
123            identity: Identity {
124                instance_uid: latest.instance_uid,
125                charter: latest.charter.name.clone(),
126                version: latest.version,
127                ttl_remaining_s: latest.ttl_remaining_s,
128            },
129            has_receipt: session.receipt.lock().is_some(),
130        }
131    }
132}
133
134#[derive(Serialize)]
135struct Stats {
136    generated_at_ms: u64,
137    session_count: usize,
138    open_count: usize,
139    closed_count: usize,
140    capture_failed_count: usize,
141    total_prompt_tokens: u64,
142    total_completion_tokens: u64,
143    total_cached_tokens: u64,
144    total_cost_usd_micros: u64,
145    total_tool_calls: u64,
146    total_tool_allowed: u64,
147    total_tool_blocked: u64,
148    workflow_signed: usize,
149    workflow_unsigned: usize,
150}
151
152/// GET /dashboard — HTML shell.
153pub async fn index() -> Response {
154    static_asset(INDEX_HTML, "text/html; charset=utf-8")
155}
156
157/// GET /dashboard/style.css — dashboard CSS.
158pub async fn style_css() -> Response {
159    static_asset(STYLE_CSS, "text/css; charset=utf-8")
160}
161
162/// GET /dashboard/app.js — dashboard app JS.
163pub async fn app_js() -> Response {
164    static_asset(APP_JS, "text/javascript; charset=utf-8")
165}
166
167fn static_asset(body: &'static str, content_type: &'static str) -> Response {
168    let mut response = Response::new(axum::body::Body::from(body));
169    response
170        .headers_mut()
171        .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
172    response.headers_mut().insert(
173        header::CACHE_CONTROL,
174        HeaderValue::from_static("no-store, max-age=0"),
175    );
176    response.headers_mut().insert(
177        HeaderName::from_static("x-content-type-options"),
178        HeaderValue::from_static("nosniff"),
179    );
180    // defense-in-depth against a future XSS regression
181    // in `highlightJson` or any innerHTML-writing dashboard code.
182    // The dashboard is a same-origin single-page app that only
183    // needs its own JS/CSS/img and its own /api/v1/dashboard/*
184    // fetches; a strict CSP shrinks the blast radius to zero even
185    // if attacker-controlled text ever reaches innerHTML.
186    // `frame-ancestors 'none'` + `X-Frame-Options: DENY` block
187    // clickjacking / iframe embed for the day the dashboard grows
188    // an identity gate. `Referrer-Policy: no-referrer` prevents
189    // session ids from leaking through an outbound link.
190    response.headers_mut().insert(
191        header::CONTENT_SECURITY_POLICY,
192        HeaderValue::from_static(
193            "default-src 'none'; script-src 'self'; style-src 'self'; \
194             img-src 'self' data:; font-src 'self'; \
195             connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; \
196             form-action 'none'",
197        ),
198    );
199    response.headers_mut().insert(
200        HeaderName::from_static("x-frame-options"),
201        HeaderValue::from_static("DENY"),
202    );
203    response
204        .headers_mut()
205        .insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
206    response
207}
208
209/// Wrap dashboard JSON responses with headers that
210/// forbid any intermediary shared cache from re-serving one caller's
211/// session detail to another. The static HTML/CSS/JS assets already
212/// set these; the JSON handlers didn't. Also sets `Vary:
213/// Authorization` for the day the dashboard gets an identity gate.
214fn no_store_json_response(value: impl serde::Serialize) -> Response {
215    let mut response = Json(value).into_response();
216    response.headers_mut().insert(
217        header::CACHE_CONTROL,
218        HeaderValue::from_static("no-store, max-age=0"),
219    );
220    response
221        .headers_mut()
222        .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
223    response
224        .headers_mut()
225        .insert(header::VARY, HeaderValue::from_static("Authorization"));
226    response.headers_mut().insert(
227        HeaderName::from_static("x-content-type-options"),
228        HeaderValue::from_static("nosniff"),
229    );
230    // Mirror the same anti-XSS / anti-clickjacking /
231    // anti-referrer-leak headers on JSON responses. A future
232    // dashboard client that navigates directly to
233    // /api/v1/dashboard/sessions/{id} in a top-level window must
234    // not become a referrer source for session-id leakage, and
235    // must not be framable.
236    response.headers_mut().insert(
237        header::CONTENT_SECURITY_POLICY,
238        HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'; base-uri 'none'"),
239    );
240    response.headers_mut().insert(
241        HeaderName::from_static("x-frame-options"),
242        HeaderValue::from_static("DENY"),
243    );
244    response
245        .headers_mut()
246        .insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
247    response
248}
249
250/// GET /api/v1/dashboard/sessions?limit=&status=
251pub async fn list_sessions(
252    State(state): State<AppState>,
253    axum::extract::Query(query): axum::extract::Query<ListQuery>,
254) -> Response {
255    let started = Instant::now();
256    let limit = query.limit.unwrap_or(DEFAULT_LIST_LIMIT).min(MAX_LIST_LIMIT);
257    let snapshot = state.sessions.open_sessions_including_closed();
258    let total_before_truncate = snapshot.len();
259    let mut summaries: Vec<SessionSummary> = snapshot
260        .iter()
261        .filter(|session| match query.status.as_deref() {
262            Some("open") => !session.is_closed(),
263            Some("closed") => session.is_closed(),
264            Some("failed") => session.capture_failed(),
265            _ => true,
266        })
267        .filter(|session| match query.workflow.as_deref() {
268            Some("signed") => session.workflow == Workflow::Signed,
269            Some("unsigned") => session.workflow == Workflow::Unsigned,
270            _ => true,
271        })
272        .map(|session| SessionSummary::from_session(session))
273        .collect();
274    let matched = summaries.len();
275    summaries.sort_by_key(|s| std::cmp::Reverse(s.last_activity_ms));
276    summaries.truncate(limit);
277    let response = no_store_json_response(json!({
278        "sessions": summaries,
279        "generated_at_ms": av_core::time::now_ms(),
280        // Total number of sessions currently in the registry (before any
281        // filter is applied). This is what /stats also sees.
282        "total_before_truncate": total_before_truncate,
283        // Number of sessions that matched the requested filter (before the
284        // `limit` cap). Useful for pagination hints and diagnostics.
285        "matched": matched,
286    }));
287    record(
288        &state,
289        crate::pipeline::DashboardEndpoint::List,
290        crate::pipeline::DashboardStatus::Ok,
291        started,
292    );
293    response
294}
295
296/// GET /api/v1/dashboard/sessions/{id}
297pub async fn session_detail(State(state): State<AppState>, Path(id): Path<String>) -> Response {
298    let started = Instant::now();
299    let Some(session) = state.sessions.get(&id) else {
300        record(
301            &state,
302            crate::pipeline::DashboardEndpoint::Detail,
303            crate::pipeline::DashboardStatus::NotFound,
304            started,
305        );
306        let mut response = no_store_json_response(json!({"error": "session not found in registry"}));
307        *response.status_mut() = StatusCode::NOT_FOUND;
308        return response;
309    };
310    let summary = SessionSummary::from_session(&session);
311    // Clone out under the lock and drop it before serialization so the
312    // reconciler / worker never blocks on a slow serde_json step.
313    let receipt_clone: Option<av_receipts::Receipt> = session.receipt.lock().clone();
314    // Previously returned `path.display().to_string()`,
315    // which disclosed the absolute spool directory (e.g.
316    // `/var/lib/agentvisor-ai/spool/<uid>.json`) to any unauthenticated
317    // caller of the dashboard. The dashboard's design intent (see
318    // module doc-comment) is "the same session metadata that lands on
319    // disk and in /metrics" — filesystem paths appear in neither.
320    // Disclosing the absolute layout gives a probe attacker a
321    // starting point for later privilege-escalation or path-
322    // traversal attempts. Return only the file name; the UI never
323    // uses the path for anything besides display.
324    let atif_filename: Option<String> = session
325        .atif_path
326        .lock()
327        .as_ref()
328        .and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned()));
329    // Chain head/count are exposed as a small provenance stub so the UI can
330    // show "47 events, head=b2b7…". The full chain lives in the journal on
331    // disk; we don't stream it through the dashboard.
332    let (chain_head_hex, chain_count) = {
333        let chain = session.chain.lock();
334        (chain.head_hex(), chain.count())
335    };
336    let receipt: Option<Value> = receipt_clone.and_then(|r| serde_json::to_value(&r).ok());
337    let response = no_store_json_response(json!({
338        "summary": summary,
339        "chain": {
340            "head_hex": chain_head_hex,
341            "count": chain_count,
342        },
343        "receipt": receipt,
344        "atif_filename": atif_filename,
345    }));
346    record(
347        &state,
348        crate::pipeline::DashboardEndpoint::Detail,
349        crate::pipeline::DashboardStatus::Ok,
350        started,
351    );
352    response
353}
354
355/// GET /api/v1/dashboard/stats
356pub async fn stats(State(state): State<AppState>) -> Response {
357    let started = Instant::now();
358    let sessions = state.sessions.open_sessions_including_closed();
359    let mut totals = Stats {
360        generated_at_ms: av_core::time::now_ms(),
361        session_count: sessions.len(),
362        open_count: 0,
363        closed_count: 0,
364        capture_failed_count: 0,
365        total_prompt_tokens: 0,
366        total_completion_tokens: 0,
367        total_cached_tokens: 0,
368        total_cost_usd_micros: 0,
369        total_tool_calls: 0,
370        total_tool_allowed: 0,
371        total_tool_blocked: 0,
372        workflow_signed: 0,
373        workflow_unsigned: 0,
374    };
375    for session in &sessions {
376        if session.is_closed() {
377            totals.closed_count += 1;
378        } else {
379            totals.open_count += 1;
380        }
381        if session.capture_failed() {
382            totals.capture_failed_count += 1;
383        }
384        match session.workflow {
385            Workflow::Signed => totals.workflow_signed += 1,
386            Workflow::Unsigned => totals.workflow_unsigned += 1,
387        }
388        totals.total_prompt_tokens = totals
389            .total_prompt_tokens
390            .saturating_add(session.totals.prompt_tokens.load(Ordering::Acquire));
391        totals.total_completion_tokens = totals
392            .total_completion_tokens
393            .saturating_add(session.totals.completion_tokens.load(Ordering::Acquire));
394        totals.total_cached_tokens = totals
395            .total_cached_tokens
396            .saturating_add(session.totals.cached_tokens.load(Ordering::Acquire));
397        totals.total_cost_usd_micros = totals
398            .total_cost_usd_micros
399            .saturating_add(session.totals.cost_usd_micros.load(Ordering::Acquire));
400        totals.total_tool_calls = totals
401            .total_tool_calls
402            .saturating_add(session.totals.tool_calls.load(Ordering::Acquire));
403        totals.total_tool_allowed = totals
404            .total_tool_allowed
405            .saturating_add(session.totals.tool_allowed.load(Ordering::Acquire));
406        totals.total_tool_blocked = totals
407            .total_tool_blocked
408            .saturating_add(session.totals.tool_blocked.load(Ordering::Acquire));
409    }
410    let response = no_store_json_response(totals);
411    record(
412        &state,
413        crate::pipeline::DashboardEndpoint::Stats,
414        crate::pipeline::DashboardStatus::Ok,
415        started,
416    );
417    response
418}
419
420/// Query parameters for `GET /api/v1/dashboard/sessions`.
421#[derive(Debug, Default, Clone, serde::Deserialize)]
422pub struct ListQuery {
423    limit: Option<usize>,
424    status: Option<String>,
425    workflow: Option<String>,
426}
427
428#[cfg(test)]
429mod tests {
430    #![allow(
431        clippy::unwrap_used,
432        clippy::expect_used,
433        clippy::panic,
434        clippy::indexing_slicing
435    )]
436
437    use crate::config::HarnessConfig;
438    use crate::pipeline::{AppState, SESSION_HEADER};
439    use crate::routes::build_router;
440    use av_bridge::{BusError, EventBus, PublishAck, StoredEvent};
441    use av_events::EventClass;
442    use av_receipts::Ed25519Signer;
443    use av_sandbox::{Sandbox, SandboxConfig};
444    use av_state::InMemoryStore;
445    use axum::body::{to_bytes, Body};
446    use axum::http::{header, HeaderValue, Method, Request, StatusCode};
447    use serde_json::Value;
448    use std::sync::Arc;
449    use tower::ServiceExt;
450
451    /// Mutation-run hardening (round 11): the static-asset handlers had
452    /// surviving Default-return mutants — an empty 200 for the CSS/JS
453    /// bundle renders a blank dashboard with no failing test. Pin that
454    /// each asset serves its non-empty bundle under the right type.
455    #[tokio::test]
456    async fn dashboard_assets_serve_their_bundles() {
457        for (handler, content_type, needle) in [
458            (super::style_css().await, "text/css; charset=utf-8", "{"),
459            (
460                super::app_js().await,
461                "text/javascript; charset=utf-8",
462                "function",
463            ),
464            (super::index().await, "text/html; charset=utf-8", "<html"),
465        ] {
466            assert_eq!(handler.status(), StatusCode::OK);
467            assert_eq!(
468                handler
469                    .headers()
470                    .get(header::CONTENT_TYPE)
471                    .and_then(|value| value.to_str().ok()),
472                Some(content_type)
473            );
474            let body = to_bytes(handler.into_body(), 1024 * 1024).await.unwrap();
475            let text = String::from_utf8_lossy(&body);
476            assert!(
477                text.contains(needle),
478                "{content_type} asset must carry its bundle (missing {needle:?})"
479            );
480        }
481    }
482
483    struct NullBus;
484
485    impl EventBus for NullBus {
486        fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
487            Ok(PublishAck {
488                topic: topic.to_owned(),
489                partition: 0,
490                offset: 0,
491            })
492        }
493
494        fn fetch(
495            &self,
496            _topic: &str,
497            _partition: u32,
498            _offset: u64,
499            _max: usize,
500        ) -> Result<Vec<StoredEvent>, BusError> {
501            Ok(Vec::new())
502        }
503
504        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
505            Ok(1)
506        }
507
508        fn topics(&self) -> Vec<String> {
509            EventClass::all()
510                .iter()
511                .map(|class| class.topic().to_owned())
512                .collect()
513        }
514    }
515
516    fn build_state(mut config: HarnessConfig) -> AppState {
517        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
518        AppState::new(
519            config,
520            Arc::new(InMemoryStore::new()),
521            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
522            Arc::new(NullBus),
523            None,
524            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
525        )
526        .unwrap()
527    }
528
529    async fn get_json(router: &axum::Router, path: &str) -> (StatusCode, Value) {
530        let response = router
531            .clone()
532            .oneshot(
533                Request::builder()
534                    .method(Method::GET)
535                    .uri(path)
536                    .body(Body::empty())
537                    .unwrap(),
538            )
539            .await
540            .unwrap();
541        let status = response.status();
542        let bytes = to_bytes(response.into_body(), 256 * 1024).await.unwrap();
543        let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
544        (status, value)
545    }
546
547    fn open_a_session(state: &AppState, id: &str) {
548        let mut headers = axum::http::HeaderMap::new();
549        headers.insert(SESSION_HEADER, HeaderValue::from_str(id).unwrap());
550        let payload = serde_json::json!({
551            "model": "test",
552            "messages": [{"role": "user", "content": "hello"}],
553        });
554        state.prepare_chat(&headers, payload, None).unwrap();
555    }
556
557    #[tokio::test]
558    async fn stats_endpoint_reports_empty_registry() {
559        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
560        let state = build_state(config);
561        let router = build_router(state);
562        let (status, body) = get_json(&router, "/api/v1/dashboard/stats").await;
563        assert_eq!(status, StatusCode::OK);
564        assert_eq!(body["session_count"], 0);
565        assert_eq!(body["open_count"], 0);
566        assert_eq!(body["closed_count"], 0);
567        assert_eq!(body["total_cost_usd_micros"], 0);
568    }
569
570    #[tokio::test]
571    async fn list_endpoint_returns_open_session() {
572        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
573        let state = build_state(config);
574        open_a_session(&state, "sess-abc");
575        let router = build_router(state);
576
577        let (status, body) = get_json(&router, "/api/v1/dashboard/sessions").await;
578        assert_eq!(status, StatusCode::OK);
579        let sessions = body["sessions"].as_array().unwrap();
580        assert_eq!(sessions.len(), 1);
581        assert_eq!(sessions[0]["id"], "sess-abc");
582        assert_eq!(sessions[0]["open"], true);
583        assert_eq!(sessions[0]["closed"], false);
584        assert_eq!(body["total_before_truncate"], 1);
585    }
586
587    #[tokio::test]
588    async fn list_endpoint_filters_by_status() {
589        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
590        let state = build_state(config);
591        open_a_session(&state, "sess-open");
592        // Mixed registry (mutation-run hardening, round 11): the filter
593        // arms and the stats accumulators were unpinned because every
594        // fixture held only OPEN sessions — deleting a match arm fell
595        // through to `_ => true` with identical results.
596        open_a_session(&state, "sess-closed");
597        {
598            let session = state.sessions.get("sess-closed").unwrap();
599            assert!(session.try_close());
600            session.mark_artifact_committed();
601            session.mark_close_complete();
602        }
603        open_a_session(&state, "sess-failed");
604        state.sessions.get("sess-failed").unwrap().mark_capture_failed();
605        let router = build_router(state);
606
607        let (_, all) = get_json(&router, "/api/v1/dashboard/sessions?status=all").await;
608        assert_eq!(all["sessions"].as_array().unwrap().len(), 3);
609
610        let (_, only_closed) = get_json(&router, "/api/v1/dashboard/sessions?status=closed").await;
611        let closed: Vec<&str> = only_closed["sessions"]
612            .as_array()
613            .unwrap()
614            .iter()
615            .map(|s| s["id"].as_str().unwrap())
616            .collect();
617        assert_eq!(closed, ["sess-closed"]);
618
619        let (_, only_open) = get_json(&router, "/api/v1/dashboard/sessions?status=open").await;
620        let mut open: Vec<&str> = only_open["sessions"]
621            .as_array()
622            .unwrap()
623            .iter()
624            .map(|s| s["id"].as_str().unwrap())
625            .collect();
626        open.sort_unstable();
627        assert_eq!(open, ["sess-failed", "sess-open"]);
628
629        let (_, only_failed) = get_json(&router, "/api/v1/dashboard/sessions?status=failed").await;
630        let failed: Vec<&str> = only_failed["sessions"]
631            .as_array()
632            .unwrap()
633            .iter()
634            .map(|s| s["id"].as_str().unwrap())
635            .collect();
636        assert_eq!(failed, ["sess-failed"]);
637
638        // The stats counters must partition the same mixed registry.
639        let (_, stats) = get_json(&router, "/api/v1/dashboard/stats").await;
640        assert_eq!(stats["session_count"], 3);
641        assert_eq!(stats["open_count"], 2);
642        assert_eq!(stats["closed_count"], 1);
643        assert_eq!(stats["capture_failed_count"], 1);
644        // Every session in this fixture is the default (unsigned)
645        // workflow — the per-workflow accumulators were unpinned too.
646        assert_eq!(
647            stats["workflow_signed"].as_u64().unwrap() + stats["workflow_unsigned"].as_u64().unwrap(),
648            3,
649            "the workflow partition must cover the whole registry"
650        );
651    }
652
653    #[tokio::test]
654    async fn detail_endpoint_returns_summary_shape() {
655        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
656        let state = build_state(config);
657        open_a_session(&state, "sess-detail");
658        let router = build_router(state);
659
660        let (status, body) = get_json(&router, "/api/v1/dashboard/sessions/sess-detail").await;
661        assert_eq!(status, StatusCode::OK);
662        assert_eq!(body["summary"]["id"], "sess-detail");
663        assert!(body["chain"].is_object());
664        assert!(body["receipt"].is_null(), "no receipt until session close");
665    }
666
667    #[tokio::test]
668    async fn detail_endpoint_404_for_unknown_id() {
669        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
670        let router = build_router(build_state(config));
671        let (status, _body) = get_json(&router, "/api/v1/dashboard/sessions/does-not-exist").await;
672        assert_eq!(status, StatusCode::NOT_FOUND);
673    }
674
675    #[tokio::test]
676    async fn dashboard_index_served_as_html() {
677        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
678        let router = build_router(build_state(config));
679        let response = router
680            .oneshot(
681                Request::builder()
682                    .method(Method::GET)
683                    .uri("/dashboard")
684                    .body(Body::empty())
685                    .unwrap(),
686            )
687            .await
688            .unwrap();
689        assert_eq!(response.status(), StatusCode::OK);
690        let ct = response
691            .headers()
692            .get(header::CONTENT_TYPE)
693            .cloned()
694            .unwrap_or_else(|| HeaderValue::from_static(""));
695        assert!(
696            ct.to_str().unwrap().starts_with("text/html"),
697            "unexpected content-type: {ct:?}"
698        );
699        let body = to_bytes(response.into_body(), 256 * 1024).await.unwrap();
700        let text = std::str::from_utf8(&body).unwrap();
701        assert!(text.contains("AgentVisor AI"), "index should mention product");
702    }
703
704    #[tokio::test]
705    async fn dashboard_disabled_returns_404() {
706        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
707        config.dashboard_enabled = false;
708        let router = build_router(build_state(config));
709        let response = router
710            .oneshot(
711                Request::builder()
712                    .method(Method::GET)
713                    .uri("/dashboard")
714                    .body(Body::empty())
715                    .unwrap(),
716            )
717            .await
718            .unwrap();
719        assert_eq!(response.status(), StatusCode::NOT_FOUND);
720    }
721
722    #[tokio::test]
723    async fn list_endpoint_reports_matched_count_and_registry_total() {
724        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
725        let state = build_state(config);
726        open_a_session(&state, "sess-open-1");
727        open_a_session(&state, "sess-open-2");
728        let router = build_router(state);
729
730        let (_, all) = get_json(&router, "/api/v1/dashboard/sessions").await;
731        assert_eq!(all["total_before_truncate"], 2);
732        assert_eq!(all["matched"], 2);
733
734        let (_, closed_only) = get_json(&router, "/api/v1/dashboard/sessions?status=closed").await;
735        // total_before_truncate reflects the registry size (2), matched
736        // only counts sessions passing the filter (0 closed).
737        assert_eq!(closed_only["total_before_truncate"], 2);
738        assert_eq!(closed_only["matched"], 0);
739        assert_eq!(closed_only["sessions"].as_array().unwrap().len(), 0);
740    }
741
742    #[tokio::test]
743    async fn list_endpoint_limit_clamps_at_max() {
744        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
745        let state = build_state(config);
746        for i in 0..3 {
747            open_a_session(&state, &format!("sess-{i}"));
748        }
749        let router = build_router(state);
750        let (_, over) = get_json(&router, "/api/v1/dashboard/sessions?limit=99999").await;
751        // 3 sessions, cap is MAX_LIST_LIMIT — should return the 3 we have.
752        assert_eq!(over["sessions"].as_array().unwrap().len(), 3);
753
754        let (_, zero) = get_json(&router, "/api/v1/dashboard/sessions?limit=0").await;
755        assert_eq!(zero["sessions"].as_array().unwrap().len(), 0);
756        assert_eq!(zero["matched"], 3, "limit does not hide the matched total");
757    }
758
759    #[tokio::test]
760    async fn list_endpoint_returns_400_on_invalid_limit() {
761        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
762        let router = build_router(build_state(config));
763        let response = router
764            .oneshot(
765                Request::builder()
766                    .method(Method::GET)
767                    .uri("/api/v1/dashboard/sessions?limit=abc")
768                    .body(Body::empty())
769                    .unwrap(),
770            )
771            .await
772            .unwrap();
773        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
774    }
775
776    #[tokio::test]
777    async fn detail_endpoint_returns_receipt_when_present() {
778        // Round-trip a signed receipt into a Session and confirm the
779        // dashboard surfaces it as JSON with the signature intact —
780        // catching any regression that would silently drop cryptographic
781        // material from the operator view.
782        use av_receipts::{CostSummary, Receipt, ReceiptBody, ReceiptSubject, Signer as _, ToolCallSummary};
783        use base64::Engine as _;
784        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
785        let state = build_state(config);
786        open_a_session(&state, "sess-with-receipt");
787        let session = state.sessions.get("sess-with-receipt").unwrap();
788        let signer = Ed25519Signer::from_seed(&[7; 32]);
789        let key_id = signer.key_id().to_owned();
790        let public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
791        let body = ReceiptBody {
792            receipt_version: 1,
793            receipt_id: "test-receipt".to_owned(),
794            session_id: session.id.clone(),
795            issued_at: 1_700_000_000_000,
796            issued_at_iso: "2023-11-14T22:13:20.000Z".to_owned(),
797            ai_agent: session.identity.clone(),
798            subject: ReceiptSubject::EventChain {
799                chain_head: "ab".repeat(32),
800                event_count: 1,
801            },
802            tool_calls: ToolCallSummary::default(),
803            cost: CostSummary::default(),
804            stop_reason_id: 1,
805            stop_reason: "Stop".to_owned(),
806            key_id,
807            public_key_b64,
808        };
809        let receipt = Receipt::issue(body, &signer).expect("sign");
810        session.restore_receipt(receipt.clone());
811        let router = build_router(state.clone());
812        let (status, body) = get_json(&router, "/api/v1/dashboard/sessions/sess-with-receipt").await;
813        assert_eq!(status, StatusCode::OK);
814        assert_eq!(body["summary"]["has_receipt"], true);
815        assert_eq!(body["receipt"]["receipt_id"], "test-receipt");
816        assert!(
817            !body["receipt"]["signature_b64"].as_str().unwrap().is_empty(),
818            "signature must round-trip",
819        );
820    }
821
822    #[tokio::test]
823    async fn stats_endpoint_aggregates_totals_across_sessions() {
824        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
825        let state = build_state(config);
826        open_a_session(&state, "sess-a");
827        open_a_session(&state, "sess-b");
828        let sess_a = state.sessions.get("sess-a").unwrap();
829        let sess_b = state.sessions.get("sess-b").unwrap();
830        sess_a
831            .totals
832            .cost_usd_micros
833            .store(1_000_000, std::sync::atomic::Ordering::Release);
834        sess_a
835            .totals
836            .tool_calls
837            .store(5, std::sync::atomic::Ordering::Release);
838        sess_a
839            .totals
840            .tool_blocked
841            .store(1, std::sync::atomic::Ordering::Release);
842        sess_b
843            .totals
844            .cost_usd_micros
845            .store(500_000, std::sync::atomic::Ordering::Release);
846        sess_b
847            .totals
848            .tool_calls
849            .store(2, std::sync::atomic::Ordering::Release);
850        let router = build_router(state.clone());
851        let (status, body) = get_json(&router, "/api/v1/dashboard/stats").await;
852        assert_eq!(status, StatusCode::OK);
853        assert_eq!(body["session_count"], 2);
854        assert_eq!(body["total_cost_usd_micros"], 1_500_000);
855        assert_eq!(body["total_tool_calls"], 7);
856        assert_eq!(body["total_tool_blocked"], 1);
857    }
858
859    #[tokio::test]
860    async fn dashboard_endpoints_register_prometheus_metrics() {
861        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
862        let state = build_state(config);
863        open_a_session(&state, "sess-metrics");
864        let metrics_registry = std::sync::Arc::clone(&state.metrics);
865        let router = build_router(state.clone());
866        let (_, _) = get_json(&router, "/api/v1/dashboard/stats").await;
867        let (_, _) = get_json(&router, "/api/v1/dashboard/sessions").await;
868        let (_, _) = get_json(&router, "/api/v1/dashboard/sessions/sess-metrics").await;
869        let (_, _) = get_json(&router, "/api/v1/dashboard/sessions/does-not-exist").await;
870        let rendered = metrics_registry.render();
871        for line in [
872            "av_dashboard_requests_total{endpoint=\"stats\",status=\"ok\"} 1",
873            "av_dashboard_requests_total{endpoint=\"list\",status=\"ok\"} 1",
874            "av_dashboard_requests_total{endpoint=\"detail\",status=\"ok\"} 1",
875            "av_dashboard_requests_total{endpoint=\"detail\",status=\"not_found\"} 1",
876            "av_dashboard_request_duration_seconds_count{endpoint=\"stats\"} 1",
877        ] {
878            assert!(
879                rendered.contains(line),
880                "expected `{line}` in metrics output:\n{rendered}",
881            );
882        }
883    }
884
885    #[tokio::test]
886    async fn dashboard_survives_concurrent_reads_under_hot_path_writes() {
887        // 128 parallel dashboard reads while the hot path is admitting
888        // more sessions — verifies the observability code does not
889        // deadlock, crash on shard lock contention, or panic on a
890        // registry that grows mid-scan.
891        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
892        let state = build_state(config);
893        // Seed enough sessions that iteration touches every DashMap
894        // shard and shows non-trivial payloads to the readers.
895        for i in 0..64 {
896            open_a_session(&state, &format!("seed-{i}"));
897        }
898        let router = build_router(state.clone());
899
900        let mut readers = Vec::new();
901        for _ in 0..128 {
902            let router = router.clone();
903            readers.push(tokio::spawn(async move {
904                let (status, body) = get_json(&router, "/api/v1/dashboard/stats").await;
905                assert_eq!(status, StatusCode::OK);
906                assert!(body["session_count"].as_u64().unwrap() >= 64);
907            }));
908        }
909        // Simultaneously open more sessions on the hot path.
910        let mut writers = Vec::new();
911        for i in 64..96 {
912            let state = state.clone();
913            writers.push(tokio::spawn(async move {
914                let mut headers = axum::http::HeaderMap::new();
915                headers.insert(
916                    SESSION_HEADER,
917                    HeaderValue::from_str(&format!("hot-{i}")).unwrap(),
918                );
919                let payload = serde_json::json!({
920                    "model": "test",
921                    "messages": [{"role":"user","content":"go"}],
922                });
923                state.prepare_chat(&headers, payload, None).unwrap();
924            }));
925        }
926        for reader in readers {
927            reader.await.unwrap();
928        }
929        for writer in writers {
930            writer.await.unwrap();
931        }
932    }
933}