Skip to main content

actr_cli/commands/
run.rs

1//! Run command implementation - Execute .actr packages
2
3use 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
20/// Default filename for the runtime config file (`actr.toml`).
21const DEFAULT_RUNTIME_CONFIG: &str = "actr.toml";
22
23/// Resolve `path` against `base`: returns `path` if absolute, otherwise `base.join(path)`.
24fn 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    /// Runtime configuration file (defaults to ./actr.toml if not specified)
35    #[arg(short = 'c', long = "config", value_name = "FILE")]
36    pub config: Option<PathBuf>,
37
38    /// Hyper data directory
39    #[arg(long = "hyper-dir", value_name = "DIR")]
40    pub hyper_dir: Option<PathBuf>,
41
42    /// Run in detached mode (background)
43    #[arg(short = 'd', long = "detach")]
44    pub detach: bool,
45
46    /// Internal flag used when the detached child re-executes this command.
47    #[arg(long = "internal-detached-child", hide = true)]
48    pub internal_detached_child: bool,
49
50    /// Internal: WID passed from parent to detached child (or from start/restart for reuse).
51    #[arg(long = "internal-wid", hide = true)]
52    pub internal_wid: Option<String>,
53    /// Run as a web server (serves static files + runtime config for browser-based actors)
54    #[arg(long = "web")]
55    pub web: bool,
56
57    /// Override web server port (default from config or 8080)
58    #[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        // The run command only supports packaged workloads via runtime config.
66        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        // Resolve runtime config path: use the provided path or default to ./actr.toml.
94        let config_path = self
95            .config
96            .clone()
97            .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_CONFIG));
98
99        // Check if the runtime config file exists.
100        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        // 1. Resolve package path from the runtime config.
122        let package_path = self.resolve_package_path(&config_path).await?;
123        info!("๐Ÿ“ฆ Loading package: {}", package_path.display());
124
125        // 2. Load package bytes
126        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        // 3. Parse package manifest
132        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        // 4. Load runtime configuration
138        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        // 6. Initialize observability
145        let _obs_guard = init_observability(&config.observability).map_err(|e| {
146            ActrCliError::command_error(format!("Failed to initialize observability: {}", e))
147        })?;
148
149        // 7. Initialize Hyper
150        let hyper = self.init_hyper(&config, &package_path, &hyper_dir).await?;
151        info!("โœ… Hyper initialized");
152
153        // 8. Node typestate chain: from_hyper โ†’ attach โ†’ register โ†’ start
154        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        // Block and wait for Ctrl+C
199        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        // Load runtime config to get the packaged workload path.
217        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            // Fallback: when no `[[trust]]` anchors are configured, auto-load
267            // the package's sidecar `public-key.json` as a StaticTrust anchor.
268            // Lets `actr init` scaffolds "just work" without boilerplate.
269            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        // Upsert: if a record already exists for this wid (start/restart scenario),
472        // update pid/started_at and clear stopped_at while preserving wid and actr_id.
473        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            // Generate a new wid in the parent; pass it to the child via --internal-wid.
509            // For start/restart, the caller sets self.internal_wid to the existing wid.
510            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    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
592    // Web server mode
593    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
594
595    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
596    // Web server mode
597    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
598
599    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        // Resolve config path
608        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        // Parse config to get runtime settings
622        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        // Extract web config with defaults
629        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        // Resolve the .actr package from [package].path
644        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        // Read the package bytes for serving
651        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        // Derive the package filename for the URL
672        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        // Option U / Phase 4: sibling `<stem>.wbg/` directory carrying the
679        // wasm-bindgen guest bundle (produced by `wasm-pack --target
680        // no-modules` from the unified guest crates, see Phase 6c). Mounted
681        // with the same `<package_url>.wbg/...` convention actor.sw.js
682        // expects.
683        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        // Build runtime config JSON โ€” auto-inject embedded asset URLs
701        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        // Build router:
711        // 1. /actr-runtime-config.json โ€” generated runtime config
712        // 2. /actor.sw.js โ€” embedded Service Worker (wasm-bindgen guest bridge)
713        // 3. /packages/actr_sw_host_bg.wasm โ€” embedded SW host WASM
714        // 4. /packages/actr_sw_host.js โ€” embedded SW host JS glue
715        // 5. /packages/<name>.actr โ€” the .actr package from [package].path
716        // 6. /packages/<name>.wbg/* โ€” wasm-bindgen guest bundle sibling of
717        //    the .actr (produced at build time by `wasm-pack --target
718        //    no-modules`). Only mounted when the directory exists.
719        // 7. / โ€” embedded host HTML (fallback: static_dir)
720        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                // Serve embedded host page from a temp dir is not ideal,
742                // so we handle "/" in the fallback via the index route
743                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        // Read package path to extract package info
808        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        // Try to read package manifest for metadata
816        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        // Parse ACL from raw config
836        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        // Extract web-specific fields
868        let web = raw.web.as_ref();
869        // Auto-generate package_url and runtime_wasm_url from embedded assets.
870        // Config-level overrides are still respected if present (backward compat).
871        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        // Serialise `[[trust]]` anchors to the web runtime in the same shape
879        // the Rust side uses (see `actr_config::TrustAnchor`). Browser-side
880        // code walks the array; today only `kind = "static"` is honoured for
881        // verification โ€” a `kind = "registry"` anchor is surfaced but causes
882        // the SW to log a warning and skip verify until the web runtime learns
883        // to do async AIS key lookup.
884        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
935/// Serve the embedded host HTML page at `/`.
936async 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
945/// Serve the embedded actor.sw.js Service Worker (wasm-bindgen guest bridge).
946///
947/// Phase 8 collapsed this to a single body โ€” the previous Component Model
948/// path and its `ACTR_WEB_GUEST_MODE` selector were deleted along with
949/// `actor.sw.js` (CM variant). See `bindings/web/docs/option-u-wit-compile-web.zh.md`
950/// ยง11.
951async 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
963/// Serve the embedded runtime WASM binary.
964async 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
973/// Serve the embedded runtime JS glue.
974async 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
986/// Serve the .actr package from [package].path.
987async 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}