Skip to main content

bamboo_server_tools/
cluster_tool.rs

1//! `cluster` — the agent's read-only window into the operator-managed Remote
2//! Cluster Fabric (RFC v2 §5, the progressive-disclosure ladder).
3//!
4//! Rung 0 (this tool's description) advertises the capability in the cached
5//! prompt prefix. Rungs 1–2 are tool-pull: `list` (inventory), `describe` (one
6//! node's capabilities), `status` (a node's live deploy state). Everything
7//! volatile is returned by the CALL — nothing is injected into the prompt
8//! prefix — so the 1h cache is never busted.
9//!
10//! It NEVER exposes credentials. Dispatch (deploying a worker onto a node,
11//! driving it) is a separate concern: the operator deploys from the UI, and the
12//! agent commands the resulting worker with `ask_agent` by the `worker_id` this
13//! tool surfaces.
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use serde::Deserialize;
19use serde_json::{json, Value};
20use tokio::sync::RwLock;
21
22use bamboo_agent_core::tools::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
23use bamboo_config::cluster_fabric::{Node, NodePlacement};
24use bamboo_config::Config;
25
26use crate::fabric_deploy::{FabricDeployer, FabricError};
27
28pub struct ClusterTool {
29    /// Read-only access for the inventory actions (list/describe/status).
30    config: Arc<RwLock<Config>>,
31    /// Shared deploy engine (one registry across HTTP + agent surfaces).
32    deployer: Arc<FabricDeployer>,
33}
34
35impl ClusterTool {
36    pub fn new(config: Arc<RwLock<Config>>, deployer: Arc<FabricDeployer>) -> Self {
37        Self { config, deployer }
38    }
39}
40
41/// Map a fabric engine error to a tool error.
42fn to_tool_error(e: FabricError) -> ToolError {
43    match e {
44        FabricError::NotFound(m) | FabricError::BadRequest(m) => ToolError::InvalidArguments(m),
45        FabricError::Conflict { expected, actual } => ToolError::Execution(format!(
46            "cluster configuration conflict: expected revision {expected}, current revision {actual}"
47        )),
48        FabricError::Committed(m) => ToolError::Execution(m),
49        FabricError::Internal(m) => ToolError::Execution(m),
50    }
51}
52
53#[derive(Debug, Deserialize)]
54#[serde(tag = "action", rename_all = "snake_case")]
55enum ClusterArgs {
56    /// Compact inventory of all nodes + clusters.
57    List,
58    /// One node's capabilities (model/role/workspace/placement/status).
59    Describe { node: String },
60    /// One node's live deploy state.
61    Status { node: String },
62    /// Deploy a worker onto a managed node (credentials resolved server-side).
63    Deploy {
64        node: String,
65        #[serde(default)]
66        echo: bool,
67    },
68    /// Stop a worker previously deployed onto a node.
69    Stop { node: String },
70}
71
72/// A node's address line for display (NEVER includes credentials).
73fn node_target(node: &Node) -> String {
74    match &node.placement {
75        NodePlacement::Local => "local".to_string(),
76        NodePlacement::Ssh(t) => format!("{}@{}:{}", t.username, t.host, t.port),
77    }
78}
79
80fn node_status(node: &Node) -> &'static str {
81    match node.state.as_ref().map(|s| s.status) {
82        Some(bamboo_config::cluster_fabric::NodeStatus::NotDeployed) | None => "not_deployed",
83        Some(bamboo_config::cluster_fabric::NodeStatus::Deploying) => "deploying",
84        Some(bamboo_config::cluster_fabric::NodeStatus::Running) => "running",
85        Some(bamboo_config::cluster_fabric::NodeStatus::Unreachable) => "unreachable",
86        Some(bamboo_config::cluster_fabric::NodeStatus::Stopped) => "stopped",
87        Some(bamboo_config::cluster_fabric::NodeStatus::Failed) => "failed",
88    }
89}
90
91/// Compact one-line-per-node summary for `list`.
92fn node_brief(node: &Node, cluster: Option<&str>) -> Value {
93    json!({
94        "id": node.id,
95        "label": node.label,
96        "target": node_target(node),
97        "status": node_status(node),
98        "worker_id": node.state.as_ref().and_then(|s| s.worker_id.clone()),
99        "cluster": cluster,
100        "enabled": node.enabled,
101    })
102}
103
104impl ClusterTool {
105    async fn list(&self) -> Result<ToolResult, ToolError> {
106        let cfg = self.config.read().await;
107        let fabric = &cfg.cluster_fabric;
108
109        // node id → first cluster name (for the brief).
110        let cluster_of = |id: &str| -> Option<String> {
111            fabric
112                .clusters
113                .iter()
114                .find(|c| c.node_ids.iter().any(|n| n == id))
115                .map(|c| c.name.clone())
116        };
117
118        let nodes: Vec<Value> = fabric
119            .nodes
120            .iter()
121            .map(|n| node_brief(n, cluster_of(&n.id).as_deref()))
122            .collect();
123        let clusters: Vec<Value> = fabric
124            .clusters
125            .iter()
126            .map(|c| {
127                json!({
128                    "name": c.name,
129                    "description": c.description,
130                    "node_ids": c.node_ids,
131                })
132            })
133            .collect();
134
135        Ok(tool_json(json!({
136            "nodes": nodes,
137            "clusters": clusters,
138            "hint": "Use action=describe node=<id> for capabilities, then drive a running worker with ask_agent(target=<worker_id>, …).",
139        })))
140    }
141
142    async fn describe(&self, node_id: &str) -> Result<ToolResult, ToolError> {
143        let cfg = self.config.read().await;
144        let node = cfg
145            .cluster_fabric
146            .node(node_id)
147            .ok_or_else(|| ToolError::InvalidArguments(format!("unknown node '{node_id}'")))?;
148
149        Ok(tool_json(json!({
150            "id": node.id,
151            "label": node.label,
152            "target": node_target(node),
153            "placement": match &node.placement {
154                NodePlacement::Local => "local",
155                NodePlacement::Ssh(_) => "ssh",
156            },
157            "trust_level": format!("{:?}", node.trust_level).to_lowercase(),
158            "status": node_status(node),
159            "worker_id": node.state.as_ref().and_then(|s| s.worker_id.clone()),
160            "enabled": node.enabled,
161            "role": node.deploy.default_role,
162            "model": node.deploy.model,
163            "workspace": node.deploy.workspace,
164        })))
165    }
166
167    async fn status(&self, node_id: &str) -> Result<ToolResult, ToolError> {
168        let cfg = self.config.read().await;
169        let node = cfg
170            .cluster_fabric
171            .node(node_id)
172            .ok_or_else(|| ToolError::InvalidArguments(format!("unknown node '{node_id}'")))?;
173        Ok(tool_json(json!({
174            "id": node.id,
175            "status": node_status(node),
176            "state": node.state,
177        })))
178    }
179
180    async fn deploy(&self, node_id: &str, echo: bool) -> Result<ToolResult, ToolError> {
181        // Delegate to the shared engine (one registry across HTTP + agent), which
182        // resolves stored creds server-side and persists NodeState.
183        let state = self
184            .deployer
185            .deploy(node_id, echo)
186            .await
187            .map_err(to_tool_error)?;
188        let worker_id = state.worker_id.clone().unwrap_or_default();
189        Ok(tool_json(json!({
190            "node": node_id,
191            "worker_id": worker_id,
192            "status": "deployed",
193            "note": format!(
194                "worker '{worker_id}' is dialing the broker; command it with ask_agent(target=\"{worker_id}\", …)."
195            ),
196        })))
197    }
198
199    async fn stop(&self, node_id: &str) -> Result<ToolResult, ToolError> {
200        self.deployer.stop(node_id).await.map_err(to_tool_error)?;
201        Ok(tool_json(json!({ "node": node_id, "status": "stopped" })))
202    }
203}
204
205fn tool_json(value: Value) -> ToolResult {
206    ToolResult {
207        success: true,
208        result: value.to_string(),
209        display_preference: None,
210        images: Vec::new(),
211    }
212}
213
214#[async_trait]
215impl Tool for ClusterTool {
216    fn name(&self) -> &str {
217        "cluster"
218    }
219
220    fn description(&self) -> &str {
221        "Inspect your operator-managed remote clusters: machines (\"nodes\") grouped into clusters \
222         that you can run work on. Use this to DISCOVER what compute you have, then dispatch to it.\n\
223         \n\
224         PREFER LOCAL: default to a local `SubAgent` for delegation. Dispatch to a cluster node ONLY \
225         when the task genuinely needs THAT machine (its data, GPU, network location). Deploying a \
226         node uploads a binary and adds network latency — don't route here by default.\n\
227         \n\
228         ACTIONS:\n\
229         - action=list — compact inventory: every node's id, label, target (user@host or local), \
230         status, its worker_id if deployed, and cluster membership. Start here.\n\
231         - action=describe node=<id> — one node's capabilities: placement, role, model, workspace, \
232         status, worker_id.\n\
233         - action=status node=<id> — one node's live deploy state (deployed_at, pid, last error).\n\
234         - action=deploy node=<id> [echo=true] — deploy a worker onto the node (credentials are \
235         resolved by the backend; you never see them). Returns a worker_id. Use echo=true for a \
236         no-LLM connectivity smoke test.\n\
237         - action=stop node=<id> — stop the worker you deployed on that node.\n\
238         \n\
239         DISPATCH: after deploy (or for an already-running node), command its worker_id with \
240         ask_agent(target=<worker_id>, question=…, mode=query|steer). For PARALLEL work: list the \
241         cluster, deploy to several nodes, then ask_agent each and gather. You address nodes by id \
242         and never handle credentials."
243    }
244
245    fn parameters_schema(&self) -> Value {
246        json!({
247            "type": "object",
248            "properties": {
249                "action": { "type": "string", "enum": ["list", "describe", "status", "deploy", "stop"] },
250                "node": { "type": "string", "description": "node id (required for describe/status/deploy/stop)." },
251                "echo": { "type": "boolean", "description": "deploy: run the no-LLM echo executor (connectivity smoke)." }
252            },
253            "required": ["action"]
254        })
255    }
256
257    async fn invoke(&self, args: Value, _ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
258        let parsed: ClusterArgs = serde_json::from_value(args)
259            .map_err(|e| ToolError::InvalidArguments(format!("Invalid cluster args: {e}")))?;
260        let result = match parsed {
261            ClusterArgs::List => self.list().await,
262            ClusterArgs::Describe { node } => self.describe(&node).await,
263            ClusterArgs::Status { node } => self.status(&node).await,
264            ClusterArgs::Deploy { node, echo } => self.deploy(&node, echo).await,
265            ClusterArgs::Stop { node } => self.stop(&node).await,
266        }?;
267        Ok(ToolOutcome::Completed(result))
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use bamboo_config::cluster_fabric::{
275        Cluster, DeployProfile, NodeState, NodeStatus, SshAuth, SshTarget, TrustLevel,
276    };
277
278    fn config_with(nodes: Vec<Node>, clusters: Vec<Cluster>) -> Arc<RwLock<Config>> {
279        let mut cfg = Config::default();
280        cfg.cluster_fabric.nodes = nodes;
281        cfg.cluster_fabric.clusters = clusters;
282        Arc::new(RwLock::new(cfg))
283    }
284
285    fn deployer_for(config: Arc<RwLock<Config>>) -> Arc<crate::fabric_deploy::FabricDeployer> {
286        Arc::new(crate::fabric_deploy::FabricDeployer::new(
287            config,
288            Arc::new(tokio::sync::Mutex::new(())),
289            std::env::temp_dir(),
290            Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
291            "/usr/bin/true",
292        ))
293    }
294
295    fn tool(config: Arc<RwLock<Config>>) -> ClusterTool {
296        let deployer = deployer_for(config.clone());
297        ClusterTool::new(config, deployer)
298    }
299
300    fn ssh_node(id: &str, running: bool) -> Node {
301        Node {
302            id: id.to_string(),
303            label: format!("label-{id}"),
304            placement: NodePlacement::Ssh(SshTarget {
305                host: "10.0.0.9".into(),
306                port: 22,
307                username: "deploy".into(),
308                auth: SshAuth::Password {
309                    password: "SECRET".into(),
310                    password_encrypted: None,
311                },
312                host_key_fingerprint: None,
313            }),
314            trust_level: TrustLevel::Trusted,
315            deploy: DeployProfile {
316                default_role: Some("worker".into()),
317                ..Default::default()
318            },
319            state: running.then(|| NodeState {
320                status: NodeStatus::Running,
321                worker_id: Some(format!("node-{id}")),
322                ..Default::default()
323            }),
324            enabled: true,
325        }
326    }
327
328    fn parse(r: ToolResult) -> Value {
329        serde_json::from_str(&r.result).unwrap()
330    }
331
332    /// A throwaway in-process broker on a random port (token `"t"`).
333    async fn start_broker() -> (String, tempfile::TempDir) {
334        let dir = tempfile::tempdir().unwrap();
335        let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
336        let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
337        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
338        let addr = listener.local_addr().unwrap();
339        tokio::spawn(async move {
340            let _ = server.serve(listener).await;
341        });
342        (format!("ws://{addr}"), dir)
343    }
344
345    /// Join a worker to the bus so the post-deploy presence verify can see it
346    /// (mailbox id == worker id, registered under `role`). Keep the handle alive.
347    async fn join_worker(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
348        let mut c = bamboo_broker::BrokerClient::connect(
349            endpoint,
350            bamboo_subagent::AgentRef {
351                session_id: id.into(),
352                role: Some(role.into()),
353            },
354            "t",
355        )
356        .await
357        .unwrap();
358        c.subscribe().await.unwrap();
359        c
360    }
361
362    #[tokio::test]
363    async fn list_summarizes_nodes_without_secrets() {
364        let cfg = config_with(
365            vec![ssh_node("n1", true)],
366            vec![Cluster {
367                name: "prod".into(),
368                description: None,
369                node_ids: vec!["n1".into()],
370            }],
371        );
372        let tool = tool(cfg);
373        let out = parse(tool.list().await.unwrap());
374        let node = &out["nodes"][0];
375        assert_eq!(node["target"], "deploy@10.0.0.9:22");
376        assert_eq!(node["status"], "running");
377        assert_eq!(node["worker_id"], "node-n1");
378        assert_eq!(node["cluster"], "prod");
379        // No credential material anywhere in the serialized output.
380        assert!(!out.to_string().contains("SECRET"));
381        assert!(!out.to_string().contains("password"));
382    }
383
384    #[tokio::test]
385    async fn describe_exposes_capabilities_not_creds() {
386        let cfg = config_with(vec![ssh_node("n1", true)], vec![]);
387        let tool = tool(cfg);
388        let out = parse(tool.describe("n1").await.unwrap());
389        assert_eq!(out["placement"], "ssh");
390        assert_eq!(out["role"], "worker");
391        assert_eq!(out["worker_id"], "node-n1");
392        assert!(!out.to_string().contains("SECRET"));
393    }
394
395    #[tokio::test]
396    async fn describe_unknown_node_errors() {
397        let cfg = config_with(vec![], vec![]);
398        let tool = tool(cfg);
399        assert!(tool.describe("nope").await.is_err());
400    }
401
402    #[tokio::test]
403    async fn status_reports_not_deployed_for_fresh_node() {
404        let cfg = config_with(vec![ssh_node("n1", false)], vec![]);
405        let tool = tool(cfg);
406        let out = parse(tool.status("n1").await.unwrap());
407        assert_eq!(out["status"], "not_deployed");
408    }
409
410    fn local_node(id: &str) -> Node {
411        Node {
412            id: id.to_string(),
413            label: id.to_string(),
414            placement: NodePlacement::Local,
415            trust_level: TrustLevel::Trusted,
416            deploy: DeployProfile::default(),
417            state: None,
418            enabled: true,
419        }
420    }
421
422    #[tokio::test]
423    async fn deploy_local_node_registers_worker_then_stop_clears_it() {
424        // Exercises the register/stop plumbing through the tool. `/usr/bin/true`
425        // stands in for the `bamboo` binary (spawned with broker-agent args it
426        // ignores), so it drives the spawn/register path only. Deploy now runs a
427        // post-deploy presence verify for NON-echo workers too, so we stand up a
428        // real broker and join a matching worker (id "node-n1" under the resident
429        // default role "general-purpose") to stand in for the process dialing home.
430        let (endpoint, _broker_dir) = start_broker().await;
431        let mut config = Config::default();
432        config.cluster_fabric.nodes = vec![local_node("n1")];
433        config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
434            endpoint: endpoint.clone(),
435            token: "t".into(),
436            token_encrypted: None,
437            credential_ref: None,
438            configured: false,
439        });
440        let cfg = Arc::new(RwLock::new(config));
441
442        // The worker "dials home": a live bus client under the node's resolved role.
443        let _worker = join_worker(&endpoint, "node-n1", "general-purpose").await;
444
445        // Unique data dir so the persisted config.json doesn't collide with peers.
446        let data_dir =
447            std::env::temp_dir().join(format!("bamboo-clustertool-{}", std::process::id()));
448        let _ = std::fs::create_dir_all(&data_dir);
449        let deployer = Arc::new(crate::fabric_deploy::FabricDeployer::new(
450            cfg.clone(),
451            Arc::new(tokio::sync::Mutex::new(())),
452            &data_dir,
453            Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
454            "/usr/bin/true",
455        ));
456        let registry = deployer.registry();
457        let t = ClusterTool::new(cfg, deployer);
458
459        let key = crate::registry_keys::node_key("n1");
460        let out = parse(t.deploy("n1", false).await.unwrap());
461        assert_eq!(out["worker_id"], "node-n1");
462        assert_eq!(out["status"], "deployed");
463        assert!(
464            registry.lock().await.contains_key(&key),
465            "handle registered"
466        );
467
468        let stopped = parse(t.stop("n1").await.unwrap());
469        assert_eq!(stopped["status"], "stopped");
470        assert!(!registry.lock().await.contains_key(&key), "handle removed");
471        let _ = std::fs::remove_dir_all(&data_dir);
472    }
473
474    #[tokio::test]
475    async fn deploy_unknown_node_errors() {
476        let cfg = config_with(vec![], vec![]);
477        let tool = tool(cfg);
478        assert!(tool.deploy("nope", true).await.is_err());
479    }
480}