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}