353 lines
13 KiB
Rust
353 lines
13 KiB
Rust
/// 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_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, 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<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.
|
||
|
|
pub fn hermes(bin: Option<&str>) -> Self {
|
||
|
|
Self {
|
||
|
|
name: "hermes".into(),
|
||
|
|
bin: bin.unwrap_or("hermes").to_string(),
|
||
|
|
args: vec!["acp".into()],
|
||
|
|
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<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") {
|
||
|
|
if let Ok(custom_runtimes) =
|
||
|
|
serde_json::from_str::<Vec<AcpRuntimeConfig>>(&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());
|
||
|
|
runtimes.insert("hermes".into(), AcpRuntimeConfig::hermes(Some(&hermes_bin)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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(|_| "hermes".into());
|
||
|
|
|
||
|
|
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> {
|
||
|
|
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<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}")))?;
|
||
|
|
|
||
|
|
// Shutdown current active runtime
|
||
|
|
let mut active_guard = self.active.lock().await;
|
||
|
|
if let Some(ref current) = *active_guard {
|
||
|
|
if current.config.name == name {
|
||
|
|
// 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<String> to Vec<&str> for AcpClient::spawn
|
||
|
|
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
|
||
|
|
let client = AcpClient::spawn(&config.bin, &args_refs).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<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() {
|
||
|
|
let cfg = AcpRuntimeConfig::hermes(None);
|
||
|
|
assert_eq!(cfg.name, "hermes");
|
||
|
|
assert_eq!(cfg.bin, "hermes");
|
||
|
|
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, vec!["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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|