Files
mnote/rust/crates/mnote-web/src/acp_runtime.rs
T

391 lines
14 KiB
Rust
Raw Normal View History

2026-05-17 16:15:52 +08:00
/// 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")
2026-05-17 20:11:39 +08:00
/// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default")
2026-05-17 16:15:52 +08:00
/// `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 ───────────────────────────────────────────
2026-05-17 20:11:39 +08:00
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2026-05-17 16:15:52 +08:00
#[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<String>,
/// Extra environment variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
/// Human-readable title for the runtime selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl AcpRuntimeConfig {
/// Create a Hermes ACP runtime config.
2026-05-17 20:11:39 +08:00
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()]
};
2026-05-17 16:15:52 +08:00
Self {
name: "hermes".into(),
bin: bin.unwrap_or("hermes").to_string(),
2026-05-17 20:11:39 +08:00
args,
2026-05-17 16:15:52 +08:00
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
2026-05-17 20:11:39 +08:00
let resolved_path = wrapper_path
.map(|p| {
if p.starts_with('/') {
p.to_string()
} else {
format!("{project_root}/{p}")
}
})
.unwrap_or(default_path);
2026-05-17 16:15:52 +08:00
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<String, AcpRuntimeConfig>,
active: Mutex<Option<ActiveRuntime>>,
default_runtime: String,
}
#[derive(Debug)]
struct ActiveRuntime {
config: AcpRuntimeConfig,
client: Arc<AcpClient>,
}
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: "hermes").
pub fn from_env() -> Self {
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
// Check for JSON-based configuration first
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
2026-05-17 20:11:39 +08:00
if let Ok(custom_runtimes) = serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json) {
2026-05-17 16:15:52 +08:00
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") {
2026-05-17 20:11:39 +08:00
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)),
);
2026-05-17 16:15:52 +08:00
}
// 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());
2026-05-17 20:11:39 +08:00
runtimes.insert(
"reasonix".into(),
AcpRuntimeConfig::reasonix(Some(&wrapper)),
);
2026-05-17 16:15:52 +08:00
}
2026-05-17 20:11:39 +08:00
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "hermes".into());
2026-05-17 16:15:52 +08:00
Self {
runtimes,
active: Mutex::new(None),
default_runtime: default,
}
}
/// Get the list of available runtime names.
pub fn available_runtimes(&self) -> Vec<String> {
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<String> {
2026-05-17 20:11:39 +08:00
self.active
.lock()
.await
.as_ref()
.map(|a| a.config.name.clone())
2026-05-17 16:15:52 +08:00
}
/// Get a reference to the currently active [`AcpClient`], if any.
pub async fn active_client(&self) -> Option<Arc<AcpClient>> {
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<Arc<AcpClient>, AcpError> {
let config = self
.runtimes
.get(name)
.cloned()
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
2026-05-17 20:11:39 +08:00
self.switch_to_config(config).await
}
2026-05-17 16:15:52 +08:00
2026-05-17 20:11:39 +08:00
/// 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<Arc<AcpClient>, AcpError> {
let name = config.name.clone();
2026-05-17 16:15:52 +08:00
// Shutdown current active runtime
let mut active_guard = self.active.lock().await;
if let Some(ref current) = *active_guard {
2026-05-17 20:11:39 +08:00
if current.config == config {
2026-05-17 16:15:52 +08:00
// 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)
}
2026-05-17 20:11:39 +08:00
info!(
"ACP runtime: switching to {name} (bin={}, args={:?})",
config.bin, config.args
);
2026-05-17 16:15:52 +08:00
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
2026-05-17 20:11:39 +08:00
let client =
AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?;
2026-05-17 16:15:52 +08:00
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<serde_json::Value, AcpError> = 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() {
2026-05-17 20:11:39 +08:00
let cfg = AcpRuntimeConfig::hermes(None, None);
2026-05-17 16:15:52 +08:00
assert_eq!(cfg.name, "hermes");
assert_eq!(cfg.bin, "hermes");
2026-05-17 20:11:39 +08:00
assert_eq!(cfg.args, vec!["-p", "default", "acp"]);
}
#[test]
fn test_runtime_config_hermes_profile_can_be_disabled() {
let cfg = AcpRuntimeConfig::hermes(None, Some(""));
2026-05-17 16:15:52 +08:00
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");
2026-05-17 20:11:39 +08:00
assert_eq!(cfg.args.len(), 1);
assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs"));
2026-05-17 16:15:52 +08:00
}
#[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;
}
}
}