Skip to main content

behest_runtime/
component.rs

1//! The `Component` trait: lifecycle contract for every pluggable runtime
2//! building block.
3//!
4//! In `behest`, every pluggable element — chat providers, embedding
5//! providers, tools, context adapters, stores, event publishers, snapshot
6//! stores, RAG adapters, and the transport layer — implements [`Component`].
7//! This gives the runtime a uniform shape for: declarative configuration,
8//! ordered initialization, lifecycle management, and health aggregation.
9//!
10//! # Lifecycle
11//!
12//! ```text
13//!    register factory ──► init ──► start ──► [serve] ──► stop
14//!                            │
15//!                            └──► on init error: instance is dropped
16//! ```
17//!
18//! - [`Component::init`] is the only phase that takes a configuration value.
19//!   It must be a pure constructor: no side effects, no network calls.
20//! - [`Component::start`] opens connections, spawns background tasks, and
21//!   registers with external systems. It must be idempotent: re-starting
22//!   a component that is already started should succeed.
23//! - [`Component::stop`] is the inverse of `start`. It must drain in-flight
24//!   work, close connections, and persist pending state. Idempotent.
25//! - [`Component::health`] is a non-mutating probe. It must be cheap
26//!   (sub-millisecond) and safe to call from a hot path.
27//!
28//! # Errors
29//!
30//! Every phase returns `Result<_, Self::Error>`. The associated error type
31//! must be a `std::error::Error` so that the registry can format it
32//! uniformly. The trait is deliberately typed: implementations are free to
33//! use the most precise error type (e.g. `reqwest::Error` for HTTP
34//! providers) without forcing the registry to model every variant.
35//!
36//! # Hot-plugging
37//!
38//! Components are not required to be hot-swappable. The trait provides the
39//! primitive contract; hot-swap semantics live in the
40//! [`ExtensionPoint`](super::extension::ExtensionPoint) layer,
41//! which is responsible for atomic reference replacement and live-reference
42//! detection.
43
44#![allow(clippy::pedantic)]
45use std::any::Any;
46use std::error::Error as StdError;
47use std::fmt;
48use std::sync::Arc;
49
50use async_trait::async_trait;
51use schemars::JsonSchema;
52use serde::de::DeserializeOwned;
53
54use super::lifecycle::ShutdownToken;
55use behest_core::health::HealthStatus;
56
57/// Context handed to [`Component::init`] and propagated through dependent
58/// components.
59#[derive(Clone)]
60pub struct ComponentContext {
61    shutdown: ShutdownToken,
62}
63
64impl ComponentContext {
65    /// Construct a new context with the given shutdown token.
66    #[must_use]
67    pub fn new(shutdown: ShutdownToken) -> Self {
68        Self { shutdown }
69    }
70
71    /// Borrow the shutdown token. Components should select a child token
72    /// for their own background tasks so that a single component's
73    /// shutdown does not implicitly trigger a registry-wide one.
74    #[must_use]
75    pub fn shutdown(&self) -> ShutdownToken {
76        self.shutdown.clone()
77    }
78
79    /// Borrow a child shutdown token.
80    #[must_use]
81    pub fn child_shutdown(&self) -> ShutdownToken {
82        self.shutdown.child()
83    }
84}
85
86impl fmt::Debug for ComponentContext {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.debug_struct("ComponentContext").finish_non_exhaustive()
89    }
90}
91
92/// The core contract every pluggable runtime element implements.
93///
94/// A `Component` is:
95///
96/// - **Self-describing**: [`Component::NAME`] is a stable identifier used
97///   in config files, log spans, and metrics labels.
98/// - **Schema-validated**: [`Component::Config`] implements
99///   [`schemars::JsonSchema`], so the registry can emit a JSON schema
100///   for IDEs and CLI tools.
101/// - **Lifecycle-bounded**: the four-phase `init → start → [serve] → stop`
102///   contract lets the registry run a coherent graph of components.
103#[async_trait]
104pub trait Component: Send + Sync + 'static {
105    /// Stable identifier for the component kind (e.g. `"provider.openai"`,
106    /// `"store.session.redis"`). Used in configuration and logging.
107    const NAME: &'static str;
108
109    /// Configuration shape. Must be deserializable from JSON/YAML/TOML and
110    /// must produce a valid JSON Schema for documentation and validation.
111    type Config: DeserializeOwned + JsonSchema + Send + Sync + 'static;
112
113    /// Error type for lifecycle phases. Must implement [`std::error::Error`]
114    /// so the registry can chain and format errors uniformly.
115    type Error: StdError + Send + Sync + 'static;
116
117    /// Construct a component instance from its validated configuration.
118    ///
119    /// Implementations must not perform IO here; defer network and disk
120    /// access to [`Component::start`]. The `ctx` provides a shutdown
121    /// token that the component can plumb into background tasks.
122    async fn init(cfg: &Self::Config, ctx: &ComponentContext) -> Result<Self, Self::Error>
123    where
124        Self: Sized;
125
126    /// Begin serving. Default is a no-op. Override to spawn workers, open
127    /// connections, or warm caches.
128    ///
129    /// MUST be idempotent: calling `start` on an already-started component
130    /// is a no-op and returns `Ok(())`.
131    async fn start(&self) -> Result<(), Self::Error> {
132        Ok(())
133    }
134
135    /// Stop serving. Default is a no-op. Override to drain queues, close
136    /// connections, and persist state.
137    ///
138    /// MUST be idempotent. After `stop` returns, the component may be
139    /// re-`start`-ed.
140    async fn stop(&self) -> Result<(), Self::Error> {
141        Ok(())
142    }
143
144    /// Non-mutating health probe. Default is [`HealthStatus::healthy`].
145    /// Override to surface upstream connectivity, queue depth, retry
146    /// pressure, etc.
147    async fn health(&self) -> HealthStatus {
148        HealthStatus::healthy()
149    }
150
151    /// Names of components this component depends on. Used by the registry
152    /// to build a dependency graph and drive ordered initialization.
153    fn depends_on() -> &'static [&'static str] {
154        &[]
155    }
156
157    /// Called before this component is replaced by a new instance.
158    ///
159    /// Default is a no-op. Override to reject new traffic, flush
160    /// buffers, or signal upstream systems that this instance is
161    /// about to be swapped out.
162    ///
163    /// The component is still running when this hook fires; in-flight
164    /// references held by other tasks remain valid.
165    async fn pre_replace_hook(&self) -> Result<(), Self::Error> {
166        Ok(())
167    }
168
169    /// Called after this component has been replaced by a new instance.
170    ///
171    /// Default is a no-op. Override to clean up resources that were
172    /// not released during `stop`, or to notify upstream systems that
173    /// the replacement is complete.
174    ///
175    /// At this point, new traffic is routed to the replacement
176    /// instance. The old instance may still be held by tasks that
177    /// obtained an `Arc` before the swap.
178    async fn post_replace_hook(&self) -> Result<(), Self::Error> {
179        Ok(())
180    }
181}
182
183/// Object-safe view of a [`Component`] instance, used by the registry to
184/// store and drive components of heterogeneous types uniformly.
185pub trait AnyComponent: Send + Sync + 'static {
186    /// Stable name of the underlying [`Component`] kind.
187    fn name(&self) -> &'static str;
188
189    /// Underlying concrete type, used by `ComponentRegistry::get` to
190    /// downcast back to a concrete `Arc<C>`. Returns a type-erased
191    /// `Arc<dyn Any>` clone of the typed instance.
192    fn as_any_arc(&self) -> Arc<dyn Any + Send + Sync>;
193
194    /// Begin serving. See [`Component::start`].
195    fn start(&self) -> futures_util::future::BoxFuture<'_, Result<(), AnyComponentError>>;
196
197    /// Stop serving. See [`Component::stop`].
198    fn stop(&self) -> futures_util::future::BoxFuture<'_, Result<(), AnyComponentError>>;
199
200    /// Health probe. See [`Component::health`].
201    fn health(&self) -> futures_util::future::BoxFuture<'_, HealthStatus>;
202
203    /// Called before this component is replaced. See
204    /// [`Component::pre_replace_hook`].
205    fn pre_replace(&self) -> futures_util::future::BoxFuture<'_, Result<(), AnyComponentError>>;
206
207    /// Called after this component has been replaced. See
208    /// [`Component::post_replace_hook`].
209    fn post_replace(&self) -> futures_util::future::BoxFuture<'_, Result<(), AnyComponentError>>;
210}
211
212/// Type-erased error from a boxed [`AnyComponent`]. The original typed
213/// error is preserved as a string for log purposes; if callers need the
214/// typed error they should downcast via [`AnyComponent::as_any_arc`].
215#[derive(Debug, thiserror::Error)]
216#[non_exhaustive]
217pub enum AnyComponentError {
218    /// The underlying component's typed error, formatted as a string.
219    #[error("component `{name}` failed: {message}")]
220    Component {
221        /// Name of the failing component.
222        name: String,
223        /// Human-readable message; the original typed error is preserved
224        /// for diagnostic purposes only.
225        message: String,
226    },
227    /// The component is registered but has not been initialized.
228    #[error("component `{0}` is not initialized")]
229    NotInitialized(String),
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use async_trait::async_trait;
236    use schemars::JsonSchema;
237    use serde::{Deserialize, Serialize};
238
239    #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
240    struct DummyConfig {
241        label: String,
242    }
243
244    struct DummyComponent {
245        label: String,
246    }
247
248    #[async_trait]
249    impl Component for DummyComponent {
250        const NAME: &'static str = "test.dummy";
251        type Config = DummyConfig;
252        type Error = std::io::Error;
253
254        async fn init(cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
255            Ok(Self {
256                label: cfg.label.clone(),
257            })
258        }
259    }
260
261    #[tokio::test]
262    async fn component_init_constructs_with_config() {
263        let shutdown = ShutdownToken::new();
264        let ctx = ComponentContext::new(shutdown);
265        let cfg = DummyConfig {
266            label: "alpha".into(),
267        };
268        let c = DummyComponent::init(&cfg, &ctx).await.unwrap_or_else(|e| {
269            panic!("init failed: {e}");
270        });
271        assert_eq!(c.label, "alpha");
272    }
273
274    #[tokio::test]
275    async fn default_lifecycle_is_noop() {
276        let shutdown = ShutdownToken::new();
277        let ctx = ComponentContext::new(shutdown);
278        let c = DummyComponent::init(&DummyConfig { label: "x".into() }, &ctx)
279            .await
280            .unwrap_or_else(|e| panic!("{e}"));
281        // start / stop / health all use defaults; must not fail.
282        let _ = c.start().await;
283        let _ = c.stop().await;
284        assert!(c.health().await.is_healthy());
285    }
286}