use std::sync::Arc;
use async_trait::async_trait;
use axum::{
extract::{Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use kova_primitives::{KovaError, PipelineEvent, PipelineState};
use serde::Deserialize;
use serde_json::{json, Value};
#[async_trait]
pub trait Engine: Send + Sync {
fn state(&self) -> PipelineState;
fn recent_events(&self, n: usize) -> Vec<PipelineEvent>;
async fn run_research(&self, task: &str) -> Result<Value, String>;
fn advance(&self) -> Result<PipelineState, String>;
}
const DEFAULT_EVENT_WINDOW: usize = 100;
const MAX_EVENT_WINDOW: usize = 1000;
#[derive(Debug, Deserialize)]
struct EventsQuery {
#[serde(default)]
n: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ResearchRequest {
task: String,
}
async fn state(State(engine): State<Arc<dyn Engine>>) -> Json<PipelineState> {
Json(engine.state())
}
async fn events(
State(engine): State<Arc<dyn Engine>>,
Query(query): Query<EventsQuery>,
) -> Json<Vec<PipelineEvent>> {
let n = query
.n
.unwrap_or(DEFAULT_EVENT_WINDOW)
.min(MAX_EVENT_WINDOW);
Json(engine.recent_events(n))
}
async fn research(
State(engine): State<Arc<dyn Engine>>,
Json(request): Json<ResearchRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
if request.task.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "task must not be empty" })),
));
}
match engine.run_research(&request.task).await {
Ok(value) => Ok(Json(value)),
Err(message) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": message })),
)),
}
}
async fn advance(
State(engine): State<Arc<dyn Engine>>,
) -> Result<Json<PipelineState>, (StatusCode, Json<Value>)> {
match engine.advance() {
Ok(state) => Ok(Json(state)),
Err(message) => Err((StatusCode::CONFLICT, Json(json!({ "error": message })))),
}
}
pub fn router_with_engine(engine: Arc<dyn Engine>) -> Router {
Router::new()
.route("/pipeline/state", get(state))
.route("/pipeline/events", get(events))
.route("/pipeline/research", post(research))
.route("/pipeline/advance", post(advance))
.with_state(engine)
.merge(crate::router())
}
pub async fn serve(addr: String, app: Router) -> Result<(), KovaError> {
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| KovaError::Config(format!("bind {addr}: {e}")))?;
tracing::info!(addr = %addr, "engine api listening");
axum::serve(listener, app)
.await
.map_err(|e| KovaError::Io(std::io::Error::other(e.to_string())))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::{to_bytes, Body};
use axum::http::{header, Method, Request};
use kova_primitives::{Phase, PhaseStatus};
use tower::ServiceExt;
struct FakeEngine;
#[async_trait]
impl Engine for FakeEngine {
fn state(&self) -> PipelineState {
PipelineState::initial()
}
fn recent_events(&self, n: usize) -> Vec<PipelineEvent> {
(0..n)
.map(|i| PipelineEvent::Log {
level: "info".into(),
message: format!("event {i}"),
})
.collect()
}
async fn run_research(&self, task: &str) -> Result<Value, String> {
Ok(json!({
"status": "complete",
"task": task,
"summary": "canned summary",
}))
}
fn advance(&self) -> Result<PipelineState, String> {
Ok(PipelineState {
phase: Phase::Verification,
status: PhaseStatus::Idle,
plan: None,
})
}
}
fn app() -> Router {
router_with_engine(Arc::new(FakeEngine))
}
async fn get_response(uri: &str) -> axum::http::Response<Body> {
app()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap()
}
async fn post_json(uri: &str, body: &str) -> axum::http::Response<Body> {
app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap()
}
async fn json_body(response: axum::http::Response<Body>) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn pipeline_state_reports_research_phase() {
let response = get_response("/pipeline/state").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["phase"], "research");
}
#[tokio::test]
async fn pipeline_events_returns_requested_window() {
let response = get_response("/pipeline/events?n=5").await;
assert_eq!(response.status(), StatusCode::OK);
let events = json_body(response).await;
let list = events
.as_array()
.expect("events endpoint should return a JSON array");
assert_eq!(list.len(), 5);
}
#[tokio::test]
async fn research_rejects_blank_task() {
let response = post_json("/pipeline/research", r#"{"task":" "}"#).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(json_body(response).await["error"], "task must not be empty");
}
#[tokio::test]
async fn research_returns_canned_summary() {
let response = post_json("/pipeline/research", r#"{"task":"probe"}"#).await;
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["task"], "probe");
assert_eq!(body["summary"], "canned summary");
}
#[tokio::test]
async fn pipeline_advance_returns_next_state() {
let response = post_json("/pipeline/advance", "").await;
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["phase"], "verification");
assert_eq!(body["status"], "idle");
}
#[tokio::test]
async fn merged_router_serves_base_health() {
let response = get_response("/health").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["status"], "ok");
}
}