use ninox_core::{events::Engine, lifecycle::brain_harvest, types::Session};
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::Serialize;
use std::sync::Arc;
pub fn sessions_router(engine: Arc<Engine>) -> Router {
Router::new()
.route("/", get(list_sessions))
.route("/:id", axum::routing::delete(terminate_session))
.route("/:id/diff", get(session_diff))
.with_state(engine)
}
async fn list_sessions(State(e): State<Arc<Engine>>) -> Result<Json<Vec<Session>>, StatusCode> {
e.store
.list_sessions()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[derive(Serialize)]
struct DiffResponse {
diff: Option<String>,
}
async fn session_diff(
State(e): State<Arc<Engine>>,
Path(id): Path<String>,
) -> Result<Json<DiffResponse>, StatusCode> {
let session = e
.store
.get_session(&id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let Some(workspace) = session.workspace_path else {
return Ok(Json(DiffResponse { diff: None }));
};
let diff = brain_harvest::compute_diff(std::path::Path::new(&workspace)).await;
Ok(Json(DiffResponse { diff }))
}
async fn terminate_session(
State(e): State<Arc<Engine>>,
Path(id): Path<String>,
) -> StatusCode {
match e.terminate_session(&id).await {
Ok(_) => StatusCode::NO_CONTENT,
Err(err) => {
tracing::error!("terminate {id}: {err}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ninox_core::{events::Engine, store::Store, types::*};
use axum::body::Body;
use http::{Request, StatusCode};
use std::sync::Arc;
use tempfile::tempdir;
use tower::ServiceExt;
fn test_engine() -> Arc<Engine> {
let dir = tempdir().unwrap();
let path = dir.path().join("t.db");
let store = Arc::new(Store::open(&path).unwrap());
std::mem::forget(dir);
Engine::new(store)
}
#[tokio::test]
async fn list_empty() {
let app = sessions_router(test_engine());
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let sessions: Vec<Session> = serde_json::from_slice(&body).unwrap();
assert!(sessions.is_empty());
}
#[tokio::test]
async fn list_returns_stored() {
let engine = test_engine();
engine
.store
.upsert_session(&Session {
id: "s1".into(),
orchestrator_id: None,
name: "w".into(),
repo: "r".into(),
status: SessionStatus::Working,
agent_type: "c".into(),
cost_usd: 0.0,
started_at: 0,
pr_number: None,
pr_id: None,
workspace_path: None,
pid: None,
model: None,
context_tokens: None,
catalogue_path: None,
context_used_pct: None,
context_total_tokens: None,
context_window_size: None,
claude_session_id: None,
summary: None,
terminal_at: None, gate_status: None,
})
.unwrap();
let app = sessions_router(engine);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let sessions: Vec<Session> = serde_json::from_slice(&body).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
}
#[tokio::test]
async fn diff_returns_404_for_unknown_session() {
let response = sessions_router(test_engine())
.oneshot(Request::builder().uri("/nope/diff").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn diff_is_none_without_a_recorded_workspace() {
let engine = test_engine();
engine
.store
.upsert_session(&Session {
id: "s1".into(),
orchestrator_id: None,
name: "w".into(),
repo: "r".into(),
status: SessionStatus::Working,
agent_type: "c".into(),
cost_usd: 0.0,
started_at: 0,
pr_number: None,
pr_id: None,
workspace_path: None,
pid: None,
model: None,
context_tokens: None,
catalogue_path: None,
context_used_pct: None,
context_total_tokens: None,
context_window_size: None,
claude_session_id: None,
summary: None,
terminal_at: None, gate_status: None,
})
.unwrap();
let response = sessions_router(engine)
.oneshot(Request::builder().uri("/s1/diff").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["diff"].is_null());
}
#[tokio::test]
async fn diff_returns_workspace_diff_including_lockfile_only_changes() {
let repo = tempdir().unwrap().keep();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(["-C", repo.to_str().unwrap()])
.args(args)
.output()
.unwrap()
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(repo.join("README.md"), "x").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "init"]);
run(&["checkout", "-q", "-b", "feature"]);
std::fs::write(repo.join("Cargo.lock"), "version = 3\n").unwrap();
run(&["add", "Cargo.lock"]);
run(&["commit", "-q", "-m", "bump lockfile"]);
let engine = test_engine();
engine
.store
.upsert_session(&Session {
id: "s1".into(),
orchestrator_id: None,
name: "w".into(),
repo: "r".into(),
status: SessionStatus::Working,
agent_type: "c".into(),
cost_usd: 0.0,
started_at: 0,
pr_number: None,
pr_id: None,
workspace_path: Some(repo.to_str().unwrap().to_string()),
pid: None,
model: None,
context_tokens: None,
catalogue_path: None,
context_used_pct: None,
context_total_tokens: None,
context_window_size: None,
claude_session_id: None,
summary: None,
terminal_at: None, gate_status: None,
})
.unwrap();
let response = sessions_router(engine)
.oneshot(Request::builder().uri("/s1/diff").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["diff"].as_str().unwrap().contains("Cargo.lock"));
}
#[tokio::test]
async fn delete_returns_no_content() {
let engine = test_engine();
engine
.store
.upsert_session(&Session {
id: "s1".into(),
orchestrator_id: None,
name: "w".into(),
repo: "r".into(),
status: SessionStatus::Working,
agent_type: "c".into(),
cost_usd: 0.0,
started_at: 0,
pr_number: None,
pr_id: None,
workspace_path: None,
pid: None,
model: None,
context_tokens: None,
catalogue_path: None,
context_used_pct: None,
context_total_tokens: None,
context_window_size: None,
claude_session_id: None,
summary: None,
terminal_at: None, gate_status: None,
})
.unwrap();
let response = sessions_router(engine)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/s1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
}