/// ACP Runtime Manager — manages agent runtime subprocess lifecycle. /// /// Supports multiple runtimes (Hermes, Reasonix) and switching between them. /// Each runtime is spawned as a subprocess communicating via the ACP JSON-RPC 2.0 protocol. /// /// Configuration: /// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix") /// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes") /// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default") /// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs") /// /// Or via JSON env var: /// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs use crate::acp_client::{AcpClient, AcpError}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::{timeout, Duration}; use tracing::{debug, info, warn}; // ── Config ─────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpRuntimeConfig { /// Display name (e.g. "hermes", "reasonix"). pub name: String, /// Binary path (e.g. "hermes", "node"). pub bin: String, /// Command arguments (e.g. ["acp"], ["scripts/reasonix-acp-wrapper.mjs"]). #[serde(default)] pub args: Vec, /// Extra environment variables. #[serde(default, skip_serializing_if = "Option::is_none")] pub env: Option>, /// Human-readable title for the runtime selector. #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, } impl AcpRuntimeConfig { /// Create a Hermes ACP runtime config. pub fn hermes(bin: Option<&str>, profile: Option<&str>) -> Self { let profile = profile.unwrap_or("default").trim(); let args = if profile.is_empty() { vec!["acp".into()] } else { vec!["-p".into(), profile.to_string(), "acp".into()] }; Self { name: "hermes".into(), bin: bin.unwrap_or("hermes").to_string(), args, env: None, title: Some("Hermes".into()), } } /// Create a Reasonix ACP runtime config. /// `wrapper_path` is relative to the project root (where Cargo.toml's parent is). /// Default: `design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/scripts/reasonix-acp-wrapper.mjs` (dev), /// or in production, the absolute path is resolved via `CARGO_MANIFEST_DIR` (the `rust/` directory). pub fn reasonix(wrapper_path: Option<&str>) -> Self { // CARGO_MANIFEST_DIR is the directory containing this crate's Cargo.toml: // /mnt/Data1T/mnote/rust/crates/mnote-web/ // We need the project root: /mnt/Data1T/mnote/ let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(3) // up: mnote-web/ → crates/ → rust/ → mnote/ (project root) .unwrap_or(std::path::Path::new("/mnt/Data1T/mnote")) .to_string_lossy() .to_string(); let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs"); // Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is let resolved_path = wrapper_path .map(|p| { if p.starts_with('/') { p.to_string() } else { format!("{project_root}/{p}") } }) .unwrap_or(default_path); Self { name: "reasonix".into(), bin: "node".into(), args: vec![resolved_path], env: None, title: Some("Reasonix".into()), } } } // ── Runtime Manager ────────────────────────────────── /// Manages lifecycle of multiple agent runtimes. /// /// Each runtime is defined by a name and spawn configuration. /// At most one runtime is "active" at a time, providing an [`AcpClient`]. #[derive(Debug)] pub struct AcpRuntimeManager { runtimes: HashMap, active: Mutex>, default_runtime: String, } #[derive(Debug)] struct ActiveRuntime { config: AcpRuntimeConfig, client: Arc, } impl AcpRuntimeManager { /// Create a new runtime manager with built-in default configurations. /// /// Reads environment variables to configure Hermes and Reasonix runtimes. /// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "reasonix"). pub fn from_env() -> Self { let mut runtimes: HashMap = HashMap::new(); // Check for JSON-based configuration first if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") { if let Ok(custom_runtimes) = serde_json::from_str::>(&json) { for rt in custom_runtimes { let name = rt.name.clone(); runtimes.insert(name, rt); } } else { warn!("Failed to parse MNOTE_WEB_ACP_RUNTIMES JSON"); } } // Always add default Hermes if not already configured if !runtimes.contains_key("hermes") { let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN").unwrap_or_else(|_| "hermes".into()); let hermes_profile = env::var("MNOTE_WEB_HERMES_ACP_PROFILE").unwrap_or_else(|_| "default".into()); runtimes.insert( "hermes".into(), AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(&hermes_profile)), ); } // Always add default Reasonix if not already configured if !runtimes.contains_key("reasonix") { let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER") .unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into()); runtimes.insert( "reasonix".into(), AcpRuntimeConfig::reasonix(Some(&wrapper)), ); } let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "reasonix".into()); Self { runtimes, active: Mutex::new(None), default_runtime: default, } } /// Get the list of available runtime names. pub fn available_runtimes(&self) -> Vec { self.runtimes.keys().cloned().collect() } /// Get a runtime config by name. pub fn get_config(&self, name: &str) -> Option<&AcpRuntimeConfig> { self.runtimes.get(name) } /// Get the default runtime name. pub fn default_runtime(&self) -> &str { &self.default_runtime } /// Get the currently active runtime name, if any. pub async fn active_runtime_name(&self) -> Option { self.active .lock() .await .as_ref() .map(|a| a.config.name.clone()) } /// Get a reference to the currently active [`AcpClient`], if any. pub async fn active_client(&self) -> Option> { self.active.lock().await.as_ref().map(|a| a.client.clone()) } /// Check if a runtime is active and the client is available. pub async fn is_active(&self) -> bool { self.active.lock().await.is_some() } /// Activate a runtime by name, spawning a new subprocess if needed. /// /// If another runtime is currently active, it will be shut down first. /// After spawn, performs an `initialize` handshake to verify the runtime is healthy. pub async fn switch_to(&self, name: &str) -> Result, AcpError> { let config = self .runtimes .get(name) .cloned() .ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?; self.switch_to_config(config).await } /// Activate a runtime from an explicit config. /// /// This is used by Hermes ACP because the binary is the same runtime name, /// but the selected Hermes profile changes the launch args. pub async fn switch_to_config( &self, config: AcpRuntimeConfig, ) -> Result, AcpError> { let name = config.name.clone(); // Shutdown current active runtime let mut active_guard = self.active.lock().await; if let Some(ref current) = *active_guard { if current.config == config { // Already active — return existing client return Ok(current.client.clone()); } // Drop the old ActiveRuntime, which will kill the child process // (via AcpClient's Drop impl) } info!( "ACP runtime: switching to {name} (bin={}, args={:?})", config.bin, config.args ); // Convert Vec to Vec<&str> for AcpClient::spawn let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect(); let client = AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?; let client = Arc::new(client); *active_guard = Some(ActiveRuntime { config, client: client.clone(), }); info!("ACP runtime: {name} active"); Ok(client) } /// Shut down the currently active runtime. pub async fn shutdown_active(&self) { let mut active_guard = self.active.lock().await; if let Some(active) = active_guard.take() { info!("ACP runtime: shutting down {}", active.config.name); // AcpClient's Drop kills the process } } /// Perform a health check on the active runtime. /// /// Returns `true` if the runtime responds to an `initialize` handshake within 5 seconds. pub async fn health_check(&self) -> bool { let client = match self.active_client().await { Some(c) => c, None => return false, }; // Use request_with_timeout with a short timeout let result: Result = timeout( Duration::from_secs(5), client.request("initialize", serde_json::json!({ "protocolVersion": 1 })), ) .await .map_err(|_| AcpError::Timeout(5)) .and_then(|r| r); match result { Ok(val) => { let ok = val.get("protocolVersion").and_then(|v| v.as_u64()) == Some(1); if ok { debug!("ACP health check OK"); } else { warn!("ACP health check: unexpected response: {val:?}"); } ok } Err(e) => { warn!("ACP health check failed: {e}"); false } } } } impl Drop for AcpRuntimeManager { fn drop(&mut self) { // The active runtime's AcpClient Drop will kill the process } } // ── Tests ──────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn test_runtime_config_hermes() { let cfg = AcpRuntimeConfig::hermes(None, None); assert_eq!(cfg.name, "hermes"); assert_eq!(cfg.bin, "hermes"); assert_eq!(cfg.args, vec!["-p", "default", "acp"]); } #[test] fn test_runtime_config_hermes_profile_can_be_disabled() { let cfg = AcpRuntimeConfig::hermes(None, Some("")); assert_eq!(cfg.args, vec!["acp"]); } #[test] fn test_runtime_config_reasonix() { let cfg = AcpRuntimeConfig::reasonix(None); assert_eq!(cfg.name, "reasonix"); assert_eq!(cfg.bin, "node"); assert_eq!(cfg.args.len(), 1); assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs")); } #[test] fn test_runtime_config_custom() { let cfg = AcpRuntimeConfig { name: "custom".into(), bin: "/usr/local/bin/my-agent".into(), args: vec!["--acp".into(), "--debug".into()], env: None, title: Some("My Agent".into()), }; let json = serde_json::to_value(&cfg).unwrap(); assert_eq!(json["name"], "custom"); assert_eq!(json["bin"], "/usr/local/bin/my-agent"); assert_eq!(json["title"], "My Agent"); } #[test] fn test_runtime_manager_from_env_defaults() { // Without env overrides, should contain hermes and reasonix let mgr = AcpRuntimeManager::from_env(); let runtimes = mgr.available_runtimes(); assert!(runtimes.contains(&"hermes".into())); assert!(runtimes.contains(&"reasonix".into())); } #[tokio::test] async fn test_switch_to_unknown_runtime() { let mgr = AcpRuntimeManager::from_env(); let result = mgr.switch_to("nonexistent").await; assert!(result.is_err()); let err_str = format!("{}", result.err().unwrap()); assert!( err_str.contains("unknown runtime"), "should return error for unknown runtime, got: {err_str}" ); } #[tokio::test] async fn test_health_check_no_active() { let mgr = AcpRuntimeManager::from_env(); assert!(!mgr.health_check().await, "no active runtime = unhealthy"); } #[tokio::test] async fn test_switch_to_hermes_requires_binary() { let mgr = AcpRuntimeManager::from_env(); // This might fail if `hermes` binary is not in PATH — that's OK for this test let result = mgr.switch_to("hermes").await; // We just verify it doesn't panic; either succeeds or returns Spawn error if let Err(e) = &result { assert!( matches!(e, AcpError::Spawn(_)), "expected Spawn error if hermes not in PATH, got: {e}" ); } else { // Success — clean up mgr.shutdown_active().await; } } }