1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AgentEntry {
10 pub pid: u32,
11 pub title: String,
12 pub action: String,
13 pub started_at: i64,
14 #[serde(default)]
15 pub log_path: Option<String>,
16 #[serde(default)]
18 pub finished_at: Option<i64>,
19 #[serde(default)]
21 pub exit_code: Option<i32>,
22}
23
24#[derive(Debug, Serialize)]
26struct AgentJsonEntry {
27 bean_id: String,
28 title: String,
29 action: String,
30 pid: u32,
31 elapsed_secs: u64,
32 status: String,
33}
34
35pub fn agents_file_path() -> Result<std::path::PathBuf> {
37 let dir = dirs::data_local_dir()
38 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
39 .join("beans");
40 std::fs::create_dir_all(&dir).context("Failed to create beans state directory")?;
41 Ok(dir.join("agents.json"))
42}
43
44pub fn load_agents() -> Result<HashMap<String, AgentEntry>> {
46 let path = agents_file_path()?;
47 if !path.exists() {
48 return Ok(HashMap::new());
49 }
50 let contents = std::fs::read_to_string(&path)
51 .with_context(|| format!("Failed to read {}", path.display()))?;
52 if contents.trim().is_empty() {
53 return Ok(HashMap::new());
54 }
55 let agents: HashMap<String, AgentEntry> =
56 serde_json::from_str(&contents).with_context(|| "Failed to parse agents.json")?;
57 Ok(agents)
58}
59
60pub fn save_agents(agents: &HashMap<String, AgentEntry>) -> Result<()> {
65 let path = agents_file_path()?;
66 let json = serde_json::to_string_pretty(agents)?;
67
68 let tmp_path = path.with_extension("json.tmp");
69 std::fs::write(&tmp_path, &json)
70 .with_context(|| format!("Failed to write temp agents file {}", tmp_path.display()))?;
71 std::fs::rename(&tmp_path, &path).with_context(|| {
72 format!(
73 "Failed to rename {} to {}",
74 tmp_path.display(),
75 path.display()
76 )
77 })?;
78
79 Ok(())
80}
81
82fn process_alive(pid: u32) -> bool {
88 let Ok(pid_i32) = i32::try_from(pid) else {
89 return false;
90 };
91
92 let ret = unsafe { libc::kill(pid_i32, 0) };
97 if ret == 0 {
98 return true;
99 }
100 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
101}
102
103fn truncate_title(title: &str, max_display_chars: usize) -> String {
106 if title.chars().count() <= max_display_chars {
107 return title.to_string();
108 }
109 let truncated: String = title.chars().take(max_display_chars - 1).collect();
110 format!("{truncated}…")
111}
112
113fn format_elapsed(secs: u64) -> String {
115 if secs >= 3600 {
116 let h = secs / 3600;
117 let m = (secs % 3600) / 60;
118 format!("{}h {}m", h, m)
119 } else {
120 let m = secs / 60;
121 let s = secs % 60;
122 format!("{}m {:02}s", m, s)
123 }
124}
125
126pub fn cmd_agents(_beans_dir: &Path, json: bool) -> Result<()> {
131 let mut agents = load_agents()?;
132 let now = chrono::Utc::now().timestamp();
133
134 let mut changed = false;
136 for entry in agents.values_mut() {
137 if entry.finished_at.is_none() && !process_alive(entry.pid) {
138 entry.finished_at = Some(now);
139 entry.exit_code = Some(-1); changed = true;
141 }
142 }
143
144 let one_hour_ago = now - 3600;
146 let before_len = agents.len();
147 agents.retain(|_id, entry| entry.finished_at.map(|f| f > one_hour_ago).unwrap_or(true));
148 if agents.len() != before_len {
149 changed = true;
150 }
151
152 if changed {
153 save_agents(&agents)?;
154 }
155
156 if agents.is_empty() {
157 if json {
158 println!("[]");
159 } else {
160 println!("No running agents.");
161 }
162 return Ok(());
163 }
164
165 if json {
166 return print_agents_json(&agents, now);
167 }
168
169 print_agents_table(&agents, now);
170 Ok(())
171}
172
173fn print_agents_json(agents: &HashMap<String, AgentEntry>, now: i64) -> Result<()> {
174 let entries: Vec<AgentJsonEntry> = agents
175 .iter()
176 .map(|(id, entry)| {
177 let elapsed = if let Some(finished) = entry.finished_at {
178 (finished - entry.started_at).unsigned_abs()
179 } else {
180 (now - entry.started_at).unsigned_abs()
181 };
182 let status = match entry.finished_at {
183 Some(_) => match entry.exit_code {
184 Some(0) | None => "completed".to_string(),
185 Some(code) => format!("failed({})", code),
186 },
187 None => "running".to_string(),
188 };
189 AgentJsonEntry {
190 bean_id: id.clone(),
191 title: entry.title.clone(),
192 action: entry.action.clone(),
193 pid: entry.pid,
194 elapsed_secs: elapsed,
195 status,
196 }
197 })
198 .collect();
199 let json_str = serde_json::to_string_pretty(&entries)?;
200 println!("{}", json_str);
201 Ok(())
202}
203
204fn print_agents_table(agents: &HashMap<String, AgentEntry>, now: i64) {
205 let mut running: Vec<(&String, &AgentEntry)> = Vec::new();
206 let mut completed: Vec<(&String, &AgentEntry)> = Vec::new();
207 for (id, entry) in agents {
208 if entry.finished_at.is_some() {
209 completed.push((id, entry));
210 } else {
211 running.push((id, entry));
212 }
213 }
214
215 running.sort_by(|a, b| crate::util::natural_cmp(a.0, b.0));
216 completed.sort_by(|a, b| crate::util::natural_cmp(a.0, b.0));
217
218 const TITLE_WIDTH: usize = 24;
219
220 if !running.is_empty() {
221 println!(
222 "{:<6} {:<24} {:<12} {:<8} ELAPSED",
223 "BEAN", "TITLE", "ACTION", "PID"
224 );
225 for (id, entry) in &running {
226 let elapsed = (now - entry.started_at).unsigned_abs();
227 let title = truncate_title(&entry.title, TITLE_WIDTH);
228 println!(
229 "{:<6} {:<24} {:<12} {:<8} {}",
230 id,
231 title,
232 entry.action,
233 entry.pid,
234 format_elapsed(elapsed)
235 );
236 }
237 }
238
239 if !completed.is_empty() {
240 if !running.is_empty() {
241 println!();
242 }
243 println!("Recently completed:");
244 for (id, entry) in &completed {
245 let duration = entry
246 .finished_at
247 .map(|f| (f - entry.started_at).unsigned_abs())
248 .unwrap_or(0);
249 let status_str = match entry.exit_code {
250 Some(0) => "✓".to_string(),
251 Some(code) => format!("✗ exit {}", code),
252 None => "?".to_string(),
253 };
254 let title = truncate_title(&entry.title, TITLE_WIDTH);
255 println!(
256 " {} {} ({}, {})",
257 id,
258 title,
259 status_str,
260 format_elapsed(duration)
261 );
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn format_elapsed_seconds() {
272 assert_eq!(format_elapsed(0), "0m 00s");
273 assert_eq!(format_elapsed(48), "0m 48s");
274 assert_eq!(format_elapsed(92), "1m 32s");
275 }
276
277 #[test]
278 fn format_elapsed_hours() {
279 assert_eq!(format_elapsed(3661), "1h 1m");
280 assert_eq!(format_elapsed(7200), "2h 0m");
281 }
282
283 #[test]
284 fn load_agents_empty_file() {
285 let dir = tempfile::tempdir().unwrap();
286 let path = dir.path().join("agents.json");
287 std::fs::write(&path, "").unwrap();
288
289 let contents = std::fs::read_to_string(&path).unwrap();
290 assert!(contents.trim().is_empty());
291 }
292
293 #[test]
294 fn agent_entry_roundtrip() {
295 let mut agents = HashMap::new();
296 agents.insert(
297 "5.1".to_string(),
298 AgentEntry {
299 pid: 42310,
300 title: "Define user types".to_string(),
301 action: "implement".to_string(),
302 started_at: 1708000000,
303 log_path: Some("/tmp/log".to_string()),
304 finished_at: None,
305 exit_code: None,
306 },
307 );
308
309 let json = serde_json::to_string_pretty(&agents).unwrap();
310 let parsed: HashMap<String, AgentEntry> = serde_json::from_str(&json).unwrap();
311 assert_eq!(parsed.len(), 1);
312 let entry = parsed.get("5.1").unwrap();
313 assert_eq!(entry.pid, 42310);
314 assert_eq!(entry.title, "Define user types");
315 assert_eq!(entry.action, "implement");
316 assert!(entry.finished_at.is_none());
317 }
318
319 #[test]
320 fn agents_empty_persistence_shows_no_agents() {
321 let dir = tempfile::tempdir().unwrap();
322 let beans_dir = dir.path().join(".beans");
323 std::fs::create_dir(&beans_dir).unwrap();
324
325 let agents = load_agents();
328 assert!(agents.is_ok());
329 }
330
331 #[test]
332 fn process_alive_returns_true_for_current() {
333 assert!(process_alive(std::process::id()));
334 }
335
336 #[test]
337 fn process_alive_returns_false_for_nonexistent() {
338 assert!(!process_alive(99_999_999));
339 }
340
341 #[test]
342 fn process_alive_returns_false_for_overflowed_pid() {
343 assert!(!process_alive(u32::MAX));
345 assert!(!process_alive(i32::MAX as u32 + 1));
346 }
347
348 #[test]
349 fn truncate_title_short_string() {
350 assert_eq!(truncate_title("hello", 24), "hello");
351 }
352
353 #[test]
354 fn truncate_title_exact_length() {
355 let title = "a".repeat(24);
356 assert_eq!(truncate_title(&title, 24), title);
357 }
358
359 #[test]
360 fn truncate_title_long_string() {
361 let title = "a".repeat(30);
362 let result = truncate_title(&title, 24);
363 assert_eq!(result.chars().count(), 24);
364 assert!(result.ends_with('…'));
365 }
366
367 #[test]
368 fn truncate_title_multibyte_utf8() {
369 let title = "🎉".repeat(13);
371 let result = truncate_title(&title, 10);
372 assert_eq!(result.chars().count(), 10);
373 assert!(result.ends_with('…'));
374 }
375}