Skip to main content

batty_cli/team/
mod.rs

1//! Team mode — hierarchical agent org chart with daemon-managed communication.
2//!
3//! A YAML-defined team (architect ↔ manager ↔ N engineers) runs in a tmux
4//! session. The daemon monitors panes, routes messages between roles, and
5//! manages agent lifecycles.
6
7pub mod artifact;
8pub mod auto_merge;
9#[cfg(test)]
10mod behavioral_tests;
11pub mod board;
12#[cfg(test)]
13mod smoke_tests;
14// -- Decomposed submodules --
15mod init;
16pub use init::*;
17mod load;
18pub use load::*;
19mod messaging;
20pub use messaging::*;
21pub mod board_cmd;
22pub mod board_health;
23pub mod capability;
24pub mod checkpoint;
25pub mod comms;
26pub mod completion;
27pub mod config;
28pub mod cost;
29pub mod daemon;
30mod daemon_mgmt;
31pub mod delivery;
32pub mod deps;
33pub mod doctor;
34pub mod errors;
35pub mod estimation;
36pub mod events;
37pub mod failure_patterns;
38pub mod git_cmd;
39pub mod grafana;
40pub mod harness;
41pub mod hierarchy;
42pub mod inbox;
43pub mod layout;
44pub use daemon_mgmt::*;
45mod session;
46pub use session::*;
47pub mod merge;
48pub mod message;
49pub mod metrics;
50pub mod metrics_cmd;
51pub mod nudge;
52pub mod policy;
53pub mod resolver;
54pub mod retrospective;
55pub mod retry;
56pub mod review;
57pub mod standup;
58pub mod status;
59pub mod task_cmd;
60pub mod task_loop;
61pub mod telegram;
62pub mod telemetry_db;
63#[cfg(test)]
64pub mod test_helpers;
65#[cfg(test)]
66pub mod test_support;
67pub mod validation;
68pub mod watcher;
69pub mod workflow;
70
71use std::path::{Path, PathBuf};
72use std::time::{Duration, SystemTime, UNIX_EPOCH};
73
74use anyhow::{Context, Result};
75use serde::{Deserialize, Serialize};
76
77/// Team config directory name inside `.batty/`.
78pub const TEAM_CONFIG_DIR: &str = "team_config";
79/// Team config filename.
80pub const TEAM_CONFIG_FILE: &str = "team.yaml";
81
82const TRIAGE_RESULT_FRESHNESS_SECONDS: u64 = 300;
83pub(crate) const DEFAULT_EVENT_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub enum AssignmentResultStatus {
88    Delivered,
89    Failed,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct AssignmentDeliveryResult {
94    pub message_id: String,
95    pub status: AssignmentResultStatus,
96    pub engineer: String,
97    pub task_summary: String,
98    pub branch: Option<String>,
99    pub work_dir: Option<String>,
100    pub detail: String,
101    pub ts: u64,
102}
103
104/// Resolve the team config directory for a project root.
105pub fn team_config_dir(project_root: &Path) -> PathBuf {
106    project_root.join(".batty").join(TEAM_CONFIG_DIR)
107}
108
109/// Resolve the path to team.yaml.
110pub fn team_config_path(project_root: &Path) -> PathBuf {
111    team_config_dir(project_root).join(TEAM_CONFIG_FILE)
112}
113
114pub fn team_events_path(project_root: &Path) -> PathBuf {
115    team_config_dir(project_root).join("events.jsonl")
116}
117
118pub(crate) fn orchestrator_log_path(project_root: &Path) -> PathBuf {
119    project_root.join(".batty").join("orchestrator.log")
120}
121
122pub(crate) fn orchestrator_ansi_log_path(project_root: &Path) -> PathBuf {
123    project_root.join(".batty").join("orchestrator.ansi.log")
124}
125
126/// Directory containing per-agent PTY log files written by the shim.
127#[allow(dead_code)] // Public API for future shim-mode daemon integration
128pub(crate) fn shim_logs_dir(project_root: &Path) -> PathBuf {
129    project_root.join(".batty").join("shim-logs")
130}
131
132/// Path to an individual agent's PTY log file.
133#[allow(dead_code)] // Public API for future shim-mode daemon integration
134pub(crate) fn shim_log_path(project_root: &Path, agent_id: &str) -> PathBuf {
135    shim_logs_dir(project_root).join(format!("{agent_id}.pty.log"))
136}
137
138fn assignment_results_dir(project_root: &Path) -> PathBuf {
139    project_root.join(".batty").join("assignment_results")
140}
141
142fn assignment_result_path(project_root: &Path, message_id: &str) -> PathBuf {
143    assignment_results_dir(project_root).join(format!("{message_id}.json"))
144}
145
146pub(crate) fn store_assignment_result(
147    project_root: &Path,
148    result: &AssignmentDeliveryResult,
149) -> Result<()> {
150    let path = assignment_result_path(project_root, &result.message_id);
151    if let Some(parent) = path.parent() {
152        std::fs::create_dir_all(parent)?;
153    }
154    let content = serde_json::to_vec_pretty(result)?;
155    std::fs::write(&path, content)
156        .with_context(|| format!("failed to write assignment result {}", path.display()))?;
157    Ok(())
158}
159
160pub fn load_assignment_result(
161    project_root: &Path,
162    message_id: &str,
163) -> Result<Option<AssignmentDeliveryResult>> {
164    let path = assignment_result_path(project_root, message_id);
165    if !path.exists() {
166        return Ok(None);
167    }
168    let data = std::fs::read(&path)
169        .with_context(|| format!("failed to read assignment result {}", path.display()))?;
170    let result = serde_json::from_slice(&data)
171        .with_context(|| format!("failed to parse assignment result {}", path.display()))?;
172    Ok(Some(result))
173}
174
175pub fn wait_for_assignment_result(
176    project_root: &Path,
177    message_id: &str,
178    timeout: Duration,
179) -> Result<Option<AssignmentDeliveryResult>> {
180    let deadline = std::time::Instant::now() + timeout;
181    loop {
182        if let Some(result) = load_assignment_result(project_root, message_id)? {
183            return Ok(Some(result));
184        }
185        if std::time::Instant::now() >= deadline {
186            return Ok(None);
187        }
188        std::thread::sleep(Duration::from_millis(200));
189    }
190}
191
192pub fn format_assignment_result(result: &AssignmentDeliveryResult) -> String {
193    let mut text = match result.status {
194        AssignmentResultStatus::Delivered => {
195            format!(
196                "Assignment delivered: {} -> {}",
197                result.message_id, result.engineer
198            )
199        }
200        AssignmentResultStatus::Failed => {
201            format!(
202                "Assignment failed: {} -> {}",
203                result.message_id, result.engineer
204            )
205        }
206    };
207
208    text.push_str(&format!("\nTask: {}", result.task_summary));
209    if let Some(branch) = result.branch.as_deref() {
210        text.push_str(&format!("\nBranch: {branch}"));
211    }
212    if let Some(work_dir) = result.work_dir.as_deref() {
213        text.push_str(&format!("\nWorktree: {work_dir}"));
214    }
215    if !result.detail.is_empty() {
216        text.push_str(&format!("\nDetail: {}", result.detail));
217    }
218    text
219}
220
221pub(crate) fn now_unix() -> u64 {
222    SystemTime::now()
223        .duration_since(UNIX_EPOCH)
224        .unwrap_or_default()
225        .as_secs()
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn team_config_dir_is_under_batty() {
234        let root = Path::new("/tmp/project");
235        assert_eq!(
236            team_config_dir(root),
237            PathBuf::from("/tmp/project/.batty/team_config")
238        );
239    }
240
241    #[test]
242    fn team_config_path_points_to_yaml() {
243        let root = Path::new("/tmp/project");
244        assert_eq!(
245            team_config_path(root),
246            PathBuf::from("/tmp/project/.batty/team_config/team.yaml")
247        );
248    }
249
250    #[test]
251    fn assignment_result_round_trip_and_format() {
252        let tmp = tempfile::tempdir().unwrap();
253        let result = AssignmentDeliveryResult {
254            message_id: "msg-1".to_string(),
255            status: AssignmentResultStatus::Delivered,
256            engineer: "eng-1-1".to_string(),
257            task_summary: "Say Hello".to_string(),
258            branch: Some("eng-1-1/task-1".to_string()),
259            work_dir: Some("/tmp/worktree".to_string()),
260            detail: "assignment launched".to_string(),
261            ts: now_unix(),
262        };
263
264        store_assignment_result(tmp.path(), &result).unwrap();
265        let loaded = load_assignment_result(tmp.path(), "msg-1")
266            .unwrap()
267            .unwrap();
268        assert_eq!(loaded, result);
269
270        let formatted = format_assignment_result(&loaded);
271        assert!(formatted.contains("Assignment delivered: msg-1 -> eng-1-1"));
272        assert!(formatted.contains("Branch: eng-1-1/task-1"));
273        assert!(formatted.contains("Worktree: /tmp/worktree"));
274    }
275
276    #[test]
277    fn wait_for_assignment_result_returns_none_when_missing() {
278        let tmp = tempfile::tempdir().unwrap();
279        let result =
280            wait_for_assignment_result(tmp.path(), "missing", Duration::from_millis(10)).unwrap();
281        assert!(result.is_none());
282    }
283
284    #[test]
285    fn shim_logs_dir_path() {
286        let root = Path::new("/tmp/project");
287        assert_eq!(
288            shim_logs_dir(root),
289            PathBuf::from("/tmp/project/.batty/shim-logs")
290        );
291    }
292
293    #[test]
294    fn shim_log_path_includes_agent_id() {
295        let root = Path::new("/tmp/project");
296        assert_eq!(
297            shim_log_path(root, "eng-1-1"),
298            PathBuf::from("/tmp/project/.batty/shim-logs/eng-1-1.pty.log")
299        );
300    }
301
302    /// Count unwrap()/expect() calls in production code (before `#[cfg(test)] mod tests`).
303    fn production_unwrap_expect_count(source: &str) -> usize {
304        // Split at the test module boundary, not individual #[cfg(test)] items
305        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
306            &source[..pos]
307        } else {
308            source
309        };
310        prod.lines()
311            .filter(|line| {
312                let trimmed = line.trim();
313                // Skip lines that are themselves cfg(test)-gated items
314                !trimmed.starts_with("#[cfg(test)]")
315                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
316            })
317            .count()
318    }
319
320    #[test]
321    fn production_mod_has_no_unwrap_or_expect_calls() {
322        let src = include_str!("mod.rs");
323        assert_eq!(
324            production_unwrap_expect_count(src),
325            0,
326            "production mod.rs should avoid unwrap/expect"
327        );
328    }
329}