Skip to main content

klieo_ops_api/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `klieo-ops-api` — read-only HTTP monitoring router for klieo agents.
4//!
5//! Mount the router returned by [`OpsRouterBuilder::build`] at `/_ops`
6//! to expose run history, A2A task state, and MCP stream state.
7//!
8//! All three backing stores are optional. Endpoints whose store is not
9//! wired return `501 Not Implemented`.
10
11mod auth;
12mod error;
13pub mod handlers;
14mod state;
15pub mod types;
16
17use crate::state::OpsState;
18use axum::{middleware, Router};
19use klieo_a2a::task_store::A2aTaskStore;
20use klieo_auth_common::Authenticator;
21use klieo_core::resume::KvResumeBuffer;
22use klieo_runlog::RunLogStore;
23use std::sync::Arc;
24
25/// Error returned by [`OpsRouterBuilder::build`].
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum OpsBuilderError {
29    /// [`OpsRouterBuilder::with_authenticator`] was not called before [`OpsRouterBuilder::build`].
30    #[error("authenticator not configured — call with_authenticator() before build()")]
31    MissingAuthenticator,
32}
33
34/// Builds the `/_ops` monitoring router.
35pub struct OpsRouterBuilder {
36    authenticator: Option<Arc<dyn Authenticator>>,
37    run_log_store: Option<Arc<dyn RunLogStore>>,
38    task_store: Option<Arc<A2aTaskStore>>,
39    resume_buffer: Option<Arc<KvResumeBuffer>>,
40}
41
42impl OpsRouterBuilder {
43    /// Start an empty builder. [`Self::build`] returns
44    /// [`OpsBuilderError::MissingAuthenticator`] until
45    /// [`Self::with_authenticator`] or [`Self::with_dev_auth`] is called.
46    pub fn new() -> Self {
47        Self {
48            authenticator: None,
49            run_log_store: None,
50            task_store: None,
51            resume_buffer: None,
52        }
53    }
54
55    /// Required — [`Self::build`] returns `Err` if not called.
56    pub fn with_authenticator(mut self, auth: Arc<dyn Authenticator>) -> Self {
57        self.authenticator = Some(auth);
58        self
59    }
60
61    /// Capability-shaped default for laptop-dev — wires
62    /// [`klieo_auth_common::AllowAnonymous`] so every request reaches
63    /// the `/_ops` router without credentials.
64    ///
65    /// **TEST FIXTURE / DEMO ONLY.** Gated behind the `dev-auth`
66    /// feature (CWE-1188); production builds without that feature
67    /// cannot reach `AllowAnonymous` and fail to compile rather than
68    /// silently accepting any caller. Pair with a non-production
69    /// network boundary (loopback bind, dev container) — never expose
70    /// a dev-auth `/_ops` to a multi-tenant network.
71    #[cfg(feature = "dev-auth")]
72    pub fn with_dev_auth(self) -> Self {
73        self.with_authenticator(Arc::new(klieo_auth_common::AllowAnonymous))
74    }
75
76    /// Enables `GET /runs`, `GET /runs/{id}`, `GET /agents`.
77    pub fn with_run_log_store(mut self, store: Arc<dyn RunLogStore>) -> Self {
78        self.run_log_store = Some(store);
79        self
80    }
81
82    /// Enables `GET /tasks`.
83    pub fn with_task_store(mut self, store: Arc<A2aTaskStore>) -> Self {
84        self.task_store = Some(store);
85        self
86    }
87
88    /// Enables `GET /streams`.
89    pub fn with_resume_buffer(mut self, buf: Arc<KvResumeBuffer>) -> Self {
90        self.resume_buffer = Some(buf);
91        self
92    }
93
94    /// # Errors
95    /// Returns [`OpsBuilderError::MissingAuthenticator`] if
96    /// [`Self::with_authenticator`] was not called before `build`.
97    pub fn build(self) -> Result<Router, OpsBuilderError> {
98        let authenticator = self
99            .authenticator
100            .ok_or(OpsBuilderError::MissingAuthenticator)?;
101
102        let state = Arc::new(OpsState {
103            authenticator,
104            run_log_store: self.run_log_store,
105            task_store: self.task_store,
106            resume_buffer: self.resume_buffer,
107        });
108
109        use crate::handlers::{runs, streams, tasks};
110        use axum::routing::get;
111
112        Ok(Router::new()
113            .route("/runs", get(runs::list_runs))
114            .route("/runs/:run_id", get(runs::get_run))
115            .route("/agents", get(runs::list_agents))
116            .route("/tasks", get(tasks::list_tasks))
117            .route("/streams", get(streams::list_streams))
118            .layer(middleware::from_fn_with_state(
119                Arc::clone(&state),
120                crate::auth::require_auth,
121            ))
122            .with_state(state))
123    }
124}
125
126impl Default for OpsRouterBuilder {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn build_without_authenticator_returns_missing_authenticator() {
138        let err = OpsRouterBuilder::new().build().unwrap_err();
139        assert!(matches!(err, OpsBuilderError::MissingAuthenticator));
140    }
141
142    #[cfg(feature = "dev-auth")]
143    #[test]
144    fn with_dev_auth_satisfies_authenticator_requirement() {
145        let result = OpsRouterBuilder::new().with_dev_auth().build();
146        assert!(
147            result.is_ok(),
148            "with_dev_auth must produce a buildable router"
149        );
150    }
151}