//! Stats + loose-ends + bash-tasks read endpoints. use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct StatsQuery { window: Option, } pub(super) async fn api_stats( State(state): State, axum::extract::Query(q): axum::extract::Query, ) -> axum::Json { let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h")); let mut snapshot = crate::stats::snapshot_default(window); // Pass the window span to the reminder-stats RPC so the broker // filters its counts to the same time range as the chart data. let window_secs = window.span_secs(); let window_secs_u = u64::try_from(window_secs).unwrap_or(0); snapshot.reminder_stats = fetch_reminder_stats(&state.socket, window_secs_u).await; axum::Json(snapshot) } /// Fetch reminder activity stats from the broker via the per-agent / manager /// socket. Returns None on any transport / decode failure — the stats are /// decorative, not authoritative. async fn fetch_reminder_stats( socket: &std::path::Path, window_secs: u64, ) -> Option { match super::broker_request( socket, &hive_sh4re::Request::ReminderRollup { since_secs: window_secs, agent: None, }, ) .await { Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats), _ => None, } } /// Proxy this agent's loose-ends list via the per-agent socket. The /// web UI surfaces the result as a collapsible section in the page /// so the operator can see at a glance what's pending against the /// agent (questions asked by it, peer questions targeting it, /// reminders it scheduled, approvals for the manager). Same data /// the `mcp__hyperhive__get_loose_ends` tool sees from inside the /// container. pub(super) async fn api_loose_ends(State(state): State) -> Response { match super::broker_request( &state.socket, &hive_sh4re::Request::GetLooseEnds { agent: None }, ) .await { Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => { axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() } Ok(hive_sh4re::Response::Err { message }) => error_response( StatusCode::INTERNAL_SERVER_ERROR, &format!("get_loose_ends: {message}"), ), Ok(other) => error_response( StatusCode::INTERNAL_SERVER_ERROR, &format!("get_loose_ends: unexpected response: {other:?}"), ), Err(e) => super::broker_error_response(&e, "get_loose_ends"), } } /// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks. /// /// The `hive-bash-mcp` daemon runs in this same container and writes one /// `.json` ([`hive_sh4re::TaskFile`]) per task under the harness /// `bash-tasks/` dir. This reads that dir and returns the tasks still /// `Pending` or `Running`, so the agent page can show what's running without /// going through the broker. Snapshot only — the page polls/refreshes it like /// `/api/loose-ends`; there's no live SSE push for task state yet. Unreadable /// or malformed files (incl. the daemon's `.json.tmp` scratch writes, which /// don't match the `.json` extension) are skipped so one stray file can't /// fail the whole list. pub(super) async fn api_bash_tasks() -> Response { let dir = crate::paths::harness_dir().join("bash-tasks"); // The dir scan + per-file reads are blocking fs I/O; run them off the // async executor so a slow or large tasks dir can't stall other requests. let tasks = tokio::task::spawn_blocking(move || { let mut tasks: Vec = Vec::new(); let Ok(rd) = std::fs::read_dir(&dir) else { return tasks; }; for entry in rd.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue; }; let Ok(task) = serde_json::from_str::(&text) else { continue; }; if matches!( task.status, hive_sh4re::TaskStatus::Pending | hive_sh4re::TaskStatus::Running ) { tasks.push(task); } } // Running before Pending, then oldest-first so a long-runner sits on top. tasks.sort_by(|a, b| { let rank = |s: &hive_sh4re::TaskStatus| match s { hive_sh4re::TaskStatus::Running => 0, _ => 1, }; rank(&a.status) .cmp(&rank(&b.status)) .then(a.created_at.cmp(&b.created_at)) }); tasks }) .await .unwrap_or_default(); axum::Json(serde_json::json!({ "tasks": tasks })).into_response() }