a2a_protocol_server/agent_card/
hot_reload.rs1use std::future::Future;
42use std::path::{Path, PathBuf};
43use std::pin::Pin;
44use std::sync::{Arc, RwLock};
45use std::time::{Duration, SystemTime};
46
47use a2a_protocol_types::agent_card::AgentCard;
48use a2a_protocol_types::error::A2aResult;
49
50use crate::agent_card::dynamic_handler::AgentCardProducer;
51use crate::error::{ServerError, ServerResult};
52
53#[derive(Debug, Clone)]
62pub struct HotReloadAgentCardHandler {
63 card: Arc<RwLock<AgentCard>>,
64}
65
66impl HotReloadAgentCardHandler {
67 #[must_use]
69 pub fn new(card: AgentCard) -> Self {
70 Self {
71 card: Arc::new(RwLock::new(card)),
72 }
73 }
74
75 #[must_use]
84 pub fn current(&self) -> AgentCard {
85 self.card
86 .read()
87 .expect("agent card RwLock poisoned")
88 .clone()
89 }
90
91 pub fn update(&self, card: AgentCard) {
99 let mut guard = self.card.write().expect("agent card RwLock poisoned");
100 *guard = card;
101 }
102
103 pub fn reload_from_file(&self, path: &Path) -> ServerResult<()> {
112 let contents = std::fs::read_to_string(path).map_err(|e| {
113 ServerError::Internal(format!(
114 "failed to read agent card file {}: {e}",
115 path.display()
116 ))
117 })?;
118 self.reload_from_json(&contents)
119 }
120
121 pub fn reload_from_json(&self, json: &str) -> ServerResult<()> {
129 let card: AgentCard = serde_json::from_str(json)?;
130 self.update(card);
131 Ok(())
132 }
133
134 #[must_use]
144 pub fn spawn_poll_watcher(
145 &self,
146 path: &Path,
147 interval: Duration,
148 ) -> tokio::task::JoinHandle<()> {
149 let handler = self.clone();
150 let path = path.to_path_buf();
151 tokio::spawn(poll_watcher_loop(handler, path, interval))
152 }
153
154 #[cfg(unix)]
168 #[must_use]
169 pub fn spawn_signal_watcher(&self, path: &Path) -> tokio::task::JoinHandle<()> {
170 let handler = self.clone();
171 let path = path.to_path_buf();
172 tokio::spawn(signal_watcher_loop(handler, path))
173 }
174}
175
176impl AgentCardProducer for HotReloadAgentCardHandler {
177 fn produce<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<AgentCard>> + Send + 'a>> {
178 Box::pin(async move { Ok(self.current()) })
179 }
180}
181
182fn file_mtime(path: &Path) -> Option<SystemTime> {
185 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
186}
187
188async fn poll_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf, interval: Duration) {
191 let mut last_mtime = file_mtime(&path);
192 let mut tick = tokio::time::interval(interval);
193 tick.tick().await;
196
197 loop {
198 tick.tick().await;
199 let current_mtime = file_mtime(&path);
200 if current_mtime != last_mtime {
201 last_mtime = current_mtime;
202 if let Err(e) = handler.reload_from_file(&path) {
203 #[cfg(feature = "tracing")]
206 tracing::warn!(
207 path = %path.display(),
208 error = %e,
209 "hot-reload: failed to reload agent card",
210 );
211 let _ = e;
212 }
213 }
214 }
215}
216
217#[cfg(unix)]
219async fn signal_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf) {
220 use tokio::signal::unix::{signal, SignalKind};
221
222 let mut stream = signal(SignalKind::hangup()).expect("failed to register SIGHUP handler");
223
224 loop {
225 stream.recv().await;
226 if let Err(e) = handler.reload_from_file(&path) {
227 #[cfg(feature = "tracing")]
228 tracing::warn!(
229 path = %path.display(),
230 error = %e,
231 "hot-reload: SIGHUP reload failed",
232 );
233 let _ = e;
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::agent_card::caching::tests::minimal_agent_card;
242
243 #[test]
244 fn new_handler_returns_initial_card() {
245 let card = minimal_agent_card();
246 let handler = HotReloadAgentCardHandler::new(card.clone());
247 let current = handler.current();
248 assert_eq!(current.name, card.name);
249 assert_eq!(current.version, card.version);
250 }
251
252 #[test]
253 fn update_replaces_card() {
254 let card1 = minimal_agent_card();
255 let handler = HotReloadAgentCardHandler::new(card1);
256
257 let mut card2 = minimal_agent_card();
258 card2.name = "Updated Agent".into();
259 handler.update(card2);
260
261 assert_eq!(handler.current().name, "Updated Agent");
262 }
263
264 #[test]
265 fn reload_from_json_valid() {
266 let card = minimal_agent_card();
267 let handler = HotReloadAgentCardHandler::new(card);
268
269 let mut new_card = minimal_agent_card();
270 new_card.name = "JSON Reloaded".into();
271 let json = serde_json::to_string(&new_card).unwrap();
272
273 handler.reload_from_json(&json).unwrap();
274 assert_eq!(handler.current().name, "JSON Reloaded");
275 }
276
277 #[test]
278 fn reload_from_json_invalid() {
279 let card = minimal_agent_card();
280 let handler = HotReloadAgentCardHandler::new(card);
281
282 let result = handler.reload_from_json("not valid json {{{");
283 assert!(result.is_err());
284 assert_eq!(handler.current().name, "Test Agent");
286 }
287
288 #[test]
289 fn reload_from_file_valid() {
290 let card = minimal_agent_card();
291 let handler = HotReloadAgentCardHandler::new(card);
292
293 let dir = std::env::temp_dir().join("a2a_hot_reload_test");
294 std::fs::create_dir_all(&dir).unwrap();
295 let file = dir.join("agent_card.json");
296
297 let mut new_card = minimal_agent_card();
298 new_card.name = "File Reloaded".into();
299 std::fs::write(&file, serde_json::to_string(&new_card).unwrap()).unwrap();
300
301 handler.reload_from_file(&file).unwrap();
302 assert_eq!(handler.current().name, "File Reloaded");
303
304 let _ = std::fs::remove_file(&file);
306 let _ = std::fs::remove_dir(&dir);
307 }
308
309 #[test]
310 fn reload_from_file_missing() {
311 let card = minimal_agent_card();
312 let handler = HotReloadAgentCardHandler::new(card);
313
314 let result = handler.reload_from_file(Path::new("/tmp/nonexistent_a2a_card.json"));
315 assert!(result.is_err());
316 }
317
318 #[test]
319 fn clone_shares_state() {
320 let card = minimal_agent_card();
321 let handler1 = HotReloadAgentCardHandler::new(card);
322 let handler2 = handler1.clone();
323
324 let mut new_card = minimal_agent_card();
325 new_card.name = "Shared Update".into();
326 handler1.update(new_card);
327
328 assert_eq!(handler2.current().name, "Shared Update");
330 }
331
332 #[tokio::test]
333 async fn producer_trait_returns_current_card() {
334 let card = minimal_agent_card();
335 let handler = HotReloadAgentCardHandler::new(card.clone());
336
337 let produced = handler.produce().await.unwrap();
338 assert_eq!(produced.name, card.name);
339 }
340
341 #[cfg(unix)]
343 #[tokio::test]
344 async fn signal_watcher_can_be_spawned_and_aborted() {
345 let card = minimal_agent_card();
346 let handler = HotReloadAgentCardHandler::new(card);
347
348 let dir = std::env::temp_dir().join("a2a_signal_watcher_test");
349 std::fs::create_dir_all(&dir).unwrap();
350 let file = dir.join("agent_card.json");
351
352 let initial = minimal_agent_card();
353 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
354
355 let handle = handler.spawn_signal_watcher(&file);
356 handle.abort();
358
359 let _ = std::fs::remove_file(&file);
361 let _ = std::fs::remove_dir(&dir);
362 }
363
364 #[test]
366 fn file_mtime_returns_none_for_missing_file() {
367 let result = file_mtime(Path::new("/tmp/nonexistent_a2a_mtime_test.json"));
368 assert!(result.is_none(), "missing file should return None");
369 }
370
371 #[test]
373 fn file_mtime_returns_some_for_existing_file() {
374 let dir = std::env::temp_dir().join("a2a_mtime_test");
375 std::fs::create_dir_all(&dir).unwrap();
376 let file = dir.join("test.json");
377 std::fs::write(&file, "{}").unwrap();
378
379 let result = file_mtime(&file);
380 assert!(result.is_some(), "existing file should return Some");
381
382 let _ = std::fs::remove_file(&file);
383 let _ = std::fs::remove_dir(&dir);
384 }
385
386 #[tokio::test]
387 async fn poll_watcher_handles_missing_file_gracefully() {
388 let card = minimal_agent_card();
391 let handler = HotReloadAgentCardHandler::new(card);
392
393 let dir = std::env::temp_dir().join("a2a_poll_missing_test");
394 std::fs::create_dir_all(&dir).unwrap();
395 let file = dir.join("agent_card.json");
396
397 let initial = minimal_agent_card();
399 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
400
401 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
402
403 tokio::time::sleep(Duration::from_millis(100)).await;
405
406 std::fs::remove_file(&file).unwrap();
408
409 tokio::time::sleep(Duration::from_millis(200)).await;
411
412 assert_eq!(handler.current().name, "Test Agent");
414
415 handle.abort();
416 let _ = std::fs::remove_dir(&dir);
417 }
418
419 #[tokio::test]
420 async fn poll_watcher_handles_invalid_json_gracefully() {
421 let card = minimal_agent_card();
423 let handler = HotReloadAgentCardHandler::new(card);
424
425 let dir = std::env::temp_dir().join("a2a_poll_invalid_json_test");
426 std::fs::create_dir_all(&dir).unwrap();
427 let file = dir.join("agent_card.json");
428
429 let initial = minimal_agent_card();
430 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
431
432 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
433
434 tokio::time::sleep(Duration::from_millis(100)).await;
435
436 std::fs::write(&file, "not valid json {{{").unwrap();
438
439 tokio::time::sleep(Duration::from_millis(200)).await;
440
441 assert_eq!(handler.current().name, "Test Agent");
443
444 handle.abort();
445 let _ = std::fs::remove_file(&file);
446 let _ = std::fs::remove_dir(&dir);
447 }
448
449 #[tokio::test]
450 async fn poll_watcher_detects_change() {
451 let dir = std::env::temp_dir().join("a2a_poll_watcher_test");
452 std::fs::create_dir_all(&dir).unwrap();
453 let file = dir.join("agent_card.json");
454
455 let initial = minimal_agent_card();
456 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
457
458 let handler = HotReloadAgentCardHandler::new(initial);
459 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
460
461 tokio::time::sleep(Duration::from_millis(100)).await;
463
464 let mut updated = minimal_agent_card();
465 updated.name = "Poll Updated".into();
466 std::fs::write(&file, serde_json::to_string(&updated).unwrap()).unwrap();
467
468 tokio::time::sleep(Duration::from_millis(200)).await;
470
471 assert_eq!(handler.current().name, "Poll Updated");
472
473 handle.abort();
474
475 let _ = std::fs::remove_file(&file);
477 let _ = std::fs::remove_dir(&dir);
478 }
479}