Skip to main content

bn/commands/
status.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::bean::Status;
8use crate::index::{Index, IndexEntry};
9use crate::util::natural_cmp;
10
11/// Agent status parsed from claimed_by field
12#[derive(Debug, Clone, Serialize)]
13pub struct AgentStatus {
14    pub pid: u32,
15    pub alive: bool,
16}
17
18/// Parse claimed_by field for agent info (e.g., "spro:12345" -> Some(AgentStatus))
19fn parse_agent_claim(claimed_by: &Option<String>) -> Option<AgentStatus> {
20    let claim = claimed_by.as_ref()?;
21    if !claim.starts_with("spro:") {
22        return None;
23    }
24    let pid_str = claim.strip_prefix("spro:")?;
25    let pid: u32 = pid_str.parse().ok()?;
26    let alive = is_pid_alive(pid);
27    Some(AgentStatus { pid, alive })
28}
29
30/// Check if a process with the given PID is alive
31fn is_pid_alive(pid: u32) -> bool {
32    // Use kill -0 to check if process exists (doesn't send a signal)
33    Command::new("kill")
34        .args(["-0", &pid.to_string()])
35        .output()
36        .map(|output| output.status.success())
37        .unwrap_or(false)
38}
39
40/// Format agent status for display
41fn format_agent_status(entry: &IndexEntry) -> String {
42    match parse_agent_claim(&entry.claimed_by) {
43        Some(agent) if agent.alive => format!("spro:{} ●", agent.pid),
44        Some(agent) => format!("spro:{} ✗", agent.pid),
45        None => entry.claimed_by.clone().unwrap_or_else(|| "-".to_string()),
46    }
47}
48
49/// Entry with agent status for JSON output
50#[derive(Serialize)]
51struct StatusEntry {
52    #[serde(flatten)]
53    entry: IndexEntry,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    agent: Option<AgentStatus>,
56}
57
58impl StatusEntry {
59    fn from_entry(entry: IndexEntry) -> Self {
60        let agent = parse_agent_claim(&entry.claimed_by);
61        Self { entry, agent }
62    }
63}
64
65/// JSON output structure for status command
66#[derive(Serialize)]
67struct StatusOutput {
68    claimed: Vec<StatusEntry>,
69    ready: Vec<IndexEntry>,
70    goals: Vec<IndexEntry>,
71    blocked: Vec<IndexEntry>,
72}
73
74/// Show complete work picture: claimed, ready, goals (need decomposition), and blocked beans
75pub fn cmd_status(json: bool, beans_dir: &Path) -> Result<()> {
76    let index = Index::load_or_rebuild(beans_dir)?;
77
78    // Separate beans into categories
79    let mut claimed: Vec<&IndexEntry> = Vec::new();
80    let mut ready: Vec<&IndexEntry> = Vec::new();
81    let mut goals: Vec<&IndexEntry> = Vec::new();
82    let mut blocked: Vec<&IndexEntry> = Vec::new();
83
84    for entry in &index.beans {
85        match entry.status {
86            Status::InProgress => {
87                claimed.push(entry);
88            }
89            Status::Open => {
90                if is_blocked(entry, &index) {
91                    blocked.push(entry);
92                } else if entry.has_verify {
93                    ready.push(entry);
94                } else {
95                    goals.push(entry);
96                }
97            }
98            Status::Closed => {}
99        }
100    }
101
102    sort_beans(&mut claimed);
103    sort_beans(&mut ready);
104    sort_beans(&mut goals);
105    sort_beans(&mut blocked);
106
107    if json {
108        let output = StatusOutput {
109            claimed: claimed
110                .into_iter()
111                .cloned()
112                .map(StatusEntry::from_entry)
113                .collect(),
114            ready: ready.into_iter().cloned().collect(),
115            goals: goals.into_iter().cloned().collect(),
116            blocked: blocked.into_iter().cloned().collect(),
117        };
118        let json_str = serde_json::to_string_pretty(&output)?;
119        println!("{}", json_str);
120    } else {
121        println!("## Claimed ({})", claimed.len());
122        if claimed.is_empty() {
123            println!("  (none)");
124        } else {
125            for entry in claimed {
126                let agent_str = format_agent_status(entry);
127                println!("  {} [-] {} ({})", entry.id, entry.title, agent_str);
128            }
129        }
130        println!();
131
132        println!("## Ready ({})", ready.len());
133        if ready.is_empty() {
134            println!("  (none)");
135        } else {
136            for entry in ready {
137                println!("  {} [ ] {}", entry.id, entry.title);
138            }
139        }
140        println!();
141
142        println!("## Goals (need decomposition) ({})", goals.len());
143        if goals.is_empty() {
144            println!("  (none)");
145        } else {
146            for entry in goals {
147                println!("  {} [?] {}", entry.id, entry.title);
148            }
149        }
150        println!();
151
152        println!("## Blocked ({})", blocked.len());
153        if blocked.is_empty() {
154            println!("  (none)");
155        } else {
156            for entry in blocked {
157                println!("  {} [!] {}", entry.id, entry.title);
158            }
159        }
160    }
161
162    Ok(())
163}
164
165fn is_blocked(entry: &IndexEntry, index: &Index) -> bool {
166    for dep_id in &entry.dependencies {
167        if let Some(dep_entry) = index.beans.iter().find(|e| &e.id == dep_id) {
168            if dep_entry.status != Status::Closed {
169                return true;
170            }
171        } else {
172            return true;
173        }
174    }
175    false
176}
177
178fn sort_beans(beans: &mut Vec<&IndexEntry>) {
179    beans.sort_by(|a, b| match a.priority.cmp(&b.priority) {
180        std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
181        other => other,
182    });
183}