use super::AppState;
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json},
};
use lopi_memory::{LoopAttemptRow, LoopTurnRow};
use serde_json::{json, Value};
const ATTEMPT_WINDOW: i64 = 60;
const TURN_WINDOW: i64 = 60;
pub(super) async fn get_loop_health(State(s): State<AppState>) -> impl IntoResponse {
let attempts = s
.store
.recent_loop_attempts(ATTEMPT_WINDOW)
.await
.unwrap_or_default();
let outcomes = s.store.loop_outcome_counts().await.unwrap_or_default();
let turns = s
.store
.recent_turn_metrics(TURN_WINDOW)
.await
.unwrap_or_default();
let (verifier_passed, verifier_total) = s.store.verifier_pass_rate().await.unwrap_or((0, 0));
(
StatusCode::OK,
Json(json!({
"stats": headline_stats(&attempts, &turns, verifier_passed, verifier_total),
"attempts": attempt_series(&attempts),
"outcomes": outcome_series(&outcomes),
"burn": burn_series(&turns),
})),
)
.into_response()
}
fn headline_stats(
attempts: &[LoopAttemptRow],
turns: &[LoopTurnRow],
verifier_passed: i64,
verifier_total: i64,
) -> Value {
let total_attempts = attempts.len();
let successes = attempts.iter().filter(|a| a.outcome == "success").count();
let runs: std::collections::HashSet<&str> =
attempts.iter().map(|a| a.task_id.as_str()).collect();
let spend: f64 = turns.iter().map(|t| t.estimated_cost_usd).sum();
let tokens: i64 = turns.iter().map(|t| t.input_tokens + t.output_tokens).sum();
json!({
"runs": runs.len(),
"attempts": total_attempts,
"success_rate": ratio(successes, total_attempts),
"verifier_pass_rate": ratio_i(verifier_passed, verifier_total),
"verifier_total": verifier_total,
"spend_usd": spend,
"tokens": tokens,
})
}
fn attempt_series(attempts: &[LoopAttemptRow]) -> Vec<Value> {
attempts
.iter()
.rev()
.map(|a| {
json!({
"task_id": a.task_id,
"attempt": a.attempt_num,
"test_pass_rate": a.test_pass_rate.unwrap_or(0.0),
"lint_errors": a.lint_errors.unwrap_or(0),
"diff_lines": a.diff_lines.unwrap_or(0),
"outcome": a.outcome,
"created_at": a.created_at,
})
})
.collect()
}
fn outcome_series(outcomes: &[(String, i64)]) -> Vec<Value> {
outcomes
.iter()
.map(|(label, count)| json!({ "label": label, "count": count }))
.collect()
}
fn burn_series(turns: &[LoopTurnRow]) -> Vec<Value> {
turns
.iter()
.rev()
.map(|t| {
json!({
"cost_usd": t.estimated_cost_usd,
"tokens": t.input_tokens + t.output_tokens,
"context_pressure": t.context_pressure,
"timestamp": t.timestamp,
})
})
.collect()
}
fn ratio(num: usize, den: usize) -> f64 {
if den == 0 {
0.0
} else {
num as f64 / den as f64
}
}
fn ratio_i(num: i64, den: i64) -> f64 {
if den == 0 {
0.0
} else {
num as f64 / den as f64
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn row(task: &str, n: i64, pass: f64, outcome: &str) -> LoopAttemptRow {
LoopAttemptRow {
task_id: task.into(),
attempt_num: n,
test_pass_rate: Some(pass),
lint_errors: Some(0),
diff_lines: Some(10),
outcome: outcome.into(),
created_at: format!("2026-06-21T00:0{n}:00Z"),
}
}
#[test]
fn ratio_guards_zero_denominator() {
assert_eq!(ratio(0, 0), 0.0);
assert_eq!(ratio(1, 2), 0.5);
assert_eq!(ratio_i(0, 0), 0.0);
assert_eq!(ratio_i(3, 4), 0.75);
}
#[test]
fn headline_counts_distinct_runs_and_successes() {
let attempts = vec![
row("t1", 1, 0.4, "retry"),
row("t1", 2, 1.0, "success"),
row("t2", 1, 1.0, "success"),
];
let stats = headline_stats(&attempts, &[], 1, 2);
assert_eq!(stats["runs"], 2);
assert_eq!(stats["attempts"], 3);
assert!((stats["success_rate"].as_f64().unwrap() - 2.0 / 3.0).abs() < 1e-9);
assert_eq!(stats["verifier_pass_rate"], 0.5);
}
#[test]
fn attempt_series_is_time_ordered_oldest_first() {
let attempts = vec![row("t1", 2, 1.0, "success"), row("t1", 1, 0.4, "retry")];
let series = attempt_series(&attempts);
assert_eq!(series[0]["attempt"], 1);
assert_eq!(series[1]["attempt"], 2);
}
#[test]
fn outcome_series_maps_pairs() {
let series = outcome_series(&[("retry".into(), 2), ("success".into(), 1)]);
assert_eq!(series[0]["label"], "retry");
assert_eq!(series[0]["count"], 2);
}
}