Skip to main content

robonix_atlas/
client.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Atlas client-side helpers shared by every Robonix component that talks
5// to Atlas (pilot, executor, cli, system services, …).
6//
7// All helpers return `anyhow::Error` wrapping the underlying
8// `tonic::Status` so callers can attach context with `.with_context(...)`.
9
10use crate::pb;
11use anyhow::{Context, Result};
12use robonix_scribe::{debug, warn};
13use std::time::Duration;
14use tonic::transport::{Channel, Endpoint};
15
16/// Wrapped `pb::atlas_client::AtlasClient` with helpers.
17///
18/// Cheap to clone — the inner generated client wraps a `Channel`, which
19/// is itself just a handle to a connection pool. Share clones across
20/// tasks rather than wrapping in a `Mutex`.
21#[derive(Clone)]
22pub struct AtlasClient {
23    inner: pb::atlas_client::AtlasClient<Channel>,
24}
25
26impl AtlasClient {
27    /// Connect once. Accepts bare `host:port` or full `http://host:port`.
28    pub async fn connect(endpoint: impl AsRef<str>) -> Result<Self> {
29        let normalized = normalize_grpc_endpoint(endpoint.as_ref());
30        let channel = Endpoint::new(normalized.clone())
31            .with_context(|| format!("invalid Atlas endpoint '{}'", normalized))?
32            .connect()
33            .await
34            .with_context(|| format!("connect to Atlas at '{}'", normalized))?;
35        Ok(Self {
36            inner: pb::atlas_client::AtlasClient::new(channel),
37        })
38    }
39
40    /// `connect`, retrying up to `attempts` times with `delay` between tries.
41    pub async fn connect_with_retry(
42        endpoint: impl AsRef<str>,
43        attempts: u32,
44        delay: Duration,
45    ) -> Result<Self> {
46        let endpoint = endpoint.as_ref();
47        let mut last_err: Option<anyhow::Error> = None;
48        for i in 0..attempts.max(1) {
49            match Self::connect(endpoint).await {
50                Ok(c) => return Ok(c),
51                Err(e) => {
52                    debug!(
53                        "[atlas-client] connect attempt {}/{} failed: {e:#}",
54                        i + 1,
55                        attempts
56                    );
57                    last_err = Some(e);
58                    if i + 1 < attempts {
59                        tokio::time::sleep(delay).await;
60                    }
61                }
62            }
63        }
64        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("connect_with_retry: 0 attempts")))
65    }
66
67    pub fn inner(&self) -> pb::atlas_client::AtlasClient<Channel> {
68        self.inner.clone()
69    }
70
71    // ── Registration (one RPC per kind, shared request/response type) ──────
72
73    fn build_register_req(
74        id: &str,
75        namespace: &str,
76        capability_md_path: &str,
77    ) -> pb::RegisterRequest {
78        pb::RegisterRequest {
79            id: id.to_string(),
80            namespace: namespace.to_string(),
81            capability_md_path: capability_md_path.to_string(),
82            // Rust system components (atlas / executor / pilot / liaison)
83            // ship no CAPABILITY.md; only Python packages register content
84            // (via the robonix_api client). Leave empty here.
85            capability_md: String::new(),
86        }
87    }
88
89    /// Register a Primitive. Returns the (possibly Atlas-assigned) id.
90    pub async fn register_primitive(
91        &mut self,
92        id: &str,
93        namespace: &str,
94        capability_md_path: &str,
95    ) -> Result<String> {
96        let resp = self
97            .inner
98            .register_primitive(Self::build_register_req(id, namespace, capability_md_path))
99            .await
100            .with_context(|| format!("RegisterPrimitive '{id}'"))?;
101        Ok(resp.into_inner().id)
102    }
103
104    /// Register a Service.
105    pub async fn register_service(
106        &mut self,
107        id: &str,
108        namespace: &str,
109        capability_md_path: &str,
110    ) -> Result<String> {
111        let resp = self
112            .inner
113            .register_service(Self::build_register_req(id, namespace, capability_md_path))
114            .await
115            .with_context(|| format!("RegisterService '{id}'"))?;
116        Ok(resp.into_inner().id)
117    }
118
119    /// Register a Skill.
120    pub async fn register_skill(
121        &mut self,
122        id: &str,
123        namespace: &str,
124        capability_md_path: &str,
125    ) -> Result<String> {
126        let resp = self
127            .inner
128            .register_skill(Self::build_register_req(id, namespace, capability_md_path))
129            .await
130            .with_context(|| format!("RegisterSkill '{id}'"))?;
131        Ok(resp.into_inner().id)
132    }
133
134    /// Unregister any registered entity. Returns `true` if a record was
135    /// removed, `false` if the id was unknown (idempotent).
136    pub async fn unregister(&mut self, id: &str) -> Result<bool> {
137        let resp = self
138            .inner
139            .unregister(pb::UnregisterRequest { id: id.to_string() })
140            .await
141            .with_context(|| format!("Unregister '{id}'"))?;
142        Ok(resp.into_inner().was_present)
143    }
144
145    // ── Liveness + lifecycle ───────────────────────────────────────────────
146
147    pub async fn heartbeat(&mut self, id: &str) -> Result<()> {
148        self.inner
149            .heartbeat(pb::HeartbeatRequest { id: id.to_string() })
150            .await
151            .with_context(|| format!("Heartbeat '{id}'"))?;
152        Ok(())
153    }
154
155    /// Push a lifecycle state transition. `detail` is a free-form
156    /// human-readable note (e.g. "missing /opt/models/...") that
157    /// `rbnx caps` surfaces verbatim; pass empty when there's nothing.
158    pub async fn set_lifecycle_state(
159        &mut self,
160        id: &str,
161        new_state: pb::LifecycleState,
162        detail: &str,
163    ) -> Result<()> {
164        self.inner
165            .set_lifecycle_state(pb::SetLifecycleStateRequest {
166                id: id.to_string(),
167                state: new_state as i32,
168                detail: detail.to_string(),
169            })
170            .await
171            .with_context(|| format!("SetLifecycleState '{id}' -> {new_state:?}"))?;
172        Ok(())
173    }
174
175    // ── Capability binding ─────────────────────────────────────────────────
176
177    /// Declare one Capability (transport-bound endpoint) on an entity.
178    /// Returns the *authoritative* endpoint (may differ from the request
179    /// when Atlas rewrote to disambiguate). `description` is the optional
180    /// natural-language description for this Capability.
181    pub async fn declare_capability(
182        &mut self,
183        provider_id: &str,
184        contract_id: &str,
185        transport: pb::Transport,
186        endpoint: &str,
187        params: pb::TransportParams,
188    ) -> Result<String> {
189        self.declare_capability_with_description(
190            provider_id,
191            contract_id,
192            transport,
193            endpoint,
194            params,
195            "",
196        )
197        .await
198    }
199
200    /// Same as `declare_capability` but with the instance-specific
201    /// description string (see DeclareCapabilityRequest.description).
202    pub async fn declare_capability_with_description(
203        &mut self,
204        provider_id: &str,
205        contract_id: &str,
206        transport: pb::Transport,
207        endpoint: &str,
208        params: pb::TransportParams,
209        description: &str,
210    ) -> Result<String> {
211        let resp = self
212            .inner
213            .declare_capability(pb::DeclareCapabilityRequest {
214                provider_id: provider_id.to_string(),
215                contract_id: contract_id.to_string(),
216                transport: transport as i32,
217                endpoint: endpoint.to_string(),
218                params: Some(params),
219                description: description.to_string(),
220            })
221            .await
222            .with_context(|| {
223                format!(
224                    "DeclareCapability provider='{provider_id}' contract='{contract_id}' \
225                     transport={transport:?}"
226                )
227            })?;
228        Ok(resp.into_inner().endpoint)
229    }
230
231    // ── Discovery ──────────────────────────────────────────────────────────
232
233    /// Generic Query. `kind == Kind::Unspecified` = no kind filter (all
234    /// kinds returned; each `CapabilityProvider.kind` carries its kind).
235    /// Empty strings / `Transport::Unspecified` = no filter on that field.
236    pub async fn query(
237        &mut self,
238        kind: pb::Kind,
239        id: &str,
240        contract_id: &str,
241        namespace_prefix: &str,
242        transport: pb::Transport,
243    ) -> Result<Vec<pb::CapabilityProvider>> {
244        let resp = self
245            .inner
246            .query(pb::QueryRequest {
247                kind: kind as i32,
248                id: id.to_string(),
249                contract_id: contract_id.to_string(),
250                namespace_prefix: namespace_prefix.to_string(),
251                transport: transport as i32,
252            })
253            .await
254            .with_context(|| format!("Query kind={kind:?}"))?;
255        Ok(resp.into_inner().providers)
256    }
257
258    /// Convenience — find Primitives (kind filter applied).
259    pub async fn query_primitives(
260        &mut self,
261        id: &str,
262        contract_id: &str,
263        namespace_prefix: &str,
264        transport: pb::Transport,
265    ) -> Result<Vec<pb::CapabilityProvider>> {
266        self.query(
267            pb::Kind::Primitive,
268            id,
269            contract_id,
270            namespace_prefix,
271            transport,
272        )
273        .await
274    }
275
276    /// Convenience — find Services.
277    pub async fn query_services(
278        &mut self,
279        id: &str,
280        contract_id: &str,
281        namespace_prefix: &str,
282        transport: pb::Transport,
283    ) -> Result<Vec<pb::CapabilityProvider>> {
284        self.query(
285            pb::Kind::Service,
286            id,
287            contract_id,
288            namespace_prefix,
289            transport,
290        )
291        .await
292    }
293
294    /// Convenience — find Skills.
295    pub async fn query_skills(
296        &mut self,
297        id: &str,
298        contract_id: &str,
299        namespace_prefix: &str,
300        transport: pb::Transport,
301    ) -> Result<Vec<pb::CapabilityProvider>> {
302        self.query(
303            pb::Kind::Skill,
304            id,
305            contract_id,
306            namespace_prefix,
307            transport,
308        )
309        .await
310    }
311
312    /// Consumer-facing discovery: flat list of Capabilities across all
313    /// kinds. Walks `Query(kind=Unspecified)` and flattens each
314    /// CapabilityProvider's nested capabilities. Each returned
315    /// `Capability` already carries `provider_id` + `provider_kind`.
316    pub async fn flatten_capabilities(
317        &mut self,
318        contract_id: &str,
319        namespace_prefix: &str,
320        transport: pb::Transport,
321    ) -> Result<Vec<pb::Capability>> {
322        let providers = self
323            .query(
324                pb::Kind::Unspecified,
325                "",
326                contract_id,
327                namespace_prefix,
328                transport,
329            )
330            .await?;
331        Ok(providers
332            .into_iter()
333            .flat_map(|p| p.capabilities.into_iter())
334            .collect())
335    }
336
337    /// Back-compat alias for the legacy 3-arg signature returning a list
338    /// of CapabilityProviders. Equivalent to
339    /// `query(Kind::Unspecified, id, contract_id, "", transport)`.
340    pub async fn query_capabilities(
341        &mut self,
342        id: &str,
343        contract_id: &str,
344        transport: pb::Transport,
345    ) -> Result<Vec<pb::CapabilityProvider>> {
346        self.query(pb::Kind::Unspecified, id, contract_id, "", transport)
347            .await
348    }
349
350    // ── Channels ───────────────────────────────────────────────────────────
351
352    /// Open a channel to one (provider, contract, transport). Atlas only
353    /// providers the edge — the consumer dials the returned endpoint
354    /// itself using whatever transport-appropriate mechanism (tonic for
355    /// grpc, rclrs for ros2, fastmcp for mcp, …).
356    /// Returns `(channel_id, endpoint, params)`.
357    pub async fn connect_capability(
358        &mut self,
359        consumer_id: &str,
360        provider_id: &str,
361        contract_id: &str,
362        transport: pb::Transport,
363    ) -> Result<(String, String, pb::TransportParams)> {
364        let resp = self
365            .inner
366            .connect_capability(pb::ConnectCapabilityRequest {
367                consumer_id: consumer_id.to_string(),
368                provider_id: provider_id.to_string(),
369                contract_id: contract_id.to_string(),
370                transport: transport as i32,
371            })
372            .await
373            .with_context(|| {
374                format!(
375                    "ConnectCapability consumer='{consumer_id}' provider='{provider_id}' \
376                     contract='{contract_id}' transport={transport:?}"
377                )
378            })?;
379        let r = resp.into_inner();
380        Ok((r.channel_id, r.endpoint, r.params.unwrap_or_default()))
381    }
382
383    /// Release a previously-opened channel. Idempotent: returns `false`
384    /// when the channel_id was unknown.
385    pub async fn disconnect_capability(&mut self, channel_id: &str) -> Result<bool> {
386        let resp = self
387            .inner
388            .disconnect_capability(pb::DisconnectCapabilityRequest {
389                channel_id: channel_id.to_string(),
390            })
391            .await
392            .with_context(|| format!("DisconnectCapability '{channel_id}'"))?;
393        Ok(resp.into_inner().was_open)
394    }
395
396    // ── Contract registry ──────────────────────────────────────────────────
397
398    /// Look up one contract by id.
399    pub async fn query_contract(
400        &mut self,
401        contract_id: &str,
402    ) -> Result<Option<pb::ContractDescriptor>> {
403        let resp = self
404            .inner
405            .query_contract(pb::QueryContractRequest {
406                contract_id: contract_id.to_string(),
407            })
408            .await
409            .with_context(|| format!("QueryContract '{contract_id}'"))?;
410        let inner = resp.into_inner();
411        Ok(if inner.found { inner.contract } else { None })
412    }
413
414    pub async fn list_contracts(
415        &mut self,
416        namespace_prefix: &str,
417    ) -> Result<Vec<pb::ContractDescriptor>> {
418        let resp = self
419            .inner
420            .list_contracts(pb::ListContractsRequest {
421                namespace_prefix: namespace_prefix.to_string(),
422            })
423            .await
424            .with_context(|| format!("ListContracts prefix='{namespace_prefix}'"))?;
425        Ok(resp.into_inner().contracts)
426    }
427}
428
429/// gRPC-only convenience: pick the first Capability matching `contract_id`
430/// over gRPC, call `ConnectCapability` to register the edge, dial the
431/// returned host:port, and hand back a tonic Channel + the channel_id
432/// (so the caller can DisconnectCapability on shutdown).
433///
434/// Returns `(channel_id, provider_id, Channel)`.
435pub async fn connect_to_capability(
436    atlas: &mut AtlasClient,
437    consumer_id: &str,
438    contract_id: &str,
439) -> Result<(String, String, Channel)> {
440    let rows = atlas
441        .flatten_capabilities(contract_id, "", pb::Transport::Grpc)
442        .await?;
443    if rows.is_empty() {
444        anyhow::bail!(
445            "no Capability offering contract_id='{contract_id}' over gRPC; \
446             registered entities may not have declared this Capability yet"
447        );
448    }
449    if rows.len() > 1 {
450        warn!(
451            "[atlas-client] {} entities offer '{contract_id}' over gRPC; \
452             picking first ('{}'). Use query_capabilities + connect_capability \
453             for deterministic selection.",
454            rows.len(),
455            rows[0].provider_id,
456        );
457    }
458    let provider_id = rows
459        .into_iter()
460        .next()
461        .expect("non-empty checked above")
462        .provider_id;
463    let (channel_id, endpoint_str, _params) = atlas
464        .connect_capability(consumer_id, &provider_id, contract_id, pb::Transport::Grpc)
465        .await?;
466    let normalized = normalize_grpc_endpoint(&endpoint_str);
467    let channel = Endpoint::new(normalized.clone())
468        .with_context(|| {
469            format!(
470                "invalid endpoint '{}' for provider '{}'",
471                normalized, provider_id
472            )
473        })?
474        .connect()
475        .await
476        .with_context(|| {
477            format!(
478                "connect to provider '{provider_id}' at '{normalized}' for contract '{contract_id}'"
479            )
480        })?;
481    Ok((channel_id, provider_id, channel))
482}
483
484// ── TransportParams constructors ───────────────────────────────────────────
485
486pub fn grpc_params(
487    proto_file: impl Into<String>,
488    service_name: impl Into<String>,
489    method: impl Into<String>,
490) -> pb::TransportParams {
491    pb::TransportParams {
492        kind: Some(pb::transport_params::Kind::Grpc(pb::GrpcParams {
493            proto_file: proto_file.into(),
494            service_name: service_name.into(),
495            method: method.into(),
496        })),
497    }
498}
499
500pub fn ros2_params(qos_profile: impl Into<String>) -> pb::TransportParams {
501    pb::TransportParams {
502        kind: Some(pb::transport_params::Kind::Ros2(pb::Ros2Params {
503            qos_profile: qos_profile.into(),
504        })),
505    }
506}
507
508/// Build `TransportParams` for an MCP tool Capability. The natural-
509/// language description now lives on `DeclareCapabilityRequest.description`,
510/// not inside `McpParams`.
511pub fn mcp_params(input_schema_json: impl Into<String>) -> pb::TransportParams {
512    pb::TransportParams {
513        kind: Some(pb::transport_params::Kind::Mcp(pb::McpParams {
514            input_schema_json: input_schema_json.into(),
515        })),
516    }
517}
518
519// ── Helpers ────────────────────────────────────────────────────────────────
520
521fn normalize_grpc_endpoint(s: &str) -> String {
522    let s = s.trim();
523    if s.starts_with("http://") || s.starts_with("https://") {
524        s.to_string()
525    } else {
526        format!("http://{s}")
527    }
528}