behest_runtime/
managed.rs1#![allow(clippy::pedantic)]
31
32use std::sync::Arc;
33
34use thiserror::Error;
35
36use super::agent::AgentRuntime;
37use super::component::{AnyComponent, Component};
38use super::extensions::Extensions;
39use super::lifecycle::ShutdownToken;
40use super::registry::{ComponentRegistry, RegistryError, TypedAnyComponent};
41use behest_core::health::HealthStatus;
42
43#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum ManagedError {
47 #[error("component `{0}` not found")]
49 ComponentNotFound(String),
50
51 #[error("registry error: {0}")]
53 Registry(#[from] RegistryError),
54
55 #[error("reload failed for component `{name}`: {message}")]
57 Reload {
58 name: String,
60 message: String,
62 },
63}
64
65pub struct ManagedRuntime {
83 runtime: AgentRuntime,
84 registry: ComponentRegistry,
85 shutdown: ShutdownToken,
86}
87
88impl ManagedRuntime {
89 #[must_use]
96 pub fn new(
97 runtime: AgentRuntime,
98 registry: ComponentRegistry,
99 shutdown: ShutdownToken,
100 ) -> Self {
101 Self {
102 runtime,
103 registry,
104 shutdown,
105 }
106 }
107
108 #[must_use]
110 pub fn runtime(&self) -> &AgentRuntime {
111 &self.runtime
112 }
113
114 #[must_use]
116 pub fn registry(&self) -> &ComponentRegistry {
117 &self.registry
118 }
119
120 #[must_use]
122 pub fn shutdown_token(&self) -> ShutdownToken {
123 self.shutdown.clone()
124 }
125
126 #[must_use]
128 pub fn extensions(&self) -> Arc<Extensions> {
129 Arc::clone(self.runtime.extensions())
130 }
131
132 pub fn component<T: Component>(&self, name: &str) -> Result<Arc<T>, ManagedError> {
140 self.registry
141 .get::<T>(name)
142 .map_err(|_| ManagedError::ComponentNotFound(name.to_owned()))
143 }
144
145 pub async fn serve(&self) -> Result<(), ManagedError> {
153 self.shutdown.wait().await;
155
156 self.registry.stop_all().await?;
158 Ok(())
159 }
160
161 #[must_use]
163 pub async fn health(&self) -> std::collections::HashMap<String, HealthStatus> {
164 self.registry.health().await
165 }
166
167 #[must_use]
169 pub async fn is_healthy(&self) -> bool {
170 let map = self.health().await;
171 map.values().all(|s| s.is_healthy())
172 }
173
174 #[must_use]
180 pub async fn overall_health(&self) -> HealthStatus {
181 let map = self.health().await;
182 HealthStatus::aggregate(&map)
183 }
184
185 #[must_use]
189 pub async fn is_ready(&self) -> bool {
190 let map = self.health().await;
191 map.values().all(|s| s.is_operational())
192 }
193
194 #[must_use]
197 pub async fn healthz_json(&self) -> serde_json::Value {
198 let map = self.health().await;
199 HealthStatus::healthz_response(&map)
200 }
201
202 pub async fn reload<T: Component>(
224 &self,
225 name: &str,
226 new_instance: T,
227 ) -> Result<Arc<T>, ManagedError> {
228 let boxed: Box<dyn AnyComponent> = Box::new(TypedAnyComponent::new(new_instance));
229 let old_any = self
230 .registry
231 .replace_instance(name, boxed)
232 .await
233 .map_err(|e| match e {
234 RegistryError::NotFound { name: n } => ManagedError::ComponentNotFound(n),
235 RegistryError::Reload { name: n, message } => {
236 ManagedError::Reload { name: n, message }
237 }
238 other => ManagedError::Registry(other),
239 })?;
240
241 let any_arc = old_any.as_any_arc();
243 any_arc.downcast::<T>().map_err(|_| ManagedError::Reload {
244 name: name.to_string(),
245 message: "old instance type mismatch after swap".to_string(),
246 })
247 }
248
249 pub async fn reload_raw(
259 &self,
260 name: &str,
261 new_instance: Box<dyn AnyComponent>,
262 ) -> Result<Arc<dyn AnyComponent>, ManagedError> {
263 self.registry
264 .replace_instance(name, new_instance)
265 .await
266 .map_err(|e| match e {
267 RegistryError::NotFound { name: n } => ManagedError::ComponentNotFound(n),
268 RegistryError::Reload { name: n, message } => {
269 ManagedError::Reload { name: n, message }
270 }
271 other => ManagedError::Registry(other),
272 })
273 }
274}
275
276impl std::fmt::Debug for ManagedRuntime {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 f.debug_struct("ManagedRuntime")
279 .field("components", &self.registry.len())
280 .finish()
281 }
282}
283
284#[cfg(test)]
285#[allow(clippy::expect_used)]
286mod tests {
287 use super::*;
288 use crate::component::ComponentContext;
289 use crate::policy::RuntimePolicy;
290 use async_trait::async_trait;
291 use schemars::JsonSchema;
292 use serde::Deserialize;
293 use std::time::Duration;
294
295 #[derive(Debug, Clone, Deserialize, JsonSchema)]
296 struct TestConfig {
297 label: String,
298 }
299
300 struct TestComp {
301 label: String,
302 }
303
304 #[async_trait]
305 impl Component for TestComp {
306 const NAME: &'static str = "test.managed";
307 type Config = TestConfig;
308 type Error = std::io::Error;
309
310 async fn init(cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
311 Ok(Self {
312 label: cfg.label.clone(),
313 })
314 }
315 }
316
317 fn test_runtime() -> ManagedRuntime {
318 let exts = Arc::new(Extensions::default());
319 let policy = RuntimePolicy::default();
320 let runtime = AgentRuntime::new(exts, policy);
321 let registry = ComponentRegistry::new();
322 let shutdown = ShutdownToken::new();
323 ManagedRuntime::new(runtime, registry, shutdown)
324 }
325
326 #[tokio::test]
327 async fn serve_returns_on_shutdown() {
328 let managed = test_runtime();
329 let token = managed.shutdown_token();
330 let handle = tokio::spawn(async move {
331 managed.serve().await.expect("serve should succeed");
332 });
333 tokio::time::sleep(Duration::from_millis(20)).await;
334 token.signal_shutdown();
335 handle.await.expect("task should complete");
336 }
337
338 #[tokio::test]
339 async fn component_lookup_returns_not_found_for_empty() {
340 let managed = test_runtime();
341 let result = managed.component::<TestComp>("missing");
342 assert!(result.is_err());
343 assert!(matches!(result, Err(ManagedError::ComponentNotFound(_))));
344 }
345
346 #[tokio::test]
347 async fn component_lookup_after_init() {
348 let exts = Arc::new(Extensions::default());
349 let policy = RuntimePolicy::default();
350 let runtime = AgentRuntime::new(exts, policy);
351 let registry = ComponentRegistry::new();
352 registry
353 .register_typed::<TestComp>("test", serde_json::json!({ "label": "hello" }))
354 .expect("register should succeed");
355 registry.init_all().await.expect("init should succeed");
356 registry.start_all().await.expect("start should succeed");
357
358 let managed = ManagedRuntime::new(runtime, registry, ShutdownToken::new());
359 let comp: Arc<TestComp> = managed
360 .component::<TestComp>("test")
361 .expect("lookup should succeed");
362 assert_eq!(comp.label, "hello");
363 }
364
365 #[tokio::test]
366 async fn health_empty_is_healthy() {
367 let managed = test_runtime();
368 let map = managed.health().await;
369 assert!(map.is_empty());
370 assert!(managed.is_healthy().await);
371 }
372
373 #[tokio::test]
374 async fn health_aggregates_registered_components() {
375 let exts = Arc::new(Extensions::default());
376 let policy = RuntimePolicy::default();
377 let runtime = AgentRuntime::new(exts, policy);
378 let registry = ComponentRegistry::new();
379 registry
380 .register_typed::<TestComp>("c1", serde_json::json!({ "label": "a" }))
381 .expect("register should succeed");
382 registry.init_all().await.expect("init should succeed");
383 registry.start_all().await.expect("start should succeed");
384
385 let managed = ManagedRuntime::new(runtime, registry, ShutdownToken::new());
386 let map = managed.health().await;
387 assert_eq!(map.len(), 1);
388 assert!(map.get("c1").map(|s| s.is_healthy()).unwrap_or(false));
389 }
390
391 #[test]
392 fn debug_format_shows_component_count() {
393 let managed = test_runtime();
394 let dbg = format!("{managed:?}");
395 assert!(dbg.contains("ManagedRuntime"));
396 assert!(dbg.contains("components"));
397 }
398
399 async fn running_runtime() -> ManagedRuntime {
400 let exts = Arc::new(Extensions::default());
401 let policy = RuntimePolicy::default();
402 let runtime = AgentRuntime::new(exts, policy);
403 let registry = ComponentRegistry::new();
404 registry
405 .register_typed::<TestComp>("c1", serde_json::json!({ "label": "old" }))
406 .expect("register should succeed");
407 registry.init_all().await.expect("init should succeed");
408 registry.start_all().await.expect("start should succeed");
409 ManagedRuntime::new(runtime, registry, ShutdownToken::new())
410 }
411
412 #[tokio::test]
413 async fn reload_swaps_component_and_returns_old() {
414 let managed = running_runtime().await;
415
416 let old: Arc<TestComp> = managed
418 .component::<TestComp>("c1")
419 .expect("lookup should succeed");
420 assert_eq!(old.label, "old");
421
422 let returned_old = managed
424 .reload::<TestComp>(
425 "c1",
426 TestComp {
427 label: "new".into(),
428 },
429 )
430 .await
431 .expect("reload should succeed");
432
433 assert_eq!(returned_old.label, "old");
435
436 let current: Arc<TestComp> = managed
438 .component::<TestComp>("c1")
439 .expect("lookup should succeed");
440 assert_eq!(current.label, "new");
441 }
442
443 #[tokio::test]
444 async fn reload_not_found_returns_error() {
445 let managed = test_runtime();
446 let result = managed
447 .reload::<TestComp>("missing", TestComp { label: "x".into() })
448 .await;
449 assert!(matches!(result, Err(ManagedError::ComponentNotFound(_))));
450 }
451
452 #[tokio::test]
453 async fn reload_raw_swaps_type_erased_instance() {
454 let managed = running_runtime().await;
455
456 let new_instance: Box<dyn AnyComponent> =
457 Box::new(TypedAnyComponent::<TestComp>::new(TestComp {
458 label: "raw-new".into(),
459 }));
460
461 managed
462 .reload_raw("c1", new_instance)
463 .await
464 .expect("reload_raw should succeed");
465
466 let current: Arc<TestComp> = managed
467 .component::<TestComp>("c1")
468 .expect("lookup should succeed");
469 assert_eq!(current.label, "raw-new");
470 }
471}