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