1use crate::commands::runtime_state::{
4 RuntimeRecord, RuntimeStateStore, absolutize_from_cwd, log_path_for_wid, resolve_hyper_dir,
5};
6use crate::core::{Command, CommandContext, CommandResult, ComponentType};
7use crate::error::{ActrCliError, Result};
8use async_trait::async_trait;
9use chrono::Utc;
10use clap::Args;
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command as StdCommand, Stdio};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15use tracing::info;
16
17const DETACHED_READY_TIMEOUT: Duration = Duration::from_secs(10);
18const DETACHED_READY_POLL_INTERVAL: Duration = Duration::from_millis(100);
19
20const DEFAULT_RUNTIME_CONFIG: &str = "actr.toml";
22
23fn resolve_against(base: &Path, path: &Path) -> PathBuf {
25 if path.is_absolute() {
26 path.to_path_buf()
27 } else {
28 base.join(path)
29 }
30}
31
32#[derive(Args)]
33pub struct RunCommand {
34 #[arg(short = 'c', long = "config", value_name = "FILE")]
36 pub config: Option<PathBuf>,
37
38 #[arg(long = "hyper-dir", value_name = "DIR")]
40 pub hyper_dir: Option<PathBuf>,
41
42 #[arg(short = 'd', long = "detach")]
44 pub detach: bool,
45
46 #[arg(long = "internal-detached-child", hide = true)]
48 pub internal_detached_child: bool,
49
50 #[arg(long = "internal-wid", hide = true)]
52 pub internal_wid: Option<String>,
53 #[arg(long = "web")]
55 pub web: bool,
56
57 #[arg(long = "port", requires = "web")]
59 pub port: Option<u16>,
60}
61
62#[async_trait]
63impl Command for RunCommand {
64 async fn execute(&self, _ctx: &CommandContext) -> anyhow::Result<CommandResult> {
65 if self.web {
67 self.execute_web_mode().await?;
68 } else {
69 self.execute_package_mode().await?;
70 }
71 Ok(CommandResult::Success(String::new()))
72 }
73
74 fn required_components(&self) -> Vec<ComponentType> {
75 vec![]
76 }
77
78 fn name(&self) -> &str {
79 "run"
80 }
81
82 fn description(&self) -> &str {
83 "Run a packaged workload"
84 }
85}
86
87impl RunCommand {
88 async fn execute_package_mode(&self) -> Result<()> {
89 use actr_hyper::{WorkloadPackage, init_observability};
90
91 info!("๐ Starting packaged workload");
92
93 let config_path = self
95 .config
96 .clone()
97 .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_CONFIG));
98
99 if !config_path.exists() {
101 return Err(ActrCliError::command_error(format!(
102 "Runtime config file not found: {}\n\n\
103 Create a runtime config file or specify one with -c/--config.",
104 config_path.display()
105 )));
106 }
107
108 let config_path = absolutize_from_cwd(&config_path)?;
109 let hyper_dir = resolve_hyper_dir(Some(&config_path), self.hyper_dir.as_deref())?;
110
111 if self.detach && !self.internal_detached_child {
112 return self.spawn_detached_child(&config_path).await;
113 }
114
115 let detached_runtime = if self.internal_detached_child {
116 Some(self.prepare_detached_child(&config_path).await?)
117 } else {
118 None
119 };
120
121 let package_path = self.resolve_package_path(&config_path).await?;
123 info!("๐ฆ Loading package: {}", package_path.display());
124
125 let package_bytes = tokio::fs::read(&package_path).await.map_err(|e| {
127 ActrCliError::command_error(format!("Failed to read package file: {}", e))
128 })?;
129 let package = WorkloadPackage::new(package_bytes.clone());
130
131 let manifest = actr_pack::read_manifest(&package_bytes).map_err(|e| {
133 ActrCliError::command_error(format!("Failed to parse package manifest: {}", e))
134 })?;
135 let package_info = self.build_package_info(&manifest);
136
137 let config =
139 actr_config::ConfigParser::from_runtime_file(&config_path, package_info.clone())?;
140
141 info!("๐ก Signaling server: {}", config.signaling_url.as_str());
142 info!("๐ Trust anchors: {} configured", config.trust.len());
143
144 let _obs_guard = init_observability(&config.observability).map_err(|e| {
146 ActrCliError::command_error(format!("Failed to initialize observability: {}", e))
147 })?;
148
149 let hyper = self.init_hyper(&config, &package_path, &hyper_dir).await?;
151 info!("โ
Hyper initialized");
152
153 let ais_endpoint = config.ais_endpoint.clone();
155 let attached = actr_hyper::Node::from_hyper(hyper, config.clone())
156 .attach(&package)
157 .await
158 .map_err(|e| ActrCliError::command_error(format!("Failed to attach package: {}", e)))?;
159 info!("โ
Package attached");
160
161 let registered = attached.register(&ais_endpoint).await.map_err(|e| {
162 ActrCliError::command_error(format!(
163 "Failed to register with AIS at {}.\n\n\
164 Possible causes:\n\
165 - AIS server is not running\n\
166 - Incorrect [ais_endpoint] url in the runtime config\n\
167 - Network connectivity issues\n\n\
168 Error: {}",
169 ais_endpoint, e
170 ))
171 })?;
172 info!("โ
AIS registration successful");
173
174 let actr_ref = registered
175 .start()
176 .await
177 .map_err(|e| ActrCliError::command_error(format!("Failed to start ActrNode: {}", e)))?;
178 info!("โ
ActrNode started");
179
180 if let Some(runtime) = detached_runtime.as_ref() {
181 self.write_runtime_record(runtime, &actr_ref).await?;
182 info!("๐ Detached runtime state recorded");
183 }
184
185 self.run_foreground(actr_ref, detached_runtime.as_ref())
186 .await?;
187
188 Ok(())
189 }
190
191 async fn run_foreground(
192 &self,
193 actr_ref: actr_hyper::ActrRef,
194 detached_runtime: Option<&DetachedRuntimeContext>,
195 ) -> Result<()> {
196 info!("๐ก Running in foreground mode (Ctrl+C to stop)");
197
198 actr_ref
200 .wait_for_ctrl_c_and_shutdown()
201 .await
202 .map_err(|e| ActrCliError::command_error(format!("Shutdown error: {}", e)))?;
203
204 if let Some(runtime) = detached_runtime {
205 runtime
206 .runtime_store
207 .mark_stopped_by_wid(&runtime.wid, Utc::now())
208 .await?;
209 }
210
211 info!("๐ Shutdown complete");
212 Ok(())
213 }
214
215 async fn resolve_package_path(&self, config_path: &Path) -> Result<PathBuf> {
216 let config_content = tokio::fs::read_to_string(config_path).await?;
218 let raw_config: actr_config::RuntimeRawConfig = toml::from_str(&config_content)
219 .map_err(|e| ActrCliError::command_error(format!("Failed to parse config: {}", e)))?;
220
221 if let Some(package_config) = raw_config.package {
222 if let Some(path) = package_config.path {
223 let base = config_path.parent().unwrap_or(Path::new("."));
224 return Ok(resolve_against(base, &path));
225 }
226 }
227
228 Err(ActrCliError::command_error(format!(
229 "Package path not specified in runtime config: {}\n\n\
230 Add the packaged workload path to your config:\n\
231 [package]\n\
232 path = \"dist/service.actr\"",
233 config_path.display()
234 )))
235 }
236
237 fn build_package_info(
238 &self,
239 manifest: &actr_pack::PackageManifest,
240 ) -> actr_config::PackageInfo {
241 actr_config::PackageInfo {
242 name: manifest.name.clone(),
243 actr_type: actr_protocol::ActrType {
244 manufacturer: manifest.manufacturer.clone(),
245 name: manifest.name.clone(),
246 version: manifest.version.clone(),
247 },
248 description: manifest.metadata.description.clone(),
249 authors: vec![],
250 license: manifest.metadata.license.clone(),
251 }
252 }
253
254 async fn init_hyper(
255 &self,
256 config: &actr_config::RuntimeConfig,
257 package_path: &Path,
258 hyper_dir: &Path,
259 ) -> Result<actr_hyper::Hyper> {
260 use actr_hyper::{
261 ChainTrust, Hyper, HyperConfig, RegistryTrust, StaticTrust, TrustProvider,
262 };
263 use std::sync::Arc;
264
265 if config.trust.is_empty() {
266 let public_key = self.load_public_key(package_path).await?;
270 let trust: Arc<dyn TrustProvider> =
271 Arc::new(StaticTrust::new(public_key).map_err(|e| {
272 ActrCliError::command_error(format!("Invalid public key: {}", e))
273 })?);
274 return Hyper::new(HyperConfig::new(hyper_dir, trust))
275 .await
276 .map_err(|e| {
277 ActrCliError::command_error(format!("Failed to initialize Hyper: {}", e))
278 });
279 }
280
281 let mut providers: Vec<Arc<dyn TrustProvider>> = Vec::with_capacity(config.trust.len());
282 for anchor in &config.trust {
283 let p: Arc<dyn TrustProvider> = match anchor {
284 actr_config::TrustAnchor::Static {
285 pubkey_file,
286 pubkey_b64,
287 } => {
288 let key_bytes = self.load_static_pubkey(pubkey_file, pubkey_b64).await?;
289 Arc::new(StaticTrust::new(key_bytes).map_err(|e| {
290 ActrCliError::command_error(format!("Invalid static pubkey: {}", e))
291 })?)
292 }
293 actr_config::TrustAnchor::Registry { endpoint } => {
294 let base = endpoint.trim_end_matches("/ais").to_string();
295 Arc::new(RegistryTrust::new(base))
296 }
297 };
298 providers.push(p);
299 }
300
301 let trust: Arc<dyn TrustProvider> = if providers.len() == 1 {
302 providers.into_iter().next().unwrap()
303 } else {
304 Arc::new(ChainTrust::new(providers))
305 };
306
307 Hyper::new(HyperConfig::new(hyper_dir, trust))
308 .await
309 .map_err(|e| ActrCliError::command_error(format!("Failed to initialize Hyper: {}", e)))
310 }
311
312 async fn load_static_pubkey(
313 &self,
314 pubkey_file: &Option<PathBuf>,
315 pubkey_b64: &Option<String>,
316 ) -> Result<Vec<u8>> {
317 use base64::Engine;
318 if let Some(b64) = pubkey_b64 {
319 let bytes = base64::engine::general_purpose::STANDARD
320 .decode(b64)
321 .map_err(|e| {
322 ActrCliError::command_error(format!("Invalid base64 pubkey: {}", e))
323 })?;
324 if bytes.len() != 32 {
325 return Err(ActrCliError::command_error(format!(
326 "pubkey_b64 must decode to 32 bytes, got {}",
327 bytes.len()
328 )));
329 }
330 return Ok(bytes);
331 }
332 let path = pubkey_file.as_deref().ok_or_else(|| {
333 ActrCliError::command_error(
334 "Static trust anchor requires either `pubkey_file` or `pubkey_b64`".to_string(),
335 )
336 })?;
337 parse_pubkey_json(path).await
338 }
339}
340
341async fn parse_pubkey_json(path: &Path) -> Result<Vec<u8>> {
342 if !path.exists() {
343 return Err(ActrCliError::command_error(format!(
344 "pubkey_file not found: {}",
345 path.display()
346 )));
347 }
348 let content = tokio::fs::read_to_string(path).await?;
349 let json: serde_json::Value = serde_json::from_str(&content)?;
350 let b64 = json["public_key"].as_str().ok_or_else(|| {
351 ActrCliError::command_error(format!("{}: missing `public_key` field", path.display()))
352 })?;
353 use base64::Engine;
354 let bytes = base64::engine::general_purpose::STANDARD
355 .decode(b64)
356 .map_err(|e| ActrCliError::command_error(format!("Invalid base64 pubkey: {}", e)))?;
357 if bytes.len() != 32 {
358 return Err(ActrCliError::command_error(format!(
359 "{}: public_key must decode to 32 bytes, got {}",
360 path.display(),
361 bytes.len()
362 )));
363 }
364 Ok(bytes)
365}
366
367impl RunCommand {
368 async fn load_public_key(&self, package_path: &Path) -> Result<Vec<u8>> {
369 let package_dir = package_path.parent().unwrap_or(Path::new("."));
370 let key_path = package_dir.join("public-key.json");
371
372 if !key_path.exists() {
373 return Err(ActrCliError::command_error(format!(
374 "Public key not found for static trust anchor.\n\n\
375 Expected location: {}\n\n\
376 Either place public-key.json next to the .actr package, or\n\
377 configure explicit trust anchors in actr.toml:\n\n\
378 [[trust]]\n\
379 kind = \"static\"\n\
380 pubkey_file = \"public-key.json\"\n\n\
381 # or\n\
382 [[trust]]\n\
383 kind = \"registry\"\n\
384 endpoint = \"http://localhost:8081/ais\"",
385 key_path.display()
386 )));
387 }
388
389 let key_content = tokio::fs::read_to_string(&key_path).await?;
390 let key_json: serde_json::Value = serde_json::from_str(&key_content)?;
391
392 let key_base64 = key_json["public_key"].as_str().ok_or_else(|| {
393 ActrCliError::command_error(
394 "Invalid public-key.json format: missing 'public_key' field".to_string(),
395 )
396 })?;
397
398 use base64::Engine;
399 let key_bytes = base64::engine::general_purpose::STANDARD
400 .decode(key_base64)
401 .map_err(|e| {
402 ActrCliError::command_error(format!("Invalid base64 in public key: {}", e))
403 })?;
404
405 if key_bytes.len() != 32 {
406 return Err(ActrCliError::command_error(format!(
407 "Invalid public key size: expected 32 bytes, got {}",
408 key_bytes.len()
409 )));
410 }
411
412 Ok(key_bytes)
413 }
414
415 #[cfg(unix)]
416 async fn prepare_detached_child(&self, config_path: &Path) -> Result<DetachedRuntimeContext> {
417 use nix::unistd::setsid;
418 use std::fs::OpenOptions;
419 use std::os::unix::io::AsRawFd;
420
421 let wid = self.internal_wid.clone().ok_or_else(|| {
422 ActrCliError::command_error("--internal-wid is required for detached child".to_string())
423 })?;
424
425 let hyper_dir = resolve_hyper_dir(Some(config_path), self.hyper_dir.as_deref())?;
426 let runtime_store = RuntimeStateStore::new(hyper_dir);
427 runtime_store.ensure_layout().await?;
428 setsid().map_err(|e| {
429 ActrCliError::command_error(format!("Failed to create new session: {}", e))
430 })?;
431
432 let pid = std::process::id();
433 let log_file = log_path_for_wid(runtime_store.hyper_dir(), &wid);
434 let log = OpenOptions::new()
435 .create(true)
436 .append(true)
437 .open(&log_file)?;
438
439 let log_fd = log.as_raw_fd();
440 nix::unistd::dup2(log_fd, std::io::stdout().as_raw_fd())
441 .map_err(|e| ActrCliError::command_error(format!("dup2 failed: {}", e)))?;
442 nix::unistd::dup2(log_fd, std::io::stderr().as_raw_fd())
443 .map_err(|e| ActrCliError::command_error(format!("dup2 failed: {}", e)))?;
444
445 info!("๐ Detached child process initialized, PID: {}", pid);
446 info!("๐ Log file: {}", log_file.display());
447
448 Ok(DetachedRuntimeContext {
449 runtime_store,
450 config_path: config_path.to_path_buf(),
451 log_file,
452 pid,
453 wid,
454 })
455 }
456
457 #[cfg(not(unix))]
458 async fn prepare_detached_child(&self, _config_path: &Path) -> Result<DetachedRuntimeContext> {
459 Err(ActrCliError::command_error(
460 "Detached mode is only supported on Unix systems".to_string(),
461 ))
462 }
463
464 async fn write_runtime_record(
465 &self,
466 detached_runtime: &DetachedRuntimeContext,
467 actr_ref: &actr_hyper::ActrRef,
468 ) -> Result<()> {
469 let actr_id_str = actr_protocol::ActrId::to_string_repr(actr_ref.actor_id());
470
471 let existing = detached_runtime
474 .runtime_store
475 .read_record_by_wid(&detached_runtime.wid)
476 .await?;
477
478 let record = if let Some(mut r) = existing {
479 r.pid = detached_runtime.pid;
480 r.started_at = Utc::now();
481 r.stopped_at = None;
482 r.config_path = detached_runtime.config_path.clone();
483 r.log_path = detached_runtime.log_file.clone();
484 r
485 } else {
486 RuntimeRecord::new(
487 detached_runtime.wid.clone(),
488 actr_id_str,
489 detached_runtime.pid,
490 detached_runtime.config_path.clone(),
491 detached_runtime.log_file.clone(),
492 Utc::now(),
493 )
494 };
495
496 detached_runtime.runtime_store.write_record(&record).await
497 }
498
499 async fn spawn_detached_child(&self, config_path: &Path) -> Result<()> {
500 #[cfg(unix)]
501 {
502 use uuid::Uuid;
503
504 let hyper_dir = resolve_hyper_dir(Some(config_path), self.hyper_dir.as_deref())?;
505 let runtime_store = RuntimeStateStore::new(hyper_dir);
506 runtime_store.ensure_layout().await?;
507
508 let wid = self
511 .internal_wid
512 .clone()
513 .unwrap_or_else(|| Uuid::new_v4().to_string());
514 let wid_short = short_wid(&wid).to_string();
515 let log_path = log_path_for_wid(runtime_store.hyper_dir(), &wid);
516
517 let current_exe = std::env::current_exe().map_err(|e| {
518 ActrCliError::command_error(format!(
519 "Failed to resolve current executable for detached mode: {}",
520 e
521 ))
522 })?;
523
524 let mut child = StdCommand::new(current_exe);
525 child
526 .arg("run")
527 .arg("--config")
528 .arg(config_path)
529 .args(
530 self.hyper_dir
531 .as_ref()
532 .map(|path| vec!["--hyper-dir".into(), path.display().to_string()])
533 .unwrap_or_default(),
534 )
535 .arg("--internal-detached-child")
536 .arg("--internal-wid")
537 .arg(&wid)
538 .stdin(Stdio::null())
539 .stdout(Stdio::null())
540 .stderr(Stdio::null());
541
542 let mut child = child.spawn().map_err(|e| {
543 ActrCliError::command_error(format!(
544 "Failed to launch detached child process: {}",
545 e
546 ))
547 })?;
548
549 let pid = child.id();
550 match wait_for_detached_runtime_ready(
551 &runtime_store,
552 &wid,
553 &log_path,
554 &mut child,
555 DETACHED_READY_TIMEOUT,
556 DETACHED_READY_POLL_INTERVAL,
557 )
558 .await?
559 {
560 DetachedRuntimeStartup::Ready => {
561 println!("Detached runtime started");
562 println!(" WID: {}", wid_short);
563 println!(" PID: {}", pid);
564 println!();
565 println!("Follow logs: actr logs {} -f", wid_short);
566 }
567 DetachedRuntimeStartup::Initializing => {
568 println!("Detached runtime launched but is still initializing");
569 println!(" WID: {}", wid_short);
570 println!(" PID: {}", pid);
571 println!(" Logs: {}", log_path.display());
572 println!();
573 println!(
574 "Wait for the runtime record to be written before using `actr logs {} -f`.",
575 wid_short
576 );
577 }
578 }
579 Ok(())
580 }
581
582 #[cfg(not(unix))]
583 {
584 let _ = config_path;
585 Err(ActrCliError::command_error(
586 "Detached mode is only supported on Unix systems".to_string(),
587 ))
588 }
589 }
590
591 async fn execute_web_mode(&self) -> Result<()> {
600 use axum::Router;
601 use axum::routing::get;
602 use tower_http::cors::CorsLayer;
603 use tower_http::services::ServeDir;
604
605 info!("๐ Starting web server mode");
606
607 let config_path = self
609 .config
610 .clone()
611 .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_CONFIG));
612
613 if !config_path.exists() {
614 return Err(ActrCliError::command_error(format!(
615 "Configuration file not found: {}\n\n\
616 Please create an actr.toml file with [web] section or specify with -c/--config",
617 config_path.display()
618 )));
619 }
620
621 let config_content = tokio::fs::read_to_string(&config_path).await?;
623 let raw_config: actr_config::RuntimeRawConfig = toml::from_str(&config_content)
624 .map_err(|e| ActrCliError::command_error(format!("Failed to parse config: {}", e)))?;
625
626 let config_dir = config_path.parent().unwrap_or(Path::new(".")).to_path_buf();
627
628 let web_port = self
630 .port
631 .unwrap_or_else(|| raw_config.web.as_ref().map(|w| w.port).unwrap_or(8080));
632 let web_host = raw_config
633 .web
634 .as_ref()
635 .map(|w| w.host.clone())
636 .unwrap_or_else(|| "0.0.0.0".to_string());
637 let static_dir = raw_config
638 .web
639 .as_ref()
640 .map(|w| config_dir.join(&w.static_dir))
641 .unwrap_or_else(|| config_dir.join("public"));
642
643 let package_path = raw_config
645 .package
646 .as_ref()
647 .and_then(|p| p.path.as_ref())
648 .map(|p| resolve_against(&config_dir, p));
649
650 let package_bytes = if let Some(ref pkg_path) = package_path {
652 if pkg_path.exists() {
653 Some(tokio::fs::read(pkg_path).await.map_err(|e| {
654 ActrCliError::command_error(format!(
655 "Failed to read package file {}: {}",
656 pkg_path.display(),
657 e
658 ))
659 })?)
660 } else {
661 info!(
662 "โ ๏ธ Package file not found: {}, /packages/*.actr will not be served",
663 pkg_path.display()
664 );
665 None
666 }
667 } else {
668 None
669 };
670
671 let package_filename = package_path
673 .as_ref()
674 .and_then(|p| p.file_name())
675 .map(|f| f.to_string_lossy().to_string())
676 .unwrap_or_else(|| "package.actr".to_string());
677
678 let wbg_dir = package_path.as_ref().and_then(|pkg_path| {
684 let stem = pkg_path.file_stem().map(|s| s.to_os_string())?;
685 let mut wbg = pkg_path.with_file_name(stem);
686 wbg.as_mut_os_string().push(".wbg");
687 if wbg.is_dir() { Some(wbg) } else { None }
688 });
689 let wbg_route_prefix = if wbg_dir.is_some() {
690 let stem = package_path
691 .as_ref()
692 .and_then(|p| p.file_stem())
693 .map(|s| s.to_string_lossy().to_string())
694 .unwrap_or_else(|| "package".to_string());
695 Some(format!("/packages/{}.wbg", stem))
696 } else {
697 None
698 };
699
700 let runtime_config_json =
702 self.build_web_runtime_config(&raw_config, &config_path, &package_filename)?;
703
704 let shared_state = Arc::new(WebServerState {
705 runtime_config_json,
706 package_bytes,
707 package_filename,
708 });
709
710 let mut app = Router::new()
721 .route("/actr-runtime-config.json", get(serve_runtime_config))
722 .route("/actor.sw.js", get(serve_actor_sw_js))
723 .route("/packages/actr_sw_host_bg.wasm", get(serve_runtime_wasm))
724 .route("/packages/actr_sw_host.js", get(serve_runtime_js))
725 .route("/packages/{filename}", get(serve_actr_package))
726 .with_state(shared_state.clone());
727
728 if let (Some(wbg_dir), Some(prefix)) = (wbg_dir.as_ref(), wbg_route_prefix.as_ref()) {
729 info!(
730 "๐ฆ Mounting wasm-bindgen guest bundle at {} -> {}",
731 prefix,
732 wbg_dir.display()
733 );
734 app = app.nest_service(prefix, ServeDir::new(wbg_dir));
735 }
736
737 let app = app
738 .fallback_service(if static_dir.exists() {
739 ServeDir::new(&static_dir)
740 } else {
741 ServeDir::new(&config_dir)
744 })
745 .route("/", get(serve_host_html))
746 .with_state(shared_state)
747 .layer(CorsLayer::permissive())
748 .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
749 axum::http::header::HeaderName::from_static("cross-origin-opener-policy"),
750 axum::http::header::HeaderValue::from_static("same-origin"),
751 ))
752 .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
753 axum::http::header::HeaderName::from_static("cross-origin-embedder-policy"),
754 axum::http::header::HeaderValue::from_static("require-corp"),
755 ));
756
757 let addr: std::net::SocketAddr = format!("{}:{}", web_host, web_port)
758 .parse()
759 .map_err(|e| ActrCliError::command_error(format!("Invalid bind address: {}", e)))?;
760
761 println!("๐ Web server started");
762 println!(" URL: http://{}:{}", web_host, web_port);
763 if static_dir.exists() {
764 println!(" Static dir: {}", static_dir.display());
765 }
766 println!(" Config: {}", config_path.display());
767 if let Some(ref pkg_path) = package_path {
768 println!(" Package: {}", pkg_path.display());
769 }
770 println!(" Press Ctrl+C to stop");
771
772 let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
773 ActrCliError::command_error(format!("Failed to bind to {}: {}", addr, e))
774 })?;
775
776 axum::serve(listener, app)
777 .with_graceful_shutdown(shutdown_signal())
778 .await
779 .map_err(|e| ActrCliError::command_error(format!("Web server error: {}", e)))?;
780
781 println!("๐ Web server stopped");
782 Ok(())
783 }
784
785 fn build_web_runtime_config(
786 &self,
787 raw: &actr_config::RuntimeRawConfig,
788 config_path: &Path,
789 package_filename: &str,
790 ) -> Result<String> {
791 let signaling_url = raw
792 .signaling
793 .url
794 .clone()
795 .unwrap_or_else(|| "ws://localhost:8081/signaling/ws".to_string());
796 let ais_endpoint = raw
797 .ais_endpoint
798 .url
799 .clone()
800 .unwrap_or_else(|| "http://localhost:8081/ais".to_string());
801 let realm_id = raw.deployment.realm_id.unwrap_or(0);
802 let visible = raw.discovery.visible.unwrap_or(true);
803 let force_relay = raw.webrtc.force_relay;
804 let stun_urls = &raw.webrtc.stun_urls;
805 let turn_urls = &raw.webrtc.turn_urls;
806
807 let config_dir = config_path.parent().unwrap_or(Path::new("."));
809 let package_path = raw
810 .package
811 .as_ref()
812 .and_then(|p| p.path.as_ref())
813 .map(|p| resolve_against(config_dir, p));
814
815 let mut package_name = String::new();
817 let mut manufacturer = String::new();
818 let mut actr_name = String::new();
819 let mut version = String::new();
820 let mut acl_rules: Vec<serde_json::Value> = Vec::new();
821
822 if let Some(ref pkg_path) = package_path {
823 if pkg_path.exists() {
824 if let Ok(bytes) = std::fs::read(pkg_path) {
825 if let Ok(manifest) = actr_pack::read_manifest(&bytes) {
826 package_name.clone_from(&manifest.name);
827 manufacturer.clone_from(&manifest.manufacturer);
828 actr_name.clone_from(&manifest.name);
829 version.clone_from(&manifest.version);
830 }
831 }
832 }
833 }
834
835 if let Some(ref acl_value) = raw.acl {
837 if let Some(rules) = acl_value.get("rules").and_then(|v| v.as_array()) {
838 for rule in rules {
839 if let Some(table) = rule.as_table() {
840 let permission = table
841 .get("permission")
842 .and_then(|v| v.as_str())
843 .unwrap_or("allow");
844 let type_str = table.get("type").and_then(|v| v.as_str()).unwrap_or("");
845 acl_rules.push(serde_json::json!({
846 "permission": permission,
847 "type": type_str
848 }));
849 }
850 }
851 }
852 }
853
854 let acl_allow_types: Vec<&str> = acl_rules
855 .iter()
856 .filter_map(|r| {
857 if r.get("permission").and_then(|v| v.as_str()) == Some("allow") {
858 r.get("type").and_then(|v| v.as_str())
859 } else {
860 None
861 }
862 })
863 .collect();
864
865 let full_type = format!("{}:{}:{}", manufacturer, actr_name, version);
866
867 let web = raw.web.as_ref();
869 let package_url = web
872 .and_then(|w| w.package_url.clone())
873 .unwrap_or_else(|| format!("/packages/{}", package_filename));
874 let runtime_wasm_url = web
875 .and_then(|w| w.runtime_wasm_url.clone())
876 .unwrap_or_else(|| "/packages/actr_sw_host_bg.wasm".to_string());
877
878 let trust_json: Vec<serde_json::Value> = raw
885 .trust
886 .iter()
887 .map(serde_json::to_value)
888 .collect::<std::result::Result<_, _>>()
889 .map_err(|e| {
890 ActrCliError::command_error(format!("Failed to serialize [[trust]]: {}", e))
891 })?;
892
893 let config_json = serde_json::json!({
894 "signaling_url": signaling_url,
895 "ais_endpoint": ais_endpoint,
896 "realm_id": realm_id,
897 "visible": visible,
898 "force_relay": force_relay,
899 "stun_urls": stun_urls,
900 "turn_urls": turn_urls,
901 "package": {
902 "name": package_name,
903 "manufacturer": manufacturer,
904 "actr_name": actr_name,
905 "version": version,
906 "full_type": full_type,
907 },
908 "acl_allow_types": acl_allow_types,
909 "package_url": package_url,
910 "runtime_wasm_url": runtime_wasm_url,
911 "trust": trust_json,
912 });
913
914 serde_json::to_string_pretty(&config_json).map_err(|e| {
915 ActrCliError::command_error(format!("Failed to serialize runtime config: {}", e))
916 })
917 }
918}
919
920struct WebServerState {
921 runtime_config_json: String,
922 package_bytes: Option<Vec<u8>>,
923 package_filename: String,
924}
925
926async fn serve_runtime_config(
927 axum::extract::State(state): axum::extract::State<Arc<WebServerState>>,
928) -> impl axum::response::IntoResponse {
929 (
930 [(axum::http::header::CONTENT_TYPE, "application/json")],
931 state.runtime_config_json.clone(),
932 )
933}
934
935async fn serve_host_html(
937 axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
938) -> impl axum::response::IntoResponse {
939 (
940 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
941 crate::web_assets::HOST_HTML,
942 )
943}
944
945async fn serve_actor_sw_js(
952 axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
953) -> impl axum::response::IntoResponse {
954 (
955 [(
956 axum::http::header::CONTENT_TYPE,
957 "application/javascript; charset=utf-8",
958 )],
959 crate::web_assets::ACTOR_SW_JS,
960 )
961}
962
963async fn serve_runtime_wasm(
965 axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
966) -> impl axum::response::IntoResponse {
967 (
968 [(axum::http::header::CONTENT_TYPE, "application/wasm")],
969 crate::web_assets::RUNTIME_WASM,
970 )
971}
972
973async fn serve_runtime_js(
975 axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
976) -> impl axum::response::IntoResponse {
977 (
978 [(
979 axum::http::header::CONTENT_TYPE,
980 "application/javascript; charset=utf-8",
981 )],
982 crate::web_assets::RUNTIME_JS,
983 )
984}
985
986async fn serve_actr_package(
988 axum::extract::State(state): axum::extract::State<Arc<WebServerState>>,
989 axum::extract::Path(filename): axum::extract::Path<String>,
990) -> impl axum::response::IntoResponse {
991 if filename == state.package_filename {
992 if let Some(ref bytes) = state.package_bytes {
993 return (
994 axum::http::StatusCode::OK,
995 [(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
996 bytes.clone(),
997 );
998 }
999 }
1000 (
1001 axum::http::StatusCode::NOT_FOUND,
1002 [(axum::http::header::CONTENT_TYPE, "text/plain")],
1003 b"Not found".to_vec(),
1004 )
1005}
1006
1007async fn shutdown_signal() {
1008 tokio::signal::ctrl_c()
1009 .await
1010 .expect("Failed to install Ctrl+C handler");
1011}
1012
1013struct DetachedRuntimeContext {
1014 runtime_store: RuntimeStateStore,
1015 config_path: PathBuf,
1016 log_file: PathBuf,
1017 pid: u32,
1018 wid: String,
1019}
1020
1021#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1022enum DetachedRuntimeStartup {
1023 Ready,
1024 Initializing,
1025}
1026
1027async fn wait_for_detached_runtime_ready(
1028 runtime_store: &RuntimeStateStore,
1029 wid: &str,
1030 log_path: &Path,
1031 child: &mut Child,
1032 timeout: Duration,
1033 poll_interval: Duration,
1034) -> Result<DetachedRuntimeStartup> {
1035 let deadline = Instant::now() + timeout;
1036
1037 loop {
1038 if runtime_store.read_record_by_wid(wid).await?.is_some() {
1039 return Ok(DetachedRuntimeStartup::Ready);
1040 }
1041
1042 if let Some(status) = child.try_wait()? {
1043 return Err(ActrCliError::command_error(format!(
1044 "Detached child exited before runtime became ready (status: {status}). Check logs at {}",
1045 log_path.display()
1046 )));
1047 }
1048
1049 if Instant::now() >= deadline {
1050 return Ok(DetachedRuntimeStartup::Initializing);
1051 }
1052
1053 tokio::time::sleep(poll_interval).await;
1054 }
1055}
1056
1057fn short_wid(wid: &str) -> &str {
1058 const SHORT_WID_CHARS: usize = 12;
1059
1060 let end = wid
1061 .char_indices()
1062 .nth(SHORT_WID_CHARS)
1063 .map(|(index, _)| index)
1064 .unwrap_or(wid.len());
1065 &wid[..end]
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::{
1071 DETACHED_READY_POLL_INTERVAL, DetachedRuntimeStartup, short_wid,
1072 wait_for_detached_runtime_ready,
1073 };
1074 use crate::commands::runtime_state::{RuntimeRecord, RuntimeStateStore};
1075 use chrono::Utc;
1076 use std::process::Command as StdCommand;
1077 use std::time::Duration;
1078 use tempfile::TempDir;
1079
1080 #[test]
1081 fn test_short_wid_handles_short_values() {
1082 assert_eq!(short_wid("shortwid"), "shortwid");
1083 assert_eq!(short_wid("1234567890123456"), "123456789012");
1084 }
1085
1086 #[cfg(unix)]
1087 #[tokio::test]
1088 async fn test_wait_for_detached_runtime_ready_returns_ready_when_record_appears() {
1089 let hyper_dir = TempDir::new().unwrap();
1090 let store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1091 store.ensure_layout().await.unwrap();
1092
1093 let wid = "readywid-0000-0000-0000-000000000000".to_string();
1094 let log_path = hyper_dir.path().join("logs").join("actr-ready.log");
1095 let config_path = hyper_dir.path().join("actr.toml");
1096 let writer_store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1097 let writer_wid = wid.clone();
1098 let writer_log_path = log_path.clone();
1099 tokio::spawn(async move {
1100 tokio::time::sleep(Duration::from_millis(50)).await;
1101 let record = RuntimeRecord::new(
1102 writer_wid,
1103 "test-actr".to_string(),
1104 99999,
1105 config_path,
1106 writer_log_path,
1107 Utc::now(),
1108 );
1109 writer_store.write_record(&record).await.unwrap();
1110 });
1111
1112 let mut child = StdCommand::new("sh")
1113 .arg("-c")
1114 .arg("sleep 5")
1115 .spawn()
1116 .unwrap();
1117 let result = wait_for_detached_runtime_ready(
1118 &store,
1119 &wid,
1120 &log_path,
1121 &mut child,
1122 Duration::from_secs(1),
1123 DETACHED_READY_POLL_INTERVAL,
1124 )
1125 .await
1126 .unwrap();
1127
1128 assert_eq!(result, DetachedRuntimeStartup::Ready);
1129
1130 let _ = child.kill();
1131 let _ = child.wait();
1132 }
1133
1134 #[cfg(unix)]
1135 #[tokio::test]
1136 async fn test_wait_for_detached_runtime_ready_returns_error_when_child_exits() {
1137 let hyper_dir = TempDir::new().unwrap();
1138 let store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1139 store.ensure_layout().await.unwrap();
1140
1141 let log_path = hyper_dir.path().join("logs").join("actr-failed.log");
1142 let mut child = StdCommand::new("sh")
1143 .arg("-c")
1144 .arg("exit 3")
1145 .spawn()
1146 .unwrap();
1147
1148 let error = wait_for_detached_runtime_ready(
1149 &store,
1150 "failedwid-0000",
1151 &log_path,
1152 &mut child,
1153 Duration::from_secs(1),
1154 DETACHED_READY_POLL_INTERVAL,
1155 )
1156 .await
1157 .unwrap_err()
1158 .to_string();
1159
1160 assert!(error.contains("Detached child exited before runtime became ready"));
1161 assert!(error.contains(log_path.to_str().unwrap()));
1162 }
1163
1164 #[cfg(unix)]
1165 #[tokio::test]
1166 async fn test_wait_for_detached_runtime_ready_returns_initializing_on_timeout() {
1167 let hyper_dir = TempDir::new().unwrap();
1168 let store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1169 store.ensure_layout().await.unwrap();
1170
1171 let log_path = hyper_dir.path().join("logs").join("actr-timeout.log");
1172 let mut child = StdCommand::new("sh")
1173 .arg("-c")
1174 .arg("sleep 5")
1175 .spawn()
1176 .unwrap();
1177
1178 let result = wait_for_detached_runtime_ready(
1179 &store,
1180 "timeoutwid-0000",
1181 &log_path,
1182 &mut child,
1183 Duration::from_millis(50),
1184 Duration::from_millis(10),
1185 )
1186 .await
1187 .unwrap();
1188
1189 assert_eq!(result, DetachedRuntimeStartup::Initializing);
1190
1191 let _ = child.kill();
1192 let _ = child.wait();
1193 }
1194}