1use crate::pb;
11use anyhow::{Context, Result};
12use robonix_scribe::{debug, warn};
13use std::time::Duration;
14use tonic::transport::{Channel, Endpoint};
15
16#[derive(Clone)]
22pub struct AtlasClient {
23 inner: pb::atlas_client::AtlasClient<Channel>,
24}
25
26impl AtlasClient {
27 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 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 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 capability_md: String::new(),
86 }
87 }
88
89 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
429pub 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
484pub 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
508pub 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
519fn 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}