use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::mcp_handle::McpHandleError;
use crate::routes::sessions::{map_tool_result, mpm_handle};
use crate::server::AppState;
pub(crate) const DISK_SURVEY_TOOL: &str = "disk_survey";
pub(crate) const DEFAULT_BUDGET_SECONDS: u64 = 20;
pub(crate) const MAX_BUDGET_SECONDS: u64 = 25;
#[derive(Debug, Default, Deserialize)]
pub struct SurveyQuery {
project: Option<String>,
budget_seconds: Option<u64>,
}
pub(crate) fn survey_args(query: &SurveyQuery) -> Value {
let budget = query
.budget_seconds
.unwrap_or(DEFAULT_BUDGET_SECONDS)
.clamp(1, MAX_BUDGET_SECONDS);
let mut args = json!({ "budget_seconds": budget });
if let Some(project) = query.project.as_deref().filter(|p| !p.is_empty()) {
args["project"] = Value::String(project.to_string());
}
args
}
pub async fn tree_handler(
State(state): State<AppState>,
Query(query): Query<SurveyQuery>,
) -> axum::response::Response {
let Some(handle) = mpm_handle(&state) else {
return unreachable_daemon();
};
map_survey_result(
handle
.call_tool_checked(DISK_SURVEY_TOOL, survey_args(&query))
.await,
)
}
fn unreachable_daemon() -> axum::response::Response {
(
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(json!({
"status": "unreachable",
"hint": "trusty-mpm is not reachable — the disk survey needs its MCP bridge",
})),
)
.into_response()
}
fn map_survey_result(result: Result<Value, McpHandleError>) -> axum::response::Response {
match result {
Err(McpHandleError::Absent | McpHandleError::Backoff { .. }) => unreachable_daemon(),
Err(e @ McpHandleError::Other(_)) => {
tracing::warn!("disk route: the survey call failed: {e:#}");
(
StatusCode::BAD_GATEWAY,
axum::Json(json!({
"status": "survey_failed",
"hint": "trusty-mpm did not answer the disk survey — it is bounded by \
the console's 30-second MCP call timeout; check the daemon's log",
})),
)
.into_response()
}
other => map_tool_result(other),
}
}
pub async fn worktree_handler(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<SurveyQuery>,
) -> axum::response::Response {
let Some(handle) = mpm_handle(&state) else {
return unreachable_daemon();
};
let survey = match handle
.call_tool_checked(DISK_SURVEY_TOOL, survey_args(&query))
.await
{
Ok(survey) => survey,
Err(e) => return map_survey_result(Err(e)),
};
match select_worktree(&survey, &id) {
Some(detail) => axum::Json(detail).into_response(),
None => (
StatusCode::NOT_FOUND,
axum::Json(json!({
"status": "not_found",
"id": id,
"hint": "no worktree with that id is in the survey — it may have been \
removed, or the `project` scope may exclude it",
})),
)
.into_response(),
}
}
pub(crate) fn select_worktree(survey: &Value, id: &str) -> Option<Value> {
let projects = survey.get("root")?.get("projects")?.as_array()?;
for project in projects {
let Some(worktrees) = project.get("worktrees").and_then(Value::as_array) else {
continue;
};
for worktree in worktrees {
if worktree.get("id").and_then(Value::as_str) != Some(id) {
continue;
}
return Some(json!({
"generated_at": survey.get("generated_at").cloned().unwrap_or(Value::Null),
"keep_list": survey.get("keep_list").cloned().unwrap_or(Value::Null),
"project": {
"name": project.get("name").cloned().unwrap_or(Value::Null),
"path": project.get("path").cloned().unwrap_or(Value::Null),
},
"worktree": worktree.clone(),
}));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::connector::ServiceConnector;
fn query(project: Option<&str>, budget: Option<u64>) -> SurveyQuery {
SurveyQuery {
project: project.map(str::to_string),
budget_seconds: budget,
}
}
fn fixture() -> Value {
json!({
"generated_at": "2026-09-07T00:00:00Z",
"keep_list": { "patterns": ["keep-me"], "invalid": [], },
"root": {
"path": "/w",
"projects": [
{ "name": "acme/one", "path": "/w/one", "worktrees": [
{ "id": "/w/one/.worktrees/a", "tier": "stale" },
]},
{ "name": "acme/two", "path": "/w/two", "worktrees": [
{ "id": "/w/two/.worktrees/b", "tier": "keep" },
]},
],
},
})
}
#[test]
fn survey_args_always_carries_a_budget() {
let args = survey_args(&query(None, None));
assert_eq!(args["budget_seconds"], json!(DEFAULT_BUDGET_SECONDS));
const { assert!(DEFAULT_BUDGET_SECONDS < 30) };
}
#[test]
fn survey_args_clamps_the_budget() {
assert_eq!(
survey_args(&query(None, Some(600)))["budget_seconds"],
json!(MAX_BUDGET_SECONDS)
);
assert_eq!(
survey_args(&query(None, Some(0)))["budget_seconds"],
json!(1)
);
assert_eq!(
survey_args(&query(None, Some(5)))["budget_seconds"],
json!(5)
);
}
#[test]
fn survey_args_omits_an_absent_project() {
let args = survey_args(&query(None, None));
assert!(args.get("project").is_none(), "closed schema: {args}");
let scoped = survey_args(&query(Some(""), None));
assert!(scoped.get("project").is_none(), "empty is not a scope");
let named = survey_args(&query(Some("acme/one"), None));
assert_eq!(named["project"], json!("acme/one"));
}
#[test]
fn select_worktree_finds_a_row_and_names_its_project() {
let picked = select_worktree(&fixture(), "/w/two/.worktrees/b").expect("row");
assert_eq!(picked["worktree"]["tier"], json!("keep"));
assert_eq!(picked["project"]["name"], json!("acme/two"));
assert_eq!(picked["keep_list"]["patterns"], json!(["keep-me"]));
}
#[test]
fn select_worktree_misses_an_unknown_id() {
assert!(select_worktree(&fixture(), "/w/nope").is_none());
assert!(select_worktree(&json!({}), "/w/one/.worktrees/a").is_none());
}
async fn state() -> AppState {
let connectors: Vec<Box<dyn ServiceConnector>> = Vec::new();
AppState::new(connectors)
}
async fn body(resp: axum::response::Response) -> Value {
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body");
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
}
#[tokio::test]
async fn a_transport_failure_carries_a_body() {
let resp = map_survey_result(Err(McpHandleError::Other(anyhow::anyhow!(
"MCP request timed out after 30s"
))));
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
let json = body(resp).await;
assert_eq!(json["status"], json!("survey_failed"), "{json:#}");
assert!(
json["hint"]
.as_str()
.is_some_and(|h| h.contains("30-second")),
"{json:#}"
);
assert!(
!json.to_string().contains("MCP request timed out"),
"{json:#}"
);
}
#[tokio::test]
async fn an_absent_handle_carries_a_body() {
let resp = map_survey_result(Err(McpHandleError::Absent));
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body(resp).await["status"], json!("unreachable"));
}
#[tokio::test]
async fn tree_absent_binary_does_not_500() {
let resp = tree_handler(State(state().await), Query(query(None, None))).await;
assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn worktree_absent_binary_does_not_500() {
let resp = worktree_handler(
State(state().await),
Path("/w/one/.worktrees/a".to_string()),
Query(query(None, None)),
)
.await;
assert!(
matches!(
resp.status(),
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE | StatusCode::BAD_GATEWAY
),
"unexpected status {}",
resp.status()
);
}
}