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