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