bamboo_agent/
admin_cli.rs1use std::path::PathBuf;
10use std::time::Duration;
11
12use colored::Colorize;
13
14const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
15
16#[derive(Debug, Clone, Default)]
18pub struct ConnArgs {
19 pub server_url: Option<String>,
21 pub port: Option<u16>,
23 pub data_dir: Option<PathBuf>,
25}
26
27impl ConnArgs {
28 fn api_base(&self) -> String {
30 if let Some(url) = &self.server_url {
31 let url = url.trim_end_matches('/');
32 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 "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
45 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
57pub 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
76pub 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
125pub 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 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
180pub async fn stop(conn: ConnArgs, session_id: &str) -> anyhow::Result<()> {
182 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
229fn 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
241fn 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}