Skip to main content

bamboo_agent/
admin_cli.rs

1//! The `bamboo health | status | sessions | stop` admin CLI.
2//!
3//! A thin HTTP client over a running `bamboo serve` instance. Each command wraps
4//! an endpoint the server already exposes — `/api/v1/health`,
5//! `/api/v1/sessions`, `/api/v1/stop/{id}` — so an operator can probe and steer
6//! the backend without hand-writing `curl`. The server is the single source of
7//! truth; this module only resolves the base URL and pretty-prints responses.
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use colored::Colorize;
13
14const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
15
16/// Connection options shared by every admin subcommand.
17#[derive(Debug, Clone, Default)]
18pub struct ConnArgs {
19    /// Full base URL override (e.g. `http://127.0.0.1:9562`). Wins over the rest.
20    pub server_url: Option<String>,
21    /// Port override (else read from the resolved config).
22    pub port: Option<u16>,
23    /// Data dir holding `config.json` (else `~/.bamboo`).
24    pub data_dir: Option<PathBuf>,
25}
26
27impl ConnArgs {
28    /// Resolve the API base, e.g. `http://127.0.0.1:9562/api/v1`.
29    fn api_base(&self) -> String {
30        if let Some(url) = &self.server_url {
31            let url = url.trim_end_matches('/');
32            // Tolerate a scheme-less override like `localhost:9562`.
33            let url = if url.contains("://") {
34                url.to_string()
35            } else {
36                format!("http://{url}")
37            };
38            return format!("{url}/api/v1");
39        }
40        let config = bamboo_llm::Config::from_data_dir(self.data_dir.clone());
41        let port = self.port.unwrap_or(config.server.port);
42        let host = match config.server.bind.trim() {
43            // Listen-on-all addresses: a client must dial a concrete host.
44            "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
45            // Bracket a bare IPv6 literal so the URL is well-formed.
46            h if h.contains(':') && !h.starts_with('[') => format!("[{h}]"),
47            h => h.to_string(),
48        };
49        format!("http://{host}:{port}/api/v1")
50    }
51}
52
53fn unreachable(base: &str, e: reqwest::Error) -> anyhow::Error {
54    anyhow::anyhow!("could not reach the server at {base} ({e}). Is `bamboo serve` running?")
55}
56
57/// `bamboo health` — liveness probe. Exits non-zero (via the returned `Err`)
58/// when the server is unreachable or reports an unhealthy status.
59pub async fn health(conn: ConnArgs) -> anyhow::Result<()> {
60    let base = conn.api_base();
61    let url = format!("{base}/health");
62    let resp = reqwest::Client::new()
63        .get(&url)
64        .timeout(REQUEST_TIMEOUT)
65        .send()
66        .await
67        .map_err(|e| unreachable(&base, e))?;
68    if resp.status().is_success() {
69        println!("{}  {base}", "● healthy".green().bold());
70        Ok(())
71    } else {
72        anyhow::bail!("unhealthy: HTTP {} from {url}", resp.status());
73    }
74}
75
76/// `bamboo status` — one-screen overview: address, health, session counts.
77pub async fn status(conn: ConnArgs) -> anyhow::Result<()> {
78    let base = conn.api_base();
79    let server = base.trim_end_matches("/api/v1");
80    println!("{:<10}{server}", "server:".bold());
81
82    let client = reqwest::Client::new();
83    let health = client
84        .get(format!("{base}/health"))
85        .timeout(REQUEST_TIMEOUT)
86        .send()
87        .await;
88    match health {
89        Ok(r) if r.status().is_success() => println!("{:<10}{}", "health:".bold(), "ok".green()),
90        Ok(r) => {
91            println!(
92                "{:<10}{} (HTTP {})",
93                "health:".bold(),
94                "down".red(),
95                r.status()
96            );
97            return Ok(());
98        }
99        Err(e) => {
100            println!("{:<10}{} ({e})", "health:".bold(), "unreachable".red());
101            return Ok(());
102        }
103    }
104
105    if let Ok(r) = client
106        .get(format!("{base}/sessions"))
107        .timeout(REQUEST_TIMEOUT)
108        .send()
109        .await
110    {
111        if let Ok(v) = r.json::<serde_json::Value>().await {
112            let sessions = v.get("sessions").and_then(|s| s.as_array());
113            let total = sessions.map(|s| s.len()).unwrap_or(0);
114            let running = sessions.map(|s| count_running(s)).unwrap_or(0);
115            println!(
116                "{:<10}{total} total, {} running",
117                "sessions:".bold(),
118                running.to_string().cyan()
119            );
120        }
121    }
122    Ok(())
123}
124
125/// `bamboo sessions` — tabulate sessions on a running server.
126pub async fn sessions_list(conn: ConnArgs) -> anyhow::Result<()> {
127    let base = conn.api_base();
128    let url = format!("{base}/sessions");
129    let resp = reqwest::Client::new()
130        .get(&url)
131        .timeout(REQUEST_TIMEOUT)
132        .send()
133        .await
134        .map_err(|e| unreachable(&base, e))?;
135    if !resp.status().is_success() {
136        anyhow::bail!("GET {url} -> HTTP {}", resp.status());
137    }
138    let v: serde_json::Value = resp.json().await?;
139    let sessions = v.get("sessions").and_then(|s| s.as_array());
140    let sessions = match sessions {
141        Some(s) if !s.is_empty() => s,
142        _ => {
143            println!("(no sessions)");
144            return Ok(());
145        }
146    };
147
148    // Plain (un-colored) cells so the column widths line up — ANSI escapes would
149    // otherwise be counted against the padding.
150    println!(
151        "{:<38} {:<5} {:<26} {:>5}  TITLE",
152        "SESSION ID", "RUN", "MODEL", "MSGS"
153    );
154    for s in sessions {
155        let id = s.get("id").and_then(|x| x.as_str()).unwrap_or("?");
156        let running = s
157            .get("is_running")
158            .and_then(|b| b.as_bool())
159            .unwrap_or(false);
160        let model = s.get("model").and_then(|x| x.as_str()).unwrap_or("");
161        let msgs = s.get("message_count").and_then(|x| x.as_u64()).unwrap_or(0);
162        let title = s.get("title").and_then(|x| x.as_str()).unwrap_or("");
163        println!(
164            "{:<38} {:<5} {:<26} {:>5}  {}",
165            id,
166            if running { "run" } else { "-" },
167            truncate(model, 26),
168            msgs,
169            truncate(title, 60)
170        );
171    }
172    let running = count_running(sessions);
173    println!(
174        "\n{running} running. Stop one with: {}",
175        "bamboo stop <session-id>".cyan()
176    );
177    Ok(())
178}
179
180/// `bamboo stop <id>` — cancel a running session's loop.
181pub async fn stop(conn: ConnArgs, session_id: &str) -> anyhow::Result<()> {
182    // Guard the path segment: real session ids are opaque tokens (UUIDs), so
183    // reject anything that could traverse or malform the URL rather than encode it.
184    if session_id.is_empty()
185        || session_id == "."
186        || session_id == ".."
187        || session_id.contains(['/', '\\', '?', '#', '%'])
188        || session_id.chars().any(char::is_whitespace)
189    {
190        anyhow::bail!("invalid session id: '{session_id}'");
191    }
192    let base = conn.api_base();
193    let url = format!("{base}/stop/{session_id}");
194    let resp = reqwest::Client::new()
195        .post(&url)
196        .timeout(REQUEST_TIMEOUT)
197        .send()
198        .await
199        .map_err(|e| unreachable(&base, e))?;
200    let status = resp.status();
201    let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
202    let message = body
203        .get("message")
204        .and_then(|m| m.as_str())
205        .unwrap_or("")
206        .to_string();
207    if status.is_success() {
208        let msg = if message.is_empty() {
209            "stopped"
210        } else {
211            &message
212        };
213        println!("{} {msg}", "✓".green());
214        Ok(())
215    } else if status.as_u16() == 404 {
216        anyhow::bail!(
217            "no active run for session '{session_id}'{}",
218            if message.is_empty() {
219                String::new()
220            } else {
221                format!(" ({message})")
222            }
223        );
224    } else {
225        anyhow::bail!("stop failed: HTTP {status} {message}");
226    }
227}
228
229/// Count array entries whose `is_running` is true.
230fn count_running(sessions: &[serde_json::Value]) -> usize {
231    sessions
232        .iter()
233        .filter(|x| {
234            x.get("is_running")
235                .and_then(|b| b.as_bool())
236                .unwrap_or(false)
237        })
238        .count()
239}
240
241/// Truncate to `max` chars with a trailing ellipsis.
242fn truncate(s: &str, max: usize) -> String {
243    if s.chars().count() <= max {
244        s.to_string()
245    } else {
246        let head: String = s.chars().take(max.saturating_sub(1)).collect();
247        format!("{head}…")
248    }
249}