Skip to main content

behest_runtime/
managed.rs

1//! [`ManagedRuntime`]: unified container orchestrating
2//! [`AgentRuntime`], [`ComponentRegistry`], and
3//! [`ShutdownToken`] into a single lifecycle.
4//!
5//! The managed runtime is the **assembly entry point** for operators who
6//! want to compose a runtime from pluggable parts. It gives you a
7//! dependency-ordered lifecycle, typed component lookup, aggregated
8//! health, and hot-reload — the machinery for "this agent is built from
9//! these providers, these tools, and these stores."
10//!
11//! If you don't need the DI container or lifecycle orchestration, you can
12//! use [`AgentRuntime`] directly with [`Extensions`] and wrap it in
13//! [`super::invocation::RuntimeInvocation`] for the emit/on call surface.
14//! `ManagedRuntime` is for when you _do_ want coordinated startup, teardown,
15//! and the ability to swap components at runtime.
16//!
17//! # What it provides
18//!
19//! - **Coordinated lifecycle**: `init_all → start_all → serve → stop_all`
20//!   with a single root shutdown token.
21//! - **Dependency ordering**: components declare `depends_on`; the registry
22//!   topologically sorts and initializes in order.
23//! - **Typed component access**: [`ManagedRuntime::component::<T>`]
24//!   downcasts into the underlying [`ComponentRegistry`].
25//! - **Aggregated health**: [`ManagedRuntime::health`] collects
26//!   component-level health probes.
27//! - **Hot-reload**: [`ManagedRuntime::reload`] replaces
28//!   a running component via the drain-aware protocol.
29
30#![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/// Errors from [`ManagedRuntime`] operations.
44#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum ManagedError {
47    /// A component was not found in the registry.
48    #[error("component `{0}` not found")]
49    ComponentNotFound(String),
50
51    /// The component registry returned an error.
52    #[error("registry error: {0}")]
53    Registry(#[from] RegistryError),
54
55    /// A reload operation failed.
56    #[error("reload failed for component `{name}`: {message}")]
57    Reload {
58        /// The component that failed to reload.
59        name: String,
60        /// Human-readable error description.
61        message: String,
62    },
63}
64
65/// Unified container orchestrating [`AgentRuntime`],
66/// [`ComponentRegistry`], and a root [`ShutdownToken`].
67///
68/// Construct via the facade `AgentConfigBuilder::build_managed` helper or
69/// [`ManagedRuntime::new`].
70///
71/// # Lifecycle
72///
73/// ```text
74///   new()  →  init_all  →  start_all  →  serve  →  signal_shutdown  →  stop_all
75/// ```
76///
77/// # Hot-reload
78///
79/// [`ManagedRuntime::reload`] walks the drain-aware replace protocol:
80/// old instance drains in-flight references, new instance is
81/// constructed and started, then swapped in atomically.
82pub struct ManagedRuntime {
83    runtime: AgentRuntime,
84    registry: ComponentRegistry,
85    shutdown: ShutdownToken,
86}
87
88impl ManagedRuntime {
89    /// Construct a new managed runtime from its constituent parts.
90    ///
91    /// The caller is responsible for ensuring that the `extensions`
92    /// backing `runtime` and the `registry` are consistent (i.e. the
93    /// registry's initialized components have already been applied to
94    /// the extensions).
95    #[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    /// Borrow the underlying [`AgentRuntime`].
109    #[must_use]
110    pub fn runtime(&self) -> &AgentRuntime {
111        &self.runtime
112    }
113
114    /// Borrow the [`ComponentRegistry`].
115    #[must_use]
116    pub fn registry(&self) -> &ComponentRegistry {
117        &self.registry
118    }
119
120    /// Clone the root [`ShutdownToken`].
121    #[must_use]
122    pub fn shutdown_token(&self) -> ShutdownToken {
123        self.shutdown.clone()
124    }
125
126    /// Clone the [`Extensions`] facade from the underlying runtime.
127    #[must_use]
128    pub fn extensions(&self) -> Arc<Extensions> {
129        Arc::clone(self.runtime.extensions())
130    }
131
132    /// Look up an initialized component by name, downcasting to a
133    /// concrete `Arc<T>`.
134    ///
135    /// # Errors
136    /// - [`ManagedError::ComponentNotFound`] if the name is not
137    ///   registered or not yet initialized.
138    /// - [`ManagedError::Registry`] on type mismatch.
139    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    /// Serve until the root shutdown token fires.
146    ///
147    /// Components are stopped in reverse dependency order after the
148    /// shutdown signal is received.
149    ///
150    /// # Errors
151    /// Returns the first error from component stop.
152    pub async fn serve(&self) -> Result<(), ManagedError> {
153        // Wait for shutdown signal.
154        self.shutdown.wait().await;
155
156        // Ordered shutdown: components in reverse dependency order.
157        self.registry.stop_all().await?;
158        Ok(())
159    }
160
161    /// Aggregate health of every initialized component.
162    #[must_use]
163    pub async fn health(&self) -> std::collections::HashMap<String, HealthStatus> {
164        self.registry.health().await
165    }
166
167    /// Returns `true` if every component reports healthy.
168    #[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    /// Aggregate all component health into a single
175    /// [`HealthStatus`] using worst-case semantics.
176    ///
177    /// Returns `Unhealthy` if any component is unhealthy, `Degraded`
178    /// if any is degraded, `Healthy` otherwise (including empty).
179    #[must_use]
180    pub async fn overall_health(&self) -> HealthStatus {
181        let map = self.health().await;
182        HealthStatus::aggregate(&map)
183    }
184
185    /// Returns `true` if every component is at least operational
186    /// (healthy or degraded). This is the readiness gate suitable
187    /// for load-balancer probes.
188    #[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    /// Build a JSON `/healthz` response body containing the overall
195    /// status and per-component breakdown.
196    #[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    /// Hot-reload a running component by replacing it with a new
203    /// instance of the same type.
204    ///
205    /// The drain-aware protocol:
206    ///
207    /// 1. Calls `pre_replace_hook` on the old instance.
208    /// 2. Starts the new instance. If this fails, the old instance
209    ///    remains in place.
210    /// 3. Atomically swaps the instance in the registry. Existing
211    ///    `Arc<T>` clones held by other tasks keep the old instance
212    ///    alive until dropped (natural drain).
213    /// 4. Calls `post_replace_hook` on the old instance (best-effort).
214    ///
215    /// Returns the old `Arc<T>` so the caller can await explicit
216    /// cleanup or hold it for drain purposes.
217    ///
218    /// # Errors
219    /// - [`ManagedError::ComponentNotFound`] if the name is not
220    ///   registered.
221    /// - [`ManagedError::Reload`] if the component is not running,
222    ///   or if any phase of the replace protocol fails.
223    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        // Downcast the old instance back to Arc<T>.
242        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    /// Low-level hot-reload using a type-erased replacement.
250    ///
251    /// This is the untyped counterpart of [`ManagedRuntime::reload`]:
252    /// the caller supplies a fully constructed `Box<dyn AnyComponent>`
253    /// instead of a typed `T`. Useful when the replacement was built
254    /// through a factory or configuration-driven path.
255    ///
256    /// # Errors
257    /// See [`ManagedRuntime::reload`].
258    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        // Verify old component is in place.
417        let old: Arc<TestComp> = managed
418            .component::<TestComp>("c1")
419            .expect("lookup should succeed");
420        assert_eq!(old.label, "old");
421
422        // Reload with a new instance.
423        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        // The returned old instance should have the old label.
434        assert_eq!(returned_old.label, "old");
435
436        // The registry should now hold the new instance.
437        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}