Skip to main content

robonix_atlas/
service.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Atlas — Robonix capability registry (gRPC service).
5//
6// `AtlasRegistry` owns the in-memory state and exposes typed async methods
7// for each operation. `AtlasService` is a thin facade that parses wire
8// types out of `pb::*` requests and calls the registry. The legacy
9// `RobonixRuntime` shim (see crate::legacy) calls the same registry
10// methods after translating old fields, which is how backward compat is
11// implemented without a parallel state.
12
13use 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
27/// How many times Atlas tries to mint a unique endpoint before giving up.
28const 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    /// Provider-supplied natural-language description for this
89    /// Capability. Empty means "fall back to contract default".
90    description: String,
91    /// Diagnostic only: this endpoint's contract is outside the provider's
92    /// primary namespace without an explicit cross-namespace opt-in.
93    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    /// Opaque identity for one successful registration generation. Replaced
118    /// on same-id takeover; never changed by heartbeat/state/declare calls.
119    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 content the provider sent at registration. Stored
125    /// verbatim and handed to consumers; unlike `capability_md_path` it is
126    /// portable across the provider's / consumer's filesystem boundaries.
127    capability_md: String,
128    last_heartbeat_ms: u64,
129    endpoints: Vec<DeclaredEndpoint>,
130    /// Last value reported by SetLifecycleState. Providers remain REGISTERED
131    /// until their lifecycle Driver explicitly publishes a transition.
132    #[serde(serialize_with = "serialize_pushed_state")]
133    pushed_state: Option<pb::LifecycleState>,
134    state_detail: String,
135}
136
137impl CapabilityProviderState {
138    /// Convert one of this record's endpoints to a wire `pb::Capability`,
139    /// stamping provider_id / provider_kind from the parent record.
140    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    /// Lifecycle state. The provider pushes via SetLifecycleState whenever its
176    /// on_init / on_activate / on_deactivate handler returns. Capability
177    /// declaration alone never implies successful initialization.
178    fn state(&self) -> pb::LifecycleState {
179        self.pushed_state
180            .unwrap_or(pb::LifecycleState::StateRegistered)
181    }
182
183    /// Return whether this Provider has the one shared or exact legacy
184    /// lifecycle Driver that is allowed to promote it to ACTIVE.
185    fn has_driver(&self) -> bool {
186        self.endpoints
187            .iter()
188            .any(|endpoint| is_driver_contract(&self.namespace, &endpoint.contract_id))
189    }
190}
191
192/// Whether `prev → next` matches the v0.1 lifecycle FSM (see
193/// `docs/src/architecture/provider-lifecycle.md`). `Unspecified` as `prev`
194/// means "fresh provider, never pushed state" — accept any first transition.
195fn is_legal_transition(prev: pb::LifecycleState, next: pb::LifecycleState) -> bool {
196    use pb::LifecycleState::*;
197    if next == StateError || next == StateTerminated {
198        return true; // any state may fail or shut down
199    }
200    match (prev, next) {
201        (StateUnspecified, _) => true,
202        (StateRegistered, StateInactive) => true,
203        (StateInactive, StateActive) => true,
204        (StateActive, StateInactive) => true,
205        (StateError, StateInactive) => true,
206        // self-transitions are no-ops, accept silently
207        (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
220/// Mirror codegen's stable contract-id to tonic service-name mapping for the
221/// shared Driver and exact namespace-legacy Driver compatibility path.
222fn 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
237/// Require a selected lifecycle Driver to expose the generated tonic ABI.
238/// Contract id alone is insufficient because launchers call this exact path.
239fn 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
274/// Return whether a declared contract should carry a namespace diagnostic.
275/// Namespace is advisory: a mismatch never rejects registration or calling.
276fn 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/// One open consumer→provider edge. Allocated by `ConnectCapability`,
289/// dropped by `DisconnectCapability` or when the provider unregisters /
290/// is evicted by heartbeat lapse.
291#[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    /// Drop every channel whose provider is `provider_id`. Called from
311    /// unregister / heartbeat eviction so dead providers don't leak
312    /// channel providers. Returns the number of channels dropped.
313    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/// In-memory state shared by all gRPC handlers (new + legacy). All ops go
321/// through one of the typed async methods below. `Arc<AtlasRegistry>` is
322/// cheap to clone; the interior `RwLock` serialises mutations.
323///
324/// `contracts` is loaded once at startup from `<robonix_source>/capabilities/**`
325/// and is read-only for the lifetime of the process — handlers serve
326/// QueryContract / ListContracts directly off it without locking.
327#[derive(Debug, Default)]
328pub struct AtlasRegistry {
329    pub(crate) inner: RwLock<State>,
330    contracts: ContractRegistry,
331}
332
333impl AtlasRegistry {
334    /// Construct a registry with an empty capability/channel state plus a
335    /// pre-loaded contract registry. Use this from `main.rs` once the
336    /// capabilities dir has been resolved.
337    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    /// Register a new capability instance, OR take over an existing
370    /// provider_id slot whose previous provider is gone. Empty `provider_id` triggers
371    /// Atlas-assigned ephemeral id. Returns the resolved id.
372    ///
373    /// Takeover semantics — a re-Register on an existing provider_id is NOT
374    /// an error. We assume the old process is dead (or about to be), so
375    /// we drop its endpoints + state and reset last_heartbeat to now.
376    /// The caller is then expected to redeclare capabilities with its own
377    /// fresh endpoints. Without this, an orphan provider (heartbeat eviction
378    /// hasn't fired yet — 60s default) blocks every subsequent boot of
379    /// the same package: rbnx waits for a "new" provider to appear in atlas,
380    /// the python framework only retries register once and then quietly
381    /// keeps going, and atlas keeps pointing consumers at the dead
382    /// process's endpoints. The previous "ALREADY_EXISTS" failure mode
383    /// caught accidental dual deployments but the cure was worse than
384    /// the disease — silent boot failures are nearly impossible to
385    /// diagnose. If two live processes claim the same id, both will
386    /// heartbeat into the same record and the latest declare wins; that
387    /// case shows up as scattered/mysterious endpoint flips, which is
388    /// at least visible.
389    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            // Cross-kind collision is rejected per proto contract.
406            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            // Same-kind takeover. Drop the previous provider's endpoints
413            // and pushed state; the caller will redeclare what it owns.
414            // Channels targeting dropped Capabilities are also auto-closed.
415            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    /// Idempotent: returns `true` if a record was removed, `false` if the id
451    /// was unknown. Also drops any channels where this provider was the provider —
452    /// consumers will get NOT_FOUND on their next call and can re-discover.
453    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    /// Update the provider's lifecycle state. Returns the previous value (or
469    /// the inferred fallback when nothing's been pushed yet) so callers
470    /// can log "X went INACTIVE → ACTIVE" without a separate query.
471    /// Illegal transitions are rejected without mutating the Provider. ACTIVE
472    /// additionally requires one declared shared or exact legacy Driver, so a
473    /// Provider cannot advertise readiness without a callable lifecycle path.
474    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    /// Updates `last_heartbeat_ms` to now. Returns the timestamp it set.
520    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    /// Declare ONE transport for ONE contract on a registered provider. Returns
533    /// the authoritative endpoint string (may differ from `proposed` when
534    /// Atlas rewrote it to disambiguate on a mintable transport).
535    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, &params)?;
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    /// Snapshot of registered Providers matching the given filters. Empty
607    /// `provider_id` / empty `contract` / `Transport::Unspecified` /
608    /// `Kind::Unspecified` mean "no filter on that field". Each
609    /// returned record carries only the Capabilities that satisfy the
610    /// `contract` + `transport` filters.
611    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    /// Open a channel to one (provider provider, contract, transport). Atlas
692    /// only providers the edge — the consumer dials the returned endpoint
693    /// itself (each transport has its own connect protocol; atlas can't
694    /// dial generically). Returns the allocated channel handle and the
695    /// full binding the consumer needs.
696    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    /// Idempotent: returns `true` if a channel was removed, `false` if
750    /// the id was unknown (already released, or auto-dropped when its
751    /// provider went away).
752    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    /// Return the provider's CAPABILITY.md content as registered. Returns ""
764    /// when the provider registered without one. Serves the stored text
765    /// directly — never touches the filesystem, since `capability_md_path`
766    /// is only valid in the provider's own (possibly containerised) mount
767    /// namespace, not Atlas's.
768    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    /// Debug-only JSON dump of the entire registry. Schema is unstable.
779    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
786/// Whether Atlas can mint a fresh endpoint name for this transport without
787/// requiring a prior OS-level bind by the caller. ros2 is a pure-name
788/// address space; grpc and mcp need a host:port that only the caller can
789/// produce.
790fn atlas_can_mint(transport: Transport) -> bool {
791    matches!(transport, Transport::Ros2)
792}
793
794/// Convert wire `pb::TransportParams` into the typed Rust enum and verify
795/// the `oneof` variant matches `transport`.
796fn 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
853/// Pick a globally-unique endpoint per the rules on `DeclareCapabilityRequest`.
854///
855/// "Globally unique" here means: no *other* provider may already own this
856/// `(transport, endpoint)` pair. The provider itself is allowed to expose multiple
857/// contracts on the same endpoint — that's the dominant pattern for MCP
858/// (one MCP server URL hosts many tools) and a legitimate one for gRPC
859/// (one tonic Server with multiple services). The per-provider
860/// `(contract_id, transport)` uniqueness check happens earlier in `declare`.
861fn 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        // A ROS 2 private-name marker (`~`) is only legal as the first
919        // character of a topic. Appending `~<uuid>` produced endpoints such
920        // as `/odom~9857059d`, which Atlas returned successfully but every
921        // ROS 2 client rejected. Double underscore is valid inside every ROS
922        // name token and keeps the collision suffix visually distinct.
923        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    /// A lifecycle contract id cannot promote an unrelated transport or tonic
1236    /// route into the selected Driver slot; rejected attempts store nothing.
1237    #[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; // check every 10s
1814const DEFAULT_HEARTBEAT_TIMEOUT_MS: u64 = 90_000; // mark TERMINATED after 90s
1815const DEFAULT_GC_AFTER_TERMINATED_MS: u64 = 600_000; // drop record 10 min after TERMINATED
1816
1817fn 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
1824/// Two-phase eviction:
1825///   1. heartbeat lapsed > `timeout_ms` AND state is not yet TERMINATED →
1826///      transition to TERMINATED, drop the provider's channels (so consumers
1827///      stop dialing a corpse).
1828///   2. state is TERMINATED AND last_heartbeat older than `gc_after_ms` →
1829///      remove the record entirely.
1830///
1831/// Phase 1 keeps a debug-friendly "yes that provider died, here's why" view in
1832/// `rbnx caps` for `gc_after_ms` after the lapse; phase 2 frees memory.
1833async 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        // Phase 1: lapsed → TERMINATED.
1854        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        // Phase 2: TERMINATED long enough → drop the record.
1876        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
1892/// Start the Atlas gRPC server on `listen` using the given registry.
1893/// Spawns a background heartbeat-eviction task and exposes BOTH the new
1894/// `Atlas` service and the deprecated `RobonixRuntime` shim on the same
1895/// gRPC port.
1896pub 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(&registry),
1911        timeout_ms,
1912        gc_after_ms,
1913        interval_ms,
1914    ));
1915
1916    let svc = AtlasService::new(Arc::clone(&registry));
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}