Skip to main content

batty_cli/team/
metrics_cmd.rs

1//! `batty metrics` — consolidated telemetry dashboard.
2//!
3//! Queries the SQLite telemetry database and prints a single-screen summary
4//! covering session totals, per-agent performance, cycle-time statistics,
5//! and review pipeline health. Handles missing/empty databases gracefully.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11use rusqlite::Connection;
12
13use super::{metrics, telemetry_db};
14
15/// Aggregated dashboard metrics produced by [`query_dashboard`].
16#[derive(Debug, Clone, Default, PartialEq)]
17pub struct DashboardMetrics {
18    // Session totals
19    pub total_tasks_completed: i64,
20    pub total_merges: i64,
21    pub total_events: i64,
22    pub sessions_count: i64,
23    pub discord_events_sent: i64,
24    pub verification_pass_count: i64,
25    pub verification_fail_count: i64,
26    pub verification_pass_rate: Option<f64>,
27    pub notification_isolation_count: i64,
28    pub avg_notification_delivery_latency_secs: Option<f64>,
29    pub merge_queue_depth: i64,
30
31    // Cycle time
32    pub avg_cycle_time_secs: Option<f64>,
33    pub min_cycle_time_secs: Option<i64>,
34    pub max_cycle_time_secs: Option<i64>,
35
36    // Rates
37    pub completion_rate: Option<f64>,
38    pub failure_rate: Option<f64>,
39    pub merge_success_rate: Option<f64>,
40
41    // Review pipeline
42    pub auto_merge_count: i64,
43    pub manual_merge_count: i64,
44    pub direct_root_merge_count: i64,
45    pub isolated_integration_merge_count: i64,
46    pub direct_root_failure_count: i64,
47    pub isolated_integration_failure_count: i64,
48    pub auto_merge_rate: Option<f64>,
49    pub accepted_decision_count: i64,
50    pub rejected_decision_count: i64,
51    pub decision_accept_rate: Option<f64>,
52    pub rejection_reasons: Vec<telemetry_db::AutoMergeReasonRow>,
53    pub post_merge_verify_pass_count: i64,
54    pub post_merge_verify_fail_count: i64,
55    pub post_merge_verify_skip_count: i64,
56    pub rework_count: i64,
57    pub rework_rate: Option<f64>,
58    pub avg_review_latency_secs: Option<f64>,
59
60    // Per-agent breakdown
61    pub agent_rows: Vec<AgentRow>,
62
63    // Task cycle time tracking
64    pub cycle_time_by_priority: Vec<telemetry_db::PriorityCycleTimeRow>,
65    pub engineer_throughput: Vec<telemetry_db::EngineerThroughputRow>,
66    pub tasks_completed_per_hour: Vec<telemetry_db::HourlyThroughputRow>,
67    pub longest_running_tasks: Vec<metrics::InProgressTaskSummary>,
68    pub latest_release: Option<crate::release::ReleaseRecord>,
69}
70
71/// Per-agent row in the dashboard.
72#[derive(Debug, Clone, PartialEq)]
73pub struct AgentRow {
74    pub role: String,
75    pub completions: i64,
76    pub failures: i64,
77    pub restarts: i64,
78    pub total_cycle_secs: i64,
79    pub idle_pct: Option<f64>,
80}
81
82/// Query the telemetry database and build the aggregated dashboard.
83pub fn query_dashboard(conn: &Connection) -> Result<DashboardMetrics> {
84    let mut m = DashboardMetrics::default();
85
86    // Session totals
87    let sessions = telemetry_db::query_session_summaries(conn)?;
88    m.sessions_count = sessions.len() as i64;
89    for s in &sessions {
90        m.total_tasks_completed += s.tasks_completed;
91        m.total_merges += s.total_merges;
92        m.total_events += s.total_events;
93        m.discord_events_sent += s.discord_events_sent;
94        m.verification_pass_count += s.verification_passes;
95        m.verification_fail_count += s.verification_failures;
96        m.notification_isolation_count += s.notification_isolations;
97    }
98    let total_notification_latency_secs: i64 = sessions
99        .iter()
100        .map(|row| row.notification_latency_total_secs)
101        .sum();
102    let total_notification_latency_samples: i64 = sessions
103        .iter()
104        .map(|row| row.notification_latency_samples)
105        .sum();
106    if total_notification_latency_samples > 0 {
107        m.avg_notification_delivery_latency_secs = Some(
108            total_notification_latency_secs as f64 / total_notification_latency_samples as f64,
109        );
110    }
111
112    // Per-agent metrics
113    let agents = telemetry_db::query_agent_metrics(conn)?;
114    let mut total_completions: i64 = 0;
115    let mut total_failures: i64 = 0;
116    for a in &agents {
117        total_completions += a.completions;
118        total_failures += a.failures;
119        let total_polls = a.idle_polls + a.working_polls;
120        let idle_pct = if total_polls > 0 {
121            Some(a.idle_polls as f64 / total_polls as f64 * 100.0)
122        } else {
123            None
124        };
125        m.agent_rows.push(AgentRow {
126            role: a.role.clone(),
127            completions: a.completions,
128            failures: a.failures,
129            restarts: a.restarts,
130            total_cycle_secs: a.total_cycle_secs,
131            idle_pct,
132        });
133    }
134
135    // Rates
136    let total_outcomes = total_completions + total_failures;
137    if total_outcomes > 0 {
138        m.completion_rate = Some(total_completions as f64 / total_outcomes as f64 * 100.0);
139        m.failure_rate = Some(total_failures as f64 / total_outcomes as f64 * 100.0);
140    }
141    if m.total_tasks_completed > 0 {
142        m.merge_success_rate = Some(m.total_merges as f64 / m.total_tasks_completed as f64 * 100.0);
143    }
144
145    // Cycle time from task_metrics
146    let tasks = telemetry_db::query_task_metrics(conn)?;
147    let cycle_times: Vec<i64> = tasks
148        .iter()
149        .filter_map(|t| match (t.started_at, t.completed_at) {
150            (Some(s), Some(c)) if c > s => Some(c - s),
151            _ => None,
152        })
153        .collect();
154    if !cycle_times.is_empty() {
155        let sum: i64 = cycle_times.iter().sum();
156        m.avg_cycle_time_secs = Some(sum as f64 / cycle_times.len() as f64);
157        m.min_cycle_time_secs = cycle_times.iter().copied().min();
158        m.max_cycle_time_secs = cycle_times.iter().copied().max();
159    }
160
161    // Review pipeline
162    let review = telemetry_db::query_review_metrics(conn)?;
163    m.auto_merge_count = review.auto_merge_count;
164    m.manual_merge_count = review.manual_merge_count;
165    m.direct_root_merge_count = review.direct_root_merge_count;
166    m.isolated_integration_merge_count = review.isolated_integration_merge_count;
167    m.direct_root_failure_count = review.direct_root_failure_count;
168    m.isolated_integration_failure_count = review.isolated_integration_failure_count;
169    m.rework_count = review.rework_count;
170    m.avg_review_latency_secs = review.avg_review_latency_secs;
171    m.accepted_decision_count = review.accepted_decision_count;
172    m.rejected_decision_count = review.rejected_decision_count;
173    m.rejection_reasons = review.rejection_reasons;
174    m.post_merge_verify_pass_count = review.post_merge_verify_pass_count;
175    m.post_merge_verify_fail_count = review.post_merge_verify_fail_count;
176    m.post_merge_verify_skip_count = review.post_merge_verify_skip_count;
177
178    let total_merge = m.auto_merge_count + m.manual_merge_count;
179    if total_merge > 0 {
180        m.auto_merge_rate = Some(m.auto_merge_count as f64 / total_merge as f64 * 100.0);
181    }
182    let total_decisions = m.accepted_decision_count + m.rejected_decision_count;
183    if total_decisions > 0 {
184        m.decision_accept_rate =
185            Some(m.accepted_decision_count as f64 / total_decisions as f64 * 100.0);
186    }
187    let total_reviewed = total_merge + m.rework_count;
188    if total_reviewed > 0 {
189        m.rework_rate = Some(m.rework_count as f64 / total_reviewed as f64 * 100.0);
190    }
191    let verification_total = m.verification_pass_count + m.verification_fail_count;
192    if verification_total > 0 {
193        m.verification_pass_rate =
194            Some(m.verification_pass_count as f64 / verification_total as f64 * 100.0);
195    }
196
197    m.merge_queue_depth = telemetry_db::query_merge_queue_depth(conn)?;
198
199    m.cycle_time_by_priority = telemetry_db::query_average_cycle_time_by_priority(conn)?;
200    m.engineer_throughput = telemetry_db::query_engineer_throughput(conn)?;
201    if !telemetry_db::query_task_cycle_times(conn)?.is_empty() {
202        let last_24h = Utc::now().timestamp() - (24 * 3600);
203        m.tasks_completed_per_hour = telemetry_db::query_hourly_throughput(conn, last_24h)?;
204    }
205
206    Ok(m)
207}
208
209/// Format a duration in seconds as a human-readable string.
210fn format_duration(secs: f64) -> String {
211    if secs < 60.0 {
212        format!("{:.0}s", secs)
213    } else if secs < 3600.0 {
214        let m = (secs / 60.0).floor();
215        let s = secs - m * 60.0;
216        format!("{:.0}m {:.0}s", m, s)
217    } else {
218        let h = (secs / 3600.0).floor();
219        let rem = secs - h * 3600.0;
220        let m = (rem / 60.0).floor();
221        format!("{:.0}h {:.0}m", h, m)
222    }
223}
224
225/// Format the dashboard for terminal display.
226pub fn format_dashboard(m: &DashboardMetrics) -> String {
227    let mut out = String::new();
228    let na = "n/a".to_string();
229
230    // Header
231    out.push_str("Telemetry Dashboard\n");
232    out.push_str(&"=".repeat(60));
233    out.push('\n');
234
235    // Session totals
236    out.push_str("\nSession Totals\n");
237    out.push_str(&"-".repeat(40));
238    out.push('\n');
239    out.push_str(&format!("  Sessions:        {}\n", m.sessions_count));
240    out.push_str(&format!("  Tasks Completed: {}\n", m.total_tasks_completed));
241    out.push_str(&format!("  Total Merges:    {}\n", m.total_merges));
242    out.push_str(&format!("  Total Events:    {}\n", m.total_events));
243    out.push_str(&format!("  Discord Events:  {}\n", m.discord_events_sent));
244
245    // Cycle time
246    out.push_str("\nCycle Time\n");
247    out.push_str(&"-".repeat(40));
248    out.push('\n');
249    let avg = m
250        .avg_cycle_time_secs
251        .map(format_duration)
252        .unwrap_or_else(|| na.clone());
253    let min = m
254        .min_cycle_time_secs
255        .map(|s| format_duration(s as f64))
256        .unwrap_or_else(|| na.clone());
257    let max = m
258        .max_cycle_time_secs
259        .map(|s| format_duration(s as f64))
260        .unwrap_or_else(|| na.clone());
261    out.push_str(&format!("  Average: {}\n", avg));
262    out.push_str(&format!("  Min:     {}\n", min));
263    out.push_str(&format!("  Max:     {}\n", max));
264
265    // Rates
266    out.push_str("\nRates\n");
267    out.push_str(&"-".repeat(40));
268    out.push('\n');
269    let cr = m
270        .completion_rate
271        .map(|r| format!("{:.0}%", r))
272        .unwrap_or_else(|| na.clone());
273    let fr = m
274        .failure_rate
275        .map(|r| format!("{:.0}%", r))
276        .unwrap_or_else(|| na.clone());
277    let mr = m
278        .merge_success_rate
279        .map(|r| format!("{:.0}%", r))
280        .unwrap_or_else(|| na.clone());
281    out.push_str(&format!("  Completion Rate:    {}\n", cr));
282    out.push_str(&format!("  Failure Rate:       {}\n", fr));
283    out.push_str(&format!("  Merge Success Rate: {}\n", mr));
284
285    // Review pipeline
286    out.push_str("\nReview Pipeline\n");
287    out.push_str(&"-".repeat(40));
288    out.push('\n');
289    let amr = m
290        .auto_merge_rate
291        .map(|r| format!("{:.0}%", r))
292        .unwrap_or_else(|| na.clone());
293    let dar = m
294        .decision_accept_rate
295        .map(|r| format!("{:.0}%", r))
296        .unwrap_or_else(|| na.clone());
297    let rr = m
298        .rework_rate
299        .map(|r| format!("{:.0}%", r))
300        .unwrap_or_else(|| na.clone());
301    let latency = m
302        .avg_review_latency_secs
303        .map(format_duration)
304        .unwrap_or_else(|| na.clone());
305    out.push_str(&format!("  Auto-merge Rate: {}\n", amr));
306    out.push_str(&format!(
307        "  Auto: {}  Manual: {}  Rework: {}\n",
308        m.auto_merge_count, m.manual_merge_count, m.rework_count
309    ));
310    out.push_str(&format!(
311        "  Merge Modes: direct ok {} / fail {}  isolated ok {} / fail {}\n",
312        m.direct_root_merge_count,
313        m.direct_root_failure_count,
314        m.isolated_integration_merge_count,
315        m.isolated_integration_failure_count
316    ));
317    out.push_str(&format!(
318        "  Decision Accept Rate: {} (accepted {} / rejected {})\n",
319        dar, m.accepted_decision_count, m.rejected_decision_count
320    ));
321    out.push_str(&format!(
322        "  Post-merge Verify: pass {}  fail {}  skipped {}\n",
323        m.post_merge_verify_pass_count,
324        m.post_merge_verify_fail_count,
325        m.post_merge_verify_skip_count
326    ));
327    out.push_str(&format!("  Rework Rate:     {}\n", rr));
328    out.push_str(&format!("  Avg Review Latency: {}\n", latency));
329    if !m.rejection_reasons.is_empty() {
330        out.push_str("  Rejection Reasons:\n");
331        for row in m.rejection_reasons.iter().take(5) {
332            out.push_str(&format!("    - {} ({})\n", row.reason, row.count));
333        }
334    }
335
336    out.push_str("\nSubsystem Health\n");
337    out.push_str(&"-".repeat(40));
338    out.push('\n');
339    let verify_rate = m
340        .verification_pass_rate
341        .map(|rate| format!("{rate:.0}%"))
342        .unwrap_or_else(|| na.clone());
343    let notification_latency = m
344        .avg_notification_delivery_latency_secs
345        .map(format_duration)
346        .unwrap_or_else(|| na.clone());
347    out.push_str(&format!(
348        "  Verification: pass {}  fail {}  pass-rate {}\n",
349        m.verification_pass_count, m.verification_fail_count, verify_rate
350    ));
351    out.push_str(&format!("  Merge Queue Depth: {}\n", m.merge_queue_depth));
352    out.push_str(&format!(
353        "  Notification Isolation: {}\n",
354        m.notification_isolation_count
355    ));
356    out.push_str(&format!(
357        "  Notification Latency: {}\n",
358        notification_latency
359    ));
360
361    if !m.cycle_time_by_priority.is_empty() {
362        out.push_str("\nAverage Cycle Time By Priority\n");
363        out.push_str(&"-".repeat(50));
364        out.push('\n');
365        for row in &m.cycle_time_by_priority {
366            out.push_str(&format!(
367                "  {:<10} {:>8.1} min ({:>2} tasks)\n",
368                row.priority, row.average_cycle_time_mins, row.completed_tasks
369            ));
370        }
371    }
372
373    if !m.tasks_completed_per_hour.is_empty() {
374        out.push_str("\nTasks Completed Per Hour (Last 24h)\n");
375        out.push_str(&"-".repeat(50));
376        out.push('\n');
377        for row in &m.tasks_completed_per_hour {
378            let label = chrono::DateTime::<Utc>::from_timestamp(row.hour_start, 0)
379                .map(|ts| ts.format("%m-%d %H:00").to_string())
380                .unwrap_or_else(|| row.hour_start.to_string());
381            out.push_str(&format!("  {}  {:>2}\n", label, row.completed_tasks));
382        }
383    }
384
385    if !m.engineer_throughput.is_empty() {
386        out.push_str("\nEngineer Throughput Ranking\n");
387        out.push_str(&"-".repeat(60));
388        out.push('\n');
389        for (index, row) in m.engineer_throughput.iter().enumerate() {
390            let avg_cycle = row
391                .average_cycle_time_mins
392                .map(|value| format!("{value:.1}m"))
393                .unwrap_or_else(|| "n/a".to_string());
394            let avg_lead = row
395                .average_lead_time_mins
396                .map(|value| format!("{value:.1}m"))
397                .unwrap_or_else(|| "n/a".to_string());
398            out.push_str(&format!(
399                "  {}. {}  completed={}  avg_cycle={}  avg_lead={}\n",
400                index + 1,
401                row.engineer,
402                row.completed_tasks,
403                avg_cycle,
404                avg_lead
405            ));
406        }
407    }
408
409    if !m.longest_running_tasks.is_empty() {
410        out.push_str("\nLongest-Running In-Progress Tasks\n");
411        out.push_str(&"-".repeat(60));
412        out.push('\n');
413        for row in &m.longest_running_tasks {
414            out.push_str(&format!(
415                "  #{} {} [{}] owner={} age={}m\n",
416                row.task_id,
417                row.title,
418                row.priority,
419                row.engineer.as_deref().unwrap_or("unassigned"),
420                row.minutes_in_progress
421            ));
422        }
423    }
424
425    if let Some(release) = &m.latest_release {
426        out.push_str("\nLatest Release\n");
427        out.push_str(&"-".repeat(40));
428        out.push('\n');
429        let status = if release.success {
430            "success"
431        } else {
432            "failure"
433        };
434        out.push_str(&format!(
435            "  {} {} ({})\n",
436            release.tag.as_deref().unwrap_or("unversioned"),
437            release.git_ref.as_deref().unwrap_or("unknown-ref"),
438            status
439        ));
440        out.push_str(&format!("  Reason: {}\n", release.reason));
441        if let Some(details) = release.details.as_deref() {
442            out.push_str(&format!("  Details: {}\n", details));
443        }
444    }
445
446    // Per-agent table
447    if !m.agent_rows.is_empty() {
448        out.push_str("\nPer-Agent Breakdown\n");
449        out.push_str(&"-".repeat(60));
450        out.push('\n');
451        out.push_str(&format!(
452            "  {:<16} {:>6} {:>6} {:>6} {:>10} {:>8}\n",
453            "ROLE", "DONE", "FAIL", "RESTART", "CYCLE_S", "IDLE%"
454        ));
455        for a in &m.agent_rows {
456            let idle = a
457                .idle_pct
458                .map(|p| format!("{:.0}%", p))
459                .unwrap_or_else(|| "-".to_string());
460            out.push_str(&format!(
461                "  {:<16} {:>6} {:>6} {:>6} {:>10} {:>8}\n",
462                a.role, a.completions, a.failures, a.restarts, a.total_cycle_secs, idle
463            ));
464        }
465    }
466
467    out
468}
469
470/// Run the `batty metrics` command against the project root.
471///
472/// Opens the telemetry DB, queries the dashboard, and prints it.
473/// Returns gracefully when the DB is missing or empty.
474pub fn run(project_root: &Path) -> Result<()> {
475    let db_path = project_root.join(".batty").join("telemetry.db");
476    if !db_path.exists() {
477        println!("Telemetry Dashboard\n{}", "=".repeat(60));
478        println!("\nNo telemetry database found. Run `batty start` to begin collecting data.");
479        return Ok(());
480    }
481
482    let conn = telemetry_db::open(project_root).context("failed to open telemetry database")?;
483    let board_dir = project_root
484        .join(".batty")
485        .join("team_config")
486        .join("board");
487    let records = metrics::collect_task_cycle_time_records(&board_dir).unwrap_or_default();
488    telemetry_db::replace_task_cycle_times(&conn, &records)?;
489
490    let mut metrics = query_dashboard(&conn)?;
491    metrics.longest_running_tasks =
492        metrics::longest_running_in_progress_tasks(&records, Utc::now(), 5);
493    metrics.latest_release = crate::release::latest_record(project_root)?;
494    print!("{}", format_dashboard(&metrics));
495    Ok(())
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use std::fs;
502
503    use crate::team::events::TeamEvent;
504    use crate::team::telemetry_db;
505
506    fn setup_db_with_data() -> Connection {
507        let conn = telemetry_db::open_in_memory().unwrap();
508
509        // Create a session
510        let mut started = TeamEvent::daemon_started();
511        started.ts = 1000;
512        telemetry_db::insert_event(&conn, &started).unwrap();
513
514        // Assign and complete tasks
515        let mut a1 = TeamEvent::task_assigned("eng-1", "10");
516        a1.ts = 1100;
517        telemetry_db::insert_event(&conn, &a1).unwrap();
518
519        let mut c1 = TeamEvent::task_completed("eng-1", Some("10"));
520        c1.ts = 1400; // 300s cycle
521        telemetry_db::insert_event(&conn, &c1).unwrap();
522
523        let mut a2 = TeamEvent::task_assigned("eng-2", "20");
524        a2.ts = 1200;
525        telemetry_db::insert_event(&conn, &a2).unwrap();
526
527        let mut c2 = TeamEvent::task_completed("eng-2", Some("20"));
528        c2.ts = 1700; // 500s cycle
529        telemetry_db::insert_event(&conn, &c2).unwrap();
530
531        // Merge events
532        let mut m1 = TeamEvent::task_auto_merged_with_mode(
533            "eng-1",
534            "10",
535            0.9,
536            2,
537            30,
538            Some(crate::team::merge::MergeMode::DirectRoot),
539        );
540        m1.ts = 1500;
541        telemetry_db::insert_event(&conn, &m1).unwrap();
542        telemetry_db::insert_event(
543            &conn,
544            &TeamEvent::auto_merge_decision_recorded(&crate::team::events::AutoMergeDecisionInfo {
545                engineer: "eng-1",
546                task: "10",
547                action_type: "accepted",
548                confidence: 0.9,
549                reason: "accepted for auto-merge: confidence 0.90; 2 files, 30 lines, 1 modules; reasons: confidence 0.90 meets threshold 0.80",
550                details: r#"{"decision":"accepted","reasons":["confidence 0.90 meets threshold 0.80"],"files_changed":2,"lines_changed":30,"modules_touched":1,"has_migrations":false,"has_config_changes":false,"has_unsafe":false,"has_conflicts":false,"rename_count":0,"tests_passed":true,"override_forced":null,"diff_available":true}"#,
551            }),
552        )
553        .unwrap();
554        telemetry_db::insert_event(
555            &conn,
556            &TeamEvent::auto_merge_post_verify_result(
557                "eng-1",
558                "10",
559                Some(true),
560                "passed",
561                Some("post-merge verification on main passed"),
562            ),
563        )
564        .unwrap();
565
566        let mut m2 = TeamEvent::task_manual_merged_with_mode(
567            "20",
568            Some(crate::team::merge::MergeMode::DirectRoot),
569        );
570        m2.ts = 1800;
571        telemetry_db::insert_event(&conn, &m2).unwrap();
572        telemetry_db::insert_event(
573            &conn,
574            &TeamEvent::task_merge_failed(
575                "eng-2",
576                "30",
577                Some(crate::team::merge::MergeMode::IsolatedIntegration),
578                "isolated merge path failed: integration checkout broke",
579            ),
580        )
581        .unwrap();
582        telemetry_db::insert_event(
583            &conn,
584            &TeamEvent::auto_merge_decision_recorded(&crate::team::events::AutoMergeDecisionInfo {
585                engineer: "eng-2",
586                task: "20",
587                action_type: "manual_review",
588                confidence: 0.6,
589                reason: "routed to manual review: confidence 0.60; 4 files, 120 lines, 3 modules; reasons: touches sensitive paths",
590                details: r#"{"decision":"manual_review","reasons":["touches sensitive paths"],"files_changed":4,"lines_changed":120,"modules_touched":3,"has_migrations":false,"has_config_changes":false,"has_unsafe":false,"has_conflicts":false,"rename_count":0,"tests_passed":true,"override_forced":null,"diff_available":true}"#,
591            }),
592        )
593        .unwrap();
594        telemetry_db::insert_event(
595            &conn,
596            &TeamEvent::auto_merge_post_verify_result(
597                "eng-2",
598                "20",
599                None,
600                "skipped",
601                Some("post-merge verification was not requested for this merge"),
602            ),
603        )
604        .unwrap();
605
606        // A failure
607        telemetry_db::insert_event(&conn, &TeamEvent::pane_death("eng-1")).unwrap();
608
609        conn
610    }
611
612    fn create_legacy_project_db(tmp: &tempfile::TempDir) -> Connection {
613        let batty_dir = tmp.path().join(".batty");
614        fs::create_dir_all(&batty_dir).unwrap();
615        let db_path = batty_dir.join("telemetry.db");
616        let conn = Connection::open(&db_path).unwrap();
617        telemetry_db::install_legacy_schema_for_tests(&conn).unwrap();
618        conn
619    }
620
621    #[test]
622    fn metrics_with_data() {
623        let conn = setup_db_with_data();
624        let m = query_dashboard(&conn).unwrap();
625
626        assert_eq!(m.sessions_count, 1);
627        assert_eq!(m.total_tasks_completed, 2);
628        assert_eq!(m.total_merges, 2);
629        assert!(m.total_events > 0);
630
631        // Cycle times: 300s and 500s → avg 400
632        let avg = m.avg_cycle_time_secs.unwrap();
633        assert!((avg - 400.0).abs() < 0.01);
634        assert_eq!(m.min_cycle_time_secs, Some(300));
635        assert_eq!(m.max_cycle_time_secs, Some(500));
636
637        // Completion rate: 2 completions, 1 failure → 2/3 ≈ 66.7%
638        let cr = m.completion_rate.unwrap();
639        assert!((cr - 66.67).abs() < 1.0);
640
641        // Failure rate: 1/3 ≈ 33.3%
642        let fr = m.failure_rate.unwrap();
643        assert!((fr - 33.33).abs() < 1.0);
644
645        // Merge success rate: 2 merges / 2 completed → 100%
646        let mr = m.merge_success_rate.unwrap();
647        assert!((mr - 100.0).abs() < 0.01);
648
649        // Review pipeline
650        assert_eq!(m.auto_merge_count, 1);
651        assert_eq!(m.manual_merge_count, 1);
652        assert_eq!(m.direct_root_merge_count, 2);
653        assert_eq!(m.isolated_integration_merge_count, 0);
654        assert_eq!(m.direct_root_failure_count, 0);
655        assert_eq!(m.isolated_integration_failure_count, 1);
656        let amr = m.auto_merge_rate.unwrap();
657        assert!((amr - 50.0).abs() < 0.01);
658        assert_eq!(m.accepted_decision_count, 1);
659        assert_eq!(m.rejected_decision_count, 1);
660        let dar = m.decision_accept_rate.unwrap();
661        assert!((dar - 50.0).abs() < 0.01);
662        assert_eq!(m.post_merge_verify_pass_count, 1);
663        assert_eq!(m.post_merge_verify_fail_count, 0);
664        assert_eq!(m.post_merge_verify_skip_count, 1);
665        assert_eq!(
666            m.rejection_reasons,
667            vec![telemetry_db::AutoMergeReasonRow {
668                reason: "touches sensitive paths".to_string(),
669                count: 1,
670            }]
671        );
672
673        // Agents present
674        assert_eq!(m.agent_rows.len(), 2);
675    }
676
677    #[test]
678    fn empty_db() {
679        let conn = telemetry_db::open_in_memory().unwrap();
680        let m = query_dashboard(&conn).unwrap();
681
682        assert_eq!(m, DashboardMetrics::default());
683        assert_eq!(m.sessions_count, 0);
684        assert_eq!(m.total_tasks_completed, 0);
685        assert!(m.avg_cycle_time_secs.is_none());
686        assert!(m.completion_rate.is_none());
687        assert!(m.failure_rate.is_none());
688        assert!(m.merge_success_rate.is_none());
689    }
690
691    #[test]
692    fn query_dashboard_reads_repaired_legacy_schema_rows() {
693        let tmp = tempfile::tempdir().unwrap();
694        let legacy = create_legacy_project_db(&tmp);
695        legacy
696            .execute(
697                "INSERT INTO session_summary (session_id, started_at, tasks_completed, total_merges, total_events)
698                 VALUES ('legacy-session', 100, 2, 1, 5)",
699                [],
700            )
701            .unwrap();
702        legacy
703            .execute(
704                "INSERT INTO task_metrics (task_id, started_at, completed_at, retries, escalations, merge_time_secs)
705                 VALUES ('42', 100, 160, 3, 1, 60)",
706                [],
707            )
708            .unwrap();
709        drop(legacy);
710
711        let conn = telemetry_db::open(tmp.path()).unwrap();
712        let metrics = query_dashboard(&conn).unwrap();
713
714        assert_eq!(metrics.sessions_count, 1);
715        assert_eq!(metrics.total_tasks_completed, 2);
716        assert_eq!(metrics.total_merges, 1);
717        assert_eq!(metrics.total_events, 5);
718        assert_eq!(metrics.verification_pass_count, 0);
719        assert_eq!(metrics.notification_isolation_count, 0);
720        assert_eq!(metrics.avg_cycle_time_secs, Some(60.0));
721    }
722
723    #[test]
724    fn missing_db_shows_message() {
725        let tmp = tempfile::tempdir().unwrap();
726        // No .batty/ directory → no DB file
727        let result = run(tmp.path());
728        assert!(result.is_ok());
729    }
730
731    #[test]
732    fn rate_calculations() {
733        let conn = telemetry_db::open_in_memory().unwrap();
734
735        // 3 completions, 1 failure
736        let mut started = TeamEvent::daemon_started();
737        started.ts = 100;
738        telemetry_db::insert_event(&conn, &started).unwrap();
739
740        for i in 1..=3 {
741            let mut a = TeamEvent::task_assigned("eng-1", &i.to_string());
742            a.ts = 200 + i as u64 * 100;
743            telemetry_db::insert_event(&conn, &a).unwrap();
744
745            let mut c = TeamEvent::task_completed("eng-1", Some(&i.to_string()));
746            c.ts = 200 + i as u64 * 100 + 60;
747            telemetry_db::insert_event(&conn, &c).unwrap();
748
749            let mut m = TeamEvent::task_auto_merged_with_mode(
750                "eng-1",
751                &i.to_string(),
752                0.9,
753                2,
754                30,
755                Some(crate::team::merge::MergeMode::DirectRoot),
756            );
757            m.ts = 200 + i as u64 * 100 + 120;
758            telemetry_db::insert_event(&conn, &m).unwrap();
759        }
760        telemetry_db::insert_event(&conn, &TeamEvent::pane_death("eng-1")).unwrap();
761
762        let m = query_dashboard(&conn).unwrap();
763
764        // completion_rate: 3 / (3+1) = 75%
765        let cr = m.completion_rate.unwrap();
766        assert!((cr - 75.0).abs() < 0.01);
767
768        // failure_rate: 1 / (3+1) = 25%
769        let fr = m.failure_rate.unwrap();
770        assert!((fr - 25.0).abs() < 0.01);
771
772        // merge_success_rate: 3 merges / 3 completed = 100%
773        let mr = m.merge_success_rate.unwrap();
774        assert!((mr - 100.0).abs() < 0.01);
775
776        // auto_merge_rate: 3 auto / (3+0) = 100%
777        let amr = m.auto_merge_rate.unwrap();
778        assert!((amr - 100.0).abs() < 0.01);
779
780        // cycle times: each task has 60s cycle → avg 60
781        let avg = m.avg_cycle_time_secs.unwrap();
782        assert!((avg - 60.0).abs() < 0.01);
783    }
784
785    #[test]
786    fn format_dashboard_renders_sections() {
787        let m = DashboardMetrics {
788            sessions_count: 2,
789            total_tasks_completed: 10,
790            total_merges: 8,
791            total_events: 50,
792            discord_events_sent: 4,
793            verification_pass_count: 5,
794            verification_fail_count: 1,
795            verification_pass_rate: Some(83.0),
796            notification_isolation_count: 3,
797            avg_notification_delivery_latency_secs: Some(12.0),
798            merge_queue_depth: 2,
799            latest_release: Some(crate::release::ReleaseRecord {
800                ts: "2026-04-10T12:00:00Z".to_string(),
801                package_name: Some("batty".to_string()),
802                version: Some("0.10.0".to_string()),
803                tag: Some("v0.10.0".to_string()),
804                git_ref: Some("abc123".to_string()),
805                branch: Some("main".to_string()),
806                previous_tag: Some("v0.9.0".to_string()),
807                commits_since_previous: Some(2),
808                verification_command: Some("cargo test".to_string()),
809                verification_summary: Some("cargo test passed".to_string()),
810                success: true,
811                reason: "created annotated tag `v0.10.0`".to_string(),
812                details: None,
813                notes_path: Some(".batty/releases/v0.10.0.md".to_string()),
814            }),
815            avg_cycle_time_secs: Some(300.0),
816            min_cycle_time_secs: Some(60),
817            max_cycle_time_secs: Some(900),
818            completion_rate: Some(90.0),
819            failure_rate: Some(10.0),
820            merge_success_rate: Some(80.0),
821            auto_merge_count: 6,
822            manual_merge_count: 2,
823            direct_root_merge_count: 5,
824            isolated_integration_merge_count: 3,
825            direct_root_failure_count: 1,
826            isolated_integration_failure_count: 2,
827            auto_merge_rate: Some(75.0),
828            accepted_decision_count: 6,
829            rejected_decision_count: 2,
830            decision_accept_rate: Some(75.0),
831            rejection_reasons: vec![telemetry_db::AutoMergeReasonRow {
832                reason: "needs-human-review".to_string(),
833                count: 2,
834            }],
835            post_merge_verify_pass_count: 5,
836            post_merge_verify_fail_count: 1,
837            post_merge_verify_skip_count: 2,
838            rework_count: 1,
839            rework_rate: Some(11.0),
840            avg_review_latency_secs: Some(120.0),
841            cycle_time_by_priority: vec![telemetry_db::PriorityCycleTimeRow {
842                priority: "high".to_string(),
843                average_cycle_time_mins: 42.0,
844                completed_tasks: 3,
845            }],
846            engineer_throughput: vec![telemetry_db::EngineerThroughputRow {
847                engineer: "eng-1".to_string(),
848                completed_tasks: 5,
849                average_cycle_time_mins: Some(42.0),
850                average_lead_time_mins: Some(60.0),
851            }],
852            tasks_completed_per_hour: vec![telemetry_db::HourlyThroughputRow {
853                hour_start: 1_744_000_000,
854                completed_tasks: 2,
855            }],
856            longest_running_tasks: vec![metrics::InProgressTaskSummary {
857                task_id: 473,
858                title: "Track cycle time".to_string(),
859                engineer: Some("eng-1".to_string()),
860                priority: "high".to_string(),
861                minutes_in_progress: 95,
862            }],
863            agent_rows: vec![AgentRow {
864                role: "eng-1".to_string(),
865                completions: 5,
866                failures: 1,
867                restarts: 0,
868                total_cycle_secs: 1500,
869                idle_pct: Some(20.0),
870            }],
871        };
872
873        let text = format_dashboard(&m);
874
875        assert!(text.contains("Telemetry Dashboard"));
876        assert!(text.contains("Session Totals"));
877        assert!(text.contains("Sessions:        2"));
878        assert!(text.contains("Tasks Completed: 10"));
879        assert!(text.contains("Total Merges:    8"));
880        assert!(text.contains("Discord Events:  4"));
881
882        assert!(text.contains("Cycle Time"));
883        assert!(text.contains("Average: 5m 0s"));
884        assert!(text.contains("Min:     1m 0s"));
885        assert!(text.contains("Max:     15m 0s"));
886
887        assert!(text.contains("Rates"));
888        assert!(text.contains("Completion Rate:    90%"));
889        assert!(text.contains("Failure Rate:       10%"));
890        assert!(text.contains("Merge Success Rate: 80%"));
891
892        assert!(text.contains("Review Pipeline"));
893        assert!(text.contains("Auto-merge Rate: 75%"));
894        assert!(text.contains("Merge Modes: direct ok 5 / fail 1  isolated ok 3 / fail 2"));
895        assert!(text.contains("Decision Accept Rate: 75%"));
896        assert!(text.contains("Post-merge Verify: pass 5  fail 1  skipped 2"));
897        assert!(text.contains("needs-human-review"));
898        assert!(text.contains("Rework Rate:     11%"));
899
900        assert!(text.contains("Average Cycle Time By Priority"));
901        assert!(text.contains("Engineer Throughput Ranking"));
902        assert!(text.contains("Longest-Running In-Progress Tasks"));
903        assert!(text.contains("Subsystem Health"));
904        assert!(text.contains("Verification: pass 5  fail 1"));
905        assert!(text.contains("Merge Queue Depth: 2"));
906        assert!(text.contains("Notification Isolation: 3"));
907        assert!(text.contains("Notification Latency: 12s"));
908        assert!(text.contains("Latest Release"));
909        assert!(text.contains("v0.10.0 abc123 (success)"));
910
911        assert!(text.contains("Per-Agent Breakdown"));
912        assert!(text.contains("eng-1"));
913    }
914
915    #[test]
916    fn format_dashboard_empty_shows_na() {
917        let m = DashboardMetrics::default();
918        let text = format_dashboard(&m);
919
920        assert!(text.contains("n/a"));
921        assert!(text.contains("Sessions:        0"));
922        // No agent table when empty
923        assert!(!text.contains("Per-Agent Breakdown"));
924    }
925
926    #[test]
927    fn format_duration_works() {
928        assert_eq!(format_duration(30.0), "30s");
929        assert_eq!(format_duration(90.0), "1m 30s");
930        assert_eq!(format_duration(3661.0), "1h 1m");
931    }
932}