roboticus_cli/cli/
status.rs1use super::*;
2
3pub(crate) fn is_connection_error(msg: &str) -> bool {
4 msg.contains("Connection refused")
5 || msg.contains("ConnectionRefused")
6 || msg.contains("ConnectError")
7 || msg.contains("connect error")
8 || msg.contains("kind: Decode")
9 || msg.contains("hyper::Error")
10}
11
12pub async fn cmd_status(url: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
13 let c = RoboticusClient::new(url)?;
14 let health = match c.get("/api/health").await {
15 Ok(h) => h,
16 Err(e) => {
17 let msg = format!("{:?}", e);
18 if is_connection_error(&msg) {
19 if json {
20 println!("{}", serde_json::json!({"status": "offline", "url": url}));
21 } else {
22 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
23 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
24 eprintln!();
25 eprintln!(" {WARN} Server is not running at {BOLD}{url}{RESET}");
26 eprintln!(" {DIM}Start with: {BOLD}roboticus serve{RESET}");
27 eprintln!();
28 }
29 return Ok(());
30 }
31 eprintln!();
32 eprintln!(" Could not connect to agent at {url}: {e}");
33 eprintln!();
34 RoboticusClient::check_connectivity_hint(&*e);
35 return Err(e);
36 }
37 };
38 let agent = c.get("/api/agent/status").await?;
39 let config = c.get("/api/config").await?;
40 let sessions = c.get("/api/sessions").await?;
41 let skills = c.get("/api/skills").await?;
42 let jobs = c.get("/api/cron/jobs").await?;
43 let cache = c.get("/api/stats/cache").await?;
44 let wallet = c.get("/api/wallet/balance").await?;
45
46 if json {
47 let out = serde_json::json!({
48 "status": "online",
49 "version": health["version"],
50 "agent": {
51 "name": config["agent"]["name"],
52 "id": config["agent"]["id"],
53 "state": agent["state"],
54 },
55 "sessions": sessions["sessions"].as_array().map(|a| a.len()).unwrap_or(0),
56 "skills": skills["skills"].as_array().map(|a| a.len()).unwrap_or(0),
57 "cron_jobs": jobs["jobs"].as_array().map(|a| a.len()).unwrap_or(0),
58 "cache": cache,
59 "wallet": wallet,
60 "primary_model": config["models"]["primary"],
61 });
62 println!("{}", serde_json::to_string_pretty(&out)?);
63 return Ok(());
64 }
65
66 heading("Agent Status");
67 let agent_name = config["agent"]["name"].as_str().unwrap_or("unknown");
68 let agent_id = config["agent"]["id"].as_str().unwrap_or("unknown");
69 let agent_state = agent["state"].as_str().unwrap_or("unknown");
70 let version = health["version"].as_str().unwrap_or("?");
71 let session_count = sessions["sessions"]
72 .as_array()
73 .map(|a| a.len())
74 .unwrap_or(0);
75 let skill_count = skills["skills"].as_array().map(|a| a.len()).unwrap_or(0);
76 let job_count = jobs["jobs"].as_array().map(|a| a.len()).unwrap_or(0);
77 let hits = cache["hits"].as_u64().unwrap_or(0);
78 let misses = cache["misses"].as_u64().unwrap_or(0);
79 let hit_rate = if hits + misses > 0 {
80 format!("{:.1}%", hits as f64 / (hits + misses) as f64 * 100.0)
81 } else {
82 "0%".into()
83 };
84 let balance = wallet["balance"].as_str().unwrap_or("0.00");
85 let currency = wallet["currency"].as_str().unwrap_or("USDC");
86 kv_accent("Agent", &format!("{agent_name} ({agent_id})"));
87 kv("State", &status_badge(agent_state).to_string());
88 kv_accent("Version", version);
89 kv("Sessions", &session_count.to_string());
90 kv("Skills", &skill_count.to_string());
91 kv("Cron Jobs", &job_count.to_string());
92 kv("Cache Hit Rate", &hit_rate);
93 kv_accent("Balance", &format!("{balance} {currency}"));
94 let primary = config["models"]["primary"].as_str().unwrap_or("unknown");
95 kv("Primary Model", primary);
96 eprintln!();
97 Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use axum::{Json, Router, routing::get};
104 use std::net::SocketAddr;
105 use tokio::net::TcpListener;
106
107 #[test]
108 fn detects_connection_refused() {
109 assert!(is_connection_error("Connection refused (os error 61)"));
110 }
111
112 #[test]
113 fn detects_connect_error_variant() {
114 assert!(is_connection_error("hyper::Error(Connect, ConnectError)"));
115 }
116
117 #[test]
118 fn detects_decode_error() {
119 assert!(is_connection_error("kind: Decode"));
120 }
121
122 #[test]
123 fn ignores_unrelated_errors() {
124 assert!(!is_connection_error("404 Not Found"));
125 assert!(!is_connection_error("timeout after 30s"));
126 }
127
128 #[test]
129 fn detects_connect_error_lowercase() {
130 assert!(is_connection_error("connect error: tcp handshake failed"));
131 }
132
133 #[test]
134 fn detects_hyper_error() {
135 assert!(is_connection_error("hyper::Error somewhere in the chain"));
136 }
137
138 #[test]
139 fn empty_string_is_not_connection_error() {
140 assert!(!is_connection_error(""));
141 }
142
143 #[test]
144 fn detects_connection_refused_variant() {
145 assert!(is_connection_error("ConnectionRefused: host unreachable"));
146 }
147
148 #[tokio::test]
149 async fn cmd_status_succeeds_against_local_mock_server() {
150 async fn health() -> Json<serde_json::Value> {
151 Json(serde_json::json!({"version":"0.8.0"}))
152 }
153 async fn agent_status() -> Json<serde_json::Value> {
154 Json(serde_json::json!({"state":"running"}))
155 }
156 async fn config() -> Json<serde_json::Value> {
157 Json(serde_json::json!({
158 "agent": {"name":"TestBot","id":"test-bot"},
159 "models": {"primary":"ollama/qwen3:8b"}
160 }))
161 }
162 async fn sessions() -> Json<serde_json::Value> {
163 Json(serde_json::json!({"sessions":[{"id":"s1"}]}))
164 }
165 async fn skills() -> Json<serde_json::Value> {
166 Json(serde_json::json!({"skills":[{"id":"k1"},{"id":"k2"}]}))
167 }
168 async fn cron_jobs() -> Json<serde_json::Value> {
169 Json(serde_json::json!({"jobs":[{"id":"j1"}]}))
170 }
171 async fn cache() -> Json<serde_json::Value> {
172 Json(serde_json::json!({"hits":3,"misses":1}))
173 }
174 async fn wallet() -> Json<serde_json::Value> {
175 Json(serde_json::json!({"balance":"12.34","currency":"USDC"}))
176 }
177
178 let app = Router::new()
179 .route("/api/health", get(health))
180 .route("/api/agent/status", get(agent_status))
181 .route("/api/config", get(config))
182 .route("/api/sessions", get(sessions))
183 .route("/api/skills", get(skills))
184 .route("/api/cron/jobs", get(cron_jobs))
185 .route("/api/stats/cache", get(cache))
186 .route("/api/wallet/balance", get(wallet));
187
188 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
189 let addr: SocketAddr = listener.local_addr().unwrap();
190 let server = tokio::spawn(async move {
191 axum::serve(listener, app).await.unwrap();
192 });
193
194 let url = format!("http://{}:{}", addr.ip(), addr.port());
195 let result = cmd_status(&url, false).await;
196 server.abort();
197 assert!(result.is_ok());
198 }
199
200 #[tokio::test]
201 async fn cmd_status_returns_ok_for_unreachable_server() {
202 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
203 let port = listener.local_addr().unwrap().port();
204 drop(listener);
205 let url = format!("http://127.0.0.1:{port}");
206 let result = cmd_status(&url, false).await;
207 assert!(result.is_ok());
208 }
209}