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 record-only bulk delete (#6431).
239#[derive(Deserialize)]
240pub struct BulkDeleteBody {
241    /// Session ids the operator confirmed. Never a filter — see the handler.
242    session_ids: Vec<String>,
243}
244
245/// `POST /api/console/sessions/bulk-delete` — record-only bulk delete (#6431).
246///
247/// Why: the Sessions tab buckets every record with a missing or unrecognised
248/// `state` under "unknown", and an operator needs one action to clear it. The
249/// route takes the ids the confirmation dialog listed rather than a predicate
250/// the server re-evaluates, so the set that is deleted is exactly the set the
251/// operator saw. #1511 (a prune that `rm -rf`'d a live workspace) is why the
252/// tool behind it deletes records and never touches a worktree or workspace.
253/// What: forwards `session_ids` to the `session_delete_records` MCP tool and
254/// returns its `{ requested, deleted, failed, results }` verbatim, so the UI
255/// renders per-session outcomes and a partial run reads as partial. Argument
256/// validation (empty list, non-string element) lives in the tool, which returns
257/// a tool error the shared mapper surfaces.
258/// Test: `bulk_delete_absent_binary_does_not_500`,
259/// `bulk_delete_route_is_not_shadowed`.
260pub async fn bulk_delete_handler(
261    State(state): State<AppState>,
262    axum::Json(body): axum::Json<BulkDeleteBody>,
263) -> axum::response::Response {
264    call(
265        &state,
266        "session_delete_records",
267        json!({ "session_ids": body.session_ids }),
268    )
269    .await
270}
271
272/// Body for the auto-resume toggle.
273#[derive(Deserialize)]
274pub struct AutoResumeBody {
275    enabled: bool,
276}
277
278/// `POST /api/console/sessions/supervisor/auto-resume` — toggle auto-resume.
279///
280/// Why: the console SHALL provide controls to enable/disable auto-resume
281/// (RFC §6 Q6) — not CLI-only. This persists the operator's desired flag.
282/// What: calls `auto_resume_set` with `{ enabled }`; returns the resulting
283/// control state (`desired`, `env`, `pending_restart`).
284/// Test: `auto_resume_absent_binary_does_not_500`.
285pub async fn auto_resume_handler(
286    State(state): State<AppState>,
287    axum::Json(body): axum::Json<AutoResumeBody>,
288) -> axum::response::Response {
289    call(
290        &state,
291        "auto_resume_set",
292        json!({ "enabled": body.enabled }),
293    )
294    .await
295}
296
297// ─── tests ──────────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use axum::body::Body;
303    use axum::http::{Request, StatusCode};
304    use http_body_util::BodyExt;
305    use tower::ServiceExt;
306
307    use crate::server::build_router;
308
309    /// Build a router whose AppState has the trusty-mpm handle registered but no
310    /// binary on PATH (CI), so every session route should degrade to 503/502 and
311    /// never 500.
312    fn router() -> axum::Router {
313        build_router(AppState::new(vec![]))
314    }
315
316    async fn assert_not_500(method: &str, uri: &str, body: Body) {
317        let req = Request::builder()
318            .method(method)
319            .uri(uri)
320            .header("content-type", "application/json")
321            .body(body)
322            .expect("request");
323        let resp = router().oneshot(req).await.expect("response");
324        assert_ne!(
325            resp.status(),
326            StatusCode::INTERNAL_SERVER_ERROR,
327            "{method} {uri} must not 500 when binary absent (got {})",
328            resp.status()
329        );
330    }
331
332    #[tokio::test]
333    async fn list_absent_binary_does_not_500() {
334        assert_not_500("GET", "/api/console/sessions", Body::empty()).await;
335    }
336
337    #[tokio::test]
338    async fn get_absent_binary_does_not_500() {
339        assert_not_500("GET", "/api/console/sessions/abc", Body::empty()).await;
340    }
341
342    #[tokio::test]
343    async fn activity_absent_binary_does_not_500() {
344        assert_not_500(
345            "GET",
346            "/api/console/sessions/abc/activity?lines=20",
347            Body::empty(),
348        )
349        .await;
350    }
351
352    #[tokio::test]
353    async fn supervisor_absent_binary_does_not_500() {
354        assert_not_500("GET", "/api/console/sessions/supervisor", Body::empty()).await;
355    }
356
357    #[tokio::test]
358    async fn new_absent_binary_does_not_500() {
359        let body = Body::from(
360            json!({ "repo_url": "https://x/y", "ref": "main", "task": "t" }).to_string(),
361        );
362        assert_not_500("POST", "/api/console/sessions", body).await;
363    }
364
365    #[tokio::test]
366    async fn stop_absent_binary_does_not_500() {
367        assert_not_500("POST", "/api/console/sessions/abc/stop", Body::empty()).await;
368    }
369
370    #[tokio::test]
371    async fn resume_absent_binary_does_not_500() {
372        assert_not_500("POST", "/api/console/sessions/abc/resume", Body::empty()).await;
373    }
374
375    #[tokio::test]
376    async fn decommission_absent_binary_does_not_500() {
377        assert_not_500("DELETE", "/api/console/sessions/abc", Body::empty()).await;
378    }
379
380    #[tokio::test]
381    async fn auto_resume_absent_binary_does_not_500() {
382        let body = Body::from(json!({ "enabled": true }).to_string());
383        assert_not_500("POST", "/api/console/sessions/supervisor/auto-resume", body).await;
384    }
385
386    #[tokio::test]
387    async fn bulk_delete_absent_binary_does_not_500() {
388        let body = Body::from(json!({ "session_ids": ["abc"] }).to_string());
389        assert_not_500("POST", "/api/console/sessions/bulk-delete", body).await;
390    }
391
392    /// Why: the shared mapper must convert a missing-tool error into a clean 503
393    /// with a hint, never a 502 — the regression class from #1170.
394    /// Test: this test.
395    #[tokio::test]
396    async fn map_tool_result_tool_unavailable_is_503_with_hint() {
397        let resp = map_tool_result(Err(McpHandleError::ToolUnavailable {
398            tool: "session_list".to_string(),
399            hint: "upgrade trusty-mpm".to_string(),
400        }));
401        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
402    }
403
404    /// Why: an absent binary must be a bare 503 (service not reachable).
405    /// Test: this test.
406    #[tokio::test]
407    async fn map_tool_result_absent_is_503() {
408        let resp = map_tool_result(Err(McpHandleError::Absent));
409        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
410    }
411
412    // ── route-shadowing verification (#1222 review finding #2) ────────────────
413    //
414    // The static `/api/console/sessions/supervisor` route shares a prefix with the
415    // dynamic `/api/console/sessions/{id}` capture. axum 0.8 (matchit 0.8)
416    // prioritises a literal/static segment over a `{param}` capture, so the static
417    // routes SHOULD win — these tests prove it rather than trusting the router
418    // ordering. They do so by priming the trusty-mpm handle to a `Connected` state
419    // whose tool set deliberately excludes every session tool, so each session
420    // route returns `503 { status: degraded, hint }` where the hint names the tool
421    // the matched handler asked for. The tool name in the hint reveals which
422    // handler axum dispatched to:
423    //   - `supervisor` route → `supervisor_handler` → hint names `supervisor_status`
424    //   - shadowed by `{id}`  → `get_handler`        → hint names `session_status`
425
426    /// Build a router whose trusty-mpm handle is `Connected` but exposes no
427    /// session tools, so each route's 503 hint reveals the dispatched handler.
428    async fn router_primed_missing_tools() -> axum::Router {
429        let state = AppState::new(vec![]);
430        {
431            let handles = state.mcp_handles();
432            let mpm = handles.get("trusty-mpm").expect("mpm handle registered");
433            // Any argument primes a Connected state whose tool set is
434            // {console_metrics, …analyze tools} — none of the session tools — so
435            // every session route trips the capability-gate with a tool-named hint.
436            mpm.prime_connected_missing_tool_for_test("supervisor_status")
437                .await;
438        }
439        build_router(state)
440    }
441
442    async fn hint_of(resp: axum::http::Response<Body>) -> String {
443        let bytes = resp
444            .into_body()
445            .collect()
446            .await
447            .expect("collect body")
448            .to_bytes()
449            .to_vec();
450        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
451        body["hint"].as_str().unwrap_or("").to_string()
452    }
453
454    /// Why: prove `GET /api/console/sessions/supervisor` reaches
455    /// `supervisor_handler` (calls `supervisor_status`) and is NOT shadowed by the
456    /// `{id}` capture (which would call `session_status` with id="supervisor").
457    /// Test: this test — the discriminator is the tool name in the 503 hint.
458    #[tokio::test]
459    async fn supervisor_route_is_not_shadowed_by_id_capture() {
460        let router = router_primed_missing_tools().await;
461        let req = Request::builder()
462            .uri("/api/console/sessions/supervisor")
463            .body(Body::empty())
464            .expect("request");
465        let resp = router.oneshot(req).await.expect("response");
466        assert_eq!(
467            resp.status(),
468            StatusCode::SERVICE_UNAVAILABLE,
469            "primed-missing-tool supervisor route must be a capability-gated 503"
470        );
471        let hint = hint_of(resp).await;
472        assert!(
473            hint.contains("supervisor_status"),
474            "supervisor route must reach supervisor_handler (hint should name \
475             supervisor_status); got: {hint}"
476        );
477        assert!(
478            !hint.contains("session_status"),
479            "supervisor route must NOT be shadowed by the {{id}} capture \
480             (session_status); got: {hint}"
481        );
482    }
483
484    /// Why: prove `POST /api/console/sessions/supervisor/auto-resume` reaches
485    /// `auto_resume_handler` (calls `auto_resume_set`), not any `{id}` capture.
486    /// Test: this test — the 503 hint must name `auto_resume_set`.
487    #[tokio::test]
488    async fn auto_resume_route_is_not_shadowed() {
489        let router = router_primed_missing_tools().await;
490        let body = Body::from(json!({ "enabled": true }).to_string());
491        let req = Request::builder()
492            .method("POST")
493            .uri("/api/console/sessions/supervisor/auto-resume")
494            .header("content-type", "application/json")
495            .body(body)
496            .expect("request");
497        let resp = router.oneshot(req).await.expect("response");
498        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
499        let hint = hint_of(resp).await;
500        assert!(
501            hint.contains("auto_resume_set"),
502            "auto-resume route must reach auto_resume_handler (hint should name \
503             auto_resume_set); got: {hint}"
504        );
505    }
506
507    /// Why: `POST /api/console/sessions/bulk-delete` must reach
508    /// `bulk_delete_handler` (calls `session_delete_records`), not the `{id}`
509    /// capture — a shadowed route would silently do nothing on a destructive
510    /// action. The discriminator is the tool name in the 503 hint.
511    /// Test: this test.
512    #[tokio::test]
513    async fn bulk_delete_route_is_not_shadowed() {
514        let router = router_primed_missing_tools().await;
515        let body = Body::from(json!({ "session_ids": ["abc"] }).to_string());
516        let req = Request::builder()
517            .method("POST")
518            .uri("/api/console/sessions/bulk-delete")
519            .header("content-type", "application/json")
520            .body(body)
521            .expect("request");
522        let resp = router.oneshot(req).await.expect("response");
523        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
524        let hint = hint_of(resp).await;
525        assert!(
526            hint.contains("session_delete_records"),
527            "bulk-delete route must reach bulk_delete_handler (hint should name \
528             session_delete_records); got: {hint}"
529        );
530    }
531
532    /// Why: the sanity counterpart — a genuine id capture (`/{id}`) must reach
533    /// `get_handler` (calls `session_status`), confirming the discriminator works
534    /// and the `{id}` route is still wired for non-`supervisor` ids.
535    /// Test: this test.
536    #[tokio::test]
537    async fn ordinary_id_route_reaches_session_status() {
538        let router = router_primed_missing_tools().await;
539        let req = Request::builder()
540            .uri("/api/console/sessions/sess-abc123")
541            .body(Body::empty())
542            .expect("request");
543        let resp = router.oneshot(req).await.expect("response");
544        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
545        let hint = hint_of(resp).await;
546        assert!(
547            hint.contains("session_status"),
548            "ordinary id route must reach get_handler (session_status); got: {hint}"
549        );
550    }
551}