Skip to main content

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}