use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse,
},
};
use futures::StreamExt as _;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use lopi_memory::{TaskRow, TaskStatusCounts};
use super::AppState;
fn build_snapshot(
rows: &[TaskRow],
costs: &HashMap<String, f64>,
counts: TaskStatusCounts,
uptime_secs: u64,
) -> serde_json::Value {
json!({
"type": "snapshot",
"tasks": rows.iter().map(|t| json!({
"id": t.id, "goal": t.goal, "status": t.status,
"created_at": t.created_at,
"cost": costs.get(&t.id).copied().unwrap_or(0.0),
})).collect::<Vec<_>>(),
"stats": {
"running": counts.running,
"queued": counts.queued,
"succeeded": counts.succeeded,
"failed": counts.failed,
"uptime_secs": uptime_secs,
}
})
}
pub(super) async fn sse_handler(State(s): State<AppState>) -> impl IntoResponse {
let rx = s.serialized_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|r: Result<Arc<str>, _>| async move {
match r {
Ok(json) => Some(Ok::<Event, std::convert::Infallible>(
Event::default().data(json.as_ref()),
)),
Err(_) => None, }
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
pub(super) async fn ws_handler(
ws: WebSocketUpgrade,
State(s): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws(socket, s))
}
pub(super) async fn handle_ws(mut socket: WebSocket, state: AppState) {
if let Ok(rows) = state.store.load_history(100).await {
let costs = state.store.task_costs().await.unwrap_or_default();
let counts = state.store.status_counts().await.unwrap_or_default();
let snapshot = build_snapshot(&rows, &costs, counts, state.pool.stats().uptime_secs);
if socket
.send(Message::Text(snapshot.to_string()))
.await
.is_err()
{
return;
}
}
let mut rx = state.serialized_tx.subscribe();
loop {
match rx.recv().await {
Ok(json) => {
if socket
.send(Message::Text(json.as_ref().to_string()))
.await
.is_err()
{
return;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("ws subscriber lagged {n} events");
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn row(id: &str, status: &str) -> TaskRow {
TaskRow {
id: id.to_string(),
goal: format!("goal for {id}"),
status: status.to_string(),
created_at: "2026-07-16T00:00:00Z".to_string(),
completed_at: None,
client_ref: None,
branch: None,
parent_task: None,
chain_depth: 0,
}
}
fn counts() -> TaskStatusCounts {
TaskStatusCounts {
running: 1,
queued: 2,
succeeded: 3,
failed: 4,
}
}
#[test]
fn build_snapshot_looks_up_cost_per_task_id() {
let rows = vec![row("t1", "done"), row("t2", "running")];
let costs = HashMap::from([("t1".to_string(), 1.25)]);
let snapshot = build_snapshot(&rows, &costs, counts(), 42);
let tasks = snapshot["tasks"].as_array().unwrap();
assert_eq!(tasks[0]["id"], "t1");
assert_eq!(tasks[0]["cost"], 1.25);
assert_eq!(
tasks[1]["cost"], 0.0,
"a task with no entry in the cost map defaults to 0.0, not null/missing"
);
}
#[test]
fn build_snapshot_carries_status_counts_and_uptime_through() {
let snapshot = build_snapshot(&[], &HashMap::new(), counts(), 42);
assert_eq!(snapshot["type"], "snapshot");
assert_eq!(snapshot["stats"]["running"], 1);
assert_eq!(snapshot["stats"]["queued"], 2);
assert_eq!(snapshot["stats"]["succeeded"], 3);
assert_eq!(snapshot["stats"]["failed"], 4);
assert_eq!(snapshot["stats"]["uptime_secs"], 42);
assert_eq!(
snapshot["tasks"].as_array().unwrap().len(),
0,
"no task rows means an empty tasks array, not an omitted field"
);
}
}