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