Skip to main content

kova_api/
engine.rs

1//! Engine-facing endpoints: pipeline state, event tail, and research runs.
2//!
3//! `kova-core` implements [`Engine`] and wires it in via
4//! [`router_with_engine`]; the endpoints themselves stay transport-agnostic
5//! and are testable against a fake engine.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use axum::{
11    extract::{Query, State},
12    http::StatusCode,
13    routing::{get, post},
14    Json, Router,
15};
16use kova_primitives::{KovaError, PipelineEvent, PipelineState};
17use serde::Deserialize;
18use serde_json::{json, Value};
19
20/// What the engine exposes over the API. Implemented by `kova-core`.
21#[async_trait]
22pub trait Engine: Send + Sync {
23    /// The current pipeline state row.
24    fn state(&self) -> PipelineState;
25
26    /// The last `n` pipeline events, oldest first.
27    fn recent_events(&self, n: usize) -> Vec<PipelineEvent>;
28
29    /// Run one Research task to completion and return a JSON summary.
30    async fn run_research(&self, task: &str) -> Result<Value, String>;
31
32    /// Advance the pipeline to the next phase, or error when the current status
33    /// is not `ReadyToAdvance` or the phase is already final.
34    fn advance(&self) -> Result<PipelineState, String>;
35}
36
37/// Default window for `GET /pipeline/events`.
38const DEFAULT_EVENT_WINDOW: usize = 100;
39/// Maximum window a client can request.
40const MAX_EVENT_WINDOW: usize = 1000;
41
42#[derive(Debug, Deserialize)]
43struct EventsQuery {
44    #[serde(default)]
45    n: Option<usize>,
46}
47
48#[derive(Debug, Deserialize)]
49struct ResearchRequest {
50    task: String,
51}
52
53async fn state(State(engine): State<Arc<dyn Engine>>) -> Json<PipelineState> {
54    Json(engine.state())
55}
56
57async fn events(
58    State(engine): State<Arc<dyn Engine>>,
59    Query(query): Query<EventsQuery>,
60) -> Json<Vec<PipelineEvent>> {
61    let n = query
62        .n
63        .unwrap_or(DEFAULT_EVENT_WINDOW)
64        .min(MAX_EVENT_WINDOW);
65    Json(engine.recent_events(n))
66}
67
68async fn research(
69    State(engine): State<Arc<dyn Engine>>,
70    Json(request): Json<ResearchRequest>,
71) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
72    if request.task.trim().is_empty() {
73        return Err((
74            StatusCode::BAD_REQUEST,
75            Json(json!({ "error": "task must not be empty" })),
76        ));
77    }
78    match engine.run_research(&request.task).await {
79        Ok(value) => Ok(Json(value)),
80        Err(message) => Err((
81            StatusCode::INTERNAL_SERVER_ERROR,
82            Json(json!({ "error": message })),
83        )),
84    }
85}
86
87async fn advance(
88    State(engine): State<Arc<dyn Engine>>,
89) -> Result<Json<PipelineState>, (StatusCode, Json<Value>)> {
90    match engine.advance() {
91        Ok(state) => Ok(Json(state)),
92        Err(message) => Err((StatusCode::CONFLICT, Json(json!({ "error": message })))),
93    }
94}
95
96/// [`crate::router`] plus the `/pipeline/*` engine endpoints.
97pub fn router_with_engine(engine: Arc<dyn Engine>) -> Router {
98    Router::new()
99        .route("/pipeline/state", get(state))
100        .route("/pipeline/events", get(events))
101        .route("/pipeline/research", post(research))
102        .route("/pipeline/advance", post(advance))
103        .with_state(engine)
104        .merge(crate::router())
105}
106
107/// Bind `addr` and serve `app` until the process exits.
108pub async fn serve(addr: String, app: Router) -> Result<(), KovaError> {
109    let listener = tokio::net::TcpListener::bind(&addr)
110        .await
111        .map_err(|e| KovaError::Config(format!("bind {addr}: {e}")))?;
112    tracing::info!(addr = %addr, "engine api listening");
113    axum::serve(listener, app)
114        .await
115        .map_err(|e| KovaError::Io(std::io::Error::other(e.to_string())))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use axum::body::{to_bytes, Body};
122    use axum::http::{header, Method, Request};
123    use kova_primitives::{Phase, PhaseStatus};
124    use tower::ServiceExt;
125
126    struct FakeEngine;
127
128    #[async_trait]
129    impl Engine for FakeEngine {
130        fn state(&self) -> PipelineState {
131            PipelineState::initial()
132        }
133
134        fn recent_events(&self, n: usize) -> Vec<PipelineEvent> {
135            (0..n)
136                .map(|i| PipelineEvent::Log {
137                    level: "info".into(),
138                    message: format!("event {i}"),
139                })
140                .collect()
141        }
142
143        async fn run_research(&self, task: &str) -> Result<Value, String> {
144            Ok(json!({
145                "status": "complete",
146                "task": task,
147                "summary": "canned summary",
148            }))
149        }
150
151        fn advance(&self) -> Result<PipelineState, String> {
152            Ok(PipelineState {
153                phase: Phase::Verification,
154                status: PhaseStatus::Idle,
155                plan: None,
156            })
157        }
158    }
159
160    fn app() -> Router {
161        router_with_engine(Arc::new(FakeEngine))
162    }
163
164    async fn get_response(uri: &str) -> axum::http::Response<Body> {
165        app()
166            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
167            .await
168            .unwrap()
169    }
170
171    async fn post_json(uri: &str, body: &str) -> axum::http::Response<Body> {
172        app()
173            .oneshot(
174                Request::builder()
175                    .method(Method::POST)
176                    .uri(uri)
177                    .header(header::CONTENT_TYPE, "application/json")
178                    .body(Body::from(body.to_string()))
179                    .unwrap(),
180            )
181            .await
182            .unwrap()
183    }
184
185    async fn json_body(response: axum::http::Response<Body>) -> Value {
186        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
187        serde_json::from_slice(&bytes).unwrap()
188    }
189
190    #[tokio::test]
191    async fn pipeline_state_reports_research_phase() {
192        let response = get_response("/pipeline/state").await;
193        assert_eq!(response.status(), StatusCode::OK);
194        assert_eq!(json_body(response).await["phase"], "research");
195    }
196
197    #[tokio::test]
198    async fn pipeline_events_returns_requested_window() {
199        let response = get_response("/pipeline/events?n=5").await;
200        assert_eq!(response.status(), StatusCode::OK);
201        let events = json_body(response).await;
202        let list = events
203            .as_array()
204            .expect("events endpoint should return a JSON array");
205        assert_eq!(list.len(), 5);
206    }
207
208    #[tokio::test]
209    async fn research_rejects_blank_task() {
210        let response = post_json("/pipeline/research", r#"{"task":"  "}"#).await;
211        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
212        assert_eq!(json_body(response).await["error"], "task must not be empty");
213    }
214
215    #[tokio::test]
216    async fn research_returns_canned_summary() {
217        let response = post_json("/pipeline/research", r#"{"task":"probe"}"#).await;
218        assert_eq!(response.status(), StatusCode::OK);
219        let body = json_body(response).await;
220        assert_eq!(body["task"], "probe");
221        assert_eq!(body["summary"], "canned summary");
222    }
223
224    #[tokio::test]
225    async fn pipeline_advance_returns_next_state() {
226        let response = post_json("/pipeline/advance", "").await;
227        assert_eq!(response.status(), StatusCode::OK);
228        let body = json_body(response).await;
229        assert_eq!(body["phase"], "verification");
230        assert_eq!(body["status"], "idle");
231    }
232
233    #[tokio::test]
234    async fn merged_router_serves_base_health() {
235        let response = get_response("/health").await;
236        assert_eq!(response.status(), StatusCode::OK);
237        assert_eq!(json_body(response).await["status"], "ok");
238    }
239}