1use super::build;
10use anyhow::{Context, Result};
11use robonix_atlas::client::AtlasClient;
12use robonix_atlas::pb as atlas_pb;
13use robonix_cli::Config;
14use robonix_cli::launch::{
15 CMD_ACTIVATE, CMD_INIT, ProviderRegistrationSnapshot, call_driver_cmd,
16 resolve_runtime_driver_contract, snapshot_provider_ids, wait_for_registration_core,
17};
18use robonix_cli::manifest;
19use robonix_cli::output;
20use robonix_cli::process::ProcessManager;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::Duration;
24
25pub(crate) const RBNX_INVOCATION_CWD: &str = "RBNX_INVOCATION_CWD";
29
30fn lifecycle_driver_migration_warning(
39 package_name: &str,
40 explicit_driver_contract: Option<&str>,
41) -> Option<String> {
42 let Some(driver_contract) = explicit_driver_contract else {
43 return None;
46 };
47 if driver_contract == manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT {
48 return None;
49 }
50 Some(format!(
51 "package '{package_name}' declares non-shared lifecycle contract \
52 '{driver_contract}'; only the exact <provider namespace>/driver form \
53 is backward-compatible and may use a shared runtime Driver. Remove the \
54 legacy declaration to select '{}' automatically; do not declare both",
55 manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT,
56 ))
57}
58
59fn shell_escape(value: &str) -> String {
62 format!("'{}'", value.replace('\'', "'\"'\"'"))
63}
64
65fn path_base_for_dash_p() -> Result<PathBuf> {
66 if let Ok(s) = std::env::var(RBNX_INVOCATION_CWD) {
67 Ok(PathBuf::from(s))
68 } else {
69 std::env::current_dir().context("Failed to get current directory")
70 }
71}
72
73pub(crate) fn resolve_local_path_for_filesystem(p: &Path) -> Result<PathBuf> {
76 if p.as_os_str() == "." || p.as_os_str() == "./" {
77 return path_base_for_dash_p();
78 }
79 if p.is_absolute() {
80 return Ok(p.to_path_buf());
81 }
82 Ok(path_base_for_dash_p()?.join(p))
83}
84
85pub(crate) fn find_package_from_cwd() -> Result<PathBuf> {
88 let start = path_base_for_dash_p()?;
89 let mut cur: Option<&Path> = Some(&start);
90 while let Some(d) = cur {
91 if d.join(manifest::MANIFEST_FILE).is_file() {
92 return d
93 .canonicalize()
94 .with_context(|| format!("Failed to canonicalize: {}", d.display()));
95 }
96 cur = d.parent();
97 }
98 anyhow::bail!(
99 "no {} found in {} or any parent; pass -p <path> or `cd` into a package directory",
100 manifest::MANIFEST_FILE,
101 start.display()
102 )
103}
104
105fn resolve_package_path(
108 config: &Config,
109 path: Option<PathBuf>,
110 global: Option<String>,
111) -> Result<PathBuf> {
112 if let Some(p) = path {
113 let p = resolve_local_path_for_filesystem(&p)?;
114 let canonical = p
115 .canonicalize()
116 .with_context(|| format!("Failed to canonicalize: {}", p.display()))?;
117 if canonical.join(manifest::MANIFEST_FILE).exists() {
118 return Ok(canonical);
119 }
120 anyhow::bail!(
121 "Path {} does not contain {}",
122 canonical.display(),
123 manifest::MANIFEST_FILE
124 );
125 }
126
127 if let Some(name) = global {
128 let db = robonix_cli::PackageDatabase::load(&config.package_storage_path)?;
129 if let Some(pkg) = db.get_package(&name) {
130 return Ok(pkg.path.clone());
131 }
132 anyhow::bail!(
133 "Package '{}' not found in system storage ({})",
134 name,
135 config.package_storage_path.display()
136 );
137 }
138
139 find_package_from_cwd()
140}
141
142fn resolve_package_path_for_start(config: &Config, spec: &str) -> Result<PathBuf> {
144 let path = resolve_local_path_for_filesystem(Path::new(spec))?;
145 if path.join(manifest::MANIFEST_FILE).is_file()
146 || path.join(manifest::LEGACY_MANIFEST_FILE).is_file()
147 {
148 return path
149 .canonicalize()
150 .with_context(|| format!("Failed to canonicalize: {}", path.display()));
151 }
152
153 let db = robonix_cli::PackageDatabase::load(&config.package_storage_path)?;
154 if let Some(pkg) = db.get_package(spec) {
155 return Ok(pkg.path.clone());
156 }
157
158 anyhow::bail!(
159 "Package '{}' not found at {} (relative -p uses {} or process cwd). Try -g <installed name> or export {}=\"$(pwd)\" before cargo run.",
160 spec,
161 path.display(),
162 RBNX_INVOCATION_CWD,
163 RBNX_INVOCATION_CWD
164 )
165}
166
167pub async fn execute_build(
168 config: Config,
169 file: Option<PathBuf>,
170 path: Option<PathBuf>,
171 global: Option<String>,
172 clean: bool,
173 no_update_check: bool,
174) -> Result<()> {
175 if let Some(file) = file {
176 let manifest_path = resolve_local_path_for_filesystem(&file)?;
177 if !manifest_path.is_file() {
178 anyhow::bail!("deployment manifest not found: {}", manifest_path.display());
179 }
180 return build_deploy_manifest(&manifest_path, &config, clean, no_update_check);
181 }
182
183 let candidate_dir = match &path {
192 Some(p) => Some(p.clone()),
193 None => std::env::current_dir().ok(),
194 };
195 if let Some(dir) = candidate_dir {
196 let deploy_manifest = dir.join("robonix_manifest.yaml");
197 if deploy_manifest.is_file() {
198 return build_deploy_manifest(&deploy_manifest, &config, clean, no_update_check);
199 }
200 }
201 let package_root = resolve_package_path(&config, path, global)?;
202 build::execute_local(package_root, clean).await
203}
204
205pub(super) fn git_clone_with_retry(
234 url: &str,
235 branch: Option<&str>,
236 dest: &std::path::Path,
237) -> Result<()> {
238 const ATTEMPTS: u32 = 3;
239 const BACKOFF: std::time::Duration = std::time::Duration::from_secs(5);
240
241 let mut last_code: Option<i32> = None;
242 for attempt in 1..=ATTEMPTS {
243 if attempt > 1 && dest.exists() {
244 let _ = std::fs::remove_dir_all(dest);
251 }
252 let mut clone = std::process::Command::new("git");
253 clone.arg("clone").arg("--depth").arg("1");
254 if let Some(b) = branch {
255 clone.arg("--branch").arg(b);
256 }
257 clone.arg(url).arg(dest);
258 let status = clone
259 .status()
260 .with_context(|| format!("git clone {url} failed to spawn"))?;
261 if status.success() {
262 return Ok(());
263 }
264 last_code = status.code();
265 if attempt < ATTEMPTS {
266 output::warning(&format!(
267 "git clone {url} failed (attempt {attempt}/{ATTEMPTS}); \
268 retrying in {}s",
269 BACKOFF.as_secs()
270 ));
271 std::thread::sleep(BACKOFF);
272 }
273 }
274 anyhow::bail!("git clone {url} exited with {last_code:?} after {ATTEMPTS} attempts")
275}
276
277fn build_deploy_manifest(
278 manifest_path: &Path,
279 config: &Config,
280 clean: bool,
281 no_update_check: bool,
282) -> Result<()> {
283 use serde_yaml::Value;
284 let manifest_dir = manifest_path
285 .parent()
286 .context("deploy manifest has no parent directory")?
287 .to_path_buf();
288 let raw = std::fs::read_to_string(manifest_path)
289 .with_context(|| format!("read {}", manifest_path.display()))?;
290 let root: Value =
291 serde_yaml::from_str(&raw).with_context(|| format!("parse {}", manifest_path.display()))?;
292 let root = super::deploy::prepare_manifest(root, config.robonix_source_path.as_deref())
293 .with_context(|| format!("prepare {}", manifest_path.display()))?;
294 let cache_root = manifest_dir.join("rbnx-boot").join("cache");
295
296 output::action(
297 "Building",
298 &format!("packages declared in {}", manifest_path.display()),
299 );
300 if !no_update_check {
302 super::check_remotes::report_outdated(manifest_path);
303 }
304
305 struct Resolved {
307 section: &'static str,
308 name: String,
309 pkg_dir: PathBuf,
310 url_to_clone: Option<(String, Option<String>)>, manifest_override: Option<String>,
315 }
316 let mut entries: Vec<Resolved> = Vec::new();
317 for section in &["primitive", "service", "skill"] {
318 let Some(seq) = root.get(*section).and_then(|v| v.as_sequence()) else {
319 continue;
320 };
321 for entry in seq {
322 let name = entry
323 .get("name")
324 .and_then(|v| v.as_str())
325 .unwrap_or("(unnamed)")
326 .to_string();
327 let local_path = entry.get("path").and_then(|v| v.as_str());
328 let url = entry.get("url").and_then(|v| v.as_str());
329 let branch = entry
330 .get("branch")
331 .and_then(|v| v.as_str())
332 .map(String::from);
333 let manifest_override = entry
334 .get("manifest")
335 .and_then(|v| v.as_str())
336 .map(String::from);
337 match (local_path, url) {
338 (Some(p), _) => entries.push(Resolved {
339 section,
340 name,
341 pkg_dir: manifest_dir.join(p),
342 url_to_clone: None,
343 manifest_override,
344 }),
345 (None, Some(u)) => entries.push(Resolved {
346 section,
347 name: name.clone(),
348 pkg_dir: cache_root.join(super::deploy::repo_dir_name(u)),
351 url_to_clone: Some((u.to_string(), branch)),
352 manifest_override,
353 }),
354 (None, None) => {
355 output::warning(&format!(
356 "skipping {section}/{name}: entry has neither `path` nor `url`"
357 ));
358 }
359 }
360 }
361 }
362 if let Some(map) = root.get("system").and_then(|v| v.as_mapping()) {
368 let source_root = config.robonix_source_path.as_ref();
369 for (key, value) in map {
370 let Some(key_str) = key.as_str() else {
371 continue;
372 };
373 if super::deploy::is_builtin_system(key_str) {
374 continue;
375 }
376 let Some(source_root) = source_root else {
377 output::warning(&format!(
378 "skipping system/{key_str}: robonix_source_path unset \
379 (run `rbnx setup` from the repo root once)"
380 ));
381 continue;
382 };
383 let pkg_dir = source_root.join("system").join(key_str);
384 if !pkg_dir.exists() {
385 output::warning(&format!(
386 "skipping system/{key_str}: not on disk at {}",
387 pkg_dir.display()
388 ));
389 continue;
390 }
391 let (manifest_override, _runtime_config) = manifest::split_system_package_config(value)
392 .with_context(|| format!("parse system/{key_str} package selector"))?;
393 entries.push(Resolved {
394 section: "system",
395 name: key_str.to_string(),
396 pkg_dir,
397 url_to_clone: None,
398 manifest_override,
399 });
400 }
401 }
402
403 let to_clone: Vec<&Resolved> = entries
405 .iter()
406 .filter(|e| e.url_to_clone.is_some() && !e.pkg_dir.exists())
407 .collect();
408 if !to_clone.is_empty() {
409 output::step("fetch", &format!("{} package(s)", to_clone.len()));
410 std::fs::create_dir_all(&cache_root)?;
411 for r in &to_clone {
412 let (url, branch) = r.url_to_clone.as_ref().unwrap();
413 output::sub_step(&format!("git clone {url} -> {}", r.pkg_dir.display()));
414 git_clone_with_retry(url, branch.as_deref(), &r.pkg_dir)?;
415 }
416 }
417
418 struct Row {
420 section: &'static str,
421 name: String,
422 pkg_name: String, version: String,
424 location: String, source: Option<(String, Option<String>)>, }
427 let mut built: Vec<Row> = Vec::new();
428 let mut skipped: Vec<(&'static str, String, String)> = Vec::new(); let mut failed: Vec<(&'static str, String, anyhow::Error)> = Vec::new();
430
431 fn read_pkg_meta(pkg_dir: &Path) -> (String, String) {
432 let manifest = pkg_dir.join("package_manifest.yaml");
434 let raw = match std::fs::read_to_string(&manifest) {
435 Ok(s) => s,
436 Err(_) => return (String::new(), String::new()),
437 };
438 let v: serde_yaml::Value = match serde_yaml::from_str(&raw) {
439 Ok(v) => v,
440 Err(_) => return (String::new(), String::new()),
441 };
442 let pkg = v.get("package");
443 let name = pkg
444 .and_then(|p| p.get("name").and_then(|n| n.as_str()))
445 .unwrap_or("")
446 .to_string();
447 let ver = pkg
448 .and_then(|p| p.get("version").and_then(|n| n.as_str()))
449 .unwrap_or("")
450 .to_string();
451 (name, ver)
452 }
453
454 fn rel_to(_base: &Path, p: &Path) -> String {
455 p.display().to_string()
458 }
459
460 for r in &entries {
461 if !r.pkg_dir.join("package_manifest.yaml").is_file() {
462 let reason = format!("no package_manifest.yaml at {}", r.pkg_dir.display());
463 output::warning(&format!("skipping {}/{}: {}", r.section, r.name, reason));
464 skipped.push((r.section, r.name.clone(), reason));
465 continue;
466 }
467 let canon = r
468 .pkg_dir
469 .canonicalize()
470 .with_context(|| format!("canonicalize {}", r.pkg_dir.display()))?;
471 output::step(r.section, &r.name);
472 let (pkg_name, version) = read_pkg_meta(&canon);
473 let location = rel_to(&manifest_dir, &canon);
474 match build::build_local_package(&canon, clean, r.manifest_override.as_deref()) {
475 Ok(()) => built.push(Row {
476 section: r.section,
477 name: r.name.clone(),
478 pkg_name,
479 version,
480 location,
481 source: r.url_to_clone.clone(),
482 }),
483 Err(e) => failed.push((r.section, r.name.clone(), e)),
484 }
485 }
486
487 let manifest_label = manifest_path
489 .file_name()
490 .and_then(|n| n.to_str())
491 .unwrap_or("manifest");
492 let term_w = crossterm::terminal::size()
493 .map(|(c, _)| c as usize)
494 .unwrap_or(120);
495
496 fn center_title(width: usize, title: &str) -> String {
497 let t = format!(" {title} ");
498 if width <= t.len() {
499 return "═".repeat(width);
500 }
501 let left = (width - t.len()) / 2;
502 let right = width - t.len() - left;
503 format!("{}{t}{}", "═".repeat(left), "═".repeat(right))
504 }
505
506 let h_status = "";
507 let h_sec = "section";
508 let h_name = "name";
509 let h_pkg = "package.name";
510 let h_ver = "version";
511 let h_loc = "location";
512 let w_status = 1;
513 let w_sec = built
514 .iter()
515 .map(|r| r.section.len())
516 .max()
517 .unwrap_or(0)
518 .max(h_sec.len());
519 let w_name = built
520 .iter()
521 .map(|r| r.name.len())
522 .max()
523 .unwrap_or(0)
524 .max(h_name.len());
525 let w_pkg = built
526 .iter()
527 .map(|r| r.pkg_name.len())
528 .max()
529 .unwrap_or(0)
530 .max(h_pkg.len());
531 let w_ver = built
532 .iter()
533 .map(|r| r.version.len())
534 .max()
535 .unwrap_or(0)
536 .max(h_ver.len());
537 let nat_loc = built
541 .iter()
542 .map(|r| r.location.len())
543 .max()
544 .unwrap_or(0)
545 .max(h_loc.len());
546 let w_loc = nat_loc;
547 let table_w = if built.is_empty() {
548 term_w
549 } else {
550 2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2 + w_loc
551 };
552 let bar_w = table_w.max(term_w);
553 let bar = "═".repeat(bar_w);
554
555 println!();
556 println!("{}", center_title(bar_w, "Build summary"));
557 println!(" Manifest: {}", manifest_path.display());
558 println!(
559 " Built: {} Fetched: {} Skipped: {} Failed: {} Total: {}",
560 built.len(),
561 to_clone.len(),
562 skipped.len(),
563 failed.len(),
564 entries.len()
565 );
566
567 if !built.is_empty() {
568 println!();
569 println!(
570 " {:<ws$} {:<wsec$} {:<wn$} {:<wp$} {:<wv$} {:<wl$}",
571 h_status,
572 h_sec,
573 h_name,
574 h_pkg,
575 h_ver,
576 h_loc,
577 ws = w_status,
578 wsec = w_sec,
579 wn = w_name,
580 wp = w_pkg,
581 wv = w_ver,
582 wl = w_loc,
583 );
584 let rule = |w: usize| "─".repeat(w);
585 println!(
586 " {} {} {} {} {} {}",
587 rule(w_status),
588 rule(w_sec),
589 rule(w_name),
590 rule(w_pkg),
591 rule(w_ver),
592 rule(w_loc),
593 );
594 let cont_indent = 2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2;
595 for r in &built {
596 println!(
597 " {:<ws$} {:<wsec$} {:<wn$} {:<wp$} {:<wv$} {}",
598 "✓",
599 r.section,
600 r.name,
601 r.pkg_name,
602 r.version,
603 r.location,
604 ws = w_status,
605 wsec = w_sec,
606 wn = w_name,
607 wp = w_pkg,
608 wv = w_ver,
609 );
610 if let Some((url, branch)) = &r.source {
611 let suffix = match branch {
612 Some(b) => format!("↳ {url} (branch={b})"),
613 None => format!("↳ {url}"),
614 };
615 println!("{}{suffix}", " ".repeat(cont_indent));
616 }
617 }
618 }
619 if !skipped.is_empty() {
620 println!();
621 for (section, name, reason) in &skipped {
622 println!(" - {section}/{name}: {reason}");
623 }
624 }
625 if !failed.is_empty() {
626 println!();
627 for (section, name, e) in &failed {
628 println!(" ✗ {section}/{name}: {e:#}");
629 }
630 }
631 println!("{bar}");
632
633 if !failed.is_empty() {
634 anyhow::bail!(
635 "{} package(s) failed to build from {manifest_label}",
636 failed.len()
637 );
638 }
639 Ok(())
640}
641
642pub async fn execute_start(
643 config: &Config,
644 spec: Option<&str>,
645 registry_endpoint: Option<&str>,
646 config_file: Option<&Path>,
647 set_overrides: &[String],
648 manifest_override: Option<&str>,
649) -> Result<()> {
650 let package_root = match spec {
651 Some(s) => resolve_package_path_for_start(config, s)?,
652 None => find_package_from_cwd()?,
653 };
654 let detected = manifest::detect_and_load(&package_root, manifest_override)?;
655 let manifest = &detected.manifest;
656 manifest.validate_and_summarize()?;
657
658 let endpoint = registry_endpoint
659 .map(String::from)
660 .unwrap_or_else(|| "127.0.0.1:50051".to_string());
661
662 let has_explicit_config = config_file.is_some() || !set_overrides.is_empty();
668 let explicit_driver_contract = manifest.explicit_lifecycle_driver_contract()?;
672 let expected_driver_contract = manifest.selected_lifecycle_driver_contract()?.to_string();
673 let allow_shared_driver_upgrade = explicit_driver_contract
674 .is_some_and(|contract| contract != manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT);
675 let deploy_managed = std::env::var_os("RBNX_DEPLOY_MANAGED").is_some();
676 let materialized_cfg_json = build_start_config_json(config_file, set_overrides)?;
677
678 if let Some(message) =
679 lifecycle_driver_migration_warning(&manifest.package.name, explicit_driver_contract)
680 {
681 output::warning(&message);
682 }
683
684 let log_dir = std::env::var("SCRIBE_LOG_DIR")
689 .map(PathBuf::from)
690 .unwrap_or_else(|_| package_root.join("rbnx-build").join("logs"));
691 let process_manager = Arc::new(ProcessManager::new(log_dir.clone())?);
692
693 output::action("Running", &manifest.package.name);
694 output::sub_step(&format!("Atlas endpoint: {}", endpoint));
695 if !manifest.capabilities.is_empty() {
696 output::sub_step(&format!(
697 "Capabilities: {}",
698 manifest
699 .capabilities
700 .iter()
701 .map(|c| c.name.as_str())
702 .collect::<Vec<_>>()
703 .join(", ")
704 ));
705 }
706
707 let mut env = std::collections::HashMap::new();
708 env.insert("ROBONIX_ATLAS".to_string(), endpoint.clone());
709 env.insert("SCRIBE_LOG_DIR".to_string(), log_dir.display().to_string());
710 env.insert(
715 "ROBONIX_DRIVER_CONTRACT_ID".to_string(),
716 expected_driver_contract.clone(),
717 );
718 env.insert(
721 "ROBONIX_DRIVER_ALLOW_OLD_ARTIFACT_FALLBACK".to_string(),
722 if allow_shared_driver_upgrade {
723 "1"
724 } else {
725 "0"
726 }
727 .to_string(),
728 );
729 if has_explicit_config && !deploy_managed {
730 output::sub_step("Config: will deliver via Driver(CMD_INIT) post-register");
731 } else if has_explicit_config && deploy_managed {
732 output::sub_step("Config: deployment owner will deliver Driver(CMD_INIT)");
733 }
734 env.entry("PYTHONUNBUFFERED".to_string())
745 .or_insert_with(|| "1".to_string());
746
747 if !manifest.build.trim().is_empty() && !build::build_stamp_path(&package_root).exists() {
748 output::sub_step("No rbnx-build/.rbnx-built — running package build first");
749 build::build_local_package(&package_root, false, manifest_override)?;
750 }
751
752 let exports = env
753 .iter()
754 .map(|(k, v)| format!("export {}={}", k, shell_escape(v)))
755 .collect::<Vec<_>>()
756 .join("; ");
757 let pythonpath_export = generated_pythonpath_export(&package_root);
758 let start_body = manifest.start.trim();
759 let setup_bash = package_root
760 .join("rbnx-build")
761 .join("ws")
762 .join("install")
763 .join("setup.bash");
764 let setup_source = if setup_bash.exists() {
765 format!("source {}", shell_escape(&setup_bash.display().to_string()))
766 } else {
767 String::new()
768 };
769 let prefix_parts: Vec<String> = [setup_source, exports, pythonpath_export]
770 .into_iter()
771 .filter(|s| !s.is_empty())
772 .collect();
773 let start_command = if prefix_parts.is_empty() {
774 start_body.to_string()
775 } else {
776 format!("{}; {start_body}", prefix_parts.join("; "))
777 };
778
779 let standalone_lifecycle = if !deploy_managed {
784 let json = materialized_cfg_json
785 .expect("start config materialization always returns a JSON object");
786 let normalized = normalize_atlas_endpoint(&endpoint);
787 let mut atlas = AtlasClient::connect(&normalized)
788 .await
789 .with_context(|| format!("connect Atlas at {normalized} before standalone spawn"))?;
790 let before_snapshot = snapshot_provider_ids(&mut atlas)
791 .await
792 .context("standalone pre-spawn Atlas snapshot")?;
793 Some((
794 atlas,
795 before_snapshot,
796 json,
797 expected_driver_contract.clone(),
798 allow_shared_driver_upgrade,
799 ))
800 } else {
801 None
802 };
803
804 let instance_name =
811 std::env::var("RBNX_INSTANCE_NAME").unwrap_or_else(|_| manifest.package.name.clone());
812 let result = if let Some((mut atlas, before, json, expected_contract, allow_shared_upgrade)) =
813 standalone_lifecycle
814 {
815 let manager_for_start = Arc::clone(&process_manager);
819 let package_root_for_start = package_root.clone();
820 let start_command_for_start = start_command.clone();
821 let instance_for_start = instance_name.clone();
822 let mut process_task = tokio::spawn(async move {
823 manager_for_start
824 .start_process(
825 &instance_for_start,
826 &instance_for_start,
827 "package",
828 &package_root_for_start,
829 &start_command_for_start,
830 )
831 .await
832 });
833
834 let recorded_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
837 while !process_manager.has_process_record(&instance_name, "package") {
838 if process_task.is_finished() {
839 let process_result = process_task.await.context("package process task failed")?;
840 return match process_result {
841 Ok(result) => anyhow::bail!(
842 "package exited before registering with Atlas (PID {})",
843 result.pid
844 ),
845 Err(error) => Err(error),
846 };
847 }
848 if tokio::time::Instant::now() >= recorded_deadline {
849 process_task.abort();
850 anyhow::bail!("package process was not recorded within 5s after spawn");
851 }
852 tokio::time::sleep(Duration::from_millis(25)).await;
853 }
854
855 let lifecycle = drive_standalone_lifecycle(
856 &mut atlas,
857 &before,
858 &instance_name,
859 &expected_contract,
860 allow_shared_upgrade,
861 json,
862 );
863 tokio::pin!(lifecycle);
864 tokio::select! {
865 lifecycle_result = &mut lifecycle => {
866 if let Err(error) = lifecycle_result {
867 output::warning(&format!("standalone lifecycle failed: {error:#}"));
868 if let Err(stop_error) = process_manager.stop_process(&instance_name, "package").await {
869 output::warning(&format!("failed to stop package after lifecycle error: {stop_error:#}"));
870 }
871 let _ = process_task.await;
872 return Err(error);
873 }
874 }
875 process_result = &mut process_task => {
876 let process_result = process_result.context("package process task failed")?;
877 return match process_result {
878 Ok(result) => anyhow::bail!(
879 "package exited before completing standalone lifecycle (PID {})",
880 result.pid
881 ),
882 Err(error) => Err(error),
883 };
884 }
885 }
886 process_task
887 .await
888 .context("package process task failed")??
889 } else {
890 process_manager
891 .start_process(
892 &instance_name,
893 &instance_name,
894 "package",
895 &package_root,
896 &start_command,
897 )
898 .await?
899 };
900 output::check(&format!(
901 "{} exited (PID {})",
902 manifest.package.name, result.pid
903 ));
904
905 output::success(&format!("Package {} finished", manifest.package.name));
906 Ok(())
907}
908
909fn generated_pythonpath_export(package_root: &Path) -> String {
914 let codegen_root = package_root.join("rbnx-build").join("codegen");
915 let mut paths = vec![package_root.to_path_buf()];
916 for path in [
917 codegen_root.join("proto_gen"),
918 codegen_root.join("robonix_mcp_types"),
919 ] {
920 if path.is_dir() {
921 paths.push(path);
922 }
923 }
924 let joined = paths
925 .iter()
926 .map(|path| path.display().to_string())
927 .collect::<Vec<_>>()
928 .join(":");
929 format!(
930 "export PYTHONPATH={}:${{PYTHONPATH:-}}",
931 shell_escape(&joined)
932 )
933}
934
935fn normalize_atlas_endpoint(endpoint: &str) -> String {
936 if endpoint.starts_with("http") {
937 endpoint.to_string()
938 } else {
939 format!("http://{endpoint}")
940 }
941}
942
943async fn drive_standalone_lifecycle(
951 atlas: &mut AtlasClient,
952 before: &ProviderRegistrationSnapshot,
953 expected_provider_id: &str,
954 expected_driver_contract: &str,
955 allow_shared_driver_upgrade: bool,
956 config_json: String,
957) -> Result<()> {
958 let outcome =
959 wait_for_registration_core(atlas, before, expected_provider_id, "rbnx start").await?;
960 let driver_contract = resolve_runtime_driver_contract(
961 &outcome.provider_id,
962 &outcome.provider_namespace,
963 expected_driver_contract,
964 &outcome.driver_contracts,
965 allow_shared_driver_upgrade,
966 )?;
967 if driver_contract != expected_driver_contract {
968 output::warning(&format!(
969 "provider '{}' publishes shared lifecycle Driver '{}' for legacy manifest selection '{}'; remove the legacy Driver declaration to finish migration",
970 outcome.provider_id, driver_contract, expected_driver_contract,
971 ));
972 }
973
974 let init_state = call_driver_cmd(
975 atlas,
976 &outcome.provider_id,
977 &driver_contract,
978 CMD_INIT,
979 config_json.clone(),
980 "rbnx start",
981 )
982 .await?;
983 output::sub_step(&format!(
984 "Driver(CMD_INIT) → {} ok (state={init_state})",
985 outcome.provider_id
986 ));
987 if should_activate_standalone_provider(outcome.provider_kind) {
988 let state = call_driver_cmd(
989 atlas,
990 &outcome.provider_id,
991 &driver_contract,
992 CMD_ACTIVATE,
993 config_json,
994 "rbnx start",
995 )
996 .await?;
997 output::sub_step(&format!(
998 "Driver(CMD_ACTIVATE) → {} ok (state={state})",
999 outcome.provider_id
1000 ));
1001 }
1002 Ok(())
1003}
1004
1005fn should_activate_standalone_provider(provider_kind: i32) -> bool {
1006 provider_kind != atlas_pb::Kind::Skill as i32
1007}
1008
1009fn build_start_config_json(config_file: Option<&Path>, sets: &[String]) -> Result<Option<String>> {
1020 if config_file.is_none() && sets.is_empty() {
1021 return Ok(Some("{}".to_string()));
1022 }
1023
1024 let mut value: serde_json::Value = match config_file {
1025 Some(p) => {
1026 let raw = std::fs::read_to_string(p)
1027 .with_context(|| format!("read config file {}", p.display()))?;
1028 match serde_json::from_str::<serde_json::Value>(&raw) {
1030 Ok(v) => v,
1031 Err(_) => {
1032 let y: serde_yaml::Value = serde_yaml::from_str(&raw)
1033 .with_context(|| format!("parse config {} as JSON or YAML", p.display()))?;
1034 serde_json::to_value(y)
1035 .with_context(|| format!("convert {} YAML→JSON", p.display()))?
1036 }
1037 }
1038 }
1039 None => serde_json::Value::Object(serde_json::Map::new()),
1040 };
1041
1042 for s in sets {
1043 let (key, raw_val) = s
1044 .split_once('=')
1045 .with_context(|| format!("--set {s:?}: expected KEY=VALUE"))?;
1046 let parsed: serde_json::Value = serde_json::from_str(raw_val)
1047 .unwrap_or_else(|_| serde_json::Value::String(raw_val.into()));
1048 merge_dotted(&mut value, key, parsed)?;
1049 }
1050
1051 Ok(Some(
1052 serde_json::to_string(&value).unwrap_or_else(|_| "{}".into()),
1053 ))
1054}
1055
1056fn merge_dotted(root: &mut serde_json::Value, key: &str, v: serde_json::Value) -> Result<()> {
1059 let parts: Vec<&str> = key.split('.').filter(|p| !p.is_empty()).collect();
1060 if parts.is_empty() {
1061 anyhow::bail!("--set: empty key");
1062 }
1063 if !root.is_object() {
1064 *root = serde_json::Value::Object(serde_json::Map::new());
1065 }
1066 let mut cur = root;
1067 for p in &parts[..parts.len() - 1] {
1068 let map = cur.as_object_mut().ok_or_else(|| {
1069 anyhow::anyhow!("--set {key}: cannot descend into non-object at '{p}'")
1070 })?;
1071 let entry = map
1072 .entry((*p).to_string())
1073 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1074 if !entry.is_object() {
1075 *entry = serde_json::Value::Object(serde_json::Map::new());
1076 }
1077 cur = entry;
1078 }
1079 let last = parts[parts.len() - 1];
1080 cur.as_object_mut()
1081 .ok_or_else(|| anyhow::anyhow!("--set {key}: parent is not an object"))?
1082 .insert(last.to_string(), v);
1083 Ok(())
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089 use std::fs;
1090 use std::time::{SystemTime, UNIX_EPOCH};
1091
1092 #[test]
1101 fn git_clone_with_retry_cleans_partial_checkouts_and_reports_the_last_status() {
1102 let root = temp_root("clone-retry");
1103 let dest = root.join("pkg");
1104 fs::create_dir_all(&dest).expect("dest");
1105 fs::write(dest.join("leftover.txt"), b"partial clone").expect("seed leftover");
1106
1107 let missing = root.join("no-such-repo.git");
1108 let err = git_clone_with_retry(&missing.to_string_lossy(), None, &dest)
1109 .expect_err("clone of a nonexistent repo must fail");
1110
1111 let msg = format!("{err:#}");
1112 assert!(msg.contains("after 3 attempts"), "unexpected error: {msg}");
1113 assert!(
1114 !dest.exists(),
1115 "a failed clone must not leave a partial checkout at {}",
1116 dest.display()
1117 );
1118 let _ = fs::remove_dir_all(&root);
1119 }
1120
1121 fn temp_root(label: &str) -> PathBuf {
1122 let nonce = SystemTime::now()
1123 .duration_since(UNIX_EPOCH)
1124 .unwrap()
1125 .as_nanos();
1126 std::env::temp_dir().join(format!("rbnx-start-{label}-{}-{nonce}", std::process::id()))
1127 }
1128
1129 #[test]
1130 fn generated_pythonpath_is_injected_without_a_setup_stub() {
1131 let root = temp_root("pythonpath");
1132 let proto = root.join("rbnx-build/codegen/proto_gen");
1133 let mcp = root.join("rbnx-build/codegen/robonix_mcp_types");
1134 fs::create_dir_all(&proto).unwrap();
1135 fs::create_dir_all(&mcp).unwrap();
1136
1137 let export = generated_pythonpath_export(&root);
1138
1139 assert!(export.contains(&root.display().to_string()));
1140 assert!(export.contains(&proto.display().to_string()));
1141 assert!(export.contains(&mcp.display().to_string()));
1142 assert!(export.ends_with(":${PYTHONPATH:-}"));
1143 assert!(!root.join("rbnx-build/ws/install/setup.bash").exists());
1144 fs::remove_dir_all(root).unwrap();
1145 }
1146
1147 #[test]
1148 fn start_without_config_still_materializes_empty_init_config() {
1149 let config = build_start_config_json(None, &[]).unwrap();
1150 assert_eq!(config.as_deref(), Some("{}"));
1151 }
1152
1153 #[test]
1154 fn standalone_start_activates_primitive_and_service_but_not_skill() {
1155 assert!(should_activate_standalone_provider(
1156 atlas_pb::Kind::Primitive as i32
1157 ));
1158 assert!(should_activate_standalone_provider(
1159 atlas_pb::Kind::Service as i32
1160 ));
1161 assert!(!should_activate_standalone_provider(
1162 atlas_pb::Kind::Skill as i32
1163 ));
1164 }
1165
1166 #[test]
1167 fn shared_driver_contract_needs_no_migration_warning() {
1168 assert_eq!(
1169 lifecycle_driver_migration_warning("test.scene", Some("robonix/lifecycle/driver"),),
1170 None
1171 );
1172 }
1173
1174 #[test]
1175 fn omitted_driver_is_the_canonical_shared_selection() {
1176 assert_eq!(lifecycle_driver_migration_warning("test.scene", None), None);
1177 }
1178
1179 #[test]
1180 fn legacy_driver_contract_gets_one_actionable_migration_warning() {
1181 let warning = lifecycle_driver_migration_warning(
1182 "test.camera",
1183 Some("robonix/primitive/camera/driver"),
1184 )
1185 .unwrap();
1186 assert!(warning.contains("test.camera"));
1187 assert!(warning.contains("robonix/primitive/camera/driver"));
1188 assert!(warning.contains("robonix/lifecycle/driver"));
1189 assert!(warning.contains("is backward-compatible"));
1190 assert!(warning.contains("exact <provider namespace>/driver"));
1191 assert!(warning.contains("shared runtime Driver"));
1192 assert!(warning.contains("do not declare both"));
1193 }
1194
1195 #[test]
1196 fn system_package_uses_the_deploy_selected_manifest() {
1197 let root = temp_root("system-manifest");
1198 let source_root = root.join("source");
1199 let scene_root = source_root.join("system/scene");
1200 let deploy_root = root.join("deploy");
1201 fs::create_dir_all(&scene_root).unwrap();
1202 fs::create_dir_all(&deploy_root).unwrap();
1203 fs::write(
1204 scene_root.join("package_manifest.yaml"),
1205 r#"manifestVersion: 1
1206package:
1207 name: test.system.scene
1208 version: 0.1.0
1209 vendor: test
1210 description: default target
1211 license: Apache-2.0
1212build: touch default-selected
1213start: "true"
1214"#,
1215 )
1216 .unwrap();
1217 fs::write(
1218 scene_root.join("package_manifest.jetson-native.yaml"),
1219 r#"manifestVersion: 1
1220package:
1221 name: test.system.scene
1222 version: 0.1.0
1223 vendor: test
1224 description: Jetson native target
1225 license: Apache-2.0
1226build: touch jetson-selected
1227start: "true"
1228"#,
1229 )
1230 .unwrap();
1231 let deploy_manifest = deploy_root.join("robonix_manifest.yaml");
1232 fs::write(
1233 &deploy_manifest,
1234 r#"manifestVersion: 1
1235name: system-target-test
1236system:
1237 scene:
1238 manifest: package_manifest.jetson-native.yaml
1239 config:
1240 camera_provider_id: front_camera
1241"#,
1242 )
1243 .unwrap();
1244 let config = Config {
1245 package_storage_path: root.join("packages"),
1246 robonix_source_path: Some(source_root),
1247 };
1248
1249 build_deploy_manifest(&deploy_manifest, &config, false, true).unwrap();
1250
1251 assert!(scene_root.join("jetson-selected").is_file());
1252 assert!(!scene_root.join("default-selected").exists());
1253 fs::remove_dir_all(root).unwrap();
1254 }
1255}