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
205fn build_deploy_manifest(
218 manifest_path: &Path,
219 config: &Config,
220 clean: bool,
221 no_update_check: bool,
222) -> Result<()> {
223 use serde_yaml::Value;
224 let manifest_dir = manifest_path
225 .parent()
226 .context("deploy manifest has no parent directory")?
227 .to_path_buf();
228 let raw = std::fs::read_to_string(manifest_path)
229 .with_context(|| format!("read {}", manifest_path.display()))?;
230 let root: Value =
231 serde_yaml::from_str(&raw).with_context(|| format!("parse {}", manifest_path.display()))?;
232 let root = super::deploy::prepare_manifest(root, config.robonix_source_path.as_deref())
233 .with_context(|| format!("prepare {}", manifest_path.display()))?;
234 let cache_root = manifest_dir.join("rbnx-boot").join("cache");
235
236 output::action(
237 "Building",
238 &format!("packages declared in {}", manifest_path.display()),
239 );
240 if !no_update_check {
242 super::check_remotes::report_outdated(manifest_path);
243 }
244
245 struct Resolved {
247 section: &'static str,
248 name: String,
249 pkg_dir: PathBuf,
250 url_to_clone: Option<(String, Option<String>)>, manifest_override: Option<String>,
255 }
256 let mut entries: Vec<Resolved> = Vec::new();
257 for section in &["primitive", "service", "skill"] {
258 let Some(seq) = root.get(*section).and_then(|v| v.as_sequence()) else {
259 continue;
260 };
261 for entry in seq {
262 let name = entry
263 .get("name")
264 .and_then(|v| v.as_str())
265 .unwrap_or("(unnamed)")
266 .to_string();
267 let local_path = entry.get("path").and_then(|v| v.as_str());
268 let url = entry.get("url").and_then(|v| v.as_str());
269 let branch = entry
270 .get("branch")
271 .and_then(|v| v.as_str())
272 .map(String::from);
273 let manifest_override = entry
274 .get("manifest")
275 .and_then(|v| v.as_str())
276 .map(String::from);
277 match (local_path, url) {
278 (Some(p), _) => entries.push(Resolved {
279 section,
280 name,
281 pkg_dir: manifest_dir.join(p),
282 url_to_clone: None,
283 manifest_override,
284 }),
285 (None, Some(u)) => entries.push(Resolved {
286 section,
287 name: name.clone(),
288 pkg_dir: cache_root.join(super::deploy::repo_dir_name(u)),
291 url_to_clone: Some((u.to_string(), branch)),
292 manifest_override,
293 }),
294 (None, None) => {
295 output::warning(&format!(
296 "skipping {section}/{name}: entry has neither `path` nor `url`"
297 ));
298 }
299 }
300 }
301 }
302 const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma", "vitals"];
308 if let Some(map) = root.get("system").and_then(|v| v.as_mapping()) {
309 let source_root = config.robonix_source_path.as_ref();
310 for (key, value) in map {
311 let Some(key_str) = key.as_str() else {
312 continue;
313 };
314 if SYSTEM_BUILTINS.contains(&key_str) {
315 continue;
316 }
317 let Some(source_root) = source_root else {
318 output::warning(&format!(
319 "skipping system/{key_str}: robonix_source_path unset \
320 (run `rbnx setup` from the repo root once)"
321 ));
322 continue;
323 };
324 let pkg_dir = source_root.join("system").join(key_str);
325 if !pkg_dir.exists() {
326 output::warning(&format!(
327 "skipping system/{key_str}: not on disk at {}",
328 pkg_dir.display()
329 ));
330 continue;
331 }
332 let (manifest_override, _runtime_config) = manifest::split_system_package_config(value)
333 .with_context(|| format!("parse system/{key_str} package selector"))?;
334 entries.push(Resolved {
335 section: "system",
336 name: key_str.to_string(),
337 pkg_dir,
338 url_to_clone: None,
339 manifest_override,
340 });
341 }
342 }
343
344 let to_clone: Vec<&Resolved> = entries
346 .iter()
347 .filter(|e| e.url_to_clone.is_some() && !e.pkg_dir.exists())
348 .collect();
349 if !to_clone.is_empty() {
350 output::step("fetch", &format!("{} package(s)", to_clone.len()));
351 std::fs::create_dir_all(&cache_root)?;
352 for r in &to_clone {
353 let (url, branch) = r.url_to_clone.as_ref().unwrap();
354 output::sub_step(&format!("git clone {url} -> {}", r.pkg_dir.display()));
355 let mut clone = std::process::Command::new("git");
356 clone.arg("clone").arg("--depth").arg("1");
357 if let Some(b) = branch {
358 clone.arg("--branch").arg(b);
359 }
360 clone.arg(url).arg(&r.pkg_dir);
361 let status = clone
362 .status()
363 .with_context(|| format!("git clone {url} failed to spawn"))?;
364 if !status.success() {
365 anyhow::bail!("git clone {url} exited with {:?}", status.code());
366 }
367 }
368 }
369
370 struct Row {
372 section: &'static str,
373 name: String,
374 pkg_name: String, version: String,
376 location: String, source: Option<(String, Option<String>)>, }
379 let mut built: Vec<Row> = Vec::new();
380 let mut skipped: Vec<(&'static str, String, String)> = Vec::new(); let mut failed: Vec<(&'static str, String, anyhow::Error)> = Vec::new();
382
383 fn read_pkg_meta(pkg_dir: &Path) -> (String, String) {
384 let manifest = pkg_dir.join("package_manifest.yaml");
386 let raw = match std::fs::read_to_string(&manifest) {
387 Ok(s) => s,
388 Err(_) => return (String::new(), String::new()),
389 };
390 let v: serde_yaml::Value = match serde_yaml::from_str(&raw) {
391 Ok(v) => v,
392 Err(_) => return (String::new(), String::new()),
393 };
394 let pkg = v.get("package");
395 let name = pkg
396 .and_then(|p| p.get("name").and_then(|n| n.as_str()))
397 .unwrap_or("")
398 .to_string();
399 let ver = pkg
400 .and_then(|p| p.get("version").and_then(|n| n.as_str()))
401 .unwrap_or("")
402 .to_string();
403 (name, ver)
404 }
405
406 fn rel_to(_base: &Path, p: &Path) -> String {
407 p.display().to_string()
410 }
411
412 for r in &entries {
413 if !r.pkg_dir.join("package_manifest.yaml").is_file() {
414 let reason = format!("no package_manifest.yaml at {}", r.pkg_dir.display());
415 output::warning(&format!("skipping {}/{}: {}", r.section, r.name, reason));
416 skipped.push((r.section, r.name.clone(), reason));
417 continue;
418 }
419 let canon = r
420 .pkg_dir
421 .canonicalize()
422 .with_context(|| format!("canonicalize {}", r.pkg_dir.display()))?;
423 output::step(r.section, &r.name);
424 let (pkg_name, version) = read_pkg_meta(&canon);
425 let location = rel_to(&manifest_dir, &canon);
426 match build::build_local_package(&canon, clean, r.manifest_override.as_deref()) {
427 Ok(()) => built.push(Row {
428 section: r.section,
429 name: r.name.clone(),
430 pkg_name,
431 version,
432 location,
433 source: r.url_to_clone.clone(),
434 }),
435 Err(e) => failed.push((r.section, r.name.clone(), e)),
436 }
437 }
438
439 let manifest_label = manifest_path
441 .file_name()
442 .and_then(|n| n.to_str())
443 .unwrap_or("manifest");
444 let term_w = crossterm::terminal::size()
445 .map(|(c, _)| c as usize)
446 .unwrap_or(120);
447
448 fn center_title(width: usize, title: &str) -> String {
449 let t = format!(" {title} ");
450 if width <= t.len() {
451 return "═".repeat(width);
452 }
453 let left = (width - t.len()) / 2;
454 let right = width - t.len() - left;
455 format!("{}{t}{}", "═".repeat(left), "═".repeat(right))
456 }
457
458 let h_status = "";
459 let h_sec = "section";
460 let h_name = "name";
461 let h_pkg = "package.name";
462 let h_ver = "version";
463 let h_loc = "location";
464 let w_status = 1;
465 let w_sec = built
466 .iter()
467 .map(|r| r.section.len())
468 .max()
469 .unwrap_or(0)
470 .max(h_sec.len());
471 let w_name = built
472 .iter()
473 .map(|r| r.name.len())
474 .max()
475 .unwrap_or(0)
476 .max(h_name.len());
477 let w_pkg = built
478 .iter()
479 .map(|r| r.pkg_name.len())
480 .max()
481 .unwrap_or(0)
482 .max(h_pkg.len());
483 let w_ver = built
484 .iter()
485 .map(|r| r.version.len())
486 .max()
487 .unwrap_or(0)
488 .max(h_ver.len());
489 let nat_loc = built
493 .iter()
494 .map(|r| r.location.len())
495 .max()
496 .unwrap_or(0)
497 .max(h_loc.len());
498 let w_loc = nat_loc;
499 let table_w = if built.is_empty() {
500 term_w
501 } else {
502 2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2 + w_loc
503 };
504 let bar_w = table_w.max(term_w);
505 let bar = "═".repeat(bar_w);
506
507 println!();
508 println!("{}", center_title(bar_w, "Build summary"));
509 println!(" Manifest: {}", manifest_path.display());
510 println!(
511 " Built: {} Fetched: {} Skipped: {} Failed: {} Total: {}",
512 built.len(),
513 to_clone.len(),
514 skipped.len(),
515 failed.len(),
516 entries.len()
517 );
518
519 if !built.is_empty() {
520 println!();
521 println!(
522 " {:<ws$} {:<wsec$} {:<wn$} {:<wp$} {:<wv$} {:<wl$}",
523 h_status,
524 h_sec,
525 h_name,
526 h_pkg,
527 h_ver,
528 h_loc,
529 ws = w_status,
530 wsec = w_sec,
531 wn = w_name,
532 wp = w_pkg,
533 wv = w_ver,
534 wl = w_loc,
535 );
536 let rule = |w: usize| "─".repeat(w);
537 println!(
538 " {} {} {} {} {} {}",
539 rule(w_status),
540 rule(w_sec),
541 rule(w_name),
542 rule(w_pkg),
543 rule(w_ver),
544 rule(w_loc),
545 );
546 let cont_indent = 2 + w_status + 2 + w_sec + 2 + w_name + 2 + w_pkg + 2 + w_ver + 2;
547 for r in &built {
548 println!(
549 " {:<ws$} {:<wsec$} {:<wn$} {:<wp$} {:<wv$} {}",
550 "✓",
551 r.section,
552 r.name,
553 r.pkg_name,
554 r.version,
555 r.location,
556 ws = w_status,
557 wsec = w_sec,
558 wn = w_name,
559 wp = w_pkg,
560 wv = w_ver,
561 );
562 if let Some((url, branch)) = &r.source {
563 let suffix = match branch {
564 Some(b) => format!("↳ {url} (branch={b})"),
565 None => format!("↳ {url}"),
566 };
567 println!("{}{suffix}", " ".repeat(cont_indent));
568 }
569 }
570 }
571 if !skipped.is_empty() {
572 println!();
573 for (section, name, reason) in &skipped {
574 println!(" - {section}/{name}: {reason}");
575 }
576 }
577 if !failed.is_empty() {
578 println!();
579 for (section, name, e) in &failed {
580 println!(" ✗ {section}/{name}: {e:#}");
581 }
582 }
583 println!("{bar}");
584
585 if !failed.is_empty() {
586 anyhow::bail!(
587 "{} package(s) failed to build from {manifest_label}",
588 failed.len()
589 );
590 }
591 Ok(())
592}
593
594pub async fn execute_start(
595 config: &Config,
596 spec: Option<&str>,
597 registry_endpoint: Option<&str>,
598 config_file: Option<&Path>,
599 set_overrides: &[String],
600 manifest_override: Option<&str>,
601) -> Result<()> {
602 let package_root = match spec {
603 Some(s) => resolve_package_path_for_start(config, s)?,
604 None => find_package_from_cwd()?,
605 };
606 let detected = manifest::detect_and_load(&package_root, manifest_override)?;
607 let manifest = &detected.manifest;
608 manifest.validate_and_summarize()?;
609
610 let endpoint = registry_endpoint
611 .map(String::from)
612 .unwrap_or_else(|| "127.0.0.1:50051".to_string());
613
614 let has_explicit_config = config_file.is_some() || !set_overrides.is_empty();
620 let explicit_driver_contract = manifest.explicit_lifecycle_driver_contract()?;
624 let expected_driver_contract = manifest.selected_lifecycle_driver_contract()?.to_string();
625 let allow_shared_driver_upgrade = explicit_driver_contract
626 .is_some_and(|contract| contract != manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT);
627 let has_driver_capability = true;
628 let deploy_managed = std::env::var_os("RBNX_DEPLOY_MANAGED").is_some();
629 let materialized_cfg_json = build_start_config_json(config_file, set_overrides)?;
630
631 if let Some(message) =
632 lifecycle_driver_migration_warning(&manifest.package.name, explicit_driver_contract)
633 {
634 output::warning(&message);
635 }
636
637 let log_dir = std::env::var("SCRIBE_LOG_DIR")
642 .map(PathBuf::from)
643 .unwrap_or_else(|_| package_root.join("rbnx-build").join("logs"));
644 let process_manager = Arc::new(ProcessManager::new(log_dir.clone())?);
645
646 output::action("Running", &manifest.package.name);
647 output::sub_step(&format!("Atlas endpoint: {}", endpoint));
648 if !manifest.capabilities.is_empty() {
649 output::sub_step(&format!(
650 "Capabilities: {}",
651 manifest
652 .capabilities
653 .iter()
654 .map(|c| c.name.as_str())
655 .collect::<Vec<_>>()
656 .join(", ")
657 ));
658 }
659
660 let mut env = std::collections::HashMap::new();
661 env.insert("ROBONIX_ATLAS".to_string(), endpoint.clone());
662 env.insert("SCRIBE_LOG_DIR".to_string(), log_dir.display().to_string());
663 env.insert(
668 "ROBONIX_DRIVER_CONTRACT_ID".to_string(),
669 expected_driver_contract.clone(),
670 );
671 env.insert(
674 "ROBONIX_DRIVER_ALLOW_OLD_ARTIFACT_FALLBACK".to_string(),
675 if allow_shared_driver_upgrade {
676 "1"
677 } else {
678 "0"
679 }
680 .to_string(),
681 );
682 if has_explicit_config && should_drive_standalone_init(has_driver_capability, deploy_managed) {
683 output::sub_step("Config: will deliver via Driver(CMD_INIT) post-register");
684 } else if has_explicit_config && deploy_managed {
685 output::sub_step("Config: deployment owner will deliver Driver(CMD_INIT)");
686 }
687 env.entry("PYTHONUNBUFFERED".to_string())
698 .or_insert_with(|| "1".to_string());
699
700 if !manifest.build.trim().is_empty() && !build::build_stamp_path(&package_root).exists() {
701 output::sub_step("No rbnx-build/.rbnx-built — running package build first");
702 build::build_local_package(&package_root, false, manifest_override)?;
703 }
704
705 let exports = env
706 .iter()
707 .map(|(k, v)| format!("export {}={}", k, shell_escape(v)))
708 .collect::<Vec<_>>()
709 .join("; ");
710 let pythonpath_export = generated_pythonpath_export(&package_root);
711 let start_body = manifest.start.trim();
712 let setup_bash = package_root
713 .join("rbnx-build")
714 .join("ws")
715 .join("install")
716 .join("setup.bash");
717 let setup_source = if setup_bash.exists() {
718 format!("source {}", shell_escape(&setup_bash.display().to_string()))
719 } else {
720 String::new()
721 };
722 let prefix_parts: Vec<String> = [setup_source, exports, pythonpath_export]
723 .into_iter()
724 .filter(|s| !s.is_empty())
725 .collect();
726 let start_command = if prefix_parts.is_empty() {
727 start_body.to_string()
728 } else {
729 format!("{}; {start_body}", prefix_parts.join("; "))
730 };
731
732 let standalone_lifecycle =
737 if should_drive_standalone_init(has_driver_capability, deploy_managed) {
738 let json = materialized_cfg_json
739 .expect("start config materialization always returns a JSON object");
740 let normalized = normalize_atlas_endpoint(&endpoint);
741 let mut atlas = AtlasClient::connect(&normalized).await.with_context(|| {
742 format!("connect Atlas at {normalized} before standalone spawn")
743 })?;
744 let before_snapshot = snapshot_provider_ids(&mut atlas)
745 .await
746 .context("standalone pre-spawn Atlas snapshot")?;
747 Some((
748 atlas,
749 before_snapshot,
750 json,
751 expected_driver_contract.clone(),
752 allow_shared_driver_upgrade,
753 ))
754 } else {
755 None
756 };
757
758 let instance_name =
765 std::env::var("RBNX_INSTANCE_NAME").unwrap_or_else(|_| manifest.package.name.clone());
766 let result = if let Some((mut atlas, before, json, expected_contract, allow_shared_upgrade)) =
767 standalone_lifecycle
768 {
769 let manager_for_start = Arc::clone(&process_manager);
773 let package_root_for_start = package_root.clone();
774 let start_command_for_start = start_command.clone();
775 let instance_for_start = instance_name.clone();
776 let mut process_task = tokio::spawn(async move {
777 manager_for_start
778 .start_process(
779 &instance_for_start,
780 &instance_for_start,
781 "package",
782 &package_root_for_start,
783 &start_command_for_start,
784 )
785 .await
786 });
787
788 let recorded_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
791 while !process_manager.has_process_record(&instance_name, "package") {
792 if process_task.is_finished() {
793 let process_result = process_task.await.context("package process task failed")?;
794 return match process_result {
795 Ok(result) => anyhow::bail!(
796 "package exited before registering with Atlas (PID {})",
797 result.pid
798 ),
799 Err(error) => Err(error),
800 };
801 }
802 if tokio::time::Instant::now() >= recorded_deadline {
803 process_task.abort();
804 anyhow::bail!("package process was not recorded within 5s after spawn");
805 }
806 tokio::time::sleep(Duration::from_millis(25)).await;
807 }
808
809 let lifecycle = drive_standalone_lifecycle(
810 &mut atlas,
811 &before,
812 &instance_name,
813 &expected_contract,
814 allow_shared_upgrade,
815 json,
816 );
817 tokio::pin!(lifecycle);
818 tokio::select! {
819 lifecycle_result = &mut lifecycle => {
820 if let Err(error) = lifecycle_result {
821 output::warning(&format!("standalone lifecycle failed: {error:#}"));
822 if let Err(stop_error) = process_manager.stop_process(&instance_name, "package").await {
823 output::warning(&format!("failed to stop package after lifecycle error: {stop_error:#}"));
824 }
825 let _ = process_task.await;
826 return Err(error);
827 }
828 }
829 process_result = &mut process_task => {
830 let process_result = process_result.context("package process task failed")?;
831 return match process_result {
832 Ok(result) => anyhow::bail!(
833 "package exited before completing standalone lifecycle (PID {})",
834 result.pid
835 ),
836 Err(error) => Err(error),
837 };
838 }
839 }
840 process_task
841 .await
842 .context("package process task failed")??
843 } else {
844 process_manager
845 .start_process(
846 &instance_name,
847 &instance_name,
848 "package",
849 &package_root,
850 &start_command,
851 )
852 .await?
853 };
854 output::check(&format!(
855 "{} exited (PID {})",
856 manifest.package.name, result.pid
857 ));
858
859 output::success(&format!("Package {} finished", manifest.package.name));
860 Ok(())
861}
862
863fn generated_pythonpath_export(package_root: &Path) -> String {
868 let codegen_root = package_root.join("rbnx-build").join("codegen");
869 let mut paths = vec![package_root.to_path_buf()];
870 for path in [
871 codegen_root.join("proto_gen"),
872 codegen_root.join("robonix_mcp_types"),
873 ] {
874 if path.is_dir() {
875 paths.push(path);
876 }
877 }
878 let joined = paths
879 .iter()
880 .map(|path| path.display().to_string())
881 .collect::<Vec<_>>()
882 .join(":");
883 format!(
884 "export PYTHONPATH={}:${{PYTHONPATH:-}}",
885 shell_escape(&joined)
886 )
887}
888
889fn should_drive_standalone_init(has_driver_capability: bool, deploy_managed: bool) -> bool {
890 has_driver_capability && !deploy_managed
891}
892
893fn normalize_atlas_endpoint(endpoint: &str) -> String {
894 if endpoint.starts_with("http") {
895 endpoint.to_string()
896 } else {
897 format!("http://{endpoint}")
898 }
899}
900
901async fn drive_standalone_lifecycle(
909 atlas: &mut AtlasClient,
910 before: &ProviderRegistrationSnapshot,
911 expected_provider_id: &str,
912 expected_driver_contract: &str,
913 allow_shared_driver_upgrade: bool,
914 config_json: String,
915) -> Result<()> {
916 let outcome =
917 wait_for_registration_core(atlas, before, expected_provider_id, "rbnx start").await?;
918 let driver_contract = resolve_runtime_driver_contract(
919 &outcome.provider_id,
920 &outcome.provider_namespace,
921 expected_driver_contract,
922 &outcome.driver_contracts,
923 allow_shared_driver_upgrade,
924 )?;
925 if driver_contract != expected_driver_contract {
926 output::warning(&format!(
927 "provider '{}' publishes shared lifecycle Driver '{}' for legacy manifest selection '{}'; remove the legacy Driver declaration to finish migration",
928 outcome.provider_id, driver_contract, expected_driver_contract,
929 ));
930 }
931
932 let init_state = call_driver_cmd(
933 atlas,
934 &outcome.provider_id,
935 &driver_contract,
936 CMD_INIT,
937 config_json.clone(),
938 "rbnx start",
939 )
940 .await?;
941 output::sub_step(&format!(
942 "Driver(CMD_INIT) → {} ok (state={init_state})",
943 outcome.provider_id
944 ));
945 if should_activate_standalone_provider(outcome.provider_kind) {
946 let state = call_driver_cmd(
947 atlas,
948 &outcome.provider_id,
949 &driver_contract,
950 CMD_ACTIVATE,
951 config_json,
952 "rbnx start",
953 )
954 .await?;
955 output::sub_step(&format!(
956 "Driver(CMD_ACTIVATE) → {} ok (state={state})",
957 outcome.provider_id
958 ));
959 }
960 Ok(())
961}
962
963fn should_activate_standalone_provider(provider_kind: i32) -> bool {
964 provider_kind != atlas_pb::Kind::Skill as i32
965}
966
967fn build_start_config_json(config_file: Option<&Path>, sets: &[String]) -> Result<Option<String>> {
978 if config_file.is_none() && sets.is_empty() {
979 return Ok(Some("{}".to_string()));
980 }
981
982 let mut value: serde_json::Value = match config_file {
983 Some(p) => {
984 let raw = std::fs::read_to_string(p)
985 .with_context(|| format!("read config file {}", p.display()))?;
986 match serde_json::from_str::<serde_json::Value>(&raw) {
988 Ok(v) => v,
989 Err(_) => {
990 let y: serde_yaml::Value = serde_yaml::from_str(&raw)
991 .with_context(|| format!("parse config {} as JSON or YAML", p.display()))?;
992 serde_json::to_value(y)
993 .with_context(|| format!("convert {} YAML→JSON", p.display()))?
994 }
995 }
996 }
997 None => serde_json::Value::Object(serde_json::Map::new()),
998 };
999
1000 for s in sets {
1001 let (key, raw_val) = s
1002 .split_once('=')
1003 .with_context(|| format!("--set {s:?}: expected KEY=VALUE"))?;
1004 let parsed: serde_json::Value = serde_json::from_str(raw_val)
1005 .unwrap_or_else(|_| serde_json::Value::String(raw_val.into()));
1006 merge_dotted(&mut value, key, parsed)?;
1007 }
1008
1009 Ok(Some(
1010 serde_json::to_string(&value).unwrap_or_else(|_| "{}".into()),
1011 ))
1012}
1013
1014fn merge_dotted(root: &mut serde_json::Value, key: &str, v: serde_json::Value) -> Result<()> {
1017 let parts: Vec<&str> = key.split('.').filter(|p| !p.is_empty()).collect();
1018 if parts.is_empty() {
1019 anyhow::bail!("--set: empty key");
1020 }
1021 if !root.is_object() {
1022 *root = serde_json::Value::Object(serde_json::Map::new());
1023 }
1024 let mut cur = root;
1025 for p in &parts[..parts.len() - 1] {
1026 let map = cur.as_object_mut().ok_or_else(|| {
1027 anyhow::anyhow!("--set {key}: cannot descend into non-object at '{p}'")
1028 })?;
1029 let entry = map
1030 .entry((*p).to_string())
1031 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1032 if !entry.is_object() {
1033 *entry = serde_json::Value::Object(serde_json::Map::new());
1034 }
1035 cur = entry;
1036 }
1037 let last = parts[parts.len() - 1];
1038 cur.as_object_mut()
1039 .ok_or_else(|| anyhow::anyhow!("--set {key}: parent is not an object"))?
1040 .insert(last.to_string(), v);
1041 Ok(())
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use std::fs;
1048 use std::time::{SystemTime, UNIX_EPOCH};
1049
1050 fn temp_root(label: &str) -> PathBuf {
1051 let nonce = SystemTime::now()
1052 .duration_since(UNIX_EPOCH)
1053 .unwrap()
1054 .as_nanos();
1055 std::env::temp_dir().join(format!("rbnx-start-{label}-{}-{nonce}", std::process::id()))
1056 }
1057
1058 #[test]
1059 fn generated_pythonpath_is_injected_without_a_setup_stub() {
1060 let root = temp_root("pythonpath");
1061 let proto = root.join("rbnx-build/codegen/proto_gen");
1062 let mcp = root.join("rbnx-build/codegen/robonix_mcp_types");
1063 fs::create_dir_all(&proto).unwrap();
1064 fs::create_dir_all(&mcp).unwrap();
1065
1066 let export = generated_pythonpath_export(&root);
1067
1068 assert!(export.contains(&root.display().to_string()));
1069 assert!(export.contains(&proto.display().to_string()));
1070 assert!(export.contains(&mcp.display().to_string()));
1071 assert!(export.ends_with(":${PYTHONPATH:-}"));
1072 assert!(!root.join("rbnx-build/ws/install/setup.bash").exists());
1073 fs::remove_dir_all(root).unwrap();
1074 }
1075
1076 #[test]
1077 fn start_without_config_still_materializes_empty_init_config() {
1078 let config = build_start_config_json(None, &[]).unwrap();
1079 assert_eq!(config.as_deref(), Some("{}"));
1080 }
1081
1082 #[test]
1083 fn standalone_driver_start_owns_init_but_deploy_managed_start_does_not() {
1084 assert!(should_drive_standalone_init(true, false));
1085 assert!(!should_drive_standalone_init(true, true));
1086 assert!(!should_drive_standalone_init(false, false));
1087 assert!(!should_drive_standalone_init(false, true));
1088 }
1089
1090 #[test]
1091 fn standalone_start_activates_primitive_and_service_but_not_skill() {
1092 assert!(should_activate_standalone_provider(
1093 atlas_pb::Kind::Primitive as i32
1094 ));
1095 assert!(should_activate_standalone_provider(
1096 atlas_pb::Kind::Service as i32
1097 ));
1098 assert!(!should_activate_standalone_provider(
1099 atlas_pb::Kind::Skill as i32
1100 ));
1101 }
1102
1103 #[test]
1104 fn shared_driver_contract_needs_no_migration_warning() {
1105 assert_eq!(
1106 lifecycle_driver_migration_warning("test.scene", Some("robonix/lifecycle/driver"),),
1107 None
1108 );
1109 }
1110
1111 #[test]
1112 fn omitted_driver_is_the_canonical_shared_selection() {
1113 assert_eq!(lifecycle_driver_migration_warning("test.scene", None), None);
1114 }
1115
1116 #[test]
1117 fn legacy_driver_contract_gets_one_actionable_migration_warning() {
1118 let warning = lifecycle_driver_migration_warning(
1119 "test.camera",
1120 Some("robonix/primitive/camera/driver"),
1121 )
1122 .unwrap();
1123 assert!(warning.contains("test.camera"));
1124 assert!(warning.contains("robonix/primitive/camera/driver"));
1125 assert!(warning.contains("robonix/lifecycle/driver"));
1126 assert!(warning.contains("is backward-compatible"));
1127 assert!(warning.contains("exact <provider namespace>/driver"));
1128 assert!(warning.contains("shared runtime Driver"));
1129 assert!(warning.contains("do not declare both"));
1130 }
1131
1132 #[test]
1133 fn system_package_uses_the_deploy_selected_manifest() {
1134 let root = temp_root("system-manifest");
1135 let source_root = root.join("source");
1136 let scene_root = source_root.join("system/scene");
1137 let deploy_root = root.join("deploy");
1138 fs::create_dir_all(&scene_root).unwrap();
1139 fs::create_dir_all(&deploy_root).unwrap();
1140 fs::write(
1141 scene_root.join("package_manifest.yaml"),
1142 r#"manifestVersion: 1
1143package:
1144 name: test.system.scene
1145 version: 0.1.0
1146 vendor: test
1147 description: default target
1148 license: Apache-2.0
1149build: touch default-selected
1150start: "true"
1151"#,
1152 )
1153 .unwrap();
1154 fs::write(
1155 scene_root.join("package_manifest.jetson-native.yaml"),
1156 r#"manifestVersion: 1
1157package:
1158 name: test.system.scene
1159 version: 0.1.0
1160 vendor: test
1161 description: Jetson native target
1162 license: Apache-2.0
1163build: touch jetson-selected
1164start: "true"
1165"#,
1166 )
1167 .unwrap();
1168 let deploy_manifest = deploy_root.join("robonix_manifest.yaml");
1169 fs::write(
1170 &deploy_manifest,
1171 r#"manifestVersion: 1
1172name: system-target-test
1173system:
1174 scene:
1175 manifest: package_manifest.jetson-native.yaml
1176 config:
1177 camera_provider_id: front_camera
1178"#,
1179 )
1180 .unwrap();
1181 let config = Config {
1182 package_storage_path: root.join("packages"),
1183 robonix_source_path: Some(source_root),
1184 };
1185
1186 build_deploy_manifest(&deploy_manifest, &config, false, true).unwrap();
1187
1188 assert!(scene_root.join("jetson-selected").is_file());
1189 assert!(!scene_root.join("default-selected").exists());
1190 fs::remove_dir_all(root).unwrap();
1191 }
1192}