1use std::path::Path;
2use std::process::Command;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::blocking::{check_blocked, check_scope_warning, BlockReason};
8use crate::index::{ArchiveIndex, Index, IndexEntry};
9use crate::unit::Status;
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, mana_dir: &Path) -> Result<()> {
85 let index = Index::load_or_rebuild(mana_dir)?;
86
87 let mut features: Vec<&IndexEntry> = Vec::new();
89 let mut claimed: Vec<&IndexEntry> = Vec::new();
90 let mut ready: Vec<&IndexEntry> = Vec::new();
91 let mut goals: Vec<&IndexEntry> = Vec::new();
92 let mut blocked: Vec<(&IndexEntry, BlockReason)> = Vec::new();
93
94 for entry in &index.units {
95 if entry.feature {
96 features.push(entry);
97 continue;
98 }
99 match entry.status {
100 Status::InProgress | Status::AwaitingVerify => {
101 claimed.push(entry);
102 }
103 Status::Open => {
104 if let Some(reason) = check_blocked(entry, &index) {
105 blocked.push((entry, reason));
106 } else if entry.has_verify {
107 ready.push(entry);
108 } else {
109 goals.push(entry);
110 }
111 }
112 Status::Closed => {}
113 }
114 }
115
116 sort_units(&mut features);
117 sort_units(&mut claimed);
118 sort_units(&mut ready);
119 sort_units(&mut goals);
120 blocked.sort_by(|(a, _), (b, _)| match a.priority.cmp(&b.priority) {
121 std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
122 other => other,
123 });
124
125 if json {
126 let output = StatusOutput {
127 claimed: claimed
128 .into_iter()
129 .cloned()
130 .map(StatusEntry::from_entry)
131 .collect(),
132 ready: ready.into_iter().cloned().collect(),
133 goals: goals.into_iter().cloned().collect(),
134 blocked: blocked
135 .iter()
136 .map(|(e, reason)| BlockedEntry {
137 entry: (*e).clone(),
138 block_reason: reason.to_string(),
139 })
140 .collect(),
141 };
142 let json_str = serde_json::to_string_pretty(&output)?;
143 println!("{}", json_str);
144 } else {
145 if !features.is_empty() {
147 let archive = ArchiveIndex::load(mana_dir).unwrap_or(ArchiveIndex { units: vec![] });
148 let closed_features = features
149 .iter()
150 .filter(|f| f.status == Status::Closed)
151 .count();
152 println!("## Features ({}/{})", closed_features, features.len());
153 for feature in &features {
154 let active_children: Vec<&IndexEntry> = index
156 .units
157 .iter()
158 .filter(|b| b.parent.as_deref() == Some(&feature.id))
159 .collect();
160 let archived_children: Vec<&IndexEntry> = archive
161 .units
162 .iter()
163 .filter(|b| b.parent.as_deref() == Some(&feature.id))
164 .collect();
165 let active_count = active_children.len();
166 let archived_count = archived_children.len();
167 let total = active_count + archived_count;
168 let closed = active_children
169 .iter()
170 .filter(|b| b.status == Status::Closed)
171 .count()
172 + archived_count; let progress = if total == 0 {
175 "not decomposed".to_string()
176 } else {
177 format!("{}/{}", closed, total)
178 };
179
180 let indicator = if feature.status == Status::Closed {
181 "✓"
182 } else if total > 0 && closed == total {
183 "★"
184 } else {
185 "○"
186 };
187
188 let suffix = if total > 0 && closed == total && feature.status != Status::Closed {
189 " — ready for review"
190 } else {
191 ""
192 };
193
194 println!(
195 " {} {} {} ({}{})",
196 indicator, feature.id, feature.title, progress, suffix
197 );
198 }
199 println!();
200 }
201
202 println!("## Claimed ({})", claimed.len());
203 if claimed.is_empty() {
204 println!(" (none)");
205 } else {
206 for entry in claimed {
207 let agent_str = format_agent_status(entry);
208 println!(" {} [-] {} ({})", entry.id, entry.title, agent_str);
209 }
210 }
211 println!();
212
213 println!("## Ready ({})", ready.len());
214 if ready.is_empty() {
215 println!(" (none)");
216 } else {
217 for entry in ready {
218 let warning = check_scope_warning(entry)
219 .map(|w| format!(" (⚠ {})", w))
220 .unwrap_or_default();
221 println!(" {} [ ] {}{}", entry.id, entry.title, warning);
222 }
223 }
224 println!();
225
226 println!("## Goals (need decomposition) ({})", goals.len());
227 if goals.is_empty() {
228 println!(" (none)");
229 } else {
230 for entry in goals {
231 println!(" {} [?] {}", entry.id, entry.title);
232 }
233 }
234 println!();
235
236 println!("## Blocked ({})", blocked.len());
237 if blocked.is_empty() {
238 println!(" (none)");
239 } else {
240 for (entry, reason) in &blocked {
241 println!(" {} [!] {} ({})", entry.id, entry.title, reason);
242 }
243 }
244 }
245
246 Ok(())
247}
248
249fn sort_units(units: &mut Vec<&IndexEntry>) {
250 units.sort_by(|a, b| match a.priority.cmp(&b.priority) {
251 std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
252 other => other,
253 });
254}