1use std::path::Path;
8
9use anyhow::{Context, Result};
10use chrono::Utc;
11use rusqlite::Connection;
12
13use super::{metrics, telemetry_db};
14
15#[derive(Debug, Clone, Default, PartialEq)]
17pub struct DashboardMetrics {
18 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 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 pub completion_rate: Option<f64>,
38 pub failure_rate: Option<f64>,
39 pub merge_success_rate: Option<f64>,
40
41 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 pub agent_rows: Vec<AgentRow>,
62
63 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#[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
82pub fn query_dashboard(conn: &Connection) -> Result<DashboardMetrics> {
84 let mut m = DashboardMetrics::default();
85
86 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 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 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 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 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
209fn 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
225pub fn format_dashboard(m: &DashboardMetrics) -> String {
227 let mut out = String::new();
228 let na = "n/a".to_string();
229
230 out.push_str("Telemetry Dashboard\n");
232 out.push_str(&"=".repeat(60));
233 out.push('\n');
234
235 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 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 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 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 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
470pub 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 let mut started = TeamEvent::daemon_started();
511 started.ts = 1000;
512 telemetry_db::insert_event(&conn, &started).unwrap();
513
514 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; 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; telemetry_db::insert_event(&conn, &c2).unwrap();
530
531 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 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 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 let cr = m.completion_rate.unwrap();
639 assert!((cr - 66.67).abs() < 1.0);
640
641 let fr = m.failure_rate.unwrap();
643 assert!((fr - 33.33).abs() < 1.0);
644
645 let mr = m.merge_success_rate.unwrap();
647 assert!((mr - 100.0).abs() < 0.01);
648
649 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 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 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 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 let cr = m.completion_rate.unwrap();
766 assert!((cr - 75.0).abs() < 0.01);
767
768 let fr = m.failure_rate.unwrap();
770 assert!((fr - 25.0).abs() < 0.01);
771
772 let mr = m.merge_success_rate.unwrap();
774 assert!((mr - 100.0).abs() < 0.01);
775
776 let amr = m.auto_merge_rate.unwrap();
778 assert!((amr - 100.0).abs() < 0.01);
779
780 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 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}