use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::warn;
use super::{
AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth, RuntimeStatus,
tail_lines,
};
use crate::registry::AgentId;
struct Supervised {
spec: AgentSpec,
state: ProcState,
}
enum ProcState {
Provisioned,
Running(Box<Child>),
Stopped,
}
#[derive(Clone)]
pub struct LocalProcessRuntime {
exe: PathBuf,
allowed_env: EnvAllowlist,
log_dir: Option<PathBuf>,
agents: Arc<Mutex<HashMap<AgentId, Supervised>>>,
}
impl LocalProcessRuntime {
pub fn new() -> Self {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("a2a"));
Self::with_exe(exe)
}
pub fn with_exe(exe: impl Into<PathBuf>) -> Self {
Self {
exe: exe.into(),
allowed_env: EnvAllowlist::deny_all(),
log_dir: None,
agents: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn with_log_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.log_dir = Some(dir.into());
self
}
pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
self.allowed_env = allowed;
self
}
}
impl Default for LocalProcessRuntime {
fn default() -> Self {
Self::new()
}
}
fn log_path(dir: &Path, id: &AgentId) -> PathBuf {
dir.join(format!("{id}.log"))
}
async fn log_stdio(dir: &Path, id: &AgentId) -> Result<(Stdio, Stdio), std::io::Error> {
tokio::fs::create_dir_all(dir).await?;
let file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path(dir, id))
.await?
.into_std()
.await;
let dup = file.try_clone()?;
Ok((Stdio::from(file), Stdio::from(dup)))
}
#[async_trait]
impl AgentRuntime for LocalProcessRuntime {
async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError> {
let content = tokio::fs::read_to_string(&spec.config_path)
.await
.map_err(|e| RuntimeError::Config(e.to_string()))?;
self.allowed_env.check(&content)?;
let id = spec.id.clone();
self.agents.lock().await.insert(
id.clone(),
Supervised {
spec,
state: ProcState::Provisioned,
},
);
Ok(id)
}
async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError> {
Ok(Recovered::Ephemeral)
}
async fn start(&self, id: &AgentId) -> Result<(), RuntimeError> {
let mut guard = self.agents.lock().await;
let entry = guard
.get_mut(id)
.ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
if matches!(entry.state, ProcState::Running(_)) {
return Err(RuntimeError::AlreadyRunning(id.clone()));
}
let mut command = Command::new(&self.exe);
command
.arg("run")
.arg("--config")
.arg(&entry.spec.config_path)
.kill_on_drop(true);
if let Some(dir) = &self.log_dir {
let (out, err) = log_stdio(dir, id)
.await
.map_err(|source| RuntimeError::Spawn {
id: id.clone(),
source,
})?;
command.stdout(out).stderr(err);
}
let child = command.spawn().map_err(|source| RuntimeError::Spawn {
id: id.clone(),
source,
})?;
entry.state = ProcState::Running(Box::new(child));
Ok(())
}
async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError> {
let mut guard = self.agents.lock().await;
let entry = guard
.get_mut(id)
.ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
if let ProcState::Running(child) = &mut entry.state
&& let Err(e) = child.kill().await
{
warn!("error killing agent '{}': {}", id, e);
}
entry.state = ProcState::Stopped;
Ok(())
}
async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError> {
let endpoint = {
let mut guard = self.agents.lock().await;
let entry = guard
.get_mut(id)
.ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
match &mut entry.state {
ProcState::Provisioned => return Ok(RuntimeHealth::Provisioned),
ProcState::Stopped => return Ok(RuntimeHealth::Stopped),
ProcState::Running(child) => match child.try_wait() {
Ok(Some(_)) => {
entry.state = ProcState::Stopped;
return Ok(RuntimeHealth::Stopped);
}
Ok(None) | Err(_) => entry.spec.endpoint.clone(),
},
}
};
match a2a_rs::fetch_agent_card(&endpoint).await {
Ok(_) => Ok(RuntimeHealth::Healthy),
Err(_) => Ok(RuntimeHealth::Unhealthy),
}
}
async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError> {
let ids: Vec<AgentId> = self.agents.lock().await.keys().cloned().collect();
let mut statuses = Vec::with_capacity(ids.len());
for id in ids {
let health = self.health(&id).await?;
let endpoint = match self.agents.lock().await.get(&id) {
Some(entry) => entry.spec.endpoint.clone(),
None => continue, };
statuses.push(RuntimeStatus {
id,
health,
endpoint,
});
}
Ok(statuses)
}
async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError> {
if !self.agents.lock().await.contains_key(id) {
return Err(RuntimeError::NotFound(id.clone()));
}
let Some(dir) = &self.log_dir else {
return Err(RuntimeError::Unsupported {
operation: "logs",
reason: "this local runtime does not capture agent output — its children \
inherit the supervisor's stdout/stderr. Start the control plane \
with --log-dir to capture it per agent."
.to_string(),
});
};
match tokio::fs::read_to_string(log_path(dir, id)).await {
Ok(text) => Ok(tail_lines(&text, tail)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(RuntimeError::Backend(format!(
"could not read the log for '{id}': {e}"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_path_is_the_slug_under_the_log_dir() {
assert_eq!(
log_path(
Path::new("/var/log/a2a"),
&AgentId::from_name("Weather Agent")
),
Path::new("/var/log/a2a/weather-agent.log")
);
}
#[tokio::test]
async fn logs_without_a_log_dir_are_unsupported_not_empty() {
let runtime = LocalProcessRuntime::with_exe("a2a");
let spec = AgentSpec {
id: AgentId::from_name("Quiet"),
config_path: PathBuf::from("quiet.toml"),
endpoint: "http://127.0.0.1:8080".to_string(),
};
runtime.agents.lock().await.insert(
spec.id.clone(),
Supervised {
spec,
state: ProcState::Provisioned,
},
);
let err = runtime
.logs(&AgentId::from("quiet"), None)
.await
.expect_err("a runtime with no log dir cannot serve logs");
assert!(
matches!(&err, RuntimeError::Unsupported { operation, .. } if *operation == "logs"),
"got: {err}"
);
assert!(err.to_string().contains("--log-dir"), "{err}");
}
#[tokio::test]
async fn logs_for_an_unprovisioned_agent_are_not_found() {
let runtime = LocalProcessRuntime::with_exe("a2a").with_log_dir(std::env::temp_dir());
assert!(matches!(
runtime.logs(&AgentId::from("ghost"), None).await,
Err(RuntimeError::NotFound(_))
));
}
}