1pub mod artifact;
8pub mod auto_merge;
9#[cfg(test)]
10mod behavioral_tests;
11pub mod board;
12#[cfg(test)]
13mod smoke_tests;
14mod 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
77pub const TEAM_CONFIG_DIR: &str = "team_config";
79pub 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
104pub fn team_config_dir(project_root: &Path) -> PathBuf {
106 project_root.join(".batty").join(TEAM_CONFIG_DIR)
107}
108
109pub 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#[allow(dead_code)] pub(crate) fn shim_logs_dir(project_root: &Path) -> PathBuf {
129 project_root.join(".batty").join("shim-logs")
130}
131
132#[allow(dead_code)] pub(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 fn production_unwrap_expect_count(source: &str) -> usize {
304 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 !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}