1use anyhow::{Context, Result};
14use robonix_scribe::{info, warn};
15use serde::Serialize;
16use std::collections::HashMap;
17use std::net::SocketAddr;
18use std::sync::Arc;
19use tokio::sync::RwLock;
20use tonic::{Request, Response, Status};
21use uuid::Uuid;
22
23use crate::contract_registry::ContractRegistry;
24use crate::pb;
25pub use pb::Transport;
26
27const MINT_ATTEMPTS: usize = 16;
29const SHARED_LIFECYCLE_DRIVER_CONTRACT: &str = "robonix/lifecycle/driver";
30
31#[derive(Debug, Clone, Serialize)]
32#[serde(tag = "transport", rename_all = "snake_case")]
33enum TransportParamsState {
34 Grpc {
35 proto_file: String,
36 service_name: String,
37 method: String,
38 },
39 Ros2 {
40 qos_profile: String,
41 },
42 Mcp {
43 input_schema_json: String,
44 },
45}
46
47impl TransportParamsState {
48 fn transport(&self) -> Transport {
49 match self {
50 Self::Grpc { .. } => Transport::Grpc,
51 Self::Ros2 { .. } => Transport::Ros2,
52 Self::Mcp { .. } => Transport::Mcp,
53 }
54 }
55}
56
57impl From<&TransportParamsState> for pb::TransportParams {
58 fn from(r: &TransportParamsState) -> Self {
59 use pb::transport_params::Kind;
60 let kind = match r {
61 TransportParamsState::Grpc {
62 proto_file,
63 service_name,
64 method,
65 } => Kind::Grpc(pb::GrpcParams {
66 proto_file: proto_file.clone(),
67 service_name: service_name.clone(),
68 method: method.clone(),
69 }),
70 TransportParamsState::Ros2 { qos_profile } => Kind::Ros2(pb::Ros2Params {
71 qos_profile: qos_profile.clone(),
72 }),
73 TransportParamsState::Mcp { input_schema_json } => Kind::Mcp(pb::McpParams {
74 input_schema_json: input_schema_json.clone(),
75 }),
76 };
77 Self { kind: Some(kind) }
78 }
79}
80
81#[derive(Debug, Clone, Serialize)]
82struct DeclaredEndpoint {
83 contract_id: String,
84 #[serde(serialize_with = "serialize_transport")]
85 transport: Transport,
86 endpoint: String,
87 params: TransportParamsState,
88 description: String,
91 namespace_mismatch: bool,
94}
95
96fn serialize_transport<S: serde::Serializer>(t: &Transport, ser: S) -> Result<S::Ok, S::Error> {
97 ser.serialize_str(t.as_str_name())
98}
99
100fn serialize_pushed_state<S: serde::Serializer>(
101 s: &Option<pb::LifecycleState>,
102 ser: S,
103) -> Result<S::Ok, S::Error> {
104 match s {
105 Some(v) => ser.serialize_str(v.as_str_name()),
106 None => ser.serialize_none(),
107 }
108}
109
110fn serialize_provider_kind<S: serde::Serializer>(k: &pb::Kind, ser: S) -> Result<S::Ok, S::Error> {
111 ser.serialize_str(k.as_str_name())
112}
113
114#[derive(Debug, Clone, Serialize)]
115struct CapabilityProviderState {
116 id: String,
117 registration_id: String,
120 #[serde(serialize_with = "serialize_provider_kind")]
121 kind: pb::Kind,
122 namespace: String,
123 capability_md_path: String,
124 capability_md: String,
128 last_heartbeat_ms: u64,
129 endpoints: Vec<DeclaredEndpoint>,
130 #[serde(serialize_with = "serialize_pushed_state")]
133 pushed_state: Option<pb::LifecycleState>,
134 state_detail: String,
135}
136
137impl CapabilityProviderState {
138 fn capability_at(&self, e: &DeclaredEndpoint) -> pb::Capability {
141 pb::Capability {
142 provider_id: self.id.clone(),
143 provider_kind: self.kind as i32,
144 contract_id: e.contract_id.clone(),
145 transport: e.transport as i32,
146 params: Some((&e.params).into()),
147 description: e.description.clone(),
148 namespace_mismatch: e.namespace_mismatch,
149 }
150 }
151}
152
153impl From<&CapabilityProviderState> for pb::CapabilityProvider {
154 fn from(provider: &CapabilityProviderState) -> Self {
155 Self {
156 id: provider.id.clone(),
157 kind: provider.kind as i32,
158 namespace: provider.namespace.clone(),
159 capability_md_path: provider.capability_md_path.clone(),
160 capability_md: provider.capability_md.clone(),
161 last_heartbeat_ms: provider.last_heartbeat_ms,
162 capabilities: provider
163 .endpoints
164 .iter()
165 .map(|e| provider.capability_at(e))
166 .collect(),
167 state: provider.state() as i32,
168 state_detail: provider.state_detail.clone(),
169 registration_id: provider.registration_id.clone(),
170 }
171 }
172}
173
174impl CapabilityProviderState {
175 fn state(&self) -> pb::LifecycleState {
179 self.pushed_state
180 .unwrap_or(pb::LifecycleState::StateRegistered)
181 }
182
183 fn has_driver(&self) -> bool {
186 self.endpoints
187 .iter()
188 .any(|endpoint| is_driver_contract(&self.namespace, &endpoint.contract_id))
189 }
190}
191
192fn is_legal_transition(prev: pb::LifecycleState, next: pb::LifecycleState) -> bool {
196 use pb::LifecycleState::*;
197 if next == StateError || next == StateTerminated {
198 return true; }
200 match (prev, next) {
201 (StateUnspecified, _) => true,
202 (StateRegistered, StateInactive) => true,
203 (StateInactive, StateActive) => true,
204 (StateActive, StateInactive) => true,
205 (StateError, StateInactive) => true,
206 (a, b) if a == b => true,
208 _ => false,
209 }
210}
211
212fn is_driver_contract(namespace: &str, contract_id: &str) -> bool {
213 if contract_id == SHARED_LIFECYCLE_DRIVER_CONTRACT {
214 return true;
215 }
216 let namespace = namespace.trim_matches('/');
217 contract_id == format!("{namespace}/driver")
218}
219
220fn driver_service_name(contract_id: &str) -> String {
223 contract_id
224 .split('/')
225 .filter(|segment| !segment.is_empty())
226 .flat_map(|segment| segment.split('_').filter(|part| !part.is_empty()))
227 .map(|part| {
228 let mut chars = part.chars();
229 chars
230 .next()
231 .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
232 .unwrap_or_default()
233 })
234 .collect()
235}
236
237fn validate_driver_abi(
240 namespace: &str,
241 contract_id: &str,
242 transport: Transport,
243 params: &TransportParamsState,
244) -> Result<(), Status> {
245 if !is_driver_contract(namespace, contract_id) {
246 return Ok(());
247 }
248 if transport != Transport::Grpc {
249 return Err(Status::invalid_argument(format!(
250 "lifecycle Driver '{contract_id}' must use GRPC transport"
251 )));
252 }
253 let service_name = format!("robonix.contracts.{}", driver_service_name(contract_id));
254 let method = format!("/{service_name}/Driver");
255 match params {
256 TransportParamsState::Grpc {
257 service_name: actual_service,
258 method: actual_method,
259 ..
260 } if actual_service == &service_name && actual_method == &method => Ok(()),
261 TransportParamsState::Grpc {
262 service_name: actual_service,
263 method: actual_method,
264 ..
265 } => Err(Status::invalid_argument(format!(
266 "lifecycle Driver '{contract_id}' must use service_name='{service_name}' and method='{method}', got service_name='{actual_service}' method='{actual_method}'"
267 ))),
268 _ => Err(Status::invalid_argument(format!(
269 "lifecycle Driver '{contract_id}' requires gRPC params"
270 ))),
271 }
272}
273
274fn has_namespace_mismatch(namespace: &str, contract_id: &str, cross_namespace: bool) -> bool {
277 if cross_namespace {
278 return false;
279 }
280 let namespace = namespace.trim_matches('/');
281 let contract_id = contract_id.trim_matches('/');
282 contract_id != namespace
283 && !contract_id
284 .strip_prefix(namespace)
285 .is_some_and(|suffix| suffix.starts_with('/'))
286}
287
288#[derive(Debug, Clone, Serialize)]
292struct OpenChannel {
293 channel_id: String,
294 consumer_id: String,
295 provider_id: String,
296 contract_id: String,
297 #[serde(serialize_with = "serialize_transport")]
298 transport: Transport,
299 endpoint: String,
300 opened_at_ms: u64,
301}
302
303#[derive(Debug, Default, Serialize)]
304pub(crate) struct State {
305 providers: HashMap<String, CapabilityProviderState>,
306 channels: HashMap<String, OpenChannel>,
307}
308
309impl State {
310 fn drop_channels_of(&mut self, provider_id: &str) -> usize {
314 let before = self.channels.len();
315 self.channels.retain(|_, ch| ch.provider_id != provider_id);
316 before - self.channels.len()
317 }
318}
319
320#[derive(Debug, Default)]
328pub struct AtlasRegistry {
329 pub(crate) inner: RwLock<State>,
330 contracts: ContractRegistry,
331}
332
333impl AtlasRegistry {
334 pub fn with_contracts(contracts: ContractRegistry) -> Self {
338 Self {
339 inner: RwLock::new(State::default()),
340 contracts,
341 }
342 }
343
344 pub fn contracts(&self) -> &ContractRegistry {
345 &self.contracts
346 }
347}
348
349impl AtlasRegistry {
350 pub fn now_ms() -> u64 {
351 std::time::SystemTime::now()
352 .duration_since(std::time::UNIX_EPOCH)
353 .unwrap_or_default()
354 .as_millis() as u64
355 }
356
357 fn assign_id() -> String {
358 format!("com.robonix.ephemeral.{}", Uuid::new_v4())
359 }
360
361 fn require<'a>(field: &str, value: &'a str) -> Result<&'a str, Status> {
362 let v = value.trim();
363 if v.is_empty() {
364 return Err(Status::invalid_argument(format!("{field} required")));
365 }
366 Ok(v)
367 }
368
369 pub async fn register(
390 &self,
391 provider_id: &str,
392 kind: pb::Kind,
393 namespace: &str,
394 capability_md_path: &str,
395 capability_md: &str,
396 ) -> Result<String, Status> {
397 let provider_id = if provider_id.trim().is_empty() {
398 Self::assign_id()
399 } else {
400 provider_id.trim().to_string()
401 };
402 let namespace = Self::require("namespace", namespace)?.to_string();
403 let mut state = self.inner.write().await;
404 if let Some(existing) = state.providers.get_mut(&provider_id) {
405 if existing.kind != kind {
407 return Err(Status::already_exists(format!(
408 "'{provider_id}' already registered as {:?}; cannot re-register as {:?}",
409 existing.kind, kind
410 )));
411 }
412 let prev_iface_count = existing.endpoints.len();
416 existing.namespace = namespace;
417 existing.registration_id = Uuid::new_v4().to_string();
418 existing.capability_md_path = capability_md_path.trim().to_string();
419 existing.capability_md = capability_md.to_string();
420 existing.last_heartbeat_ms = Self::now_ms();
421 existing.endpoints.clear();
422 existing.pushed_state = None;
423 existing.state_detail.clear();
424 let dropped = state.drop_channels_of(&provider_id);
425 info!(
426 "[atlas] register {provider_id} (takeover; dropped {prev_iface_count} \
427 stale capabilities, {dropped} channels)"
428 );
429 return Ok(provider_id);
430 }
431 state.providers.insert(
432 provider_id.clone(),
433 CapabilityProviderState {
434 id: provider_id.clone(),
435 registration_id: Uuid::new_v4().to_string(),
436 kind,
437 namespace,
438 capability_md_path: capability_md_path.trim().to_string(),
439 capability_md: capability_md.to_string(),
440 last_heartbeat_ms: Self::now_ms(),
441 endpoints: Vec::new(),
442 pushed_state: None,
443 state_detail: String::new(),
444 },
445 );
446 info!("[atlas] register {provider_id} kind={kind:?}");
447 Ok(provider_id)
448 }
449
450 pub async fn unregister(&self, provider_id: &str) -> bool {
454 let provider_id = provider_id.trim();
455 if provider_id.is_empty() {
456 return false;
457 }
458 let mut state = self.inner.write().await;
459 let was_present = state.providers.remove(provider_id).is_some();
460 let dropped = state.drop_channels_of(provider_id);
461 info!(
462 "[atlas] unregister {provider_id} (was_present={was_present}, \
463 channels_dropped={dropped})"
464 );
465 was_present
466 }
467
468 pub async fn set_lifecycle_state(
475 &self,
476 provider_id: &str,
477 new_state: pb::LifecycleState,
478 detail: &str,
479 ) -> Result<pb::LifecycleState, Status> {
480 let provider_id = Self::require("provider_id", provider_id)?;
481 if new_state == pb::LifecycleState::StateUnspecified {
482 return Err(Status::invalid_argument(
483 "state: must not be STATE_UNSPECIFIED",
484 ));
485 }
486 let mut state = self.inner.write().await;
487 let provider = state
488 .providers
489 .get_mut(provider_id)
490 .ok_or_else(|| Status::not_found(format!("unknown provider_id: {provider_id}")))?;
491 let prev = provider.state();
492 if new_state == pb::LifecycleState::StateActive && !provider.has_driver() {
493 return Err(Status::failed_precondition(format!(
494 "provider '{provider_id}' cannot become ACTIVE without lifecycle Driver '{}' or exact legacy '{}/driver'",
495 SHARED_LIFECYCLE_DRIVER_CONTRACT,
496 provider.namespace.trim_matches('/'),
497 )));
498 }
499 if !is_legal_transition(prev, new_state) {
500 return Err(Status::failed_precondition(format!(
501 "illegal lifecycle transition for provider '{provider_id}': {prev:?} -> {new_state:?}"
502 )));
503 }
504 provider.pushed_state = Some(new_state);
505 provider.state_detail = detail.trim().to_string();
506 info!(
507 "[atlas] state {provider_id}: {:?} -> {:?}{}",
508 prev,
509 new_state,
510 if provider.state_detail.is_empty() {
511 String::new()
512 } else {
513 format!(" ({})", provider.state_detail)
514 }
515 );
516 Ok(prev)
517 }
518
519 pub async fn heartbeat(&self, provider_id: &str) -> Result<u64, Status> {
521 let provider_id = Self::require("provider_id", provider_id)?;
522 let now = Self::now_ms();
523 let mut state = self.inner.write().await;
524 let provider = state
525 .providers
526 .get_mut(provider_id)
527 .ok_or_else(|| Status::not_found(format!("unknown provider_id: {provider_id}")))?;
528 provider.last_heartbeat_ms = now;
529 Ok(now)
530 }
531
532 pub async fn declare(
536 &self,
537 provider_id: &str,
538 contract_id: &str,
539 transport: Transport,
540 proposed: &str,
541 params: pb::TransportParams,
542 description: &str,
543 ) -> Result<String, Status> {
544 let provider_id = Self::require("provider_id", provider_id)?;
545 let contract_id = Self::require("contract_id", contract_id)?.to_string();
546 let params = parse_params(transport, Some(params))?;
547 let proposed = proposed.trim().to_string();
548
549 let mut state = self.inner.write().await;
550 let provider = state
551 .providers
552 .get(provider_id)
553 .ok_or_else(|| Status::not_found(format!("unknown provider_id: {provider_id}")))?;
554 validate_driver_abi(&provider.namespace, &contract_id, transport, ¶ms)?;
555 let cross_namespace = self
556 .contracts
557 .get(&contract_id)
558 .is_some_and(|contract| contract.cross_namespace);
559 let namespace_mismatch =
560 has_namespace_mismatch(&provider.namespace, &contract_id, cross_namespace);
561 if namespace_mismatch {
562 warn!(
563 "[atlas] namespace mismatch: provider '{provider_id}' declares '{contract_id}' \
564 outside primary namespace '{}'; accepting capability",
565 provider.namespace
566 );
567 }
568 if is_driver_contract(&provider.namespace, &contract_id)
569 && let Some(existing) = provider
570 .endpoints
571 .iter()
572 .find(|endpoint| is_driver_contract(&provider.namespace, &endpoint.contract_id))
573 {
574 return Err(Status::failed_precondition(format!(
575 "provider '{provider_id}' already declares lifecycle Driver '{}'; cannot also declare '{contract_id}'",
576 existing.contract_id
577 )));
578 }
579 if provider
580 .endpoints
581 .iter()
582 .any(|e| e.contract_id == contract_id && e.transport == transport)
583 {
584 return Err(Status::already_exists(format!(
585 "({contract_id}, {transport:?}) already declared by {provider_id}"
586 )));
587 }
588
589 let endpoint = resolve_endpoint(&state, transport, &proposed, &contract_id, provider_id)?;
590 let provider = state
591 .providers
592 .get_mut(provider_id)
593 .ok_or_else(|| Status::internal("capability vanished mid-declare"))?;
594 provider.endpoints.push(DeclaredEndpoint {
595 contract_id: contract_id.clone(),
596 transport,
597 endpoint: endpoint.clone(),
598 params,
599 description: description.to_string(),
600 namespace_mismatch,
601 });
602 info!("[atlas] declare {provider_id} {contract_id} via {transport:?} -> {endpoint}");
603 Ok(endpoint)
604 }
605
606 pub async fn query(
612 &self,
613 provider_id: &str,
614 kind: pb::Kind,
615 contract: &str,
616 transport: Transport,
617 ) -> Vec<pb::CapabilityProvider> {
618 self.query_with_prefix(provider_id, kind, contract, "", transport)
619 .await
620 }
621
622 pub async fn query_with_prefix(
623 &self,
624 provider_id: &str,
625 kind: pb::Kind,
626 contract: &str,
627 namespace_prefix: &str,
628 transport: Transport,
629 ) -> Vec<pb::CapabilityProvider> {
630 let f_cap_id = provider_id.trim();
631 let f_contract = contract.trim();
632 let f_ns_prefix = namespace_prefix.trim();
633 let f_transport = if transport == Transport::Unspecified {
634 None
635 } else {
636 Some(transport)
637 };
638 let f_kind = if kind == pb::Kind::Unspecified {
639 None
640 } else {
641 Some(kind)
642 };
643
644 let state = self.inner.read().await;
645 let mut out = Vec::new();
646 for provider in state.providers.values() {
647 if !f_cap_id.is_empty() && provider.id != f_cap_id {
648 continue;
649 }
650 if let Some(k) = f_kind
651 && provider.kind != k
652 {
653 continue;
654 }
655 if !f_ns_prefix.is_empty() && !provider.namespace.starts_with(f_ns_prefix) {
656 continue;
657 }
658 if !f_contract.is_empty()
659 && !provider
660 .endpoints
661 .iter()
662 .any(|e| e.contract_id == f_contract)
663 {
664 continue;
665 }
666 let capabilities: Vec<pb::Capability> = provider
667 .endpoints
668 .iter()
669 .filter(|e| {
670 (f_contract.is_empty() || e.contract_id == f_contract)
671 && f_transport.is_none_or(|t| e.transport == t)
672 })
673 .map(|e| provider.capability_at(e))
674 .collect();
675 out.push(pb::CapabilityProvider {
676 id: provider.id.clone(),
677 kind: provider.kind as i32,
678 namespace: provider.namespace.clone(),
679 capability_md_path: provider.capability_md_path.clone(),
680 capability_md: provider.capability_md.clone(),
681 last_heartbeat_ms: provider.last_heartbeat_ms,
682 state: provider.state() as i32,
683 state_detail: provider.state_detail.clone(),
684 capabilities,
685 registration_id: provider.registration_id.clone(),
686 });
687 }
688 out
689 }
690
691 pub async fn connect(
697 &self,
698 consumer_id: &str,
699 provider_id: &str,
700 contract_id: &str,
701 transport: Transport,
702 ) -> Result<(String, String, pb::TransportParams), Status> {
703 let consumer_id = Self::require("consumer_id", consumer_id)?.to_string();
704 let provider_id = Self::require("provider_id", provider_id)?.to_string();
705 let contract_id = Self::require("contract_id", contract_id)?.to_string();
706 if transport == Transport::Unspecified {
707 return Err(Status::invalid_argument(
708 "transport: must not be UNSPECIFIED",
709 ));
710 }
711
712 let mut state = self.inner.write().await;
713 let provider = state
714 .providers
715 .get(&provider_id)
716 .ok_or_else(|| Status::not_found(format!("unknown provider_id: {provider_id}")))?;
717 let ep = provider
718 .endpoints
719 .iter()
720 .find(|e| e.contract_id == contract_id && e.transport == transport)
721 .ok_or_else(|| {
722 Status::not_found(format!(
723 "provider '{provider_id}' has not declared ({contract_id}, {transport:?})"
724 ))
725 })?;
726 let endpoint = ep.endpoint.clone();
727 let params: pb::TransportParams = (&ep.params).into();
728
729 let channel_id = format!("ch-{}", Uuid::new_v4().simple());
730 state.channels.insert(
731 channel_id.clone(),
732 OpenChannel {
733 channel_id: channel_id.clone(),
734 consumer_id: consumer_id.clone(),
735 provider_id: provider_id.clone(),
736 contract_id: contract_id.clone(),
737 transport,
738 endpoint: endpoint.clone(),
739 opened_at_ms: Self::now_ms(),
740 },
741 );
742 info!(
743 "[atlas] connect '{consumer_id}' -> '{provider_id}' \
744 {contract_id} via {transport:?} -> {endpoint} ({channel_id})"
745 );
746 Ok((channel_id, endpoint, params))
747 }
748
749 pub async fn disconnect(&self, channel_id: &str) -> bool {
753 let channel_id = channel_id.trim();
754 if channel_id.is_empty() {
755 return false;
756 }
757 let mut state = self.inner.write().await;
758 let was_open = state.channels.remove(channel_id).is_some();
759 info!("[atlas] disconnect {channel_id} (was_open={was_open})");
760 was_open
761 }
762
763 pub async fn capability_md(&self, provider_id: &str) -> Result<String, Status> {
769 let provider_id = Self::require("provider_id", provider_id)?;
770 let state = self.inner.read().await;
771 let provider = state
772 .providers
773 .get(provider_id)
774 .ok_or_else(|| Status::not_found(format!("unknown provider_id: {provider_id}")))?;
775 Ok(provider.capability_md.clone())
776 }
777
778 pub async fn inspect_json(&self) -> Result<String, Status> {
780 let state = self.inner.read().await;
781 serde_json::to_string_pretty(&*state)
782 .map_err(|e| Status::internal(format!("inspect serialise failed: {e}")))
783 }
784}
785
786fn atlas_can_mint(transport: Transport) -> bool {
791 matches!(transport, Transport::Ros2)
792}
793
794fn parse_params(
797 transport: Transport,
798 params: Option<pb::TransportParams>,
799) -> Result<TransportParamsState, Status> {
800 use pb::transport_params::Kind;
801 let kind = params.and_then(|p| p.kind).ok_or_else(|| {
802 Status::invalid_argument(
803 "params required: set TransportParams.kind to the variant matching `transport`",
804 )
805 })?;
806 let params_state = match kind {
807 Kind::Grpc(g) => TransportParamsState::Grpc {
808 proto_file: g.proto_file,
809 service_name: g.service_name,
810 method: g.method,
811 },
812 Kind::Ros2(r) => TransportParamsState::Ros2 {
813 qos_profile: r.qos_profile,
814 },
815 Kind::Mcp(m) => {
816 if !m.input_schema_json.is_empty() {
817 let v: serde_json::Value =
818 serde_json::from_str(&m.input_schema_json).map_err(|e| {
819 Status::invalid_argument(format!("mcp input_schema_json invalid: {e}"))
820 })?;
821 if !v.is_object() {
822 return Err(Status::invalid_argument(
823 "mcp input_schema_json must be a JSON object",
824 ));
825 }
826 }
827 TransportParamsState::Mcp {
828 input_schema_json: m.input_schema_json,
829 }
830 }
831 };
832 if params_state.transport() != transport {
833 return Err(Status::invalid_argument(format!(
834 "params: oneof variant {:?} does not match transport {:?}",
835 params_state.transport(),
836 transport
837 )));
838 }
839 Ok(params_state)
840}
841
842fn parse_transport(t: i32) -> Result<Transport, Status> {
843 let v = Transport::try_from(t)
844 .map_err(|_| Status::invalid_argument(format!("transport: unknown enum value {t}")))?;
845 if v == Transport::Unspecified {
846 return Err(Status::invalid_argument(
847 "transport: must not be UNSPECIFIED",
848 ));
849 }
850 Ok(v)
851}
852
853fn resolve_endpoint(
862 state: &State,
863 transport: Transport,
864 proposed: &str,
865 contract_id: &str,
866 own_cap_id: &str,
867) -> Result<String, Status> {
868 let mintable = atlas_can_mint(transport);
869 let collides = |s: &str| -> bool {
870 state.providers.iter().any(|(other_id, provider)| {
871 other_id != own_cap_id
872 && provider
873 .endpoints
874 .iter()
875 .any(|e| e.transport == transport && e.endpoint == s)
876 })
877 };
878 let short_uuid = || -> String {
879 Uuid::new_v4()
880 .simple()
881 .to_string()
882 .chars()
883 .take(8)
884 .collect()
885 };
886 let dotted = contract_id.replace('/', ".");
887
888 if proposed.is_empty() {
889 if !mintable {
890 return Err(Status::invalid_argument(format!(
891 "transport '{transport:?}' requires caller-supplied endpoint; \
892 Atlas cannot allocate (e.g. caller must bind a port and pass host:port)"
893 )));
894 }
895 for _ in 0..MINT_ATTEMPTS {
896 let candidate = format!("/rbnx/{dotted}/{}", short_uuid());
897 if !collides(&candidate) {
898 return Ok(candidate);
899 }
900 }
901 return Err(Status::internal(format!(
902 "could not mint unique endpoint for ({transport:?}, {contract_id}) \
903 after {MINT_ATTEMPTS} attempts (existing providers: {})",
904 state.providers.len()
905 )));
906 }
907
908 if !collides(proposed) {
909 return Ok(proposed.to_string());
910 }
911 if !mintable {
912 return Err(Status::already_exists(format!(
913 "endpoint '{proposed}' already registered on transport '{transport:?}'; \
914 pick a new address (rebind a different port / use a different name) and retry"
915 )));
916 }
917 for _ in 0..MINT_ATTEMPTS {
918 let candidate = format!("{proposed}__{}", short_uuid());
924 if !collides(&candidate) {
925 return Ok(candidate);
926 }
927 }
928 Err(Status::internal(format!(
929 "could not disambiguate '{proposed}' on transport '{transport:?}' \
930 after {MINT_ATTEMPTS} attempts (existing providers: {})",
931 state.providers.len()
932 )))
933}
934
935#[cfg(test)]
936mod endpoint_tests {
937 use super::*;
938
939 fn ros2_params() -> pb::TransportParams {
940 pb::TransportParams {
941 kind: Some(pb::transport_params::Kind::Ros2(pb::Ros2Params {
942 qos_profile: String::new(),
943 })),
944 }
945 }
946
947 fn grpc_params_for(contract_id: &str) -> pb::TransportParams {
948 let service_name = format!("robonix.contracts.{}", driver_service_name(contract_id));
949 pb::TransportParams {
950 kind: Some(pb::transport_params::Kind::Grpc(pb::GrpcParams {
951 proto_file: "robonix_contracts.proto".to_string(),
952 method: format!("/{service_name}/Driver"),
953 service_name,
954 })),
955 }
956 }
957
958 fn grpc_params() -> pb::TransportParams {
959 grpc_params_for(SHARED_LIFECYCLE_DRIVER_CONTRACT)
960 }
961
962 fn grpc_params_with(service_name: &str, method: &str) -> pb::TransportParams {
963 pb::TransportParams {
964 kind: Some(pb::transport_params::Kind::Grpc(pb::GrpcParams {
965 proto_file: "robonix_contracts.proto".to_string(),
966 service_name: service_name.to_string(),
967 method: method.to_string(),
968 })),
969 }
970 }
971
972 #[test]
973 fn namespace_mismatch_is_advisory_and_shared_contracts_are_exempt() {
974 assert!(!has_namespace_mismatch(
975 "robonix/primitive/camera",
976 "robonix/primitive/camera/rgb",
977 false
978 ));
979 assert!(has_namespace_mismatch(
980 "robonix/primitive/camera",
981 "robonix/primitive/health/stream",
982 false
983 ));
984 assert!(!has_namespace_mismatch(
985 "robonix/primitive/camera",
986 "robonix/primitive/health/stream",
987 true
988 ));
989 }
990
991 #[tokio::test]
992 async fn registration_id_changes_only_on_successful_registration_generation() {
993 let registry = AtlasRegistry::default();
994 registry
995 .register(
996 "front_camera",
997 pb::Kind::Primitive,
998 "robonix/primitive/camera",
999 "",
1000 "",
1001 )
1002 .await
1003 .expect("initial registration");
1004 let initial = registry
1005 .query(
1006 "front_camera",
1007 pb::Kind::Unspecified,
1008 "",
1009 Transport::Unspecified,
1010 )
1011 .await
1012 .pop()
1013 .expect("initial provider")
1014 .registration_id;
1015 assert!(!initial.is_empty());
1016
1017 registry.heartbeat("front_camera").await.expect("heartbeat");
1018 let after_heartbeat = registry
1019 .query(
1020 "front_camera",
1021 pb::Kind::Unspecified,
1022 "",
1023 Transport::Unspecified,
1024 )
1025 .await
1026 .pop()
1027 .expect("provider after heartbeat")
1028 .registration_id;
1029 assert_eq!(after_heartbeat, initial);
1030
1031 registry
1032 .register(
1033 "front_camera",
1034 pb::Kind::Primitive,
1035 "robonix/primitive/camera",
1036 "",
1037 "",
1038 )
1039 .await
1040 .expect("same-kind takeover");
1041 let takeover = registry
1042 .query(
1043 "front_camera",
1044 pb::Kind::Unspecified,
1045 "",
1046 Transport::Unspecified,
1047 )
1048 .await
1049 .pop()
1050 .expect("takeover provider")
1051 .registration_id;
1052 assert_ne!(takeover, initial);
1053
1054 registry
1055 .register(
1056 "front_camera",
1057 pb::Kind::Service,
1058 "robonix/service/camera",
1059 "",
1060 "",
1061 )
1062 .await
1063 .expect_err("cross-kind takeover must fail");
1064 let after_rejection = registry
1065 .query(
1066 "front_camera",
1067 pb::Kind::Unspecified,
1068 "",
1069 Transport::Unspecified,
1070 )
1071 .await
1072 .pop()
1073 .expect("provider after rejected takeover")
1074 .registration_id;
1075 assert_eq!(after_rejection, takeover);
1076 }
1077
1078 #[tokio::test]
1079 async fn declare_accepts_namespace_mismatch_and_surfaces_diagnostic() {
1080 let registry = AtlasRegistry::default();
1081 registry
1082 .register(
1083 "front_camera",
1084 pb::Kind::Primitive,
1085 "robonix/primitive/camera",
1086 "",
1087 "",
1088 )
1089 .await
1090 .expect("provider registration");
1091
1092 registry
1093 .declare(
1094 "front_camera",
1095 "robonix/primitive/health/stream",
1096 Transport::Ros2,
1097 "/camera_health",
1098 ros2_params(),
1099 "",
1100 )
1101 .await
1102 .expect("namespace mismatch must not block declaration");
1103
1104 let providers = registry
1105 .query(
1106 "front_camera",
1107 pb::Kind::Primitive,
1108 "robonix/primitive/health/stream",
1109 Transport::Ros2,
1110 )
1111 .await;
1112 assert_eq!(providers.len(), 1);
1113 assert_eq!(providers[0].capabilities.len(), 1);
1114 assert!(providers[0].capabilities[0].namespace_mismatch);
1115 }
1116
1117 #[tokio::test]
1118 async fn legacy_namespaced_driver_contract_remains_clean() {
1119 let registry = AtlasRegistry::default();
1120 registry
1121 .register(
1122 "front_camera",
1123 pb::Kind::Primitive,
1124 "robonix/primitive/camera",
1125 "",
1126 "",
1127 )
1128 .await
1129 .expect("provider registration");
1130
1131 registry
1132 .declare(
1133 "front_camera",
1134 "robonix/primitive/camera/driver",
1135 Transport::Grpc,
1136 "127.0.0.1:51010",
1137 grpc_params_for("robonix/primitive/camera/driver"),
1138 "",
1139 )
1140 .await
1141 .expect("legacy namespaced driver declaration");
1142
1143 let providers = registry
1144 .query(
1145 "front_camera",
1146 pb::Kind::Primitive,
1147 "robonix/primitive/camera/driver",
1148 Transport::Grpc,
1149 )
1150 .await;
1151 assert_eq!(providers.len(), 1);
1152 assert!(!providers[0].capabilities[0].namespace_mismatch);
1153 }
1154
1155 #[tokio::test]
1156 async fn one_provider_cannot_declare_shared_and_legacy_drivers() {
1157 let registry = AtlasRegistry::default();
1158 registry
1159 .register(
1160 "front_camera",
1161 pb::Kind::Primitive,
1162 "robonix/primitive/camera",
1163 "",
1164 "",
1165 )
1166 .await
1167 .expect("provider registration");
1168 registry
1169 .declare(
1170 "front_camera",
1171 "robonix/primitive/camera/driver",
1172 Transport::Grpc,
1173 "127.0.0.1:51011",
1174 grpc_params_for("robonix/primitive/camera/driver"),
1175 "",
1176 )
1177 .await
1178 .expect("legacy Driver declaration");
1179
1180 let error = registry
1181 .declare(
1182 "front_camera",
1183 "robonix/lifecycle/driver",
1184 Transport::Grpc,
1185 "127.0.0.1:51012",
1186 grpc_params(),
1187 "",
1188 )
1189 .await
1190 .expect_err("second lifecycle Driver must be rejected");
1191 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
1192 assert!(error.message().contains("cannot also declare"));
1193 }
1194
1195 #[tokio::test]
1196 async fn one_provider_cannot_declare_the_shared_driver_twice() {
1197 let registry = AtlasRegistry::default();
1198 registry
1199 .register(
1200 "front_camera",
1201 pb::Kind::Primitive,
1202 "robonix/primitive/camera",
1203 "",
1204 "",
1205 )
1206 .await
1207 .expect("provider registration");
1208 registry
1209 .declare(
1210 "front_camera",
1211 SHARED_LIFECYCLE_DRIVER_CONTRACT,
1212 Transport::Grpc,
1213 "127.0.0.1:51001",
1214 grpc_params(),
1215 "",
1216 )
1217 .await
1218 .expect("first shared Driver declaration");
1219
1220 let error = registry
1221 .declare(
1222 "front_camera",
1223 SHARED_LIFECYCLE_DRIVER_CONTRACT,
1224 Transport::Grpc,
1225 "127.0.0.1:51013",
1226 grpc_params(),
1227 "",
1228 )
1229 .await
1230 .expect_err("second shared Driver transport must be rejected");
1231 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
1232 assert!(error.message().contains("cannot also declare"));
1233 }
1234
1235 #[tokio::test]
1238 async fn driver_declaration_requires_the_generated_grpc_abi() {
1239 let registry = AtlasRegistry::default();
1240 for (provider_id, contract_id, port) in [
1241 ("shared_camera", SHARED_LIFECYCLE_DRIVER_CONTRACT, 51016),
1242 ("legacy_camera", "robonix/primitive/camera/driver", 51017),
1243 ] {
1244 registry
1245 .register(
1246 provider_id,
1247 pb::Kind::Primitive,
1248 "robonix/primitive/camera",
1249 "",
1250 "",
1251 )
1252 .await
1253 .expect("provider registration");
1254 let expected_service =
1255 format!("robonix.contracts.{}", driver_service_name(contract_id));
1256 let expected_method = format!("/{expected_service}/Driver");
1257 for (transport, params, failure) in [
1258 (Transport::Ros2, ros2_params(), "transport"),
1259 (
1260 Transport::Grpc,
1261 grpc_params_with("robonix.contracts.NotTheDriver", &expected_method),
1262 "service_name",
1263 ),
1264 (
1265 Transport::Grpc,
1266 grpc_params_with(&expected_service, "/robonix.contracts.NotTheDriver/Call"),
1267 "method",
1268 ),
1269 ] {
1270 let error = registry
1271 .declare(
1272 provider_id,
1273 contract_id,
1274 transport,
1275 &format!("127.0.0.1:{port}"),
1276 params,
1277 "",
1278 )
1279 .await
1280 .expect_err("malformed Driver ABI must be rejected");
1281 assert_eq!(error.code(), tonic::Code::InvalidArgument);
1282 assert!(error.message().contains(failure), "{error}");
1283 }
1284
1285 registry
1286 .declare(
1287 provider_id,
1288 contract_id,
1289 Transport::Grpc,
1290 &format!("127.0.0.1:{port}"),
1291 grpc_params_for(contract_id),
1292 "",
1293 )
1294 .await
1295 .expect("valid generated Driver ABI");
1296 registry
1297 .set_lifecycle_state(provider_id, pb::LifecycleState::StateInactive, "")
1298 .await
1299 .expect("REGISTERED to INACTIVE");
1300 registry
1301 .set_lifecycle_state(provider_id, pb::LifecycleState::StateActive, "")
1302 .await
1303 .expect("INACTIVE to ACTIVE");
1304 }
1305 }
1306
1307 #[tokio::test]
1308 async fn active_without_an_exact_driver_is_rejected_without_mutation() {
1309 let registry = AtlasRegistry::default();
1310 registry
1311 .register(
1312 "front_camera",
1313 pb::Kind::Primitive,
1314 "robonix/primitive/camera",
1315 "",
1316 "",
1317 )
1318 .await
1319 .expect("provider registration");
1320 registry
1321 .declare(
1322 "front_camera",
1323 "robonix/primitive/other/driver",
1324 Transport::Ros2,
1325 "/unrelated_driver",
1326 ros2_params(),
1327 "",
1328 )
1329 .await
1330 .expect("unrelated capability declaration remains advisory");
1331
1332 let error = registry
1333 .set_lifecycle_state(
1334 "front_camera",
1335 pb::LifecycleState::StateActive,
1336 "must not persist",
1337 )
1338 .await
1339 .expect_err("ACTIVE without an exact Driver must fail");
1340 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
1341 assert!(error.message().contains("without lifecycle Driver"));
1342
1343 let provider = registry
1344 .query(
1345 "front_camera",
1346 pb::Kind::Primitive,
1347 "",
1348 Transport::Unspecified,
1349 )
1350 .await
1351 .pop()
1352 .expect("provider after rejected transition");
1353 assert_eq!(
1354 provider.state,
1355 pb::LifecycleState::StateRegistered as i32,
1356 "the rejected state must not be stored"
1357 );
1358 assert!(provider.state_detail.is_empty());
1359 }
1360
1361 #[tokio::test]
1362 async fn shared_and_exact_legacy_drivers_both_allow_active() {
1363 let registry = AtlasRegistry::default();
1364 for (provider_id, driver_contract) in [
1365 ("shared_camera", SHARED_LIFECYCLE_DRIVER_CONTRACT),
1366 ("legacy_camera", "robonix/primitive/camera/driver"),
1367 ] {
1368 registry
1369 .register(
1370 provider_id,
1371 pb::Kind::Primitive,
1372 "robonix/primitive/camera",
1373 "",
1374 "",
1375 )
1376 .await
1377 .expect("provider registration");
1378 registry
1379 .declare(
1380 provider_id,
1381 driver_contract,
1382 Transport::Grpc,
1383 &format!(
1384 "127.0.0.1:{}",
1385 if provider_id == "shared_camera" {
1386 51002
1387 } else {
1388 51003
1389 }
1390 ),
1391 grpc_params_for(driver_contract),
1392 "",
1393 )
1394 .await
1395 .expect("Driver declaration");
1396 registry
1397 .set_lifecycle_state(provider_id, pb::LifecycleState::StateInactive, "")
1398 .await
1399 .expect("REGISTERED to INACTIVE");
1400 registry
1401 .set_lifecycle_state(provider_id, pb::LifecycleState::StateActive, "")
1402 .await
1403 .expect("INACTIVE to ACTIVE");
1404 }
1405
1406 let providers = registry
1407 .query("", pb::Kind::Primitive, "", Transport::Unspecified)
1408 .await;
1409 assert_eq!(providers.len(), 2);
1410 assert!(
1411 providers
1412 .iter()
1413 .all(|provider| { provider.state == pb::LifecycleState::StateActive as i32 })
1414 );
1415 }
1416
1417 #[tokio::test]
1418 async fn illegal_transition_is_rejected_without_mutation() {
1419 let registry = AtlasRegistry::default();
1420 registry
1421 .register(
1422 "front_camera",
1423 pb::Kind::Primitive,
1424 "robonix/primitive/camera",
1425 "",
1426 "",
1427 )
1428 .await
1429 .expect("provider registration");
1430 registry
1431 .declare(
1432 "front_camera",
1433 SHARED_LIFECYCLE_DRIVER_CONTRACT,
1434 Transport::Grpc,
1435 "127.0.0.1:51004",
1436 grpc_params(),
1437 "",
1438 )
1439 .await
1440 .expect("shared Driver declaration");
1441
1442 let error = registry
1443 .set_lifecycle_state(
1444 "front_camera",
1445 pb::LifecycleState::StateActive,
1446 "must not persist",
1447 )
1448 .await
1449 .expect_err("REGISTERED to ACTIVE must fail");
1450 assert_eq!(error.code(), tonic::Code::FailedPrecondition);
1451 assert!(error.message().contains("illegal lifecycle transition"));
1452
1453 let provider = registry
1454 .query(
1455 "front_camera",
1456 pb::Kind::Primitive,
1457 "",
1458 Transport::Unspecified,
1459 )
1460 .await
1461 .pop()
1462 .expect("provider after rejected transition");
1463 assert_eq!(provider.state, pb::LifecycleState::StateRegistered as i32);
1464 assert!(provider.state_detail.is_empty());
1465 }
1466
1467 #[tokio::test]
1468 async fn multiple_providers_can_implement_the_same_contract() {
1469 let registry = AtlasRegistry::default();
1470 for (provider_id, endpoint) in [
1471 ("front_camera", "127.0.0.1:51014"),
1472 ("rear_camera", "127.0.0.1:51015"),
1473 ] {
1474 registry
1475 .register(
1476 provider_id,
1477 pb::Kind::Primitive,
1478 "robonix/primitive/camera",
1479 "",
1480 "",
1481 )
1482 .await
1483 .expect("provider registration");
1484 registry
1485 .declare(
1486 provider_id,
1487 "robonix/primitive/camera/driver",
1488 Transport::Grpc,
1489 endpoint,
1490 grpc_params_for("robonix/primitive/camera/driver"),
1491 "",
1492 )
1493 .await
1494 .expect("shared contract declaration");
1495 }
1496
1497 let providers = registry
1498 .query(
1499 "",
1500 pb::Kind::Primitive,
1501 "robonix/primitive/camera/driver",
1502 Transport::Grpc,
1503 )
1504 .await;
1505 assert_eq!(providers.len(), 2);
1506 assert!(providers.iter().all(|provider| {
1507 provider.capabilities.len() == 1 && !provider.capabilities[0].namespace_mismatch
1508 }));
1509 }
1510
1511 #[tokio::test]
1512 async fn cross_namespace_contract_suppresses_the_diagnostic() {
1513 let root = std::env::temp_dir().join(format!(
1514 "robonix-atlas-cross-namespace-{}",
1515 uuid::Uuid::new_v4()
1516 ));
1517 std::fs::create_dir_all(&root).expect("create temporary capability root");
1518 std::fs::write(
1519 root.join("health.toml"),
1520 r#"[contract]
1521id = "robonix/primitive/health/stream"
1522cross_namespace = true
1523"#,
1524 )
1525 .expect("write shared contract fixture");
1526 let contracts = ContractRegistry::load_from_capabilities_dir(&root)
1527 .expect("load shared contract fixture");
1528 std::fs::remove_dir_all(&root).expect("remove temporary capability root");
1529
1530 let registry = AtlasRegistry::with_contracts(contracts);
1531 registry
1532 .register(
1533 "front_camera",
1534 pb::Kind::Primitive,
1535 "robonix/primitive/camera",
1536 "",
1537 "",
1538 )
1539 .await
1540 .expect("provider registration");
1541 registry
1542 .declare(
1543 "front_camera",
1544 "robonix/primitive/health/stream",
1545 Transport::Ros2,
1546 "/camera_health",
1547 ros2_params(),
1548 "",
1549 )
1550 .await
1551 .expect("cross-namespace contract declaration");
1552
1553 let providers = registry
1554 .query(
1555 "front_camera",
1556 pb::Kind::Primitive,
1557 "robonix/primitive/health/stream",
1558 Transport::Ros2,
1559 )
1560 .await;
1561 assert_eq!(providers.len(), 1);
1562 assert!(!providers[0].capabilities[0].namespace_mismatch);
1563 }
1564
1565 #[test]
1566 fn ros2_collision_rewrite_never_appends_private_name_marker() {
1567 let mut state = State::default();
1568 state.providers.insert(
1569 "chassis".to_string(),
1570 CapabilityProviderState {
1571 id: "chassis".to_string(),
1572 registration_id: Uuid::new_v4().to_string(),
1573 kind: pb::Kind::Primitive,
1574 namespace: "robonix/primitive/chassis".to_string(),
1575 capability_md_path: String::new(),
1576 capability_md: String::new(),
1577 last_heartbeat_ms: 0,
1578 endpoints: vec![DeclaredEndpoint {
1579 contract_id: "robonix/primitive/chassis/odom".to_string(),
1580 transport: Transport::Ros2,
1581 endpoint: "/odom".to_string(),
1582 params: TransportParamsState::Ros2 {
1583 qos_profile: "reliable".to_string(),
1584 },
1585 description: String::new(),
1586 namespace_mismatch: false,
1587 }],
1588 pushed_state: None,
1589 state_detail: String::new(),
1590 },
1591 );
1592
1593 let rewritten = resolve_endpoint(
1594 &state,
1595 Transport::Ros2,
1596 "/odom",
1597 "robonix/service/map/odom",
1598 "mapping",
1599 )
1600 .expect("ROS 2 collision should be mintable");
1601
1602 assert!(rewritten.starts_with("/odom__"), "{rewritten}");
1603 assert!(!rewritten.contains('~'), "{rewritten}");
1604 assert_eq!(rewritten.len(), "/odom__".len() + 8);
1605 }
1606}
1607
1608#[derive(Debug)]
1609pub struct AtlasService {
1610 registry: Arc<AtlasRegistry>,
1611}
1612
1613impl AtlasService {
1614 pub fn new(registry: Arc<AtlasRegistry>) -> Self {
1615 Self { registry }
1616 }
1617}
1618
1619#[tonic::async_trait]
1620impl pb::atlas_server::Atlas for AtlasService {
1621 async fn register_primitive(
1622 &self,
1623 req: Request<pb::RegisterRequest>,
1624 ) -> Result<Response<pb::RegisterResponse>, Status> {
1625 let r = req.into_inner();
1626 let id = self
1627 .registry
1628 .register(
1629 &r.id,
1630 pb::Kind::Primitive,
1631 &r.namespace,
1632 &r.capability_md_path,
1633 &r.capability_md,
1634 )
1635 .await?;
1636 Ok(Response::new(pb::RegisterResponse { id }))
1637 }
1638
1639 async fn register_service(
1640 &self,
1641 req: Request<pb::RegisterRequest>,
1642 ) -> Result<Response<pb::RegisterResponse>, Status> {
1643 let r = req.into_inner();
1644 let id = self
1645 .registry
1646 .register(
1647 &r.id,
1648 pb::Kind::Service,
1649 &r.namespace,
1650 &r.capability_md_path,
1651 &r.capability_md,
1652 )
1653 .await?;
1654 Ok(Response::new(pb::RegisterResponse { id }))
1655 }
1656
1657 async fn register_skill(
1658 &self,
1659 req: Request<pb::RegisterRequest>,
1660 ) -> Result<Response<pb::RegisterResponse>, Status> {
1661 let r = req.into_inner();
1662 let id = self
1663 .registry
1664 .register(
1665 &r.id,
1666 pb::Kind::Skill,
1667 &r.namespace,
1668 &r.capability_md_path,
1669 &r.capability_md,
1670 )
1671 .await?;
1672 Ok(Response::new(pb::RegisterResponse { id }))
1673 }
1674
1675 async fn unregister(
1676 &self,
1677 req: Request<pb::UnregisterRequest>,
1678 ) -> Result<Response<pb::UnregisterResponse>, Status> {
1679 let r = req.into_inner();
1680 let was_present = self.registry.unregister(&r.id).await;
1681 Ok(Response::new(pb::UnregisterResponse { was_present }))
1682 }
1683
1684 async fn heartbeat(
1685 &self,
1686 req: Request<pb::HeartbeatRequest>,
1687 ) -> Result<Response<pb::HeartbeatResponse>, Status> {
1688 let r = req.into_inner();
1689 self.registry.heartbeat(&r.id).await?;
1690 Ok(Response::new(pb::HeartbeatResponse {}))
1691 }
1692
1693 async fn set_lifecycle_state(
1694 &self,
1695 req: Request<pb::SetLifecycleStateRequest>,
1696 ) -> Result<Response<pb::SetLifecycleStateResponse>, Status> {
1697 let r = req.into_inner();
1698 let new_state = pb::LifecycleState::try_from(r.state).map_err(|_| {
1699 Status::invalid_argument(format!("unknown LifecycleState value: {}", r.state))
1700 })?;
1701 let prev = self
1702 .registry
1703 .set_lifecycle_state(&r.id, new_state, &r.detail)
1704 .await?;
1705 Ok(Response::new(pb::SetLifecycleStateResponse {
1706 previous_state: prev as i32,
1707 }))
1708 }
1709
1710 async fn declare_capability(
1711 &self,
1712 req: Request<pb::DeclareCapabilityRequest>,
1713 ) -> Result<Response<pb::DeclareCapabilityResponse>, Status> {
1714 let r = req.into_inner();
1715 let transport = parse_transport(r.transport)?;
1716 let endpoint = self
1717 .registry
1718 .declare(
1719 &r.provider_id,
1720 &r.contract_id,
1721 transport,
1722 &r.endpoint,
1723 r.params.unwrap_or_default(),
1724 &r.description,
1725 )
1726 .await?;
1727 Ok(Response::new(pb::DeclareCapabilityResponse { endpoint }))
1728 }
1729
1730 async fn query(
1731 &self,
1732 req: Request<pb::QueryRequest>,
1733 ) -> Result<Response<pb::QueryResponse>, Status> {
1734 let r = req.into_inner();
1735 let transport = Transport::try_from(r.transport).unwrap_or(Transport::Unspecified);
1736 let kind = pb::Kind::try_from(r.kind).unwrap_or(pb::Kind::Unspecified);
1737 let providers = self
1738 .registry
1739 .query_with_prefix(&r.id, kind, &r.contract_id, &r.namespace_prefix, transport)
1740 .await;
1741 Ok(Response::new(pb::QueryResponse { providers }))
1742 }
1743
1744 async fn connect_capability(
1745 &self,
1746 req: Request<pb::ConnectCapabilityRequest>,
1747 ) -> Result<Response<pb::ConnectCapabilityResponse>, Status> {
1748 let r = req.into_inner();
1749 let transport = parse_transport(r.transport)?;
1750 let (channel_id, endpoint, params) = self
1751 .registry
1752 .connect(&r.consumer_id, &r.provider_id, &r.contract_id, transport)
1753 .await?;
1754 Ok(Response::new(pb::ConnectCapabilityResponse {
1755 channel_id,
1756 endpoint,
1757 params: Some(params),
1758 }))
1759 }
1760
1761 async fn disconnect_capability(
1762 &self,
1763 req: Request<pb::DisconnectCapabilityRequest>,
1764 ) -> Result<Response<pb::DisconnectCapabilityResponse>, Status> {
1765 let r = req.into_inner();
1766 let was_open = self.registry.disconnect(&r.channel_id).await;
1767 Ok(Response::new(pb::DisconnectCapabilityResponse { was_open }))
1768 }
1769
1770 async fn inspect_atlas(
1771 &self,
1772 _req: Request<pb::InspectAtlasRequest>,
1773 ) -> Result<Response<pb::InspectAtlasResponse>, Status> {
1774 let json = self.registry.inspect_json().await?;
1775 Ok(Response::new(pb::InspectAtlasResponse { json }))
1776 }
1777
1778 async fn query_contract(
1779 &self,
1780 req: Request<pb::QueryContractRequest>,
1781 ) -> Result<Response<pb::QueryContractResponse>, Status> {
1782 let r = req.into_inner();
1783 let id = r.contract_id.trim();
1784 if id.is_empty() {
1785 return Err(Status::invalid_argument("contract_id required"));
1786 }
1787 let resp = match self.registry.contracts().get(id) {
1788 Some(d) => pb::QueryContractResponse {
1789 contract: Some(d.clone()),
1790 found: true,
1791 },
1792 None => pb::QueryContractResponse {
1793 contract: Some(pb::ContractDescriptor::default()),
1794 found: false,
1795 },
1796 };
1797 Ok(Response::new(resp))
1798 }
1799
1800 async fn list_contracts(
1801 &self,
1802 req: Request<pb::ListContractsRequest>,
1803 ) -> Result<Response<pb::ListContractsResponse>, Status> {
1804 let r = req.into_inner();
1805 let contracts = self
1806 .registry
1807 .contracts()
1808 .list_with_prefix(&r.namespace_prefix);
1809 Ok(Response::new(pb::ListContractsResponse { contracts }))
1810 }
1811}
1812
1813const DEFAULT_EVICTION_INTERVAL_MS: u64 = 10_000; const DEFAULT_HEARTBEAT_TIMEOUT_MS: u64 = 90_000; const DEFAULT_GC_AFTER_TERMINATED_MS: u64 = 600_000; fn read_env_u64(name: &str, default: u64) -> u64 {
1818 std::env::var(name)
1819 .ok()
1820 .and_then(|s| s.parse().ok())
1821 .unwrap_or(default)
1822}
1823
1824async fn eviction_loop(
1834 registry: Arc<AtlasRegistry>,
1835 timeout_ms: u64,
1836 gc_after_ms: u64,
1837 interval_ms: u64,
1838) {
1839 if timeout_ms == 0 {
1840 info!("[atlas] heartbeat eviction disabled (timeout=0)");
1841 return;
1842 }
1843 info!(
1844 "[atlas] heartbeat eviction: terminate-after={timeout_ms}ms \
1845 gc-after-terminated={gc_after_ms}ms interval={interval_ms}ms"
1846 );
1847 let interval = std::time::Duration::from_millis(interval_ms);
1848 loop {
1849 tokio::time::sleep(interval).await;
1850 let now = AtlasRegistry::now_ms();
1851 let mut state = registry.inner.write().await;
1852
1853 let lapsed: Vec<String> = state
1855 .providers
1856 .iter()
1857 .filter(|(_, provider)| {
1858 now.saturating_sub(provider.last_heartbeat_ms) > timeout_ms
1859 && provider.state() != pb::LifecycleState::StateTerminated
1860 })
1861 .map(|(id, _)| id.clone())
1862 .collect();
1863 for id in &lapsed {
1864 if let Some(provider) = state.providers.get_mut(id) {
1865 provider.pushed_state = Some(pb::LifecycleState::StateTerminated);
1866 provider.state_detail = format!("heartbeat lapsed > {timeout_ms}ms");
1867 }
1868 let dropped = state.drop_channels_of(id);
1869 warn!(
1870 "[atlas] '{id}' → TERMINATED (heartbeat lapsed > {timeout_ms}ms, \
1871 channels_dropped={dropped})"
1872 );
1873 }
1874
1875 let stale: Vec<String> = state
1877 .providers
1878 .iter()
1879 .filter(|(_, provider)| {
1880 provider.state() == pb::LifecycleState::StateTerminated
1881 && now.saturating_sub(provider.last_heartbeat_ms) > timeout_ms + gc_after_ms
1882 })
1883 .map(|(id, _)| id.clone())
1884 .collect();
1885 for id in &stale {
1886 state.providers.remove(id);
1887 warn!("[atlas] '{id}' GC'd from registry (TERMINATED > {gc_after_ms}ms)");
1888 }
1889 }
1890}
1891
1892pub async fn serve_atlas(registry: Arc<AtlasRegistry>, listen: SocketAddr) -> Result<()> {
1897 let timeout_ms = read_env_u64(
1898 "ROBONIX_ATLAS_HEARTBEAT_TIMEOUT_MS",
1899 DEFAULT_HEARTBEAT_TIMEOUT_MS,
1900 );
1901 let gc_after_ms = read_env_u64(
1902 "ROBONIX_ATLAS_GC_AFTER_TERMINATED_MS",
1903 DEFAULT_GC_AFTER_TERMINATED_MS,
1904 );
1905 let interval_ms = read_env_u64(
1906 "ROBONIX_ATLAS_EVICTION_INTERVAL_MS",
1907 DEFAULT_EVICTION_INTERVAL_MS,
1908 );
1909 let _eviction_task = tokio::spawn(eviction_loop(
1910 Arc::clone(®istry),
1911 timeout_ms,
1912 gc_after_ms,
1913 interval_ms,
1914 ));
1915
1916 let svc = AtlasService::new(Arc::clone(®istry));
1917
1918 info!("[atlas] gRPC listening on {listen}");
1919 tonic::transport::Server::builder()
1920 .add_service(pb::atlas_server::AtlasServer::new(svc))
1921 .serve(listen)
1922 .await
1923 .context("Atlas server failed")?;
1924 Ok(())
1925}