use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime};
use a2a_protocol_types::agent_card::AgentCard;
use a2a_protocol_types::error::A2aResult;
use crate::agent_card::dynamic_handler::AgentCardProducer;
use crate::error::{ServerError, ServerResult};
#[derive(Debug, Clone)]
pub struct HotReloadAgentCardHandler {
card: Arc<RwLock<AgentCard>>,
}
impl HotReloadAgentCardHandler {
#[must_use]
pub fn new(card: AgentCard) -> Self {
Self {
card: Arc::new(RwLock::new(card)),
}
}
#[must_use]
pub fn current(&self) -> AgentCard {
self.card
.read()
.expect("agent card RwLock poisoned")
.clone()
}
pub fn update(&self, card: AgentCard) {
let mut guard = self.card.write().expect("agent card RwLock poisoned");
*guard = card;
}
pub fn reload_from_file(&self, path: &Path) -> ServerResult<()> {
let contents = std::fs::read_to_string(path).map_err(|e| {
ServerError::Internal(format!(
"failed to read agent card file {}: {e}",
path.display()
))
})?;
self.reload_from_json(&contents)
}
pub fn reload_from_json(&self, json: &str) -> ServerResult<()> {
let card: AgentCard = serde_json::from_str(json)?;
self.update(card);
Ok(())
}
#[must_use]
pub fn spawn_poll_watcher(
&self,
path: &Path,
interval: Duration,
) -> tokio::task::JoinHandle<()> {
let handler = self.clone();
let path = path.to_path_buf();
tokio::spawn(poll_watcher_loop(handler, path, interval))
}
#[cfg(unix)]
#[must_use]
pub fn spawn_signal_watcher(&self, path: &Path) -> tokio::task::JoinHandle<()> {
let handler = self.clone();
let path = path.to_path_buf();
tokio::spawn(signal_watcher_loop(handler, path))
}
}
impl AgentCardProducer for HotReloadAgentCardHandler {
fn produce<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<AgentCard>> + Send + 'a>> {
Box::pin(async move { Ok(self.current()) })
}
}
fn file_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
async fn poll_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf, interval: Duration) {
let mut last_mtime = file_mtime(&path);
let mut tick = tokio::time::interval(interval);
tick.tick().await;
loop {
tick.tick().await;
let current_mtime = file_mtime(&path);
if current_mtime != last_mtime {
last_mtime = current_mtime;
if let Err(e) = handler.reload_from_file(&path) {
#[cfg(feature = "tracing")]
tracing::warn!(
path = %path.display(),
error = %e,
"hot-reload: failed to reload agent card",
);
let _ = e;
}
}
}
}
#[cfg(unix)]
async fn signal_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf) {
use tokio::signal::unix::{signal, SignalKind};
let mut stream = signal(SignalKind::hangup()).expect("failed to register SIGHUP handler");
loop {
stream.recv().await;
if let Err(e) = handler.reload_from_file(&path) {
#[cfg(feature = "tracing")]
tracing::warn!(
path = %path.display(),
error = %e,
"hot-reload: SIGHUP reload failed",
);
let _ = e;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_card::caching::tests::minimal_agent_card;
#[test]
fn new_handler_returns_initial_card() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card.clone());
let current = handler.current();
assert_eq!(current.name, card.name);
assert_eq!(current.version, card.version);
}
#[test]
fn update_replaces_card() {
let card1 = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card1);
let mut card2 = minimal_agent_card();
card2.name = "Updated Agent".into();
handler.update(card2);
assert_eq!(handler.current().name, "Updated Agent");
}
#[test]
fn reload_from_json_valid() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let mut new_card = minimal_agent_card();
new_card.name = "JSON Reloaded".into();
let json = serde_json::to_string(&new_card).unwrap();
handler.reload_from_json(&json).unwrap();
assert_eq!(handler.current().name, "JSON Reloaded");
}
#[test]
fn reload_from_json_invalid() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let result = handler.reload_from_json("not valid json {{{");
assert!(result.is_err());
assert_eq!(handler.current().name, "Test Agent");
}
#[test]
fn reload_from_file_valid() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let dir = std::env::temp_dir().join("a2a_hot_reload_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("agent_card.json");
let mut new_card = minimal_agent_card();
new_card.name = "File Reloaded".into();
std::fs::write(&file, serde_json::to_string(&new_card).unwrap()).unwrap();
handler.reload_from_file(&file).unwrap();
assert_eq!(handler.current().name, "File Reloaded");
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn reload_from_file_missing() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let result = handler.reload_from_file(Path::new("/tmp/nonexistent_a2a_card.json"));
assert!(result.is_err());
}
#[test]
fn clone_shares_state() {
let card = minimal_agent_card();
let handler1 = HotReloadAgentCardHandler::new(card);
let handler2 = handler1.clone();
let mut new_card = minimal_agent_card();
new_card.name = "Shared Update".into();
handler1.update(new_card);
assert_eq!(handler2.current().name, "Shared Update");
}
#[tokio::test]
async fn producer_trait_returns_current_card() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card.clone());
let produced = handler.produce().await.unwrap();
assert_eq!(produced.name, card.name);
}
#[cfg(unix)]
#[tokio::test]
async fn signal_watcher_can_be_spawned_and_aborted() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let dir = std::env::temp_dir().join("a2a_signal_watcher_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("agent_card.json");
let initial = minimal_agent_card();
std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
let handle = handler.spawn_signal_watcher(&file);
handle.abort();
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn file_mtime_returns_none_for_missing_file() {
let result = file_mtime(Path::new("/tmp/nonexistent_a2a_mtime_test.json"));
assert!(result.is_none(), "missing file should return None");
}
#[test]
fn file_mtime_returns_some_for_existing_file() {
let dir = std::env::temp_dir().join("a2a_mtime_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("test.json");
std::fs::write(&file, "{}").unwrap();
let result = file_mtime(&file);
assert!(result.is_some(), "existing file should return Some");
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[tokio::test]
async fn poll_watcher_handles_missing_file_gracefully() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let dir = std::env::temp_dir().join("a2a_poll_missing_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("agent_card.json");
let initial = minimal_agent_card();
std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
tokio::time::sleep(Duration::from_millis(100)).await;
std::fs::remove_file(&file).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(handler.current().name, "Test Agent");
handle.abort();
let _ = std::fs::remove_dir(&dir);
}
#[tokio::test]
async fn poll_watcher_handles_invalid_json_gracefully() {
let card = minimal_agent_card();
let handler = HotReloadAgentCardHandler::new(card);
let dir = std::env::temp_dir().join("a2a_poll_invalid_json_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("agent_card.json");
let initial = minimal_agent_card();
std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
tokio::time::sleep(Duration::from_millis(100)).await;
std::fs::write(&file, "not valid json {{{").unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(handler.current().name, "Test Agent");
handle.abort();
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[tokio::test]
async fn poll_watcher_detects_change() {
let dir = std::env::temp_dir().join("a2a_poll_watcher_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("agent_card.json");
let initial = minimal_agent_card();
std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
let handler = HotReloadAgentCardHandler::new(initial);
let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
tokio::time::sleep(Duration::from_millis(100)).await;
let mut updated = minimal_agent_card();
updated.name = "Poll Updated".into();
std::fs::write(&file, serde_json::to_string(&updated).unwrap()).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(handler.current().name, "Poll Updated");
handle.abort();
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
}