chore: move 3-14 WS push design to done/
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user