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}