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 config_diff;
29pub mod cost;
30pub mod daemon;
31mod daemon_mgmt;
32pub mod delivery;
33pub mod deps;
34pub mod doctor;
35pub mod errors;
36pub mod estimation;
37pub mod events;
38pub mod failure_patterns;
39pub mod git_cmd;
40pub mod grafana;
41pub mod harness;
42pub mod hierarchy;
43pub mod inbox;
44pub mod layout;
45pub use daemon_mgmt::*;
46mod session;
47pub use session::*;
48pub mod merge;
49pub mod message;
50pub mod metrics;
51pub mod metrics_cmd;
52pub mod nudge;
53pub mod policy;
54pub mod resolver;
55pub mod retrospective;
56pub mod retry;
57pub mod review;
58pub mod scale;
59pub mod standup;
60pub mod status;
61pub mod tact;
62pub mod task_cmd;
63pub mod task_loop;
64pub mod telegram;
65pub mod telemetry_db;
66#[cfg(test)]
67pub mod test_helpers;
68#[cfg(test)]
69pub mod test_support;
70pub mod validation;
71pub mod watcher;
72pub mod workflow;
73
74use std::path::{Path, PathBuf};
75use std::time::{Duration, SystemTime, UNIX_EPOCH};
76
77use anyhow::{Context, Result};
78use serde::{Deserialize, Serialize};
79
80pub const TEAM_CONFIG_DIR: &str = "team_config";
82pub const TEAM_CONFIG_FILE: &str = "team.yaml";
84
85const TRIAGE_RESULT_FRESHNESS_SECONDS: u64 = 300;
86pub(crate) const DEFAULT_EVENT_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum AssignmentResultStatus {
91 Delivered,
92 Failed,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct AssignmentDeliveryResult {
97 pub message_id: String,
98 pub status: AssignmentResultStatus,
99 pub engineer: String,
100 pub task_summary: String,
101 pub branch: Option<String>,
102 pub work_dir: Option<String>,
103 pub detail: String,
104 pub ts: u64,
105}
106
107pub fn team_config_dir(project_root: &Path) -> PathBuf {
109 project_root.join(".batty").join(TEAM_CONFIG_DIR)
110}
111
112pub fn team_config_path(project_root: &Path) -> PathBuf {
114 team_config_dir(project_root).join(TEAM_CONFIG_FILE)
115}
116
117pub fn team_events_path(project_root: &Path) -> PathBuf {
118 team_config_dir(project_root).join("events.jsonl")
119}
120
121pub(crate) fn orchestrator_log_path(project_root: &Path) -> PathBuf {
122 project_root.join(".batty").join("orchestrator.log")
123}
124
125pub(crate) fn orchestrator_ansi_log_path(project_root: &Path) -> PathBuf {
126 project_root.join(".batty").join("orchestrator.ansi.log")
127}
128
129#[allow(dead_code)] pub(crate) fn shim_logs_dir(project_root: &Path) -> PathBuf {
132 project_root.join(".batty").join("shim-logs")
133}
134
135#[allow(dead_code)] pub(crate) fn shim_log_path(project_root: &Path, agent_id: &str) -> PathBuf {
138 shim_logs_dir(project_root).join(format!("{agent_id}.pty.log"))
139}
140
141pub(crate) fn shim_events_log_path(project_root: &Path, agent_id: &str) -> PathBuf {
142 shim_logs_dir(project_root).join(format!("{agent_id}.events.log"))
143}
144
145pub(crate) fn append_shim_event_log(project_root: &Path, agent_id: &str, line: &str) -> Result<()> {
146 let path = shim_events_log_path(project_root, agent_id);
147 if let Some(parent) = path.parent() {
148 std::fs::create_dir_all(parent)?;
149 }
150 let mut file = std::fs::OpenOptions::new()
151 .create(true)
152 .append(true)
153 .open(&path)
154 .with_context(|| format!("failed to open shim event log {}", path.display()))?;
155 use std::io::Write;
156 writeln!(file, "[{}] {}", now_unix(), line)
157 .with_context(|| format!("failed to write shim event log {}", path.display()))?;
158 Ok(())
159}
160
161fn assignment_results_dir(project_root: &Path) -> PathBuf {
162 project_root.join(".batty").join("assignment_results")
163}
164
165fn assignment_result_path(project_root: &Path, message_id: &str) -> PathBuf {
166 assignment_results_dir(project_root).join(format!("{message_id}.json"))
167}
168
169pub(crate) fn store_assignment_result(
170 project_root: &Path,
171 result: &AssignmentDeliveryResult,
172) -> Result<()> {
173 let path = assignment_result_path(project_root, &result.message_id);
174 if let Some(parent) = path.parent() {
175 std::fs::create_dir_all(parent)?;
176 }
177 let content = serde_json::to_vec_pretty(result)?;
178 std::fs::write(&path, content)
179 .with_context(|| format!("failed to write assignment result {}", path.display()))?;
180 Ok(())
181}
182
183pub fn load_assignment_result(
184 project_root: &Path,
185 message_id: &str,
186) -> Result<Option<AssignmentDeliveryResult>> {
187 let path = assignment_result_path(project_root, message_id);
188 if !path.exists() {
189 return Ok(None);
190 }
191 let data = std::fs::read(&path)
192 .with_context(|| format!("failed to read assignment result {}", path.display()))?;
193 let result = serde_json::from_slice(&data)
194 .with_context(|| format!("failed to parse assignment result {}", path.display()))?;
195 Ok(Some(result))
196}
197
198pub fn wait_for_assignment_result(
199 project_root: &Path,
200 message_id: &str,
201 timeout: Duration,
202) -> Result<Option<AssignmentDeliveryResult>> {
203 let deadline = std::time::Instant::now() + timeout;
204 loop {
205 if let Some(result) = load_assignment_result(project_root, message_id)? {
206 return Ok(Some(result));
207 }
208 if std::time::Instant::now() >= deadline {
209 return Ok(None);
210 }
211 std::thread::sleep(Duration::from_millis(200));
212 }
213}
214
215pub fn format_assignment_result(result: &AssignmentDeliveryResult) -> String {
216 let mut text = match result.status {
217 AssignmentResultStatus::Delivered => {
218 format!(
219 "Assignment delivered: {} -> {}",
220 result.message_id, result.engineer
221 )
222 }
223 AssignmentResultStatus::Failed => {
224 format!(
225 "Assignment failed: {} -> {}",
226 result.message_id, result.engineer
227 )
228 }
229 };
230
231 text.push_str(&format!("\nTask: {}", result.task_summary));
232 if let Some(branch) = result.branch.as_deref() {
233 text.push_str(&format!("\nBranch: {branch}"));
234 }
235 if let Some(work_dir) = result.work_dir.as_deref() {
236 text.push_str(&format!("\nWorktree: {work_dir}"));
237 }
238 if !result.detail.is_empty() {
239 text.push_str(&format!("\nDetail: {}", result.detail));
240 }
241 text
242}
243
244pub(crate) fn now_unix() -> u64 {
245 SystemTime::now()
246 .duration_since(UNIX_EPOCH)
247 .unwrap_or_default()
248 .as_secs()
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn team_config_dir_is_under_batty() {
257 let root = Path::new("/tmp/project");
258 assert_eq!(
259 team_config_dir(root),
260 PathBuf::from("/tmp/project/.batty/team_config")
261 );
262 }
263
264 #[test]
265 fn team_config_path_points_to_yaml() {
266 let root = Path::new("/tmp/project");
267 assert_eq!(
268 team_config_path(root),
269 PathBuf::from("/tmp/project/.batty/team_config/team.yaml")
270 );
271 }
272
273 #[test]
274 fn assignment_result_round_trip_and_format() {
275 let tmp = tempfile::tempdir().unwrap();
276 let result = AssignmentDeliveryResult {
277 message_id: "msg-1".to_string(),
278 status: AssignmentResultStatus::Delivered,
279 engineer: "eng-1-1".to_string(),
280 task_summary: "Say Hello".to_string(),
281 branch: Some("eng-1-1/task-1".to_string()),
282 work_dir: Some("/tmp/worktree".to_string()),
283 detail: "assignment launched".to_string(),
284 ts: now_unix(),
285 };
286
287 store_assignment_result(tmp.path(), &result).unwrap();
288 let loaded = load_assignment_result(tmp.path(), "msg-1")
289 .unwrap()
290 .unwrap();
291 assert_eq!(loaded, result);
292
293 let formatted = format_assignment_result(&loaded);
294 assert!(formatted.contains("Assignment delivered: msg-1 -> eng-1-1"));
295 assert!(formatted.contains("Branch: eng-1-1/task-1"));
296 assert!(formatted.contains("Worktree: /tmp/worktree"));
297 }
298
299 #[test]
300 fn wait_for_assignment_result_returns_none_when_missing() {
301 let tmp = tempfile::tempdir().unwrap();
302 let result =
303 wait_for_assignment_result(tmp.path(), "missing", Duration::from_millis(10)).unwrap();
304 assert!(result.is_none());
305 }
306
307 #[test]
308 fn shim_logs_dir_path() {
309 let root = Path::new("/tmp/project");
310 assert_eq!(
311 shim_logs_dir(root),
312 PathBuf::from("/tmp/project/.batty/shim-logs")
313 );
314 }
315
316 #[test]
317 fn shim_log_path_includes_agent_id() {
318 let root = Path::new("/tmp/project");
319 assert_eq!(
320 shim_log_path(root, "eng-1-1"),
321 PathBuf::from("/tmp/project/.batty/shim-logs/eng-1-1.pty.log")
322 );
323 }
324
325 #[test]
326 fn shim_events_log_path_includes_agent_id() {
327 let root = Path::new("/tmp/project");
328 assert_eq!(
329 shim_events_log_path(root, "eng-1-1"),
330 PathBuf::from("/tmp/project/.batty/shim-logs/eng-1-1.events.log")
331 );
332 }
333
334 fn production_unwrap_expect_count(source: &str) -> usize {
336 let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
338 &source[..pos]
339 } else {
340 source
341 };
342 prod.lines()
343 .filter(|line| {
344 let trimmed = line.trim();
345 !trimmed.starts_with("#[cfg(test)]")
347 && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
348 })
349 .count()
350 }
351
352 #[test]
353 fn production_mod_has_no_unwrap_or_expect_calls() {
354 let src = include_str!("mod.rs");
355 assert_eq!(
356 production_unwrap_expect_count(src),
357 0,
358 "production mod.rs should avoid unwrap/expect"
359 );
360 }
361}