Skip to main content

roboticus_cli/cli/
status.rs

1use 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}