Skip to main content

rbnx/cmd/
run_package.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Run package commands: build, start (start blocks until process exits).
3//
4// Dev-packaging contract: one package has ONE top-level `start` shell body
5// (not a list of nodes). `rbnx start` just executes that body at the
6// package root — the body itself is responsible for spawning processes
7// and registering capabilities with atlas. No node-id flag.
8
9use super::build;
10use anyhow::{Context, Result};
11use robonix_atlas::client::AtlasClient;
12use robonix_atlas::pb as atlas_pb;
13use robonix_cli::Config;
14use robonix_cli::launch::{
15    CMD_ACTIVATE, CMD_INIT, ProviderRegistrationSnapshot, call_driver_cmd,
16    resolve_runtime_driver_contract, snapshot_provider_ids, wait_for_registration_core,
17};
18use robonix_cli::manifest;
19use robonix_cli::output;
20use robonix_cli::process::ProcessManager;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::Duration;
24
25/// Directory against which relative `-p` is resolved: **the pwd of the command invocation**.
26/// When `cargo run` runs from `robonix/rust`, the process cwd is not the user's shell cwd — wrappers
27/// should `export RBNX_INVOCATION_CWD="$(pwd)"` before `cd`+`cargo run`. If unset, `std::env::current_dir()` is used.
28pub(crate) const RBNX_INVOCATION_CWD: &str = "RBNX_INVOCATION_CWD";
29
30/// Return the migration warning emitted by `rbnx start` for a non-shared
31/// Driver declaration. Runtime registration later proves whether it is the
32/// provider's exact namespace legacy contract before accepting it.
33///
34/// The warning lives in `rbnx`, rather than in a language SDK, so native and
35/// non-Python providers receive the same guidance. Legacy contracts remain
36/// exact namespace legacy contracts remain supported; the warning only
37/// describes the incremental migration.
38fn lifecycle_driver_migration_warning(
39    package_name: &str,
40    explicit_driver_contract: Option<&str>,
41) -> Option<String> {
42    let Some(driver_contract) = explicit_driver_contract else {
43        // Omission is the canonical author-facing form: current codegen and
44        // the SDK automatically provide the shared lifecycle Driver.
45        return None;
46    };
47    if driver_contract == manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT {
48        return None;
49    }
50    Some(format!(
51        "package '{package_name}' declares non-shared lifecycle contract \
52         '{driver_contract}'; only the exact <provider namespace>/driver form \
53         is backward-compatible and may use a shared runtime Driver. Remove the \
54         legacy declaration to select '{}' automatically; do not declare both",
55        manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT,
56    ))
57}
58
59/// POSIX-shell single-quoted escape, used when we synthesise `export FOO=...`
60/// fragments to inject into a package's `start` body.
61fn shell_escape(value: &str) -> String {
62    format!("'{}'", value.replace('\'', "'\"'\"'"))
63}
64
65fn path_base_for_dash_p() -> Result<PathBuf> {
66    if let Ok(s) = std::env::var(RBNX_INVOCATION_CWD) {
67        Ok(PathBuf::from(s))
68    } else {
69        std::env::current_dir().context("Failed to get current directory")
70    }
71}
72
73/// Resolve `-p` to a filesystem path before `canonicalize`: relative paths and `.` use
74/// [`path_base_for_dash_p`] as the prefix (invocation pwd, or process cwd).
75pub(crate) fn resolve_local_path_for_filesystem(p: &Path) -> Result<PathBuf> {
76    if p.as_os_str() == "." || p.as_os_str() == "./" {
77        return path_base_for_dash_p();
78    }
79    if p.is_absolute() {
80        return Ok(p.to_path_buf());
81    }
82    Ok(path_base_for_dash_p()?.join(p))
83}
84
85/// Walk up from the invocation cwd looking for a directory that contains
86/// a `package_manifest.yaml`. Returns the first match.
87pub(crate) fn find_package_from_cwd() -> Result<PathBuf> {
88    let start = path_base_for_dash_p()?;
89    let mut cur: Option<&Path> = Some(&start);
90    while let Some(d) = cur {
91        if d.join(manifest::MANIFEST_FILE).is_file() {
92            return d
93                .canonicalize()
94                .with_context(|| format!("Failed to canonicalize: {}", d.display()));
95        }
96        cur = d.parent();
97    }
98    anyhow::bail!(
99        "no {} found in {} or any parent; pass -p <path> or `cd` into a package directory",
100        manifest::MANIFEST_FILE,
101        start.display()
102    )
103}
104
105/// Resolve package path from -p (local path) or -g (system-installed name).
106/// When neither is given, walk up from cwd to find a package manifest.
107fn resolve_package_path(
108    config: &Config,
109    path: Option<PathBuf>,
110    global: Option<String>,
111) -> Result<PathBuf> {
112    if let Some(p) = path {
113        let p = resolve_local_path_for_filesystem(&p)?;
114        let canonical = p
115            .canonicalize()
116            .with_context(|| format!("Failed to canonicalize: {}", p.display()))?;
117        if canonical.join(manifest::MANIFEST_FILE).exists() {
118            return Ok(canonical);
119        }
120        anyhow::bail!(
121            "Path {} does not contain {}",
122            canonical.display(),
123            manifest::MANIFEST_FILE
124        );
125    }
126
127    if let Some(name) = global {
128        let db = robonix_cli::PackageDatabase::load(&config.package_storage_path)?;
129        if let Some(pkg) = db.get_package(&name) {
130            return Ok(pkg.path.clone());
131        }
132        anyhow::bail!(
133            "Package '{}' not found in system storage ({})",
134            name,
135            config.package_storage_path.display()
136        );
137    }
138
139    find_package_from_cwd()
140}
141
142/// Resolve package path for `start`: same `-p` rules as `build`, then system-installed name fallback.
143fn resolve_package_path_for_start(config: &Config, spec: &str) -> Result<PathBuf> {
144    let path = resolve_local_path_for_filesystem(Path::new(spec))?;
145    if path.join(manifest::MANIFEST_FILE).is_file()
146        || path.join(manifest::LEGACY_MANIFEST_FILE).is_file()
147    {
148        return path
149            .canonicalize()
150            .with_context(|| format!("Failed to canonicalize: {}", path.display()));
151    }
152
153    let db = robonix_cli::PackageDatabase::load(&config.package_storage_path)?;
154    if let Some(pkg) = db.get_package(spec) {
155        return Ok(pkg.path.clone());
156    }
157
158    anyhow::bail!(
159        "Package '{}' not found at {} (relative -p uses {} or process cwd). Try -g <installed name> or export {}=\"$(pwd)\" before cargo run.",
160        spec,
161        path.display(),
162        RBNX_INVOCATION_CWD,
163        RBNX_INVOCATION_CWD
164    )
165}
166
167pub async fn execute_build(
168    config: Config,
169    file: Option<PathBuf>,
170    path: Option<PathBuf>,
171    global: Option<String>,
172    clean: bool,
173    no_update_check: bool,
174) -> Result<()> {
175    if let Some(file) = file {
176        let manifest_path = resolve_local_path_for_filesystem(&file)?;
177        if !manifest_path.is_file() {
178            anyhow::bail!("deployment manifest not found: {}", manifest_path.display());
179        }
180        return build_deploy_manifest(&manifest_path, &config, clean, no_update_check);
181    }
182
183    // Deploy-manifest mode: if `path` (or cwd, when -p is omitted)
184    // contains a `robonix_manifest.yaml`, build every primitive /
185    // service / skill entry it lists. This lets the user run
186    //   `cd examples/webots && rbnx build`
187    // and get all packages built in one shot rather than chasing
188    // each package directory by hand. The corresponding lookup for
189    // `package_manifest.yaml` (single-package mode) stays as the
190    // fallback below.
191    let candidate_dir = match &path {
192        Some(p) => Some(p.clone()),
193        None => std::env::current_dir().ok(),
194    };
195    if let Some(dir) = candidate_dir {
196        let deploy_manifest = dir.join("robonix_manifest.yaml");
197        if deploy_manifest.is_file() {
198            return build_deploy_manifest(&deploy_manifest, &config, clean, no_update_check);
199        }
200    }
201    let package_root = resolve_package_path(&config, path, global)?;
202    build::execute_local(package_root, clean).await
203}
204
205/// Build every package referenced by a top-level `robonix_manifest.yaml`.
206/// Two phases:
207///   1. **fetch** — `path:` entries already on disk; `url:` entries
208///      get `git clone --depth 1` into `rbnx-boot/cache/<name>/`
209///      (idempotent — skipped when the cache dir already exists).
210///   2. **build** — for each resolved package, run its `build.sh`.
211///
212/// `rbnx boot` deliberately does NOT do either; it just verifies
213/// both phases happened (warns + remediates if not). This lets
214/// "fetch → build" be a controlled offline step the user can run
215/// when they have network / time, then `rbnx boot` is a fast,
216/// online-optional bring-up.
217/// `git clone --depth 1` with bounded retries.
218///
219/// Clones the repo into `dest`, retrying transient failures. Between attempts
220/// it removes any partial checkout git left behind, because git refuses to
221/// clone into a non-empty directory and a half-written tree would otherwise
222/// turn one flaky network moment into a permanent failure.
223///
224/// Retries exist because a deployment build clones several remotes over
225/// several minutes, and any one of them dying takes the whole build with it.
226/// On a runner whose only route to GitHub is a proxy that drops for stretches
227/// at a time, a single `GnuTLS, handshake failed` mid-build was enough to fail
228/// a run whose next attempt seconds later would have succeeded.
229///
230/// The last attempt's exit status is what the error reports. Returns `Err`
231/// only after every attempt has failed, or if git could not be spawned at all
232/// — a missing git binary is not transient, so that is not retried.
233pub(super) fn git_clone_with_retry(
234    url: &str,
235    branch: Option<&str>,
236    dest: &std::path::Path,
237) -> Result<()> {
238    const ATTEMPTS: u32 = 3;
239    const BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
240
241    let mut last_code: Option<i32> = None;
242    for attempt in 1..=ATTEMPTS {
243        if attempt > 1 && dest.exists() {
244            // A failed clone can leave a partial tree; git will not clone into
245            // it, so the retry would fail for a different reason than the one
246            // we are retrying. Only ever remove what a previous *attempt of
247            // this call* created — every caller already guarantees the
248            // destination is absent, and a helper that deletes whatever it
249            // finds there would be a trap for the next one that does not.
250            let _ = std::fs::remove_dir_all(dest);
251        }
252        let mut clone = std::process::Command::new("git");
253        clone.arg("clone").arg("--depth").arg("1");
254        if let Some(b) = branch {
255            clone.arg("--branch").arg(b);
256        }
257        clone.arg(url).arg(dest);
258        let status = clone
259            .status()
260            .with_context(|| format!("git clone {url} failed to spawn"))?;
261        if status.success() {
262            return Ok(());
263        }
264        last_code = status.code();
265        if attempt < ATTEMPTS {
266            output::warning(&format!(
267                "git clone {url} failed (attempt {attempt}/{ATTEMPTS}); \
268                 retrying in {}s",
269                BACKOFF.as_secs()
270            ));
271            std::thread::sleep(BACKOFF);
272        }
273    }
274    anyhow::bail!("git clone {url} exited with {last_code:?} after {ATTEMPTS} attempts")
275}
276
277fn build_deploy_manifest(
278    manifest_path: &Path,
279    config: &Config,
280    clean: bool,
281    no_update_check: bool,
282) -> Result<()> {
283    use serde_yaml::Value;
284    let manifest_dir = manifest_path
285        .parent()
286        .context("deploy manifest has no parent directory")?
287        .to_path_buf();
288    let raw = std::fs::read_to_string(manifest_path)
289        .with_context(|| format!("read {}", manifest_path.display()))?;
290    let root: Value =
291        serde_yaml::from_str(&raw).with_context(|| format!("parse {}", manifest_path.display()))?;
292    let root = super::deploy::prepare_manifest(root, config.robonix_source_path.as_deref())
293        .with_context(|| format!("prepare {}", manifest_path.display()))?;
294    let cache_root = manifest_dir.join("rbnx-boot").join("cache");
295
296    output::action(
297        "Building",
298        &format!("packages declared in {}", manifest_path.display()),
299    );
300    // Notice (non-fatal) if any cloned remote provider is behind upstream.
301    if !no_update_check {
302        super::check_remotes::report_outdated(manifest_path);
303    }
304
305    // Collect (section, name, pkg_dir, url_to_clone) for every entry.
306    struct Resolved {
307        section: &'static str,
308        name: String,
309        pkg_dir: PathBuf,
310        url_to_clone: Option<(String, Option<String>)>, // (url, branch)
311        // Deploy `manifest:` override — selects a per-target package
312        // manifest variant (e.g. package_manifest.jetson-native.yaml) so
313        // the right build path runs. None = default package_manifest.yaml.
314        manifest_override: Option<String>,
315    }
316    let mut entries: Vec<Resolved> = Vec::new();
317    for section in &["primitive", "service", "skill"] {
318        let Some(seq) = root.get(*section).and_then(|v| v.as_sequence()) else {
319            continue;
320        };
321        for entry in seq {
322            let name = entry
323                .get("name")
324                .and_then(|v| v.as_str())
325                .unwrap_or("(unnamed)")
326                .to_string();
327            let local_path = entry.get("path").and_then(|v| v.as_str());
328            let url = entry.get("url").and_then(|v| v.as_str());
329            let branch = entry
330                .get("branch")
331                .and_then(|v| v.as_str())
332                .map(String::from);
333            let manifest_override = entry
334                .get("manifest")
335                .and_then(|v| v.as_str())
336                .map(String::from);
337            match (local_path, url) {
338                (Some(p), _) => entries.push(Resolved {
339                    section,
340                    name,
341                    pkg_dir: manifest_dir.join(p),
342                    url_to_clone: None,
343                    manifest_override,
344                }),
345                (None, Some(u)) => entries.push(Resolved {
346                    section,
347                    name: name.clone(),
348                    // Cache dir = git repo name (one clone per repo), not the
349                    // per-instance provider id. See deploy::repo_dir_name.
350                    pkg_dir: cache_root.join(super::deploy::repo_dir_name(u)),
351                    url_to_clone: Some((u.to_string(), branch)),
352                    manifest_override,
353                }),
354                (None, None) => {
355                    output::warning(&format!(
356                        "skipping {section}/{name}: entry has neither `path` nor `url`"
357                    ));
358                }
359            }
360        }
361    }
362    // `system:` non-builtin entries are real packages too (memory / scene
363    // / speech / …), they just live under `<robonix_source>/system/<key>/`
364    // instead of being declared with an explicit `path:` / `url:`. The
365    // builtin Rust binaries (atlas / executor / pilot / liaison / soma / vitals) are
366    // shipped via `cargo install` and skipped here.
367    if let Some(map) = root.get("system").and_then(|v| v.as_mapping()) {
368        let source_root = config.robonix_source_path.as_ref();
369        for (key, value) in map {
370            let Some(key_str) = key.as_str() else {
371                continue;
372            };
373            if super::deploy::is_builtin_system(key_str) {
374                continue;
375            }
376            let Some(source_root) = source_root else {
377                output::warning(&format!(
378                    "skipping system/{key_str}: robonix_source_path unset \
379                     (run `rbnx setup` from the repo root once)"
380                ));
381                continue;
382            };
383            let pkg_dir = source_root.join("system").join(key_str);
384            if !pkg_dir.exists() {
385                output::warning(&format!(
386                    "skipping system/{key_str}: not on disk at {}",
387                    pkg_dir.display()
388                ));
389                continue;
390            }
391            let (manifest_override, _runtime_config) = manifest::split_system_package_config(value)
392                .with_context(|| format!("parse system/{key_str} package selector"))?;
393            entries.push(Resolved {
394                section: "system",
395                name: key_str.to_string(),
396                pkg_dir,
397                url_to_clone: None,
398                manifest_override,
399            });
400        }
401    }
402
403    // Phase 1: fetch. git clone url-remote pkgs into cache.
404    let to_clone: Vec<&Resolved> = entries
405        .iter()
406        .filter(|e| e.url_to_clone.is_some() && !e.pkg_dir.exists())
407        .collect();
408    if !to_clone.is_empty() {
409        output::step("fetch", &format!("{} package(s)", to_clone.len()));
410        std::fs::create_dir_all(&cache_root)?;
411        for r in &to_clone {
412            let (url, branch) = r.url_to_clone.as_ref().unwrap();
413            output::sub_step(&format!("git clone {url} -> {}", r.pkg_dir.display()));
414            git_clone_with_retry(url, branch.as_deref(), &r.pkg_dir)?;
415        }
416    }
417
418    // Phase 2: build. Run build.sh for each resolved pkg.
419    struct Row {
420        section: &'static str,
421        name: String,
422        pkg_name: String, // reverse-domain `package.name` from package_manifest.yaml
423        version: String,
424        location: String, // path relative to manifest_dir, or absolute when outside
425        source: Option<(String, Option<String>)>, // (git url, branch) for url-fetched
426    }
427    let mut built: Vec<Row> = Vec::new();
428    let mut skipped: Vec<(&'static str, String, String)> = Vec::new(); // (section, name, reason)
429    let mut failed: Vec<(&'static str, String, anyhow::Error)> = Vec::new();
430
431    fn read_pkg_meta(pkg_dir: &Path) -> (String, String) {
432        // Best-effort: parse package.name + package.version from manifest.
433        let manifest = pkg_dir.join("package_manifest.yaml");
434        let raw = match std::fs::read_to_string(&manifest) {
435            Ok(s) => s,
436            Err(_) => return (String::new(), String::new()),
437        };
438        let v: serde_yaml::Value = match serde_yaml::from_str(&raw) {
439            Ok(v) => v,
440            Err(_) => return (String::new(), String::new()),
441        };
442        let pkg = v.get("package");
443        let name = pkg
444            .and_then(|p| p.get("name").and_then(|n| n.as_str()))
445            .unwrap_or("")
446            .to_string();
447        let ver = pkg
448            .and_then(|p| p.get("version").and_then(|n| n.as_str()))
449            .unwrap_or("")
450            .to_string();
451        (name, ver)
452    }
453
454    fn rel_to(_base: &Path, p: &Path) -> String {
455        // Always show absolute (realpath) so the user can copy-paste straight
456        // into a shell. The pkg_dir we get is already canonicalize()'d below.
457        p.display().to_string()
458    }
459
460    for r in &entries {
461        if !r.pkg_dir.join("package_manifest.yaml").is_file() {
462            let reason = format!("no package_manifest.yaml at {}", r.pkg_dir.display());
463            output::warning(&format!("skipping {}/{}: {}", r.section, r.name, reason));
464            skipped.push((r.section, r.name.clone(), reason));
465            continue;
466        }
467        let canon = r
468            .pkg_dir
469            .canonicalize()
470            .with_context(|| format!("canonicalize {}", r.pkg_dir.display()))?;
471        output::step(r.section, &r.name);
472        let (pkg_name, version) = read_pkg_meta(&canon);
473        let location = rel_to(&manifest_dir, &canon);
474        match build::build_local_package(&canon, clean, r.manifest_override.as_deref()) {
475            Ok(()) => built.push(Row {
476                section: r.section,
477                name: r.name.clone(),
478                pkg_name,
479                version,
480                location,
481                source: r.url_to_clone.clone(),
482            }),
483            Err(e) => failed.push((r.section, r.name.clone(), e)),
484        }
485    }
486
487    // ── Summary ─────────────────────────────────────────────────────────────
488    let manifest_label = manifest_path
489        .file_name()
490        .and_then(|n| n.to_str())
491        .unwrap_or("manifest");
492    let term_w = crossterm::terminal::size()
493        .map(|(c, _)| c as usize)
494        .unwrap_or(120);
495
496    fn center_title(width: usize, title: &str) -> String {
497        let t = format!(" {title} ");
498        if width <= t.len() {
499            return "═".repeat(width);
500        }
501        let left = (width - t.len()) / 2;
502        let right = width - t.len() - left;
503        format!("{}{t}{}", "═".repeat(left), "═".repeat(right))
504    }
505
506    let h_status = "";
507    let h_sec = "section";
508    let h_name = "name";
509    let h_pkg = "package.name";
510    let h_ver = "version";
511    let h_loc = "location";
512    let w_status = 1;
513    let w_sec = built
514        .iter()
515        .map(|r| r.section.len())
516        .max()
517        .unwrap_or(0)
518        .max(h_sec.len());
519    let w_name = built
520        .iter()
521        .map(|r| r.name.len())
522        .max()
523        .unwrap_or(0)
524        .max(h_name.len());
525    let w_pkg = built
526        .iter()
527        .map(|r| r.pkg_name.len())
528        .max()
529        .unwrap_or(0)
530        .max(h_pkg.len());
531    let w_ver = built
532        .iter()
533        .map(|r| r.version.len())
534        .max()
535        .unwrap_or(0)
536        .max(h_ver.len());
537    // Location: take its natural width so realpaths don't get truncated. The
538    // table simply ends up wider than the terminal — better that the user can
539    // copy-paste a full path than read a half-truncated one.
540    let nat_loc = built
541        .iter()
542        .map(|r| r.location.len())
543        .max()
544        .unwrap_or(0)
545        .max(h_loc.len());
546    let w_loc = nat_loc;
547    let table_w = if built.is_empty() {
548        term_w
549    } else {
550        2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2 + w_loc
551    };
552    let bar_w = table_w.max(term_w);
553    let bar = "═".repeat(bar_w);
554
555    println!();
556    println!("{}", center_title(bar_w, "Build summary"));
557    println!("  Manifest: {}", manifest_path.display());
558    println!(
559        "  Built: {}   Fetched: {}   Skipped: {}   Failed: {}   Total: {}",
560        built.len(),
561        to_clone.len(),
562        skipped.len(),
563        failed.len(),
564        entries.len()
565    );
566
567    if !built.is_empty() {
568        println!();
569        println!(
570            "  {:<ws$}  {:<wsec$}  {:<wn$}  {:<wp$}  {:<wv$}  {:<wl$}",
571            h_status,
572            h_sec,
573            h_name,
574            h_pkg,
575            h_ver,
576            h_loc,
577            ws = w_status,
578            wsec = w_sec,
579            wn = w_name,
580            wp = w_pkg,
581            wv = w_ver,
582            wl = w_loc,
583        );
584        let rule = |w: usize| "─".repeat(w);
585        println!(
586            "  {}  {}  {}  {}  {}  {}",
587            rule(w_status),
588            rule(w_sec),
589            rule(w_name),
590            rule(w_pkg),
591            rule(w_ver),
592            rule(w_loc),
593        );
594        let cont_indent = 2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2;
595        for r in &built {
596            println!(
597                "  {:<ws$}  {:<wsec$}  {:<wn$}  {:<wp$}  {:<wv$}  {}",
598                "✓",
599                r.section,
600                r.name,
601                r.pkg_name,
602                r.version,
603                r.location,
604                ws = w_status,
605                wsec = w_sec,
606                wn = w_name,
607                wp = w_pkg,
608                wv = w_ver,
609            );
610            if let Some((url, branch)) = &r.source {
611                let suffix = match branch {
612                    Some(b) => format!("↳ {url} (branch={b})"),
613                    None => format!("↳ {url}"),
614                };
615                println!("{}{suffix}", " ".repeat(cont_indent));
616            }
617        }
618    }
619    if !skipped.is_empty() {
620        println!();
621        for (section, name, reason) in &skipped {
622            println!("  - {section}/{name}: {reason}");
623        }
624    }
625    if !failed.is_empty() {
626        println!();
627        for (section, name, e) in &failed {
628            println!("  ✗ {section}/{name}: {e:#}");
629        }
630    }
631    println!("{bar}");
632
633    if !failed.is_empty() {
634        anyhow::bail!(
635            "{} package(s) failed to build from {manifest_label}",
636            failed.len()
637        );
638    }
639    Ok(())
640}
641
642pub async fn execute_start(
643    config: &Config,
644    spec: Option<&str>,
645    registry_endpoint: Option<&str>,
646    config_file: Option<&Path>,
647    set_overrides: &[String],
648    manifest_override: Option<&str>,
649) -> Result<()> {
650    let package_root = match spec {
651        Some(s) => resolve_package_path_for_start(config, s)?,
652        None => find_package_from_cwd()?,
653    };
654    let detected = manifest::detect_and_load(&package_root, manifest_override)?;
655    let manifest = &detected.manifest;
656    manifest.validate_and_summarize()?;
657
658    let endpoint = registry_endpoint
659        .map(String::from)
660        .unwrap_or_else(|| "127.0.0.1:50051".to_string());
661
662    // Materialize per-instance config from --config + --set overrides
663    // entirely in memory. The provider process never sees the file — config
664    // is delivered via Driver(CMD_INIT, config_json) only (post-spawn
665    // task below). Empty inputs still deliver Driver(CMD_INIT, "{}") so a
666    // standalone `rbnx start` follows the same lifecycle as `rbnx boot`.
667    let has_explicit_config = config_file.is_some() || !set_overrides.is_empty();
668    // Omission is the canonical shared selection. Only one explicit legacy
669    // selection may opt into a current shared runtime while manifests migrate;
670    // omitted and explicit shared selections never downgrade to legacy.
671    let explicit_driver_contract = manifest.explicit_lifecycle_driver_contract()?;
672    let expected_driver_contract = manifest.selected_lifecycle_driver_contract()?.to_string();
673    let allow_shared_driver_upgrade = explicit_driver_contract
674        .is_some_and(|contract| contract != manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT);
675    let deploy_managed = std::env::var_os("RBNX_DEPLOY_MANAGED").is_some();
676    let materialized_cfg_json = build_start_config_json(config_file, set_overrides)?;
677
678    if let Some(message) =
679        lifecycle_driver_migration_warning(&manifest.package.name, explicit_driver_contract)
680    {
681        output::warning(&message);
682    }
683
684    // Per-package run logs live under <pkg>/rbnx-build/logs (gitignored,
685    // owned by the package itself).  When `rbnx boot` spawns us, it sets
686    // $SCRIBE_LOG_DIR to the deploy log dir — respect that so boot-time
687    // logs stay under `rbnx-boot/logs/` for `rbnx logs` to find.
688    let log_dir = std::env::var("SCRIBE_LOG_DIR")
689        .map(PathBuf::from)
690        .unwrap_or_else(|_| package_root.join("rbnx-build").join("logs"));
691    let process_manager = Arc::new(ProcessManager::new(log_dir.clone())?);
692
693    output::action("Running", &manifest.package.name);
694    output::sub_step(&format!("Atlas endpoint: {}", endpoint));
695    if !manifest.capabilities.is_empty() {
696        output::sub_step(&format!(
697            "Capabilities: {}",
698            manifest
699                .capabilities
700                .iter()
701                .map(|c| c.name.as_str())
702                .collect::<Vec<_>>()
703                .join(", ")
704        ));
705    }
706
707    let mut env = std::collections::HashMap::new();
708    env.insert("ROBONIX_ATLAS".to_string(), endpoint.clone());
709    env.insert("SCRIBE_LOG_DIR".to_string(), log_dir.display().to_string());
710    // The exact selection and compatibility permission are distinct. The
711    // historical marker name is retained across wrapper/container boundaries,
712    // but it is now true only for a legacy manifest that may use shared runtime
713    // stubs. Shared selections never receive downgrade permission.
714    env.insert(
715        "ROBONIX_DRIVER_CONTRACT_ID".to_string(),
716        expected_driver_contract.clone(),
717    );
718    // Always overwrite any inherited marker so an explicit selection can
719    // never accidentally inherit omission's downgrade permission.
720    env.insert(
721        "ROBONIX_DRIVER_ALLOW_OLD_ARTIFACT_FALLBACK".to_string(),
722        if allow_shared_driver_upgrade {
723            "1"
724        } else {
725            "0"
726        }
727        .to_string(),
728    );
729    if has_explicit_config && !deploy_managed {
730        output::sub_step("Config: will deliver via Driver(CMD_INIT) post-register");
731    } else if has_explicit_config && deploy_managed {
732        output::sub_step("Config: deployment owner will deliver Driver(CMD_INIT)");
733    }
734    // Force unbuffered stdout/stderr in any Python child the package's
735    // start body launches. Without this, Python block-buffers stdout
736    // when it's a pipe (which `rbnx boot` always makes it), and a
737    // primitive whose driver is still alive never flushes its
738    // `Driver(cmd=0) received` line until the buffer fills or the
739    // process exits — so a 60-second boot full of "what is happening"
740    // looks like the package wedged at "ready - awaiting Driver".
741    // See `examples/webots/rbnx-boot/logs/primitive_tiago_camera.log`
742    // for the diagnostic this turned up. Override with PYTHONUNBUFFERED=
743    // (empty) in the manifest if a package really wants buffered output.
744    env.entry("PYTHONUNBUFFERED".to_string())
745        .or_insert_with(|| "1".to_string());
746
747    if !manifest.build.trim().is_empty() && !build::build_stamp_path(&package_root).exists() {
748        output::sub_step("No rbnx-build/.rbnx-built — running package build first");
749        build::build_local_package(&package_root, false, manifest_override)?;
750    }
751
752    let exports = env
753        .iter()
754        .map(|(k, v)| format!("export {}={}", k, shell_escape(v)))
755        .collect::<Vec<_>>()
756        .join("; ");
757    let pythonpath_export = generated_pythonpath_export(&package_root);
758    let start_body = manifest.start.trim();
759    let setup_bash = package_root
760        .join("rbnx-build")
761        .join("ws")
762        .join("install")
763        .join("setup.bash");
764    let setup_source = if setup_bash.exists() {
765        format!("source {}", shell_escape(&setup_bash.display().to_string()))
766    } else {
767        String::new()
768    };
769    let prefix_parts: Vec<String> = [setup_source, exports, pythonpath_export]
770        .into_iter()
771        .filter(|s| !s.is_empty())
772        .collect();
773    let start_command = if prefix_parts.is_empty() {
774        start_body.to_string()
775    } else {
776        format!("{}; {start_body}", prefix_parts.join("; "))
777    };
778
779    // A standalone lifecycle owner snapshots Atlas before spawning. Snapshot
780    // failures are fatal: treating them as an empty set could select an
781    // unrelated pre-existing provider and deliver this package's config to it.
782    // `rbnx boot` sets RBNX_DEPLOY_MANAGED and owns this sequence itself.
783    let standalone_lifecycle = if !deploy_managed {
784        let json = materialized_cfg_json
785            .expect("start config materialization always returns a JSON object");
786        let normalized = normalize_atlas_endpoint(&endpoint);
787        let mut atlas = AtlasClient::connect(&normalized)
788            .await
789            .with_context(|| format!("connect Atlas at {normalized} before standalone spawn"))?;
790        let before_snapshot = snapshot_provider_ids(&mut atlas)
791            .await
792            .context("standalone pre-spawn Atlas snapshot")?;
793        Some((
794            atlas,
795            before_snapshot,
796            json,
797            expected_driver_contract.clone(),
798            allow_shared_driver_upgrade,
799        ))
800    } else {
801        None
802    };
803
804    // Scribe tag = the per-INSTANCE provider id, never the package name. A
805    // single package (one `package.name`) can be deployed as N instances, each
806    // with a distinct provider id; tagging by package.name would collide them
807    // all into one log. `rbnx boot` passes the instance's provider id via
808    // RBNX_INSTANCE_NAME (the deploy manifest entry's `name`); fall back to
809    // package.name only for a bare standalone `rbnx start` with no instance.
810    let instance_name =
811        std::env::var("RBNX_INSTANCE_NAME").unwrap_or_else(|_| manifest.package.name.clone());
812    let result = if let Some((mut atlas, before, json, expected_contract, allow_shared_upgrade)) =
813        standalone_lifecycle
814    {
815        // `start_process` blocks for the package lifetime, so run it alongside
816        // registration/lifecycle driving. On lifecycle failure, stop the exact
817        // package process group instead of leaving a REGISTERED/ERROR provider.
818        let manager_for_start = Arc::clone(&process_manager);
819        let package_root_for_start = package_root.clone();
820        let start_command_for_start = start_command.clone();
821        let instance_for_start = instance_name.clone();
822        let mut process_task = tokio::spawn(async move {
823            manager_for_start
824                .start_process(
825                    &instance_for_start,
826                    &instance_for_start,
827                    "package",
828                    &package_root_for_start,
829                    &start_command_for_start,
830                )
831                .await
832        });
833
834        // Wait until ProcessManager has recorded the child so every lifecycle
835        // failure path can terminate it. Surface an early child exit directly.
836        let recorded_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
837        while !process_manager.has_process_record(&instance_name, "package") {
838            if process_task.is_finished() {
839                let process_result = process_task.await.context("package process task failed")?;
840                return match process_result {
841                    Ok(result) => anyhow::bail!(
842                        "package exited before registering with Atlas (PID {})",
843                        result.pid
844                    ),
845                    Err(error) => Err(error),
846                };
847            }
848            if tokio::time::Instant::now() >= recorded_deadline {
849                process_task.abort();
850                anyhow::bail!("package process was not recorded within 5s after spawn");
851            }
852            tokio::time::sleep(Duration::from_millis(25)).await;
853        }
854
855        let lifecycle = drive_standalone_lifecycle(
856            &mut atlas,
857            &before,
858            &instance_name,
859            &expected_contract,
860            allow_shared_upgrade,
861            json,
862        );
863        tokio::pin!(lifecycle);
864        tokio::select! {
865            lifecycle_result = &mut lifecycle => {
866                if let Err(error) = lifecycle_result {
867                    output::warning(&format!("standalone lifecycle failed: {error:#}"));
868                    if let Err(stop_error) = process_manager.stop_process(&instance_name, "package").await {
869                        output::warning(&format!("failed to stop package after lifecycle error: {stop_error:#}"));
870                    }
871                    let _ = process_task.await;
872                    return Err(error);
873                }
874            }
875            process_result = &mut process_task => {
876                let process_result = process_result.context("package process task failed")?;
877                return match process_result {
878                    Ok(result) => anyhow::bail!(
879                        "package exited before completing standalone lifecycle (PID {})",
880                        result.pid
881                    ),
882                    Err(error) => Err(error),
883                };
884            }
885        }
886        process_task
887            .await
888            .context("package process task failed")??
889    } else {
890        process_manager
891            .start_process(
892                &instance_name,
893                &instance_name,
894                "package",
895                &package_root,
896                &start_command,
897            )
898            .await?
899    };
900    output::check(&format!(
901        "{} exited (PID {})",
902        manifest.package.name, result.pid
903    ));
904
905    output::success(&format!("Package {} finished", manifest.package.name));
906    Ok(())
907}
908
909/// Build the package-local Python import path without touching a colcon
910/// workspace. A real `rbnx-build/ws/install/setup.bash`, when present, is
911/// sourced first; this export then prepends generated stubs while preserving
912/// every Python path contributed by that overlay and the parent environment.
913fn generated_pythonpath_export(package_root: &Path) -> String {
914    let codegen_root = package_root.join("rbnx-build").join("codegen");
915    let mut paths = vec![package_root.to_path_buf()];
916    for path in [
917        codegen_root.join("proto_gen"),
918        codegen_root.join("robonix_mcp_types"),
919    ] {
920        if path.is_dir() {
921            paths.push(path);
922        }
923    }
924    let joined = paths
925        .iter()
926        .map(|path| path.display().to_string())
927        .collect::<Vec<_>>()
928        .join(":");
929    format!(
930        "export PYTHONPATH={}:${{PYTHONPATH:-}}",
931        shell_escape(&joined)
932    )
933}
934
935fn normalize_atlas_endpoint(endpoint: &str) -> String {
936    if endpoint.starts_with("http") {
937        endpoint.to_string()
938    } else {
939        format!("http://{endpoint}")
940    }
941}
942
943/// Wait for exactly one new provider, verify that it declares the driver
944/// contract from this package manifest, then drive INIT and (except for
945/// skills) ACTIVATE. Contract verification is mandatory before any config is
946/// sent, so a provider exposing a different lifecycle cannot receive this
947/// package's configuration. Concurrent starts of two instances with the same
948/// driver contract still require a future registration token for full identity
949/// correlation.
950async fn drive_standalone_lifecycle(
951    atlas: &mut AtlasClient,
952    before: &ProviderRegistrationSnapshot,
953    expected_provider_id: &str,
954    expected_driver_contract: &str,
955    allow_shared_driver_upgrade: bool,
956    config_json: String,
957) -> Result<()> {
958    let outcome =
959        wait_for_registration_core(atlas, before, expected_provider_id, "rbnx start").await?;
960    let driver_contract = resolve_runtime_driver_contract(
961        &outcome.provider_id,
962        &outcome.provider_namespace,
963        expected_driver_contract,
964        &outcome.driver_contracts,
965        allow_shared_driver_upgrade,
966    )?;
967    if driver_contract != expected_driver_contract {
968        output::warning(&format!(
969            "provider '{}' publishes shared lifecycle Driver '{}' for legacy manifest selection '{}'; remove the legacy Driver declaration to finish migration",
970            outcome.provider_id, driver_contract, expected_driver_contract,
971        ));
972    }
973
974    let init_state = call_driver_cmd(
975        atlas,
976        &outcome.provider_id,
977        &driver_contract,
978        CMD_INIT,
979        config_json.clone(),
980        "rbnx start",
981    )
982    .await?;
983    output::sub_step(&format!(
984        "Driver(CMD_INIT) → {} ok (state={init_state})",
985        outcome.provider_id
986    ));
987    if should_activate_standalone_provider(outcome.provider_kind) {
988        let state = call_driver_cmd(
989            atlas,
990            &outcome.provider_id,
991            &driver_contract,
992            CMD_ACTIVATE,
993            config_json,
994            "rbnx start",
995        )
996        .await?;
997        output::sub_step(&format!(
998            "Driver(CMD_ACTIVATE) → {} ok (state={state})",
999            outcome.provider_id
1000        ));
1001    }
1002    Ok(())
1003}
1004
1005fn should_activate_standalone_provider(provider_kind: i32) -> bool {
1006    provider_kind != atlas_pb::Kind::Skill as i32
1007}
1008
1009/// Materialize a per-instance config from `--config <file>` plus
1010/// repeatable `--set k.v=val` overrides. Returns the merged JSON
1011/// string. When neither input was provided, returns an empty JSON object so
1012/// `rbnx start` still performs the provider lifecycle initialization.
1013///
1014/// Layering: load file (json or yaml) → overlay each `--set` on the
1015/// tree → serialise to a single JSON string. The string is delivered
1016/// to the provider exclusively via Driver(CMD_INIT, config_json). The provider
1017/// process MUST NOT read this through env / disk — that's the v0.1
1018/// invariant `rbnx start` and `rbnx boot` both honour.
1019fn build_start_config_json(config_file: Option<&Path>, sets: &[String]) -> Result<Option<String>> {
1020    if config_file.is_none() && sets.is_empty() {
1021        return Ok(Some("{}".to_string()));
1022    }
1023
1024    let mut value: serde_json::Value = match config_file {
1025        Some(p) => {
1026            let raw = std::fs::read_to_string(p)
1027                .with_context(|| format!("read config file {}", p.display()))?;
1028            // Try JSON first; fall through to YAML.
1029            match serde_json::from_str::<serde_json::Value>(&raw) {
1030                Ok(v) => v,
1031                Err(_) => {
1032                    let y: serde_yaml::Value = serde_yaml::from_str(&raw)
1033                        .with_context(|| format!("parse config {} as JSON or YAML", p.display()))?;
1034                    serde_json::to_value(y)
1035                        .with_context(|| format!("convert {} YAML→JSON", p.display()))?
1036                }
1037            }
1038        }
1039        None => serde_json::Value::Object(serde_json::Map::new()),
1040    };
1041
1042    for s in sets {
1043        let (key, raw_val) = s
1044            .split_once('=')
1045            .with_context(|| format!("--set {s:?}: expected KEY=VALUE"))?;
1046        let parsed: serde_json::Value = serde_json::from_str(raw_val)
1047            .unwrap_or_else(|_| serde_json::Value::String(raw_val.into()));
1048        merge_dotted(&mut value, key, parsed)?;
1049    }
1050
1051    Ok(Some(
1052        serde_json::to_string(&value).unwrap_or_else(|_| "{}".into()),
1053    ))
1054}
1055
1056/// Set `obj[a][b][c] = v` for a dotted key like `"a.b.c"`. Creates
1057/// intermediate objects as needed; bails on a non-object collision.
1058fn merge_dotted(root: &mut serde_json::Value, key: &str, v: serde_json::Value) -> Result<()> {
1059    let parts: Vec<&str> = key.split('.').filter(|p| !p.is_empty()).collect();
1060    if parts.is_empty() {
1061        anyhow::bail!("--set: empty key");
1062    }
1063    if !root.is_object() {
1064        *root = serde_json::Value::Object(serde_json::Map::new());
1065    }
1066    let mut cur = root;
1067    for p in &parts[..parts.len() - 1] {
1068        let map = cur.as_object_mut().ok_or_else(|| {
1069            anyhow::anyhow!("--set {key}: cannot descend into non-object at '{p}'")
1070        })?;
1071        let entry = map
1072            .entry((*p).to_string())
1073            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1074        if !entry.is_object() {
1075            *entry = serde_json::Value::Object(serde_json::Map::new());
1076        }
1077        cur = entry;
1078    }
1079    let last = parts[parts.len() - 1];
1080    cur.as_object_mut()
1081        .ok_or_else(|| anyhow::anyhow!("--set {key}: parent is not an object"))?
1082        .insert(last.to_string(), v);
1083    Ok(())
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089    use std::fs;
1090    use std::time::{SystemTime, UNIX_EPOCH};
1091
1092    /// A failing clone must exhaust its attempts, name the exit status, and
1093    /// leave no partial checkout behind. The stale directory seeded here
1094    /// stands in for the tree a network-interrupted clone leaves: without the
1095    /// cleanup between attempts, git refuses to clone into a non-empty
1096    /// directory and every retry fails for the wrong reason.
1097    ///
1098    /// The URL is a local path that does not exist, so git fails immediately
1099    /// and the test needs no network.
1100    #[test]
1101    fn git_clone_with_retry_cleans_partial_checkouts_and_reports_the_last_status() {
1102        let root = temp_root("clone-retry");
1103        let dest = root.join("pkg");
1104        fs::create_dir_all(&dest).expect("dest");
1105        fs::write(dest.join("leftover.txt"), b"partial clone").expect("seed leftover");
1106
1107        let missing = root.join("no-such-repo.git");
1108        let err = git_clone_with_retry(&missing.to_string_lossy(), None, &dest)
1109            .expect_err("clone of a nonexistent repo must fail");
1110
1111        let msg = format!("{err:#}");
1112        assert!(msg.contains("after 3 attempts"), "unexpected error: {msg}");
1113        assert!(
1114            !dest.exists(),
1115            "a failed clone must not leave a partial checkout at {}",
1116            dest.display()
1117        );
1118        let _ = fs::remove_dir_all(&root);
1119    }
1120
1121    fn temp_root(label: &str) -> PathBuf {
1122        let nonce = SystemTime::now()
1123            .duration_since(UNIX_EPOCH)
1124            .unwrap()
1125            .as_nanos();
1126        std::env::temp_dir().join(format!("rbnx-start-{label}-{}-{nonce}", std::process::id()))
1127    }
1128
1129    #[test]
1130    fn generated_pythonpath_is_injected_without_a_setup_stub() {
1131        let root = temp_root("pythonpath");
1132        let proto = root.join("rbnx-build/codegen/proto_gen");
1133        let mcp = root.join("rbnx-build/codegen/robonix_mcp_types");
1134        fs::create_dir_all(&proto).unwrap();
1135        fs::create_dir_all(&mcp).unwrap();
1136
1137        let export = generated_pythonpath_export(&root);
1138
1139        assert!(export.contains(&root.display().to_string()));
1140        assert!(export.contains(&proto.display().to_string()));
1141        assert!(export.contains(&mcp.display().to_string()));
1142        assert!(export.ends_with(":${PYTHONPATH:-}"));
1143        assert!(!root.join("rbnx-build/ws/install/setup.bash").exists());
1144        fs::remove_dir_all(root).unwrap();
1145    }
1146
1147    #[test]
1148    fn start_without_config_still_materializes_empty_init_config() {
1149        let config = build_start_config_json(None, &[]).unwrap();
1150        assert_eq!(config.as_deref(), Some("{}"));
1151    }
1152
1153    #[test]
1154    fn standalone_start_activates_primitive_and_service_but_not_skill() {
1155        assert!(should_activate_standalone_provider(
1156            atlas_pb::Kind::Primitive as i32
1157        ));
1158        assert!(should_activate_standalone_provider(
1159            atlas_pb::Kind::Service as i32
1160        ));
1161        assert!(!should_activate_standalone_provider(
1162            atlas_pb::Kind::Skill as i32
1163        ));
1164    }
1165
1166    #[test]
1167    fn shared_driver_contract_needs_no_migration_warning() {
1168        assert_eq!(
1169            lifecycle_driver_migration_warning("test.scene", Some("robonix/lifecycle/driver"),),
1170            None
1171        );
1172    }
1173
1174    #[test]
1175    fn omitted_driver_is_the_canonical_shared_selection() {
1176        assert_eq!(lifecycle_driver_migration_warning("test.scene", None), None);
1177    }
1178
1179    #[test]
1180    fn legacy_driver_contract_gets_one_actionable_migration_warning() {
1181        let warning = lifecycle_driver_migration_warning(
1182            "test.camera",
1183            Some("robonix/primitive/camera/driver"),
1184        )
1185        .unwrap();
1186        assert!(warning.contains("test.camera"));
1187        assert!(warning.contains("robonix/primitive/camera/driver"));
1188        assert!(warning.contains("robonix/lifecycle/driver"));
1189        assert!(warning.contains("is backward-compatible"));
1190        assert!(warning.contains("exact <provider namespace>/driver"));
1191        assert!(warning.contains("shared runtime Driver"));
1192        assert!(warning.contains("do not declare both"));
1193    }
1194
1195    #[test]
1196    fn system_package_uses_the_deploy_selected_manifest() {
1197        let root = temp_root("system-manifest");
1198        let source_root = root.join("source");
1199        let scene_root = source_root.join("system/scene");
1200        let deploy_root = root.join("deploy");
1201        fs::create_dir_all(&scene_root).unwrap();
1202        fs::create_dir_all(&deploy_root).unwrap();
1203        fs::write(
1204            scene_root.join("package_manifest.yaml"),
1205            r#"manifestVersion: 1
1206package:
1207  name: test.system.scene
1208  version: 0.1.0
1209  vendor: test
1210  description: default target
1211  license: Apache-2.0
1212build: touch default-selected
1213start: "true"
1214"#,
1215        )
1216        .unwrap();
1217        fs::write(
1218            scene_root.join("package_manifest.jetson-native.yaml"),
1219            r#"manifestVersion: 1
1220package:
1221  name: test.system.scene
1222  version: 0.1.0
1223  vendor: test
1224  description: Jetson native target
1225  license: Apache-2.0
1226build: touch jetson-selected
1227start: "true"
1228"#,
1229        )
1230        .unwrap();
1231        let deploy_manifest = deploy_root.join("robonix_manifest.yaml");
1232        fs::write(
1233            &deploy_manifest,
1234            r#"manifestVersion: 1
1235name: system-target-test
1236system:
1237  scene:
1238    manifest: package_manifest.jetson-native.yaml
1239    config:
1240      camera_provider_id: front_camera
1241"#,
1242        )
1243        .unwrap();
1244        let config = Config {
1245            package_storage_path: root.join("packages"),
1246            robonix_source_path: Some(source_root),
1247        };
1248
1249        build_deploy_manifest(&deploy_manifest, &config, false, true).unwrap();
1250
1251        assert!(scene_root.join("jetson-selected").is_file());
1252        assert!(!scene_root.join("default-selected").exists());
1253        fs::remove_dir_all(root).unwrap();
1254    }
1255}