1use std::path::Path;
6use std::path::PathBuf;
7
8use anyhow::{Context, Result, bail};
9use tracing::{info, warn};
10
11use super::daemon_mgmt::{
12 DAEMON_SHUTDOWN_GRACE_PERIOD, force_kill_daemon, request_graceful_daemon_shutdown,
13 resume_marker_path,
14};
15use super::{
16 config, estimation, events, hierarchy, now_unix, status, team_config_path, team_events_path,
17};
18use crate::tmux;
19
20pub fn pause_marker_path(project_root: &Path) -> PathBuf {
22 project_root.join(".batty").join("paused")
23}
24
25pub fn pause_team(project_root: &Path) -> Result<()> {
27 let marker = pause_marker_path(project_root);
28 if marker.exists() {
29 bail!("Team is already paused.");
30 }
31 if let Some(parent) = marker.parent() {
32 std::fs::create_dir_all(parent).ok();
33 }
34 std::fs::write(&marker, "").context("failed to write pause marker")?;
35 info!("paused nudges and standups");
36 Ok(())
37}
38
39pub fn resume_team(project_root: &Path) -> Result<()> {
41 let marker = pause_marker_path(project_root);
42 if !marker.exists() {
43 bail!("Team is not paused.");
44 }
45 std::fs::remove_file(&marker).context("failed to remove pause marker")?;
46 info!("resumed nudges and standups");
47 Ok(())
48}
49
50pub fn nudge_disabled_marker_path(project_root: &Path, intervention: &str) -> PathBuf {
52 project_root
53 .join(".batty")
54 .join(format!("nudge_{intervention}_disabled"))
55}
56
57pub fn disable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
59 let marker = nudge_disabled_marker_path(project_root, intervention);
60 if marker.exists() {
61 bail!("Intervention '{intervention}' is already disabled.");
62 }
63 if let Some(parent) = marker.parent() {
64 std::fs::create_dir_all(parent).ok();
65 }
66 std::fs::write(&marker, "").context("failed to write nudge disabled marker")?;
67 info!(intervention, "disabled intervention");
68 Ok(())
69}
70
71pub fn enable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
73 let marker = nudge_disabled_marker_path(project_root, intervention);
74 if !marker.exists() {
75 bail!("Intervention '{intervention}' is not disabled.");
76 }
77 std::fs::remove_file(&marker).context("failed to remove nudge disabled marker")?;
78 info!(intervention, "enabled intervention");
79 Ok(())
80}
81
82pub fn nudge_status(project_root: &Path) -> Result<()> {
84 use crate::cli::NudgeIntervention;
85
86 let config_path = team_config_path(project_root);
87 let automation = if config_path.exists() {
88 let team_config = config::TeamConfig::load(&config_path)?;
89 Some(team_config.automation)
90 } else {
91 None
92 };
93
94 println!(
95 "{:<16} {:<10} {:<10} {:<10}",
96 "INTERVENTION", "CONFIG", "RUNTIME", "EFFECTIVE"
97 );
98
99 for intervention in NudgeIntervention::ALL {
100 let name = intervention.marker_name();
101 let config_enabled = automation
102 .as_ref()
103 .map(|a| match intervention {
104 NudgeIntervention::Replenish => true, NudgeIntervention::Triage => a.triage_interventions,
106 NudgeIntervention::Review => a.review_interventions,
107 NudgeIntervention::Dispatch => a.manager_dispatch_interventions,
108 NudgeIntervention::Utilization => a.architect_utilization_interventions,
109 NudgeIntervention::OwnedTask => a.owned_task_interventions,
110 })
111 .unwrap_or(true);
112
113 let runtime_disabled = nudge_disabled_marker_path(project_root, name).exists();
114 let runtime_str = if runtime_disabled {
115 "disabled"
116 } else {
117 "enabled"
118 };
119 let config_str = if config_enabled {
120 "enabled"
121 } else {
122 "disabled"
123 };
124 let effective = config_enabled && !runtime_disabled;
125 let effective_str = if effective { "enabled" } else { "DISABLED" };
126
127 println!(
128 "{:<16} {:<10} {:<10} {:<10}",
129 name, config_str, runtime_str, effective_str
130 );
131 }
132
133 Ok(())
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
139pub(crate) struct SessionSummary {
140 pub tasks_completed: u32,
141 pub tasks_merged: u32,
142 pub runtime_secs: u64,
143}
144
145impl SessionSummary {
146 pub fn display(&self) -> String {
147 format!(
148 "Session summary: {} tasks completed, {} merged, runtime {}\nBatty v{} — https://github.com/battysh/batty",
149 self.tasks_completed,
150 self.tasks_merged,
151 format_runtime(self.runtime_secs),
152 env!("CARGO_PKG_VERSION"),
153 )
154 }
155}
156
157fn format_runtime(secs: u64) -> String {
158 if secs < 60 {
159 format!("{secs}s")
160 } else if secs < 3600 {
161 format!("{}m", secs / 60)
162 } else {
163 let hours = secs / 3600;
164 let mins = (secs % 3600) / 60;
165 if mins == 0 {
166 format!("{hours}h")
167 } else {
168 format!("{hours}h {mins}m")
169 }
170 }
171}
172
173pub(crate) fn compute_session_summary(project_root: &Path) -> Option<SessionSummary> {
179 let events_path = team_events_path(project_root);
180 let all_events = events::read_events(&events_path).ok()?;
181
182 let session_start = all_events
184 .iter()
185 .rev()
186 .find(|e| e.event == "daemon_started")?;
187 let start_ts = session_start.ts;
188 let now_ts = now_unix();
189
190 let session_events: Vec<_> = all_events.iter().filter(|e| e.ts >= start_ts).collect();
191
192 let tasks_completed = session_events
193 .iter()
194 .filter(|e| e.event == "task_completed")
195 .count() as u32;
196
197 let tasks_merged = session_events
198 .iter()
199 .filter(|e| e.event == "task_auto_merged" || e.event == "task_manual_merged")
200 .count() as u32;
201
202 let runtime_secs = now_ts.saturating_sub(start_ts);
203
204 Some(SessionSummary {
205 tasks_completed,
206 tasks_merged,
207 runtime_secs,
208 })
209}
210
211pub fn stop_team(project_root: &Path) -> Result<()> {
212 let summary = compute_session_summary(project_root);
214
215 let marker = resume_marker_path(project_root);
217 if let Some(parent) = marker.parent() {
218 std::fs::create_dir_all(parent).ok();
219 }
220 std::fs::write(&marker, "").ok();
221
222 if !request_graceful_daemon_shutdown(project_root, DAEMON_SHUTDOWN_GRACE_PERIOD) {
224 warn!("daemon did not stop gracefully; forcing shutdown");
225 force_kill_daemon(project_root);
226 }
227
228 let config_path = team_config_path(project_root);
229 let primary_session = if config_path.exists() {
230 let team_config = config::TeamConfig::load(&config_path)?;
231 Some(format!("batty-{}", team_config.name))
232 } else {
233 None
234 };
235
236 match &primary_session {
238 Some(session) if tmux::session_exists(session) => {
239 tmux::kill_session(session)?;
240 info!(session = %session, "team session stopped");
241 }
242 Some(session) => {
243 info!(session = %session, "no running session to stop");
244 }
245 None => {
246 bail!("no team config found at {}", config_path.display());
247 }
248 }
249
250 if let Some(summary) = summary {
252 println!();
253 println!("{}", summary.display());
254 }
255
256 Ok(())
257}
258
259pub fn attach_team(project_root: &Path) -> Result<()> {
264 let config_path = team_config_path(project_root);
265
266 let session = if config_path.exists() {
267 let team_config = config::TeamConfig::load(&config_path)?;
268 format!("batty-{}", team_config.name)
269 } else {
270 let mut sessions = tmux::list_sessions_with_prefix("batty-");
272 match sessions.len() {
273 0 => bail!("no team config found and no batty sessions running"),
274 1 => sessions.swap_remove(0),
275 _ => {
276 let list = sessions.join(", ");
277 bail!(
278 "no team config found and multiple batty sessions running: {list}\n\
279 Run from the project directory, or use: tmux attach -t <session>"
280 );
281 }
282 }
283 };
284
285 if !tmux::session_exists(&session) {
286 bail!("no running session '{session}'; run `batty start` first");
287 }
288
289 tmux::attach(&session)
290}
291
292pub fn team_status(project_root: &Path, json: bool) -> Result<()> {
294 let config_path = team_config_path(project_root);
295 if !config_path.exists() {
296 bail!("no team config found at {}", config_path.display());
297 }
298
299 let team_config = config::TeamConfig::load(&config_path)?;
300 let members = hierarchy::resolve_hierarchy(&team_config)?;
301 let session = format!("batty-{}", team_config.name);
302 let session_running = tmux::session_exists(&session);
303 let runtime_statuses = if session_running {
304 match status::list_runtime_member_statuses(&session) {
305 Ok(statuses) => statuses,
306 Err(error) => {
307 warn!(session = %session, error = %error, "failed to read live runtime statuses");
308 std::collections::HashMap::new()
309 }
310 }
311 } else {
312 std::collections::HashMap::new()
313 };
314 let pending_inbox_counts = status::pending_inbox_counts(project_root, &members);
315 let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
316 let owned_task_buckets = status::owned_task_buckets(project_root, &members);
317 let agent_health = status::agent_health_by_member(project_root, &members);
318 let paused = pause_marker_path(project_root).exists();
319 let mut rows = status::build_team_status_rows(
320 &members,
321 session_running,
322 &runtime_statuses,
323 &pending_inbox_counts,
324 &triage_backlog_counts,
325 &owned_task_buckets,
326 &agent_health,
327 );
328
329 let active_task_elapsed: Vec<(u32, u64)> = rows
331 .iter()
332 .filter(|row| !row.active_owned_tasks.is_empty())
333 .flat_map(|row| {
334 let elapsed = row.health.task_elapsed_secs.unwrap_or(0);
335 row.active_owned_tasks
336 .iter()
337 .map(move |&task_id| (task_id, elapsed))
338 })
339 .collect();
340 let etas = estimation::compute_etas(project_root, &active_task_elapsed);
341 for row in &mut rows {
342 if let Some(&task_id) = row.active_owned_tasks.first() {
343 if let Some(eta) = etas.get(&task_id) {
344 row.eta = eta.clone();
345 }
346 }
347 }
348
349 let workflow_metrics = status::workflow_metrics_section(project_root, &members);
350 let (active_tasks, review_queue) = match status::board_status_task_queues(project_root) {
351 Ok(queues) => queues,
352 Err(error) => {
353 warn!(error = %error, "failed to load board task queues for status json");
354 (Vec::new(), Vec::new())
355 }
356 };
357
358 if json {
359 let report = status::build_team_status_json_report(status::TeamStatusJsonReportInput {
360 team: team_config.name.clone(),
361 session: session.clone(),
362 session_running,
363 paused,
364 workflow_metrics: workflow_metrics
365 .as_ref()
366 .map(|(_, metrics)| metrics.clone()),
367 active_tasks,
368 review_queue,
369 members: rows,
370 });
371 println!("{}", serde_json::to_string_pretty(&report)?);
372 } else {
373 println!("Team: {}", team_config.name);
374 println!(
375 "Session: {} ({})",
376 session,
377 if session_running {
378 "running"
379 } else {
380 "stopped"
381 }
382 );
383 println!();
384 println!(
385 "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
386 "MEMBER",
387 "ROLE",
388 "AGENT",
389 "STATE",
390 "INBOX",
391 "TRIAGE",
392 "ACTIVE",
393 "REVIEW",
394 "ETA",
395 "HEALTH",
396 "SIGNAL",
397 "REPORTS TO"
398 );
399 println!("{}", "-".repeat(195));
400 for row in &rows {
401 println!(
402 "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
403 row.name,
404 row.role,
405 row.agent.as_deref().unwrap_or("-"),
406 row.state,
407 row.pending_inbox,
408 row.triage_backlog,
409 status::format_owned_tasks_summary(&row.active_owned_tasks),
410 status::format_owned_tasks_summary(&row.review_owned_tasks),
411 row.eta,
412 row.health_summary,
413 row.signal.as_deref().unwrap_or("-"),
414 row.reports_to.as_deref().unwrap_or("-"),
415 );
416 }
417 if let Some((formatted, _)) = workflow_metrics {
418 println!();
419 println!("{formatted}");
420 }
421 }
422
423 Ok(())
424}
425
426fn workflow_mode_declared(config_path: &Path) -> Result<bool> {
427 let content = std::fs::read_to_string(config_path)
428 .with_context(|| format!("failed to read {}", config_path.display()))?;
429 let value: serde_yaml::Value = serde_yaml::from_str(&content)
430 .with_context(|| format!("failed to parse {}", config_path.display()))?;
431 let Some(mapping) = value.as_mapping() else {
432 return Ok(false);
433 };
434
435 Ok(mapping.contains_key(serde_yaml::Value::String("workflow_mode".to_string())))
436}
437
438fn migration_validation_notes(
439 team_config: &config::TeamConfig,
440 workflow_mode_is_explicit: bool,
441) -> Vec<String> {
442 if !workflow_mode_is_explicit {
443 if team_config.orchestrator_pane
444 && matches!(team_config.workflow_mode, config::WorkflowMode::Hybrid)
445 {
446 return vec![
447 "Migration: workflow_mode omitted; orchestrator_pane=true promotes the team to hybrid mode so the orchestrator surface is active.".to_string(),
448 ];
449 }
450 return vec![
451 "Migration: workflow_mode omitted; defaulting to legacy so existing teams and boards run unchanged.".to_string(),
452 ];
453 }
454
455 match team_config.workflow_mode {
456 config::WorkflowMode::Legacy => vec![
457 "Migration: legacy mode selected; Batty keeps current runtime behavior and treats workflow metadata as optional.".to_string(),
458 ],
459 config::WorkflowMode::Hybrid => vec![
460 "Migration: hybrid mode selected; workflow adoption is incremental and legacy runtime behavior remains available.".to_string(),
461 ],
462 config::WorkflowMode::WorkflowFirst => vec![
463 "Migration: workflow_first mode selected; complete board metadata and orchestrator rollout before treating workflow state as primary truth.".to_string(),
464 ],
465 }
466}
467
468pub fn validate_team(project_root: &Path, verbose: bool) -> Result<()> {
470 let config_path = team_config_path(project_root);
471 if !config_path.exists() {
472 bail!("no team config found at {}", config_path.display());
473 }
474
475 let team_config = config::TeamConfig::load(&config_path)?;
476
477 if verbose {
478 let checks = team_config.validate_verbose();
479 let mut any_failed = false;
480 for check in &checks {
481 let status = if check.passed { "PASS" } else { "FAIL" };
482 println!("[{status}] {}: {}", check.name, check.detail);
483 if !check.passed {
484 any_failed = true;
485 }
486 }
487 if any_failed {
488 bail!("validation failed — see FAIL checks above");
489 }
490 } else {
491 team_config.validate()?;
492 }
493
494 let workflow_mode_is_explicit = workflow_mode_declared(&config_path)?;
495
496 let members = hierarchy::resolve_hierarchy(&team_config)?;
497
498 println!("Config: {}", config_path.display());
499 println!("Team: {}", team_config.name);
500 println!(
501 "Workflow mode: {}",
502 match team_config.workflow_mode {
503 config::WorkflowMode::Legacy => "legacy",
504 config::WorkflowMode::Hybrid => "hybrid",
505 config::WorkflowMode::WorkflowFirst => "workflow_first",
506 }
507 );
508 println!("Roles: {}", team_config.roles.len());
509 println!("Total members: {}", members.len());
510
511 let backend_warnings = team_config.check_backend_health();
513 for warning in &backend_warnings {
514 println!("[WARN] {warning}");
515 }
516
517 for note in migration_validation_notes(&team_config, workflow_mode_is_explicit) {
518 println!("{note}");
519 }
520 println!("Valid.");
521 Ok(())
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::team::TRIAGE_RESULT_FRESHNESS_SECONDS;
528 use crate::team::config::RoleType;
529 use crate::team::hierarchy;
530 use crate::team::inbox;
531 use crate::team::status;
532 use crate::team::team_config_dir;
533 use crate::team::team_config_path;
534 use serial_test::serial;
535
536 #[test]
537 fn nudge_disable_creates_marker_and_enable_removes_it() {
538 let tmp = tempfile::tempdir().unwrap();
539 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
540
541 let marker = nudge_disabled_marker_path(tmp.path(), "triage");
542 assert!(!marker.exists());
543
544 disable_nudge(tmp.path(), "triage").unwrap();
545 assert!(marker.exists());
546
547 assert!(disable_nudge(tmp.path(), "triage").is_err());
549
550 enable_nudge(tmp.path(), "triage").unwrap();
551 assert!(!marker.exists());
552
553 assert!(enable_nudge(tmp.path(), "triage").is_err());
555 }
556
557 #[test]
558 fn nudge_marker_path_uses_intervention_name() {
559 let root = std::path::Path::new("/tmp/test-project");
560 assert_eq!(
561 nudge_disabled_marker_path(root, "replenish"),
562 root.join(".batty").join("nudge_replenish_disabled")
563 );
564 assert_eq!(
565 nudge_disabled_marker_path(root, "owned-task"),
566 root.join(".batty").join("nudge_owned-task_disabled")
567 );
568 }
569
570 #[test]
571 fn nudge_multiple_interventions_independent() {
572 let tmp = tempfile::tempdir().unwrap();
573 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
574
575 disable_nudge(tmp.path(), "triage").unwrap();
576 disable_nudge(tmp.path(), "review").unwrap();
577
578 assert!(nudge_disabled_marker_path(tmp.path(), "triage").exists());
579 assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
580 assert!(!nudge_disabled_marker_path(tmp.path(), "dispatch").exists());
581
582 enable_nudge(tmp.path(), "triage").unwrap();
583 assert!(!nudge_disabled_marker_path(tmp.path(), "triage").exists());
584 assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
585 }
586
587 #[test]
588 fn pause_creates_marker_and_resume_removes_it() {
589 let tmp = tempfile::tempdir().unwrap();
590 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
591
592 assert!(!pause_marker_path(tmp.path()).exists());
593 pause_team(tmp.path()).unwrap();
594 assert!(pause_marker_path(tmp.path()).exists());
595
596 assert!(pause_team(tmp.path()).is_err());
598
599 resume_team(tmp.path()).unwrap();
600 assert!(!pause_marker_path(tmp.path()).exists());
601
602 assert!(resume_team(tmp.path()).is_err());
604 }
605
606 fn write_team_config(project_root: &std::path::Path, yaml: &str) {
607 std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
608 std::fs::write(team_config_path(project_root), yaml).unwrap();
609 }
610
611 #[test]
612 fn workflow_mode_declared_detects_absent_field() {
613 let tmp = tempfile::tempdir().unwrap();
614 write_team_config(
615 tmp.path(),
616 r#"
617name: test
618roles:
619 - name: engineer
620 role_type: engineer
621 agent: codex
622"#,
623 );
624
625 assert!(!workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
626 }
627
628 #[test]
629 fn workflow_mode_declared_detects_present_field() {
630 let tmp = tempfile::tempdir().unwrap();
631 write_team_config(
632 tmp.path(),
633 r#"
634name: test
635workflow_mode: hybrid
636roles:
637 - name: engineer
638 role_type: engineer
639 agent: codex
640"#,
641 );
642
643 assert!(workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
644 }
645
646 #[test]
647 fn migration_validation_notes_explain_legacy_default_for_older_configs() {
648 let config =
649 config::TeamConfig::load(std::path::Path::new("src/team/templates/team_pair.yaml"))
650 .unwrap();
651 let notes = migration_validation_notes(&config, false);
652
653 assert_eq!(notes.len(), 1);
654 assert!(notes[0].contains("workflow_mode omitted"));
655 assert!(notes[0].contains("promotes the team to hybrid"));
657 }
658
659 #[test]
660 fn migration_validation_notes_warn_about_workflow_first_partial_rollout() {
661 let config: config::TeamConfig = serde_yaml::from_str(
662 r#"
663name: test
664workflow_mode: workflow_first
665roles:
666 - name: engineer
667 role_type: engineer
668 agent: codex
669"#,
670 )
671 .unwrap();
672 let notes = migration_validation_notes(&config, true);
673
674 assert_eq!(notes.len(), 1);
675 assert!(notes[0].contains("workflow_first mode selected"));
676 assert!(notes[0].contains("primary truth"));
677 }
678
679 fn make_member(name: &str, role_name: &str, role_type: RoleType) -> hierarchy::MemberInstance {
680 hierarchy::MemberInstance {
681 name: name.to_string(),
682 role_name: role_name.to_string(),
683 role_type,
684 agent: Some("codex".to_string()),
685 prompt: None,
686 reports_to: None,
687 use_worktrees: false,
688 }
689 }
690
691 #[test]
692 fn strip_tmux_style_removes_formatting_sequences() {
693 let raw = "#[fg=yellow]idle#[default] #[fg=magenta]nudge 1:05#[default]";
694 assert_eq!(status::strip_tmux_style(raw), "idle nudge 1:05");
695 }
696
697 #[test]
698 fn summarize_runtime_member_status_extracts_state_and_signal() {
699 let summary = status::summarize_runtime_member_status(
700 "#[fg=cyan]working#[default] #[fg=blue]standup 4:12#[default]",
701 false,
702 );
703
704 assert_eq!(summary.state, "working");
705 assert_eq!(summary.signal.as_deref(), Some("standup"));
706 assert_eq!(summary.label.as_deref(), Some("working standup 4:12"));
707 }
708
709 #[test]
710 fn summarize_runtime_member_status_marks_nudge_and_standup_together() {
711 let summary = status::summarize_runtime_member_status(
712 "#[fg=yellow]idle#[default] #[fg=magenta]nudge now#[default] #[fg=blue]standup 0:10#[default]",
713 false,
714 );
715
716 assert_eq!(summary.state, "idle");
717 assert_eq!(
718 summary.signal.as_deref(),
719 Some("waiting for nudge, standup")
720 );
721 }
722
723 #[test]
724 fn summarize_runtime_member_status_distinguishes_sent_nudge() {
725 let summary = status::summarize_runtime_member_status(
726 "#[fg=yellow]idle#[default] #[fg=magenta]nudge sent#[default]",
727 false,
728 );
729
730 assert_eq!(summary.state, "idle");
731 assert_eq!(summary.signal.as_deref(), Some("nudged"));
732 assert_eq!(summary.label.as_deref(), Some("idle nudge sent"));
733 }
734
735 #[test]
736 fn summarize_runtime_member_status_tracks_paused_automation() {
737 let summary = status::summarize_runtime_member_status(
738 "#[fg=cyan]working#[default] #[fg=244]nudge paused#[default] #[fg=244]standup paused#[default]",
739 false,
740 );
741
742 assert_eq!(summary.state, "working");
743 assert_eq!(
744 summary.signal.as_deref(),
745 Some("nudge paused, standup paused")
746 );
747 assert_eq!(
748 summary.label.as_deref(),
749 Some("working nudge paused standup paused")
750 );
751 }
752
753 #[test]
754 fn build_team_status_rows_defaults_by_session_state() {
755 let architect = make_member("architect", "architect", RoleType::Architect);
756 let human = hierarchy::MemberInstance {
757 name: "human".to_string(),
758 role_name: "human".to_string(),
759 role_type: RoleType::User,
760 agent: None,
761 prompt: None,
762 reports_to: None,
763 use_worktrees: false,
764 };
765
766 let pending = std::collections::HashMap::from([
767 (architect.name.clone(), 3usize),
768 (human.name.clone(), 1usize),
769 ]);
770 let triage = std::collections::HashMap::from([(architect.name.clone(), 2usize)]);
771 let owned = std::collections::HashMap::from([(
772 architect.name.clone(),
773 status::OwnedTaskBuckets {
774 active: vec![191u32],
775 review: vec![193u32],
776 },
777 )]);
778 let rows = status::build_team_status_rows(
779 &[architect.clone(), human.clone()],
780 false,
781 &Default::default(),
782 &pending,
783 &triage,
784 &owned,
785 &Default::default(),
786 );
787 assert_eq!(rows[0].state, "stopped");
788 assert_eq!(rows[0].pending_inbox, 3);
789 assert_eq!(rows[0].triage_backlog, 2);
790 assert_eq!(rows[0].active_owned_tasks, vec![191]);
791 assert_eq!(rows[0].review_owned_tasks, vec![193]);
792 assert_eq!(rows[0].health_summary, "-");
793 assert_eq!(rows[1].state, "user");
794 assert_eq!(rows[1].pending_inbox, 1);
795 assert_eq!(rows[1].triage_backlog, 0);
796 assert!(rows[1].active_owned_tasks.is_empty());
797 assert!(rows[1].review_owned_tasks.is_empty());
798
799 let runtime = std::collections::HashMap::from([(
800 architect.name.clone(),
801 status::RuntimeMemberStatus {
802 state: "idle".to_string(),
803 signal: Some("standup".to_string()),
804 label: Some("idle standup 2:00".to_string()),
805 },
806 )]);
807 let rows = status::build_team_status_rows(
808 &[architect],
809 true,
810 &runtime,
811 &pending,
812 &triage,
813 &owned,
814 &Default::default(),
815 );
816 assert_eq!(rows[0].state, "reviewing");
817 assert_eq!(rows[0].pending_inbox, 3);
818 assert_eq!(rows[0].triage_backlog, 2);
819 assert_eq!(rows[0].active_owned_tasks, vec![191]);
820 assert_eq!(rows[0].review_owned_tasks, vec![193]);
821 assert_eq!(
822 rows[0].signal.as_deref(),
823 Some("standup, needs triage (2), needs review (1)")
824 );
825 assert_eq!(rows[0].runtime_label.as_deref(), Some("idle standup 2:00"));
826 }
827
828 #[test]
829 fn delivered_direct_report_triage_count_only_counts_results_newer_than_lead_response() {
830 let tmp = tempfile::tempdir().unwrap();
831 let root = inbox::inboxes_root(tmp.path());
832 inbox::init_inbox(&root, "lead").unwrap();
833 inbox::init_inbox(&root, "eng-1").unwrap();
834 inbox::init_inbox(&root, "eng-2").unwrap();
835
836 let mut old_result = inbox::InboxMessage::new_send("eng-1", "lead", "old result");
837 old_result.timestamp = 10;
838 let old_result_id = inbox::deliver_to_inbox(&root, &old_result).unwrap();
839 inbox::mark_delivered(&root, "lead", &old_result_id).unwrap();
840
841 let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "next task");
842 lead_reply.timestamp = 20;
843 let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
844 inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
845
846 let mut new_result = inbox::InboxMessage::new_send("eng-1", "lead", "new result");
847 new_result.timestamp = 30;
848 let new_result_id = inbox::deliver_to_inbox(&root, &new_result).unwrap();
849 inbox::mark_delivered(&root, "lead", &new_result_id).unwrap();
850
851 let mut other_result = inbox::InboxMessage::new_send("eng-2", "lead", "parallel result");
852 other_result.timestamp = 40;
853 let other_result_id = inbox::deliver_to_inbox(&root, &other_result).unwrap();
854 inbox::mark_delivered(&root, "lead", &other_result_id).unwrap();
855
856 let triage_state = status::delivered_direct_report_triage_state_at(
857 &root,
858 "lead",
859 &["eng-1".to_string(), "eng-2".to_string()],
860 100,
861 )
862 .unwrap();
863 assert_eq!(triage_state.count, 2);
864 assert_eq!(triage_state.newest_result_ts, 40);
865 }
866
867 #[test]
868 fn delivered_direct_report_triage_count_excludes_stale_delivered_results() {
869 let tmp = tempfile::tempdir().unwrap();
870 let root = inbox::inboxes_root(tmp.path());
871 inbox::init_inbox(&root, "lead").unwrap();
872 inbox::init_inbox(&root, "eng-1").unwrap();
873
874 let mut stale_result = inbox::InboxMessage::new_send("eng-1", "lead", "stale result");
875 stale_result.timestamp = 10;
876 let stale_result_id = inbox::deliver_to_inbox(&root, &stale_result).unwrap();
877 inbox::mark_delivered(&root, "lead", &stale_result_id).unwrap();
878
879 let triage_state = status::delivered_direct_report_triage_state_at(
880 &root,
881 "lead",
882 &["eng-1".to_string()],
883 10 + TRIAGE_RESULT_FRESHNESS_SECONDS + 1,
884 )
885 .unwrap();
886
887 assert_eq!(triage_state.count, 0);
888 assert_eq!(triage_state.newest_result_ts, 0);
889 }
890
891 #[test]
892 fn delivered_direct_report_triage_count_keeps_fresh_delivered_results() {
893 let tmp = tempfile::tempdir().unwrap();
894 let root = inbox::inboxes_root(tmp.path());
895 inbox::init_inbox(&root, "lead").unwrap();
896 inbox::init_inbox(&root, "eng-1").unwrap();
897
898 let mut fresh_result = inbox::InboxMessage::new_send("eng-1", "lead", "fresh result");
899 fresh_result.timestamp = 100;
900 let fresh_result_id = inbox::deliver_to_inbox(&root, &fresh_result).unwrap();
901 inbox::mark_delivered(&root, "lead", &fresh_result_id).unwrap();
902
903 let triage_state = status::delivered_direct_report_triage_state_at(
904 &root,
905 "lead",
906 &["eng-1".to_string()],
907 150,
908 )
909 .unwrap();
910
911 assert_eq!(triage_state.count, 1);
912 assert_eq!(triage_state.newest_result_ts, 100);
913 }
914
915 #[test]
916 fn delivered_direct_report_triage_count_excludes_acked_results() {
917 let tmp = tempfile::tempdir().unwrap();
918 let root = inbox::inboxes_root(tmp.path());
919 inbox::init_inbox(&root, "lead").unwrap();
920 inbox::init_inbox(&root, "eng-1").unwrap();
921
922 let mut result = inbox::InboxMessage::new_send("eng-1", "lead", "task complete");
923 result.timestamp = 100;
924 let result_id = inbox::deliver_to_inbox(&root, &result).unwrap();
925 inbox::mark_delivered(&root, "lead", &result_id).unwrap();
926
927 let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "acknowledged");
928 lead_reply.timestamp = 110;
929 let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
930 inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
931
932 let triage_state = status::delivered_direct_report_triage_state_at(
933 &root,
934 "lead",
935 &["eng-1".to_string()],
936 150,
937 )
938 .unwrap();
939
940 assert_eq!(triage_state.count, 0);
941 assert_eq!(triage_state.newest_result_ts, 0);
942 }
943
944 #[test]
945 fn format_owned_tasks_summary_compacts_multiple_ids() {
946 assert_eq!(status::format_owned_tasks_summary(&[]), "-");
947 assert_eq!(status::format_owned_tasks_summary(&[191]), "#191");
948 assert_eq!(status::format_owned_tasks_summary(&[191, 192]), "#191,#192");
949 assert_eq!(
950 status::format_owned_tasks_summary(&[191, 192, 193]),
951 "#191,#192,+1"
952 );
953 }
954
955 #[test]
956 fn owned_task_buckets_split_active_and_review_claims() {
957 let tmp = tempfile::tempdir().unwrap();
958 let members = vec![
959 make_member("lead", "lead", RoleType::Manager),
960 hierarchy::MemberInstance {
961 name: "eng-1".to_string(),
962 role_name: "eng".to_string(),
963 role_type: RoleType::Engineer,
964 agent: Some("codex".to_string()),
965 prompt: None,
966 reports_to: Some("lead".to_string()),
967 use_worktrees: false,
968 },
969 ];
970 std::fs::create_dir_all(
971 tmp.path()
972 .join(".batty")
973 .join("team_config")
974 .join("board")
975 .join("tasks"),
976 )
977 .unwrap();
978 std::fs::write(
979 tmp.path()
980 .join(".batty")
981 .join("team_config")
982 .join("board")
983 .join("tasks")
984 .join("191-active.md"),
985 "---\nid: 191\ntitle: Active\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
986 )
987 .unwrap();
988 std::fs::write(
989 tmp.path()
990 .join(".batty")
991 .join("team_config")
992 .join("board")
993 .join("tasks")
994 .join("193-review.md"),
995 "---\nid: 193\ntitle: Review\nstatus: review\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
996 )
997 .unwrap();
998
999 let owned = status::owned_task_buckets(tmp.path(), &members);
1000 let buckets = owned.get("eng-1").unwrap();
1001 assert_eq!(buckets.active, vec![191]);
1002 assert!(buckets.review.is_empty());
1003 let review_buckets = owned.get("lead").unwrap();
1004 assert!(review_buckets.active.is_empty());
1005 assert_eq!(review_buckets.review, vec![193]);
1006 }
1007
1008 #[test]
1009 fn workflow_metrics_enabled_detects_supported_modes() {
1010 let tmp = tempfile::tempdir().unwrap();
1011 let config_path = tmp.path().join("team.yaml");
1012
1013 std::fs::write(
1014 &config_path,
1015 "name: test\nworkflow_mode: hybrid\nroles: []\n",
1016 )
1017 .unwrap();
1018 assert!(status::workflow_metrics_enabled(&config_path));
1019
1020 std::fs::write(
1021 &config_path,
1022 "name: test\nworkflow_mode: workflow_first\nroles: []\n",
1023 )
1024 .unwrap();
1025 assert!(status::workflow_metrics_enabled(&config_path));
1026
1027 std::fs::write(&config_path, "name: test\nroles: []\n").unwrap();
1028 assert!(!status::workflow_metrics_enabled(&config_path));
1029 }
1030
1031 #[test]
1032 fn team_status_metrics_section_renders_when_workflow_mode_enabled() {
1033 let tmp = tempfile::tempdir().unwrap();
1034 let team_dir = tmp.path().join(".batty").join("team_config");
1035 let board_dir = team_dir.join("board");
1036 let tasks_dir = board_dir.join("tasks");
1037 std::fs::create_dir_all(&tasks_dir).unwrap();
1038 std::fs::write(
1039 team_dir.join("team.yaml"),
1040 "name: test\nworkflow_mode: hybrid\nroles:\n - name: engineer\n role_type: engineer\n agent: codex\n",
1041 )
1042 .unwrap();
1043 std::fs::write(
1044 tasks_dir.join("031-runnable.md"),
1045 "---\nid: 31\ntitle: Runnable\nstatus: todo\npriority: medium\nclass: standard\n---\n\nTask body.\n",
1046 )
1047 .unwrap();
1048
1049 let members = vec![make_member("eng-1-1", "engineer", RoleType::Engineer)];
1050 let section = status::workflow_metrics_section(tmp.path(), &members).unwrap();
1051
1052 assert!(section.0.contains("Workflow Metrics"));
1053 assert_eq!(section.1.runnable_count, 1);
1054 assert_eq!(section.1.idle_with_runnable, vec!["eng-1-1"]);
1055 }
1056
1057 #[test]
1058 #[serial]
1059 #[cfg_attr(not(feature = "integration"), ignore)]
1060 fn list_runtime_member_statuses_reads_tmux_role_and_status_options() {
1061 let session = "batty-test-team-status-runtime";
1062 let _ = crate::tmux::kill_session(session);
1063
1064 crate::tmux::create_session(session, "sleep", &["20".to_string()], "/tmp").unwrap();
1065 let pane_id = crate::tmux::pane_id(session).unwrap();
1066
1067 let role_output = std::process::Command::new("tmux")
1068 .args(["set-option", "-p", "-t", &pane_id, "@batty_role", "eng-1"])
1069 .output()
1070 .unwrap();
1071 assert!(role_output.status.success());
1072
1073 let status_output = std::process::Command::new("tmux")
1074 .args([
1075 "set-option",
1076 "-p",
1077 "-t",
1078 &pane_id,
1079 "@batty_status",
1080 "#[fg=yellow]idle#[default] #[fg=magenta]nudge 0:30#[default]",
1081 ])
1082 .output()
1083 .unwrap();
1084 assert!(status_output.status.success());
1085
1086 let statuses = status::list_runtime_member_statuses(session).unwrap();
1087 let eng = statuses.get("eng-1").unwrap();
1088 assert_eq!(eng.state, "idle");
1089 assert_eq!(eng.signal.as_deref(), Some("waiting for nudge"));
1090 assert_eq!(eng.label.as_deref(), Some("idle nudge 0:30"));
1091
1092 crate::tmux::kill_session(session).unwrap();
1093 }
1094
1095 #[test]
1098 fn session_summary_counts_completions_correctly() {
1099 let tmp = tempfile::tempdir().unwrap();
1100 let events_dir = tmp.path().join(".batty").join("team_config");
1101 std::fs::create_dir_all(&events_dir).unwrap();
1102
1103 let now = crate::team::now_unix();
1104 let events = [
1105 format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 3600),
1106 format!(
1107 r#"{{"event":"task_completed","role":"eng-1","task":"10","ts":{}}}"#,
1108 now - 3000
1109 ),
1110 format!(
1111 r#"{{"event":"task_completed","role":"eng-2","task":"11","ts":{}}}"#,
1112 now - 2000
1113 ),
1114 format!(
1115 r#"{{"event":"task_auto_merged","role":"eng-1","task":"10","ts":{}}}"#,
1116 now - 2900
1117 ),
1118 format!(
1119 r#"{{"event":"task_manual_merged","role":"eng-2","task":"11","ts":{}}}"#,
1120 now - 1900
1121 ),
1122 format!(
1123 r#"{{"event":"task_completed","role":"eng-1","task":"12","ts":{}}}"#,
1124 now - 1000
1125 ),
1126 ];
1127 std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
1128
1129 let summary = compute_session_summary(tmp.path()).unwrap();
1130 assert_eq!(summary.tasks_completed, 3);
1131 assert_eq!(summary.tasks_merged, 2);
1132 assert!(summary.runtime_secs >= 3599 && summary.runtime_secs <= 3601);
1133 }
1134
1135 #[test]
1136 fn session_summary_calculates_runtime() {
1137 let tmp = tempfile::tempdir().unwrap();
1138 let events_dir = tmp.path().join(".batty").join("team_config");
1139 std::fs::create_dir_all(&events_dir).unwrap();
1140
1141 let now = crate::team::now_unix();
1142 let events = [format!(
1143 r#"{{"event":"daemon_started","ts":{}}}"#,
1144 now - 7200
1145 )];
1146 std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
1147
1148 let summary = compute_session_summary(tmp.path()).unwrap();
1149 assert_eq!(summary.tasks_completed, 0);
1150 assert_eq!(summary.tasks_merged, 0);
1151 assert!(summary.runtime_secs >= 7199 && summary.runtime_secs <= 7201);
1152 }
1153
1154 #[test]
1155 fn session_summary_handles_empty_session() {
1156 let tmp = tempfile::tempdir().unwrap();
1157 let events_dir = tmp.path().join(".batty").join("team_config");
1158 std::fs::create_dir_all(&events_dir).unwrap();
1159
1160 std::fs::write(events_dir.join("events.jsonl"), "").unwrap();
1162 assert!(compute_session_summary(tmp.path()).is_none());
1163 }
1164
1165 #[test]
1166 fn session_summary_handles_missing_events_file() {
1167 let tmp = tempfile::tempdir().unwrap();
1168 assert!(compute_session_summary(tmp.path()).is_none());
1170 }
1171
1172 #[test]
1173 fn session_summary_display_format() {
1174 let summary = SessionSummary {
1175 tasks_completed: 5,
1176 tasks_merged: 4,
1177 runtime_secs: 8100, };
1179 assert_eq!(
1180 summary.display(),
1181 format!(
1182 "Session summary: 5 tasks completed, 4 merged, runtime 2h 15m\nBatty v{} — https://github.com/battysh/batty",
1183 env!("CARGO_PKG_VERSION")
1184 )
1185 );
1186 }
1187
1188 #[test]
1189 fn format_runtime_seconds() {
1190 assert_eq!(format_runtime(45), "45s");
1191 }
1192
1193 #[test]
1194 fn format_runtime_minutes() {
1195 assert_eq!(format_runtime(300), "5m");
1196 }
1197
1198 #[test]
1199 fn format_runtime_hours_and_minutes() {
1200 assert_eq!(format_runtime(5400), "1h 30m");
1201 }
1202
1203 #[test]
1204 fn format_runtime_exact_hours() {
1205 assert_eq!(format_runtime(7200), "2h");
1206 }
1207
1208 #[test]
1209 fn session_summary_uses_latest_daemon_started() {
1210 let tmp = tempfile::tempdir().unwrap();
1211 let events_dir = tmp.path().join(".batty").join("team_config");
1212 std::fs::create_dir_all(&events_dir).unwrap();
1213
1214 let now = crate::team::now_unix();
1215 let events = [
1217 format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 7200),
1218 format!(
1219 r#"{{"event":"task_completed","role":"eng-1","task":"1","ts":{}}}"#,
1220 now - 6000
1221 ),
1222 format!(
1223 r#"{{"event":"task_completed","role":"eng-1","task":"2","ts":{}}}"#,
1224 now - 5000
1225 ),
1226 format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 1800),
1227 format!(
1228 r#"{{"event":"task_completed","role":"eng-1","task":"3","ts":{}}}"#,
1229 now - 1000
1230 ),
1231 ];
1232 std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
1233
1234 let summary = compute_session_summary(tmp.path()).unwrap();
1235 assert_eq!(summary.tasks_completed, 1);
1237 assert!(summary.runtime_secs >= 1799 && summary.runtime_secs <= 1801);
1238 }
1239
1240 fn production_unwrap_expect_count(source: &str) -> usize {
1242 let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
1244 &source[..pos]
1245 } else {
1246 source
1247 };
1248 prod.lines()
1249 .filter(|line| {
1250 let trimmed = line.trim();
1251 !trimmed.starts_with("#[cfg(test)]")
1253 && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
1254 })
1255 .count()
1256 }
1257
1258 #[test]
1259 fn production_daemon_mgmt_has_limited_unwrap_or_expect_calls() {
1260 let src = include_str!("daemon_mgmt.rs");
1261 assert!(
1263 production_unwrap_expect_count(src) <= 1,
1264 "daemon_mgmt.rs should minimize unwrap/expect in production code"
1265 );
1266 }
1267
1268 #[test]
1269 fn production_session_has_no_unwrap_or_expect_calls() {
1270 let src = include_str!("session.rs");
1271 assert_eq!(
1272 production_unwrap_expect_count(src),
1273 0,
1274 "session.rs should avoid unwrap/expect"
1275 );
1276 }
1277}