trusty_console/routes/sessions.rs
1//! trusty-mpm session-manager HTTP routes — the single HTTP front door (#1222).
2//!
3//! Why: per the #1104 architecture principle, HTTP lives ONLY in trusty-console;
4//! the session-manager daemon speaks stdio MCP. These handlers turn the
5//! console's `/api/console/sessions/*` surface into the canonical operator
6//! interface for the session REST API (P3), rendering fleet state and driving
7//! lifecycle ops natively from the trusty-mpm MCP tools (P2) — never by proxying
8//! to the daemon's HTTP port.
9//! What: read routes (`list`, `get`, `activity`, `supervisor`) and write routes
10//! (`new`, `stop`, `resume`, `decommission`, `auto_resume`) that each call a
11//! trusty-mpm MCP tool through the shared `McpServiceHandle` via
12//! `call_tool_checked` (capability-gated). [`map_tool_result`] converts a tool
13//! result or `McpHandleError` into a clean HTTP response: 200 on success, 503
14//! when the binary is absent / in backoff / degraded / the tool is missing
15//! (with an actionable hint), 502 on any other transport error.
16//! Test: the `tests` module drives each handler with an absent-binary handle
17//! (CI has no trusty-mpm on PATH) and asserts no handler ever 500s.
18
19use std::sync::Arc;
20
21use axum::{
22 extract::{Path, Query, State},
23 http::StatusCode,
24 response::IntoResponse,
25};
26use serde::Deserialize;
27use serde_json::{Value, json};
28
29use crate::mcp_handle::{McpHandleError, McpServiceHandle};
30use crate::server::AppState;
31
32/// Default trailing pane lines for the activity route (parity with the MCP tool).
33const DEFAULT_ACTIVITY_LINES: u32 = 60;
34
35/// Resolve the trusty-mpm MCP handle from app state, or return a 503 response.
36///
37/// Why: every session route needs the mpm handle; when it is unregistered (it
38/// should always be present, but be defensive) the route must degrade to 503
39/// rather than panic.
40/// What: looks up `"trusty-mpm"` in the handle map; `Some(handle)` or `None`
41/// (the caller maps `None` to a 503). Returning `Option` rather than
42/// `Result<_, Response>` avoids carrying a large axum `Response` in the error
43/// variant (`clippy::result_large_err`).
44/// Test: indirectly via the route tests (handle is always registered in tests).
45fn mpm_handle(state: &AppState) -> Option<Arc<McpServiceHandle>> {
46 let handle = state.mcp_handles().get("trusty-mpm").cloned();
47 if handle.is_none() {
48 tracing::error!("sessions route: no MCP handle registered for trusty-mpm");
49 }
50 handle
51}
52
53/// Map a capability-gated MCP tool call result into an HTTP response.
54///
55/// Why: all session routes share the same error taxonomy — a missing tool means
56/// a stale daemon (503 + hint), an absent binary / backoff / degraded handle
57/// means the service is not reachable (503), and any other error is a transport
58/// failure (502). Centralising it keeps every handler a one-liner and the
59/// behaviour uniform (mirrors the analyze routes in `server.rs`).
60/// What: `Ok(val)` → 200 JSON; `ToolUnavailable` → 503 `{status, hint}`;
61/// `Absent|Backoff|Degraded` → bare 503; `Other` → 502.
62/// Test: `map_tool_result_*` unit tests below.
63pub fn map_tool_result(result: Result<Value, McpHandleError>) -> axum::response::Response {
64 match result {
65 Ok(val) => axum::Json(val).into_response(),
66 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
67 tracing::warn!(tool = %tool, hint = %hint, "sessions route: tool unavailable");
68 (
69 StatusCode::SERVICE_UNAVAILABLE,
70 axum::Json(json!({ "status": "degraded", "hint": hint })),
71 )
72 .into_response()
73 }
74 Err(
75 McpHandleError::Absent
76 | McpHandleError::Backoff { .. }
77 | McpHandleError::Degraded { .. },
78 ) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
79 Err(e) => {
80 tracing::warn!("sessions route error: {e:#}");
81 StatusCode::BAD_GATEWAY.into_response()
82 }
83 }
84}
85
86/// Call a trusty-mpm tool through the handle and map the result to a response.
87///
88/// Why: every handler resolves the handle then calls one tool; this collapses
89/// both steps so each route body is a single expression.
90/// What: resolves the handle (503 on absence), calls `call_tool_checked`, and
91/// passes the result through [`map_tool_result`].
92/// Test: exercised by every route test below.
93async fn call(state: &AppState, tool: &str, args: Value) -> axum::response::Response {
94 let Some(handle) = mpm_handle(state) else {
95 return StatusCode::SERVICE_UNAVAILABLE.into_response();
96 };
97 map_tool_result(handle.call_tool_checked(tool, args).await)
98}
99
100// ─── read routes ──────────────────────────────────────────────────────────────
101
102/// `GET /api/console/sessions` — list the managed-session fleet via `session_list`.
103///
104/// Why: the Sessions tab renders the fleet from this; native MCP, not a proxy.
105/// What: calls `session_list` (no args) and returns the JSON array.
106/// Test: `list_absent_binary_does_not_500`.
107pub async fn list_handler(State(state): State<AppState>) -> axum::response::Response {
108 call(&state, "session_list", json!({})).await
109}
110
111/// `GET /api/console/sessions/{id}` — detailed status via `session_status`.
112///
113/// Why: the Sessions tab's per-session detail view needs the full record.
114/// What: calls `session_status` with the path `session_id`.
115/// Test: `get_absent_binary_does_not_500`.
116pub async fn get_handler(
117 State(state): State<AppState>,
118 Path(id): Path<String>,
119) -> axum::response::Response {
120 call(&state, "session_status", json!({ "session_id": id })).await
121}
122
123/// Query params for the activity route.
124#[derive(Deserialize)]
125pub struct ActivityQuery {
126 /// Optional trailing-line count (defaults to 60, matching the MCP tool).
127 lines: Option<u32>,
128}
129
130/// `GET /api/console/sessions/{id}/activity` — recent pane via `session_activity`.
131///
132/// Why: the activity panel shows the last N pane lines so an operator can watch
133/// an actively-failing/auto-resuming session at the configured poll cadence.
134/// What: calls `session_activity` with `session_id` + `lines` (capped default).
135/// Test: `activity_absent_binary_does_not_500`.
136pub async fn activity_handler(
137 State(state): State<AppState>,
138 Path(id): Path<String>,
139 Query(params): Query<ActivityQuery>,
140) -> axum::response::Response {
141 let lines = params.lines.unwrap_or(DEFAULT_ACTIVITY_LINES);
142 call(
143 &state,
144 "session_activity",
145 json!({ "session_id": id, "lines": lines }),
146 )
147 .await
148}
149
150/// `GET /api/console/sessions/supervisor` — fleet + auto-resume via `supervisor_status`.
151///
152/// Why: the supervisor widget needs fleet counts and the auto-resume control
153/// state in one call (RFC §4 P3).
154/// What: calls `supervisor_status` (no args); returns `{ fleet, auto_resume }`.
155/// Test: `supervisor_absent_binary_does_not_500`.
156pub async fn supervisor_handler(State(state): State<AppState>) -> axum::response::Response {
157 call(&state, "supervisor_status", json!({})).await
158}
159
160// ─── write routes ─────────────────────────────────────────────────────────────
161
162/// Body for the spawn route — mirrors the `session_new` MCP tool arguments.
163#[derive(Deserialize)]
164pub struct NewSessionBody {
165 repo_url: String,
166 #[serde(rename = "ref")]
167 git_ref: String,
168 task: String,
169 #[serde(default)]
170 name_hint: Option<String>,
171 #[serde(default)]
172 runtime: Option<String>,
173}
174
175/// `POST /api/console/sessions` — spawn a new session via `session_new`.
176///
177/// Why: the Sessions tab's "spawn" control creates a managed session end-to-end
178/// through the console → MCP bridge → daemon (no direct daemon HTTP).
179/// What: forwards the body fields to `session_new`; required fields are enforced
180/// by serde (a missing field yields a 422 from axum's JSON extractor).
181/// Test: `new_absent_binary_does_not_500`.
182pub async fn new_handler(
183 State(state): State<AppState>,
184 axum::Json(body): axum::Json<NewSessionBody>,
185) -> axum::response::Response {
186 let mut args = json!({
187 "repo_url": body.repo_url,
188 "ref": body.git_ref,
189 "task": body.task,
190 });
191 if let Some(obj) = args.as_object_mut() {
192 if let Some(hint) = body.name_hint {
193 obj.insert("name_hint".to_string(), json!(hint));
194 }
195 if let Some(rt) = body.runtime {
196 obj.insert("runtime".to_string(), json!(rt));
197 }
198 }
199 call(&state, "session_new", args).await
200}
201
202/// `POST /api/console/sessions/{id}/stop` — stop a session via `session_stop`.
203///
204/// Why: the per-session Stop control.
205/// What: calls `session_stop` with the path id.
206/// Test: `stop_absent_binary_does_not_500`.
207pub async fn stop_handler(
208 State(state): State<AppState>,
209 Path(id): Path<String>,
210) -> axum::response::Response {
211 call(&state, "session_stop", json!({ "session_id": id })).await
212}
213
214/// `POST /api/console/sessions/{id}/resume` — resume via `session_resume`.
215///
216/// Why: the per-session Resume control.
217/// What: calls `session_resume` with the path id.
218/// Test: `resume_absent_binary_does_not_500`.
219pub async fn resume_handler(
220 State(state): State<AppState>,
221 Path(id): Path<String>,
222) -> axum::response::Response {
223 call(&state, "session_resume", json!({ "session_id": id })).await
224}
225
226/// `DELETE /api/console/sessions/{id}` — full teardown via `session_decommission`.
227///
228/// Why: the per-session Decommission control (terminal — removes the workspace).
229/// What: calls `session_decommission` with the path id.
230/// Test: `decommission_absent_binary_does_not_500`.
231pub async fn decommission_handler(
232 State(state): State<AppState>,
233 Path(id): Path<String>,
234) -> axum::response::Response {
235 call(&state, "session_decommission", json!({ "session_id": id })).await
236}
237
238/// Body for the auto-resume toggle.
239#[derive(Deserialize)]
240pub struct AutoResumeBody {
241 enabled: bool,
242}
243
244/// `POST /api/console/sessions/supervisor/auto-resume` — toggle auto-resume.
245///
246/// Why: the console SHALL provide controls to enable/disable auto-resume
247/// (RFC §6 Q6) — not CLI-only. This persists the operator's desired flag.
248/// What: calls `auto_resume_set` with `{ enabled }`; returns the resulting
249/// control state (`desired`, `env`, `pending_restart`).
250/// Test: `auto_resume_absent_binary_does_not_500`.
251pub async fn auto_resume_handler(
252 State(state): State<AppState>,
253 axum::Json(body): axum::Json<AutoResumeBody>,
254) -> axum::response::Response {
255 call(
256 &state,
257 "auto_resume_set",
258 json!({ "enabled": body.enabled }),
259 )
260 .await
261}
262
263// ─── tests ──────────────────────────────────────────────────────────────────
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use axum::body::Body;
269 use axum::http::{Request, StatusCode};
270 use http_body_util::BodyExt;
271 use tower::ServiceExt;
272
273 use crate::server::build_router;
274
275 /// Build a router whose AppState has the trusty-mpm handle registered but no
276 /// binary on PATH (CI), so every session route should degrade to 503/502 and
277 /// never 500.
278 fn router() -> axum::Router {
279 build_router(AppState::new(vec![]))
280 }
281
282 async fn assert_not_500(method: &str, uri: &str, body: Body) {
283 let req = Request::builder()
284 .method(method)
285 .uri(uri)
286 .header("content-type", "application/json")
287 .body(body)
288 .expect("request");
289 let resp = router().oneshot(req).await.expect("response");
290 assert_ne!(
291 resp.status(),
292 StatusCode::INTERNAL_SERVER_ERROR,
293 "{method} {uri} must not 500 when binary absent (got {})",
294 resp.status()
295 );
296 }
297
298 #[tokio::test]
299 async fn list_absent_binary_does_not_500() {
300 assert_not_500("GET", "/api/console/sessions", Body::empty()).await;
301 }
302
303 #[tokio::test]
304 async fn get_absent_binary_does_not_500() {
305 assert_not_500("GET", "/api/console/sessions/abc", Body::empty()).await;
306 }
307
308 #[tokio::test]
309 async fn activity_absent_binary_does_not_500() {
310 assert_not_500(
311 "GET",
312 "/api/console/sessions/abc/activity?lines=20",
313 Body::empty(),
314 )
315 .await;
316 }
317
318 #[tokio::test]
319 async fn supervisor_absent_binary_does_not_500() {
320 assert_not_500("GET", "/api/console/sessions/supervisor", Body::empty()).await;
321 }
322
323 #[tokio::test]
324 async fn new_absent_binary_does_not_500() {
325 let body = Body::from(
326 json!({ "repo_url": "https://x/y", "ref": "main", "task": "t" }).to_string(),
327 );
328 assert_not_500("POST", "/api/console/sessions", body).await;
329 }
330
331 #[tokio::test]
332 async fn stop_absent_binary_does_not_500() {
333 assert_not_500("POST", "/api/console/sessions/abc/stop", Body::empty()).await;
334 }
335
336 #[tokio::test]
337 async fn resume_absent_binary_does_not_500() {
338 assert_not_500("POST", "/api/console/sessions/abc/resume", Body::empty()).await;
339 }
340
341 #[tokio::test]
342 async fn decommission_absent_binary_does_not_500() {
343 assert_not_500("DELETE", "/api/console/sessions/abc", Body::empty()).await;
344 }
345
346 #[tokio::test]
347 async fn auto_resume_absent_binary_does_not_500() {
348 let body = Body::from(json!({ "enabled": true }).to_string());
349 assert_not_500("POST", "/api/console/sessions/supervisor/auto-resume", body).await;
350 }
351
352 /// Why: the shared mapper must convert a missing-tool error into a clean 503
353 /// with a hint, never a 502 — the regression class from #1170.
354 /// Test: this test.
355 #[tokio::test]
356 async fn map_tool_result_tool_unavailable_is_503_with_hint() {
357 let resp = map_tool_result(Err(McpHandleError::ToolUnavailable {
358 tool: "session_list".to_string(),
359 hint: "upgrade trusty-mpm".to_string(),
360 }));
361 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
362 }
363
364 /// Why: an absent binary must be a bare 503 (service not reachable).
365 /// Test: this test.
366 #[tokio::test]
367 async fn map_tool_result_absent_is_503() {
368 let resp = map_tool_result(Err(McpHandleError::Absent));
369 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
370 }
371
372 // ── route-shadowing verification (#1222 review finding #2) ────────────────
373 //
374 // The static `/api/console/sessions/supervisor` route shares a prefix with the
375 // dynamic `/api/console/sessions/{id}` capture. axum 0.8 (matchit 0.8)
376 // prioritises a literal/static segment over a `{param}` capture, so the static
377 // routes SHOULD win — these tests prove it rather than trusting the router
378 // ordering. They do so by priming the trusty-mpm handle to a `Connected` state
379 // whose tool set deliberately excludes every session tool, so each session
380 // route returns `503 { status: degraded, hint }` where the hint names the tool
381 // the matched handler asked for. The tool name in the hint reveals which
382 // handler axum dispatched to:
383 // - `supervisor` route → `supervisor_handler` → hint names `supervisor_status`
384 // - shadowed by `{id}` → `get_handler` → hint names `session_status`
385
386 /// Build a router whose trusty-mpm handle is `Connected` but exposes no
387 /// session tools, so each route's 503 hint reveals the dispatched handler.
388 async fn router_primed_missing_tools() -> axum::Router {
389 let state = AppState::new(vec![]);
390 {
391 let handles = state.mcp_handles();
392 let mpm = handles.get("trusty-mpm").expect("mpm handle registered");
393 // Any argument primes a Connected state whose tool set is
394 // {console_metrics, …analyze tools} — none of the session tools — so
395 // every session route trips the capability-gate with a tool-named hint.
396 mpm.prime_connected_missing_tool_for_test("supervisor_status")
397 .await;
398 }
399 build_router(state)
400 }
401
402 async fn hint_of(resp: axum::http::Response<Body>) -> String {
403 let bytes = resp
404 .into_body()
405 .collect()
406 .await
407 .expect("collect body")
408 .to_bytes()
409 .to_vec();
410 let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
411 body["hint"].as_str().unwrap_or("").to_string()
412 }
413
414 /// Why: prove `GET /api/console/sessions/supervisor` reaches
415 /// `supervisor_handler` (calls `supervisor_status`) and is NOT shadowed by the
416 /// `{id}` capture (which would call `session_status` with id="supervisor").
417 /// Test: this test — the discriminator is the tool name in the 503 hint.
418 #[tokio::test]
419 async fn supervisor_route_is_not_shadowed_by_id_capture() {
420 let router = router_primed_missing_tools().await;
421 let req = Request::builder()
422 .uri("/api/console/sessions/supervisor")
423 .body(Body::empty())
424 .expect("request");
425 let resp = router.oneshot(req).await.expect("response");
426 assert_eq!(
427 resp.status(),
428 StatusCode::SERVICE_UNAVAILABLE,
429 "primed-missing-tool supervisor route must be a capability-gated 503"
430 );
431 let hint = hint_of(resp).await;
432 assert!(
433 hint.contains("supervisor_status"),
434 "supervisor route must reach supervisor_handler (hint should name \
435 supervisor_status); got: {hint}"
436 );
437 assert!(
438 !hint.contains("session_status"),
439 "supervisor route must NOT be shadowed by the {{id}} capture \
440 (session_status); got: {hint}"
441 );
442 }
443
444 /// Why: prove `POST /api/console/sessions/supervisor/auto-resume` reaches
445 /// `auto_resume_handler` (calls `auto_resume_set`), not any `{id}` capture.
446 /// Test: this test — the 503 hint must name `auto_resume_set`.
447 #[tokio::test]
448 async fn auto_resume_route_is_not_shadowed() {
449 let router = router_primed_missing_tools().await;
450 let body = Body::from(json!({ "enabled": true }).to_string());
451 let req = Request::builder()
452 .method("POST")
453 .uri("/api/console/sessions/supervisor/auto-resume")
454 .header("content-type", "application/json")
455 .body(body)
456 .expect("request");
457 let resp = router.oneshot(req).await.expect("response");
458 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
459 let hint = hint_of(resp).await;
460 assert!(
461 hint.contains("auto_resume_set"),
462 "auto-resume route must reach auto_resume_handler (hint should name \
463 auto_resume_set); got: {hint}"
464 );
465 }
466
467 /// Why: the sanity counterpart — a genuine id capture (`/{id}`) must reach
468 /// `get_handler` (calls `session_status`), confirming the discriminator works
469 /// and the `{id}` route is still wired for non-`supervisor` ids.
470 /// Test: this test.
471 #[tokio::test]
472 async fn ordinary_id_route_reaches_session_status() {
473 let router = router_primed_missing_tools().await;
474 let req = Request::builder()
475 .uri("/api/console/sessions/sess-abc123")
476 .body(Body::empty())
477 .expect("request");
478 let resp = router.oneshot(req).await.expect("response");
479 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
480 let hint = hint_of(resp).await;
481 assert!(
482 hint.contains("session_status"),
483 "ordinary id route must reach get_handler (session_status); got: {hint}"
484 );
485 }
486}