Skip to main content

bn/commands/
agents.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7/// A persisted agent entry in the agents.json file.
8#[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    /// Set when the agent completes.
17    #[serde(default)]
18    pub finished_at: Option<i64>,
19    /// Exit code on completion.
20    #[serde(default)]
21    pub exit_code: Option<i32>,
22}
23
24/// JSON output entry for `bn agents --json`.
25#[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
35/// Return the path to the agents persistence file.
36pub 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
44/// Load agents from the persistence file. Returns empty map if file doesn't exist.
45pub 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
60/// Save agents atomically by writing to a temp file then renaming.
61///
62/// Prevents corruption if the process is killed mid-write (e.g., an agent
63/// crashing during `bn close`). The rename is atomic on the same filesystem.
64pub 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
82/// Check if a process with the given PID is still alive.
83///
84/// Uses `kill(pid, 0)` which checks existence without signaling.
85/// Returns `true` if the process exists, even if owned by another user (EPERM).
86/// Returns `false` if the PID overflows `i32` (not a valid Unix PID).
87fn process_alive(pid: u32) -> bool {
88    let Ok(pid_i32) = i32::try_from(pid) else {
89        return false;
90    };
91
92    // SAFETY: kill(pid, 0) sends no signal — it only checks process existence.
93    // Returns 0 if the process exists and we can signal it.
94    // Returns -1 with EPERM if the process exists but is owned by another user.
95    // Returns -1 with ESRCH if the process does not exist.
96    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
103/// Truncate a string to fit within `max_display_chars` characters, appending "…"
104/// if truncated. Works correctly with multi-byte UTF-8.
105fn 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
113/// Format a duration in seconds as a human-readable string (e.g. "1m 32s").
114fn 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
126/// Show running and recently completed agents.
127///
128/// Reads agent state from the persistence file, checks PIDs, cleans up stale
129/// entries, and displays a table of agents.
130pub fn cmd_agents(_beans_dir: &Path, json: bool) -> Result<()> {
131    let mut agents = load_agents()?;
132    let now = chrono::Utc::now().timestamp();
133
134    // Clean up stale entries: if PID is dead and no finished_at, mark as completed
135    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); // unknown — process vanished
140            changed = true;
141        }
142    }
143
144    // Remove completed entries older than 1 hour
145    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        // load_agents reads from the real state dir, which may or may not exist.
326        // The function handles both cases gracefully.
327        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        // PID > i32::MAX should return false, not panic
344        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        // 13 emoji = 13 chars but 52 bytes — must not panic on byte boundary
370        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}