Skip to main content

rbnx/cmd/
deploy.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx boot` — bring up the whole robonix stack from a top-level
3// `robonix_manifest.yaml`. (`rbnx boot` is a back-compat alias.)
4//
5// Conventions:
6//   - `system:` Rust binaries (atlas / pilot / executor) are launched with
7//     CLI arguments translated from the manifest block (`--listen`,
8//     `--log`, `--vlm-*`, …). No env-var translation, no YAML config files.
9//   - Package entries (`primitive` / `service`) are launched serially:
10//     spawn → wait for the package to register a provider with a `*/driver`
11//     capability on atlas → call Driver(CMD_INIT, config_json) → wait for
12//     `ok=true`. Only after every primitive's driver returns ok do we move
13//     on to `service:` (which can depend on primitive data being ready).
14//     The package's `config:` block is JSON-encoded and delivered ONLY via
15//     Driver(CMD_INIT)'s config_json field. Omitting Driver is the canonical
16//     way to select the shared lifecycle service. An exact legacy manifest may
17//     use a current shared runtime Driver while it is migrated. A provider
18//     without exactly one lifecycle Driver fails startup.
19//     The provider process never sees a config file or env var.
20//   - `skill:` entries are spawned identically to `service:` — they
21//     need a long-lived process for their MCP tools to be registered
22//     on atlas. The semantic difference (skill = atomic intent
23//     invokable by pilot, service = always-on capability) lives in
24//     the contract namespace (`robonix/skill/*` vs `robonix/service/*`),
25//     not in the lifecycle. The earlier "skill is registered but not
26//     spawned" model lied about what was actually running and forced
27//     manifest authors to put skills like explore in `service:` as a
28//     workaround.
29//
30// Out of scope: crash-restart, health checks beyond Driver(INIT).
31
32use anyhow::{Context, Result};
33use robonix_atlas::client::AtlasClient;
34use robonix_atlas::pb as atlas_pb;
35use robonix_cli::launch::{
36    PackageRuntimeRecord, ProviderRegistrationSnapshot, RegistrationOutcome,
37    resolve_runtime_driver_contract, snapshot_provider_ids, terminate_process_group,
38};
39use robonix_cli::output;
40use serde::Deserialize;
41use std::collections::{HashMap, HashSet};
42use std::os::fd::RawFd;
43use std::path::{Path, PathBuf};
44use std::process::Stdio;
45use std::time::{Duration, Instant};
46use tokio::io::AsyncBufReadExt;
47use tokio::process::{Child, Command};
48use tokio::signal::unix::{SignalKind, signal};
49use tonic::Request;
50use tonic::transport::Endpoint;
51use uuid::Uuid;
52
53use robonix_scribe as scribe;
54
55use crate::pb::lifecycle::{DriverRequest, DriverResponse};
56
57use super::teardown;
58
59// Driver.srv command discriminators (mirrors lifecycle/srv/Driver.srv).
60const CMD_INIT: u32 = 0;
61const CMD_ACTIVATE: u32 = 1;
62#[allow(dead_code)]
63const CMD_DEACTIVATE: u32 = 2;
64#[allow(dead_code)]
65const CMD_SHUTDOWN: u32 = 3;
66// How long to wait for a freshly spawned package to register its driver
67// capability with atlas before giving up.
68const DRIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(60);
69// Default Driver(CMD_INIT) deadline. Webots CI can override this with
70// ROBONIX_DRIVER_INIT_TIMEOUT_S for real stacks whose lifecycle bringup may
71// exceed 90s on a cold self-hosted runner.
72const DEFAULT_DRIVER_INIT_TIMEOUT: Duration = Duration::from_secs(90);
73const DEPLOY_CONSUMER_ID: &str = "rbnx-cli/deploy";
74
75fn driver_init_timeout() -> Duration {
76    std::env::var("ROBONIX_DRIVER_INIT_TIMEOUT_S")
77        .ok()
78        .and_then(|s| s.parse::<u64>().ok())
79        .filter(|secs| *secs > 0)
80        .map(Duration::from_secs)
81        .unwrap_or(DEFAULT_DRIVER_INIT_TIMEOUT)
82}
83
84// ── Deploy manifest schema (subset used by this orchestrator) ───────────
85
86#[derive(Debug, Clone, Deserialize, Default)]
87struct DeployManifest {
88    #[serde(default)]
89    name: String,
90    #[serde(default)]
91    system: HashMap<String, serde_yaml::Value>,
92    #[serde(default)]
93    primitive: Vec<PackageEntry>,
94    #[serde(default)]
95    service: Vec<PackageEntry>,
96    #[serde(default)]
97    skill: Vec<PackageEntry>,
98}
99
100#[derive(Debug, Clone, Deserialize)]
101struct PackageEntry {
102    /// Package identifier for logs (falls back to the directory basename).
103    #[serde(default)]
104    name: String,
105    /// Local filesystem path (relative to the manifest dir). Mutually
106    /// exclusive with `url`.
107    #[serde(default)]
108    path: Option<String>,
109    /// Git URL for remote packages (e.g. the standalone mapping or nav
110    /// repos too big to ship inside `examples/`). `rbnx boot` clones
111    /// into `<manifest-dir>/rbnx-boot/cache/<name>/` on first run and
112    /// reuses that checkout on subsequent runs. Mutually exclusive with
113    /// `path`.
114    #[serde(default)]
115    url: Option<String>,
116    /// Git branch / tag / commit to check out. Defaults to the default
117    /// branch at clone time. Ignored when `path` is used.
118    #[serde(default)]
119    branch: Option<String>,
120    /// Opaque config block; serialised to JSON and delivered through
121    /// Driver(CMD_INIT). Startup fails if the provider does not declare its
122    /// selected shared or exact compatible legacy lifecycle Driver.
123    #[serde(default)]
124    config: serde_yaml::Value,
125    /// Optional package-manifest filename override. A package may ship
126    /// per-deployment-target manifests (e.g. `package_manifest.yaml` for
127    /// x86+docker, `package_manifest.jetson-native.yaml`,
128    /// `package_manifest.jetson-docker.yaml`), each with its own build/start.
129    /// This selects which one `rbnx build`/`boot` uses for THIS deployment;
130    /// the package-manifest schema itself is unchanged. Defaults to
131    /// `package_manifest.yaml`.
132    #[serde(default)]
133    manifest: Option<String>,
134}
135
136/// Compute a `PackageEntry`'s expected on-disk path. PURE — no I/O,
137/// no logging, no cloning. `path:` entries land at `manifest_dir/path`;
138/// `url:` entries land at `cache_root/<name>` (whether or not it's
139/// been cloned yet). Use `entry_path_exists_on_disk` to check
140/// presence; use the public `cmd::fetch::clone_remote_packages`
141/// (called from `rbnx build`) to actually populate the cache.
142/// Cache directory name for a url-remote package: the git REPO name (last path
143/// segment of the url, minus `.git`), NOT the per-instance provider id.
144///
145/// A single repo can back several providers/instances in one manifest (each
146/// with its own `name`/provider_id); they must share ONE clone. Keying the
147/// cache dir by `name` would clone the same repo once per instance — and the
148/// directory wouldn't reflect what was actually cloned. Key it by the repo.
149///
150/// Compatibility forwarding entry for sibling commands. The implementation
151/// lives in `robonix_cli::manifest` so Soma and rbnx use one rule.
152pub(crate) fn repo_dir_name(url: &str) -> String {
153    robonix_cli::manifest::deploy_repo_dir_name(url)
154}
155
156fn resolve_entry_path(
157    entry: &PackageEntry,
158    cache_root: &Path,
159    manifest_dir: &Path,
160) -> Result<PathBuf> {
161    match (&entry.path, &entry.url) {
162        (Some(p), None) => Ok(manifest_dir.join(p)),
163        (None, Some(url)) => Ok(cache_root.join(repo_dir_name(url))),
164        (Some(_), Some(_)) => {
165            anyhow::bail!("package entry has both `path` and `url`; pick one")
166        }
167        (None, None) => {
168            anyhow::bail!("package entry has neither `path` nor `url`")
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn boot_prerequisites_build_non_builtin_system_packages() {
179        let nonce = std::time::SystemTime::now()
180            .duration_since(std::time::UNIX_EPOCH)
181            .expect("system clock")
182            .as_nanos();
183        let temp = std::env::temp_dir().join(format!(
184            "rbnx-system-prerequisite-{}-{nonce}",
185            std::process::id()
186        ));
187        let scene = temp.join("system/scene");
188        std::fs::create_dir_all(&scene).expect("scene package directory");
189        std::fs::write(
190            scene.join("package_manifest.yaml"),
191            r#"manifestVersion: 1
192package:
193  name: com.robonix.system.scene.test
194  version: 0.1.0
195  description: test system package
196  license: MulanPSL-2.0
197build: mkdir -p rbnx-build && touch rbnx-build/proof
198start: "true"
199stop: "true"
200"#,
201        )
202        .expect("test package manifest");
203
204        let deploy = DeployManifest {
205            system: HashMap::from([("scene".to_string(), serde_yaml::Value::Null)]),
206            ..Default::default()
207        };
208        let manifest_dir = temp.join("deployment");
209        let cache_root = manifest_dir.join("rbnx-boot/cache");
210        std::fs::create_dir_all(&cache_root).expect("cache directory");
211
212        check_prerequisites(&deploy, &cache_root, &manifest_dir, Some(&temp))
213            .expect("system-package prerequisite build");
214
215        assert!(scene.join("rbnx-build/proof").is_file());
216        assert!(scene.join("rbnx-build/.rbnx-built").is_file());
217        std::fs::remove_dir_all(temp).expect("remove test directory");
218    }
219
220    #[test]
221    fn soma_always_receives_the_selected_boot_manifest() {
222        use serde_yaml::{Mapping, Value};
223
224        let manifest_dir = PathBuf::from("/tmp/ranger-deploy");
225        let selected = manifest_dir.join("robonix_manifest.arm.yaml");
226        let mut soma = Mapping::new();
227        // A stale local value must not make Soma boot the default profile
228        // after `rbnx boot -f <arm-profile>`.
229        soma.insert(
230            Value::String("deployment_manifest".into()),
231            Value::String("robonix_manifest.yaml".into()),
232        );
233        let mut system = HashMap::from([("soma".to_string(), Value::Mapping(soma))]);
234
235        ensure_soma_defaults(&mut system, &manifest_dir, &selected);
236        let args = system_cli_args("soma", system.get("soma"), None);
237        let manifest_arg = args
238            .windows(2)
239            .find(|pair| pair[0] == "--deployment-manifest")
240            .map(|pair| pair[1].as_str());
241
242        assert_eq!(manifest_arg, Some(selected.to_string_lossy().as_ref()));
243    }
244
245    #[test]
246    fn vitals_receives_typed_manifest_fields() {
247        let cfg: serde_yaml::Value = serde_yaml::from_str(
248            r#"
249listen: 0.0.0.0:50093
250provider_id: vitals
251thresholds_path: config/vitals.yaml
252soma_endpoint: 127.0.0.1:50091
253"#,
254        )
255        .unwrap();
256        let args = system_cli_args("vitals", Some(&cfg), Some("0.0.0.0:50051"));
257
258        for expected in [
259            ["--listen", "0.0.0.0:50093"],
260            ["--atlas", "0.0.0.0:50051"],
261            ["--id", "vitals"],
262            ["--thresholds-path", "config/vitals.yaml"],
263            ["--soma-endpoint", "127.0.0.1:50091"],
264        ] {
265            assert!(
266                args.windows(2)
267                    .any(|pair| pair[0] == expected[0] && pair[1] == expected[1]),
268                "missing {:?} in {:?}",
269                expected,
270                args
271            );
272        }
273    }
274
275    #[test]
276    fn provider_failure_ignores_later_shutdown_noise() {
277        let path = std::env::temp_dir().join(format!(
278            "rbnx-provider-failure-{}.log",
279            uuid::Uuid::new_v4()
280        ));
281        std::fs::write(
282            &path,
283            concat!(
284                "{\"level\":\"info\",\"msg\":\"ready -- awaiting Driver(CMD_INIT)\"}\n",
285                "{\"level\":\"info\",\"msg\":\"[ranger_chassis] state REGISTERED -> ERROR (CAN setup failed: sudo password required)\"}\n",
286                "{\"level\":\"info\",\"msg\":\"shutdown hook completed\"}\n",
287            ),
288        )
289        .unwrap();
290
291        assert_eq!(
292            read_provider_failure(&path).as_deref(),
293            Some("CAN setup failed: sudo password required")
294        );
295        let _ = std::fs::remove_file(path);
296    }
297
298    #[test]
299    fn provider_failure_accepts_error_level_records() {
300        let path = std::env::temp_dir().join(format!(
301            "rbnx-provider-error-level-{}.log",
302            uuid::Uuid::new_v4()
303        ));
304        std::fs::write(
305            &path,
306            "{\"level\":\"error\",\"msg\":\"camera device disconnected\"}\n",
307        )
308        .unwrap();
309
310        assert_eq!(
311            read_provider_failure(&path).as_deref(),
312            Some("camera device disconnected")
313        );
314        let _ = std::fs::remove_file(path);
315    }
316
317    #[test]
318    fn provider_exit_summary_uses_last_structured_message() {
319        let path = std::env::temp_dir().join(format!(
320            "rbnx-provider-exit-summary-{}.log",
321            uuid::Uuid::new_v4()
322        ));
323        std::fs::write(
324            &path,
325            concat!(
326                "{\"level\":\"info\",\"msg\":\"Traceback (most recent call last):\"}\n",
327                "{\"level\":\"info\",\"msg\":\"ImportError: generated contract is missing\"}\n",
328                "{\"level\":\"info\",\"msg\":\"Error: scene process exited with status 1\"}\n",
329            ),
330        )
331        .unwrap();
332
333        assert_eq!(
334            read_provider_exit_summary(&path).as_deref(),
335            Some("Error: scene process exited with status 1")
336        );
337        let _ = std::fs::remove_file(path);
338    }
339}
340
341/// Boot-time prerequisites check:
342///   - any url-remote package whose cache dir doesn't exist → warn,
343///     clone it inline (so the user isn't blocked) and tell them to
344///     run `rbnx build` for proper bring-up.
345///   - any package whose `rbnx-build/.rbnx-built` sentinel is missing
346///     → warn and run its build.sh inline.
347///
348/// Boot's job is to spawn and atlas-register; fetching and building
349/// belong to `rbnx build`. We do the inline remediation here ONLY so
350/// the user isn't stuck after a fresh clone with no build done — the
351/// warnings are deliberately loud so the right path (build first,
352/// then boot) stays visible.
353fn check_prerequisites(
354    deploy: &DeployManifest,
355    cache_root: &Path,
356    manifest_dir: &Path,
357    robonix_source_path: Option<&Path>,
358) -> Result<()> {
359    use std::collections::BTreeMap;
360    // value: (url, branch, manifest_override)
361    let mut needs_clone: BTreeMap<String, (String, Option<String>, Option<String>)> =
362        BTreeMap::new();
363    // value: (pkg_path, manifest_override)
364    let mut needs_build: BTreeMap<String, (PathBuf, Option<String>)> = BTreeMap::new();
365    for entry in deploy
366        .primitive
367        .iter()
368        .chain(deploy.service.iter())
369        .chain(deploy.skill.iter())
370    {
371        let pkg_path = match resolve_entry_path(entry, cache_root, manifest_dir) {
372            Ok(p) => p,
373            Err(_) => continue, // bad manifest entry; later steps will surface it
374        };
375        let name = if entry.name.is_empty() {
376            pkg_path
377                .file_name()
378                .and_then(|n| n.to_str())
379                .unwrap_or("(unnamed)")
380                .to_string()
381        } else {
382            entry.name.clone()
383        };
384        if !pkg_path.exists()
385            && let Some(url) = entry.url.as_ref()
386        {
387            needs_clone.insert(
388                name.clone(),
389                (url.clone(), entry.branch.clone(), entry.manifest.clone()),
390            );
391            continue;
392        }
393        let stamp = pkg_path.join("rbnx-build").join(".rbnx-built");
394        if !stamp.exists() {
395            needs_build.insert(name, (pkg_path, entry.manifest.clone()));
396        }
397    }
398
399    // Non-builtin `system:` entries are packages too.  They are resolved
400    // from the configured Robonix source tree rather than from an explicit
401    // deployment `path:`, so the package loop above cannot see them.  Build
402    // them during prerequisites just like primitive/service/skill packages;
403    // otherwise `rbnx start` performs the build after spawn and the provider
404    // registration timeout can kill a legitimate first build (Scene model
405    // downloads are a common example).
406    const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma"];
407    if let Some(source_root) = robonix_source_path {
408        for name in deploy.system.keys() {
409            if SYSTEM_BUILTINS.contains(&name.as_str()) {
410                continue;
411            }
412            let pkg_path = source_root.join("system").join(name);
413            if !pkg_path.exists() {
414                continue; // the later system-package loop reports optional packages
415            }
416            let stamp = pkg_path.join("rbnx-build").join(".rbnx-built");
417            if !stamp.exists() {
418                needs_build.insert(name.clone(), (pkg_path, None));
419            }
420        }
421    }
422    if needs_clone.is_empty() && needs_build.is_empty() {
423        return Ok(());
424    }
425    output::boot_section("prerequisites");
426    for (name, (url, branch, manifest_ov)) in &needs_clone {
427        output::warning(&format!(
428            "{name}: not in cache — `rbnx build` should run before `rbnx boot`. cloning inline."
429        ));
430        let dest = cache_root.join(repo_dir_name(url));
431        std::fs::create_dir_all(cache_root)?;
432        let mut clone = std::process::Command::new("git");
433        clone.arg("clone").arg("--depth").arg("1");
434        if let Some(b) = branch {
435            clone.arg("--branch").arg(b);
436        }
437        clone.arg(url).arg(&dest);
438        let status = clone
439            .status()
440            .with_context(|| format!("git clone {url} failed to spawn"))?;
441        if !status.success() {
442            anyhow::bail!("git clone {url} exited with {:?}", status.code());
443        }
444        // Newly-cloned package needs a build too.
445        let stamp = dest.join("rbnx-build").join(".rbnx-built");
446        if !stamp.exists() {
447            needs_build.insert(name.clone(), (dest, manifest_ov.clone()));
448        }
449    }
450    for (name, (pkg_path, manifest_ov)) in &needs_build {
451        output::warning(&format!(
452            "{name}: not built — `rbnx build` should run before `rbnx boot`. building inline."
453        ));
454        crate::cmd::build::build_local_package(pkg_path, false, manifest_ov.as_deref())
455            .with_context(|| format!("inline build of {name} at {} failed", pkg_path.display()))?;
456    }
457    Ok(())
458}
459
460/// Apply top-level deployment variables, then expand every scalar in the
461/// manifest. Build and boot share this preparation path so package locations,
462/// target-manifest selectors, system settings, and package config all resolve
463/// against the same environment.
464pub(super) fn prepare_manifest(
465    root: serde_yaml::Value,
466    robonix_source_path: Option<&Path>,
467) -> Result<serde_yaml::Value> {
468    let prepared = robonix_cli::manifest::prepare_deployment_manifest(root, robonix_source_path)?;
469    robonix_cli::manifest::validate_deployment_instance_names(&prepared)?;
470    Ok(prepared)
471}
472
473/// Make sure `system.soma` exists in the manifest map as a mapping,
474/// and resolve any relative file paths inside it against `manifest_dir`.
475///
476/// v2 soma has four flat config keys — `atlas_endpoint`, `listen`,
477/// `provider_id`, `robot_yaml` — and rbnx forwards them via CLI.
478///
479/// This helper handles the two "soma implied but not spelled out"
480/// manifest patterns:
481///   * `system.soma:` with no body — a bare tag or null.
482///   * no `system.soma:` at all, but `primitive:` / `skill:` present.
483///
484/// In both cases we promote / insert an empty mapping so the rest of
485/// deploy.rs (system_cli_args, the builtin loop, the stage 2 pipe)
486/// sees a populated entry. Existing operator-supplied values are
487/// NEVER overwritten.
488///
489/// It also normalises the two path-valued fields inside `system.soma`
490/// — `robot_yaml` and `config` — from possibly relative to always
491/// absolute. rbnx passes both straight through to `robonix-soma` as
492/// CLI flags without chdir-ing, and soma itself only resolves paths
493/// relative to its `--config` file's parent dir (not the manifest's
494/// dir), so any relative value written by the operator has to be
495/// pinned here or soma will try to open it from its own cwd — which
496/// on systemd-launched Jetsons is `/`, producing errors like
497/// `read Soma config '/soma_config.local.yaml' … No such file`.
498///
499/// Semantics: if the value is absolute it is left untouched (operator
500/// escape hatch for bind-mounts, /opt paths, etc.); if it's relative
501/// (including bare filenames like `soma.yaml`) it is joined onto the
502/// manifest's own directory. Non-string values are ignored — malformed
503/// manifests will surface the type mismatch at soma CLI parse time
504/// rather than being silently rewritten here.
505///
506/// `robot_yaml` auto-injection: soma refuses to boot without a
507/// `--robot-yaml` — it needs the robot description before it can
508/// spawn any primitive in stage 1. Previously we left this to the
509/// operator, but the failure mode ("missing robot_yaml" → soma exits
510/// → rbnx sits in `wait_for_soma_stage1` for the full 180s timeout
511/// before reporting failure) is disproportionately painful for a
512/// missing default. So: if the operator hasn't set `robot_yaml` and
513/// a file literally named `soma.yaml` sits next to the manifest, we
514/// inject that path. If neither is present, we leave the slot empty
515/// — soma will bail on config parse with a clear error, and the
516/// stage-1 waiter (see `wait_for_soma_stage1`) will surface soma's
517/// early exit instead of waiting for the timeout. Operators who
518/// want a different name still just set `robot_yaml:` explicitly.
519fn ensure_soma_defaults(
520    system: &mut HashMap<String, serde_yaml::Value>,
521    manifest_dir: &Path,
522    manifest_path: &Path,
523) {
524    use serde_yaml::{Mapping, Value};
525    let entry = system
526        .entry("soma".to_string())
527        .or_insert_with(|| Value::Mapping(Mapping::new()));
528    // Promote a non-mapping value (`soma: ~`, `soma: true`, ...) to an
529    // empty mapping so operators who wrote `soma:` with no body get a
530    // usable slot rather than a parse-time surprise.
531    if !entry.is_mapping() {
532        *entry = Value::Mapping(Mapping::new());
533    }
534    let map = entry
535        .as_mapping_mut()
536        .expect("promoted to mapping just above");
537
538    // Soma launches primitive and skill packages itself. It therefore must
539    // read the exact deployment file selected by `rbnx boot -f`, not infer a
540    // sibling default manifest from robot_yaml. This is intentionally owned
541    // by the boot command: a stale manifest-local value must not make rbnx
542    // build one profile while Soma starts another.
543    map.insert(
544        Value::String("deployment_manifest".to_string()),
545        Value::String(manifest_path.to_string_lossy().into_owned()),
546    );
547
548    // Auto-inject `robot_yaml: <manifest_dir>/soma.yaml` when the
549    // operator didn't set it AND the file exists. We check for the
550    // key's presence-and-non-emptiness rather than presence alone so
551    // an unset `${ROBOT_YAML}` (which manifest preparation turns into
552    // "") still triggers the sidecar lookup. Missing sidecar → leave
553    // absent (soma's own config-resolve error is the right signal;
554    // stage-1 waiter surfaces the early exit fast).
555    let robot_yaml_key = Value::String("robot_yaml".to_string());
556    let robot_yaml_missing = match map.get(&robot_yaml_key) {
557        None => true,
558        Some(Value::Null) => true,
559        Some(Value::String(s)) if s.is_empty() => true,
560        _ => false,
561    };
562    if robot_yaml_missing {
563        let sidecar = manifest_dir.join("soma.yaml");
564        if sidecar.is_file() {
565            map.insert(
566                robot_yaml_key,
567                Value::String(sidecar.to_string_lossy().into_owned()),
568            );
569        }
570    }
571
572    for key in ["robot_yaml", "deployment_manifest", "config"] {
573        let k = Value::String(key.to_string());
574        let Some(v) = map.get_mut(&k) else { continue };
575        let Some(s) = v.as_str() else { continue };
576        // Empty string usually means "${SOME_UNSET_VAR}" got expanded
577        // away by manifest preparation. Don't paper over that by turning
578        // it into `manifest_dir/` — leave it empty so soma's own
579        // "read Soma config '' … No such file" error still fires and
580        // the operator gets a signal instead of a mystery success.
581        if s.is_empty() {
582            continue;
583        }
584        let p = Path::new(s);
585        if p.is_absolute() {
586            continue;
587        }
588        let joined = manifest_dir.join(p);
589        *v = Value::String(joined.to_string_lossy().into_owned());
590    }
591}
592
593// ── child-process helpers ───────────────────────────────────────────────
594
595struct Spawned {
596    name: String,
597    /// "system_builtin" | "system_package" | "primitive" | "service"
598    kind: String,
599    child: Child,
600    pid: u32,
601    /// Process group id. Each child is spawned with `process_group(0)` so
602    /// it becomes the leader of a new PGID == its own PID.
603    pgid: u32,
604    provider_id: Option<String>,
605    driver_contract: Option<String>,
606    /// Lifecycle contract selected by this package's exact manifest. Builtin
607    /// system processes are not package-managed and leave this unset.
608    expected_driver_contract: Option<String>,
609    /// True only for an explicit legacy selection, permitting a current shared
610    /// runtime Driver while the manifest is migrated.
611    allow_shared_driver_upgrade: bool,
612    config_json: Option<String>,
613    package_dir: Option<PathBuf>,
614    stop: Option<String>,
615}
616
617fn log_path(log_dir: &Path, name: &str) -> PathBuf {
618    // `name` is the provider_id — the exact Scribe tag, so `<name>.log` is the
619    // real file rbnx should point at. No name mangling.
620    log_dir.join(format!("{name}.log"))
621}
622
623async fn spawn_system_binary(
624    log_dir: &Path,
625    name: &str,
626    bin: &str,
627    args: &[String],
628) -> Result<Spawned> {
629    // Run the installed binary directly.  Stdout / stderr are piped
630    // through Scribe (tag = binary name, e.g. "executor") so nothing
631    // escapes to the terminal.  Structured logs from within the binary
632    // also go through Scribe via the `log` facade auto-init.
633    let mut cmd = Command::new(bin);
634    for a in args {
635        cmd.arg(a);
636    }
637    cmd.stdin(Stdio::null())
638        .stdout(Stdio::piped())
639        .stderr(Stdio::piped())
640        .env("SCRIBE_LOG_DIR", log_dir)
641        .process_group(0);
642    let mut child = cmd.spawn().with_context(|| {
643        format!(
644            "failed to spawn system binary `{bin}` — is it installed (try `make install` from the rust/ workspace)?"
645        )
646    })?;
647    let pid = child
648        .id()
649        .ok_or_else(|| anyhow::anyhow!("spawned `{bin}` but it had no pid"))?;
650
651    // Pipe stdout / stderr into Scribe so raw println!/eprintln! from the
652    // binary are captured alongside its structured logs.
653    let stdout = child.stdout.take().expect("stdout not piped");
654    let stderr = child.stderr.take().expect("stderr not piped");
655    let tag_out = name.to_string();
656    let tag_err = name.to_string();
657    tokio::spawn(async move {
658        let reader = tokio::io::BufReader::new(stdout);
659        let mut lines = reader.lines();
660        while let Ok(Some(line)) = lines.next_line().await {
661            scribe::ingest(&tag_out, &line);
662        }
663    });
664    tokio::spawn(async move {
665        let reader = tokio::io::BufReader::new(stderr);
666        let mut lines = reader.lines();
667        while let Ok(Some(line)) = lines.next_line().await {
668            // stderr is not always errors — Python logging defaults to
669            // stderr for INFO too.  Use `info` to avoid misrepresenting
670            // the actual severity.
671            scribe::ingest(&tag_err, &line);
672        }
673    });
674    // Salient detail per builtin: port + role, redact long flag soup
675    // (--capabilities path lists, --vlm-api-key, …). Full args are
676    // available in the log file; the boot line stays terse so users
677    // can scan the bring-up sequence at a glance.
678    let detail = system_boot_detail(name, args);
679    output::boot_ok(name, &detail);
680    Ok(Spawned {
681        name: name.to_string(),
682        kind: "system_builtin".to_string(),
683        child,
684        pid,
685        pgid: pid,
686        provider_id: None,
687        driver_contract: None,
688        expected_driver_contract: None,
689        allow_shared_driver_upgrade: false,
690        config_json: None,
691        package_dir: None,
692        stop: None,
693    })
694}
695
696/// Fixed fd number rbnx exports as `ROBONIX_SOMA_STAGE_FD` in soma's
697/// environment. Any fd ≥ 3 works — we pick 3 because it's the first
698/// non-stdio slot, which keeps `ls /proc/<soma>/fd` readable at a
699/// glance. Soma reads the trigger line, then closes it.
700const SOMA_STAGE_FD: RawFd = 3;
701
702/// Spawn soma with an inherited pipe on `SOMA_STAGE_FD`. The parent
703/// keeps the write end and later writes `stage2\n` to it (see
704/// `write_stage2_trigger` below). Layered on `spawn_system_binary`'s
705/// stdio+scribe pattern but adds:
706///   * pipe() to create the trigger channel
707///   * pre_exec dup2 to move the child's end onto SOMA_STAGE_FD
708///     (the natural fd from pipe() is unpredictable — some later
709///     lib open() call could grab it — so we pin it to a known
710///     number)
711///   * ROBONIX_SOMA_STAGE_FD env so soma finds it
712///   * close-on-exec cleared on the child fd (dup2 clears it by
713///     default, which is what we want)
714///
715/// Returns the Spawned handle and the parent's write-end File. Drop
716/// the File to close the pipe (soma sees EOF and continues without
717/// stage 2 — matches the `no fd` env-absent path).
718async fn spawn_soma_binary(
719    log_dir: &Path,
720    name: &str,
721    bin: &str,
722    args: &[String],
723) -> Result<(Spawned, std::fs::File)> {
724    use std::os::fd::{AsRawFd, IntoRawFd, OwnedFd};
725
726    // Create the trigger pipe. Parent owns the write end for the
727    // lifetime of the boot; child inherits the read end. We keep
728    // the OwnedFd wrappers so an early error path drops the fds
729    // rather than leaking them.
730    let (read_fd, write_fd): (OwnedFd, OwnedFd) =
731        nix::unistd::pipe().context("pipe() for soma stage-2 trigger")?;
732    let child_raw = read_fd.as_raw_fd();
733
734    // tokio::process::Command is a thin wrapper over std::process,
735    // but pre_exec lives on the std side. We prime the std Command
736    // via .as_std_mut() below.
737    let mut cmd = Command::new(bin);
738    for a in args {
739        cmd.arg(a);
740    }
741    cmd.stdin(Stdio::null())
742        .stdout(Stdio::piped())
743        .stderr(Stdio::piped())
744        .env("SCRIBE_LOG_DIR", log_dir)
745        .env("ROBONIX_SOMA_STAGE_FD", SOMA_STAGE_FD.to_string())
746        .process_group(0);
747
748    // Hand the child's raw read fd into the closure. We can NOT let
749    // `read_fd` (the OwnedFd) run its Drop in the parent before the
750    // child inherits it — that would close the fd. Move ownership
751    // into the closure and leak/consume it there.
752    let read_owned = read_fd; // captured
753    let child_target = SOMA_STAGE_FD;
754    // Safety: pre_exec runs in the forked child before exec. Only
755    // async-signal-safe syscalls are permitted; dup2 and close are
756    // both on that list.
757    unsafe {
758        cmd.pre_exec(move || {
759            // dup2(oldfd, newfd) atomically closes newfd (if open)
760            // and duplicates oldfd onto it. The new fd has
761            // CLOEXEC=0 by default, which is what we want (soma
762            // needs to see it after exec).
763            let old = read_owned.as_raw_fd();
764            if old != child_target {
765                let ret = libc_dup2(old, child_target);
766                if ret < 0 {
767                    return Err(std::io::Error::last_os_error());
768                }
769                // Original fd number is no longer needed in the
770                // child; close it so it doesn't linger.
771                let _ = libc_close(old);
772            }
773            Ok(())
774        });
775    }
776
777    let mut child = cmd.spawn().with_context(|| {
778        format!(
779            "failed to spawn system binary `{bin}` — is it installed (try `make install` from the rust/ workspace)?"
780        )
781    })?;
782    let pid = child
783        .id()
784        .ok_or_else(|| anyhow::anyhow!("spawned `{bin}` but it had no pid"))?;
785
786    // fork() + our pre_exec dup2 have run; the child has its own
787    // copy on fd 3 and the fork copy of read_owned (whatever number
788    // pipe() picked). The parent's read_owned has been consumed by
789    // the closure — the value inside the parent process is a dead
790    // OwnedFd shell that will drop at the end of pre_exec's scope.
791    // Nothing left to close on this side; child_raw is only used
792    // for the debug/log line below.
793    let _ = child_raw;
794
795    // Turn the parent's write-end OwnedFd into a std File so the
796    // caller can write! into it. into_raw_fd releases ownership;
797    // File::from_raw_fd takes it back.
798    let write_raw = write_fd.into_raw_fd();
799    // Safety: write_raw is a valid, open, owned fd we just released
800    // from OwnedFd — we're transferring ownership one hop over.
801    let writer = unsafe { <std::fs::File as std::os::fd::FromRawFd>::from_raw_fd(write_raw) };
802
803    // Pipe stdout / stderr into Scribe. (Same pattern as
804    // spawn_system_binary — kept inline rather than extracted so
805    // both call sites stay readable.)
806    let stdout = child.stdout.take().expect("stdout not piped");
807    let stderr = child.stderr.take().expect("stderr not piped");
808    let tag_out = name.to_string();
809    let tag_err = name.to_string();
810    tokio::spawn(async move {
811        let reader = tokio::io::BufReader::new(stdout);
812        let mut lines = reader.lines();
813        while let Ok(Some(line)) = lines.next_line().await {
814            scribe::ingest(&tag_out, &line);
815        }
816    });
817    tokio::spawn(async move {
818        let reader = tokio::io::BufReader::new(stderr);
819        let mut lines = reader.lines();
820        while let Ok(Some(line)) = lines.next_line().await {
821            scribe::ingest(&tag_err, &line);
822        }
823    });
824
825    let detail = system_boot_detail(name, args);
826    output::boot_ok(name, &detail);
827    Ok((
828        Spawned {
829            name: name.to_string(),
830            kind: "system_builtin".to_string(),
831            child,
832            pid,
833            pgid: pid,
834            provider_id: None,
835            driver_contract: None,
836            expected_driver_contract: None,
837            allow_shared_driver_upgrade: false,
838            config_json: None,
839            package_dir: None,
840            stop: None,
841        },
842        writer,
843    ))
844}
845
846// Local libc thunks to avoid pulling libc as a direct dep — nix
847// exposes these via `nix::unistd::dup2` / `close`, but we need
848// async-signal-safety inside pre_exec and can't rely on nix's
849// wrappers not allocating on the error path. Raw syscalls are the
850// safe choice.
851unsafe extern "C" {
852    fn dup2(oldfd: i32, newfd: i32) -> i32;
853    fn close(fd: i32) -> i32;
854}
855#[inline]
856fn libc_dup2(oldfd: RawFd, newfd: RawFd) -> i32 {
857    unsafe { dup2(oldfd, newfd) }
858}
859#[inline]
860fn libc_close(fd: RawFd) -> i32 {
861    unsafe { close(fd) }
862}
863
864struct PackageSpawnEnv<'a> {
865    log_dir: &'a Path,
866    cache_root: &'a Path,
867    instances_dir: &'a Path,
868    manifest_dir: &'a Path,
869    atlas_endpoint: &'a str,
870}
871
872async fn spawn_package(
873    component: &str,
874    entry: &PackageEntry,
875    env: &PackageSpawnEnv<'_>,
876) -> Result<Spawned> {
877    let pkg_path = resolve_entry_path(entry, env.cache_root, env.manifest_dir)?;
878    let pkg_path = pkg_path
879        .canonicalize()
880        .with_context(|| format!("package path not found: {}", pkg_path.display()))?;
881
882    let name = if entry.name.is_empty() {
883        pkg_path
884            .file_name()
885            .and_then(|n| n.to_str())
886            .unwrap_or("package")
887            .to_string()
888    } else {
889        entry.name.clone()
890    };
891    let package_manifest =
892        robonix_cli::manifest::detect_and_load(&pkg_path, entry.manifest.as_deref())
893            .with_context(|| format!("load package manifest for {}", pkg_path.display()))?;
894    package_manifest.manifest.validate_and_summarize()?;
895    let explicit_driver_contract = package_manifest
896        .manifest
897        .explicit_lifecycle_driver_contract()?;
898    let allow_shared_driver_upgrade = explicit_driver_contract.is_some_and(|contract| {
899        contract != robonix_cli::manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT
900    });
901    let expected_driver_contract = Some(
902        package_manifest
903            .manifest
904            .selected_lifecycle_driver_contract()?
905            .to_string(),
906    );
907    let stop = package_manifest.manifest.stop.trim().to_string();
908    let stop = if stop.is_empty() { None } else { Some(stop) };
909    // Scribe tag + log-file stem = the provider_id (`entry.name`) verbatim.
910    // provider_id is unique per deploy (atlas enforces it), so no kind prefix
911    // is needed for disambiguation — `rbnx logs -t <provider_id>` and the file
912    // `<provider_id>.log` both key on the same name the user wrote.
913    let log_name = name.clone();
914
915    // Write this instance's config to disk for boot's own bookkeeping
916    // (debugging via `cat <instances>/<name>.json`, post-mortem
917    // inspection). Boot itself reads `entry.config` in-memory and
918    // pushes it via Driver(CMD_INIT, config_json) — see call_driver_cmd
919    // below. The provider process MUST NOT see this path; we do not export
920    // it as an env var to the spawned `rbnx start`.
921    let cfg_json = serde_json::to_value(&entry.config).unwrap_or(serde_json::Value::Null);
922    let cfg_pretty = serde_json::to_string_pretty(&cfg_json).unwrap_or_else(|_| "{}".into());
923    let cfg_file = env.instances_dir.join(format!("{name}.json"));
924    std::fs::write(&cfg_file, &cfg_pretty)
925        .with_context(|| format!("failed to write {}", cfg_file.display()))?;
926
927    // Spawn `rbnx start -p <pkg>` via the currently-running rbnx binary
928    // itself — i.e. argv[0] of the deploy process. This way deploy doesn't
929    // need a cargo workspace on disk and version-skew is impossible.
930    // Stdout / stderr are piped through Scribe (tag = log_name, e.g.
931    // "service_mapping") so boot-time display stays clean.
932    let rbnx_bin = std::env::current_exe()
933        .context("could not resolve current rbnx binary path for `start` re-exec")?;
934    // Per v0.1 layering: do NOT pass the config file path to the
935    // spawned `rbnx start` (which would propagate to the provider process
936    // env). rbnx boot itself drives Driver(CMD_INIT, config_json) over
937    // gRPC after the provider registers (see `call_driver_cmd` below). The
938    // cfg_file on disk is for boot's own use — we read it back via
939    // `entry.config` higher in this module — and atlas-side bookkeeping;
940    // the provider never sees it.
941    let _ = &cfg_file; // kept for debug / inspection; not exported
942    // Tell the provider which atlas to register with — derived from the
943    // manifest's `system.atlas.listen`, NOT the hard default 127.0.0.1:50051.
944    // Without this an alt-port deploy (e.g. an isolated CI run) leaves every
945    // provider dialing 50051 and failing to register. A bind-all listen
946    // (0.0.0.0) is rewritten to a dialable loopback for the provider; an
947    // in-container driver further overrides this via ROBONIX_SIM_ATLAS.
948    let provider_atlas = env.atlas_endpoint.replacen("0.0.0.0", "127.0.0.1", 1);
949    let mut cmd = Command::new(&rbnx_bin);
950    cmd.arg("start")
951        .arg("-p")
952        .arg(pkg_path.as_os_str())
953        .arg("--endpoint")
954        .arg(&provider_atlas)
955        .env("RBNX_INSTANCE_NAME", &name)
956        .env("RBNX_INVOCATION_CWD", env.manifest_dir)
957        // `rbnx start` must keep its package shell in this group.  Otherwise
958        // ProcessManager creates a nested PGID and boot's failure teardown
959        // kills only the wrapper, leaving the real package process orphaned.
960        .env("RBNX_DEPLOY_MANAGED", "1")
961        .env("SCRIBE_LOG_DIR", env.log_dir)
962        .stdin(Stdio::null())
963        .stdout(Stdio::piped())
964        .stderr(Stdio::piped())
965        .process_group(0);
966    // Per-deployment-target package manifest selector (deploy entry's
967    // `manifest:` field) — `rbnx start` loads this file instead of the
968    // default package_manifest.yaml so the right start path runs.
969    if let Some(m) = entry.manifest.as_deref() {
970        cmd.arg("--manifest").arg(m);
971    }
972    let mut child = cmd.spawn().with_context(|| {
973        format!(
974            "failed to spawn package {name} via `{} start`",
975            rbnx_bin.display()
976        )
977    })?;
978    let pid = child
979        .id()
980        .ok_or_else(|| anyhow::anyhow!("spawned package '{name}' but it had no pid"))?;
981
982    // Pipe stdout / stderr into Scribe — tag = provider_id, so the file is
983    // `<provider_id>.log` (e.g. "mapping.log").
984    let stdout = child.stdout.take().expect("stdout not piped");
985    let stderr = child.stderr.take().expect("stderr not piped");
986    let tag_out = log_name.clone();
987    let tag_err = log_name.clone();
988    tokio::spawn(async move {
989        let reader = tokio::io::BufReader::new(stdout);
990        let mut lines = reader.lines();
991        while let Ok(Some(line)) = lines.next_line().await {
992            scribe::ingest(&tag_out, &line);
993        }
994    });
995    tokio::spawn(async move {
996        let reader = tokio::io::BufReader::new(stderr);
997        let mut lines = reader.lines();
998        while let Ok(Some(line)) = lines.next_line().await {
999            // stderr is not always errors — Python logging defaults to
1000            // stderr for INFO too.  Use `info` to avoid misrepresenting
1001            // the actual severity.
1002            scribe::ingest(&tag_err, &line);
1003        }
1004    });
1005    // No spawn line here — wait until provider registration and emit one
1006    // boot_ok with the provider_id so each component takes ONE line in the
1007    // boot log instead of three (spawn + waiting + registered).
1008    let kind = match component {
1009        "system" => "system_package",
1010        other => other,
1011    }
1012    .to_string();
1013    Ok(Spawned {
1014        name: log_name,
1015        kind,
1016        child,
1017        pid,
1018        pgid: pid,
1019        provider_id: None,
1020        driver_contract: None,
1021        expected_driver_contract,
1022        allow_shared_driver_upgrade,
1023        config_json: None,
1024        package_dir: Some(pkg_path),
1025        stop,
1026    })
1027}
1028
1029// ── entry point ─────────────────────────────────────────────────────────
1030
1031pub async fn execute(
1032    config: robonix_cli::Config,
1033    manifest_path: PathBuf,
1034    log_dir: Option<PathBuf>,
1035    skip_system: bool,
1036    no_update_check: bool,
1037    verbose: bool,
1038) -> Result<()> {
1039    output::set_boot_verbose(verbose);
1040    let manifest_path = manifest_path
1041        .canonicalize()
1042        .with_context(|| format!("manifest not found: {}", manifest_path.display()))?;
1043    let manifest_dir = manifest_path
1044        .parent()
1045        .context("manifest has no parent directory")?
1046        .to_path_buf();
1047
1048    let raw = std::fs::read_to_string(&manifest_path)
1049        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
1050    let root: serde_yaml::Value = serde_yaml::from_str(&raw)
1051        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
1052    let root = prepare_manifest(root, config.robonix_source_path.as_deref())
1053        .with_context(|| format!("failed to prepare {}", manifest_path.display()))?;
1054    robonix_cli::manifest::validate_deployment_instance_names(&root).with_context(|| {
1055        format!(
1056            "invalid deployment identities in {}",
1057            manifest_path.display()
1058        )
1059    })?;
1060    let mut deploy: DeployManifest = serde_yaml::from_value(root)
1061        .with_context(|| format!("failed to decode {}", manifest_path.display()))?;
1062    // Banner + boot header FIRST, so the logo/version and what we're booting
1063    // lead the output — before the (possibly slow) remote freshness check.
1064    output::boot_banner();
1065    output::boot_start(
1066        if deploy.name.is_empty() {
1067            "robonix"
1068        } else {
1069            &deploy.name
1070        },
1071        &manifest_path.display().to_string(),
1072    );
1073    // Notice (non-fatal) if any cloned remote provider is behind upstream.
1074    // `--no-update-check` skips the per-package `git fetch` pass entirely.
1075    if !no_update_check {
1076        super::check_remotes::report_outdated(&manifest_path);
1077    }
1078
1079    // soma owns primitive + skill bring-up (see
1080    // docs/soma_two_stage_bringup.md). If the manifest declares ANY
1081    // primitive or skill, we MUST start a soma — otherwise those
1082    // packages are silently never spawned (boot looks "OK" because rbnx
1083    // got through atlas/executor/pilot, but the robot stays dead).
1084    //
1085    // Two manifest patterns we want to keep working without forcing
1086    // every existing deploy to add a `system.soma:` block:
1087    //   1. manifest has primitive/skill, no system.soma at all
1088    //      → inject a default soma block.
1089    //   2. manifest has system.soma but didn't set deployments / didn't
1090    //      set start_packages → fill in sane defaults (deployments =
1091    //      [this manifest's dir], start_packages = true).
1092    //
1093    // Both branches route through `ensure_soma_defaults` so the rest of
1094    // deploy.rs (spawning the soma binary in the builtin loop, sending
1095    // the stage 2 trigger after service: bring-up) just sees a
1096    // populated system.soma entry like any other.
1097    //
1098    // Existing operator-supplied values are NEVER overwritten — this
1099    // hole-fills, it does not override intent.
1100    //
1101    // We also run this whenever `system.soma` was explicitly declared,
1102    // even if there are no primitives/skills that would auto-imply
1103    // soma. Rationale: `ensure_soma_defaults` no longer just fills in
1104    // a mapping shell — it also resolves `robot_yaml` / `config`
1105    // relative paths against manifest_dir. An operator who writes
1106    // `system.soma: { config: soma_config.local.yaml }` on its own
1107    // deserves the same path-normalisation as the auto-injected case.
1108    let soma_declared = deploy.system.contains_key("soma");
1109    let soma_implied = !deploy.primitive.is_empty() || !deploy.skill.is_empty();
1110    if (soma_declared || soma_implied) && !skip_system {
1111        ensure_soma_defaults(&mut deploy.system, &manifest_dir, &manifest_path);
1112    }
1113
1114    let log_dir = log_dir.unwrap_or_else(|| manifest_dir.join("rbnx-boot").join("logs"));
1115    // The CLI prepares and clears this directory before Scribe's first log
1116    // call. Do not remove files here: Scribe may already hold open handles.
1117    std::fs::create_dir_all(&log_dir)
1118        .with_context(|| format!("failed to create log dir {}", log_dir.display()))?;
1119
1120    // SCRIBE_CONSOLE_LEVEL is set in main.rs before any scribe call.
1121    // Set SCRIBE_LOG_DIR so boot-time scribe messages (bootstrap,
1122    // child-process pipe forwarding) land in the deploy log dir rather
1123    // than the default ./logs.
1124    // Safety: called before any child spawns, no concurrent access.
1125    unsafe {
1126        std::env::set_var("SCRIBE_LOG_DIR", log_dir.as_os_str());
1127    }
1128    scribe::info(
1129        "bootstrap",
1130        &format!(
1131            "booting {} from {}",
1132            if deploy.name.is_empty() {
1133                "robonix"
1134            } else {
1135                &deploy.name
1136            },
1137            manifest_path.display()
1138        ),
1139    );
1140
1141    let cache_root = manifest_dir.join("rbnx-boot").join("cache");
1142    let instances_dir = manifest_dir.join("rbnx-boot").join("instances");
1143    std::fs::create_dir_all(&instances_dir)
1144        .with_context(|| format!("failed to create instances dir {}", instances_dir.display()))?;
1145
1146    let mut children: Vec<Spawned> = Vec::new();
1147    let state_path = teardown::state_path(&manifest_dir);
1148    let boot_id = Uuid::new_v4().to_string();
1149    // Every wrapper and provider inherits this marker. Persisted teardown
1150    // verifies it against /proc before signalling a PGID, preventing stale
1151    // state from killing an unrelated process after PID reuse.
1152    unsafe { std::env::set_var("RBNX_BOOT_ID", &boot_id) };
1153    let boot_start_time_ticks = robonix_cli::launch::proc_start_time_ticks(std::process::id());
1154    let started_at_ms = std::time::SystemTime::now()
1155        .duration_since(std::time::UNIX_EPOCH)
1156        .map(|d| d.as_millis() as u64)
1157        .unwrap_or(0);
1158    let atlas_endpoint = deploy
1159        .system
1160        .get("atlas")
1161        .and_then(|v| v.as_mapping())
1162        .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1163        .and_then(|v| v.as_str())
1164        .unwrap_or("127.0.0.1:50051")
1165        .to_string();
1166    teardown::write_state(
1167        &state_path,
1168        &teardown::BootState {
1169            manifest_path: manifest_path.display().to_string(),
1170            boot_pid: std::process::id(),
1171            boot_start_time_ticks,
1172            boot_id: boot_id.clone(),
1173            started_at_ms,
1174            atlas_endpoint: atlas_endpoint.clone(),
1175            components: Vec::new(),
1176        },
1177    )?;
1178    super::boot_watchdog::spawn(
1179        &state_path,
1180        std::process::id(),
1181        boot_start_time_ticks,
1182        &boot_id,
1183    )?;
1184    let spawn_env = PackageSpawnEnv {
1185        log_dir: &log_dir,
1186        cache_root: &cache_root,
1187        instances_dir: &instances_dir,
1188        manifest_dir: &manifest_dir,
1189        atlas_endpoint: &atlas_endpoint,
1190    };
1191
1192    // Boot is responsible for spawning + atlas registration ONLY.
1193    // Fetching (git clone of url-remote pkgs) and building are
1194    // `rbnx build`'s job. We just verify both have happened; if
1195    // not, warn loudly and remediate inline so the user isn't
1196    // stuck on a fresh clone.
1197    check_prerequisites(
1198        &deploy,
1199        &cache_root,
1200        &manifest_dir,
1201        config.robonix_source_path.as_deref(),
1202    )?;
1203
1204    // Install the SIGINT/SIGTERM handlers BEFORE bringup begins, not after.
1205    // Bringup takes many seconds (git, spawns, waiting for ACTIVE); a Ctrl-C
1206    // in that window used to hit the default disposition and kill rbnx
1207    // outright, orphaning every child already spawned. Racing the bringup
1208    // future against these streams lets us tear the partial stack down
1209    // instead. (SIGKILL can't be trapped — only SIGINT/SIGTERM.) The same
1210    // streams are reused for the post-boot idle wait further down.
1211    let mut sigint = signal(SignalKind::interrupt())?;
1212    let mut sigterm = signal(SignalKind::terminate())?;
1213
1214    // Owns the parent side of the stage-2 trigger pipe (created inside
1215    // `spawn_soma_binary`, drained by `write_stage2_trigger`). Declared
1216    // outside `bringup` so it survives the async-block scope even
1217    // though we only assign into it from inside.
1218    let mut soma_stage_writer: Option<std::fs::File> = None;
1219
1220    let bringup = async {
1221        if !skip_system {
1222            output::boot_section("system");
1223            // System Rust binaries: launched in atlas → executor → pilot order.
1224            // Each is fed CLI flags translated from `system.<name>:` block.
1225            // executor + pilot inherit `--atlas` from `system.atlas.listen`
1226            // unless they declare their own `atlas:` (rare).
1227            let atlas_listen = deploy
1228                .system
1229                .get("atlas")
1230                .and_then(|v| v.as_mapping())
1231                .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1232                .and_then(|v| v.as_str())
1233                .map(str::to_string);
1234            let soma_listen = deploy
1235                .system
1236                .get("soma")
1237                .and_then(|v| v.as_mapping())
1238                .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1239                .and_then(|v| v.as_str())
1240                .map(|s| s.replacen("0.0.0.0", "127.0.0.1", 1));
1241            // Atlas's contract registry walks every dir in
1242            // --capabilities at startup. We seed it with:
1243            //   1. <robonix_source>/capabilities — the global tree
1244            //   2. <pkg>/capabilities for every primitive/service/skill
1245            //      package whose source dir is on disk and contains a
1246            //      `capabilities/` subdir
1247            // Roots are merged in order; later wins on duplicate id, so
1248            // a package can re-declare a global contract for itself.
1249            // A manifest-level override `system.atlas.capabilities`
1250            // still wins via system_cli_args (clobbers the auto list).
1251            let mut atlas_caps_roots: Vec<String> = Vec::new();
1252            if let Some(root) = config.robonix_source_path.as_ref() {
1253                atlas_caps_roots.push(root.join("capabilities").to_string_lossy().into_owned());
1254            }
1255            for entry in deploy
1256                .primitive
1257                .iter()
1258                .chain(deploy.service.iter())
1259                .chain(deploy.skill.iter())
1260            {
1261                if let Ok(pkg_path) = resolve_entry_path(entry, &cache_root, &manifest_dir) {
1262                    let providers = pkg_path.join("capabilities");
1263                    if providers.is_dir() {
1264                        atlas_caps_roots.push(providers.to_string_lossy().into_owned());
1265                    }
1266                }
1267            }
1268            let atlas_caps_default: Option<String> = if atlas_caps_roots.is_empty() {
1269                None
1270            } else {
1271                Some(atlas_caps_roots.join(","))
1272            };
1273            let bin_map: &[(&str, &str)] = &[
1274                ("atlas", "robonix-atlas"),
1275                ("executor", "robonix-executor"),
1276                ("soma", "robonix-soma"),
1277                ("vitals", "robonix-vitals"),
1278                ("pilot", "robonix-pilot"),
1279                ("liaison", "robonix-liaison"),
1280            ];
1281            for (name, bin) in bin_map {
1282                if !deploy.system.contains_key(*name) {
1283                    continue;
1284                }
1285                let mut args =
1286                    system_cli_args(name, deploy.system.get(*name), atlas_listen.as_deref());
1287                if *name == "vitals"
1288                    && !args.iter().any(|arg| arg == "--soma-endpoint")
1289                    && let Some(endpoint) = soma_listen.as_ref()
1290                {
1291                    args.push("--soma-endpoint".into());
1292                    args.push(endpoint.clone());
1293                }
1294                if *name == "atlas"
1295                    && !args.iter().any(|a| a == "--capabilities")
1296                    && let Some(p) = atlas_caps_default.as_ref()
1297                {
1298                    args.push("--capabilities".into());
1299                    args.push(p.clone());
1300                }
1301                // Refuse to spawn if the listen port is already taken — without
1302                // this, the spawned binary silently dies on bind() failure but
1303                // boot keeps going against whoever already owns the port (often
1304                // a stale debug-build atlas/executor/etc from a prior aborted
1305                // run). The fallout is mysterious: register_capability hits an
1306                // atlas that doesn't have your takeover/state-push fixes,
1307                // endpoints route to dead orphan gRPC servers, …
1308                if let Some(listen) = system_listen(name, deploy.system.get(*name))
1309                    && let Err(e) = port_is_free(&listen)
1310                {
1311                    output::boot_fail(
1312                        name,
1313                        &format!(
1314                            "listen address '{listen}' is taken: {e:#}. \
1315                                  Stop the running process (try `bash sim/stop.sh` \
1316                                  or `pkill -f robonix-{name}`) and retry."
1317                        ),
1318                    );
1319                    anyhow::bail!(
1320                        "system/{name}: listen address '{listen}' is already in use; \
1321                         refusing to spawn (would shadow the existing process)"
1322                    );
1323                }
1324
1325                // Required-arg validation before spawn. Without this, an empty
1326                // `${VLM_BASE_URL}` (forgot to source the env file) makes pilot
1327                // start, register_capability briefly, then die with `missing
1328                // required field 'vlm.upstream'`. Boot still printed `[ OK ]`
1329                // because we never re-checked. Fail fast at spawn time and tell
1330                // the user exactly what's missing.
1331                if let Err(e) = require_system_args(name, &args) {
1332                    output::boot_fail(name, &e);
1333                    anyhow::bail!("system/{name}: {e}");
1334                }
1335
1336                let sp = if *name == "soma" {
1337                    // soma needs an inherited pipe fd for the stage-2
1338                    // trigger. Everything else uses the plain spawn path.
1339                    let (sp, writer) = spawn_soma_binary(&log_dir, name, bin, &args).await?;
1340                    soma_stage_writer = Some(writer);
1341                    sp
1342                } else {
1343                    spawn_system_binary(&log_dir, name, bin, &args).await?
1344                };
1345                children.push(sp);
1346                persist_state(
1347                    &state_path,
1348                    &manifest_path,
1349                    &atlas_endpoint,
1350                    started_at_ms,
1351                    &children,
1352                );
1353                tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1354                if *name == "soma" {
1355                    if !deploy.primitive.is_empty() {
1356                        output::boot_section("primitive");
1357                        if output::boot_verbose() {
1358                            for entry in &deploy.primitive {
1359                                output::boot_wait(&entry.name, "waiting for registration");
1360                            }
1361                        }
1362                    }
1363                    let mut stage1_atlas = AtlasClient::connect_with_retry(
1364                        &atlas_endpoint,
1365                        20,
1366                        Duration::from_millis(500),
1367                    )
1368                    .await
1369                    .with_context(|| {
1370                        format!("connect to atlas at '{atlas_endpoint}' for primitive readiness")
1371                    })?;
1372                    // We just pushed the soma `Spawned` above; grab a
1373                    // mutable borrow on its Child so the stage-1 waiter
1374                    // can `try_wait()` per tick. Without this the waiter
1375                    // sits for the full SOMA_STAGE1_TIMEOUT even when
1376                    // soma exited immediately (e.g. `missing robot_yaml`
1377                    // config error). The unwrap is safe: we just pushed.
1378                    let soma_child = &mut children
1379                        .last_mut()
1380                        .expect("soma Spawned pushed above")
1381                        .child;
1382                    wait_for_soma_stage1(
1383                        &mut stage1_atlas,
1384                        &deploy
1385                            .primitive
1386                            .iter()
1387                            .map(|entry| entry.name.clone())
1388                            .collect::<Vec<_>>(),
1389                        soma_child,
1390                        &log_dir,
1391                    )
1392                    .await?;
1393
1394                    // soma just finished stage 1 (all primitives ACTIVE). The
1395                    // next thing the operator sees on this terminal is the
1396                    // remaining builtins (pilot, liaison — whichever come
1397                    // after soma in `bin_map`) followed by the non-builtin
1398                    // `system:` entries loop below (memory / scene / speech
1399                    // / …). Both cohorts are *system services*, NOT
1400                    // primitives, but without a fresh section header they
1401                    // visually chain onto the "primitive" header just above
1402                    // and readers routinely mistake pilot for a primitive
1403                    // — the exact confusion this header exists to prevent.
1404                    //
1405                    // Only emit the header if there's actually something
1406                    // downstream to label. Concretely: at least one builtin
1407                    // ordered after soma in `bin_map` is declared in the
1408                    // manifest, OR the manifest has any non-builtin
1409                    // `system:` key that will run in the loop after this
1410                    // for-loop finishes. Otherwise (e.g. an atlas-executor-
1411                    // soma-only deploy) skip the header — dangling section
1412                    // titles with nothing under them are worse than none.
1413                    let builtin_after_soma = bin_map
1414                        .iter()
1415                        .skip_while(|(n, _)| *n != "soma")
1416                        .skip(1) // drop soma itself
1417                        .any(|(n, _)| deploy.system.contains_key(*n));
1418                    let has_non_builtin_system = deploy
1419                        .system
1420                        .keys()
1421                        .any(|k| !bin_map.iter().any(|(n, _)| n == k));
1422                    if builtin_after_soma || has_non_builtin_system {
1423                        output::boot_section("system");
1424                    }
1425                }
1426            }
1427        } else {
1428            output::sub_step("Skipping system bring-up (--skip-system)");
1429        }
1430
1431        // Connect to atlas once; reuse for every primitive/service init dance.
1432        let mut atlas =
1433            AtlasClient::connect_with_retry(&atlas_endpoint, 20, Duration::from_millis(500))
1434                .await
1435                .with_context(|| {
1436                    format!("connect to atlas at '{atlas_endpoint}' for lifecycle init")
1437                })?;
1438
1439        // Non-builtin `system:` keys (memory / speech / …) are real robonix
1440        // packages — same start/init/register flow as primitive/service, just
1441        // resolved by name against `<robonix_source>/system/<key>/`. Builtin
1442        // Rust binaries (atlas/executor/pilot) were spawned above and skipped
1443        // here. A key whose package directory is missing on disk is warned
1444        // and skipped, not fatal — manifests can declare optional services
1445        // that aren't installed yet (e.g. liaison while it's being ported).
1446        // Best-effort boot: a failure on any non-system-builtin package is
1447        // recorded but does NOT bail the whole bring-up. Goal is to get
1448        // atlas + executor + pilot + liaison up so `rbnx chat` can still
1449        // be poked at even when scene / memory / mapping is broken — the
1450        // alternative (the previous fail-fast model) means a single
1451        // package's milvus lock or sensor-init quirk gates every other
1452        // component the operator wants to test.
1453        //
1454        // System builtins (atlas/executor/pilot/liaison) are still
1455        // bail-on-error: nothing else makes sense without those.
1456        let mut failures: Vec<(String, String, String)> = Vec::new(); // (component, name, err)
1457
1458        if !skip_system {
1459            let builtin_names: &[&str] =
1460                &["atlas", "executor", "pilot", "liaison", "soma", "vitals"];
1461            for (key, value) in &deploy.system {
1462                if builtin_names.contains(&key.as_str()) {
1463                    continue;
1464                }
1465                let pkg_dir = match config.robonix_source_path.as_ref() {
1466                    Some(root) => root.join("system").join(key),
1467                    None => {
1468                        output::boot_skip(
1469                            key,
1470                            "robonix_source_path unset (`rbnx setup` from repo root)",
1471                        );
1472                        continue;
1473                    }
1474                };
1475                if !pkg_dir.exists() {
1476                    output::boot_skip(key, "not on disk");
1477                    continue;
1478                }
1479                let (manifest_override, runtime_config) =
1480                    robonix_cli::manifest::split_system_package_config(value)
1481                        .with_context(|| format!("parse system/{key} package selector"))?;
1482                let entry = PackageEntry {
1483                    name: key.clone(),
1484                    path: Some(pkg_dir.to_string_lossy().into_owned()),
1485                    url: None,
1486                    branch: None,
1487                    config: runtime_config,
1488                    manifest: manifest_override,
1489                };
1490                match spawn_and_init("system", &entry, &spawn_env, &mut atlas).await {
1491                    Ok(sp) => {
1492                        children.push(sp);
1493                        persist_state(
1494                            &state_path,
1495                            &manifest_path,
1496                            &atlas_endpoint,
1497                            started_at_ms,
1498                            &children,
1499                        );
1500                    }
1501                    Err(e) => {
1502                        failures.push(("system".to_string(), key.clone(), format!("{e:#}")));
1503                    }
1504                }
1505            }
1506        }
1507
1508        // primitive: handled by soma's stage 1 (kicked off when soma
1509        //   starts up, finishes before soma declares get_yaml/get_urdf).
1510        // skill:     handled by soma's stage 2 (kicked off by the
1511        //   StageTrigger("stage2") we send below, after non-builtin
1512        //   system services and service: entries have finished
1513        //   bring-up — which is the earliest skills can safely talk
1514        //   to executor/pilot/memory/scene from their MCP tools).
1515        // The deploy.primitive / deploy.skill fields stay in the
1516        // manifest schema because soma reads them; rbnx just doesn't
1517        // launch those processes any more.
1518        if !deploy.service.is_empty() {
1519            output::boot_section("service");
1520        }
1521        for e in &deploy.service {
1522            match spawn_and_init("service", e, &spawn_env, &mut atlas).await {
1523                Ok(sp) => {
1524                    children.push(sp);
1525                    persist_state(
1526                        &state_path,
1527                        &manifest_path,
1528                        &atlas_endpoint,
1529                        started_at_ms,
1530                        &children,
1531                    );
1532                }
1533                Err(err) => {
1534                    failures.push(("service".to_string(), e.name.clone(), format!("{err:#}")));
1535                }
1536            }
1537        }
1538
1539        // All system + service bring-up done; fire soma's stage 2
1540        // trigger so it spawns + INITs the skill packages. Best
1541        // effort: a soma that never went ACTIVE (or shut down
1542        // between our checks and this NotifyProvider) is a deploy
1543        // problem the operator already sees in earlier boot output;
1544        // we log and continue rather than tearing everything down
1545        // for the skills that never started.
1546        //
1547        // The section is labelled "skill" (not "stage 2") because
1548        // that's what the operator actually sees launching under the
1549        // header — the "stage 2" name is an internal rbnx↔soma pipe
1550        // protocol detail (see `STAGE2_TRIGGER` in soma/main.rs and
1551        // `write_stage2_trigger` below). Keeping the wire word out
1552        // of the terminal UI avoids operators having to learn our
1553        // two-stage bring-up vocabulary just to read boot output.
1554        if deploy.system.contains_key("soma") && !skip_system {
1555            output::boot_section("skill");
1556            if output::boot_verbose() {
1557                for entry in &deploy.skill {
1558                    output::boot_wait(&entry.name, "waiting for registration");
1559                }
1560            }
1561            if let Err(e) = write_stage2_trigger(&mut soma_stage_writer) {
1562                failures.push((
1563                    "system".to_string(),
1564                    "soma".to_string(),
1565                    format!("start skill packages: {e:#}"),
1566                ));
1567            } else if let Err(e) = wait_for_soma_skills(
1568                &mut atlas,
1569                &deploy
1570                    .skill
1571                    .iter()
1572                    .map(|entry| entry.name.clone())
1573                    .collect::<Vec<_>>(),
1574            )
1575            .await
1576            {
1577                failures.push(("skill".to_string(), "soma".to_string(), format!("{e:#}")));
1578            }
1579        }
1580        Ok(failures)
1581    };
1582
1583    // Race bringup against the signal streams. On a mid-boot signal the
1584    // pinned future is dropped at the end of this scope (cancelling bringup
1585    // at its current await point), which releases its `&mut children` borrow
1586    // so we can tear down whatever was spawned so far.
1587    let mut interrupted_during_boot = false;
1588    let outcome: Result<Vec<(String, String, String)>> = {
1589        tokio::pin!(bringup);
1590        tokio::select! {
1591            o = &mut bringup => o,
1592            _ = sigint.recv() => { interrupted_during_boot = true; Ok(Vec::new()) }
1593            _ = sigterm.recv() => { interrupted_during_boot = true; Ok(Vec::new()) }
1594        }
1595    };
1596
1597    if interrupted_during_boot {
1598        output::action(
1599            "Interrupted",
1600            &format!("tearing down {} partial child(ren)", children.len()),
1601        );
1602        scribe::info(
1603            "bootstrap",
1604            &format!(
1605                "boot interrupted by signal — tearing down {} partial children",
1606                children.len()
1607            ),
1608        );
1609        persist_state(
1610            &state_path,
1611            &manifest_path,
1612            &atlas_endpoint,
1613            started_at_ms,
1614            &children,
1615        );
1616        let providers = component_records(&children);
1617        let complete = teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1618        if !complete {
1619            anyhow::bail!(
1620                "interrupted boot left identity-mismatched process groups; preserving {}",
1621                state_path.display()
1622            );
1623        }
1624        for sp in &mut children {
1625            let _ = sp.child.wait().await;
1626        }
1627        let _ = std::fs::remove_file(&state_path);
1628        return Ok(());
1629    }
1630
1631    let failures = match outcome {
1632        Ok(failures) => failures,
1633        Err(e) => {
1634            output::action("Boot failed", &format!("{e:#}"));
1635            // System-builtin failure is still terminal — no point
1636            // pretending the deploy is usable when atlas itself didn't come
1637            // up. Reap whatever we did spawn before bailing.
1638            persist_state(
1639                &state_path,
1640                &manifest_path,
1641                &atlas_endpoint,
1642                started_at_ms,
1643                &children,
1644            );
1645            let providers = component_records(&children);
1646            let complete =
1647                teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1648            if complete {
1649                let _ = std::fs::remove_file(&state_path);
1650            } else {
1651                return Err(e.context(format!(
1652                    "cleanup refused identity-mismatched process groups; preserving {}",
1653                    state_path.display()
1654                )));
1655            }
1656            return Err(e);
1657        }
1658    };
1659
1660    if !failures.is_empty() {
1661        output::boot_section("failures");
1662        for (component, name, err) in &failures {
1663            // Trim the err to a single line — the full stack already lives
1664            // in the per-package log file we listed in the FAIL line.
1665            let one_line = err.lines().next().unwrap_or(err.as_str());
1666            output::boot_fail(name, &format!("[{component}] {one_line}"));
1667        }
1668        eprintln!();
1669        eprintln!(
1670            "  {} of {} packages failed to start; the rest are running. \
1671             `rbnx caps` to inspect, `rbnx shutdown` to tear down.",
1672            failures.len(),
1673            failures.len() + children.len(),
1674        );
1675    }
1676
1677    output::success(&format!(
1678        "{} component(s) up; logs under {}",
1679        children.len(),
1680        log_dir.display()
1681    ));
1682    if failures.is_empty() {
1683        scribe::info("bootstrap", "all components up — waiting for signal");
1684    } else {
1685        scribe::info(
1686            "bootstrap",
1687            &format!(
1688                "{} component(s) up, {} package(s) failed — waiting for signal",
1689                children.len(),
1690                failures.len()
1691            ),
1692        );
1693    }
1694    output::sub_step("Ctrl-C to tear down (or run `rbnx shutdown` from another shell).");
1695
1696    // Wait for SIGINT / SIGTERM (reusing the streams installed before
1697    // bringup), then shut children down.
1698    tokio::select! {
1699        _ = sigint.recv() => {}
1700        _ = sigterm.recv() => {}
1701    }
1702    output::action("Stopping", &format!("{} child(ren)", children.len()));
1703    scribe::info(
1704        "bootstrap",
1705        &format!(
1706            "shutdown signal received, tearing down {} children",
1707            children.len()
1708        ),
1709    );
1710    let providers = component_records(&children);
1711    let complete = teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1712    if !complete {
1713        anyhow::bail!(
1714            "shutdown refused identity-mismatched process groups; preserving {}",
1715            state_path.display()
1716        );
1717    }
1718    // Best-effort wait so we get clean "exited" lines in our own log.
1719    for sp in &mut children {
1720        let _ = sp.child.wait().await;
1721    }
1722    let _ = std::fs::remove_file(&state_path);
1723    Ok(())
1724}
1725
1726fn component_records(children: &[Spawned]) -> Vec<teardown::ComponentRecord> {
1727    children
1728        .iter()
1729        .map(|s| PackageRuntimeRecord {
1730            name: s.name.clone(),
1731            kind: s.kind.clone(),
1732            pid: s.pid,
1733            pgid: s.pgid,
1734            provider_id: s.provider_id.clone(),
1735            driver_contract: s.driver_contract.clone(),
1736            config_json: s.config_json.clone(),
1737            package_dir: s.package_dir.as_ref().map(|p| p.display().to_string()),
1738            stop: s.stop.clone(),
1739        })
1740        .collect()
1741}
1742
1743fn persist_state(
1744    state_path: &Path,
1745    manifest_path: &Path,
1746    atlas_endpoint: &str,
1747    started_at_ms: u64,
1748    children: &[Spawned],
1749) {
1750    let state = teardown::BootState {
1751        manifest_path: manifest_path.display().to_string(),
1752        boot_pid: std::process::id(),
1753        boot_start_time_ticks: robonix_cli::launch::proc_start_time_ticks(std::process::id()),
1754        boot_id: std::env::var("RBNX_BOOT_ID").unwrap_or_default(),
1755        started_at_ms,
1756        atlas_endpoint: atlas_endpoint.to_string(),
1757        components: component_records(children),
1758    };
1759    if let Err(e) = teardown::write_state(state_path, &state) {
1760        output::sub_step(&format!(
1761            "[boot] warning: failed to persist boot state to {}: {e:#}",
1762            state_path.display()
1763        ));
1764    }
1765}
1766
1767/// Render a one-line "what is this binary doing" string for the boot
1768/// log. Pulls out the high-signal flags (port, vlm model+host) and
1769/// drops noisy ones (--capabilities, --log, raw API keys).
1770/// Per-binary required-arg sanity check, run before spawning.
1771///
1772/// Pilot needs all three VLM fields non-empty. The manifest renders
1773/// `${VLM_BASE_URL}` etc. literally when the env var isn't set, which
1774/// produces `--vlm-upstream ""` — pilot then registers briefly, dies
1775/// with `missing required field 'vlm.upstream'`, and boot reports
1776/// `[ OK ]` because the failure happens after spawn-and-register. Catch
1777/// it here so the user sees a `[FAIL]` line naming the bad keys.
1778fn require_system_args(name: &str, args: &[String]) -> std::result::Result<(), String> {
1779    if name != "pilot" {
1780        return Ok(());
1781    }
1782    let need = [
1783        ("--vlm-upstream", "vlm.upstream / VLM_BASE_URL"),
1784        ("--vlm-api-key", "vlm.api_key / VLM_API_KEY"),
1785        ("--vlm-model", "vlm.model / VLM_MODEL"),
1786    ];
1787    let mut missing: Vec<&str> = Vec::new();
1788    for (flag, label) in need {
1789        let val = args
1790            .iter()
1791            .position(|a| a == flag)
1792            .and_then(|i| args.get(i + 1));
1793        match val {
1794            Some(v) if !v.is_empty() => {}
1795            _ => missing.push(label),
1796        }
1797    }
1798    if missing.is_empty() {
1799        Ok(())
1800    } else {
1801        Err(format!(
1802            "missing required pilot config: {}. Set in manifest under \
1803             system: pilot: vlm: {{...}} or via env (source your .zshrc / \
1804             inline-prepend VLM_BASE_URL=… VLM_API_KEY=… VLM_MODEL=…)",
1805            missing.join(", "),
1806        ))
1807    }
1808}
1809
1810fn system_boot_detail(name: &str, args: &[String]) -> String {
1811    let mut listen: Option<&str> = None;
1812    let mut vlm_upstream: Option<&str> = None;
1813    let mut vlm_model: Option<&str> = None;
1814    let mut i = 0;
1815    while i < args.len() {
1816        let a = args[i].as_str();
1817        let next = args.get(i + 1).map(|s| s.as_str());
1818        match (a, next) {
1819            ("--listen", Some(v)) => {
1820                listen = Some(v);
1821                i += 2;
1822            }
1823            ("--vlm-upstream", Some(v)) => {
1824                vlm_upstream = Some(v);
1825                i += 2;
1826            }
1827            ("--vlm-model", Some(v)) => {
1828                vlm_model = Some(v);
1829                i += 2;
1830            }
1831            _ => {
1832                i += 1;
1833            }
1834        }
1835    }
1836    let port = listen
1837        .and_then(|s| s.rsplit(':').next())
1838        .map(|p| format!(":{p}"))
1839        .unwrap_or_default();
1840    if name == "pilot" {
1841        let host = vlm_upstream
1842            .and_then(|u| {
1843                u.trim_start_matches("https://")
1844                    .trim_start_matches("http://")
1845                    .split('/')
1846                    .next()
1847            })
1848            .unwrap_or("?");
1849        let model = vlm_model.unwrap_or("?");
1850        format!("{port}  vlm={model}@{host}")
1851    } else {
1852        port
1853    }
1854}
1855
1856/// Translate a `system.<name>:` block into CLI args for the corresponding
1857/// Rust binary. Per-binary mapping kept narrow — adding a new flag means
1858/// touching exactly this function plus the binary's clap struct.
1859///
1860/// `atlas_listen` is the value of `system.atlas.listen` (already resolved
1861/// elsewhere). Consumers that don't carry their own `atlas:` field inherit
1862/// from this so the manifest doesn't have to repeat the address. An
1863/// explicit per-block `atlas:` still wins.
1864/// Extract the `host:port` string each system binary will try to bind.
1865/// Used by the pre-spawn port-availability check. Returns None for
1866/// services we don't gate on (or whose listen field is absent — caller
1867/// then doesn't pre-check).
1868fn system_listen(name: &str, cfg: Option<&serde_yaml::Value>) -> Option<String> {
1869    let map = cfg?.as_mapping()?;
1870    let s = map
1871        .get(serde_yaml::Value::String("listen".into()))?
1872        .as_str()?;
1873    let trimmed = s.trim();
1874    if trimmed.is_empty()
1875        || !matches!(
1876            name,
1877            "atlas" | "executor" | "pilot" | "liaison" | "soma" | "vitals"
1878        )
1879    {
1880        return None;
1881    }
1882    Some(trimmed.to_string())
1883}
1884
1885/// Probe a host:port. Returns `Ok(())` when nothing is listening (we can
1886/// safely bind), `Err` describing the live owner otherwise. This is a
1887/// race-prone pre-check (someone else can grab the port between probe
1888/// and spawn) but in practice the failure mode it catches — a stale
1889/// previous-boot daemon — has been alive for minutes, not seconds, so
1890/// a single connect attempt is enough.
1891fn port_is_free(listen: &str) -> std::result::Result<(), anyhow::Error> {
1892    use std::net::{TcpStream, ToSocketAddrs};
1893    let addrs: Vec<_> = listen
1894        .to_socket_addrs()
1895        .with_context(|| format!("parse listen='{listen}' as socket addr"))?
1896        .collect();
1897    for addr in &addrs {
1898        // 200 ms is enough for a local connect; if a daemon is alive on
1899        // 127.0.0.1 the SYN-ACK is sub-ms.
1900        if TcpStream::connect_timeout(addr, std::time::Duration::from_millis(200)).is_ok() {
1901            return Err(anyhow::anyhow!("something is already listening on {addr}"));
1902        }
1903    }
1904    Ok(())
1905}
1906
1907/// Translate supported `system:` manifest fields into CLI args for built-in binaries.
1908fn system_cli_args(
1909    name: &str,
1910    cfg: Option<&serde_yaml::Value>,
1911    atlas_listen: Option<&str>,
1912) -> Vec<String> {
1913    let mut out = Vec::new();
1914    let map = cfg.and_then(|v| v.as_mapping());
1915
1916    // Pass the component's whole manifest config block as one JSON arg. The
1917    // binary parses the keys it needs (e.g. scribe reads `log` via
1918    // robonix_scribe::init_from_config), so new manifest keys flow through
1919    // without per-key plumbing here. The typed flags below remain for the
1920    // fields binaries still read individually.
1921    if let Some(v) = cfg
1922        && let Ok(json) = serde_json::to_string(v)
1923    {
1924        out.push("--config-json".into());
1925        out.push(json);
1926    }
1927
1928    let s = |k: &str| -> Option<String> {
1929        map.and_then(|m| {
1930            m.get(serde_yaml::Value::String(k.into()))
1931                .and_then(|v| v.as_str())
1932                .map(|s| s.to_string())
1933        })
1934    };
1935    let nested_str = |outer: &str, inner: &str| -> Option<String> {
1936        map.and_then(|m| m.get(serde_yaml::Value::String(outer.into())))
1937            .and_then(|v| v.as_mapping())
1938            .and_then(|m| m.get(serde_yaml::Value::String(inner.into())))
1939            .and_then(|v| v.as_str())
1940            .map(|s| s.to_string())
1941    };
1942    let push_pair = |out: &mut Vec<String>, flag: &str, val: Option<String>| {
1943        if let Some(v) = val {
1944            out.push(flag.into());
1945            out.push(v);
1946        }
1947    };
1948    match name {
1949        "atlas" => {
1950            push_pair(&mut out, "--listen", s("listen"));
1951            push_pair(&mut out, "--log", s("log"));
1952            // Atlas walks `<root>/capabilities/**/*.toml` at startup to
1953            // build the contract registry. Honour an explicit override
1954            // from the manifest, otherwise let atlas fall back to its
1955            // own ROBONIX_SOURCE_PATH-derived default (we don't pass
1956            // --capabilities here from rbnx; deploy.rs sets the env var
1957            // on the spawned process so the default path stays correct
1958            // even when manifests don't mention atlas at all).
1959            push_pair(&mut out, "--capabilities", s("capabilities"));
1960        }
1961        "executor" => {
1962            push_pair(&mut out, "--listen", s("listen"));
1963            push_pair(
1964                &mut out,
1965                "--atlas",
1966                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
1967            );
1968            push_pair(&mut out, "--log", s("log"));
1969        }
1970        "pilot" => {
1971            push_pair(&mut out, "--listen", s("listen"));
1972            push_pair(
1973                &mut out,
1974                "--atlas",
1975                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
1976            );
1977            push_pair(&mut out, "--log", s("log"));
1978            // Embedded VLM block.
1979            push_pair(&mut out, "--vlm-upstream", nested_str("vlm", "upstream"));
1980            push_pair(&mut out, "--vlm-api-key", nested_str("vlm", "api_key"));
1981            push_pair(&mut out, "--vlm-model", nested_str("vlm", "model"));
1982            push_pair(&mut out, "--vlm-format", nested_str("vlm", "api_format"));
1983        }
1984        "liaison" => {
1985            push_pair(&mut out, "--listen", s("listen"));
1986            push_pair(
1987                &mut out,
1988                "--atlas",
1989                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
1990            );
1991            push_pair(&mut out, "--pilot-endpoint", s("pilot_endpoint"));
1992            push_pair(&mut out, "--log", s("log"));
1993        }
1994        "soma" => {
1995            // v2 flat schema: four keys, four CLI flags. rbnx no longer
1996            // passes `--rbnx-bin` (soma calls the on-PATH `rbnx`),
1997            // `--default-robot` / `--deployment` (single robot, deployment
1998            // path derived from --robot-yaml's parent), or
1999            // `--start-packages` (soma always spawns primitives + skills;
2000            // opting out was never actually used). Stage 2 is delivered
2001            // over an inherited pipe fd (see spawn_soma_binary), not
2002            // atlas RPC.
2003            push_pair(&mut out, "--listen", s("listen"));
2004            push_pair(
2005                &mut out,
2006                "--atlas",
2007                s("atlas_endpoint")
2008                    .or_else(|| s("atlas"))
2009                    .or_else(|| atlas_listen.map(str::to_string)),
2010            );
2011            push_pair(&mut out, "--provider-id", s("provider_id"));
2012            push_pair(&mut out, "--robot-yaml", s("robot_yaml"));
2013            push_pair(&mut out, "--deployment-manifest", s("deployment_manifest"));
2014            push_pair(&mut out, "--config", s("config"));
2015            push_pair(&mut out, "--log", s("log"));
2016        }
2017        "vitals" => {
2018            push_pair(&mut out, "--listen", s("listen"));
2019            push_pair(
2020                &mut out,
2021                "--atlas",
2022                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2023            );
2024            push_pair(&mut out, "--id", s("provider_id").or_else(|| s("id")));
2025            push_pair(&mut out, "--thresholds-path", s("thresholds_path"));
2026            push_pair(&mut out, "--soma-endpoint", s("soma_endpoint"));
2027            push_pair(&mut out, "--config", s("config"));
2028            push_pair(&mut out, "--log", s("log"));
2029        }
2030        _ => {}
2031    }
2032    out
2033}
2034
2035/// Spawn one package and wait for its provider to register with Atlas.
2036///
2037/// The selected shared or explicit legacy Driver is verified before
2038/// INIT/ACTIVATE and receives the entry's config. Omission and explicit shared
2039/// selections stay shared-only; only an exact namespace legacy selection may
2040/// accept an upgraded shared runtime Driver.
2041async fn spawn_and_init(
2042    component: &str,
2043    entry: &PackageEntry,
2044    spawn_env: &PackageSpawnEnv<'_>,
2045    atlas: &mut AtlasClient,
2046) -> Result<Spawned> {
2047    let before = snapshot_provider_ids(atlas)
2048        .await
2049        .with_context(|| format!("[{component}] pre-spawn atlas snapshot"))?;
2050
2051    let mut sp = spawn_package(component, entry, spawn_env).await?;
2052    let pkg_label = sp.name.clone();
2053
2054    // One package = one provider. Atlas may reuse a stable provider id on
2055    // takeover, so registration_id (not id alone) correlates this spawn.
2056
2057    // Once the wrapper is up, every error path below must terminate the
2058    // PGID before bailing — otherwise `?` returns the spawned process to
2059    // a dead Spawned (which itself has no killing Drop), the caller's
2060    // teardown loop never sees it (`children.push(sp)` only runs after
2061    // this fn succeeds), and the orphan keeps holding whatever the
2062    // package opened (e.g. memsearch's milvus DB lock, executor's gRPC
2063    // port, …). Give the provider's SIGTERM handler time to run
2064    // `on_shutdown` before SIGKILL fallback; providers may own ROS children
2065    // in their own process groups that only the handler knows about.
2066    let pgid = sp.pgid;
2067
2068    let registration = match wait_for_registration(
2069        atlas,
2070        &before,
2071        &entry.name,
2072        &pkg_label,
2073        component,
2074        spawn_env.log_dir,
2075        &mut sp.child,
2076    )
2077    .await
2078    {
2079        Ok(v) => v,
2080        Err(e) => {
2081            terminate_process_group(pgid, Duration::from_secs(8)).await;
2082            return Err(e);
2083        }
2084    };
2085    let provider_id = registration.provider_id.clone();
2086    // The exact-id waiter makes this an internal invariant. Keep the check as
2087    // defense in depth so a future launcher refactor cannot deliver config to
2088    // a provider other than the manifest instance.
2089    if provider_id != entry.name {
2090        let log_file = log_path(spawn_env.log_dir, &pkg_label);
2091        output::boot_fail(
2092            short_label(&pkg_label, component),
2093            &format!(
2094                "deployment instance propagation failed: expected manifest name='{}', \
2095                 observed Capability(id='{}'). Log: {}",
2096                entry.name,
2097                provider_id,
2098                log_file.display()
2099            ),
2100        );
2101        terminate_process_group(pgid, Duration::from_secs(8)).await;
2102        anyhow::bail!(
2103            "[{component}/{pkg_label}] deployment identity invariant failed: manifest name='{}' vs Capability(id='{}')",
2104            entry.name,
2105            provider_id,
2106        );
2107    }
2108
2109    sp.provider_id = Some(provider_id.clone());
2110    let expected_driver_contract = sp
2111        .expected_driver_contract
2112        .as_deref()
2113        .expect("package spawns always carry a lifecycle selection");
2114    let driver_contract = match resolve_runtime_driver_contract(
2115        &provider_id,
2116        &registration.provider_namespace,
2117        expected_driver_contract,
2118        &registration.driver_contracts,
2119        sp.allow_shared_driver_upgrade,
2120    ) {
2121        Ok(contract) => contract,
2122        Err(error) => {
2123            let log_file = log_path(spawn_env.log_dir, &pkg_label);
2124            output::boot_fail(
2125                short_label(&pkg_label, component),
2126                &format!("{error}; log {}", log_file.display()),
2127            );
2128            terminate_process_group(pgid, Duration::from_secs(8)).await;
2129            return Err(error).with_context(|| format!("[{component}/{pkg_label}] lifecycle"));
2130        }
2131    };
2132
2133    if driver_contract != expected_driver_contract {
2134        output::warning(&format!(
2135            "provider '{provider_id}' publishes shared lifecycle Driver '{driver_contract}' for legacy manifest selection '{expected_driver_contract}'; remove the legacy Driver declaration to finish migration"
2136        ));
2137    }
2138
2139    let config_json = serde_json::to_string(&entry.config).with_context(|| {
2140        format!(
2141            "[{component}/{pkg_label}] serialize config for deployment instance '{}'",
2142            entry.name
2143        )
2144    })?;
2145    sp.driver_contract = Some(driver_contract.clone());
2146    sp.config_json = Some(config_json.clone());
2147
2148    let display_label = short_label(&pkg_label, component);
2149    let init_state = match with_spinner(
2150        display_label,
2151        "driver(INIT)…",
2152        call_driver_cmd(
2153            atlas,
2154            &provider_id,
2155            &driver_contract,
2156            component,
2157            &pkg_label,
2158            CMD_INIT,
2159            config_json.clone(),
2160        ),
2161    )
2162    .await
2163    {
2164        Ok(v) => v,
2165        Err(e) => {
2166            terminate_process_group(pgid, Duration::from_secs(8)).await;
2167            return Err(e);
2168        }
2169    };
2170
2171    if component == "skill" {
2172        // Skills stop at INACTIVE post-INIT; the executor sends
2173        // CMD_ACTIVATE on first MCP call (lazy-activate).
2174        output::boot_ok(
2175            display_label,
2176            &format!(
2177                "{}  (skill — awaits executor activate)",
2178                init_state.to_uppercase()
2179            ),
2180        );
2181        return Ok(sp);
2182    }
2183
2184    let activate_state = match with_spinner(
2185        display_label,
2186        "driver(ACTIVATE)…",
2187        call_driver_cmd(
2188            atlas,
2189            &provider_id,
2190            &driver_contract,
2191            component,
2192            &pkg_label,
2193            CMD_ACTIVATE,
2194            config_json,
2195        ),
2196    )
2197    .await
2198    {
2199        Ok(v) => v,
2200        Err(e) => {
2201            terminate_process_group(pgid, Duration::from_secs(8)).await;
2202            return Err(e);
2203        }
2204    };
2205    // Boot succeeded: provider walked REGISTERED → INACTIVE → ACTIVE. Show
2206    // only the final state — the two intermediate driver calls already
2207    // got their own spinner lines and OK ticks above. provider_id is the
2208    // leftmost label so we don't repeat it here.
2209    let _ = init_state; // intermediate, only kept for the assertion below
2210    output::boot_ok(display_label, &activate_state.to_uppercase());
2211
2212    Ok(sp)
2213}
2214
2215/// Run `fut` while animating the boot spinner so the user sees the
2216/// `[ ⠙ ] name  msg_prefix N.Ns` line update steadily even when the
2217/// underlying RPC takes a while (Driver(CMD_INIT) for sensor-warm-up
2218/// packages routinely sits at 30+ seconds). Without this the line goes
2219/// silent right after `wait_for_registration` finishes and rbnx looks
2220/// hung between OK lines.
2221async fn with_spinner<F, T>(label: &str, msg_prefix: &str, fut: F) -> T
2222where
2223    F: std::future::Future<Output = T>,
2224{
2225    if output::boot_verbose() {
2226        output::boot_wait(label, msg_prefix);
2227        return fut.await;
2228    }
2229    use std::time::Instant;
2230    let started = Instant::now();
2231    let mut tick = tokio::time::interval(Duration::from_millis(100));
2232    tick.tick().await; // first tick fires immediately; consume so the
2233    // first redraw is delayed by 100 ms (no double-frame at t=0).
2234    tokio::pin!(fut);
2235    let mut frame: usize = 0;
2236    loop {
2237        tokio::select! {
2238            res = &mut fut => return res,
2239            _ = tick.tick() => {
2240                let elapsed = started.elapsed().as_secs_f32();
2241                output::boot_progress(
2242                    label,
2243                    &format!("{msg_prefix} {elapsed:>4.1}s"),
2244                    frame,
2245                );
2246                frame = frame.wrapping_add(1);
2247            }
2248        }
2249    }
2250}
2251
2252async fn wait_for_soma_stage1(
2253    atlas: &mut AtlasClient,
2254    primitive_names: &[String],
2255    soma_child: &mut Child,
2256    log_dir: &Path,
2257) -> Result<()> {
2258    const SPINNER_TICK: Duration = Duration::from_millis(100);
2259    const POLLS_PER_TICK: usize = 5; // poll atlas every 500 ms
2260    const SOMA_STAGE1_TIMEOUT: Duration = Duration::from_secs(180);
2261    const SOMA_GET_YAML_CONTRACT: &str = "robonix/system/soma/get_yaml";
2262
2263    let started = Instant::now();
2264    let deadline = started + SOMA_STAGE1_TIMEOUT;
2265    let mut frame: usize = 0;
2266    let mut observed_states: HashMap<String, i32> = HashMap::new();
2267    let mut active_primitives: HashSet<String> = HashSet::new();
2268    let mut reported_failures: HashSet<String> = HashSet::new();
2269    if output::boot_verbose() {
2270        output::boot_wait("primitive", "waiting for Soma-managed providers");
2271    }
2272    loop {
2273        let elapsed_s = started.elapsed().as_secs_f32();
2274        let detail = if primitive_names.is_empty() {
2275            format!("waiting for Soma gRPC readiness… {elapsed_s:>4.1}s")
2276        } else {
2277            format!(
2278                "starting {} primitive package(s)… {elapsed_s:>4.1}s",
2279                primitive_names.len()
2280            )
2281        };
2282        if output::boot_verbose() {
2283            if frame > 0 && frame.is_multiple_of(50) {
2284                output::boot_note("primitive", &detail);
2285            }
2286        } else {
2287            output::boot_progress("primitive", &detail, frame);
2288        }
2289        // Check every tick whether soma is still alive. If it exited
2290        // (typically: `missing robot_yaml`, `read Soma config`, port
2291        // bind failure), surface that immediately with the tail of
2292        // its own log rather than sitting on this spinner for the
2293        // full SOMA_STAGE1_TIMEOUT (180s) which frustrates operators
2294        // and blocks CI. try_wait is non-blocking; Ok(Some(_)) means
2295        // the child has been reaped and the OS-level status is known.
2296        if let Ok(Some(status)) = soma_child.try_wait() {
2297            let mut provider_failures = Vec::new();
2298            for name in primitive_names {
2299                let log_file = log_dir.join(format!("{name}.log"));
2300                if let Some(cause) = read_provider_failure(&log_file) {
2301                    if reported_failures.insert(name.clone()) {
2302                        output::boot_fail(
2303                            name,
2304                            &format!("ERROR; {cause}; log {}", log_file.display()),
2305                        );
2306                    }
2307                    provider_failures.push((name, cause, log_file));
2308                }
2309            }
2310            if !provider_failures.is_empty() {
2311                let names = provider_failures
2312                    .iter()
2313                    .map(|(name, _, _)| name.as_str())
2314                    .collect::<Vec<_>>()
2315                    .join(", ");
2316                let logs = provider_failures
2317                    .iter()
2318                    .map(|(_, _, path)| path.display().to_string())
2319                    .collect::<Vec<_>>()
2320                    .join(", ");
2321                output::boot_fail(
2322                    "primitive",
2323                    &format!("soma exited after provider failure(s): {names}"),
2324                );
2325                anyhow::bail!(
2326                    "Soma exited with {status:?} after provider failure(s): {names}; logs: {logs}"
2327                );
2328            }
2329
2330            let log_file = log_dir.join("soma.log");
2331            let tail = read_log_tail(&log_file, 20);
2332            output::boot_fail(
2333                "primitive",
2334                &format!(
2335                    "soma exited before becoming ACTIVE (status={status:?}); see {}",
2336                    log_file.display()
2337                ),
2338            );
2339            let hint = if tail.is_empty() {
2340                String::new()
2341            } else {
2342                format!("\n--- soma.log tail ---\n{tail}\n--- end ---")
2343            };
2344            anyhow::bail!(
2345                "soma exited with {status:?} before primitive readiness; \
2346                 log: {}{hint}",
2347                log_file.display()
2348            );
2349        }
2350        if frame.is_multiple_of(POLLS_PER_TICK) {
2351            for name in primitive_names {
2352                let providers = atlas
2353                    .query_capabilities(name, "", atlas_pb::Transport::Unspecified)
2354                    .await
2355                    .with_context(|| format!("poll primitive '{name}' during Soma bring-up"))?;
2356                let Some(provider) = providers.into_iter().find(|provider| provider.id == *name)
2357                else {
2358                    continue;
2359                };
2360                let previous = observed_states.insert(name.clone(), provider.state);
2361                if previous != Some(provider.state) {
2362                    let state = lifecycle_state_label(provider.state);
2363                    if provider.state == atlas_pb::LifecycleState::StateActive as i32 {
2364                        output::boot_ok(name, "ACTIVE");
2365                        active_primitives.insert(name.clone());
2366                    } else if provider.state == atlas_pb::LifecycleState::StateError as i32 {
2367                        let log_file = log_dir.join(format!("{name}.log"));
2368                        let detail = read_provider_failure(&log_file).map_or_else(
2369                            || format!("ERROR; log {}", log_file.display()),
2370                            |cause| format!("ERROR; {cause}; log {}", log_file.display()),
2371                        );
2372                        output::boot_fail(name, &detail);
2373                        reported_failures.insert(name.clone());
2374                    } else if output::boot_verbose() {
2375                        output::boot_note(name, state);
2376                    }
2377                }
2378            }
2379            let providers = atlas
2380                .query_capabilities("soma", SOMA_GET_YAML_CONTRACT, atlas_pb::Transport::Grpc)
2381                .await
2382                .context("wait for Soma primitive readiness")?;
2383            if let Some(soma) = providers.into_iter().find(|p| p.id == "soma")
2384                && soma.state == atlas_pb::LifecycleState::StateActive as i32
2385                && soma_grpc_ready(atlas, SOMA_GET_YAML_CONTRACT).await
2386            {
2387                for name in primitive_names {
2388                    if !active_primitives.contains(name) {
2389                        output::boot_ok(name, "ACTIVE");
2390                    }
2391                }
2392                return Ok(());
2393            }
2394        }
2395        if Instant::now() >= deadline {
2396            output::boot_fail(
2397                "primitive",
2398                &format!(
2399                    "timeout after {:?}; service bring-up needs primitives ACTIVE first",
2400                    SOMA_STAGE1_TIMEOUT
2401                ),
2402            );
2403            anyhow::bail!(
2404                "Soma primitive bring-up did not become ready within {:?}; refusing to start service packages before primitives are ready",
2405                SOMA_STAGE1_TIMEOUT
2406            );
2407        }
2408        tokio::time::sleep(SPINNER_TICK).await;
2409        frame = frame.wrapping_add(1);
2410    }
2411}
2412
2413fn lifecycle_state_label(state: i32) -> &'static str {
2414    if state == atlas_pb::LifecycleState::StateRegistered as i32 {
2415        "REGISTERED"
2416    } else if state == atlas_pb::LifecycleState::StateInactive as i32 {
2417        "INACTIVE"
2418    } else if state == atlas_pb::LifecycleState::StateActive as i32 {
2419        "ACTIVE"
2420    } else if state == atlas_pb::LifecycleState::StateError as i32 {
2421        "ERROR"
2422    } else if state == atlas_pb::LifecycleState::StateTerminated as i32 {
2423        "TERMINATED"
2424    } else {
2425        "STARTING"
2426    }
2427}
2428
2429async fn wait_for_soma_skills(atlas: &mut AtlasClient, skill_names: &[String]) -> Result<()> {
2430    const TIMEOUT: Duration = Duration::from_secs(180);
2431    if skill_names.is_empty() {
2432        return Ok(());
2433    }
2434    let deadline = Instant::now() + TIMEOUT;
2435    let mut observed_states: HashMap<String, i32> = HashMap::new();
2436    let mut ready: HashSet<String> = HashSet::new();
2437    while Instant::now() < deadline {
2438        for name in skill_names {
2439            let providers = atlas
2440                .query_capabilities(name, "", atlas_pb::Transport::Unspecified)
2441                .await
2442                .with_context(|| format!("poll skill '{name}' during soma bring-up"))?;
2443            let Some(provider) = providers.into_iter().find(|provider| provider.id == *name) else {
2444                continue;
2445            };
2446            if observed_states.insert(name.clone(), provider.state) != Some(provider.state) {
2447                let state = lifecycle_state_label(provider.state);
2448                if provider.state == atlas_pb::LifecycleState::StateInactive as i32
2449                    || provider.state == atlas_pb::LifecycleState::StateActive as i32
2450                {
2451                    output::boot_ok(name, state);
2452                    ready.insert(name.clone());
2453                } else if provider.state == atlas_pb::LifecycleState::StateError as i32 {
2454                    output::boot_fail(name, "ERROR; see soma.log and provider log");
2455                    anyhow::bail!("skill '{name}' entered ERROR during Soma bring-up");
2456                } else if output::boot_verbose() {
2457                    output::boot_note(name, state);
2458                }
2459            }
2460        }
2461        if ready.len() == skill_names.len() {
2462            return Ok(());
2463        }
2464        tokio::time::sleep(Duration::from_millis(200)).await;
2465    }
2466    let pending = skill_names
2467        .iter()
2468        .filter(|name| !ready.contains(*name))
2469        .cloned()
2470        .collect::<Vec<_>>();
2471    for name in &pending {
2472        output::boot_fail(
2473            name,
2474            "registration/INIT timeout; see soma.log and provider log",
2475        );
2476    }
2477    anyhow::bail!(
2478        "Soma skill bring-up timed out after {TIMEOUT:?}: {}",
2479        pending.join(", ")
2480    )
2481}
2482
2483/// Read the last `max_lines` lines of a file for embedding into an
2484/// error message. Best-effort: an unreadable/missing file returns an
2485/// empty string rather than an error — the caller already reports the
2486/// path, we just enrich when we can. We read the whole file (soma.log
2487/// is scribe-managed and stays small during boot), split, and take
2488/// the tail — no seek-from-end acrobatics needed for the boot-time
2489/// use case.
2490fn read_log_tail(path: &Path, max_lines: usize) -> String {
2491    let Ok(contents) = std::fs::read_to_string(path) else {
2492        return String::new();
2493    };
2494    let lines: Vec<&str> = contents.lines().collect();
2495    let start = lines.len().saturating_sub(max_lines);
2496    lines[start..].join("\n")
2497}
2498
2499/// Return the provider's actual lifecycle failure rather than whichever
2500/// shutdown record happened to be written last. Scribe records are JSONL;
2501/// providers commonly report lifecycle transitions at info level, so prefer
2502/// `-> ERROR (...)` messages before falling back to an error-level record.
2503fn read_provider_failure(path: &Path) -> Option<String> {
2504    let contents = std::fs::read_to_string(path).ok()?;
2505    let mut error_level_fallback = None;
2506    for line in contents.lines().rev() {
2507        let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
2508            continue;
2509        };
2510        let Some(message) = record.get("msg").and_then(|value| value.as_str()) else {
2511            continue;
2512        };
2513        if let Some((_, cause)) = message.split_once(" -> ERROR (") {
2514            return Some(cause.strip_suffix(')').unwrap_or(cause).to_string());
2515        }
2516        if error_level_fallback.is_none()
2517            && record.get("level").and_then(|value| value.as_str()) == Some("error")
2518        {
2519            error_level_fallback = Some(message.to_string());
2520        }
2521    }
2522    error_level_fallback
2523}
2524
2525/// Summarize a package that exited before Atlas registration. Providers often
2526/// forward Python tracebacks through Scribe at info level, so the lifecycle-
2527/// specific parser above may intentionally return None. In that case the last
2528/// structured message is the most useful single-line cause for boot output.
2529fn read_provider_exit_summary(path: &Path) -> Option<String> {
2530    if let Some(cause) = read_provider_failure(path) {
2531        return Some(cause);
2532    }
2533    let contents = std::fs::read_to_string(path).ok()?;
2534    for line in contents.lines().rev() {
2535        if let Ok(record) = serde_json::from_str::<serde_json::Value>(line)
2536            && let Some(message) = record.get("msg").and_then(|value| value.as_str())
2537            && !message.trim().is_empty()
2538        {
2539            return Some(message.trim().to_string());
2540        }
2541        if !line.trim().is_empty() {
2542            return Some(line.trim().to_string());
2543        }
2544    }
2545    None
2546}
2547
2548async fn soma_grpc_ready(atlas: &mut AtlasClient, contract_id: &str) -> bool {
2549    let Ok((channel_id, endpoint, _params)) = atlas
2550        .connect_capability(
2551            DEPLOY_CONSUMER_ID,
2552            "soma",
2553            contract_id,
2554            atlas_pb::Transport::Grpc,
2555        )
2556        .await
2557    else {
2558        return false;
2559    };
2560    let normalized = if endpoint.starts_with("http") {
2561        endpoint
2562    } else {
2563        format!("http://{endpoint}")
2564    };
2565    let ready = match Endpoint::new(normalized.clone()) {
2566        Ok(endpoint) => tokio::time::timeout(Duration::from_secs(1), endpoint.connect())
2567            .await
2568            .is_ok_and(|r| r.is_ok()),
2569        Err(_) => false,
2570    };
2571    let _ = atlas.disconnect_capability(&channel_id).await;
2572    ready
2573}
2574
2575/// Write the `stage2\n` trigger into the pipe rbnx and soma share
2576/// (see `spawn_soma_binary`). This is a one-shot: soma reads the
2577/// line, unblocks its skill-package launcher, and closes its read
2578/// end. rbnx-side we drop the writer here — no reason to hold it
2579/// open, and closing gives soma an immediate EOF on the (very
2580/// unlikely) chance it re-reads.
2581///
2582/// If we don't have a writer (soma wasn't spawned by us, e.g.
2583/// --skip-system), this is a no-op with a warning: someone else
2584/// owns soma's fd and there's nothing rbnx can meaningfully do.
2585fn write_stage2_trigger(writer: &mut Option<std::fs::File>) -> Result<()> {
2586    use std::io::Write;
2587    let Some(mut w) = writer.take() else {
2588        output::boot_skip(
2589            "skill",
2590            "start skipped: no trigger writer (Soma was not spawned by this rbnx)",
2591        );
2592        return Ok(());
2593    };
2594    w.write_all(b"stage2\n")
2595        .context("write 'stage2' to soma stage-trigger pipe")?;
2596    w.flush().context("flush soma stage-trigger pipe")?;
2597    // "written", not "delivered": all we know at this point is that
2598    // the bytes hit the pipe. Actual delivery (soma reads the line,
2599    // spawns skills, and their MCP tools/caps register) is verified
2600    // downstream by the boot-poll cap-wait loop, not here.
2601    Ok(())
2602}
2603
2604/// Issue one Driver(cmd) RPC against a freshly-connected channel, then
2605/// release the channel. Returns the response's `state` string on success;
2606/// bail-errors when ok=false or the RPC itself fails. Used by the boot
2607/// path for both CMD_INIT and CMD_ACTIVATE, with identical timeout / channel
2608/// hygiene.
2609async fn call_driver_cmd(
2610    atlas: &mut AtlasClient,
2611    provider_id: &str,
2612    driver_contract: &str,
2613    component: &str,
2614    pkg_label: &str,
2615    cmd: u32,
2616    config_json: String,
2617) -> Result<String> {
2618    let cmd_name = match cmd {
2619        CMD_INIT => "INIT",
2620        CMD_ACTIVATE => "ACTIVATE",
2621        CMD_DEACTIVATE => "DEACTIVATE",
2622        CMD_SHUTDOWN => "SHUTDOWN",
2623        _ => "?",
2624    };
2625    let (channel_id, endpoint, _params) = atlas
2626        .connect_capability(
2627            DEPLOY_CONSUMER_ID,
2628            provider_id,
2629            driver_contract,
2630            atlas_pb::Transport::Grpc,
2631        )
2632        .await
2633        .with_context(|| {
2634            format!("[{component}/{pkg_label}] ConnectCapability for {driver_contract}")
2635        })?;
2636    let normalized = if endpoint.starts_with("http") {
2637        endpoint
2638    } else {
2639        format!("http://{endpoint}")
2640    };
2641    let result = async {
2642        let driver_timeout = driver_init_timeout();
2643        let channel = Endpoint::new(normalized.clone())
2644            .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
2645            .connect()
2646            .await
2647            .with_context(|| format!("dial driver at '{normalized}'"))?;
2648        let svc_name = contract_id_to_service_name(driver_contract);
2649        let path: tonic::codegen::http::uri::PathAndQuery =
2650            format!("/robonix.contracts.{svc_name}/Driver")
2651                .parse()
2652                .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
2653        let mut grpc = tonic::client::Grpc::new(channel);
2654        grpc.ready().await.with_context(|| "gRPC ready")?;
2655        let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
2656        let resp = tokio::time::timeout(
2657            driver_timeout,
2658            grpc.unary(
2659                Request::new(DriverRequest {
2660                    command: cmd,
2661                    config_json,
2662                }),
2663                path,
2664                codec,
2665            ),
2666        )
2667        .await
2668        .map_err(|_| {
2669            anyhow::anyhow!(
2670                "Driver(CMD_{cmd_name}) timed out after {}s",
2671                driver_timeout.as_secs()
2672            )
2673        })?
2674        .with_context(|| format!("Driver(CMD_{cmd_name}) RPC failed"))?;
2675        Ok::<_, anyhow::Error>(resp.into_inner())
2676    }
2677    .await;
2678    let _ = atlas.disconnect_capability(&channel_id).await;
2679    let r = result
2680        .map_err(|e| anyhow::anyhow!("[{component}/{pkg_label}] Driver(CMD_{cmd_name}): {e:#}"))?;
2681    if !r.ok {
2682        anyhow::bail!(
2683            "[{component}/{pkg_label}] Driver(CMD_{cmd_name}) returned ok=false (state={}, error={})",
2684            r.state,
2685            r.error
2686        );
2687    }
2688    Ok(r.state)
2689}
2690
2691/// Mirrors `robonix_codegen::contract_gen::contract_id_to_service_name`.
2692/// Uniform PascalCase: `robonix/primitive/chassis/driver` →
2693/// `RobonixPrimitiveChassisDriver`. No prefix stripping. Full gRPC
2694/// service path: `/robonix.contracts.<this>/Driver`.
2695fn contract_id_to_service_name(id: &str) -> String {
2696    id.split('/')
2697        .filter(|x| !x.is_empty())
2698        .map(|seg| {
2699            seg.split('_')
2700                .filter(|p| !p.is_empty())
2701                .map(|p| {
2702                    let mut c = p.chars();
2703                    match c.next() {
2704                        None => String::new(),
2705                        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
2706                    }
2707                })
2708                .collect::<String>()
2709        })
2710        .collect::<String>()
2711}
2712
2713/// Poll atlas until a provider NOT in `before` appears. Returns the new
2714/// `provider_id` plus every distinct lifecycle Driver observed after the
2715/// declaration settle window. The caller verifies this list before sending
2716/// config or lifecycle commands.
2717/// Strip the leading `<component>_` from the boot-log pkg_label.
2718/// `system_memory` → `memory`; `primitive_tiago_chassis` → `tiago_chassis`.
2719/// Keeps boot-output columns narrow (the section header above already
2720/// said which class the entry belongs to).
2721fn short_label<'a>(pkg_label: &'a str, component: &str) -> &'a str {
2722    pkg_label
2723        .strip_prefix(&format!("{component}_"))
2724        .unwrap_or(pkg_label)
2725}
2726
2727async fn wait_for_registration(
2728    atlas: &mut AtlasClient,
2729    before: &ProviderRegistrationSnapshot,
2730    expected_provider_id: &str,
2731    pkg_label: &str,
2732    component: &str,
2733    log_dir: &Path,
2734    child: &mut Child,
2735) -> Result<RegistrationOutcome> {
2736    if before.contains_key(expected_provider_id) {
2737        anyhow::bail!(
2738            "[{component}/{pkg_label}] deployment instance '{expected_provider_id}' \
2739             was already registered before spawn"
2740        );
2741    }
2742
2743    // Wait for this manifest instance's exact id with a fresh registration
2744    // generation. Unrelated providers can register concurrently and must not
2745    // receive this instance's lifecycle config.
2746    const SPINNER_TICK: Duration = Duration::from_millis(100);
2747    const POLLS_PER_TICK: u32 = 2; // poll atlas every 200 ms
2748    let started = Instant::now();
2749    let deadline = started + DRIVER_REGISTER_TIMEOUT;
2750    let mut frame: usize = 0;
2751    let display_label = short_label(pkg_label, component);
2752    if output::boot_verbose() {
2753        output::boot_wait(display_label, "registering with atlas");
2754    }
2755    loop {
2756        let elapsed_s = started.elapsed().as_secs_f32();
2757        let detail = format!("registering with atlas… {elapsed_s:>4.1}s");
2758        if output::boot_verbose() {
2759            if frame > 0 && frame.is_multiple_of(50) {
2760                output::boot_note(display_label, &detail);
2761            }
2762        } else {
2763            output::boot_progress(display_label, &detail, frame);
2764        }
2765        // A package wrapper that exits before registering can never recover.
2766        // Detect it on every spinner tick instead of waiting out the full
2767        // registration timeout and then continuing with a misleading generic
2768        // timeout. The caller still terminates the package PGID so any child
2769        // processes left behind by a failed start hook are reaped.
2770        match child.try_wait() {
2771            Ok(Some(status)) => {
2772                let log_file = log_path(log_dir, pkg_label);
2773                let cause = read_provider_exit_summary(&log_file)
2774                    .unwrap_or_else(|| "no diagnostic message in provider log".to_string());
2775                output::boot_fail(
2776                    display_label,
2777                    &format!(
2778                        "start process exited ({status}); {cause}; log {}",
2779                        log_file.display()
2780                    ),
2781                );
2782                anyhow::bail!(
2783                    "[{component}/{pkg_label}] start process exited ({status}) before Atlas registration: {cause}. Log: {}",
2784                    log_file.display()
2785                );
2786            }
2787            Ok(None) => {}
2788            Err(error) => {
2789                anyhow::bail!(
2790                    "[{component}/{pkg_label}] inspect start process while waiting for Atlas registration: {error}"
2791                );
2792            }
2793        }
2794        if frame.is_multiple_of(POLLS_PER_TICK as usize) {
2795            let providers = atlas
2796                .query_capabilities("", "", atlas_pb::Transport::Unspecified)
2797                .await
2798                .with_context(|| format!("[{component}/{pkg_label}] poll atlas"))?;
2799            let matched = providers.iter().find(|provider| {
2800                robonix_cli::launch::is_expected_provider_registration(
2801                    provider,
2802                    before,
2803                    expected_provider_id,
2804                )
2805            });
2806            if let Some(first) = matched {
2807                let provider_id = first.id.clone();
2808                let registration_id = first.registration_id.clone();
2809                // RegisterPrimitive/Service/Skill and DeclareCapability are
2810                // two separate RPCs from the package side — Register lands
2811                // first, declares follow within a few hundred ms. Give it
2812                // up to a 1 s settle window so we don't false-fire a missing
2813                // Driver error on a fast poll. Capped by the outer
2814                // `deadline` so we never exceed user-facing timeout.
2815                let settle_until = Instant::now()
2816                    .checked_add(Duration::from_millis(1000))
2817                    .map(|t| t.min(deadline))
2818                    .unwrap_or(deadline);
2819                let mut current: atlas_pb::CapabilityProvider = (*first).clone();
2820                // Consume the complete settle window so a package that
2821                // declares both shared and legacy Drivers cannot hide the
2822                // second declaration behind the first successful poll.
2823                loop {
2824                    if Instant::now() >= settle_until {
2825                        break;
2826                    }
2827                    tokio::time::sleep(Duration::from_millis(100)).await;
2828                    let providers = atlas
2829                        .query_capabilities(&provider_id, "", atlas_pb::Transport::Unspecified)
2830                        .await
2831                        .with_context(|| format!("[{component}/{pkg_label}] re-poll for driver"))?;
2832                    match providers.into_iter().find(|p| p.id == provider_id) {
2833                        Some(p) if p.registration_id == registration_id => current = p,
2834                        Some(p) => {
2835                            let log_file = log_path(log_dir, pkg_label);
2836                            output::boot_fail(
2837                                display_label,
2838                                &format!(
2839                                    "provider '{provider_id}' registration changed during settle — see {}",
2840                                    log_file.display(),
2841                                ),
2842                            );
2843                            anyhow::bail!(
2844                                "[{component}/{pkg_label}] provider '{provider_id}' registration changed during settle ('{registration_id}' -> '{}'). Log: {}",
2845                                p.registration_id,
2846                                log_file.display(),
2847                            );
2848                        }
2849                        None => {
2850                            // Provider vanished between the original match
2851                            // and now (crashed mid-settle, atlas evicted,
2852                            // heartbeat lapsed). Report loudly so downstream
2853                            // boot logic cannot march on against a dead process.
2854                            let log_file = log_path(log_dir, pkg_label);
2855                            output::boot_fail(
2856                                display_label,
2857                                &format!(
2858                                    "provider '{provider_id}' disappeared during settle — see {}",
2859                                    log_file.display()
2860                                ),
2861                            );
2862                            anyhow::bail!(
2863                                "[{component}/{pkg_label}] provider '{provider_id}' \
2864                                 unregistered during settle window. Log: {}",
2865                                log_file.display()
2866                            );
2867                        }
2868                    }
2869                }
2870                let mut driver_contracts = current
2871                    .capabilities
2872                    .iter()
2873                    .filter(|capability| {
2874                        capability.transport == atlas_pb::Transport::Grpc as i32
2875                            && capability.contract_id.ends_with("/driver")
2876                    })
2877                    .map(|capability| capability.contract_id.clone())
2878                    .collect::<Vec<_>>();
2879                driver_contracts.sort();
2880                driver_contracts.dedup();
2881                return Ok(RegistrationOutcome {
2882                    provider_id,
2883                    provider_kind: current.kind,
2884                    provider_namespace: current.namespace,
2885                    registration_id: current.registration_id,
2886                    driver_contracts,
2887                });
2888            }
2889        }
2890        if Instant::now() >= deadline {
2891            let log_file = log_path(log_dir, pkg_label);
2892            output::boot_fail(
2893                display_label,
2894                &format!(
2895                    "registration timeout after {:?}; expected instance '{}' — see {}",
2896                    DRIVER_REGISTER_TIMEOUT,
2897                    expected_provider_id,
2898                    log_file.display()
2899                ),
2900            );
2901            anyhow::bail!(
2902                "[{component}/{pkg_label}] timed out after {:?} — package never registered expected deployment instance '{}' with atlas. Log: {}",
2903                DRIVER_REGISTER_TIMEOUT,
2904                expected_provider_id,
2905                log_file.display()
2906            );
2907        }
2908        tokio::time::sleep(SPINNER_TICK).await;
2909        frame = frame.wrapping_add(1);
2910    }
2911}