use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::mcp_handle::{McpHandleError, McpServiceHandle};
use crate::server::AppState;
const DEFAULT_ACTIVITY_LINES: u32 = 60;
fn mpm_handle(state: &AppState) -> Option<Arc<McpServiceHandle>> {
let handle = state.mcp_handles().get("trusty-mpm").cloned();
if handle.is_none() {
tracing::error!("sessions route: no MCP handle registered for trusty-mpm");
}
handle
}
pub fn map_tool_result(result: Result<Value, McpHandleError>) -> axum::response::Response {
match result {
Ok(val) => axum::Json(val).into_response(),
Err(McpHandleError::ToolUnavailable { tool, hint }) => {
tracing::warn!(tool = %tool, hint = %hint, "sessions route: tool unavailable");
(
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(json!({ "status": "degraded", "hint": hint })),
)
.into_response()
}
Err(
McpHandleError::Absent
| McpHandleError::Backoff { .. }
| McpHandleError::Degraded { .. },
) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
Err(e) => {
tracing::warn!("sessions route error: {e:#}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
async fn call(state: &AppState, tool: &str, args: Value) -> axum::response::Response {
let Some(handle) = mpm_handle(state) else {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
};
map_tool_result(handle.call_tool_checked(tool, args).await)
}
pub async fn list_handler(State(state): State<AppState>) -> axum::response::Response {
call(&state, "session_list", json!({})).await
}
pub async fn get_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> axum::response::Response {
call(&state, "session_status", json!({ "session_id": id })).await
}
#[derive(Deserialize)]
pub struct ActivityQuery {
lines: Option<u32>,
}
pub async fn activity_handler(
State(state): State<AppState>,
Path(id): Path<String>,
Query(params): Query<ActivityQuery>,
) -> axum::response::Response {
let lines = params.lines.unwrap_or(DEFAULT_ACTIVITY_LINES);
call(
&state,
"session_activity",
json!({ "session_id": id, "lines": lines }),
)
.await
}
pub async fn supervisor_handler(State(state): State<AppState>) -> axum::response::Response {
call(&state, "supervisor_status", json!({})).await
}
#[derive(Deserialize)]
pub struct NewSessionBody {
repo_url: String,
#[serde(rename = "ref")]
git_ref: String,
task: String,
#[serde(default)]
name_hint: Option<String>,
#[serde(default)]
runtime: Option<String>,
}
pub async fn new_handler(
State(state): State<AppState>,
axum::Json(body): axum::Json<NewSessionBody>,
) -> axum::response::Response {
let mut args = json!({
"repo_url": body.repo_url,
"ref": body.git_ref,
"task": body.task,
});
if let Some(obj) = args.as_object_mut() {
if let Some(hint) = body.name_hint {
obj.insert("name_hint".to_string(), json!(hint));
}
if let Some(rt) = body.runtime {
obj.insert("runtime".to_string(), json!(rt));
}
}
call(&state, "session_new", args).await
}
pub async fn stop_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> axum::response::Response {
call(&state, "session_stop", json!({ "session_id": id })).await
}
pub async fn resume_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> axum::response::Response {
call(&state, "session_resume", json!({ "session_id": id })).await
}
pub async fn decommission_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> axum::response::Response {
call(&state, "session_decommission", json!({ "session_id": id })).await
}
#[derive(Deserialize)]
pub struct BulkDeleteBody {
session_ids: Vec<String>,
}
pub async fn bulk_delete_handler(
State(state): State<AppState>,
axum::Json(body): axum::Json<BulkDeleteBody>,
) -> axum::response::Response {
call(
&state,
"session_delete_records",
json!({ "session_ids": body.session_ids }),
)
.await
}
#[derive(Deserialize)]
pub struct AutoResumeBody {
enabled: bool,
}
pub async fn auto_resume_handler(
State(state): State<AppState>,
axum::Json(body): axum::Json<AutoResumeBody>,
) -> axum::response::Response {
call(
&state,
"auto_resume_set",
json!({ "enabled": body.enabled }),
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use crate::server::build_router;
fn router() -> axum::Router {
build_router(AppState::new(vec![]))
}
async fn assert_not_500(method: &str, uri: &str, body: Body) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(body)
.expect("request");
let resp = router().oneshot(req).await.expect("response");
assert_ne!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"{method} {uri} must not 500 when binary absent (got {})",
resp.status()
);
}
#[tokio::test]
async fn list_absent_binary_does_not_500() {
assert_not_500("GET", "/api/console/sessions", Body::empty()).await;
}
#[tokio::test]
async fn get_absent_binary_does_not_500() {
assert_not_500("GET", "/api/console/sessions/abc", Body::empty()).await;
}
#[tokio::test]
async fn activity_absent_binary_does_not_500() {
assert_not_500(
"GET",
"/api/console/sessions/abc/activity?lines=20",
Body::empty(),
)
.await;
}
#[tokio::test]
async fn supervisor_absent_binary_does_not_500() {
assert_not_500("GET", "/api/console/sessions/supervisor", Body::empty()).await;
}
#[tokio::test]
async fn new_absent_binary_does_not_500() {
let body = Body::from(
json!({ "repo_url": "https://x/y", "ref": "main", "task": "t" }).to_string(),
);
assert_not_500("POST", "/api/console/sessions", body).await;
}
#[tokio::test]
async fn stop_absent_binary_does_not_500() {
assert_not_500("POST", "/api/console/sessions/abc/stop", Body::empty()).await;
}
#[tokio::test]
async fn resume_absent_binary_does_not_500() {
assert_not_500("POST", "/api/console/sessions/abc/resume", Body::empty()).await;
}
#[tokio::test]
async fn decommission_absent_binary_does_not_500() {
assert_not_500("DELETE", "/api/console/sessions/abc", Body::empty()).await;
}
#[tokio::test]
async fn auto_resume_absent_binary_does_not_500() {
let body = Body::from(json!({ "enabled": true }).to_string());
assert_not_500("POST", "/api/console/sessions/supervisor/auto-resume", body).await;
}
#[tokio::test]
async fn bulk_delete_absent_binary_does_not_500() {
let body = Body::from(json!({ "session_ids": ["abc"] }).to_string());
assert_not_500("POST", "/api/console/sessions/bulk-delete", body).await;
}
#[tokio::test]
async fn map_tool_result_tool_unavailable_is_503_with_hint() {
let resp = map_tool_result(Err(McpHandleError::ToolUnavailable {
tool: "session_list".to_string(),
hint: "upgrade trusty-mpm".to_string(),
}));
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn map_tool_result_absent_is_503() {
let resp = map_tool_result(Err(McpHandleError::Absent));
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
async fn router_primed_missing_tools() -> axum::Router {
let state = AppState::new(vec![]);
{
let handles = state.mcp_handles();
let mpm = handles.get("trusty-mpm").expect("mpm handle registered");
mpm.prime_connected_missing_tool_for_test("supervisor_status")
.await;
}
build_router(state)
}
async fn hint_of(resp: axum::http::Response<Body>) -> String {
let bytes = resp
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes()
.to_vec();
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
body["hint"].as_str().unwrap_or("").to_string()
}
#[tokio::test]
async fn supervisor_route_is_not_shadowed_by_id_capture() {
let router = router_primed_missing_tools().await;
let req = Request::builder()
.uri("/api/console/sessions/supervisor")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"primed-missing-tool supervisor route must be a capability-gated 503"
);
let hint = hint_of(resp).await;
assert!(
hint.contains("supervisor_status"),
"supervisor route must reach supervisor_handler (hint should name \
supervisor_status); got: {hint}"
);
assert!(
!hint.contains("session_status"),
"supervisor route must NOT be shadowed by the {{id}} capture \
(session_status); got: {hint}"
);
}
#[tokio::test]
async fn auto_resume_route_is_not_shadowed() {
let router = router_primed_missing_tools().await;
let body = Body::from(json!({ "enabled": true }).to_string());
let req = Request::builder()
.method("POST")
.uri("/api/console/sessions/supervisor/auto-resume")
.header("content-type", "application/json")
.body(body)
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let hint = hint_of(resp).await;
assert!(
hint.contains("auto_resume_set"),
"auto-resume route must reach auto_resume_handler (hint should name \
auto_resume_set); got: {hint}"
);
}
#[tokio::test]
async fn bulk_delete_route_is_not_shadowed() {
let router = router_primed_missing_tools().await;
let body = Body::from(json!({ "session_ids": ["abc"] }).to_string());
let req = Request::builder()
.method("POST")
.uri("/api/console/sessions/bulk-delete")
.header("content-type", "application/json")
.body(body)
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let hint = hint_of(resp).await;
assert!(
hint.contains("session_delete_records"),
"bulk-delete route must reach bulk_delete_handler (hint should name \
session_delete_records); got: {hint}"
);
}
#[tokio::test]
async fn ordinary_id_route_reaches_session_status() {
let router = router_primed_missing_tools().await;
let req = Request::builder()
.uri("/api/console/sessions/sess-abc123")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let hint = hint_of(resp).await;
assert!(
hint.contains("session_status"),
"ordinary id route must reach get_handler (session_status); got: {hint}"
);
}
}