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#[derive(Debug, Clone, Serialize)]
13pub struct AgentStatus {
14 pub pid: u32,
15 pub alive: bool,
16}
17
18fn 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
30fn is_pid_alive(pid: u32) -> bool {
32 Command::new("kill")
34 .args(["-0", &pid.to_string()])
35 .output()
36 .map(|output| output.status.success())
37 .unwrap_or(false)
38}
39
40fn 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#[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#[derive(Serialize)]
67struct StatusOutput {
68 claimed: Vec<StatusEntry>,
69 ready: Vec<IndexEntry>,
70 goals: Vec<IndexEntry>,
71 blocked: Vec<IndexEntry>,
72}
73
74pub fn cmd_status(json: bool, beans_dir: &Path) -> Result<()> {
76 let index = Index::load_or_rebuild(beans_dir)?;
77
78 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}