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 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
59const 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;
66const DRIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(60);
69const 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#[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 #[serde(default)]
104 name: String,
105 #[serde(default)]
108 path: Option<String>,
109 #[serde(default)]
115 url: Option<String>,
116 #[serde(default)]
119 branch: Option<String>,
120 #[serde(default)]
124 config: serde_yaml::Value,
125 #[serde(default)]
133 manifest: Option<String>,
134}
135
136pub(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 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
341fn 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 let mut needs_clone: BTreeMap<String, (String, Option<String>, Option<String>)> =
362 BTreeMap::new();
363 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, };
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 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; }
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 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
460pub(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
473fn 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 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 map.insert(
544 Value::String("deployment_manifest".to_string()),
545 Value::String(manifest_path.to_string_lossy().into_owned()),
546 );
547
548 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 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
593struct Spawned {
596 name: String,
597 kind: String,
599 child: Child,
600 pid: u32,
601 pgid: u32,
604 provider_id: Option<String>,
605 driver_contract: Option<String>,
606 expected_driver_contract: Option<String>,
609 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 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 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 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 scribe::ingest(&tag_err, &line);
672 }
673 });
674 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
696const SOMA_STAGE_FD: RawFd = 3;
701
702async 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 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 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 let read_owned = read_fd; let child_target = SOMA_STAGE_FD;
754 unsafe {
758 cmd.pre_exec(move || {
759 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 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 let _ = child_raw;
794
795 let write_raw = write_fd.into_raw_fd();
799 let writer = unsafe { <std::fs::File as std::os::fd::FromRawFd>::from_raw_fd(write_raw) };
802
803 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
846unsafe 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 let log_name = name.clone();
914
915 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 let rbnx_bin = std::env::current_exe()
933 .context("could not resolve current rbnx binary path for `start` re-exec")?;
934 let _ = &cfg_file; 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 .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 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 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 scribe::ingest(&tag_err, &line);
1003 }
1004 });
1005 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
1029pub 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 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 if !no_update_check {
1076 super::check_remotes::report_outdated(&manifest_path);
1077 }
1078
1079 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 std::fs::create_dir_all(&log_dir)
1118 .with_context(|| format!("failed to create log dir {}", log_dir.display()))?;
1119
1120 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 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 check_prerequisites(
1198 &deploy,
1199 &cache_root,
1200 &manifest_dir,
1201 config.robonix_source_path.as_deref(),
1202 )?;
1203
1204 let mut sigint = signal(SignalKind::interrupt())?;
1212 let mut sigterm = signal(SignalKind::terminate())?;
1213
1214 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 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 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 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 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 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 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 let builtin_after_soma = bin_map
1414 .iter()
1415 .skip_while(|(n, _)| *n != "soma")
1416 .skip(1) .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 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 let mut failures: Vec<(String, String, String)> = Vec::new(); 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 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 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 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 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 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 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 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
1767fn 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
1856fn 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
1885fn 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 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
1907fn 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 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 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 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 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
2035async 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 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 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 ®istration.provider_namespace,
2117 expected_driver_contract,
2118 ®istration.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 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 let _ = init_state; output::boot_ok(display_label, &activate_state.to_uppercase());
2211
2212 Ok(sp)
2213}
2214
2215async 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; 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; 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 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
2483fn 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
2499fn 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
2525fn 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
2575fn 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 Ok(())
2602}
2603
2604async 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
2691fn 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
2713fn 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 const SPINNER_TICK: Duration = Duration::from_millis(100);
2747 const POLLS_PER_TICK: u32 = 2; 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 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 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 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 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}