1use crate::LockFile;
10use serde::Serialize;
11use std::path::Path;
12
13#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum AgentStatusKind {
17 Running,
19 Stale,
21 Stopped,
23}
24
25impl AgentStatusKind {
26 pub fn emoji(&self) -> &'static str {
30 match self {
31 AgentStatusKind::Running => "🟢",
32 AgentStatusKind::Stale => "🟡",
33 AgentStatusKind::Stopped => "⚪",
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy)]
40pub struct AgentStatus {
41 pub kind: AgentStatusKind,
42 pub pid: Option<u32>,
44}
45
46pub fn read(lock_path: &Path) -> std::io::Result<Option<LockFile>> {
51 let bytes = match std::fs::read(lock_path) {
52 Ok(b) => b,
53 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
54 Err(e) => return Err(e),
55 };
56 serde_json::from_slice(&bytes)
57 .map(Some)
58 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
59}
60
61#[cfg(unix)]
72pub fn pid_alive(pid: u32) -> bool {
73 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
76}
77
78#[cfg(windows)]
79pub fn pid_alive(pid: u32) -> bool {
80 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
81 use windows_sys::Win32::System::Threading::{
82 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
83 };
84 unsafe {
87 let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
88 if h.is_null() {
89 return false;
90 }
91 let mut code: u32 = 0;
101 let queried = GetExitCodeProcess(h, &mut code);
102 CloseHandle(h);
103 queried == 0 || code == STILL_ACTIVE as u32
108 }
109}
110
111#[cfg(not(any(unix, windows)))]
112pub fn pid_alive(_pid: u32) -> bool {
113 true
114}
115
116pub fn classify(lock_path: &Path) -> AgentStatus {
125 match read(lock_path) {
126 Ok(None) => AgentStatus {
127 kind: AgentStatusKind::Stopped,
128 pid: None,
129 },
130 Err(_) => AgentStatus {
131 kind: AgentStatusKind::Stale,
132 pid: None,
133 },
134 Ok(Some(lock)) => {
135 let kind = if pid_alive(lock.pid) {
136 AgentStatusKind::Running
137 } else {
138 AgentStatusKind::Stale
139 };
140 AgentStatus {
141 kind,
142 pid: Some(lock.pid),
143 }
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::agent::LockTransports;
152
153 #[test]
154 fn status_emoji_mapping_is_stable() {
155 assert_eq!(AgentStatusKind::Running.emoji(), "🟢");
156 assert_eq!(AgentStatusKind::Stale.emoji(), "🟡");
157 assert_eq!(AgentStatusKind::Stopped.emoji(), "⚪");
158 }
159
160 fn make_lock(pid: u32) -> LockFile {
161 LockFile {
162 schema: 1,
163 uuid: "01JQX4TM8Y9K7VQH6B2N3R5DPE".into(),
164 name: "agent_a".into(),
165 pid,
166 ppid: 1,
167 started_at: "2026-04-22T08:00:00Z".into(),
168 binary_version: "mur-agent-runtime 0.1.0".into(),
169 transports: LockTransports {
170 stdio: false,
171 unix_socket: Some("/tmp/x.sock".into()),
172 tcp: None,
173 webhook: None,
174 },
175 card_digest: "sha256:abc".into(),
176 capabilities: vec!["a2a.message.send".into()],
177 build_sha: String::new(),
178 proto_version: 0,
179 }
180 }
181
182 fn write_lock_file(dir: &std::path::Path, pid: u32) -> std::path::PathBuf {
183 let path = dir.join("running.lock");
184 let lock = make_lock(pid);
185 std::fs::write(&path, serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
186 path
187 }
188
189 #[test]
190 fn classify_returns_stopped_when_no_lock() {
191 let tmp = tempfile::tempdir().unwrap();
192 let lock_path = tmp.path().join("running.lock");
193 let status = classify(&lock_path);
194 assert_eq!(status.kind, AgentStatusKind::Stopped);
195 assert_eq!(status.pid, None);
196 }
197
198 #[cfg(unix)]
199 #[test]
200 fn classify_returns_running_when_pid_alive() {
201 let tmp = tempfile::tempdir().unwrap();
202 let lock_path = write_lock_file(tmp.path(), std::process::id());
203 let status = classify(&lock_path);
204 assert_eq!(status.kind, AgentStatusKind::Running);
205 assert_eq!(status.pid, Some(std::process::id()));
206 }
207
208 #[cfg(unix)]
209 #[test]
210 fn classify_returns_stale_when_pid_dead() {
211 let tmp = tempfile::tempdir().unwrap();
212 let dead_pid: u32 = 999_999;
213 let lock_path = write_lock_file(tmp.path(), dead_pid);
214 let status = classify(&lock_path);
215 assert_eq!(status.kind, AgentStatusKind::Stale);
216 assert_eq!(status.pid, Some(dead_pid));
217 }
218
219 #[test]
220 fn classify_returns_stale_when_lock_malformed() {
221 let tmp = tempfile::tempdir().unwrap();
222 let lock_path = tmp.path().join("running.lock");
223 std::fs::write(&lock_path, b"not json").unwrap();
224 let status = classify(&lock_path);
225 assert_eq!(status.kind, AgentStatusKind::Stale);
226 assert_eq!(status.pid, None);
227 }
228
229 #[test]
230 fn read_returns_none_for_missing_file() {
231 let tmp = tempfile::tempdir().unwrap();
232 let lock_path = tmp.path().join("running.lock");
233 let result = read(&lock_path).unwrap();
234 assert!(result.is_none());
235 }
236
237 #[test]
238 fn read_returns_ok_for_valid_lock() {
239 let tmp = tempfile::tempdir().unwrap();
240 let lock_path = write_lock_file(tmp.path(), 42);
241 let result = read(&lock_path).unwrap();
242 assert!(result.is_some());
243 assert_eq!(result.unwrap().pid, 42);
244 }
245
246 #[test]
247 fn read_returns_err_for_malformed_json() {
248 let tmp = tempfile::tempdir().unwrap();
249 let lock_path = tmp.path().join("running.lock");
250 std::fs::write(&lock_path, b"not json").unwrap();
251 let result = read(&lock_path);
252 assert!(result.is_err());
253 }
254
255 #[cfg(unix)]
256 #[test]
257 fn pid_alive_returns_true_for_self() {
258 assert!(pid_alive(std::process::id()));
259 }
260
261 #[cfg(unix)]
262 #[test]
263 fn pid_alive_returns_false_for_dead_pid() {
264 assert!(!pid_alive(999_999));
265 }
266}