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.
217fn build_deploy_manifest(
218    manifest_path: &Path,
219    config: &Config,
220    clean: bool,
221    no_update_check: bool,
222) -> Result<()> {
223    use serde_yaml::Value;
224    let manifest_dir = manifest_path
225        .parent()
226        .context("deploy manifest has no parent directory")?
227        .to_path_buf();
228    let raw = std::fs::read_to_string(manifest_path)
229        .with_context(|| format!("read {}", manifest_path.display()))?;
230    let root: Value =
231        serde_yaml::from_str(&raw).with_context(|| format!("parse {}", manifest_path.display()))?;
232    let root = super::deploy::prepare_manifest(root, config.robonix_source_path.as_deref())
233        .with_context(|| format!("prepare {}", manifest_path.display()))?;
234    let cache_root = manifest_dir.join("rbnx-boot").join("cache");
235
236    output::action(
237        "Building",
238        &format!("packages declared in {}", manifest_path.display()),
239    );
240    // Notice (non-fatal) if any cloned remote provider is behind upstream.
241    if !no_update_check {
242        super::check_remotes::report_outdated(manifest_path);
243    }
244
245    // Collect (section, name, pkg_dir, url_to_clone) for every entry.
246    struct Resolved {
247        section: &'static str,
248        name: String,
249        pkg_dir: PathBuf,
250        url_to_clone: Option<(String, Option<String>)>, // (url, branch)
251        // Deploy `manifest:` override — selects a per-target package
252        // manifest variant (e.g. package_manifest.jetson-native.yaml) so
253        // the right build path runs. None = default package_manifest.yaml.
254        manifest_override: Option<String>,
255    }
256    let mut entries: Vec<Resolved> = Vec::new();
257    for section in &["primitive", "service", "skill"] {
258        let Some(seq) = root.get(*section).and_then(|v| v.as_sequence()) else {
259            continue;
260        };
261        for entry in seq {
262            let name = entry
263                .get("name")
264                .and_then(|v| v.as_str())
265                .unwrap_or("(unnamed)")
266                .to_string();
267            let local_path = entry.get("path").and_then(|v| v.as_str());
268            let url = entry.get("url").and_then(|v| v.as_str());
269            let branch = entry
270                .get("branch")
271                .and_then(|v| v.as_str())
272                .map(String::from);
273            let manifest_override = entry
274                .get("manifest")
275                .and_then(|v| v.as_str())
276                .map(String::from);
277            match (local_path, url) {
278                (Some(p), _) => entries.push(Resolved {
279                    section,
280                    name,
281                    pkg_dir: manifest_dir.join(p),
282                    url_to_clone: None,
283                    manifest_override,
284                }),
285                (None, Some(u)) => entries.push(Resolved {
286                    section,
287                    name: name.clone(),
288                    // Cache dir = git repo name (one clone per repo), not the
289                    // per-instance provider id. See deploy::repo_dir_name.
290                    pkg_dir: cache_root.join(super::deploy::repo_dir_name(u)),
291                    url_to_clone: Some((u.to_string(), branch)),
292                    manifest_override,
293                }),
294                (None, None) => {
295                    output::warning(&format!(
296                        "skipping {section}/{name}: entry has neither `path` nor `url`"
297                    ));
298                }
299            }
300        }
301    }
302    // `system:` non-builtin entries are real packages too (memory / scene
303    // / speech / …), they just live under `<robonix_source>/system/<key>/`
304    // instead of being declared with an explicit `path:` / `url:`. The
305    // builtin Rust binaries (atlas / executor / pilot / liaison / soma / vitals) are
306    // shipped via `cargo install` and skipped here.
307    const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma", "vitals"];
308    if let Some(map) = root.get("system").and_then(|v| v.as_mapping()) {
309        let source_root = config.robonix_source_path.as_ref();
310        for (key, value) in map {
311            let Some(key_str) = key.as_str() else {
312                continue;
313            };
314            if SYSTEM_BUILTINS.contains(&key_str) {
315                continue;
316            }
317            let Some(source_root) = source_root else {
318                output::warning(&format!(
319                    "skipping system/{key_str}: robonix_source_path unset \
320                     (run `rbnx setup` from the repo root once)"
321                ));
322                continue;
323            };
324            let pkg_dir = source_root.join("system").join(key_str);
325            if !pkg_dir.exists() {
326                output::warning(&format!(
327                    "skipping system/{key_str}: not on disk at {}",
328                    pkg_dir.display()
329                ));
330                continue;
331            }
332            let (manifest_override, _runtime_config) = manifest::split_system_package_config(value)
333                .with_context(|| format!("parse system/{key_str} package selector"))?;
334            entries.push(Resolved {
335                section: "system",
336                name: key_str.to_string(),
337                pkg_dir,
338                url_to_clone: None,
339                manifest_override,
340            });
341        }
342    }
343
344    // Phase 1: fetch. git clone url-remote pkgs into cache.
345    let to_clone: Vec<&Resolved> = entries
346        .iter()
347        .filter(|e| e.url_to_clone.is_some() && !e.pkg_dir.exists())
348        .collect();
349    if !to_clone.is_empty() {
350        output::step("fetch", &format!("{} package(s)", to_clone.len()));
351        std::fs::create_dir_all(&cache_root)?;
352        for r in &to_clone {
353            let (url, branch) = r.url_to_clone.as_ref().unwrap();
354            output::sub_step(&format!("git clone {url} -> {}", r.pkg_dir.display()));
355            let mut clone = std::process::Command::new("git");
356            clone.arg("clone").arg("--depth").arg("1");
357            if let Some(b) = branch {
358                clone.arg("--branch").arg(b);
359            }
360            clone.arg(url).arg(&r.pkg_dir);
361            let status = clone
362                .status()
363                .with_context(|| format!("git clone {url} failed to spawn"))?;
364            if !status.success() {
365                anyhow::bail!("git clone {url} exited with {:?}", status.code());
366            }
367        }
368    }
369
370    // Phase 2: build. Run build.sh for each resolved pkg.
371    struct Row {
372        section: &'static str,
373        name: String,
374        pkg_name: String, // reverse-domain `package.name` from package_manifest.yaml
375        version: String,
376        location: String, // path relative to manifest_dir, or absolute when outside
377        source: Option<(String, Option<String>)>, // (git url, branch) for url-fetched
378    }
379    let mut built: Vec<Row> = Vec::new();
380    let mut skipped: Vec<(&'static str, String, String)> = Vec::new(); // (section, name, reason)
381    let mut failed: Vec<(&'static str, String, anyhow::Error)> = Vec::new();
382
383    fn read_pkg_meta(pkg_dir: &Path) -> (String, String) {
384        // Best-effort: parse package.name + package.version from manifest.
385        let manifest = pkg_dir.join("package_manifest.yaml");
386        let raw = match std::fs::read_to_string(&manifest) {
387            Ok(s) => s,
388            Err(_) => return (String::new(), String::new()),
389        };
390        let v: serde_yaml::Value = match serde_yaml::from_str(&raw) {
391            Ok(v) => v,
392            Err(_) => return (String::new(), String::new()),
393        };
394        let pkg = v.get("package");
395        let name = pkg
396            .and_then(|p| p.get("name").and_then(|n| n.as_str()))
397            .unwrap_or("")
398            .to_string();
399        let ver = pkg
400            .and_then(|p| p.get("version").and_then(|n| n.as_str()))
401            .unwrap_or("")
402            .to_string();
403        (name, ver)
404    }
405
406    fn rel_to(_base: &Path, p: &Path) -> String {
407        // Always show absolute (realpath) so the user can copy-paste straight
408        // into a shell. The pkg_dir we get is already canonicalize()'d below.
409        p.display().to_string()
410    }
411
412    for r in &entries {
413        if !r.pkg_dir.join("package_manifest.yaml").is_file() {
414            let reason = format!("no package_manifest.yaml at {}", r.pkg_dir.display());
415            output::warning(&format!("skipping {}/{}: {}", r.section, r.name, reason));
416            skipped.push((r.section, r.name.clone(), reason));
417            continue;
418        }
419        let canon = r
420            .pkg_dir
421            .canonicalize()
422            .with_context(|| format!("canonicalize {}", r.pkg_dir.display()))?;
423        output::step(r.section, &r.name);
424        let (pkg_name, version) = read_pkg_meta(&canon);
425        let location = rel_to(&manifest_dir, &canon);
426        match build::build_local_package(&canon, clean, r.manifest_override.as_deref()) {
427            Ok(()) => built.push(Row {
428                section: r.section,
429                name: r.name.clone(),
430                pkg_name,
431                version,
432                location,
433                source: r.url_to_clone.clone(),
434            }),
435            Err(e) => failed.push((r.section, r.name.clone(), e)),
436        }
437    }
438
439    // ── Summary ─────────────────────────────────────────────────────────────
440    let manifest_label = manifest_path
441        .file_name()
442        .and_then(|n| n.to_str())
443        .unwrap_or("manifest");
444    let term_w = crossterm::terminal::size()
445        .map(|(c, _)| c as usize)
446        .unwrap_or(120);
447
448    fn center_title(width: usize, title: &str) -> String {
449        let t = format!(" {title} ");
450        if width <= t.len() {
451            return "═".repeat(width);
452        }
453        let left = (width - t.len()) / 2;
454        let right = width - t.len() - left;
455        format!("{}{t}{}", "═".repeat(left), "═".repeat(right))
456    }
457
458    let h_status = "";
459    let h_sec = "section";
460    let h_name = "name";
461    let h_pkg = "package.name";
462    let h_ver = "version";
463    let h_loc = "location";
464    let w_status = 1;
465    let w_sec = built
466        .iter()
467        .map(|r| r.section.len())
468        .max()
469        .unwrap_or(0)
470        .max(h_sec.len());
471    let w_name = built
472        .iter()
473        .map(|r| r.name.len())
474        .max()
475        .unwrap_or(0)
476        .max(h_name.len());
477    let w_pkg = built
478        .iter()
479        .map(|r| r.pkg_name.len())
480        .max()
481        .unwrap_or(0)
482        .max(h_pkg.len());
483    let w_ver = built
484        .iter()
485        .map(|r| r.version.len())
486        .max()
487        .unwrap_or(0)
488        .max(h_ver.len());
489    // Location: take its natural width so realpaths don't get truncated. The
490    // table simply ends up wider than the terminal — better that the user can
491    // copy-paste a full path than read a half-truncated one.
492    let nat_loc = built
493        .iter()
494        .map(|r| r.location.len())
495        .max()
496        .unwrap_or(0)
497        .max(h_loc.len());
498    let w_loc = nat_loc;
499    let table_w = if built.is_empty() {
500        term_w
501    } else {
502        2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2 + w_loc
503    };
504    let bar_w = table_w.max(term_w);
505    let bar = "═".repeat(bar_w);
506
507    println!();
508    println!("{}", center_title(bar_w, "Build summary"));
509    println!("  Manifest: {}", manifest_path.display());
510    println!(
511        "  Built: {}   Fetched: {}   Skipped: {}   Failed: {}   Total: {}",
512        built.len(),
513        to_clone.len(),
514        skipped.len(),
515        failed.len(),
516        entries.len()
517    );
518
519    if !built.is_empty() {
520        println!();
521        println!(
522            "  {:<ws$}  {:<wsec$}  {:<wn$}  {:<wp$}  {:<wv$}  {:<wl$}",
523            h_status,
524            h_sec,
525            h_name,
526            h_pkg,
527            h_ver,
528            h_loc,
529            ws = w_status,
530            wsec = w_sec,
531            wn = w_name,
532            wp = w_pkg,
533            wv = w_ver,
534            wl = w_loc,
535        );
536        let rule = |w: usize| "─".repeat(w);
537        println!(
538            "  {}  {}  {}  {}  {}  {}",
539            rule(w_status),
540            rule(w_sec),
541            rule(w_name),
542            rule(w_pkg),
543            rule(w_ver),
544            rule(w_loc),
545        );
546        let cont_indent = 2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2;
547        for r in &built {
548            println!(
549                "  {:<ws$}  {:<wsec$}  {:<wn$}  {:<wp$}  {:<wv$}  {}",
550                "✓",
551                r.section,
552                r.name,
553                r.pkg_name,
554                r.version,
555                r.location,
556                ws = w_status,
557                wsec = w_sec,
558                wn = w_name,
559                wp = w_pkg,
560                wv = w_ver,
561            );
562            if let Some((url, branch)) = &r.source {
563                let suffix = match branch {
564                    Some(b) => format!("↳ {url} (branch={b})"),
565                    None => format!("↳ {url}"),
566                };
567                println!("{}{suffix}", " ".repeat(cont_indent));
568            }
569        }
570    }
571    if !skipped.is_empty() {
572        println!();
573        for (section, name, reason) in &skipped {
574            println!("  - {section}/{name}: {reason}");
575        }
576    }
577    if !failed.is_empty() {
578        println!();
579        for (section, name, e) in &failed {
580            println!("  ✗ {section}/{name}: {e:#}");
581        }
582    }
583    println!("{bar}");
584
585    if !failed.is_empty() {
586        anyhow::bail!(
587            "{} package(s) failed to build from {manifest_label}",
588            failed.len()
589        );
590    }
591    Ok(())
592}
593
594pub async fn execute_start(
595    config: &Config,
596    spec: Option<&str>,
597    registry_endpoint: Option<&str>,
598    config_file: Option<&Path>,
599    set_overrides: &[String],
600    manifest_override: Option<&str>,
601) -> Result<()> {
602    let package_root = match spec {
603        Some(s) => resolve_package_path_for_start(config, s)?,
604        None => find_package_from_cwd()?,
605    };
606    let detected = manifest::detect_and_load(&package_root, manifest_override)?;
607    let manifest = &detected.manifest;
608    manifest.validate_and_summarize()?;
609
610    let endpoint = registry_endpoint
611        .map(String::from)
612        .unwrap_or_else(|| "127.0.0.1:50051".to_string());
613
614    // Materialize per-instance config from --config + --set overrides
615    // entirely in memory. The provider process never sees the file — config
616    // is delivered via Driver(CMD_INIT, config_json) only (post-spawn
617    // task below). Empty inputs still deliver Driver(CMD_INIT, "{}") so a
618    // standalone `rbnx start` follows the same lifecycle as `rbnx boot`.
619    let has_explicit_config = config_file.is_some() || !set_overrides.is_empty();
620    // Omission is the canonical shared selection. Only one explicit legacy
621    // selection may opt into a current shared runtime while manifests migrate;
622    // omitted and explicit shared selections never downgrade to legacy.
623    let explicit_driver_contract = manifest.explicit_lifecycle_driver_contract()?;
624    let expected_driver_contract = manifest.selected_lifecycle_driver_contract()?.to_string();
625    let allow_shared_driver_upgrade = explicit_driver_contract
626        .is_some_and(|contract| contract != manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT);
627    let has_driver_capability = true;
628    let deploy_managed = std::env::var_os("RBNX_DEPLOY_MANAGED").is_some();
629    let materialized_cfg_json = build_start_config_json(config_file, set_overrides)?;
630
631    if let Some(message) =
632        lifecycle_driver_migration_warning(&manifest.package.name, explicit_driver_contract)
633    {
634        output::warning(&message);
635    }
636
637    // Per-package run logs live under <pkg>/rbnx-build/logs (gitignored,
638    // owned by the package itself).  When `rbnx boot` spawns us, it sets
639    // $SCRIBE_LOG_DIR to the deploy log dir — respect that so boot-time
640    // logs stay under `rbnx-boot/logs/` for `rbnx logs` to find.
641    let log_dir = std::env::var("SCRIBE_LOG_DIR")
642        .map(PathBuf::from)
643        .unwrap_or_else(|_| package_root.join("rbnx-build").join("logs"));
644    let process_manager = Arc::new(ProcessManager::new(log_dir.clone())?);
645
646    output::action("Running", &manifest.package.name);
647    output::sub_step(&format!("Atlas endpoint: {}", endpoint));
648    if !manifest.capabilities.is_empty() {
649        output::sub_step(&format!(
650            "Capabilities: {}",
651            manifest
652                .capabilities
653                .iter()
654                .map(|c| c.name.as_str())
655                .collect::<Vec<_>>()
656                .join(", ")
657        ));
658    }
659
660    let mut env = std::collections::HashMap::new();
661    env.insert("ROBONIX_ATLAS".to_string(), endpoint.clone());
662    env.insert("SCRIBE_LOG_DIR".to_string(), log_dir.display().to_string());
663    // The exact selection and compatibility permission are distinct. The
664    // historical marker name is retained across wrapper/container boundaries,
665    // but it is now true only for a legacy manifest that may use shared runtime
666    // stubs. Shared selections never receive downgrade permission.
667    env.insert(
668        "ROBONIX_DRIVER_CONTRACT_ID".to_string(),
669        expected_driver_contract.clone(),
670    );
671    // Always overwrite any inherited marker so an explicit selection can
672    // never accidentally inherit omission's downgrade permission.
673    env.insert(
674        "ROBONIX_DRIVER_ALLOW_OLD_ARTIFACT_FALLBACK".to_string(),
675        if allow_shared_driver_upgrade {
676            "1"
677        } else {
678            "0"
679        }
680        .to_string(),
681    );
682    if has_explicit_config && should_drive_standalone_init(has_driver_capability, deploy_managed) {
683        output::sub_step("Config: will deliver via Driver(CMD_INIT) post-register");
684    } else if has_explicit_config && deploy_managed {
685        output::sub_step("Config: deployment owner will deliver Driver(CMD_INIT)");
686    }
687    // Force unbuffered stdout/stderr in any Python child the package's
688    // start body launches. Without this, Python block-buffers stdout
689    // when it's a pipe (which `rbnx boot` always makes it), and a
690    // primitive whose driver is still alive never flushes its
691    // `Driver(cmd=0) received` line until the buffer fills or the
692    // process exits — so a 60-second boot full of "what is happening"
693    // looks like the package wedged at "ready - awaiting Driver".
694    // See `examples/webots/rbnx-boot/logs/primitive_tiago_camera.log`
695    // for the diagnostic this turned up. Override with PYTHONUNBUFFERED=
696    // (empty) in the manifest if a package really wants buffered output.
697    env.entry("PYTHONUNBUFFERED".to_string())
698        .or_insert_with(|| "1".to_string());
699
700    if !manifest.build.trim().is_empty() && !build::build_stamp_path(&package_root).exists() {
701        output::sub_step("No rbnx-build/.rbnx-built — running package build first");
702        build::build_local_package(&package_root, false, manifest_override)?;
703    }
704
705    let exports = env
706        .iter()
707        .map(|(k, v)| format!("export {}={}", k, shell_escape(v)))
708        .collect::<Vec<_>>()
709        .join("; ");
710    let pythonpath_export = generated_pythonpath_export(&package_root);
711    let start_body = manifest.start.trim();
712    let setup_bash = package_root
713        .join("rbnx-build")
714        .join("ws")
715        .join("install")
716        .join("setup.bash");
717    let setup_source = if setup_bash.exists() {
718        format!("source {}", shell_escape(&setup_bash.display().to_string()))
719    } else {
720        String::new()
721    };
722    let prefix_parts: Vec<String> = [setup_source, exports, pythonpath_export]
723        .into_iter()
724        .filter(|s| !s.is_empty())
725        .collect();
726    let start_command = if prefix_parts.is_empty() {
727        start_body.to_string()
728    } else {
729        format!("{}; {start_body}", prefix_parts.join("; "))
730    };
731
732    // A standalone lifecycle owner snapshots Atlas before spawning. Snapshot
733    // failures are fatal: treating them as an empty set could select an
734    // unrelated pre-existing provider and deliver this package's config to it.
735    // `rbnx boot` sets RBNX_DEPLOY_MANAGED and owns this sequence itself.
736    let standalone_lifecycle =
737        if should_drive_standalone_init(has_driver_capability, deploy_managed) {
738            let json = materialized_cfg_json
739                .expect("start config materialization always returns a JSON object");
740            let normalized = normalize_atlas_endpoint(&endpoint);
741            let mut atlas = AtlasClient::connect(&normalized).await.with_context(|| {
742                format!("connect Atlas at {normalized} before standalone spawn")
743            })?;
744            let before_snapshot = snapshot_provider_ids(&mut atlas)
745                .await
746                .context("standalone pre-spawn Atlas snapshot")?;
747            Some((
748                atlas,
749                before_snapshot,
750                json,
751                expected_driver_contract.clone(),
752                allow_shared_driver_upgrade,
753            ))
754        } else {
755            None
756        };
757
758    // Scribe tag = the per-INSTANCE provider id, never the package name. A
759    // single package (one `package.name`) can be deployed as N instances, each
760    // with a distinct provider id; tagging by package.name would collide them
761    // all into one log. `rbnx boot` passes the instance's provider id via
762    // RBNX_INSTANCE_NAME (the deploy manifest entry's `name`); fall back to
763    // package.name only for a bare standalone `rbnx start` with no instance.
764    let instance_name =
765        std::env::var("RBNX_INSTANCE_NAME").unwrap_or_else(|_| manifest.package.name.clone());
766    let result = if let Some((mut atlas, before, json, expected_contract, allow_shared_upgrade)) =
767        standalone_lifecycle
768    {
769        // `start_process` blocks for the package lifetime, so run it alongside
770        // registration/lifecycle driving. On lifecycle failure, stop the exact
771        // package process group instead of leaving a REGISTERED/ERROR provider.
772        let manager_for_start = Arc::clone(&process_manager);
773        let package_root_for_start = package_root.clone();
774        let start_command_for_start = start_command.clone();
775        let instance_for_start = instance_name.clone();
776        let mut process_task = tokio::spawn(async move {
777            manager_for_start
778                .start_process(
779                    &instance_for_start,
780                    &instance_for_start,
781                    "package",
782                    &package_root_for_start,
783                    &start_command_for_start,
784                )
785                .await
786        });
787
788        // Wait until ProcessManager has recorded the child so every lifecycle
789        // failure path can terminate it. Surface an early child exit directly.
790        let recorded_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
791        while !process_manager.has_process_record(&instance_name, "package") {
792            if process_task.is_finished() {
793                let process_result = process_task.await.context("package process task failed")?;
794                return match process_result {
795                    Ok(result) => anyhow::bail!(
796                        "package exited before registering with Atlas (PID {})",
797                        result.pid
798                    ),
799                    Err(error) => Err(error),
800                };
801            }
802            if tokio::time::Instant::now() >= recorded_deadline {
803                process_task.abort();
804                anyhow::bail!("package process was not recorded within 5s after spawn");
805            }
806            tokio::time::sleep(Duration::from_millis(25)).await;
807        }
808
809        let lifecycle = drive_standalone_lifecycle(
810            &mut atlas,
811            &before,
812            &instance_name,
813            &expected_contract,
814            allow_shared_upgrade,
815            json,
816        );
817        tokio::pin!(lifecycle);
818        tokio::select! {
819            lifecycle_result = &mut lifecycle => {
820                if let Err(error) = lifecycle_result {
821                    output::warning(&format!("standalone lifecycle failed: {error:#}"));
822                    if let Err(stop_error) = process_manager.stop_process(&instance_name, "package").await {
823                        output::warning(&format!("failed to stop package after lifecycle error: {stop_error:#}"));
824                    }
825                    let _ = process_task.await;
826                    return Err(error);
827                }
828            }
829            process_result = &mut process_task => {
830                let process_result = process_result.context("package process task failed")?;
831                return match process_result {
832                    Ok(result) => anyhow::bail!(
833                        "package exited before completing standalone lifecycle (PID {})",
834                        result.pid
835                    ),
836                    Err(error) => Err(error),
837                };
838            }
839        }
840        process_task
841            .await
842            .context("package process task failed")??
843    } else {
844        process_manager
845            .start_process(
846                &instance_name,
847                &instance_name,
848                "package",
849                &package_root,
850                &start_command,
851            )
852            .await?
853    };
854    output::check(&format!(
855        "{} exited (PID {})",
856        manifest.package.name, result.pid
857    ));
858
859    output::success(&format!("Package {} finished", manifest.package.name));
860    Ok(())
861}
862
863/// Build the package-local Python import path without touching a colcon
864/// workspace. A real `rbnx-build/ws/install/setup.bash`, when present, is
865/// sourced first; this export then prepends generated stubs while preserving
866/// every Python path contributed by that overlay and the parent environment.
867fn generated_pythonpath_export(package_root: &Path) -> String {
868    let codegen_root = package_root.join("rbnx-build").join("codegen");
869    let mut paths = vec![package_root.to_path_buf()];
870    for path in [
871        codegen_root.join("proto_gen"),
872        codegen_root.join("robonix_mcp_types"),
873    ] {
874        if path.is_dir() {
875            paths.push(path);
876        }
877    }
878    let joined = paths
879        .iter()
880        .map(|path| path.display().to_string())
881        .collect::<Vec<_>>()
882        .join(":");
883    format!(
884        "export PYTHONPATH={}:${{PYTHONPATH:-}}",
885        shell_escape(&joined)
886    )
887}
888
889fn should_drive_standalone_init(has_driver_capability: bool, deploy_managed: bool) -> bool {
890    has_driver_capability && !deploy_managed
891}
892
893fn normalize_atlas_endpoint(endpoint: &str) -> String {
894    if endpoint.starts_with("http") {
895        endpoint.to_string()
896    } else {
897        format!("http://{endpoint}")
898    }
899}
900
901/// Wait for exactly one new provider, verify that it declares the driver
902/// contract from this package manifest, then drive INIT and (except for
903/// skills) ACTIVATE. Contract verification is mandatory before any config is
904/// sent, so a provider exposing a different lifecycle cannot receive this
905/// package's configuration. Concurrent starts of two instances with the same
906/// driver contract still require a future registration token for full identity
907/// correlation.
908async fn drive_standalone_lifecycle(
909    atlas: &mut AtlasClient,
910    before: &ProviderRegistrationSnapshot,
911    expected_provider_id: &str,
912    expected_driver_contract: &str,
913    allow_shared_driver_upgrade: bool,
914    config_json: String,
915) -> Result<()> {
916    let outcome =
917        wait_for_registration_core(atlas, before, expected_provider_id, "rbnx start").await?;
918    let driver_contract = resolve_runtime_driver_contract(
919        &outcome.provider_id,
920        &outcome.provider_namespace,
921        expected_driver_contract,
922        &outcome.driver_contracts,
923        allow_shared_driver_upgrade,
924    )?;
925    if driver_contract != expected_driver_contract {
926        output::warning(&format!(
927            "provider '{}' publishes shared lifecycle Driver '{}' for legacy manifest selection '{}'; remove the legacy Driver declaration to finish migration",
928            outcome.provider_id, driver_contract, expected_driver_contract,
929        ));
930    }
931
932    let init_state = call_driver_cmd(
933        atlas,
934        &outcome.provider_id,
935        &driver_contract,
936        CMD_INIT,
937        config_json.clone(),
938        "rbnx start",
939    )
940    .await?;
941    output::sub_step(&format!(
942        "Driver(CMD_INIT) → {} ok (state={init_state})",
943        outcome.provider_id
944    ));
945    if should_activate_standalone_provider(outcome.provider_kind) {
946        let state = call_driver_cmd(
947            atlas,
948            &outcome.provider_id,
949            &driver_contract,
950            CMD_ACTIVATE,
951            config_json,
952            "rbnx start",
953        )
954        .await?;
955        output::sub_step(&format!(
956            "Driver(CMD_ACTIVATE) → {} ok (state={state})",
957            outcome.provider_id
958        ));
959    }
960    Ok(())
961}
962
963fn should_activate_standalone_provider(provider_kind: i32) -> bool {
964    provider_kind != atlas_pb::Kind::Skill as i32
965}
966
967/// Materialize a per-instance config from `--config <file>` plus
968/// repeatable `--set k.v=val` overrides. Returns the merged JSON
969/// string. When neither input was provided, returns an empty JSON object so
970/// `rbnx start` still performs the provider lifecycle initialization.
971///
972/// Layering: load file (json or yaml) → overlay each `--set` on the
973/// tree → serialise to a single JSON string. The string is delivered
974/// to the provider exclusively via Driver(CMD_INIT, config_json). The provider
975/// process MUST NOT read this through env / disk — that's the v0.1
976/// invariant `rbnx start` and `rbnx boot` both honour.
977fn build_start_config_json(config_file: Option<&Path>, sets: &[String]) -> Result<Option<String>> {
978    if config_file.is_none() && sets.is_empty() {
979        return Ok(Some("{}".to_string()));
980    }
981
982    let mut value: serde_json::Value = match config_file {
983        Some(p) => {
984            let raw = std::fs::read_to_string(p)
985                .with_context(|| format!("read config file {}", p.display()))?;
986            // Try JSON first; fall through to YAML.
987            match serde_json::from_str::<serde_json::Value>(&raw) {
988                Ok(v) => v,
989                Err(_) => {
990                    let y: serde_yaml::Value = serde_yaml::from_str(&raw)
991                        .with_context(|| format!("parse config {} as JSON or YAML", p.display()))?;
992                    serde_json::to_value(y)
993                        .with_context(|| format!("convert {} YAML→JSON", p.display()))?
994                }
995            }
996        }
997        None => serde_json::Value::Object(serde_json::Map::new()),
998    };
999
1000    for s in sets {
1001        let (key, raw_val) = s
1002            .split_once('=')
1003            .with_context(|| format!("--set {s:?}: expected KEY=VALUE"))?;
1004        let parsed: serde_json::Value = serde_json::from_str(raw_val)
1005            .unwrap_or_else(|_| serde_json::Value::String(raw_val.into()));
1006        merge_dotted(&mut value, key, parsed)?;
1007    }
1008
1009    Ok(Some(
1010        serde_json::to_string(&value).unwrap_or_else(|_| "{}".into()),
1011    ))
1012}
1013
1014/// Set `obj[a][b][c] = v` for a dotted key like `"a.b.c"`. Creates
1015/// intermediate objects as needed; bails on a non-object collision.
1016fn merge_dotted(root: &mut serde_json::Value, key: &str, v: serde_json::Value) -> Result<()> {
1017    let parts: Vec<&str> = key.split('.').filter(|p| !p.is_empty()).collect();
1018    if parts.is_empty() {
1019        anyhow::bail!("--set: empty key");
1020    }
1021    if !root.is_object() {
1022        *root = serde_json::Value::Object(serde_json::Map::new());
1023    }
1024    let mut cur = root;
1025    for p in &parts[..parts.len() - 1] {
1026        let map = cur.as_object_mut().ok_or_else(|| {
1027            anyhow::anyhow!("--set {key}: cannot descend into non-object at '{p}'")
1028        })?;
1029        let entry = map
1030            .entry((*p).to_string())
1031            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1032        if !entry.is_object() {
1033            *entry = serde_json::Value::Object(serde_json::Map::new());
1034        }
1035        cur = entry;
1036    }
1037    let last = parts[parts.len() - 1];
1038    cur.as_object_mut()
1039        .ok_or_else(|| anyhow::anyhow!("--set {key}: parent is not an object"))?
1040        .insert(last.to_string(), v);
1041    Ok(())
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047    use std::fs;
1048    use std::time::{SystemTime, UNIX_EPOCH};
1049
1050    fn temp_root(label: &str) -> PathBuf {
1051        let nonce = SystemTime::now()
1052            .duration_since(UNIX_EPOCH)
1053            .unwrap()
1054            .as_nanos();
1055        std::env::temp_dir().join(format!("rbnx-start-{label}-{}-{nonce}", std::process::id()))
1056    }
1057
1058    #[test]
1059    fn generated_pythonpath_is_injected_without_a_setup_stub() {
1060        let root = temp_root("pythonpath");
1061        let proto = root.join("rbnx-build/codegen/proto_gen");
1062        let mcp = root.join("rbnx-build/codegen/robonix_mcp_types");
1063        fs::create_dir_all(&proto).unwrap();
1064        fs::create_dir_all(&mcp).unwrap();
1065
1066        let export = generated_pythonpath_export(&root);
1067
1068        assert!(export.contains(&root.display().to_string()));
1069        assert!(export.contains(&proto.display().to_string()));
1070        assert!(export.contains(&mcp.display().to_string()));
1071        assert!(export.ends_with(":${PYTHONPATH:-}"));
1072        assert!(!root.join("rbnx-build/ws/install/setup.bash").exists());
1073        fs::remove_dir_all(root).unwrap();
1074    }
1075
1076    #[test]
1077    fn start_without_config_still_materializes_empty_init_config() {
1078        let config = build_start_config_json(None, &[]).unwrap();
1079        assert_eq!(config.as_deref(), Some("{}"));
1080    }
1081
1082    #[test]
1083    fn standalone_driver_start_owns_init_but_deploy_managed_start_does_not() {
1084        assert!(should_drive_standalone_init(true, false));
1085        assert!(!should_drive_standalone_init(true, true));
1086        assert!(!should_drive_standalone_init(false, false));
1087        assert!(!should_drive_standalone_init(false, true));
1088    }
1089
1090    #[test]
1091    fn standalone_start_activates_primitive_and_service_but_not_skill() {
1092        assert!(should_activate_standalone_provider(
1093            atlas_pb::Kind::Primitive as i32
1094        ));
1095        assert!(should_activate_standalone_provider(
1096            atlas_pb::Kind::Service as i32
1097        ));
1098        assert!(!should_activate_standalone_provider(
1099            atlas_pb::Kind::Skill as i32
1100        ));
1101    }
1102
1103    #[test]
1104    fn shared_driver_contract_needs_no_migration_warning() {
1105        assert_eq!(
1106            lifecycle_driver_migration_warning("test.scene", Some("robonix/lifecycle/driver"),),
1107            None
1108        );
1109    }
1110
1111    #[test]
1112    fn omitted_driver_is_the_canonical_shared_selection() {
1113        assert_eq!(lifecycle_driver_migration_warning("test.scene", None), None);
1114    }
1115
1116    #[test]
1117    fn legacy_driver_contract_gets_one_actionable_migration_warning() {
1118        let warning = lifecycle_driver_migration_warning(
1119            "test.camera",
1120            Some("robonix/primitive/camera/driver"),
1121        )
1122        .unwrap();
1123        assert!(warning.contains("test.camera"));
1124        assert!(warning.contains("robonix/primitive/camera/driver"));
1125        assert!(warning.contains("robonix/lifecycle/driver"));
1126        assert!(warning.contains("is backward-compatible"));
1127        assert!(warning.contains("exact <provider namespace>/driver"));
1128        assert!(warning.contains("shared runtime Driver"));
1129        assert!(warning.contains("do not declare both"));
1130    }
1131
1132    #[test]
1133    fn system_package_uses_the_deploy_selected_manifest() {
1134        let root = temp_root("system-manifest");
1135        let source_root = root.join("source");
1136        let scene_root = source_root.join("system/scene");
1137        let deploy_root = root.join("deploy");
1138        fs::create_dir_all(&scene_root).unwrap();
1139        fs::create_dir_all(&deploy_root).unwrap();
1140        fs::write(
1141            scene_root.join("package_manifest.yaml"),
1142            r#"manifestVersion: 1
1143package:
1144  name: test.system.scene
1145  version: 0.1.0
1146  vendor: test
1147  description: default target
1148  license: Apache-2.0
1149build: touch default-selected
1150start: "true"
1151"#,
1152        )
1153        .unwrap();
1154        fs::write(
1155            scene_root.join("package_manifest.jetson-native.yaml"),
1156            r#"manifestVersion: 1
1157package:
1158  name: test.system.scene
1159  version: 0.1.0
1160  vendor: test
1161  description: Jetson native target
1162  license: Apache-2.0
1163build: touch jetson-selected
1164start: "true"
1165"#,
1166        )
1167        .unwrap();
1168        let deploy_manifest = deploy_root.join("robonix_manifest.yaml");
1169        fs::write(
1170            &deploy_manifest,
1171            r#"manifestVersion: 1
1172name: system-target-test
1173system:
1174  scene:
1175    manifest: package_manifest.jetson-native.yaml
1176    config:
1177      camera_provider_id: front_camera
1178"#,
1179        )
1180        .unwrap();
1181        let config = Config {
1182            package_storage_path: root.join("packages"),
1183            robonix_source_path: Some(source_root),
1184        };
1185
1186        build_deploy_manifest(&deploy_manifest, &config, false, true).unwrap();
1187
1188        assert!(scene_root.join("jetson-selected").is_file());
1189        assert!(!scene_root.join("default-selected").exists());
1190        fs::remove_dir_all(root).unwrap();
1191    }
1192}