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