1use 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#[async_trait]
22pub trait Engine: Send + Sync {
23 fn state(&self) -> PipelineState;
25
26 fn recent_events(&self, n: usize) -> Vec<PipelineEvent>;
28
29 async fn run_research(&self, task: &str) -> Result<Value, String>;
31
32 fn advance(&self) -> Result<PipelineState, String>;
35}
36
37const DEFAULT_EVENT_WINDOW: usize = 100;
39const 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
96pub 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
107pub 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}