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#[derive(Debug, Clone, Serialize)]
14pub struct AgentStatus {
15 pub pid: u32,
16 pub alive: bool,
17}
18
19fn 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
31fn is_pid_alive(pid: u32) -> bool {
33 Command::new("kill")
35 .args(["-0", &pid.to_string()])
36 .output()
37 .map(|output| output.status.success())
38 .unwrap_or(false)
39}
40
41fn 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#[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#[derive(Serialize)]
68struct BlockedEntry {
69 #[serde(flatten)]
70 entry: IndexEntry,
71 block_reason: String,
72}
73
74#[derive(Serialize)]
76struct StatusOutput {
77 claimed: Vec<StatusEntry>,
78 ready: Vec<IndexEntry>,
79 goals: Vec<IndexEntry>,
80 blocked: Vec<BlockedEntry>,
81}
82
83pub fn cmd_status(json: bool, beans_dir: &Path) -> Result<()> {
85 let index = Index::load_or_rebuild(beans_dir)?;
86
87 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}