Skip to main content

robonix_atlas/
contract_registry.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Contract registry — loads `<robonix_source>/capabilities/**/*.toml` at
5// atlas startup and serves their parsed metadata to clients via
6// QueryContract / ListContracts. Clients no longer walk the filesystem
7// or parse contract TOMLs themselves; atlas is the single source of
8// truth for "what contracts exist and what's their wire shape".
9//
10// Scope is deliberately small: only the fields current TOMLs actually
11// carry (`[contract]` id/version/kind/cross_namespace/llm_callable, `[mode]` type,
12// `[io.msg].msg`, `[io.srv].srv`). Richer metadata (summary / examples / safety /
13// capability-card-style fields) waits until the TOML schema grows.
14
15use anyhow::Context;
16use robonix_scribe::{info, warn};
17use serde::Deserialize;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use walkdir::WalkDir;
21
22use robonix_codegen::codegen::msg_parser::{
23    MsgResolver, MsgSpec, MsgTypeRef, ResolveContext, parse_ridl_type_ref,
24};
25
26use crate::pb;
27
28#[derive(Debug, Deserialize)]
29struct RawContract {
30    contract: ContractSection,
31    #[serde(default)]
32    mode: Option<ModeSection>,
33    #[serde(default)]
34    io: Option<IoSection>,
35}
36
37#[derive(Debug, Deserialize)]
38struct ContractSection {
39    id: String,
40    #[serde(default)]
41    version: Option<String>,
42    #[serde(default)]
43    kind: Option<String>,
44    /// Lib-relative IDL path (with extension), e.g. `sensor_msgs/msg/Image.msg`
45    /// or `pilot/srv/SubmitTask.srv`. Source of truth in the post-migration
46    /// schema; `[io.msg]` / `[io.srv]` are legacy and only honoured when
47    /// `idl` is absent so old TOMLs keep loading until they're rewritten.
48    #[serde(default)]
49    idl: Option<String>,
50    /// Generic one-line natural-language description for this contract
51    /// (what it does in the abstract). Consumers MERGE this with each
52    /// CapabilityProvider's instance-specific
53    /// `DeclareCapabilityRequest.description` at consume time -- the
54    /// two are complementary (generic + instance-specific), not
55    /// alternatives.
56    #[serde(default)]
57    description: Option<String>,
58    /// Shared framework contracts can be implemented by providers whose
59    /// primary namespace differs from this contract's id prefix.
60    #[serde(default)]
61    cross_namespace: bool,
62    /// Whether Pilot may expose this contract to its planning model. This is
63    /// presentation metadata only; Atlas registration and Executor routing
64    /// are unchanged. Existing contracts default to visible.
65    #[serde(default = "default_true")]
66    llm_callable: bool,
67}
68
69fn default_true() -> bool {
70    true
71}
72
73#[derive(Debug, Deserialize)]
74struct ModeSection {
75    #[serde(default, rename = "type")]
76    ty: Option<String>,
77}
78
79#[derive(Debug, Deserialize)]
80struct IoSection {
81    #[serde(default)]
82    msg: Option<MsgSubsection>,
83    #[serde(default)]
84    srv: Option<SrvSubsection>,
85}
86
87#[derive(Debug, Deserialize)]
88struct MsgSubsection {
89    #[serde(default)]
90    msg: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
94struct SrvSubsection {
95    #[serde(default)]
96    srv: Option<String>,
97}
98
99/// In-memory contract metadata. Built once at startup; never mutated
100/// while atlas runs. Wrapped in `Arc<ContractRegistry>` so handlers can
101/// read it without locks.
102#[derive(Debug, Default)]
103pub struct ContractRegistry {
104    by_id: HashMap<String, pb::ContractDescriptor>,
105}
106
107impl ContractRegistry {
108    /// Walk `<root>/**/*.toml` for one root and load it into the registry.
109    /// Convenience wrapper around `load_from_capability_roots(&[root])`.
110    pub fn load_from_capabilities_dir(root: &Path) -> anyhow::Result<Self> {
111        Self::load_from_capability_roots(std::slice::from_ref(&root))
112    }
113
114    /// Walk every `<root>/**/*.toml` across all `roots` (in order) and
115    /// merge the parsed `ContractDescriptor`s into one registry. Later
116    /// roots override earlier ones on duplicate `[contract].id`, which
117    /// matches the package-merge semantics: per-package
118    /// `<pkg>/capabilities/` can re-declare a contract from the global
119    /// `<robonix_source>/capabilities/`. Symlinks are followed so msg/srv
120    /// directories that live in `interfaces/lib/...` are reachable when
121    /// they are linked under `capabilities/`.
122    ///
123    /// After all TOMLs are loaded, this also indexes every `.msg`/`.srv`
124    /// under `<root>/lib/**/*` and tries to attach top-level field
125    /// schemas to each contract whose io_msg_type / io_srv_type points
126    /// at a known IDL. Failures here are non-fatal (the contract is
127    /// still served, just without field-level introspection).
128    ///
129    /// Malformed TOMLs and `*.toml` files without a `[contract].id` are
130    /// logged and skipped — one bad file must not take atlas down.
131    pub fn load_from_capability_roots(roots: &[&Path]) -> anyhow::Result<Self> {
132        let mut by_id: HashMap<String, pb::ContractDescriptor> = HashMap::new();
133        let mut total_roots_walked = 0usize;
134        for root in roots {
135            if !root.exists() {
136                warn!(
137                    "[atlas] contract registry: capabilities root missing: {} \
138                     (skipping)",
139                    root.display()
140                );
141                continue;
142            }
143            total_roots_walked += 1;
144            let mut loaded_from_root = 0usize;
145            for entry in WalkDir::new(root)
146                .follow_links(true)
147                .into_iter()
148                .filter_entry(|e| {
149                    // Hard convention: `<capabilities>/lib/` holds only
150                    // ROS msg/srv source for IDL codegen. Skip it so any
151                    // stray .toml under lib/ never lands in the contract
152                    // registry.
153                    !(e.file_type().is_dir() && e.file_name() == "lib" && e.depth() > 0)
154                })
155                .filter_map(|e| e.ok())
156            {
157                if !entry.file_type().is_file() {
158                    continue;
159                }
160                let path = entry.path();
161                if path.extension().and_then(|s| s.to_str()) != Some("toml") {
162                    continue;
163                }
164                match load_one(path) {
165                    Ok(desc) => {
166                        let id = desc.id.clone();
167                        if let Some(prev) = by_id.insert(id.clone(), desc) {
168                            warn!(
169                                "[atlas] contract registry: duplicate id '{id}' \
170                                 (was {}, now {}); keeping latest",
171                                prev.source_toml_path,
172                                path.display()
173                            );
174                        }
175                        loaded_from_root += 1;
176                    }
177                    Err(e) => warn!("[atlas] contract registry: skip {} ({e:#})", path.display()),
178                }
179            }
180            info!(
181                "[atlas] contract registry: {} contracts from {}",
182                loaded_from_root,
183                root.display()
184            );
185        }
186        info!(
187            "[atlas] contract registry: total {} unique contracts across {} root(s)",
188            by_id.len(),
189            total_roots_walked
190        );
191
192        attach_idl_fields(&mut by_id, roots);
193
194        Ok(Self { by_id })
195    }
196
197    pub fn get(&self, contract_id: &str) -> Option<&pb::ContractDescriptor> {
198        self.by_id.get(contract_id)
199    }
200
201    /// Return all contracts whose id starts with `prefix`. Empty prefix
202    /// returns every contract.
203    pub fn list_with_prefix(&self, prefix: &str) -> Vec<pb::ContractDescriptor> {
204        let prefix = prefix.trim();
205        let mut out: Vec<pb::ContractDescriptor> = self
206            .by_id
207            .values()
208            .filter(|c| prefix.is_empty() || c.id.starts_with(prefix))
209            .cloned()
210            .collect();
211        out.sort_by(|a, b| a.id.cmp(&b.id));
212        out
213    }
214
215    pub fn len(&self) -> usize {
216        self.by_id.len()
217    }
218
219    pub fn is_empty(&self) -> bool {
220        self.by_id.is_empty()
221    }
222}
223
224/// Derive (io_msg_type, io_srv_type) from a parsed contract toml. New
225/// schema: `[contract].idl = "<pkg>/(msg|srv)/<Name>.<ext>"`. Old
226/// schema (pre-migration): `[io.msg].msg = "..."` / `[io.srv].srv = "..."`.
227/// `idl` wins when both are present; old form is the fallback so TOMLs
228/// that haven't been rewritten still load.
229fn io_types_from_parsed(parsed: &RawContract) -> (String, String) {
230    if let Some(idl_raw) = parsed.contract.idl.as_deref() {
231        let idl = idl_raw.trim();
232        if !idl.is_empty()
233            && let Some((pkg, kind, name)) = parse_idl_path(idl)
234        {
235            let composed = format!("{pkg}/{kind}/{name}");
236            return match kind {
237                "msg" => (composed, String::new()),
238                "srv" => (String::new(), composed),
239                _ => (String::new(), String::new()),
240            };
241        }
242    }
243    match &parsed.io {
244        Some(io) => {
245            let msg = io
246                .msg
247                .as_ref()
248                .and_then(|m| m.msg.as_deref())
249                .map(|s| s.trim().to_string())
250                .unwrap_or_default();
251            let srv = io
252                .srv
253                .as_ref()
254                .and_then(|s| s.srv.as_deref())
255                .map(|s| s.trim().to_string())
256                .unwrap_or_default();
257            (msg, srv)
258        }
259        None => (String::new(), String::new()),
260    }
261}
262
263/// Parse a lib-relative IDL path like `sensor_msgs/msg/Image.msg`
264/// into (pkg, kind, name). Mirrors the codegen-side parser
265/// (`robonix-codegen::contract_gen::parse_idl_path`) but lives here so
266/// atlas doesn't need a build-dep on the full codegen crate just for one
267/// six-line helper.
268fn parse_idl_path(s: &str) -> Option<(&str, &'static str, &str)> {
269    let (stem, kind): (&str, &'static str) = if let Some(rest) = s.strip_suffix(".srv") {
270        (rest, "srv")
271    } else {
272        let rest = s.strip_suffix(".msg")?;
273        (rest, "msg")
274    };
275    let parts: Vec<&str> = stem.split('/').filter(|p| !p.is_empty()).collect();
276    if parts.is_empty() {
277        return None;
278    }
279    let n = parts.len();
280    let name = parts[n - 1];
281    let pkg = if n >= 3 && (parts[n - 2] == "srv" || parts[n - 2] == "msg") {
282        parts[n - 3]
283    } else {
284        ""
285    };
286    Some((pkg, kind, name))
287}
288
289fn load_one(path: &Path) -> anyhow::Result<pb::ContractDescriptor> {
290    let raw = std::fs::read_to_string(path)
291        .with_context(|| format!("read contract toml: {}", path.display()))?;
292    let parsed: RawContract =
293        toml::from_str(&raw).with_context(|| format!("parse contract toml: {}", path.display()))?;
294    let id = parsed.contract.id.trim().to_string();
295    if id.is_empty() {
296        anyhow::bail!("[contract].id is empty");
297    }
298    let (io_msg_type, io_srv_type) = io_types_from_parsed(&parsed);
299    let version = parsed
300        .contract
301        .version
302        .map(|s| s.trim().to_string())
303        .unwrap_or_default();
304    let kind_str = parsed
305        .contract
306        .kind
307        .map(|s| s.trim().to_string())
308        .unwrap_or_default();
309    let kind = match kind_str.as_str() {
310        "primitive" => pb::Kind::Primitive,
311        "service" => pb::Kind::Service,
312        "skill" => pb::Kind::Skill,
313        "" => pb::Kind::Unspecified,
314        other => {
315            return Err(anyhow::anyhow!(
316                "contract '{id}': unknown kind '{other}' (want primitive|service|skill)"
317            ));
318        }
319    };
320    let mode = parsed
321        .mode
322        .and_then(|m| m.ty)
323        .map(|s| s.trim().to_string())
324        .unwrap_or_default();
325    let description = parsed
326        .contract
327        .description
328        .map(|s| s.trim().to_string())
329        .unwrap_or_default();
330    Ok(pb::ContractDescriptor {
331        id,
332        version,
333        kind: kind as i32,
334        mode,
335        io_msg_type,
336        io_srv_type,
337        source_toml_path: path.to_string_lossy().into_owned(),
338        description,
339        cross_namespace: parsed.contract.cross_namespace,
340        llm_callable: Some(parsed.contract.llm_callable),
341        // Filled later by attach_idl_fields() after every TOML has
342        // been loaded. Empty here is the right default.
343        msg_fields: Vec::new(),
344        srv_request_fields: Vec::new(),
345        srv_response_fields: Vec::new(),
346    })
347}
348
349/// After all TOMLs are loaded, walk every `<root>/lib/**/*.{msg,srv}`
350/// and attach top-level field schemas to each contract whose
351/// `io_msg_type` / `io_srv_type` resolves to a known IDL.
352///
353/// Failures are logged-and-skipped: a contract with no resolvable IDL
354/// (e.g. type "X" not present in any `lib/`) just keeps its empty
355/// `msg_fields` / `srv_*_fields`. Atlas startup must not fail because
356/// of a single missing `.msg`.
357fn attach_idl_fields(by_id: &mut HashMap<String, pb::ContractDescriptor>, roots: &[&Path]) {
358    // The msg_parser indexes from `include_paths`. For each capability
359    // root, the IDL files live under `<root>/lib`; everything else
360    // under the root is either contract TOMLs or non-IDL data.
361    let lib_paths: Vec<PathBuf> = roots
362        .iter()
363        .map(|r| r.join("lib"))
364        .filter(|p| p.exists())
365        .collect();
366    if lib_paths.is_empty() {
367        info!("[atlas] contract registry: no <root>/lib/ found — skipping IDL field attachment");
368        return;
369    }
370    let mut resolver = match MsgResolver::new(&lib_paths) {
371        Ok(r) => r,
372        Err(e) => {
373            warn!(
374                "[atlas] contract registry: MsgResolver init failed ({e:#}); \
375                 contracts will have no field-level schema"
376            );
377            return;
378        }
379    };
380    let mut msg_filled = 0usize;
381    let mut srv_filled = 0usize;
382    let mut msg_missing = 0usize;
383    let mut srv_missing = 0usize;
384    for desc in by_id.values_mut() {
385        if !desc.io_msg_type.is_empty() {
386            match resolve_msg_fields(&mut resolver, &desc.io_msg_type) {
387                Ok(fields) => {
388                    desc.msg_fields = fields;
389                    msg_filled += 1;
390                }
391                Err(e) => {
392                    warn!(
393                        "[atlas] contract registry: IDL resolve failed for \
394                         contract '{}' io_msg_type='{}': {e:#}",
395                        desc.id, desc.io_msg_type
396                    );
397                    msg_missing += 1;
398                }
399            }
400        }
401        if !desc.io_srv_type.is_empty() {
402            match resolve_srv_fields(&mut resolver, &desc.io_srv_type) {
403                Ok((req, resp)) => {
404                    desc.srv_request_fields = req;
405                    desc.srv_response_fields = resp;
406                    srv_filled += 1;
407                }
408                Err(e) => {
409                    warn!(
410                        "[atlas] contract registry: IDL resolve failed for \
411                         contract '{}' io_srv_type='{}': {e:#}",
412                        desc.id, desc.io_srv_type
413                    );
414                    srv_missing += 1;
415                }
416            }
417        }
418    }
419    info!(
420        "[atlas] contract registry: IDL fields — msg {msg_filled} ok / {msg_missing} missing, \
421         srv {srv_filled} ok / {srv_missing} missing"
422    );
423}
424
425/// Look up the .msg file for "pkg/msg/Name" (ROS-style fully-qualified
426/// type ref) and convert its top-level fields into the wire schema.
427fn resolve_msg_fields(
428    resolver: &mut MsgResolver,
429    type_ref: &str,
430) -> anyhow::Result<Vec<pb::FieldSpec>> {
431    let (pkg, name) = parse_ridl_type_ref(type_ref)
432        .with_context(|| format!("not a fully-qualified IDL type ref: {type_ref}"))?;
433    let ctx = ResolveContext {
434        namespace: None,
435        interface_kind: Some("msg"),
436        interface_name: Some(name.clone()),
437        field_name: None,
438    };
439    resolver.resolve_named_type(&pkg, &name, Some((type_ref, &ctx)))?;
440    let spec = resolver
441        .cache
442        .get(&(pkg.clone(), name.clone()))
443        .with_context(|| format!("MsgResolver cache miss for {pkg}/{name}"))?;
444    Ok(spec_to_field_specs(spec))
445}
446
447/// Same for "pkg/srv/Name" → (request_fields, response_fields).
448/// `parse_ridl_type_ref` accepts both `pkg/msg/Name` and
449/// `pkg/srv/Name`; we don't need a separate parser anymore.
450fn resolve_srv_fields(
451    resolver: &mut MsgResolver,
452    type_ref: &str,
453) -> anyhow::Result<(Vec<pb::FieldSpec>, Vec<pb::FieldSpec>)> {
454    let (pkg, name) = parse_ridl_type_ref(type_ref)
455        .with_context(|| format!("not a fully-qualified srv type ref: {type_ref}"))?;
456    let key = (pkg.clone(), name.clone());
457    if !resolver.srv_cache.contains_key(&key) {
458        let path = resolver
459            .srv_index
460            .get(&key)
461            .cloned()
462            .with_context(|| format!("MsgResolver srv_index has no entry for {pkg}/{name}"))?;
463        let parsed = robonix_codegen::codegen::msg_parser::parse_srv_file(&pkg, &name, &path)?;
464        resolver.srv_cache.insert(key.clone(), parsed);
465    }
466    let spec = resolver
467        .srv_cache
468        .get(&key)
469        .with_context(|| format!("srv_cache miss for {pkg}/{name}"))?;
470    Ok((
471        spec_to_field_specs(&spec.request),
472        spec_to_field_specs(&spec.response),
473    ))
474}
475
476fn spec_to_field_specs(spec: &MsgSpec) -> Vec<pb::FieldSpec> {
477    spec.fields
478        .iter()
479        .map(|f| {
480            let (type_name, is_primitive) = match &f.type_ref {
481                MsgTypeRef::Primitive(s) => (s.clone(), true),
482                MsgTypeRef::Named { package, name } => (format!("{package}/{name}"), false),
483            };
484            pb::FieldSpec {
485                name: f.name.clone(),
486                type_name,
487                is_primitive,
488                is_array: f.is_array,
489                array_size: f.array_size.unwrap_or(0) as u32,
490            }
491        })
492        .collect()
493}
494
495/// Resolve the list of capability roots atlas should load. Priority:
496///   1. explicit CLI/env paths (any non-empty entries from
497///      `--capabilities a,b,c` or `ROBONIX_ATLAS_CAPABILITIES=a,b,c`)
498///   2. `$ROBONIX_SOURCE_PATH/capabilities` as a single fallback root
499///
500/// Returns an empty vec if nothing is configured; atlas then runs with
501/// an empty registry (handlers return found=false on every query).
502///
503/// Per-package `<pkg>/capabilities/` dirs aren't included here — those
504/// can be added at deploy time by the rbnx CLI walking installed
505/// package paths and passing the merged list via `--capabilities`.
506pub fn resolve_capabilities_roots(explicit: &[String]) -> Vec<PathBuf> {
507    let cleaned: Vec<PathBuf> = explicit
508        .iter()
509        .map(|s| s.trim())
510        .filter(|s| !s.is_empty())
511        .map(PathBuf::from)
512        .collect();
513    if !cleaned.is_empty() {
514        return cleaned;
515    }
516    if let Ok(root) = std::env::var("ROBONIX_SOURCE_PATH") {
517        let trimmed = root.trim();
518        if !trimmed.is_empty() {
519            return vec![PathBuf::from(trimmed).join("capabilities")];
520        }
521    }
522    Vec::new()
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn cross_namespace_is_explicit_and_defaults_to_false() {
531        let shared: RawContract = toml::from_str(
532            r#"
533                [contract]
534                id = "robonix/primitive/health/stream"
535                cross_namespace = true
536
537                [mode]
538                type = "topic_out"
539            "#,
540        )
541        .expect("shared contract TOML");
542        assert!(shared.contract.cross_namespace);
543
544        let regular: RawContract = toml::from_str(
545            r#"
546                [contract]
547                id = "robonix/primitive/camera/rgb"
548
549                [mode]
550                type = "topic_out"
551            "#,
552        )
553        .expect("regular contract TOML");
554        assert!(!regular.contract.cross_namespace);
555    }
556
557    #[test]
558    fn llm_callable_is_explicit_and_defaults_to_true() {
559        let hidden: RawContract = toml::from_str(
560            r#"
561                [contract]
562                id = "robonix/service/verifier/verify"
563                llm_callable = false
564
565                [mode]
566                type = "rpc"
567            "#,
568        )
569        .expect("hidden contract TOML");
570        assert!(!hidden.contract.llm_callable);
571
572        let regular: RawContract = toml::from_str(
573            r#"
574                [contract]
575                id = "robonix/service/example/run"
576
577                [mode]
578                type = "rpc"
579            "#,
580        )
581        .expect("regular contract TOML");
582        assert!(regular.contract.llm_callable);
583    }
584}