ai-dispatch 8.99.5

Multi-AI CLI team orchestrator
// Agent performance dashboard for the `aid stats` command.
// Exports: run(). Deps: crate::store::Store, crate::types, crate::usage, crate::cost.

use anyhow::Result;
use chrono::{DateTime, Local, Timelike};
use std::collections::{BTreeMap, HashMap};

use super::stats_hint;
use crate::cost;
use crate::store::Store;
use crate::types::{AgentKind, Task, TaskStatus};
use crate::usage::UsageWindow;

#[derive(Debug, PartialEq)] struct StatsSnapshot { agent_rows: Vec<AgentRow>, failure_rows: Vec<FailureRow>, model_rows: Vec<ModelRow>, activity_by_day: Vec<(String, usize)>, activity_by_hour: Vec<(u32, usize)>, top_sessions: Vec<TopSession>, total_cost: f64, total_tokens: i64, total_tasks: usize }
#[derive(Debug, PartialEq)] struct AgentRow { agent: String, tasks: usize, share_pct: usize, success_rate: f64, avg_duration_ms: Option<i64>, cost: String }
#[derive(Debug, PartialEq)] struct FailureRow { label: String, tasks: usize, agents: Vec<(String, usize)> }
#[derive(Debug, PartialEq)] struct ModelRow { model: String, tasks: usize, cost: String }
#[derive(Debug, PartialEq)] struct TopSession { task_id: String, agent: String, label: &'static str, value: String }

pub fn run(store: &Store, window: String, agent: Option<String>, insights: bool) -> Result<()> {
    let window = UsageWindow::parse(&window)?;
    let stats = collect(store, window, agent.as_deref(), Local::now())?;
    print!("{}", render_output(&stats, window, insights, agent.is_some()));
    Ok(())
}

fn collect(store: &Store, window: UsageWindow, agent: Option<&str>, now: DateTime<Local>) -> Result<StatsSnapshot> {
    let tasks: Vec<Task> = store.list_tasks(crate::types::TaskFilter::All)?.into_iter().filter(|t| matches!(window.range(now), Some((s, e)) if t.created_at >= s && t.created_at <= e) || matches!(window, UsageWindow::All)).filter(|t| agent.is_none_or(|name| t.agent_display_name().eq_ignore_ascii_case(name))).collect();
    let mut agents: BTreeMap<String, (AgentKind, usize, usize, usize, i64, usize, f64)> = BTreeMap::new();
    let mut failures: HashMap<String, (usize, BTreeMap<String, usize>)> = HashMap::new();
    let mut models: BTreeMap<String, (usize, f64, AgentKind)> = BTreeMap::new();
    let (mut day_counts, mut hour_counts, mut total_cost, mut total_tokens, mut total_tasks) = (HashMap::new(), [0usize; 24], 0.0, 0, 0);
    let (mut longest, mut most_tokens, mut highest_cost) = (None, None, None);
    for task in &tasks {
        let cost_usd = task_cost(task);
        let row = agents.entry(task.agent_display_name().to_string()).or_insert((task.agent, 0, 0, 0, 0, 0, 0.0));
        row.1 += 1;
        row.2 += usize::from(matches!(task.status, TaskStatus::Done | TaskStatus::Merged));
        row.3 += usize::from(task.status != TaskStatus::Waiting);
        if let Some(ms) = task.duration_ms { row.4 += ms; row.5 += 1; }
        row.6 += cost_usd;
        let model = task.model.clone().unwrap_or_else(|| "unknown".to_string());
        let model_row = models.entry(model).or_insert((0, 0.0, task.agent));
        model_row.0 += 1;
        model_row.1 += cost_usd;
        *day_counts.entry(task.created_at.format("%a").to_string()).or_default() += 1;
        hour_counts[task.created_at.hour() as usize] += 1;
        total_cost += cost_usd;
        total_tokens += task.tokens.unwrap_or(0);
        total_tasks += 1;
        if let Some(ms) = task.duration_ms { if longest.as_ref().is_none_or(|(_, best)| ms > *best) { longest = Some((task, ms)); } }
        let tokens = task.tokens.unwrap_or(0);
        if most_tokens.as_ref().is_none_or(|(_, best)| tokens > *best) { most_tokens = Some((task, tokens)); }
        if highest_cost.as_ref().is_none_or(|(_, best)| cost_usd > *best) { highest_cost = Some((task, cost_usd)); }
        if task.status == TaskStatus::Failed {
            let label = classify_failure(store.latest_error(task.id.as_str()).as_deref(), task.exit_code);
            let entry = failures.entry(label).or_insert((0, BTreeMap::new()));
            entry.0 += 1;
            *entry.1.entry(task.agent_display_name().to_string()).or_default() += 1;
        }
    }
    let mut agent_rows: Vec<_> = agents.into_iter().map(|(agent, (kind, tasks, success, success_base, duration_ms, duration_count, cost_usd))| AgentRow {
        agent, tasks, share_pct: usage_share(tasks, total_tasks), success_rate: if success_base == 0 { 0.0 } else { success as f64 * 100.0 / success_base as f64 }, avg_duration_ms: (duration_count > 0).then(|| duration_ms / duration_count as i64), cost: cost::format_cost_label(Some(cost_usd), kind),
    }).collect();
    agent_rows.sort_by(|a, b| b.tasks.cmp(&a.tasks).then_with(|| a.agent.cmp(&b.agent)));
    let mut failure_rows: Vec<_> = failures.into_iter().map(|(label, (tasks, agents))| {
        let mut agents: Vec<_> = agents.into_iter().collect();
        agents.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
        FailureRow { label, tasks, agents }
    }).collect();
    failure_rows.sort_by(|a, b| b.tasks.cmp(&a.tasks).then_with(|| a.label.cmp(&b.label)));
    failure_rows.truncate(5);
    let mut model_rows: Vec<_> = models.into_iter().map(|(model, (tasks, cost_usd, kind))| ModelRow {
        model, tasks, cost: cost::format_cost_label(Some(cost_usd), kind),
    }).collect();
    model_rows.sort_by(|a, b| b.tasks.cmp(&a.tasks).then_with(|| a.model.cmp(&b.model)));
    Ok(StatsSnapshot {
        agent_rows, failure_rows, model_rows, total_cost, total_tokens, total_tasks,
        activity_by_day: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].into_iter().map(|day| (day.to_string(), *day_counts.get(day).unwrap_or(&0))).collect(),
        activity_by_hour: hour_counts.into_iter().enumerate().map(|(hour, count)| (hour as u32, count)).collect(),
        top_sessions: [
            longest.map(|(task, ms)| TopSession { task_id: task.id.to_string(), agent: task.agent_display_name().to_string(), label: "Longest", value: format_duration(Some(ms)) }),
            most_tokens.map(|(task, tokens)| TopSession { task_id: task.id.to_string(), agent: task.agent_display_name().to_string(), label: "Most tokens", value: format_tokens(tokens) }),
            highest_cost.map(|(task, cost_usd)| TopSession { task_id: task.id.to_string(), agent: task.agent_display_name().to_string(), label: "Highest cost", value: cost::format_cost(Some(cost_usd)) }),
        ].into_iter().flatten().collect(),
    })
}

fn render_output(stats: &StatsSnapshot, window: UsageWindow, insights: bool, filtered_agent: bool) -> String {
    if stats.agent_rows.is_empty() {
        return format!("No tasks matched the selected filters for {}.\n", window.description());
    }
    render(stats, window, insights, filtered_agent)
}

fn render(stats: &StatsSnapshot, window: UsageWindow, insights: bool, filtered_agent: bool) -> String {
    let mut out = format!("Agent Performance ({})\n", window.description());
    for row in &stats.agent_rows { out.push_str(&format!("  {:<10} {:>3} tasks  {:>3}% share  {:>3.0}% success  avg {:<7}  {}\n", row.agent, row.tasks, row.share_pct, row.success_rate, format_duration(row.avg_duration_ms), row.cost)); }
    if stats.agent_rows.is_empty() { out.push_str("  (none)\n"); }
    out.push_str("\nTop Failure Causes\n");
    for (index, row) in stats.failure_rows.iter().enumerate() {
        let breakdown = row.agents.iter().map(|(agent, count)| format!("{agent}: {count}")).collect::<Vec<_>>().join(", ");
        out.push_str(&format!("  {}. {:<28} {:>3} tasks  ({})\n", index + 1, row.label, row.tasks, breakdown));
    }
    if stats.failure_rows.is_empty() { out.push_str("  (none)\n"); }
    out.push_str("\nModel Usage\n");
    for row in &stats.model_rows { out.push_str(&format!("  {:<18} {:>3} tasks  {}\n", row.model, row.tasks, row.cost)); }
    if stats.model_rows.is_empty() { out.push_str("  (none)\n"); }
    out.push_str(&format!("\nOverview\n  Total: {} tasks  {} tokens  {}\n", stats.total_tasks, format_tokens(stats.total_tokens), cost::format_cost(Some(stats.total_cost))));
    if let Some(hint) = stats.agent_rows.first().and_then(|row| stats_hint::diversification_hint(&row.agent, row.share_pct, stats.total_tasks, filtered_agent)) {
        out.push_str(&format!("  {hint}\n"));
    }
    if insights {
        push_bars(&mut out, "Activity by Day", &stats.activity_by_day);
        push_bars(&mut out, "Activity by Hour", &stats.activity_by_hour.iter().map(|(hour, count)| (format!("{hour:02}"), *count)).collect::<Vec<_>>());
    }
    out.push_str("\nTop Sessions\n");
    for row in &stats.top_sessions { out.push_str(&format!("  {:<13} {:<7} {:<7} {}\n", format!("{}:", row.label), row.task_id, row.agent, row.value)); }
    if stats.top_sessions.is_empty() { out.push_str("  (none)\n"); }
    out
}

fn push_bars(out: &mut String, title: &str, rows: &[(String, usize)]) {
    out.push_str(&format!("\n{title}\n"));
    let max = rows.iter().map(|(_, count)| *count).max().unwrap_or(0);
    for (label, count) in rows {
        let width = (count * 30).checked_div(max).unwrap_or(0);
        out.push_str(&format!("  {:<3} {:<30} {}\n", label, "█".repeat(width), count));
    }
}

fn classify_failure(detail: Option<&str>, exit_code: Option<i32>) -> String {
    let text = detail.unwrap_or("unknown failure");
    let lower = text.to_ascii_lowercase();
    if lower.contains("verify failed") { "verify failed".to_string() } else if lower.contains("hung: no output") { "agent hung: no output".to_string() } else if lower.contains("usage limit") || lower.contains("quota") { "usage limit".to_string() } else if let Some(idx) = lower.find("exit code ") {
        let suffix = &text[idx..];
        suffix.split_whitespace().take(3).collect::<Vec<_>>().join(" ")
    } else { exit_code.map(|code| format!("exit code {code}")).unwrap_or_else(|| text.to_string()) }
}

fn format_duration(duration_ms: Option<i64>) -> String {
    let secs = duration_ms.unwrap_or(0) / 1_000;
    match (secs / 60, secs % 60) { (0, s) => format!("{s}s"), (m, 0) => format!("{m}m"), (m, s) => format!("{m}m {s}s") }
}

fn format_tokens(tokens: i64) -> String {
    if tokens >= 1_000_000 { format!("{:.1}M", tokens as f64 / 1_000_000.0) } else if tokens >= 1_000 { format!("{:.1}k", tokens as f64 / 1_000.0) } else { tokens.to_string() }
}

fn usage_share(tasks: usize, total_tasks: usize) -> usize {
    (tasks * 100).checked_div(total_tasks).unwrap_or(0)
}

fn task_cost(task: &Task) -> f64 {
    task.cost_usd.unwrap_or_else(|| {
        if matches!(task.agent, AgentKind::Cursor | AgentKind::Copilot) {
            0.0
        } else {
            cost::estimate_cost(task.tokens.unwrap_or(0), task.model.as_deref(), task.agent)
                .unwrap_or(0.0)
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;
    use crate::types::{EventKind, TaskEvent, TaskId, VerifyStatus};

    fn task(id: &str, agent: AgentKind, status: TaskStatus, age_days: i64, model: &str, cost_usd: Option<f64>, duration_ms: Option<i64>, tokens: i64) -> Task {
        Task { id: TaskId(id.to_string()), agent, custom_agent_name: None, prompt: "prompt".to_string(), resolved_prompt: None, category: None, status, parent_task_id: None, workgroup_id: None, caller_kind: None, caller_session_id: None, agent_session_id: None, repo_path: None, worktree_path: None, worktree_branch: None, start_sha: None, log_path: None, output_path: None, tokens: Some(tokens), prompt_tokens: None, duration_ms, model: Some(model.to_string()), cost_usd, exit_code: None, created_at: Local::now() - Duration::days(age_days), completed_at: None, verify: None, verify_status: VerifyStatus::Skipped, pending_reason: None, read_only: false, budget: false, audit_verdict: None, audit_report_path: None, delivery_assessment: None }
    }

    #[test]
    fn collects_agent_failure_and_model_stats() {
        let store = Store::open_memory().unwrap();
        let now = Local::now();
        for task in [task("t-1", AgentKind::Codex, TaskStatus::Done, 1, "gpt-5.4", Some(10.0), Some(120_000), 1_000), task("t-2", AgentKind::Codex, TaskStatus::Failed, 2, "gpt-5.4", Some(5.0), Some(60_000), 1_000), task("t-3", AgentKind::Cursor, TaskStatus::Merged, 1, "composer-2", None, Some(90_000), 1_000), task("t-4", AgentKind::OpenCode, TaskStatus::Failed, 8, "glm-4.7", Some(1.0), Some(30_000), 1_000)] {
            store.insert_task(&task).unwrap();
        }
        store.insert_event(&TaskEvent { task_id: TaskId("t-2".to_string()), timestamp: now, event_kind: EventKind::Error, detail: "verify failed (cargo check)".to_string(), metadata: None }).unwrap();
        let stats = collect(&store, UsageWindow::Days(7), None, now).unwrap();
        assert_eq!(stats.agent_rows[0], AgentRow { agent: "codex".to_string(), tasks: 2, share_pct: 66, success_rate: 50.0, avg_duration_ms: Some(90_000), cost: "$15.00".to_string() });
        assert_eq!(stats.agent_rows[1], AgentRow { agent: "cursor".to_string(), tasks: 1, share_pct: 33, success_rate: 100.0, avg_duration_ms: Some(90_000), cost: "subscription".to_string() });
        assert_eq!(stats.failure_rows, vec![FailureRow { label: "verify failed".to_string(), tasks: 1, agents: vec![("codex".to_string(), 1)] }]);
        assert_eq!(stats.model_rows[0], ModelRow { model: "gpt-5.4".to_string(), tasks: 2, cost: "$15.00".to_string() });
        assert_eq!(stats.activity_by_day.len(), 7);
    }

    #[test]
    fn stats_does_not_panic_on_zero_duration_count() {
        let store = Store::open_memory().unwrap();
        let task = task("t-no-dur", AgentKind::Codex, TaskStatus::Done, 1, "gpt-5.4", Some(1.0), None, 1_000);
        store.insert_task(&task).unwrap();

        let stats = collect(&store, UsageWindow::Days(7), None, Local::now()).unwrap();

        assert_eq!(stats.agent_rows[0].avg_duration_ms, None);
    }

    #[test]
    fn render_output_shows_friendly_message_when_no_tasks_match() {
        let stats = StatsSnapshot { agent_rows: Vec::new(), failure_rows: Vec::new(), model_rows: Vec::new(), activity_by_day: Vec::new(), activity_by_hour: Vec::new(), top_sessions: Vec::new(), total_cost: 0.0, total_tokens: 0, total_tasks: 0 };

        assert_eq!(render_output(&stats, UsageWindow::Days(7), false, false), "No tasks matched the selected filters for last 7 days.\n");
    }

    #[test]
    fn copilot_defaults_to_subscription_cost_when_cost_is_missing() {
        let store = Store::open_memory().unwrap();
        let task = task(
            "t-copilot",
            AgentKind::Copilot,
            TaskStatus::Done,
            0,
            "gpt-5",
            None,
            Some(1_000),
            1_000,
        );
        store.insert_task(&task).unwrap();

        let stats = collect(&store, UsageWindow::Days(7), None, Local::now()).unwrap();

        assert_eq!(stats.agent_rows[0].cost, "subscription");
        assert_eq!(stats.model_rows[0].cost, "subscription");
    }

    #[test]
    fn top_sessions_pick_correct_extremes() {
        let store = Store::open_memory().unwrap();
        for task in [task("t-long", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(1.0), Some(300_000), 1_000), task("t-tokens", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(0.5), Some(10_000), 3_100_000), task("t-cost", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(11.91), Some(20_000), 2_000)] {
            store.insert_task(&task).unwrap();
        }
        let stats = collect(&store, UsageWindow::Days(7), None, Local::now()).unwrap();
        assert_eq!(stats.top_sessions.iter().find(|row| row.label == "Longest").unwrap().task_id, "t-long");
        assert_eq!(stats.top_sessions.iter().find(|row| row.label == "Most tokens").unwrap().task_id, "t-tokens");
        assert_eq!(stats.top_sessions.iter().find(|row| row.label == "Highest cost").unwrap().task_id, "t-cost");
    }

    #[test]
    fn render_includes_overview_section() {
        let store = Store::open_memory().unwrap();
        store.insert_task(&task("t-1", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(2.5), Some(1_000), 2_000)).unwrap();
        let output = render_output(&collect(&store, UsageWindow::Days(7), None, Local::now()).unwrap(), UsageWindow::Days(7), false, false);
        assert!(output.contains("Overview\n  Total: 1 tasks  2.0k tokens  $2.50"));
    }

    #[test]
    fn collects_usage_share_per_agent() {
        let store = Store::open_memory().unwrap();
        for task in [
            task("t-1", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(1.0), Some(1_000), 1_000),
            task("t-2", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(1.0), Some(1_000), 1_000),
            task("t-3", AgentKind::Codex, TaskStatus::Done, 0, "gpt-5.4", Some(1.0), Some(1_000), 1_000),
            task("t-4", AgentKind::OpenCode, TaskStatus::Done, 0, "glm-4.7", Some(0.1), Some(1_000), 1_000),
        ] {
            store.insert_task(&task).unwrap();
        }
        let stats = collect(&store, UsageWindow::Days(7), None, Local::now()).unwrap();
        assert_eq!(stats.agent_rows[0].share_pct, 75);
        assert_eq!(stats.agent_rows[1].share_pct, 25);
    }
}