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