1use anyhow::{Context, Result};
33use robonix_atlas::client::AtlasClient;
34use robonix_atlas::pb as atlas_pb;
35use robonix_cli::launch::{
36 PackageRuntimeRecord, ProviderRegistrationSnapshot, RegistrationOutcome,
37 contract_id_to_service_name, resolve_runtime_driver_contract, snapshot_provider_ids,
38 terminate_process_group,
39};
40use robonix_cli::output;
41use serde::Deserialize;
42use sha2::{Digest, Sha256};
43use std::collections::{HashMap, HashSet};
44use std::os::fd::RawFd;
45use std::path::{Path, PathBuf};
46use std::process::Stdio;
47use std::time::{Duration, Instant};
48use tokio::io::AsyncBufReadExt;
49use tokio::process::{Child, Command};
50use tokio::signal::unix::{SignalKind, signal};
51use tonic::Request;
52use tonic::transport::Endpoint;
53use uuid::Uuid;
54
55use robonix_scribe as scribe;
56
57use crate::pb::lifecycle::{DriverRequest, DriverResponse};
58
59use super::teardown;
60
61const CMD_INIT: u32 = 0;
63const CMD_ACTIVATE: u32 = 1;
64#[allow(dead_code)]
65const CMD_DEACTIVATE: u32 = 2;
66#[allow(dead_code)]
67const CMD_SHUTDOWN: u32 = 3;
68const DRIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(60);
71const DEFAULT_DRIVER_INIT_TIMEOUT: Duration = Duration::from_secs(90);
75const DEPLOY_CONSUMER_ID: &str = "rbnx-cli/deploy";
76
77pub(super) const SYSTEM_BUILTINS: &[&str] =
80 &["atlas", "executor", "pilot", "liaison", "soma", "vitals"];
81
82pub(super) fn is_builtin_system(name: &str) -> bool {
83 SYSTEM_BUILTINS.contains(&name)
84}
85
86fn driver_init_timeout() -> Duration {
87 std::env::var("ROBONIX_DRIVER_INIT_TIMEOUT_S")
88 .ok()
89 .and_then(|s| s.parse::<u64>().ok())
90 .filter(|secs| *secs > 0)
91 .map(Duration::from_secs)
92 .unwrap_or(DEFAULT_DRIVER_INIT_TIMEOUT)
93}
94
95#[derive(Debug, Clone, Deserialize, Default)]
98struct DeployManifest {
99 #[serde(default)]
100 name: String,
101 #[serde(default)]
102 system: HashMap<String, serde_yaml::Value>,
103 #[serde(default)]
104 primitive: Vec<PackageEntry>,
105 #[serde(default)]
106 service: Vec<PackageEntry>,
107 #[serde(default)]
108 skill: Vec<PackageEntry>,
109}
110
111#[derive(Debug, Clone, Deserialize)]
112struct PackageEntry {
113 #[serde(default)]
115 name: String,
116 #[serde(default)]
119 path: Option<String>,
120 #[serde(default)]
126 url: Option<String>,
127 #[serde(default)]
130 branch: Option<String>,
131 #[serde(default)]
135 config: serde_yaml::Value,
136 #[serde(default)]
144 manifest: Option<String>,
145}
146
147pub(crate) fn repo_dir_name(url: &str) -> String {
164 robonix_cli::manifest::deploy_repo_dir_name(url)
165}
166
167fn resolve_entry_path(
168 entry: &PackageEntry,
169 cache_root: &Path,
170 manifest_dir: &Path,
171) -> Result<PathBuf> {
172 match (&entry.path, &entry.url) {
173 (Some(p), None) => Ok(manifest_dir.join(p)),
174 (None, Some(url)) => Ok(cache_root.join(repo_dir_name(url))),
175 (Some(_), Some(_)) => {
176 anyhow::bail!("package entry has both `path` and `url`; pick one")
177 }
178 (None, None) => {
179 anyhow::bail!("package entry has neither `path` nor `url`")
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn git(dir: &Path, args: &[&str]) {
189 let ok = std::process::Command::new("git")
190 .args(args)
191 .current_dir(dir)
192 .output()
193 .expect("git")
194 .status
195 .success();
196 assert!(ok, "git {args:?} failed in {}", dir.display());
197 }
198
199 #[test]
200 fn the_lockfile_records_the_commit_and_whether_the_tree_was_edited() {
201 let nonce = std::time::SystemTime::now()
206 .duration_since(std::time::UNIX_EPOCH)
207 .expect("system clock")
208 .as_nanos();
209 let temp = std::env::temp_dir().join(format!("rbnx-lock-{}-{nonce}", std::process::id()));
210 let pkg = temp.join("pkg");
211 std::fs::create_dir_all(&pkg).expect("package directory");
212 git(&pkg, &["init", "-q"]);
213 git(&pkg, &["config", "user.email", "test@example.invalid"]);
214 git(&pkg, &["config", "user.name", "test"]);
215 std::fs::write(pkg.join("f"), "one").expect("file");
216 git(&pkg, &["add", "."]);
217 git(&pkg, &["commit", "-qm", "one"]);
218
219 let deploy: DeployManifest =
220 serde_yaml::from_str("primitive:\n - name: sample\n path: pkg\n branch: main\n")
221 .expect("deployment manifest");
222 let cache_root = temp.join("rbnx-boot/cache");
223 let lock = temp.join("rbnx-boot/deployment.lock");
224
225 write_lockfile(&deploy, &cache_root, &temp);
226 let clean = std::fs::read_to_string(&lock).expect("lockfile");
227 assert!(clean.contains("version: 1"), "{clean}");
228 assert!(clean.contains("name: sample"), "{clean}");
229 assert!(clean.contains("original:"), "{clean}");
231 assert!(clean.contains("branch: main"), "{clean}");
232 assert!(clean.contains("locked:"), "{clean}");
233 let head = std::process::Command::new("git")
234 .args(["rev-parse", "HEAD"])
235 .current_dir(&pkg)
236 .output()
237 .expect("git rev-parse");
238 let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
239 assert!(clean.contains(&format!("commit: {head}")), "{clean}");
240 assert!(
241 !clean.contains("dirty"),
242 "a clean checkout must not be flagged: {clean}"
243 );
244
245 let before = std::fs::metadata(&lock)
247 .expect("lock metadata")
248 .modified()
249 .ok();
250 write_lockfile(&deploy, &cache_root, &temp);
251 assert_eq!(std::fs::read_to_string(&lock).expect("lockfile"), clean);
252 assert_eq!(
253 std::fs::metadata(&lock)
254 .expect("lock metadata")
255 .modified()
256 .ok(),
257 before,
258 "an unchanged deployment must not rewrite its lock"
259 );
260
261 std::fs::write(pkg.join("f"), "two").expect("edit");
265 write_lockfile(&deploy, &cache_root, &temp);
266 let dirty = std::fs::read_to_string(&lock).expect("lockfile");
267 assert!(dirty.contains("dirty: true"), "{dirty}");
268 assert!(dirty.contains("dirty_digest: sha256:"), "{dirty}");
269
270 let first_digest = dirty
271 .lines()
272 .find(|l| l.contains("dirty_digest"))
273 .expect("digest line")
274 .trim()
275 .to_string();
276 std::fs::write(pkg.join("f"), "three").expect("second edit");
277 write_lockfile(&deploy, &cache_root, &temp);
278 let dirty2 = std::fs::read_to_string(&lock).expect("lockfile");
279 assert!(
280 !dirty2.contains(&first_digest),
281 "a different edit must not share a digest: {dirty2}"
282 );
283
284 std::fs::remove_dir_all(&temp).expect("remove test directory");
285 }
286
287 #[test]
288 fn boot_prerequisites_build_scene_but_skip_vitals_builtin() {
289 let nonce = std::time::SystemTime::now()
290 .duration_since(std::time::UNIX_EPOCH)
291 .expect("system clock")
292 .as_nanos();
293 let temp = std::env::temp_dir().join(format!(
294 "rbnx-system-prerequisite-{}-{nonce}",
295 std::process::id()
296 ));
297 let scene = temp.join("system/scene");
298 std::fs::create_dir_all(&scene).expect("scene package directory");
299 std::fs::create_dir_all(temp.join("system/vitals")).expect("vitals builtin directory");
300 std::fs::write(
301 scene.join("package_manifest.yaml"),
302 r#"manifestVersion: 1
303package:
304 name: com.robonix.system.scene.test
305 version: 0.1.0
306 description: test system package
307 license: MulanPSL-2.0
308build: mkdir -p rbnx-build && touch rbnx-build/proof
309start: "true"
310stop: "true"
311"#,
312 )
313 .expect("test package manifest");
314
315 let deploy = DeployManifest {
316 system: HashMap::from([
317 ("scene".to_string(), serde_yaml::Value::Null),
318 ("vitals".to_string(), serde_yaml::Value::Null),
319 ]),
320 ..Default::default()
321 };
322 let manifest_dir = temp.join("deployment");
323 let cache_root = manifest_dir.join("rbnx-boot/cache");
324 std::fs::create_dir_all(&cache_root).expect("cache directory");
325
326 check_prerequisites(&deploy, &cache_root, &manifest_dir, Some(&temp))
327 .expect("system-package prerequisite build");
328
329 assert!(scene.join("rbnx-build/proof").is_file());
330 assert!(scene.join("rbnx-build/.rbnx-built").is_file());
331 std::fs::remove_dir_all(temp).expect("remove test directory");
332 }
333
334 #[test]
335 fn soma_always_receives_the_selected_boot_manifest() {
336 use serde_yaml::{Mapping, Value};
337
338 let manifest_dir = PathBuf::from("/tmp/ranger-deploy");
339 let selected = manifest_dir.join("robonix_manifest.arm.yaml");
340 let mut soma = Mapping::new();
341 soma.insert(
344 Value::String("deployment_manifest".into()),
345 Value::String("robonix_manifest.yaml".into()),
346 );
347 let mut system = HashMap::from([("soma".to_string(), Value::Mapping(soma))]);
348
349 ensure_soma_defaults(&mut system, &manifest_dir, &selected);
350 let args = system_cli_args("soma", system.get("soma"), None);
351 let manifest_arg = args
352 .windows(2)
353 .find(|pair| pair[0] == "--deployment-manifest")
354 .map(|pair| pair[1].as_str());
355
356 assert_eq!(manifest_arg, Some(selected.to_string_lossy().as_ref()));
357 }
358
359 #[test]
360 fn vitals_receives_typed_manifest_fields() {
361 let cfg: serde_yaml::Value = serde_yaml::from_str(
362 r#"
363listen: 0.0.0.0:50093
364provider_id: vitals
365thresholds_path: config/vitals.yaml
366soma_endpoint: 127.0.0.1:50091
367"#,
368 )
369 .unwrap();
370 let args = system_cli_args("vitals", Some(&cfg), Some("0.0.0.0:50051"));
371
372 for expected in [
373 ["--listen", "0.0.0.0:50093"],
374 ["--atlas", "0.0.0.0:50051"],
375 ["--id", "vitals"],
376 ["--thresholds-path", "config/vitals.yaml"],
377 ["--soma-endpoint", "127.0.0.1:50091"],
378 ] {
379 assert!(
380 args.windows(2)
381 .any(|pair| pair[0] == expected[0] && pair[1] == expected[1]),
382 "missing {:?} in {:?}",
383 expected,
384 args
385 );
386 }
387 }
388
389 #[test]
390 fn provider_failure_ignores_later_shutdown_noise() {
391 let path = std::env::temp_dir().join(format!(
392 "rbnx-provider-failure-{}.log",
393 uuid::Uuid::new_v4()
394 ));
395 std::fs::write(
396 &path,
397 concat!(
398 "{\"level\":\"info\",\"msg\":\"ready -- awaiting Driver(CMD_INIT)\"}\n",
399 "{\"level\":\"info\",\"msg\":\"[ranger_chassis] state REGISTERED -> ERROR (CAN setup failed: sudo password required)\"}\n",
400 "{\"level\":\"info\",\"msg\":\"shutdown hook completed\"}\n",
401 ),
402 )
403 .unwrap();
404
405 assert_eq!(
406 read_provider_failure(&path).as_deref(),
407 Some("CAN setup failed: sudo password required")
408 );
409 let _ = std::fs::remove_file(path);
410 }
411
412 #[test]
413 fn provider_failure_accepts_error_level_records() {
414 let path = std::env::temp_dir().join(format!(
415 "rbnx-provider-error-level-{}.log",
416 uuid::Uuid::new_v4()
417 ));
418 std::fs::write(
419 &path,
420 "{\"level\":\"error\",\"msg\":\"camera device disconnected\"}\n",
421 )
422 .unwrap();
423
424 assert_eq!(
425 read_provider_failure(&path).as_deref(),
426 Some("camera device disconnected")
427 );
428 let _ = std::fs::remove_file(path);
429 }
430
431 #[test]
432 fn provider_exit_summary_uses_last_structured_message() {
433 let path = std::env::temp_dir().join(format!(
434 "rbnx-provider-exit-summary-{}.log",
435 uuid::Uuid::new_v4()
436 ));
437 std::fs::write(
438 &path,
439 concat!(
440 "{\"level\":\"info\",\"msg\":\"Traceback (most recent call last):\"}\n",
441 "{\"level\":\"info\",\"msg\":\"ImportError: generated contract is missing\"}\n",
442 "{\"level\":\"info\",\"msg\":\"Error: scene process exited with status 1\"}\n",
443 ),
444 )
445 .unwrap();
446
447 assert_eq!(
448 read_provider_exit_summary(&path).as_deref(),
449 Some("Error: scene process exited with status 1")
450 );
451 let _ = std::fs::remove_file(path);
452 }
453}
454
455fn check_prerequisites(
468 deploy: &DeployManifest,
469 cache_root: &Path,
470 manifest_dir: &Path,
471 robonix_source_path: Option<&Path>,
472) -> Result<()> {
473 use std::collections::BTreeMap;
474 let mut needs_clone: BTreeMap<String, (String, Option<String>, Option<String>)> =
476 BTreeMap::new();
477 let mut needs_build: BTreeMap<String, (PathBuf, Option<String>)> = BTreeMap::new();
479 for entry in deploy
480 .primitive
481 .iter()
482 .chain(deploy.service.iter())
483 .chain(deploy.skill.iter())
484 {
485 let pkg_path = match resolve_entry_path(entry, cache_root, manifest_dir) {
486 Ok(p) => p,
487 Err(_) => continue, };
489 let name = if entry.name.is_empty() {
490 pkg_path
491 .file_name()
492 .and_then(|n| n.to_str())
493 .unwrap_or("(unnamed)")
494 .to_string()
495 } else {
496 entry.name.clone()
497 };
498 if !pkg_path.exists()
499 && let Some(url) = entry.url.as_ref()
500 {
501 needs_clone.insert(
502 name.clone(),
503 (url.clone(), entry.branch.clone(), entry.manifest.clone()),
504 );
505 continue;
506 }
507 let stamp = pkg_path.join("rbnx-build").join(".rbnx-built");
508 if !stamp.exists() {
509 needs_build.insert(name, (pkg_path, entry.manifest.clone()));
510 }
511 }
512
513 if let Some(source_root) = robonix_source_path {
522 for name in deploy.system.keys() {
523 if is_builtin_system(name.as_str()) {
524 continue;
525 }
526 let pkg_path = source_root.join("system").join(name);
527 if !pkg_path.exists() {
528 continue; }
530 let stamp = pkg_path.join("rbnx-build").join(".rbnx-built");
531 if !stamp.exists() {
532 needs_build.insert(name.clone(), (pkg_path, None));
533 }
534 }
535 }
536 if needs_clone.is_empty() && needs_build.is_empty() {
537 return Ok(());
538 }
539 output::boot_section("prerequisites");
540 for (name, (url, branch, manifest_ov)) in &needs_clone {
541 output::warning(&format!(
542 "{name}: not in cache — `rbnx build` should run before `rbnx boot`. cloning inline."
543 ));
544 let dest = cache_root.join(repo_dir_name(url));
545 std::fs::create_dir_all(cache_root)?;
546 super::run_package::git_clone_with_retry(url, branch.as_deref(), &dest)?;
547 let stamp = dest.join("rbnx-build").join(".rbnx-built");
549 if !stamp.exists() {
550 needs_build.insert(name.clone(), (dest, manifest_ov.clone()));
551 }
552 }
553 for (name, (pkg_path, manifest_ov)) in &needs_build {
554 output::warning(&format!(
555 "{name}: not built — `rbnx build` should run before `rbnx boot`. building inline."
556 ));
557 crate::cmd::build::build_local_package(pkg_path, false, manifest_ov.as_deref())
558 .with_context(|| format!("inline build of {name} at {} failed", pkg_path.display()))?;
559 }
560 Ok(())
561}
562
563struct ResolvedPackage {
565 name: String,
566 url: Option<String>,
568 path: Option<String>,
569 branch: Option<String>,
570 commit: Option<String>,
572 dirty_digest: Option<String>,
573}
574
575fn git_state(dir: &Path) -> (Option<String>, Option<String>) {
587 let run = |args: &[&str]| -> Option<String> {
588 std::process::Command::new("git")
589 .args(args)
590 .current_dir(dir)
591 .output()
592 .ok()
593 .filter(|o| o.status.success())
594 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
595 };
596 let head = run(&["rev-parse", "HEAD"])
597 .map(|s| s.trim().to_string())
598 .filter(|s| !s.is_empty());
599 if head.is_none() {
600 return (None, None);
601 }
602 let status = run(&["status", "--porcelain"]).unwrap_or_default();
605 if status.trim().is_empty() {
606 return (head, None);
607 }
608 let diff = run(&["diff", "HEAD"]).unwrap_or_default();
609 let mut hasher = Sha256::new();
610 hasher.update(status.as_bytes());
611 hasher.update(diff.as_bytes());
612 (head, Some(format!("sha256:{:x}", hasher.finalize())))
613}
614
615fn write_lockfile(deploy: &DeployManifest, cache_root: &Path, manifest_dir: &Path) {
631 let mut rows: Vec<ResolvedPackage> = Vec::new();
632 for entry in deploy
633 .primitive
634 .iter()
635 .chain(deploy.service.iter())
636 .chain(deploy.skill.iter())
637 {
638 let Ok(pkg_path) = resolve_entry_path(entry, cache_root, manifest_dir) else {
639 continue;
640 };
641 if !pkg_path.exists() {
642 continue;
643 }
644 let (commit, dirty_digest) = git_state(&pkg_path);
645 rows.push(ResolvedPackage {
646 name: if entry.name.is_empty() {
647 pkg_path
648 .file_name()
649 .and_then(|n| n.to_str())
650 .unwrap_or("(unnamed)")
651 .to_string()
652 } else {
653 entry.name.clone()
654 },
655 url: entry.url.clone(),
656 path: entry.path.clone(),
657 branch: entry.branch.clone(),
658 commit,
659 dirty_digest,
660 });
661 }
662 rows.sort_by(|a, b| a.name.cmp(&b.name));
664
665 for row in rows.iter().filter(|r| r.dirty_digest.is_some()) {
666 output::warning(&format!(
667 "{}: the cached checkout has uncommitted changes — this run is not \
668 reproducible from its branch alone",
669 row.name
670 ));
671 }
672
673 let mut out = String::from(
674 "# Generated by `rbnx boot`. Do not edit.\n\
675 #\n\
676 # `original` is what the manifest asked for, `locked` is what that\n\
677 # resolved to on this machine. A manifest pins packages by branch and a\n\
678 # cache is cloned once, so the two are not the same thing and only this\n\
679 # file records the second.\n\
680 version: 1\n\
681 packages:\n",
682 );
683 for row in &rows {
684 out.push_str(&format!(" - name: {}\n original:\n", row.name));
685 if let Some(u) = &row.url {
686 out.push_str(&format!(" url: {u}\n"));
687 }
688 if let Some(p) = &row.path {
689 out.push_str(&format!(" path: {p}\n"));
690 }
691 if let Some(b) = &row.branch {
692 out.push_str(&format!(" branch: {b}\n"));
693 }
694 out.push_str(" locked:\n");
695 match &row.commit {
696 Some(c) => out.push_str(&format!(" commit: {c}\n")),
697 None => out.push_str(" commit: null # not a git checkout\n"),
698 }
699 if let Some(d) = &row.dirty_digest {
700 out.push_str(&format!(" dirty: true\n dirty_digest: {d}\n"));
701 }
702 }
703
704 let path = manifest_dir.join("rbnx-boot").join("deployment.lock");
705 if let Some(parent) = path.parent() {
706 let _ = std::fs::create_dir_all(parent);
707 }
708 if std::fs::read_to_string(&path).ok().as_deref() == Some(out.as_str()) {
711 return;
712 }
713 if let Err(e) = std::fs::write(&path, out) {
714 output::warning(&format!("could not write {}: {e}", path.display()));
715 }
716}
717
718pub(super) fn prepare_manifest(
723 root: serde_yaml::Value,
724 robonix_source_path: Option<&Path>,
725) -> Result<serde_yaml::Value> {
726 let prepared = robonix_cli::manifest::prepare_deployment_manifest(root, robonix_source_path)?;
727 robonix_cli::manifest::validate_deployment_instance_names(&prepared)?;
728 Ok(prepared)
729}
730
731fn ensure_soma_defaults(
778 system: &mut HashMap<String, serde_yaml::Value>,
779 manifest_dir: &Path,
780 manifest_path: &Path,
781) {
782 use serde_yaml::{Mapping, Value};
783 let entry = system
784 .entry("soma".to_string())
785 .or_insert_with(|| Value::Mapping(Mapping::new()));
786 if !entry.is_mapping() {
790 *entry = Value::Mapping(Mapping::new());
791 }
792 let map = entry
793 .as_mapping_mut()
794 .expect("promoted to mapping just above");
795
796 map.insert(
802 Value::String("deployment_manifest".to_string()),
803 Value::String(manifest_path.to_string_lossy().into_owned()),
804 );
805
806 let robot_yaml_key = Value::String("robot_yaml".to_string());
814 let robot_yaml_missing = match map.get(&robot_yaml_key) {
815 None => true,
816 Some(Value::Null) => true,
817 Some(Value::String(s)) if s.is_empty() => true,
818 _ => false,
819 };
820 if robot_yaml_missing {
821 let sidecar = manifest_dir.join("soma.yaml");
822 if sidecar.is_file() {
823 map.insert(
824 robot_yaml_key,
825 Value::String(sidecar.to_string_lossy().into_owned()),
826 );
827 }
828 }
829
830 for key in ["robot_yaml", "deployment_manifest", "config"] {
831 let k = Value::String(key.to_string());
832 let Some(v) = map.get_mut(&k) else { continue };
833 let Some(s) = v.as_str() else { continue };
834 if s.is_empty() {
840 continue;
841 }
842 let p = Path::new(s);
843 if p.is_absolute() {
844 continue;
845 }
846 let joined = manifest_dir.join(p);
847 *v = Value::String(joined.to_string_lossy().into_owned());
848 }
849}
850
851struct Spawned {
854 name: String,
855 kind: String,
857 child: Child,
858 pid: u32,
859 pgid: u32,
862 provider_id: Option<String>,
863 driver_contract: Option<String>,
864 expected_driver_contract: Option<String>,
867 allow_shared_driver_upgrade: bool,
870 config_json: Option<String>,
871 package_dir: Option<PathBuf>,
872 stop: Option<String>,
873}
874
875fn log_path(log_dir: &Path, name: &str) -> PathBuf {
876 log_dir.join(format!("{name}.log"))
879}
880
881async fn spawn_system_binary(
882 log_dir: &Path,
883 name: &str,
884 bin: &str,
885 args: &[String],
886) -> Result<Spawned> {
887 let mut cmd = Command::new(bin);
892 for a in args {
893 cmd.arg(a);
894 }
895 cmd.stdin(Stdio::null())
896 .stdout(Stdio::piped())
897 .stderr(Stdio::piped())
898 .env("SCRIBE_LOG_DIR", log_dir)
899 .process_group(0);
900 let mut child = cmd.spawn().with_context(|| {
901 format!(
902 "failed to spawn system binary `{bin}` — is it installed (try `make install` from the rust/ workspace)?"
903 )
904 })?;
905 let pid = child
906 .id()
907 .ok_or_else(|| anyhow::anyhow!("spawned `{bin}` but it had no pid"))?;
908
909 let stdout = child.stdout.take().expect("stdout not piped");
912 let stderr = child.stderr.take().expect("stderr not piped");
913 let tag_out = name.to_string();
914 let tag_err = name.to_string();
915 tokio::spawn(async move {
916 let reader = tokio::io::BufReader::new(stdout);
917 let mut lines = reader.lines();
918 while let Ok(Some(line)) = lines.next_line().await {
919 scribe::ingest(&tag_out, &line);
920 }
921 });
922 tokio::spawn(async move {
923 let reader = tokio::io::BufReader::new(stderr);
924 let mut lines = reader.lines();
925 while let Ok(Some(line)) = lines.next_line().await {
926 scribe::ingest(&tag_err, &line);
930 }
931 });
932 let detail = system_boot_detail(name, args);
937 output::boot_ok(name, &detail);
938 Ok(Spawned {
939 name: name.to_string(),
940 kind: "system_builtin".to_string(),
941 child,
942 pid,
943 pgid: pid,
944 provider_id: None,
945 driver_contract: None,
946 expected_driver_contract: None,
947 allow_shared_driver_upgrade: false,
948 config_json: None,
949 package_dir: None,
950 stop: None,
951 })
952}
953
954const SOMA_STAGE_FD: RawFd = 3;
959
960async fn spawn_soma_binary(
977 log_dir: &Path,
978 name: &str,
979 bin: &str,
980 args: &[String],
981) -> Result<(Spawned, std::fs::File)> {
982 use std::os::fd::{AsRawFd, IntoRawFd, OwnedFd};
983
984 let (read_fd, write_fd): (OwnedFd, OwnedFd) =
989 nix::unistd::pipe().context("pipe() for soma stage-2 trigger")?;
990 let child_raw = read_fd.as_raw_fd();
991
992 let mut cmd = Command::new(bin);
996 for a in args {
997 cmd.arg(a);
998 }
999 cmd.stdin(Stdio::null())
1000 .stdout(Stdio::piped())
1001 .stderr(Stdio::piped())
1002 .env("SCRIBE_LOG_DIR", log_dir)
1003 .env("ROBONIX_SOMA_STAGE_FD", SOMA_STAGE_FD.to_string())
1004 .process_group(0);
1005
1006 let read_owned = read_fd; let child_target = SOMA_STAGE_FD;
1012 unsafe {
1016 cmd.pre_exec(move || {
1017 let old = read_owned.as_raw_fd();
1022 if old != child_target {
1023 let ret = libc_dup2(old, child_target);
1024 if ret < 0 {
1025 return Err(std::io::Error::last_os_error());
1026 }
1027 let _ = libc_close(old);
1030 }
1031 Ok(())
1032 });
1033 }
1034
1035 let mut child = cmd.spawn().with_context(|| {
1036 format!(
1037 "failed to spawn system binary `{bin}` — is it installed (try `make install` from the rust/ workspace)?"
1038 )
1039 })?;
1040 let pid = child
1041 .id()
1042 .ok_or_else(|| anyhow::anyhow!("spawned `{bin}` but it had no pid"))?;
1043
1044 let _ = child_raw;
1052
1053 let write_raw = write_fd.into_raw_fd();
1057 let writer = unsafe { <std::fs::File as std::os::fd::FromRawFd>::from_raw_fd(write_raw) };
1060
1061 let stdout = child.stdout.take().expect("stdout not piped");
1065 let stderr = child.stderr.take().expect("stderr not piped");
1066 let tag_out = name.to_string();
1067 let tag_err = name.to_string();
1068 tokio::spawn(async move {
1069 let reader = tokio::io::BufReader::new(stdout);
1070 let mut lines = reader.lines();
1071 while let Ok(Some(line)) = lines.next_line().await {
1072 scribe::ingest(&tag_out, &line);
1073 }
1074 });
1075 tokio::spawn(async move {
1076 let reader = tokio::io::BufReader::new(stderr);
1077 let mut lines = reader.lines();
1078 while let Ok(Some(line)) = lines.next_line().await {
1079 scribe::ingest(&tag_err, &line);
1080 }
1081 });
1082
1083 let detail = system_boot_detail(name, args);
1084 output::boot_ok(name, &detail);
1085 Ok((
1086 Spawned {
1087 name: name.to_string(),
1088 kind: "system_builtin".to_string(),
1089 child,
1090 pid,
1091 pgid: pid,
1092 provider_id: None,
1093 driver_contract: None,
1094 expected_driver_contract: None,
1095 allow_shared_driver_upgrade: false,
1096 config_json: None,
1097 package_dir: None,
1098 stop: None,
1099 },
1100 writer,
1101 ))
1102}
1103
1104unsafe extern "C" {
1110 fn dup2(oldfd: i32, newfd: i32) -> i32;
1111 fn close(fd: i32) -> i32;
1112}
1113#[inline]
1114fn libc_dup2(oldfd: RawFd, newfd: RawFd) -> i32 {
1115 unsafe { dup2(oldfd, newfd) }
1116}
1117#[inline]
1118fn libc_close(fd: RawFd) -> i32 {
1119 unsafe { close(fd) }
1120}
1121
1122struct PackageSpawnEnv<'a> {
1123 log_dir: &'a Path,
1124 cache_root: &'a Path,
1125 instances_dir: &'a Path,
1126 manifest_dir: &'a Path,
1127 atlas_endpoint: &'a str,
1128}
1129
1130async fn spawn_package(
1131 component: &str,
1132 entry: &PackageEntry,
1133 env: &PackageSpawnEnv<'_>,
1134) -> Result<Spawned> {
1135 let pkg_path = resolve_entry_path(entry, env.cache_root, env.manifest_dir)?;
1136 let pkg_path = pkg_path
1137 .canonicalize()
1138 .with_context(|| format!("package path not found: {}", pkg_path.display()))?;
1139
1140 let name = if entry.name.is_empty() {
1141 pkg_path
1142 .file_name()
1143 .and_then(|n| n.to_str())
1144 .unwrap_or("package")
1145 .to_string()
1146 } else {
1147 entry.name.clone()
1148 };
1149 let package_manifest =
1150 robonix_cli::manifest::detect_and_load(&pkg_path, entry.manifest.as_deref())
1151 .with_context(|| format!("load package manifest for {}", pkg_path.display()))?;
1152 package_manifest.manifest.validate_and_summarize()?;
1153 let explicit_driver_contract = package_manifest
1154 .manifest
1155 .explicit_lifecycle_driver_contract()?;
1156 let allow_shared_driver_upgrade = explicit_driver_contract.is_some_and(|contract| {
1157 contract != robonix_cli::manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT
1158 });
1159 let expected_driver_contract = Some(
1160 package_manifest
1161 .manifest
1162 .selected_lifecycle_driver_contract()?
1163 .to_string(),
1164 );
1165 let stop = package_manifest.manifest.stop.trim().to_string();
1166 let stop = if stop.is_empty() { None } else { Some(stop) };
1167 let log_name = name.clone();
1172
1173 let cfg_json = serde_json::to_value(&entry.config).unwrap_or(serde_json::Value::Null);
1180 let cfg_pretty = serde_json::to_string_pretty(&cfg_json).unwrap_or_else(|_| "{}".into());
1181 let cfg_file = env.instances_dir.join(format!("{name}.json"));
1182 std::fs::write(&cfg_file, &cfg_pretty)
1183 .with_context(|| format!("failed to write {}", cfg_file.display()))?;
1184
1185 let rbnx_bin = std::env::current_exe()
1191 .context("could not resolve current rbnx binary path for `start` re-exec")?;
1192 let _ = &cfg_file; let provider_atlas = env.atlas_endpoint.replacen("0.0.0.0", "127.0.0.1", 1);
1207 let mut cmd = Command::new(&rbnx_bin);
1208 cmd.arg("start")
1209 .arg("-p")
1210 .arg(pkg_path.as_os_str())
1211 .arg("--endpoint")
1212 .arg(&provider_atlas)
1213 .env("RBNX_INSTANCE_NAME", &name)
1214 .env("RBNX_INVOCATION_CWD", env.manifest_dir)
1215 .env("RBNX_DEPLOY_MANAGED", "1")
1219 .env("SCRIBE_LOG_DIR", env.log_dir)
1220 .stdin(Stdio::null())
1221 .stdout(Stdio::piped())
1222 .stderr(Stdio::piped())
1223 .process_group(0);
1224 if let Some(m) = entry.manifest.as_deref() {
1228 cmd.arg("--manifest").arg(m);
1229 }
1230 let mut child = cmd.spawn().with_context(|| {
1231 format!(
1232 "failed to spawn package {name} via `{} start`",
1233 rbnx_bin.display()
1234 )
1235 })?;
1236 let pid = child
1237 .id()
1238 .ok_or_else(|| anyhow::anyhow!("spawned package '{name}' but it had no pid"))?;
1239
1240 let stdout = child.stdout.take().expect("stdout not piped");
1243 let stderr = child.stderr.take().expect("stderr not piped");
1244 let tag_out = log_name.clone();
1245 let tag_err = log_name.clone();
1246 tokio::spawn(async move {
1247 let reader = tokio::io::BufReader::new(stdout);
1248 let mut lines = reader.lines();
1249 while let Ok(Some(line)) = lines.next_line().await {
1250 scribe::ingest(&tag_out, &line);
1251 }
1252 });
1253 tokio::spawn(async move {
1254 let reader = tokio::io::BufReader::new(stderr);
1255 let mut lines = reader.lines();
1256 while let Ok(Some(line)) = lines.next_line().await {
1257 scribe::ingest(&tag_err, &line);
1261 }
1262 });
1263 let kind = match component {
1267 "system" => "system_package",
1268 other => other,
1269 }
1270 .to_string();
1271 Ok(Spawned {
1272 name: log_name,
1273 kind,
1274 child,
1275 pid,
1276 pgid: pid,
1277 provider_id: None,
1278 driver_contract: None,
1279 expected_driver_contract,
1280 allow_shared_driver_upgrade,
1281 config_json: None,
1282 package_dir: Some(pkg_path),
1283 stop,
1284 })
1285}
1286
1287pub async fn execute(
1290 config: robonix_cli::Config,
1291 manifest_path: PathBuf,
1292 log_dir: Option<PathBuf>,
1293 skip_system: bool,
1294 no_update_check: bool,
1295 verbose: bool,
1296) -> Result<()> {
1297 output::set_boot_verbose(verbose);
1298 let manifest_path = manifest_path
1299 .canonicalize()
1300 .with_context(|| format!("manifest not found: {}", manifest_path.display()))?;
1301 let manifest_dir = manifest_path
1302 .parent()
1303 .context("manifest has no parent directory")?
1304 .to_path_buf();
1305
1306 let raw = std::fs::read_to_string(&manifest_path)
1307 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
1308 let root: serde_yaml::Value = serde_yaml::from_str(&raw)
1309 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
1310 let root = prepare_manifest(root, config.robonix_source_path.as_deref())
1311 .with_context(|| format!("failed to prepare {}", manifest_path.display()))?;
1312 robonix_cli::manifest::validate_deployment_instance_names(&root).with_context(|| {
1313 format!(
1314 "invalid deployment identities in {}",
1315 manifest_path.display()
1316 )
1317 })?;
1318 let mut deploy: DeployManifest = serde_yaml::from_value(root)
1319 .with_context(|| format!("failed to decode {}", manifest_path.display()))?;
1320 output::boot_banner();
1323 output::boot_start(
1324 if deploy.name.is_empty() {
1325 "robonix"
1326 } else {
1327 &deploy.name
1328 },
1329 &manifest_path.display().to_string(),
1330 );
1331 if !no_update_check {
1334 super::check_remotes::report_outdated(&manifest_path);
1335 }
1336
1337 let soma_declared = deploy.system.contains_key("soma");
1367 let soma_implied = !deploy.primitive.is_empty() || !deploy.skill.is_empty();
1368 if (soma_declared || soma_implied) && !skip_system {
1369 ensure_soma_defaults(&mut deploy.system, &manifest_dir, &manifest_path);
1370 }
1371
1372 let log_dir = log_dir.unwrap_or_else(|| manifest_dir.join("rbnx-boot").join("logs"));
1373 std::fs::create_dir_all(&log_dir)
1376 .with_context(|| format!("failed to create log dir {}", log_dir.display()))?;
1377
1378 unsafe {
1384 std::env::set_var("SCRIBE_LOG_DIR", log_dir.as_os_str());
1385 }
1386 scribe::info(
1387 "bootstrap",
1388 &format!(
1389 "booting {} from {}",
1390 if deploy.name.is_empty() {
1391 "robonix"
1392 } else {
1393 &deploy.name
1394 },
1395 manifest_path.display()
1396 ),
1397 );
1398
1399 let cache_root = manifest_dir.join("rbnx-boot").join("cache");
1400 let instances_dir = manifest_dir.join("rbnx-boot").join("instances");
1401 std::fs::create_dir_all(&instances_dir)
1402 .with_context(|| format!("failed to create instances dir {}", instances_dir.display()))?;
1403
1404 let mut children: Vec<Spawned> = Vec::new();
1405 let state_path = teardown::state_path(&manifest_dir);
1406 let boot_id = Uuid::new_v4().to_string();
1407 unsafe { std::env::set_var("RBNX_BOOT_ID", &boot_id) };
1411 let boot_start_time_ticks = robonix_cli::launch::proc_start_time_ticks(std::process::id());
1412 let started_at_ms = std::time::SystemTime::now()
1413 .duration_since(std::time::UNIX_EPOCH)
1414 .map(|d| d.as_millis() as u64)
1415 .unwrap_or(0);
1416 let atlas_endpoint = deploy
1417 .system
1418 .get("atlas")
1419 .and_then(|v| v.as_mapping())
1420 .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1421 .and_then(|v| v.as_str())
1422 .unwrap_or("127.0.0.1:50051")
1423 .to_string();
1424 teardown::write_state(
1425 &state_path,
1426 &teardown::BootState {
1427 manifest_path: manifest_path.display().to_string(),
1428 boot_pid: std::process::id(),
1429 boot_start_time_ticks,
1430 boot_id: boot_id.clone(),
1431 started_at_ms,
1432 atlas_endpoint: atlas_endpoint.clone(),
1433 components: Vec::new(),
1434 },
1435 )?;
1436 super::boot_watchdog::spawn(
1437 &state_path,
1438 std::process::id(),
1439 boot_start_time_ticks,
1440 &boot_id,
1441 )?;
1442 let spawn_env = PackageSpawnEnv {
1443 log_dir: &log_dir,
1444 cache_root: &cache_root,
1445 instances_dir: &instances_dir,
1446 manifest_dir: &manifest_dir,
1447 atlas_endpoint: &atlas_endpoint,
1448 };
1449
1450 check_prerequisites(
1456 &deploy,
1457 &cache_root,
1458 &manifest_dir,
1459 config.robonix_source_path.as_deref(),
1460 )?;
1461
1462 write_lockfile(&deploy, &cache_root, &manifest_dir);
1465
1466 let mut sigint = signal(SignalKind::interrupt())?;
1474 let mut sigterm = signal(SignalKind::terminate())?;
1475
1476 let mut soma_stage_writer: Option<std::fs::File> = None;
1481
1482 let bringup = async {
1483 if !skip_system {
1484 output::boot_section("system");
1485 let atlas_listen = deploy
1490 .system
1491 .get("atlas")
1492 .and_then(|v| v.as_mapping())
1493 .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1494 .and_then(|v| v.as_str())
1495 .map(str::to_string);
1496 let soma_listen = deploy
1497 .system
1498 .get("soma")
1499 .and_then(|v| v.as_mapping())
1500 .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1501 .and_then(|v| v.as_str())
1502 .map(|s| s.replacen("0.0.0.0", "127.0.0.1", 1));
1503 let mut atlas_caps_roots: Vec<String> = Vec::new();
1514 if let Some(root) = config.robonix_source_path.as_ref() {
1515 atlas_caps_roots.push(root.join("capabilities").to_string_lossy().into_owned());
1516 }
1517 for entry in deploy
1518 .primitive
1519 .iter()
1520 .chain(deploy.service.iter())
1521 .chain(deploy.skill.iter())
1522 {
1523 if let Ok(pkg_path) = resolve_entry_path(entry, &cache_root, &manifest_dir) {
1524 let providers = pkg_path.join("capabilities");
1525 if providers.is_dir() {
1526 atlas_caps_roots.push(providers.to_string_lossy().into_owned());
1527 }
1528 }
1529 }
1530 let atlas_caps_default: Option<String> = if atlas_caps_roots.is_empty() {
1531 None
1532 } else {
1533 Some(atlas_caps_roots.join(","))
1534 };
1535 let bin_map: &[(&str, &str)] = &[
1536 ("atlas", "robonix-atlas"),
1537 ("executor", "robonix-executor"),
1538 ("soma", "robonix-soma"),
1539 ("vitals", "robonix-vitals"),
1540 ("pilot", "robonix-pilot"),
1541 ("liaison", "robonix-liaison"),
1542 ];
1543 for (name, bin) in bin_map {
1544 if !deploy.system.contains_key(*name) {
1545 continue;
1546 }
1547 let mut args =
1548 system_cli_args(name, deploy.system.get(*name), atlas_listen.as_deref());
1549 if *name == "vitals"
1550 && !args.iter().any(|arg| arg == "--soma-endpoint")
1551 && let Some(endpoint) = soma_listen.as_ref()
1552 {
1553 args.push("--soma-endpoint".into());
1554 args.push(endpoint.clone());
1555 }
1556 if *name == "atlas"
1557 && !args.iter().any(|a| a == "--capabilities")
1558 && let Some(p) = atlas_caps_default.as_ref()
1559 {
1560 args.push("--capabilities".into());
1561 args.push(p.clone());
1562 }
1563 if let Some(listen) = system_listen(name, deploy.system.get(*name))
1571 && let Err(e) = port_is_free(&listen)
1572 {
1573 output::boot_fail(
1574 name,
1575 &format!(
1576 "listen address '{listen}' is taken: {e:#}. \
1577 Stop the running process (try `bash sim/stop.sh` \
1578 or `pkill -f robonix-{name}`) and retry."
1579 ),
1580 );
1581 anyhow::bail!(
1582 "system/{name}: listen address '{listen}' is already in use; \
1583 refusing to spawn (would shadow the existing process)"
1584 );
1585 }
1586
1587 if let Err(e) = require_system_args(name, &args) {
1594 output::boot_fail(name, &e);
1595 anyhow::bail!("system/{name}: {e}");
1596 }
1597
1598 let sp = if *name == "soma" {
1599 let (sp, writer) = spawn_soma_binary(&log_dir, name, bin, &args).await?;
1602 soma_stage_writer = Some(writer);
1603 sp
1604 } else {
1605 spawn_system_binary(&log_dir, name, bin, &args).await?
1606 };
1607 children.push(sp);
1608 persist_state(
1609 &state_path,
1610 &manifest_path,
1611 &atlas_endpoint,
1612 started_at_ms,
1613 &children,
1614 );
1615 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1616 if *name == "soma" {
1617 if !deploy.primitive.is_empty() {
1618 output::boot_section("primitive");
1619 if output::boot_verbose() {
1620 for entry in &deploy.primitive {
1621 output::boot_wait(&entry.name, "waiting for registration");
1622 }
1623 }
1624 }
1625 let mut stage1_atlas = AtlasClient::connect_with_retry(
1626 &atlas_endpoint,
1627 20,
1628 Duration::from_millis(500),
1629 )
1630 .await
1631 .with_context(|| {
1632 format!("connect to atlas at '{atlas_endpoint}' for primitive readiness")
1633 })?;
1634 let soma_child = &mut children
1641 .last_mut()
1642 .expect("soma Spawned pushed above")
1643 .child;
1644 wait_for_soma_stage1(
1645 &mut stage1_atlas,
1646 &deploy
1647 .primitive
1648 .iter()
1649 .map(|entry| entry.name.clone())
1650 .collect::<Vec<_>>(),
1651 soma_child,
1652 &log_dir,
1653 )
1654 .await?;
1655
1656 let builtin_after_soma = bin_map
1676 .iter()
1677 .skip_while(|(n, _)| *n != "soma")
1678 .skip(1) .any(|(n, _)| deploy.system.contains_key(*n));
1680 let has_non_builtin_system = deploy
1681 .system
1682 .keys()
1683 .any(|k| !bin_map.iter().any(|(n, _)| n == k));
1684 if builtin_after_soma || has_non_builtin_system {
1685 output::boot_section("system");
1686 }
1687 }
1688 }
1689 } else {
1690 output::sub_step("Skipping system bring-up (--skip-system)");
1691 }
1692
1693 let mut atlas =
1695 AtlasClient::connect_with_retry(&atlas_endpoint, 20, Duration::from_millis(500))
1696 .await
1697 .with_context(|| {
1698 format!("connect to atlas at '{atlas_endpoint}' for lifecycle init")
1699 })?;
1700
1701 let mut failures: Vec<(String, String, String)> = Vec::new(); if !skip_system {
1721 for (key, value) in &deploy.system {
1722 if is_builtin_system(key) {
1723 continue;
1724 }
1725 let pkg_dir = match config.robonix_source_path.as_ref() {
1726 Some(root) => root.join("system").join(key),
1727 None => {
1728 output::boot_skip(
1729 key,
1730 "robonix_source_path unset (`rbnx setup` from repo root)",
1731 );
1732 continue;
1733 }
1734 };
1735 if !pkg_dir.exists() {
1736 output::boot_skip(key, "not on disk");
1737 continue;
1738 }
1739 let (manifest_override, runtime_config) =
1740 robonix_cli::manifest::split_system_package_config(value)
1741 .with_context(|| format!("parse system/{key} package selector"))?;
1742 let entry = PackageEntry {
1743 name: key.clone(),
1744 path: Some(pkg_dir.to_string_lossy().into_owned()),
1745 url: None,
1746 branch: None,
1747 config: runtime_config,
1748 manifest: manifest_override,
1749 };
1750 match spawn_and_init("system", &entry, &spawn_env, &mut atlas).await {
1751 Ok(sp) => {
1752 children.push(sp);
1753 persist_state(
1754 &state_path,
1755 &manifest_path,
1756 &atlas_endpoint,
1757 started_at_ms,
1758 &children,
1759 );
1760 }
1761 Err(e) => {
1762 failures.push(("system".to_string(), key.clone(), format!("{e:#}")));
1763 }
1764 }
1765 }
1766 }
1767
1768 if !deploy.service.is_empty() {
1779 output::boot_section("service");
1780 }
1781 for e in &deploy.service {
1782 match spawn_and_init("service", e, &spawn_env, &mut atlas).await {
1783 Ok(sp) => {
1784 children.push(sp);
1785 persist_state(
1786 &state_path,
1787 &manifest_path,
1788 &atlas_endpoint,
1789 started_at_ms,
1790 &children,
1791 );
1792 }
1793 Err(err) => {
1794 failures.push(("service".to_string(), e.name.clone(), format!("{err:#}")));
1795 }
1796 }
1797 }
1798
1799 if deploy.system.contains_key("soma") && !skip_system {
1815 output::boot_section("skill");
1816 if output::boot_verbose() {
1817 for entry in &deploy.skill {
1818 output::boot_wait(&entry.name, "waiting for registration");
1819 }
1820 }
1821 if let Err(e) = write_stage2_trigger(&mut soma_stage_writer) {
1822 failures.push((
1823 "system".to_string(),
1824 "soma".to_string(),
1825 format!("start skill packages: {e:#}"),
1826 ));
1827 } else if let Err(e) = wait_for_soma_skills(
1828 &mut atlas,
1829 &deploy
1830 .skill
1831 .iter()
1832 .map(|entry| entry.name.clone())
1833 .collect::<Vec<_>>(),
1834 )
1835 .await
1836 {
1837 failures.push(("skill".to_string(), "soma".to_string(), format!("{e:#}")));
1838 }
1839 }
1840 Ok(failures)
1841 };
1842
1843 let mut interrupted_during_boot = false;
1848 let outcome: Result<Vec<(String, String, String)>> = {
1849 tokio::pin!(bringup);
1850 tokio::select! {
1851 o = &mut bringup => o,
1852 _ = sigint.recv() => { interrupted_during_boot = true; Ok(Vec::new()) }
1853 _ = sigterm.recv() => { interrupted_during_boot = true; Ok(Vec::new()) }
1854 }
1855 };
1856
1857 if interrupted_during_boot {
1858 output::action(
1859 "Interrupted",
1860 &format!("tearing down {} partial child(ren)", children.len()),
1861 );
1862 scribe::info(
1863 "bootstrap",
1864 &format!(
1865 "boot interrupted by signal — tearing down {} partial children",
1866 children.len()
1867 ),
1868 );
1869 persist_state(
1870 &state_path,
1871 &manifest_path,
1872 &atlas_endpoint,
1873 started_at_ms,
1874 &children,
1875 );
1876 let providers = component_records(&children);
1877 let complete = teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1878 if !complete {
1879 anyhow::bail!(
1880 "interrupted boot left identity-mismatched process groups; preserving {}",
1881 state_path.display()
1882 );
1883 }
1884 for sp in &mut children {
1885 let _ = sp.child.wait().await;
1886 }
1887 let _ = std::fs::remove_file(&state_path);
1888 return Ok(());
1889 }
1890
1891 let failures = match outcome {
1892 Ok(failures) => failures,
1893 Err(e) => {
1894 output::action("Boot failed", &format!("{e:#}"));
1895 persist_state(
1899 &state_path,
1900 &manifest_path,
1901 &atlas_endpoint,
1902 started_at_ms,
1903 &children,
1904 );
1905 let providers = component_records(&children);
1906 let complete =
1907 teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1908 if complete {
1909 let _ = std::fs::remove_file(&state_path);
1910 } else {
1911 return Err(e.context(format!(
1912 "cleanup refused identity-mismatched process groups; preserving {}",
1913 state_path.display()
1914 )));
1915 }
1916 return Err(e);
1917 }
1918 };
1919
1920 if !failures.is_empty() {
1921 output::boot_section("failures");
1922 for (component, name, err) in &failures {
1923 let one_line = err.lines().next().unwrap_or(err.as_str());
1926 output::boot_fail(name, &format!("[{component}] {one_line}"));
1927 }
1928 eprintln!();
1929 eprintln!(
1930 " {} of {} packages failed to start; the rest are running. \
1931 `rbnx caps` to inspect, `rbnx shutdown` to tear down.",
1932 failures.len(),
1933 failures.len() + children.len(),
1934 );
1935 }
1936
1937 output::success(&format!(
1938 "{} component(s) up; logs under {}",
1939 children.len(),
1940 log_dir.display()
1941 ));
1942 if failures.is_empty() {
1943 scribe::info("bootstrap", "all components up — waiting for signal");
1944 } else {
1945 scribe::info(
1946 "bootstrap",
1947 &format!(
1948 "{} component(s) up, {} package(s) failed — waiting for signal",
1949 children.len(),
1950 failures.len()
1951 ),
1952 );
1953 }
1954 output::sub_step("Ctrl-C to tear down (or run `rbnx shutdown` from another shell).");
1955
1956 tokio::select! {
1959 _ = sigint.recv() => {}
1960 _ = sigterm.recv() => {}
1961 }
1962 output::action("Stopping", &format!("{} child(ren)", children.len()));
1963 scribe::info(
1964 "bootstrap",
1965 &format!(
1966 "shutdown signal received, tearing down {} children",
1967 children.len()
1968 ),
1969 );
1970 let providers = component_records(&children);
1971 let complete = teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1972 if !complete {
1973 anyhow::bail!(
1974 "shutdown refused identity-mismatched process groups; preserving {}",
1975 state_path.display()
1976 );
1977 }
1978 for sp in &mut children {
1980 let _ = sp.child.wait().await;
1981 }
1982 let _ = std::fs::remove_file(&state_path);
1983 Ok(())
1984}
1985
1986fn component_records(children: &[Spawned]) -> Vec<teardown::ComponentRecord> {
1987 children
1988 .iter()
1989 .map(|s| PackageRuntimeRecord {
1990 name: s.name.clone(),
1991 kind: s.kind.clone(),
1992 pid: s.pid,
1993 pgid: s.pgid,
1994 provider_id: s.provider_id.clone(),
1995 driver_contract: s.driver_contract.clone(),
1996 config_json: s.config_json.clone(),
1997 package_dir: s.package_dir.as_ref().map(|p| p.display().to_string()),
1998 stop: s.stop.clone(),
1999 })
2000 .collect()
2001}
2002
2003fn persist_state(
2004 state_path: &Path,
2005 manifest_path: &Path,
2006 atlas_endpoint: &str,
2007 started_at_ms: u64,
2008 children: &[Spawned],
2009) {
2010 let state = teardown::BootState {
2011 manifest_path: manifest_path.display().to_string(),
2012 boot_pid: std::process::id(),
2013 boot_start_time_ticks: robonix_cli::launch::proc_start_time_ticks(std::process::id()),
2014 boot_id: std::env::var("RBNX_BOOT_ID").unwrap_or_default(),
2015 started_at_ms,
2016 atlas_endpoint: atlas_endpoint.to_string(),
2017 components: component_records(children),
2018 };
2019 if let Err(e) = teardown::write_state(state_path, &state) {
2020 output::sub_step(&format!(
2021 "[boot] warning: failed to persist boot state to {}: {e:#}",
2022 state_path.display()
2023 ));
2024 }
2025}
2026
2027fn require_system_args(name: &str, args: &[String]) -> std::result::Result<(), String> {
2039 if name != "pilot" {
2040 return Ok(());
2041 }
2042 let need = [
2043 ("--vlm-upstream", "vlm.upstream / VLM_BASE_URL"),
2044 ("--vlm-api-key", "vlm.api_key / VLM_API_KEY"),
2045 ("--vlm-model", "vlm.model / VLM_MODEL"),
2046 ];
2047 let mut missing: Vec<&str> = Vec::new();
2048 for (flag, label) in need {
2049 let val = args
2050 .iter()
2051 .position(|a| a == flag)
2052 .and_then(|i| args.get(i + 1));
2053 match val {
2054 Some(v) if !v.is_empty() => {}
2055 _ => missing.push(label),
2056 }
2057 }
2058 if missing.is_empty() {
2059 Ok(())
2060 } else {
2061 Err(format!(
2062 "missing required pilot config: {}. Set in manifest under \
2063 system: pilot: vlm: {{...}} or via env (source your .zshrc / \
2064 inline-prepend VLM_BASE_URL=… VLM_API_KEY=… VLM_MODEL=…)",
2065 missing.join(", "),
2066 ))
2067 }
2068}
2069
2070fn system_boot_detail(name: &str, args: &[String]) -> String {
2071 let mut listen: Option<&str> = None;
2072 let mut vlm_upstream: Option<&str> = None;
2073 let mut vlm_model: Option<&str> = None;
2074 let mut i = 0;
2075 while i < args.len() {
2076 let a = args[i].as_str();
2077 let next = args.get(i + 1).map(|s| s.as_str());
2078 match (a, next) {
2079 ("--listen", Some(v)) => {
2080 listen = Some(v);
2081 i += 2;
2082 }
2083 ("--vlm-upstream", Some(v)) => {
2084 vlm_upstream = Some(v);
2085 i += 2;
2086 }
2087 ("--vlm-model", Some(v)) => {
2088 vlm_model = Some(v);
2089 i += 2;
2090 }
2091 _ => {
2092 i += 1;
2093 }
2094 }
2095 }
2096 let port = listen
2097 .and_then(|s| s.rsplit(':').next())
2098 .map(|p| format!(":{p}"))
2099 .unwrap_or_default();
2100 if name == "pilot" {
2101 let host = vlm_upstream
2102 .and_then(|u| {
2103 u.trim_start_matches("https://")
2104 .trim_start_matches("http://")
2105 .split('/')
2106 .next()
2107 })
2108 .unwrap_or("?");
2109 let model = vlm_model.unwrap_or("?");
2110 format!("{port} vlm={model}@{host}")
2111 } else {
2112 port
2113 }
2114}
2115
2116fn system_listen(name: &str, cfg: Option<&serde_yaml::Value>) -> Option<String> {
2129 let map = cfg?.as_mapping()?;
2130 let s = map
2131 .get(serde_yaml::Value::String("listen".into()))?
2132 .as_str()?;
2133 let trimmed = s.trim();
2134 if trimmed.is_empty() || !is_builtin_system(name) {
2135 return None;
2136 }
2137 Some(trimmed.to_string())
2138}
2139
2140fn port_is_free(listen: &str) -> std::result::Result<(), anyhow::Error> {
2147 use std::net::{TcpStream, ToSocketAddrs};
2148 let addrs: Vec<_> = listen
2149 .to_socket_addrs()
2150 .with_context(|| format!("parse listen='{listen}' as socket addr"))?
2151 .collect();
2152 for addr in &addrs {
2153 if TcpStream::connect_timeout(addr, std::time::Duration::from_millis(200)).is_ok() {
2156 return Err(anyhow::anyhow!("something is already listening on {addr}"));
2157 }
2158 }
2159 Ok(())
2160}
2161
2162fn system_cli_args(
2164 name: &str,
2165 cfg: Option<&serde_yaml::Value>,
2166 atlas_listen: Option<&str>,
2167) -> Vec<String> {
2168 let mut out = Vec::new();
2169 let map = cfg.and_then(|v| v.as_mapping());
2170
2171 if let Some(v) = cfg
2177 && let Ok(json) = serde_json::to_string(v)
2178 {
2179 out.push("--config-json".into());
2180 out.push(json);
2181 }
2182
2183 let s = |k: &str| -> Option<String> {
2184 map.and_then(|m| {
2185 m.get(serde_yaml::Value::String(k.into()))
2186 .and_then(|v| v.as_str())
2187 .map(|s| s.to_string())
2188 })
2189 };
2190 let nested_str = |outer: &str, inner: &str| -> Option<String> {
2191 map.and_then(|m| m.get(serde_yaml::Value::String(outer.into())))
2192 .and_then(|v| v.as_mapping())
2193 .and_then(|m| m.get(serde_yaml::Value::String(inner.into())))
2194 .and_then(|v| v.as_str())
2195 .map(|s| s.to_string())
2196 };
2197 let push_pair = |out: &mut Vec<String>, flag: &str, val: Option<String>| {
2198 if let Some(v) = val {
2199 out.push(flag.into());
2200 out.push(v);
2201 }
2202 };
2203 match name {
2204 "atlas" => {
2205 push_pair(&mut out, "--listen", s("listen"));
2206 push_pair(&mut out, "--log", s("log"));
2207 push_pair(&mut out, "--capabilities", s("capabilities"));
2215 }
2216 "executor" => {
2217 push_pair(&mut out, "--listen", s("listen"));
2218 push_pair(
2219 &mut out,
2220 "--atlas",
2221 s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2222 );
2223 push_pair(&mut out, "--log", s("log"));
2224 }
2225 "pilot" => {
2226 push_pair(&mut out, "--listen", s("listen"));
2227 push_pair(
2228 &mut out,
2229 "--atlas",
2230 s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2231 );
2232 push_pair(&mut out, "--log", s("log"));
2233 push_pair(&mut out, "--vlm-upstream", nested_str("vlm", "upstream"));
2235 push_pair(&mut out, "--vlm-api-key", nested_str("vlm", "api_key"));
2236 push_pair(&mut out, "--vlm-model", nested_str("vlm", "model"));
2237 push_pair(&mut out, "--vlm-format", nested_str("vlm", "api_format"));
2238 }
2239 "liaison" => {
2240 push_pair(&mut out, "--listen", s("listen"));
2241 push_pair(
2242 &mut out,
2243 "--atlas",
2244 s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2245 );
2246 push_pair(&mut out, "--pilot-endpoint", s("pilot_endpoint"));
2247 push_pair(&mut out, "--log", s("log"));
2248 }
2249 "soma" => {
2250 push_pair(&mut out, "--listen", s("listen"));
2259 push_pair(
2260 &mut out,
2261 "--atlas",
2262 s("atlas_endpoint")
2263 .or_else(|| s("atlas"))
2264 .or_else(|| atlas_listen.map(str::to_string)),
2265 );
2266 push_pair(&mut out, "--provider-id", s("provider_id"));
2267 push_pair(&mut out, "--robot-yaml", s("robot_yaml"));
2268 push_pair(&mut out, "--deployment-manifest", s("deployment_manifest"));
2269 push_pair(&mut out, "--config", s("config"));
2270 push_pair(&mut out, "--log", s("log"));
2271 }
2272 "vitals" => {
2273 push_pair(&mut out, "--listen", s("listen"));
2274 push_pair(
2275 &mut out,
2276 "--atlas",
2277 s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2278 );
2279 push_pair(&mut out, "--id", s("provider_id").or_else(|| s("id")));
2280 push_pair(&mut out, "--thresholds-path", s("thresholds_path"));
2281 push_pair(&mut out, "--soma-endpoint", s("soma_endpoint"));
2282 push_pair(&mut out, "--config", s("config"));
2283 push_pair(&mut out, "--log", s("log"));
2284 }
2285 _ => {}
2286 }
2287 out
2288}
2289
2290async fn spawn_and_init(
2297 component: &str,
2298 entry: &PackageEntry,
2299 spawn_env: &PackageSpawnEnv<'_>,
2300 atlas: &mut AtlasClient,
2301) -> Result<Spawned> {
2302 let before = snapshot_provider_ids(atlas)
2303 .await
2304 .with_context(|| format!("[{component}] pre-spawn atlas snapshot"))?;
2305
2306 let mut sp = spawn_package(component, entry, spawn_env).await?;
2307 let pkg_label = sp.name.clone();
2308
2309 let pgid = sp.pgid;
2322
2323 let registration = match wait_for_registration(
2324 atlas,
2325 &before,
2326 &entry.name,
2327 &pkg_label,
2328 component,
2329 spawn_env.log_dir,
2330 &mut sp.child,
2331 )
2332 .await
2333 {
2334 Ok(v) => v,
2335 Err(e) => {
2336 terminate_process_group(pgid, Duration::from_secs(8)).await;
2337 return Err(e);
2338 }
2339 };
2340 let provider_id = registration.provider_id.clone();
2341 if provider_id != entry.name {
2345 let log_file = log_path(spawn_env.log_dir, &pkg_label);
2346 output::boot_fail(
2347 short_label(&pkg_label, component),
2348 &format!(
2349 "deployment instance propagation failed: expected manifest name='{}', \
2350 observed Capability(id='{}'). Log: {}",
2351 entry.name,
2352 provider_id,
2353 log_file.display()
2354 ),
2355 );
2356 terminate_process_group(pgid, Duration::from_secs(8)).await;
2357 anyhow::bail!(
2358 "[{component}/{pkg_label}] deployment identity invariant failed: manifest name='{}' vs Capability(id='{}')",
2359 entry.name,
2360 provider_id,
2361 );
2362 }
2363
2364 sp.provider_id = Some(provider_id.clone());
2365 let expected_driver_contract = sp
2366 .expected_driver_contract
2367 .as_deref()
2368 .expect("package spawns always carry a lifecycle selection");
2369 let driver_contract = match resolve_runtime_driver_contract(
2370 &provider_id,
2371 ®istration.provider_namespace,
2372 expected_driver_contract,
2373 ®istration.driver_contracts,
2374 sp.allow_shared_driver_upgrade,
2375 ) {
2376 Ok(contract) => contract,
2377 Err(error) => {
2378 let log_file = log_path(spawn_env.log_dir, &pkg_label);
2379 output::boot_fail(
2380 short_label(&pkg_label, component),
2381 &format!("{error}; log {}", log_file.display()),
2382 );
2383 terminate_process_group(pgid, Duration::from_secs(8)).await;
2384 return Err(error).with_context(|| format!("[{component}/{pkg_label}] lifecycle"));
2385 }
2386 };
2387
2388 if driver_contract != expected_driver_contract {
2389 output::warning(&format!(
2390 "provider '{provider_id}' publishes shared lifecycle Driver '{driver_contract}' for legacy manifest selection '{expected_driver_contract}'; remove the legacy Driver declaration to finish migration"
2391 ));
2392 }
2393
2394 let config_json = serde_json::to_string(&entry.config).with_context(|| {
2395 format!(
2396 "[{component}/{pkg_label}] serialize config for deployment instance '{}'",
2397 entry.name
2398 )
2399 })?;
2400 sp.driver_contract = Some(driver_contract.clone());
2401 sp.config_json = Some(config_json.clone());
2402
2403 let display_label = short_label(&pkg_label, component);
2404 let init_state = match with_spinner(
2405 display_label,
2406 "driver(INIT)…",
2407 call_driver_cmd(
2408 atlas,
2409 &provider_id,
2410 &driver_contract,
2411 component,
2412 &pkg_label,
2413 CMD_INIT,
2414 config_json.clone(),
2415 ),
2416 )
2417 .await
2418 {
2419 Ok(v) => v,
2420 Err(e) => {
2421 terminate_process_group(pgid, Duration::from_secs(8)).await;
2422 return Err(e);
2423 }
2424 };
2425
2426 if component == "skill" {
2427 output::boot_ok(
2430 display_label,
2431 &format!(
2432 "{} (skill — awaits executor activate)",
2433 init_state.to_uppercase()
2434 ),
2435 );
2436 return Ok(sp);
2437 }
2438
2439 let activate_state = match with_spinner(
2440 display_label,
2441 "driver(ACTIVATE)…",
2442 call_driver_cmd(
2443 atlas,
2444 &provider_id,
2445 &driver_contract,
2446 component,
2447 &pkg_label,
2448 CMD_ACTIVATE,
2449 config_json,
2450 ),
2451 )
2452 .await
2453 {
2454 Ok(v) => v,
2455 Err(e) => {
2456 terminate_process_group(pgid, Duration::from_secs(8)).await;
2457 return Err(e);
2458 }
2459 };
2460 let _ = init_state; output::boot_ok(display_label, &activate_state.to_uppercase());
2466
2467 Ok(sp)
2468}
2469
2470async fn with_spinner<F, T>(label: &str, msg_prefix: &str, fut: F) -> T
2477where
2478 F: std::future::Future<Output = T>,
2479{
2480 if output::boot_verbose() {
2481 output::boot_wait(label, msg_prefix);
2482 return fut.await;
2483 }
2484 use std::time::Instant;
2485 let started = Instant::now();
2486 let mut tick = tokio::time::interval(Duration::from_millis(100));
2487 tick.tick().await; tokio::pin!(fut);
2490 let mut frame: usize = 0;
2491 loop {
2492 tokio::select! {
2493 res = &mut fut => return res,
2494 _ = tick.tick() => {
2495 let elapsed = started.elapsed().as_secs_f32();
2496 output::boot_progress(
2497 label,
2498 &format!("{msg_prefix} {elapsed:>4.1}s"),
2499 frame,
2500 );
2501 frame = frame.wrapping_add(1);
2502 }
2503 }
2504 }
2505}
2506
2507async fn wait_for_soma_stage1(
2508 atlas: &mut AtlasClient,
2509 primitive_names: &[String],
2510 soma_child: &mut Child,
2511 log_dir: &Path,
2512) -> Result<()> {
2513 const SPINNER_TICK: Duration = Duration::from_millis(100);
2514 const POLLS_PER_TICK: usize = 5; const SOMA_STAGE1_TIMEOUT: Duration = Duration::from_secs(180);
2516 const SOMA_GET_YAML_CONTRACT: &str = "robonix/system/soma/get_yaml";
2517
2518 let started = Instant::now();
2519 let deadline = started + SOMA_STAGE1_TIMEOUT;
2520 let mut frame: usize = 0;
2521 let mut observed_states: HashMap<String, i32> = HashMap::new();
2522 let mut active_primitives: HashSet<String> = HashSet::new();
2523 let mut reported_failures: HashSet<String> = HashSet::new();
2524 if output::boot_verbose() {
2525 output::boot_wait("primitive", "waiting for Soma-managed providers");
2526 }
2527 loop {
2528 let elapsed_s = started.elapsed().as_secs_f32();
2529 let detail = if primitive_names.is_empty() {
2530 format!("waiting for Soma gRPC readiness… {elapsed_s:>4.1}s")
2531 } else {
2532 format!(
2533 "starting {} primitive package(s)… {elapsed_s:>4.1}s",
2534 primitive_names.len()
2535 )
2536 };
2537 if output::boot_verbose() {
2538 if frame > 0 && frame.is_multiple_of(50) {
2539 output::boot_note("primitive", &detail);
2540 }
2541 } else {
2542 output::boot_progress("primitive", &detail, frame);
2543 }
2544 if let Ok(Some(status)) = soma_child.try_wait() {
2552 let mut provider_failures = Vec::new();
2553 for name in primitive_names {
2554 let log_file = log_dir.join(format!("{name}.log"));
2555 if let Some(cause) = read_provider_failure(&log_file) {
2556 if reported_failures.insert(name.clone()) {
2557 output::boot_fail(
2558 name,
2559 &format!("ERROR; {cause}; log {}", log_file.display()),
2560 );
2561 }
2562 provider_failures.push((name, cause, log_file));
2563 }
2564 }
2565 if !provider_failures.is_empty() {
2566 let names = provider_failures
2567 .iter()
2568 .map(|(name, _, _)| name.as_str())
2569 .collect::<Vec<_>>()
2570 .join(", ");
2571 let logs = provider_failures
2572 .iter()
2573 .map(|(_, _, path)| path.display().to_string())
2574 .collect::<Vec<_>>()
2575 .join(", ");
2576 output::boot_fail(
2577 "primitive",
2578 &format!("soma exited after provider failure(s): {names}"),
2579 );
2580 anyhow::bail!(
2581 "Soma exited with {status:?} after provider failure(s): {names}; logs: {logs}"
2582 );
2583 }
2584
2585 let log_file = log_dir.join("soma.log");
2586 let tail = read_log_tail(&log_file, 20);
2587 output::boot_fail(
2588 "primitive",
2589 &format!(
2590 "soma exited before becoming ACTIVE (status={status:?}); see {}",
2591 log_file.display()
2592 ),
2593 );
2594 let hint = if tail.is_empty() {
2595 String::new()
2596 } else {
2597 format!("\n--- soma.log tail ---\n{tail}\n--- end ---")
2598 };
2599 anyhow::bail!(
2600 "soma exited with {status:?} before primitive readiness; \
2601 log: {}{hint}",
2602 log_file.display()
2603 );
2604 }
2605 if frame.is_multiple_of(POLLS_PER_TICK) {
2606 for name in primitive_names {
2607 let providers = atlas
2608 .query_capabilities(name, "", atlas_pb::Transport::Unspecified)
2609 .await
2610 .with_context(|| format!("poll primitive '{name}' during Soma bring-up"))?;
2611 let Some(provider) = providers.into_iter().find(|provider| provider.id == *name)
2612 else {
2613 continue;
2614 };
2615 let previous = observed_states.insert(name.clone(), provider.state);
2616 if previous != Some(provider.state) {
2617 let state = lifecycle_state_label(provider.state);
2618 if provider.state == atlas_pb::LifecycleState::StateActive as i32 {
2619 output::boot_ok(name, "ACTIVE");
2620 active_primitives.insert(name.clone());
2621 } else if provider.state == atlas_pb::LifecycleState::StateError as i32 {
2622 let log_file = log_dir.join(format!("{name}.log"));
2623 let detail = read_provider_failure(&log_file).map_or_else(
2624 || format!("ERROR; log {}", log_file.display()),
2625 |cause| format!("ERROR; {cause}; log {}", log_file.display()),
2626 );
2627 output::boot_fail(name, &detail);
2628 reported_failures.insert(name.clone());
2629 } else if output::boot_verbose() {
2630 output::boot_note(name, state);
2631 }
2632 }
2633 }
2634 let providers = atlas
2635 .query_capabilities("soma", SOMA_GET_YAML_CONTRACT, atlas_pb::Transport::Grpc)
2636 .await
2637 .context("wait for Soma primitive readiness")?;
2638 if let Some(soma) = providers.into_iter().find(|p| p.id == "soma")
2639 && soma.state == atlas_pb::LifecycleState::StateActive as i32
2640 && soma_grpc_ready(atlas, SOMA_GET_YAML_CONTRACT).await
2641 {
2642 for name in primitive_names {
2643 if !active_primitives.contains(name) {
2644 output::boot_ok(name, "ACTIVE");
2645 }
2646 }
2647 return Ok(());
2648 }
2649 }
2650 if Instant::now() >= deadline {
2651 output::boot_fail(
2652 "primitive",
2653 &format!(
2654 "timeout after {:?}; service bring-up needs primitives ACTIVE first",
2655 SOMA_STAGE1_TIMEOUT
2656 ),
2657 );
2658 anyhow::bail!(
2659 "Soma primitive bring-up did not become ready within {:?}; refusing to start service packages before primitives are ready",
2660 SOMA_STAGE1_TIMEOUT
2661 );
2662 }
2663 tokio::time::sleep(SPINNER_TICK).await;
2664 frame = frame.wrapping_add(1);
2665 }
2666}
2667
2668fn lifecycle_state_label(state: i32) -> &'static str {
2669 if state == atlas_pb::LifecycleState::StateRegistered as i32 {
2670 "REGISTERED"
2671 } else if state == atlas_pb::LifecycleState::StateInactive as i32 {
2672 "INACTIVE"
2673 } else if state == atlas_pb::LifecycleState::StateActive as i32 {
2674 "ACTIVE"
2675 } else if state == atlas_pb::LifecycleState::StateError as i32 {
2676 "ERROR"
2677 } else if state == atlas_pb::LifecycleState::StateTerminated as i32 {
2678 "TERMINATED"
2679 } else {
2680 "STARTING"
2681 }
2682}
2683
2684async fn wait_for_soma_skills(atlas: &mut AtlasClient, skill_names: &[String]) -> Result<()> {
2685 const TIMEOUT: Duration = Duration::from_secs(180);
2686 if skill_names.is_empty() {
2687 return Ok(());
2688 }
2689 let deadline = Instant::now() + TIMEOUT;
2690 let mut observed_states: HashMap<String, i32> = HashMap::new();
2691 let mut ready: HashSet<String> = HashSet::new();
2692 while Instant::now() < deadline {
2693 for name in skill_names {
2694 let providers = atlas
2695 .query_capabilities(name, "", atlas_pb::Transport::Unspecified)
2696 .await
2697 .with_context(|| format!("poll skill '{name}' during soma bring-up"))?;
2698 let Some(provider) = providers.into_iter().find(|provider| provider.id == *name) else {
2699 continue;
2700 };
2701 if observed_states.insert(name.clone(), provider.state) != Some(provider.state) {
2702 let state = lifecycle_state_label(provider.state);
2703 if provider.state == atlas_pb::LifecycleState::StateInactive as i32
2704 || provider.state == atlas_pb::LifecycleState::StateActive as i32
2705 {
2706 output::boot_ok(name, state);
2707 ready.insert(name.clone());
2708 } else if provider.state == atlas_pb::LifecycleState::StateError as i32 {
2709 output::boot_fail(name, "ERROR; see soma.log and provider log");
2710 anyhow::bail!("skill '{name}' entered ERROR during Soma bring-up");
2711 } else if output::boot_verbose() {
2712 output::boot_note(name, state);
2713 }
2714 }
2715 }
2716 if ready.len() == skill_names.len() {
2717 return Ok(());
2718 }
2719 tokio::time::sleep(Duration::from_millis(200)).await;
2720 }
2721 let pending = skill_names
2722 .iter()
2723 .filter(|name| !ready.contains(*name))
2724 .cloned()
2725 .collect::<Vec<_>>();
2726 for name in &pending {
2727 output::boot_fail(
2728 name,
2729 "registration/INIT timeout; see soma.log and provider log",
2730 );
2731 }
2732 anyhow::bail!(
2733 "Soma skill bring-up timed out after {TIMEOUT:?}: {}",
2734 pending.join(", ")
2735 )
2736}
2737
2738fn read_log_tail(path: &Path, max_lines: usize) -> String {
2746 let Ok(contents) = std::fs::read_to_string(path) else {
2747 return String::new();
2748 };
2749 let lines: Vec<&str> = contents.lines().collect();
2750 let start = lines.len().saturating_sub(max_lines);
2751 lines[start..].join("\n")
2752}
2753
2754fn read_provider_failure(path: &Path) -> Option<String> {
2759 let contents = std::fs::read_to_string(path).ok()?;
2760 let mut error_level_fallback = None;
2761 for line in contents.lines().rev() {
2762 let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
2763 continue;
2764 };
2765 let Some(message) = record.get("msg").and_then(|value| value.as_str()) else {
2766 continue;
2767 };
2768 if let Some((_, cause)) = message.split_once(" -> ERROR (") {
2769 return Some(cause.strip_suffix(')').unwrap_or(cause).to_string());
2770 }
2771 if error_level_fallback.is_none()
2772 && record.get("level").and_then(|value| value.as_str()) == Some("error")
2773 {
2774 error_level_fallback = Some(message.to_string());
2775 }
2776 }
2777 error_level_fallback
2778}
2779
2780fn read_provider_exit_summary(path: &Path) -> Option<String> {
2785 if let Some(cause) = read_provider_failure(path) {
2786 return Some(cause);
2787 }
2788 let contents = std::fs::read_to_string(path).ok()?;
2789 for line in contents.lines().rev() {
2790 if let Ok(record) = serde_json::from_str::<serde_json::Value>(line)
2791 && let Some(message) = record.get("msg").and_then(|value| value.as_str())
2792 && !message.trim().is_empty()
2793 {
2794 return Some(message.trim().to_string());
2795 }
2796 if !line.trim().is_empty() {
2797 return Some(line.trim().to_string());
2798 }
2799 }
2800 None
2801}
2802
2803async fn soma_grpc_ready(atlas: &mut AtlasClient, contract_id: &str) -> bool {
2804 let Ok((channel_id, endpoint, _params)) = atlas
2805 .connect_capability(
2806 DEPLOY_CONSUMER_ID,
2807 "soma",
2808 contract_id,
2809 atlas_pb::Transport::Grpc,
2810 )
2811 .await
2812 else {
2813 return false;
2814 };
2815 let normalized = if endpoint.starts_with("http") {
2816 endpoint
2817 } else {
2818 format!("http://{endpoint}")
2819 };
2820 let ready = match Endpoint::new(normalized.clone()) {
2821 Ok(endpoint) => tokio::time::timeout(Duration::from_secs(1), endpoint.connect())
2822 .await
2823 .is_ok_and(|r| r.is_ok()),
2824 Err(_) => false,
2825 };
2826 let _ = atlas.disconnect_capability(&channel_id).await;
2827 ready
2828}
2829
2830fn write_stage2_trigger(writer: &mut Option<std::fs::File>) -> Result<()> {
2841 use std::io::Write;
2842 let Some(mut w) = writer.take() else {
2843 output::boot_skip(
2844 "skill",
2845 "start skipped: no trigger writer (Soma was not spawned by this rbnx)",
2846 );
2847 return Ok(());
2848 };
2849 w.write_all(b"stage2\n")
2850 .context("write 'stage2' to soma stage-trigger pipe")?;
2851 w.flush().context("flush soma stage-trigger pipe")?;
2852 Ok(())
2857}
2858
2859async fn call_driver_cmd(
2865 atlas: &mut AtlasClient,
2866 provider_id: &str,
2867 driver_contract: &str,
2868 component: &str,
2869 pkg_label: &str,
2870 cmd: u32,
2871 config_json: String,
2872) -> Result<String> {
2873 let cmd_name = match cmd {
2874 CMD_INIT => "INIT",
2875 CMD_ACTIVATE => "ACTIVATE",
2876 CMD_DEACTIVATE => "DEACTIVATE",
2877 CMD_SHUTDOWN => "SHUTDOWN",
2878 _ => "?",
2879 };
2880 let (channel_id, endpoint, _params) = atlas
2881 .connect_capability(
2882 DEPLOY_CONSUMER_ID,
2883 provider_id,
2884 driver_contract,
2885 atlas_pb::Transport::Grpc,
2886 )
2887 .await
2888 .with_context(|| {
2889 format!("[{component}/{pkg_label}] ConnectCapability for {driver_contract}")
2890 })?;
2891 let normalized = if endpoint.starts_with("http") {
2892 endpoint
2893 } else {
2894 format!("http://{endpoint}")
2895 };
2896 let result = async {
2897 let driver_timeout = driver_init_timeout();
2898 let channel = Endpoint::new(normalized.clone())
2899 .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
2900 .connect()
2901 .await
2902 .with_context(|| format!("dial driver at '{normalized}'"))?;
2903 let svc_name = contract_id_to_service_name(driver_contract);
2904 let path: tonic::codegen::http::uri::PathAndQuery =
2905 format!("/robonix.contracts.{svc_name}/Driver")
2906 .parse()
2907 .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
2908 let mut grpc = tonic::client::Grpc::new(channel);
2909 grpc.ready().await.with_context(|| "gRPC ready")?;
2910 let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
2911 let resp = tokio::time::timeout(
2912 driver_timeout,
2913 grpc.unary(
2914 Request::new(DriverRequest {
2915 command: cmd,
2916 config_json,
2917 }),
2918 path,
2919 codec,
2920 ),
2921 )
2922 .await
2923 .map_err(|_| {
2924 anyhow::anyhow!(
2925 "Driver(CMD_{cmd_name}) timed out after {}s",
2926 driver_timeout.as_secs()
2927 )
2928 })?
2929 .with_context(|| format!("Driver(CMD_{cmd_name}) RPC failed"))?;
2930 Ok::<_, anyhow::Error>(resp.into_inner())
2931 }
2932 .await;
2933 let _ = atlas.disconnect_capability(&channel_id).await;
2934 let r = result
2935 .map_err(|e| anyhow::anyhow!("[{component}/{pkg_label}] Driver(CMD_{cmd_name}): {e:#}"))?;
2936 if !r.ok {
2937 anyhow::bail!(
2938 "[{component}/{pkg_label}] Driver(CMD_{cmd_name}) returned ok=false (state={}, error={})",
2939 r.state,
2940 r.error
2941 );
2942 }
2943 Ok(r.state)
2944}
2945
2946fn short_label<'a>(pkg_label: &'a str, component: &str) -> &'a str {
2951 pkg_label
2952 .strip_prefix(&format!("{component}_"))
2953 .unwrap_or(pkg_label)
2954}
2955
2956async fn wait_for_registration(
2961 atlas: &mut AtlasClient,
2962 before: &ProviderRegistrationSnapshot,
2963 expected_provider_id: &str,
2964 pkg_label: &str,
2965 component: &str,
2966 log_dir: &Path,
2967 child: &mut Child,
2968) -> Result<RegistrationOutcome> {
2969 if before.contains_key(expected_provider_id) {
2970 anyhow::bail!(
2971 "[{component}/{pkg_label}] deployment instance '{expected_provider_id}' \
2972 was already registered before spawn"
2973 );
2974 }
2975
2976 const SPINNER_TICK: Duration = Duration::from_millis(100);
2980 const POLLS_PER_TICK: u32 = 2; let started = Instant::now();
2982 let deadline = started + DRIVER_REGISTER_TIMEOUT;
2983 let mut frame: usize = 0;
2984 let display_label = short_label(pkg_label, component);
2985 if output::boot_verbose() {
2986 output::boot_wait(display_label, "registering with atlas");
2987 }
2988 loop {
2989 let elapsed_s = started.elapsed().as_secs_f32();
2990 let detail = format!("registering with atlas… {elapsed_s:>4.1}s");
2991 if output::boot_verbose() {
2992 if frame > 0 && frame.is_multiple_of(50) {
2993 output::boot_note(display_label, &detail);
2994 }
2995 } else {
2996 output::boot_progress(display_label, &detail, frame);
2997 }
2998 match child.try_wait() {
3004 Ok(Some(status)) => {
3005 let log_file = log_path(log_dir, pkg_label);
3006 let cause = read_provider_exit_summary(&log_file)
3007 .unwrap_or_else(|| "no diagnostic message in provider log".to_string());
3008 output::boot_fail(
3009 display_label,
3010 &format!(
3011 "start process exited ({status}); {cause}; log {}",
3012 log_file.display()
3013 ),
3014 );
3015 anyhow::bail!(
3016 "[{component}/{pkg_label}] start process exited ({status}) before Atlas registration: {cause}. Log: {}",
3017 log_file.display()
3018 );
3019 }
3020 Ok(None) => {}
3021 Err(error) => {
3022 anyhow::bail!(
3023 "[{component}/{pkg_label}] inspect start process while waiting for Atlas registration: {error}"
3024 );
3025 }
3026 }
3027 if frame.is_multiple_of(POLLS_PER_TICK as usize) {
3028 let providers = atlas
3029 .query_capabilities("", "", atlas_pb::Transport::Unspecified)
3030 .await
3031 .with_context(|| format!("[{component}/{pkg_label}] poll atlas"))?;
3032 let matched = providers.iter().find(|provider| {
3033 robonix_cli::launch::is_expected_provider_registration(
3034 provider,
3035 before,
3036 expected_provider_id,
3037 )
3038 });
3039 if let Some(first) = matched {
3040 let provider_id = first.id.clone();
3041 let registration_id = first.registration_id.clone();
3042 let settle_until = Instant::now()
3049 .checked_add(Duration::from_millis(1000))
3050 .map(|t| t.min(deadline))
3051 .unwrap_or(deadline);
3052 let mut current: atlas_pb::CapabilityProvider = (*first).clone();
3053 loop {
3057 if Instant::now() >= settle_until {
3058 break;
3059 }
3060 tokio::time::sleep(Duration::from_millis(100)).await;
3061 let providers = atlas
3062 .query_capabilities(&provider_id, "", atlas_pb::Transport::Unspecified)
3063 .await
3064 .with_context(|| format!("[{component}/{pkg_label}] re-poll for driver"))?;
3065 match providers.into_iter().find(|p| p.id == provider_id) {
3066 Some(p) if p.registration_id == registration_id => current = p,
3067 Some(p) => {
3068 let log_file = log_path(log_dir, pkg_label);
3069 output::boot_fail(
3070 display_label,
3071 &format!(
3072 "provider '{provider_id}' registration changed during settle — see {}",
3073 log_file.display(),
3074 ),
3075 );
3076 anyhow::bail!(
3077 "[{component}/{pkg_label}] provider '{provider_id}' registration changed during settle ('{registration_id}' -> '{}'). Log: {}",
3078 p.registration_id,
3079 log_file.display(),
3080 );
3081 }
3082 None => {
3083 let log_file = log_path(log_dir, pkg_label);
3088 output::boot_fail(
3089 display_label,
3090 &format!(
3091 "provider '{provider_id}' disappeared during settle — see {}",
3092 log_file.display()
3093 ),
3094 );
3095 anyhow::bail!(
3096 "[{component}/{pkg_label}] provider '{provider_id}' \
3097 unregistered during settle window. Log: {}",
3098 log_file.display()
3099 );
3100 }
3101 }
3102 }
3103 let mut driver_contracts = current
3104 .capabilities
3105 .iter()
3106 .filter(|capability| {
3107 capability.transport == atlas_pb::Transport::Grpc as i32
3108 && capability.contract_id.ends_with("/driver")
3109 })
3110 .map(|capability| capability.contract_id.clone())
3111 .collect::<Vec<_>>();
3112 driver_contracts.sort();
3113 driver_contracts.dedup();
3114 return Ok(RegistrationOutcome {
3115 provider_id,
3116 provider_kind: current.kind,
3117 provider_namespace: current.namespace,
3118 registration_id: current.registration_id,
3119 driver_contracts,
3120 });
3121 }
3122 }
3123 if Instant::now() >= deadline {
3124 let log_file = log_path(log_dir, pkg_label);
3125 output::boot_fail(
3126 display_label,
3127 &format!(
3128 "registration timeout after {:?}; expected instance '{}' — see {}",
3129 DRIVER_REGISTER_TIMEOUT,
3130 expected_provider_id,
3131 log_file.display()
3132 ),
3133 );
3134 anyhow::bail!(
3135 "[{component}/{pkg_label}] timed out after {:?} — package never registered expected deployment instance '{}' with atlas. Log: {}",
3136 DRIVER_REGISTER_TIMEOUT,
3137 expected_provider_id,
3138 log_file.display()
3139 );
3140 }
3141 tokio::time::sleep(SPINNER_TICK).await;
3142 frame = frame.wrapping_add(1);
3143 }
3144}