Skip to main content

trusty_console/
server.rs

1//! Axum HTTP server for the trusty-console.
2//!
3//! Why: The console needs a lightweight HTTP server that serves the embedded
4//! SPA, a JSON API route for service status, and a reverse-proxy layer for
5//! all daemon sub-paths.
6//! What: Builds an axum `Router` with:
7//!   - `GET /health` — liveness probe.
8//!   - `GET /api/console/services` — return cached snapshot (background poll).
9//!   - `GET /api/console/metrics/{analyze,memory,search,review,mpm}` — MCP-polled metrics.
10//!   - `GET /api/console/metrics/analyze/indexes` — analyze index list via stdio MCP.
11//!   - `GET /api/console/metrics/analyze/visualize?index=<id>` — graph+entities+clusters.
12//!   - `…/api/console/sessions/*` — the single HTTP front door for the trusty-mpm
13//!     session manager (#1222); handlers live in `crate::routes::sessions`.
14//!   - `ANY /proxy/{daemon}/{*path}` — reverse-proxy to live daemon (mpm is NOT
15//!     in the proxy allowlist — operators use `/api/console/sessions/*` instead).
16//!   - `GET /` and `GET /ui/*path` — serve the embedded Svelte SPA.
17//!
18//! All logs go to stderr; stdout is clean.
19//!
20//! Test: The `tests` module starts the router in a real axum test client.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::Duration;
25
26use axum::{
27    Router,
28    body::Body,
29    extract::{Path, Query, State},
30    http::{Response, StatusCode, header},
31    response::IntoResponse,
32    routing::{any, get},
33};
34use rust_embed::RustEmbed;
35use serde::Deserialize;
36use serde_json::json;
37use tower_http::cors::CorsLayer;
38use tower_http::trace::TraceLayer;
39
40use crate::connector::{ServiceConnector, ServiceInfo, ServiceStatus};
41use crate::mcp_handle::{McpHandleError, McpServiceHandle};
42use crate::metrics_poller::MetricsCache;
43use crate::poller::PollerCache;
44
45// ─── embedded UI ─────────────────────────────────────────────────────────────
46
47/// Embedded Svelte SPA assets compiled by `build.rs`.
48///
49/// Why: Shipping the UI inside the binary eliminates external file dependencies
50/// and matches the pattern used by trusty-search, trusty-memory, and
51/// trusty-analyze.
52/// What: rust-embed embeds every file under `ui/dist/` at compile time.
53/// Test: The server tests assert that `GET /` returns 200.
54#[derive(RustEmbed)]
55#[folder = "ui/dist/"]
56struct UiAssets;
57
58// ─── app state ───────────────────────────────────────────────────────────────
59
60/// Shared application state injected into every route handler.
61///
62/// Why: Connectors, the poller cache, metrics caches, and HTTP client are
63/// created once at startup and reused for every request so there is no per-
64/// request allocation. A separate `MetricsCache` is maintained for each
65/// stdio-MCP-polled service (analyze, memory, search, review) so they can be
66/// updated independently and served without coupling. `analyze_handle` is held
67/// in Arc so the on-demand visualize/index routes can call the analyze stdio MCP
68/// without going through the /proxy path.
69/// `mcp_handles` maps each service id to its `McpServiceHandle` so the
70/// services route can overlay the connector-reported status with the actual
71/// tools/list probe result (Degraded when `console_metrics` is absent).
72/// What: Wraps the connector list, poller cache, per-service metrics caches,
73/// reqwest client, the analyze MCP handle, and the full handle map in `Arc`s
74/// for cheap cloning.
75/// Test: Constructed in `build_router`; exercised by the integration tests.
76#[derive(Clone)]
77pub struct AppState {
78    connectors: Arc<Vec<Box<dyn ServiceConnector>>>,
79    poller_cache: PollerCache,
80    metrics_cache: MetricsCache,
81    memory_metrics_cache: MetricsCache,
82    search_metrics_cache: MetricsCache,
83    review_metrics_cache: MetricsCache,
84    /// trusty-mpm `console_metrics` cache (#1222). Populated by the background
85    /// poller; served by `GET /api/console/metrics/mpm`.
86    mpm_metrics_cache: MetricsCache,
87    http_client: Arc<reqwest::Client>,
88    /// Analyze stdio MCP handle — shared with the metrics poller so both the
89    /// background poll and on-demand route calls reuse the same child process.
90    analyze_handle: Arc<McpServiceHandle>,
91    /// All per-service MCP handles keyed by service id.
92    ///
93    /// Why: The services route reads each handle's degraded state to override
94    /// the connector-reported status when a reachable service is missing
95    /// `console_metrics`. Using a HashMap avoids adding individual Arc fields
96    /// for every future service.
97    /// What: Populated by `AppState::new`; read by `apply_handle_overrides`.
98    mcp_handles: Arc<HashMap<String, Arc<McpServiceHandle>>>,
99}
100
101impl AppState {
102    /// Create a new `AppState` from a list of connectors.
103    ///
104    /// Why: Lets tests inject a custom connector list and fresh caches.
105    /// What: Wraps `connectors` in `Arc`; initialises empty `PollerCache`,
106    /// three `MetricsCache` instances (analyze / memory / search), and a
107    /// default `reqwest::Client`. Creates the analyze stdio MCP handle that is
108    /// shared between the background metrics poller and on-demand routes.
109    /// Populates `mcp_handles` with all three per-service handles so the
110    /// services route can read their degraded state.
111    /// Test: Used in `build_router` and directly in `tests`.
112    pub fn new(connectors: Vec<Box<dyn ServiceConnector>>) -> Self {
113        let client = reqwest::Client::builder()
114            .timeout(Duration::from_secs(30))
115            .build()
116            .expect("reqwest client init");
117        let analyze_handle = Arc::new(McpServiceHandle::new(
118            "trusty-analyze",
119            vec!["mcp".to_string()],
120        ));
121        let memory_handle = Arc::new(McpServiceHandle::new(
122            "trusty-memory",
123            vec!["serve".to_string(), "--stdio".to_string()],
124        ));
125        let search_handle = Arc::new(McpServiceHandle::new(
126            "trusty-search",
127            vec!["serve".to_string()],
128        ));
129        // Why: trusty-review's stdio MCP mode is `serve --stdio` (see ServeArgs
130        // in commands/serve.rs). This is the canonical command the console spawns
131        // to poll `console_metrics` without requiring the HTTP daemon to be running.
132        let review_handle = Arc::new(McpServiceHandle::new(
133            "trusty-review",
134            vec!["serve".to_string(), "--stdio".to_string()],
135        ));
136        // Why: trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge
137        // that auto-starts the durable daemon and forwards JSON-RPC to its
138        // loopback POST /rpc). The console spawns this to render the Sessions tab
139        // natively (#1222) without ever touching the daemon's HTTP port (#1104).
140        let mpm_handle = Arc::new(McpServiceHandle::new(
141            "trusty-mpm",
142            vec!["serve".to_string(), "--stdio".to_string()],
143        ));
144        let mut handles: HashMap<String, Arc<McpServiceHandle>> = HashMap::new();
145        handles.insert("trusty-analyze".to_string(), Arc::clone(&analyze_handle));
146        handles.insert("trusty-memory".to_string(), Arc::clone(&memory_handle));
147        handles.insert("trusty-search".to_string(), Arc::clone(&search_handle));
148        handles.insert("trusty-review".to_string(), Arc::clone(&review_handle));
149        handles.insert("trusty-mpm".to_string(), Arc::clone(&mpm_handle));
150        Self {
151            connectors: Arc::new(connectors),
152            poller_cache: PollerCache::new(),
153            metrics_cache: MetricsCache::new(),
154            memory_metrics_cache: MetricsCache::new(),
155            search_metrics_cache: MetricsCache::new(),
156            review_metrics_cache: MetricsCache::new(),
157            mpm_metrics_cache: MetricsCache::new(),
158            http_client: Arc::new(client),
159            analyze_handle,
160            mcp_handles: Arc::new(handles),
161        }
162    }
163
164    /// Access the per-service MCP handle map.
165    ///
166    /// Why: The services route reads handles from this map to overlay connector
167    /// statuses with the tools/list probe result.
168    /// What: Returns a clone of the `Arc<HashMap>` (cheap).
169    /// Test: Used by `apply_handle_overrides` and the services handler.
170    pub fn mcp_handles(&self) -> Arc<HashMap<String, Arc<McpServiceHandle>>> {
171        Arc::clone(&self.mcp_handles)
172    }
173
174    /// Access the shared analyze MCP handle.
175    ///
176    /// Why: On-demand routes (`/api/console/metrics/analyze/indexes`,
177    /// `/api/console/metrics/analyze/visualize`) call the analyze stdio MCP
178    /// without touching the analyze daemon HTTP directly (architecture: console
179    /// is a stdio MCP client only, per #1104).
180    /// What: Returns a clone of the `Arc<McpServiceHandle>` (cheap).
181    /// Test: Exercised by the analyze index and visualize route tests.
182    pub fn analyze_handle(&self) -> Arc<McpServiceHandle> {
183        Arc::clone(&self.analyze_handle)
184    }
185
186    /// Access the shared connector list.
187    ///
188    /// Why: The background poller and the fallback `spawn_blocking` path both
189    /// need the connector list.
190    /// What: Returns a clone of the `Arc` (cheap).
191    /// Test: Used by `run_serve` in `main.rs`.
192    pub fn connectors(&self) -> Arc<Vec<Box<dyn ServiceConnector>>> {
193        Arc::clone(&self.connectors)
194    }
195
196    /// Access the background poll cache.
197    ///
198    /// Why: Routes read from the cache; the background task writes to it.
199    /// What: Returns a clone of the `PollerCache` handle (cheap — it's an Arc).
200    /// Test: Used by `services_handler` and `proxy_handler`.
201    pub fn poller_cache(&self) -> &PollerCache {
202        &self.poller_cache
203    }
204
205    /// Access the metrics cache for the trusty-analyze stdio MCP poller.
206    ///
207    /// Why: The metrics poller writes `ConsoleMetricsReport`s here; the
208    /// `/api/console/metrics/analyze` route reads from it.
209    /// What: Returns a reference to the `MetricsCache` handle.
210    /// Test: `test_metrics_analyze_route_cold_cache_returns_503`.
211    pub fn metrics_cache(&self) -> &MetricsCache {
212        &self.metrics_cache
213    }
214
215    /// Access the metrics cache for the trusty-memory stdio MCP poller.
216    ///
217    /// Why: Separate cache per service so memory and analyze reports can be
218    /// updated and served independently.
219    /// What: Returns a reference to the `MetricsCache` handle for memory.
220    /// Test: `test_metrics_memory_route_cold_cache_returns_503`.
221    pub fn memory_metrics_cache(&self) -> &MetricsCache {
222        &self.memory_metrics_cache
223    }
224
225    /// Access the metrics cache for the trusty-search stdio MCP poller.
226    ///
227    /// Why: Separate cache per service so search and analyze reports can be
228    /// updated and served independently.
229    /// What: Returns a reference to the `MetricsCache` handle for search.
230    /// Test: `test_metrics_search_route_cold_cache_returns_503`.
231    pub fn search_metrics_cache(&self) -> &MetricsCache {
232        &self.search_metrics_cache
233    }
234
235    /// Access the metrics cache for the trusty-review stdio MCP poller.
236    ///
237    /// Why: Separate cache per service so review reports can be updated and
238    /// served independently from the other service caches.
239    /// What: Returns a reference to the `MetricsCache` handle for review.
240    /// Test: `test_metrics_review_route_cold_cache_returns_503`.
241    pub fn review_metrics_cache(&self) -> &MetricsCache {
242        &self.review_metrics_cache
243    }
244
245    /// Access the metrics cache for the trusty-mpm stdio MCP poller (#1222).
246    ///
247    /// Why: separate cache per service so the mpm session/supervisor report can
248    /// be updated and served independently from the other service caches.
249    /// What: returns a reference to the `MetricsCache` handle for mpm.
250    /// Test: `test_metrics_mpm_route_cold_cache_returns_503`.
251    pub fn mpm_metrics_cache(&self) -> &MetricsCache {
252        &self.mpm_metrics_cache
253    }
254
255    /// Access the shared `reqwest::Client`.
256    ///
257    /// Why: Re-using one client enables connection pooling across proxy requests.
258    /// What: Returns a clone of the `Arc<reqwest::Client>` (cheap).
259    /// Test: Used by `proxy_handler`.
260    pub fn http_client(&self) -> Arc<reqwest::Client> {
261        Arc::clone(&self.http_client)
262    }
263}
264
265// ─── router ──────────────────────────────────────────────────────────────────
266
267/// Build the axum `Router` with all routes wired.
268///
269/// Why: Extracting the router into its own function allows both `main` and the
270/// test harness to share the same routing configuration without running a real
271/// TCP server.
272/// What: Returns a `Router<()>` with CORS, tracing middleware, and all routes.
273/// Test: Called from `tests::test_services_route_returns_json` below.
274pub fn build_router(state: AppState) -> Router {
275    Router::new()
276        .route("/health", get(health_handler))
277        .route("/api/console/services", get(services_handler))
278        .route("/api/console/metrics/analyze", get(metrics_analyze_handler))
279        .route("/api/console/metrics/memory", get(metrics_memory_handler))
280        .route("/api/console/metrics/search", get(metrics_search_handler))
281        .route("/api/console/metrics/review", get(metrics_review_handler))
282        .route("/api/console/metrics/mpm", get(metrics_mpm_handler))
283        // ── trusty-mpm session-manager surface (#1222: P2 tab + P3 front door) ──
284        // The console is the SINGLE HTTP front door for the session REST API;
285        // every handler calls a trusty-mpm MCP tool via the stdio bridge — never
286        // the daemon's HTTP port (#1104).
287        //
288        // Route precedence (verified, NOT declaration-order dependent): axum 0.8
289        // routes via matchit 0.8, which prioritises a literal/static path segment
290        // over a `{param}` capture at the same position regardless of the order
291        // routes are added. So `/sessions/supervisor` and
292        // `/sessions/supervisor/auto-resume` always win over `/sessions/{id}` —
293        // a request for `…/supervisor` reaches `supervisor_handler`, never
294        // `get_handler` with id="supervisor". This is asserted directly by
295        // `routes::sessions::tests::supervisor_route_is_not_shadowed_by_id_capture`
296        // and `…::auto_resume_route_is_not_shadowed`.
297        .route(
298            "/api/console/sessions",
299            get(crate::routes::sessions::list_handler).post(crate::routes::sessions::new_handler),
300        )
301        .route(
302            "/api/console/sessions/supervisor",
303            get(crate::routes::sessions::supervisor_handler),
304        )
305        .route(
306            "/api/console/sessions/supervisor/auto-resume",
307            axum::routing::post(crate::routes::sessions::auto_resume_handler),
308        )
309        .route(
310            "/api/console/sessions/{id}",
311            get(crate::routes::sessions::get_handler)
312                .delete(crate::routes::sessions::decommission_handler),
313        )
314        .route(
315            "/api/console/sessions/{id}/activity",
316            get(crate::routes::sessions::activity_handler),
317        )
318        .route(
319            "/api/console/sessions/{id}/stop",
320            axum::routing::post(crate::routes::sessions::stop_handler),
321        )
322        .route(
323            "/api/console/sessions/{id}/resume",
324            axum::routing::post(crate::routes::sessions::resume_handler),
325        )
326        // #1220 Config tab: read/write the `~/.trusty-tools/trusty-mpm/config.yaml`
327        // convention via the trusty-mpm `config_read` / `config_write` MCP tools.
328        // The POST is a state-changing write, so it sits inside the same
329        // origin-guarded block as the session write routes below.
330        .route(
331            "/api/console/config/mpm",
332            get(crate::routes::config::get_handler).post(crate::routes::config::post_handler),
333        )
334        // Same-origin guard for the DESTRUCTIVE session write routes (#1222
335        // review #3). The console serves a permissive CORS policy (open reads),
336        // so without this guard any web page the operator visited could fire a
337        // cross-origin `fetch` and spawn/stop/decommission sessions (CSRF). The
338        // middleware is method-aware — it only blocks state-changing methods
339        // whose `Origin` header is present and non-loopback, so the GET reads on
340        // these same paths pass through untouched. `route_layer` applies it only
341        // to the seven session routes declared above, not the whole console.
342        .route_layer(axum::middleware::from_fn(
343            crate::routes::origin_guard::guard_write_origin,
344        ))
345        // Analyze on-demand routes — call the analyze stdio MCP directly (no /proxy).
346        .route(
347            "/api/console/metrics/analyze/indexes",
348            get(analyze_indexes_handler),
349        )
350        .route(
351            "/api/console/metrics/analyze/visualize",
352            get(analyze_visualize_handler),
353        )
354        // Reverse-proxy: /proxy/{daemon}/{*path}
355        .route("/proxy/{daemon}/{*path}", any(crate::proxy::proxy_handler))
356        .route("/", get(spa_index_handler))
357        .route("/ui", get(spa_index_handler))
358        .route("/ui/", get(spa_index_handler))
359        .route("/ui/{*path}", get(spa_asset_handler))
360        .with_state(state)
361        .layer(CorsLayer::permissive())
362        .layer(TraceLayer::new_for_http())
363}
364
365// ─── handlers ────────────────────────────────────────────────────────────────
366
367/// `GET /health` — liveness probe.
368///
369/// Why: Required by process monitors and the `trusty-console status` CLI
370/// subcommand. Returns a minimal JSON body so callers can confirm the server
371/// is up and which version is running.
372/// What: Returns `{"status":"ok","version":"<CARGO_PKG_VERSION>"}`.
373/// Test: Tested by `test_health_route` below.
374async fn health_handler() -> impl IntoResponse {
375    axum::Json(json!({
376        "status": "ok",
377        "version": env!("CARGO_PKG_VERSION"),
378    }))
379}
380
381/// Apply per-service MCP handle state on top of connector-reported statuses.
382///
383/// Why: The connector `detect()` path (TCP probe / `which`) can only report
384/// `Running`, `Available`, or `Absent`. It has no knowledge of the MCP
385/// `tools/list` probe result.  When a service is reachable but the
386/// `console_metrics` tool is absent (`HandleState::Degraded`), the connector
387/// still reports `Running` or `Available` — the UI incorrectly shows a healthy
388/// badge. This function overlays the handle's known state: if a handle is
389/// Degraded, the corresponding `ServiceInfo` is updated in-place to
390/// `status = Degraded` and `hint = DEGRADED_HINT`. If a handle is Connected, the
391/// daemon version from the `initialize` response is surfaced (unless the connector
392/// already reported a version from the HTTP `/health` endpoint).
393/// What: Iterates `infos` in place; for each entry looks up the matching handle
394/// by `id`. If `handle.degraded_hint()` returns `Some(hint)` and the current
395/// status is NOT already `Absent`, sets `status = Degraded` and `hint = Some`.
396/// If `info.version` is `None` and `handle.daemon_version()` returns `Some`,
397/// sets `info.version` from the MCP `serverInfo.version`.
398/// A process-down (`Absent`) service is never overridden — only reachable ones.
399/// Skipping only `Absent` is safe: `Available` handles always return `None`
400/// from `degraded_hint` (no tools/list probe runs until the first poll), so
401/// they pass through unchanged.
402/// Test: `test_services_route_handle_degraded_overlay` and
403/// `test_services_route_daemon_version_overlay` below.
404async fn apply_handle_overrides(
405    infos: &mut [ServiceInfo],
406    handles: &HashMap<String, Arc<McpServiceHandle>>,
407) {
408    for info in infos.iter_mut() {
409        if info.status == ServiceStatus::Absent {
410            continue;
411        }
412        if let Some(handle) = handles.get(&info.id) {
413            if let Some(hint) = handle.degraded_hint().await {
414                info.status = ServiceStatus::Degraded;
415                info.hint = Some(hint);
416            }
417            // Surface the MCP daemon version when the connector hasn't
418            // already provided one (e.g. when the HTTP daemon isn't running
419            // but the stdio MCP process is up and has responded to initialize).
420            if info.version.is_none()
421                && let Some(ver) = handle.daemon_version().await
422            {
423                info.version = Some(ver);
424            }
425        }
426    }
427}
428
429/// `GET /api/console/services` — return cached snapshot of all services.
430///
431/// Why: The Svelte SPA fetches this endpoint on load to render service cards.
432///      With the background poller in place the response is instant (no per-
433///      request TCP probes).
434/// What: Reads the latest `CachedSnapshot` from the `PollerCache`. If the first
435/// poll has not completed yet, falls back to a synchronous on-demand detection
436/// so the UI always gets data (the first-boot latency is acceptable; after that
437/// every response is cache-backed).  A panic in the fallback blocking task
438/// surfaces as HTTP 500 rather than an empty 200.
439/// After obtaining the base service list (from cache or fallback), applies
440/// per-service handle degraded overrides via `apply_handle_overrides` so
441/// reachable services missing `console_metrics` surface as `status: degraded`.
442/// Test: `test_services_route_returns_json`,
443/// `test_services_handler_returns_500_on_panic`, and
444/// `test_services_route_handle_degraded_overlay` below.
445async fn services_handler(State(state): State<AppState>) -> axum::response::Response {
446    let handles = state.mcp_handles();
447
448    if let Some(snap) = state.poller_cache().snapshot().await {
449        let mut services = snap.services;
450        apply_handle_overrides(&mut services, &handles).await;
451        return axum::Json(services).into_response();
452    }
453
454    // First-boot fallback: run a one-shot detection synchronously.
455    let connectors = state.connectors();
456    match tokio::task::spawn_blocking(move || {
457        connectors.iter().map(|c| c.detect()).collect::<Vec<_>>()
458    })
459    .await
460    {
461        Ok(mut infos) => {
462            apply_handle_overrides(&mut infos, &handles).await;
463            axum::Json(infos).into_response()
464        }
465        Err(e) => {
466            tracing::error!("service detection task panicked: {e}");
467            StatusCode::INTERNAL_SERVER_ERROR.into_response()
468        }
469    }
470}
471
472/// `GET /api/console/metrics/analyze` — return the latest metrics report.
473///
474/// Why: Surfaces trusty-analyze health/metrics to the SPA without per-request
475/// MCP calls (the background poller keeps the cache warm).
476/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
477/// no poll has completed yet (binary absent or first boot).
478/// Test: `test_metrics_analyze_route_cold_cache_returns_503` below.
479async fn metrics_analyze_handler(State(state): State<AppState>) -> axum::response::Response {
480    match state.metrics_cache().get().await {
481        Some(report) => axum::Json(report).into_response(),
482        None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
483    }
484}
485
486/// `GET /api/console/metrics/memory` — return the latest memory metrics report.
487///
488/// Why: Surfaces trusty-memory health/metrics to the SPA without per-request
489/// MCP calls (the background poller keeps the cache warm).
490/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
491/// no poll has completed yet (binary absent or first boot).
492/// Test: `test_metrics_memory_route_cold_cache_returns_503` below.
493async fn metrics_memory_handler(State(state): State<AppState>) -> axum::response::Response {
494    match state.memory_metrics_cache().get().await {
495        Some(report) => axum::Json(report).into_response(),
496        None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
497    }
498}
499
500/// `GET /api/console/metrics/search` — return the latest search metrics report.
501///
502/// Why: Surfaces trusty-search health/metrics to the SPA without per-request
503/// MCP calls (the background poller keeps the cache warm).
504/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
505/// no poll has completed yet (binary absent or first boot).
506/// Test: `test_metrics_search_route_cold_cache_returns_503` below.
507async fn metrics_search_handler(State(state): State<AppState>) -> axum::response::Response {
508    match state.search_metrics_cache().get().await {
509        Some(report) => axum::Json(report).into_response(),
510        None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
511    }
512}
513
514/// `GET /api/console/metrics/review` — return the latest review metrics report.
515///
516/// Why: Surfaces trusty-review health/metrics to the SPA without per-request
517/// MCP calls (the background poller keeps the cache warm).
518/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
519/// no poll has completed yet (binary absent or first boot).
520/// Test: `test_metrics_review_route_cold_cache_returns_503` below.
521async fn metrics_review_handler(State(state): State<AppState>) -> axum::response::Response {
522    match state.review_metrics_cache().get().await {
523        Some(report) => axum::Json(report).into_response(),
524        None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
525    }
526}
527
528/// `GET /api/console/metrics/mpm` — return the latest trusty-mpm metrics report.
529///
530/// Why: surfaces trusty-mpm session-fleet + supervisor health to the SPA without
531/// per-request MCP calls (the background poller keeps the cache warm). This is
532/// the coarse, low-frequency health cache; the Sessions tab polls the live
533/// `/api/console/sessions` list at a faster cadence for active monitoring.
534/// What: returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when no
535/// poll has completed yet (binary absent or first boot).
536/// Test: `test_metrics_mpm_route_cold_cache_returns_503` below.
537async fn metrics_mpm_handler(State(state): State<AppState>) -> axum::response::Response {
538    match state.mpm_metrics_cache().get().await {
539        Some(report) => axum::Json(report).into_response(),
540        None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
541    }
542}
543
544/// Query params for the analyze visualize route.
545///
546/// Why: The index id must be a query param so the Svelte component can change
547/// the selected index without a page navigation.
548/// What: `index` is the analyze index id (string). Optional: no default —
549/// returns 400 when absent.
550/// Test: `test_analyze_visualize_handler_no_index_returns_400` below.
551#[derive(Deserialize)]
552struct VisualizeQuery {
553    index: Option<String>,
554}
555
556/// `GET /api/console/metrics/analyze/indexes` — list analyze indexes via stdio.
557///
558/// Why: The Analyze tab needs a list of indexes to populate the dropdown.
559/// This route calls the analyze stdio MCP (via `McpServiceHandle::call_tool_checked`)
560/// instead of the browser hitting the analyze daemon HTTP directly, honouring
561/// the #1104 architecture principle: the console is a stdio MCP client only.
562/// Using `call_tool_checked` instead of `call_tool_raw` prevents a raw -32601
563/// JSON-RPC error from reaching the browser as a 502 when the stale daemon lacks
564/// the `list_analyze_indexes` tool — the capability-gate returns `ToolUnavailable`
565/// which maps to a clean 503 with an actionable hint.
566/// What: Calls the `list_analyze_indexes` MCP tool (which proxies `GET /indexes`
567/// on the daemon). Returns the JSON array on 200, 503+hint when the analyze binary
568/// is absent, in backoff, degraded, or the tool is not in the cached tool set;
569/// 502 on any other error.
570/// Test: `test_analyze_indexes_absent_binary_returns_503` and
571/// `test_analyze_indexes_tool_unavailable_returns_degraded_hint` below.
572async fn analyze_indexes_handler(State(state): State<AppState>) -> axum::response::Response {
573    match state
574        .analyze_handle()
575        .call_tool_checked("list_analyze_indexes", serde_json::json!({}))
576        .await
577    {
578        Ok(val) => axum::Json(val).into_response(),
579        Err(McpHandleError::ToolUnavailable { tool, hint }) => {
580            tracing::warn!(
581                tool = %tool,
582                hint = %hint,
583                "analyze_indexes_handler: tool not available — capability-gate triggered"
584            );
585            (
586                StatusCode::SERVICE_UNAVAILABLE,
587                axum::Json(serde_json::json!({
588                    "status": "degraded",
589                    "hint": hint,
590                })),
591            )
592                .into_response()
593        }
594        Err(
595            McpHandleError::Absent
596            | McpHandleError::Backoff { .. }
597            | McpHandleError::Degraded { .. },
598        ) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
599        Err(e) => {
600            tracing::warn!("analyze_indexes_handler error: {e:#}");
601            StatusCode::BAD_GATEWAY.into_response()
602        }
603    }
604}
605
606/// `GET /api/console/metrics/analyze/visualize?index=<id>` — combined viz data.
607///
608/// Why: The Analyze tab needs graph nodes, entities, and clusters in one round
609/// trip. This route calls the analyze stdio MCP for all three without the
610/// browser hitting the analyze daemon HTTP directly (#1104 architecture).
611/// Using `call_tool_checked` prevents a raw -32601 from reaching the browser
612/// as a 502 when a stale daemon lacks `extract_graph`/`list_entities`/
613/// `cluster_concepts` — the capability-gate returns `ToolUnavailable` which maps
614/// to a clean 503+hint response.
615/// What: Calls `extract_graph`, `list_entities`, and `cluster_concepts` (k=8)
616/// via `McpServiceHandle::call_tool_checked` and returns a combined JSON object:
617/// `{"graph": ..., "entities": ..., "clusters": ...}`. Missing index param
618/// returns 400 (BAD_REQUEST). Absent binary, backoff, degraded, or tool
619/// unavailable returns 503 (SERVICE_UNAVAILABLE) with optional hint JSON.
620/// A hard graph error (non-absent/backoff/tool-unavailable) returns 502 (BAD_GATEWAY).
621/// Test: `test_analyze_visualize_handler_no_index_returns_400` and
622/// `test_analyze_visualize_handler_absent_binary_returns_503` below.
623async fn analyze_visualize_handler(
624    State(state): State<AppState>,
625    Query(params): Query<VisualizeQuery>,
626) -> axum::response::Response {
627    let index_id = match params.index {
628        Some(id) if !id.is_empty() => id,
629        _ => {
630            return (
631                StatusCode::BAD_REQUEST,
632                axum::Json(json!({"error": "missing required query param: index"})),
633            )
634                .into_response();
635        }
636    };
637
638    let handle = state.analyze_handle();
639    let args = serde_json::json!({ "index_id": index_id });
640
641    // NOTE: although `tokio::join!` normally drives all three futures
642    // concurrently, these three `call_tool_checked` calls share a single stdio
643    // child process behind `McpServiceHandle`'s inner `Arc<Mutex<StdioMcpClient>>`.
644    // Each call acquires that inner mutex for the full duration of its
645    // JSON-RPC round trip, so the three futures effectively serialize behind
646    // the lock — `join!` does not provide real I/O parallelism here. The
647    // `join!` form is retained for code readability (all three results
648    // collected symmetrically) and because the serialization is transparent
649    // to callers. If the analyze MCP child ever supports multiplexed requests
650    // (separate stdin/stdout framing per call), this join would gain true
651    // concurrency automatically without changing the call sites.
652    let (graph_res, entities_res, clusters_res) = tokio::join!(
653        handle.call_tool_checked("extract_graph", args.clone()),
654        handle.call_tool_checked("list_entities", args.clone()),
655        handle.call_tool_checked("cluster_concepts", {
656            let mut a = args.clone();
657            if let Some(m) = a.as_object_mut() {
658                m.insert("k".to_string(), serde_json::json!(8));
659            }
660            a
661        }),
662    );
663
664    // Classify the graph result: tool unavailable → 503+hint, absent/backoff/degraded → 503,
665    // hard error → 502, success → combine with best-effort entities and clusters.
666    match &graph_res {
667        Err(McpHandleError::ToolUnavailable { tool, hint }) => {
668            tracing::warn!(
669                tool = %tool,
670                hint = %hint,
671                "analyze_visualize_handler: tool not available — capability-gate triggered"
672            );
673            return (
674                StatusCode::SERVICE_UNAVAILABLE,
675                axum::Json(serde_json::json!({
676                    "status": "degraded",
677                    "hint": hint,
678                })),
679            )
680                .into_response();
681        }
682        Err(
683            McpHandleError::Absent
684            | McpHandleError::Backoff { .. }
685            | McpHandleError::Degraded { .. },
686        ) => {
687            return StatusCode::SERVICE_UNAVAILABLE.into_response();
688        }
689        Err(e) => {
690            tracing::warn!("analyze_visualize_handler graph error: {e:#}");
691            return StatusCode::BAD_GATEWAY.into_response();
692        }
693        Ok(_) => {}
694    }
695
696    // Log a warning when a best-effort tool is missing (e.g. stale daemon that
697    // predates list_entities or cluster_concepts).  We do NOT return 503 here —
698    // these two are genuinely best-effort and the route still returns a useful
699    // partial payload.  The primary `extract_graph` gate above is the hard 503
700    // path; these are only observable degradation signals.
701    if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &entities_res {
702        tracing::warn!(
703            tool = %tool,
704            "analyze_visualize_handler: list_entities tool unavailable — returning partial payload"
705        );
706    }
707    if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &clusters_res {
708        tracing::warn!(
709            tool = %tool,
710            "analyze_visualize_handler: cluster_concepts tool unavailable — returning partial payload"
711        );
712    }
713
714    let combined = json!({
715        "graph":    graph_res.unwrap_or(serde_json::Value::Null),
716        "entities": entities_res.unwrap_or(serde_json::Value::Null),
717        "clusters": clusters_res.unwrap_or(serde_json::Value::Null),
718    });
719    axum::Json(combined).into_response()
720}
721
722/// `GET /` — serve the SPA index.html.
723///
724/// Why: The root path must return the SPA shell so the browser bootstraps.
725/// What: Reads `index.html` from the embedded asset set.
726/// Test: `test_spa_root_returns_html` below.
727async fn spa_index_handler() -> impl IntoResponse {
728    serve_asset("index.html")
729}
730
731/// `GET /ui/*path` — serve SPA static assets.
732///
733/// Why: Vite emits JS/CSS/assets under hashed filenames; all are embedded and
734/// served from the `/ui/*` prefix.
735/// What: Strips the leading `/ui/` from `path` and serves the matching asset.
736/// Test: Indirectly covered by `test_spa_root_returns_html`.
737async fn spa_asset_handler(Path(path): Path<String>) -> impl IntoResponse {
738    let path = path.trim_start_matches('/');
739    serve_asset(path)
740}
741
742/// Serve one asset from the embedded `UiAssets`.
743///
744/// Why: Centralises asset serving so both the index and asset routes share the
745/// same content-type detection and 404 handling.
746/// What: Looks up the path in `UiAssets`, infers the MIME type via
747/// `mime_guess`, returns the bytes with the appropriate `Content-Type` header.
748/// On a 404 serves `index.html` (SPA client-side routing).
749/// Test: `test_spa_root_returns_html`.
750fn serve_asset(path: &str) -> Response<Body> {
751    match UiAssets::get(path) {
752        Some(content) => {
753            let mime = mime_guess::from_path(path).first_or_octet_stream();
754            Response::builder()
755                .status(StatusCode::OK)
756                .header(header::CONTENT_TYPE, mime.as_ref())
757                .body(Body::from(content.data.to_vec()))
758                .unwrap_or_else(|_| {
759                    Response::builder()
760                        .status(StatusCode::INTERNAL_SERVER_ERROR)
761                        .body(Body::empty())
762                        .expect("static response")
763                })
764        }
765        None => {
766            // SPA fallback: serve index.html for unknown paths so client-side
767            // routing works when the user navigates directly to a subpath.
768            match UiAssets::get("index.html") {
769                Some(content) => Response::builder()
770                    .status(StatusCode::OK)
771                    .header(header::CONTENT_TYPE, "text/html")
772                    .body(Body::from(content.data.to_vec()))
773                    .unwrap_or_else(|_| {
774                        Response::builder()
775                            .status(StatusCode::INTERNAL_SERVER_ERROR)
776                            .body(Body::empty())
777                            .expect("static response")
778                    }),
779                None => Response::builder()
780                    .status(StatusCode::NOT_FOUND)
781                    .body(Body::from("not found"))
782                    .expect("static 404"),
783            }
784        }
785    }
786}
787
788// ─── tests ───────────────────────────────────────────────────────────────────
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use axum::http::header::CONTENT_TYPE;
794    use axum::http::{Request, StatusCode};
795    use http_body_util::BodyExt;
796    use tower::ServiceExt;
797
798    use crate::connector::{ServiceInfo, ServiceStatus};
799
800    /// A stub connector for tests — always returns a fixed `ServiceInfo`.
801    struct StubConnector {
802        id: &'static str,
803        display_name: &'static str,
804        status: ServiceStatus,
805    }
806
807    impl ServiceConnector for StubConnector {
808        fn id(&self) -> &'static str {
809            self.id
810        }
811        fn display_name(&self) -> &'static str {
812            self.display_name
813        }
814        fn detect(&self) -> ServiceInfo {
815            ServiceInfo {
816                id: self.id.to_string(),
817                display_name: self.display_name.to_string(),
818                status: self.status.clone(),
819                version: None,
820                url: None,
821                hint: None,
822            }
823        }
824    }
825
826    fn make_test_state() -> AppState {
827        AppState::new(vec![
828            Box::new(StubConnector {
829                id: "trusty-search",
830                display_name: "Trusty Search",
831                status: ServiceStatus::Running,
832            }),
833            Box::new(StubConnector {
834                id: "trusty-memory",
835                display_name: "Trusty Memory",
836                status: ServiceStatus::Available,
837            }),
838            Box::new(StubConnector {
839                id: "trusty-analyze",
840                display_name: "Trusty Analyze",
841                status: ServiceStatus::Absent,
842            }),
843        ])
844    }
845
846    async fn get_bytes(resp: axum::http::Response<Body>) -> Vec<u8> {
847        resp.into_body()
848            .collect()
849            .await
850            .expect("collect body")
851            .to_bytes()
852            .to_vec()
853    }
854
855    /// Why: the services route must return a valid JSON array with one entry
856    /// per connector, each containing `id`, `display_name`, and `status`.
857    /// What: builds the router with stub connectors, issues GET
858    /// /api/console/services, parses the response.
859    /// Test: this test itself.
860    #[tokio::test]
861    async fn test_services_route_returns_json() {
862        let router = build_router(make_test_state());
863
864        let req = Request::builder()
865            .uri("/api/console/services")
866            .body(Body::empty())
867            .expect("request");
868        let resp = router.oneshot(req).await.expect("response");
869        assert_eq!(resp.status(), StatusCode::OK);
870
871        let bytes = get_bytes(resp).await;
872        let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
873        assert_eq!(body.len(), 3);
874
875        assert_eq!(body[0]["id"], "trusty-search");
876        assert_eq!(body[0]["status"], "running");
877        assert_eq!(body[0]["display_name"], "Trusty Search");
878
879        assert_eq!(body[1]["id"], "trusty-memory");
880        assert_eq!(body[1]["status"], "available");
881
882        assert_eq!(body[2]["id"], "trusty-analyze");
883        assert_eq!(body[2]["status"], "absent");
884    }
885
886    /// Why: health endpoint must return 200 with `status: ok`.
887    /// What: issues GET /health and checks the JSON body.
888    /// Test: this test itself.
889    #[tokio::test]
890    async fn test_health_route() {
891        let router = build_router(make_test_state());
892
893        let req = Request::builder()
894            .uri("/health")
895            .body(Body::empty())
896            .expect("request");
897        let resp = router.oneshot(req).await.expect("response");
898        assert_eq!(resp.status(), StatusCode::OK);
899
900        let bytes = get_bytes(resp).await;
901        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
902        assert_eq!(body["status"], "ok");
903        assert!(body["version"].is_string());
904    }
905
906    /// Why: the services route must serialise `Degraded` status and the
907    /// `hint` field correctly so the UI can render a distinct badge.
908    /// What: builds the router with a Degraded stub connector, issues GET
909    /// /api/console/services, asserts `status == "degraded"` and `hint` present.
910    /// Test: this test itself.
911    #[tokio::test]
912    async fn test_services_route_returns_degraded_with_hint() {
913        use crate::connector::ServiceInfo;
914        struct DegradedConnector;
915        impl ServiceConnector for DegradedConnector {
916            fn id(&self) -> &'static str {
917                "trusty-analyze"
918            }
919            fn display_name(&self) -> &'static str {
920                "Trusty Analyze"
921            }
922            fn detect(&self) -> ServiceInfo {
923                ServiceInfo {
924                    id: "trusty-analyze".to_string(),
925                    display_name: "Trusty Analyze".to_string(),
926                    status: ServiceStatus::Degraded,
927                    version: None,
928                    url: None,
929                    hint: Some("reachable but `console_metrics` tool not registered".to_string()),
930                }
931            }
932        }
933        let state = AppState::new(vec![Box::new(DegradedConnector)]);
934        let router = build_router(state);
935        let req = Request::builder()
936            .uri("/api/console/services")
937            .body(Body::empty())
938            .expect("request");
939        let resp = router.oneshot(req).await.expect("response");
940        assert_eq!(resp.status(), StatusCode::OK);
941        let bytes = get_bytes(resp).await;
942        let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
943        assert_eq!(body.len(), 1);
944        assert_eq!(body[0]["status"], "degraded");
945        assert!(
946            body[0].get("hint").is_some(),
947            "degraded service must include hint field"
948        );
949        assert!(
950            body[0]["hint"]
951                .as_str()
952                .unwrap_or("")
953                .contains("console_metrics"),
954            "hint must mention console_metrics"
955        );
956    }
957
958    /// Why: the root path must serve the embedded HTML (or placeholder).
959    /// What: issues GET / and asserts 200 + text/html content-type.
960    /// Test: this test itself.
961    #[tokio::test]
962    async fn test_spa_root_returns_html() {
963        let router = build_router(make_test_state());
964
965        let req = Request::builder()
966            .uri("/")
967            .body(Body::empty())
968            .expect("request");
969        let resp = router.oneshot(req).await.expect("response");
970        assert_eq!(resp.status(), StatusCode::OK);
971
972        let ct = resp
973            .headers()
974            .get(CONTENT_TYPE)
975            .and_then(|v| v.to_str().ok())
976            .unwrap_or("")
977            .to_string();
978        assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
979    }
980
981    /// A connector whose `detect()` always panics — simulates a buggy plugin.
982    struct PanicConnector;
983
984    impl ServiceConnector for PanicConnector {
985        fn id(&self) -> &'static str {
986            "panic-svc"
987        }
988        fn display_name(&self) -> &'static str {
989            "Panic Service"
990        }
991        fn detect(&self) -> ServiceInfo {
992            panic!("intentional test panic from PanicConnector");
993        }
994    }
995
996    /// Why: a panicking connector must not silently return HTTP 200 with an
997    /// empty list — that is indistinguishable from "no services installed".
998    /// The handler must return HTTP 500 so the UI can display an error state.
999    /// What: builds the router with a PanicConnector, issues GET
1000    /// /api/console/services, asserts the response status is 500.
1001    /// Test: this test itself.
1002    #[tokio::test]
1003    async fn test_services_handler_returns_500_on_panic() {
1004        let state = AppState::new(vec![Box::new(PanicConnector)]);
1005        let router = build_router(state);
1006
1007        let req = Request::builder()
1008            .uri("/api/console/services")
1009            .body(Body::empty())
1010            .expect("request");
1011        let resp = router.oneshot(req).await.expect("response");
1012        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1013    }
1014
1015    /// Why: with an empty metrics cache the route must return 503 so the UI
1016    /// can show a "not yet available" state rather than empty JSON.
1017    /// What: issues GET /api/console/metrics/analyze on a fresh state,
1018    /// asserts 503.
1019    /// Test: this test itself.
1020    #[tokio::test]
1021    async fn test_metrics_analyze_route_cold_cache_returns_503() {
1022        let router = build_router(make_test_state());
1023        let req = Request::builder()
1024            .uri("/api/console/metrics/analyze")
1025            .body(Body::empty())
1026            .expect("request");
1027        let resp = router.oneshot(req).await.expect("response");
1028        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1029    }
1030
1031    /// Why: the proxy route for an unknown daemon key must return 400.
1032    /// What: issues GET /proxy/unknown/health, asserts 400.
1033    /// Test: this test itself.
1034    #[tokio::test]
1035    async fn test_proxy_unknown_daemon_returns_400() {
1036        let router = build_router(make_test_state());
1037
1038        let req = Request::builder()
1039            .uri("/proxy/unknown/health")
1040            .body(Body::empty())
1041            .expect("request");
1042        let resp = router.oneshot(req).await.expect("response");
1043        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1044    }
1045
1046    /// Why: the proxy route for a known daemon that is not running must return
1047    /// 503 (cache not populated) when no poll has occurred yet.
1048    /// What: issues GET /proxy/search/health on a fresh state (no poll),
1049    /// asserts 503 SERVICE_UNAVAILABLE.
1050    /// Test: this test itself.
1051    #[tokio::test]
1052    async fn test_proxy_known_daemon_cold_cache_returns_503() {
1053        let router = build_router(make_test_state());
1054
1055        let req = Request::builder()
1056            .uri("/proxy/search/health")
1057            .body(Body::empty())
1058            .expect("request");
1059        let resp = router.oneshot(req).await.expect("response");
1060        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1061    }
1062
1063    /// Why: with an empty memory metrics cache the route must return 503 so the
1064    /// UI can show a "not yet available" state rather than empty JSON.
1065    /// What: issues GET /api/console/metrics/memory on a fresh state, asserts 503.
1066    /// Test: this test itself.
1067    #[tokio::test]
1068    async fn test_metrics_memory_route_cold_cache_returns_503() {
1069        let router = build_router(make_test_state());
1070        let req = Request::builder()
1071            .uri("/api/console/metrics/memory")
1072            .body(Body::empty())
1073            .expect("request");
1074        let resp = router.oneshot(req).await.expect("response");
1075        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1076    }
1077
1078    /// Why: with an empty search metrics cache the route must return 503 so the
1079    /// UI can show a "not yet available" state rather than empty JSON.
1080    /// What: issues GET /api/console/metrics/search on a fresh state, asserts 503.
1081    /// Test: this test itself.
1082    #[tokio::test]
1083    async fn test_metrics_search_route_cold_cache_returns_503() {
1084        let router = build_router(make_test_state());
1085        let req = Request::builder()
1086            .uri("/api/console/metrics/search")
1087            .body(Body::empty())
1088            .expect("request");
1089        let resp = router.oneshot(req).await.expect("response");
1090        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1091    }
1092
1093    /// Why: with an empty review metrics cache the route must return 503 so the
1094    /// UI can show a "not yet available" state rather than empty JSON.
1095    /// What: issues GET /api/console/metrics/review on a fresh state, asserts 503.
1096    /// Test: this test itself.
1097    #[tokio::test]
1098    async fn test_metrics_review_route_cold_cache_returns_503() {
1099        let router = build_router(make_test_state());
1100        let req = Request::builder()
1101            .uri("/api/console/metrics/review")
1102            .body(Body::empty())
1103            .expect("request");
1104        let resp = router.oneshot(req).await.expect("response");
1105        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1106    }
1107
1108    /// Why: with an empty mpm metrics cache the route must return 503 so the UI
1109    /// can show a "not yet available" state rather than empty JSON (#1222).
1110    /// What: issues GET /api/console/metrics/mpm on a fresh state, asserts 503.
1111    /// Test: this test itself.
1112    #[tokio::test]
1113    async fn test_metrics_mpm_route_cold_cache_returns_503() {
1114        let router = build_router(make_test_state());
1115        let req = Request::builder()
1116            .uri("/api/console/metrics/mpm")
1117            .body(Body::empty())
1118            .expect("request");
1119        let resp = router.oneshot(req).await.expect("response");
1120        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1121    }
1122
1123    /// Why: the analyze indexes route must return 503 (not 200 with empty data)
1124    /// when the trusty-analyze binary is absent — the handle immediately marks
1125    /// itself Absent and the route converts that to SERVICE_UNAVAILABLE.
1126    /// What: issues GET /api/console/metrics/analyze/indexes on a fresh state
1127    /// (where trusty-analyze is not on PATH in CI), asserts 503.
1128    /// Test: this test itself.
1129    #[tokio::test]
1130    async fn test_analyze_indexes_absent_binary_returns_503() {
1131        let router = build_router(make_test_state());
1132        let req = Request::builder()
1133            .uri("/api/console/metrics/analyze/indexes")
1134            .body(Body::empty())
1135            .expect("request");
1136        let resp = router.oneshot(req).await.expect("response");
1137        // Binary absent (or in backoff) → 503; if present and daemon is up → 200.
1138        // In CI neither condition holds; the route must not return 500.
1139        assert_ne!(
1140            resp.status(),
1141            StatusCode::INTERNAL_SERVER_ERROR,
1142            "indexes route must not 500 when binary absent"
1143        );
1144    }
1145
1146    /// Why: the analyze visualize route must return 400 when no `index` param
1147    /// is provided — the endpoint needs it to query the daemon. A 200 with an
1148    /// error field is indistinguishable from a success response to callers that
1149    /// only check the status code.
1150    /// What: issues GET /api/console/metrics/analyze/visualize (no ?index=),
1151    /// asserts HTTP 400 and a JSON body containing `error`.
1152    /// Test: this test itself.
1153    #[tokio::test]
1154    async fn test_analyze_visualize_handler_no_index_returns_json_error() {
1155        let router = build_router(make_test_state());
1156        let req = Request::builder()
1157            .uri("/api/console/metrics/analyze/visualize")
1158            .body(Body::empty())
1159            .expect("request");
1160        let resp = router.oneshot(req).await.expect("response");
1161        // Missing index returns 400 BAD_REQUEST with a JSON error body.
1162        assert_eq!(
1163            resp.status(),
1164            StatusCode::BAD_REQUEST,
1165            "missing index param must return 400"
1166        );
1167        let bytes = get_bytes(resp).await;
1168        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
1169        assert!(
1170            body.get("error").is_some(),
1171            "expected error field, got: {body}"
1172        );
1173    }
1174
1175    /// Why: This is the key regression test for the UAT gap: the connector
1176    /// `detect()` path reports `Running` or `Available` because it only does a
1177    /// TCP/which probe and knows nothing about `tools/list`. When the actual
1178    /// `McpServiceHandle` is in `Degraded` state (tools/list succeeded but
1179    /// `console_metrics` absent), `GET /api/console/services` MUST override that
1180    /// connector result to `degraded` with the remediation hint.
1181    /// What: Builds state whose connector returns `Running` for trusty-search,
1182    /// manually primes the trusty-search `McpServiceHandle` to `Degraded`, then
1183    /// issues GET /api/console/services and asserts `status == "degraded"` with
1184    /// a non-empty `hint`.  A connector that was `Absent` must NOT be overridden
1185    /// (only reachable services can be Degraded by the tools/list probe).
1186    /// This test intentionally does NOT use a hand-stubbed DegradedConnector —
1187    /// it exercises the real `apply_handle_overrides` bridge from
1188    /// `McpServiceHandle.state` → route response.
1189    /// Test: this test itself.
1190    #[tokio::test]
1191    async fn test_services_route_handle_degraded_overlay() {
1192        // Build state with:
1193        //  - trusty-search connector returning Running (TCP probe passed)
1194        //  - trusty-analyze connector returning Absent (binary not found)
1195        let state = AppState::new(vec![
1196            Box::new(StubConnector {
1197                id: "trusty-search",
1198                display_name: "Trusty Search",
1199                status: ServiceStatus::Running,
1200            }),
1201            Box::new(StubConnector {
1202                id: "trusty-analyze",
1203                display_name: "Trusty Analyze",
1204                status: ServiceStatus::Absent,
1205            }),
1206        ]);
1207
1208        // Prime the trusty-search handle to Degraded state (tools/list passed
1209        // but console_metrics was absent).  This simulates the real-world
1210        // situation on a machine where the daemon lacks console_metrics.
1211        {
1212            let handles = state.mcp_handles();
1213            let search_handle = handles
1214                .get("trusty-search")
1215                .expect("search handle must exist");
1216            search_handle.prime_degraded_for_test().await;
1217        }
1218
1219        let router = build_router(state);
1220        let req = Request::builder()
1221            .uri("/api/console/services")
1222            .body(Body::empty())
1223            .expect("request");
1224        let resp = router.oneshot(req).await.expect("response");
1225        assert_eq!(resp.status(), StatusCode::OK);
1226
1227        let bytes = get_bytes(resp).await;
1228        let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
1229        assert_eq!(body.len(), 2);
1230
1231        // trusty-search was Running via connector but Degraded via handle →
1232        // must be overridden to degraded with a hint.
1233        let search = body
1234            .iter()
1235            .find(|s| s["id"] == "trusty-search")
1236            .expect("search entry");
1237        assert_eq!(
1238            search["status"], "degraded",
1239            "Running service whose handle is Degraded must report degraded, got: {search}"
1240        );
1241        let hint = search["hint"].as_str().unwrap_or("");
1242        assert!(
1243            !hint.is_empty(),
1244            "degraded service must include a non-empty hint"
1245        );
1246        assert!(
1247            hint.contains("console_metrics"),
1248            "hint must mention console_metrics, got: {hint}"
1249        );
1250
1251        // trusty-analyze was Absent via connector — Absent must NOT be overridden
1252        // even if the handle were somehow Degraded (process-down ≠ degraded).
1253        let analyze = body
1254            .iter()
1255            .find(|s| s["id"] == "trusty-analyze")
1256            .expect("analyze entry");
1257        assert_eq!(
1258            analyze["status"], "absent",
1259            "Absent service must not be overridden to degraded"
1260        );
1261    }
1262
1263    /// Why: Regression test for issue #1170 — a stale daemon whose MCP process
1264    /// is running but lacks the `list_analyze_indexes` tool must cause the
1265    /// `/api/console/metrics/analyze/indexes` route to return HTTP 503 with a
1266    /// clean JSON body containing `status: "degraded"` and an actionable `hint`,
1267    /// NOT HTTP 502 with empty body. The capability-gate in `call_tool_checked`
1268    /// must fire before any JSON-RPC call is made to the daemon.
1269    /// What: Builds state with a `trusty-analyze` handle primed to `Connected`
1270    /// but missing `list_analyze_indexes` in the cached tool set. Issues GET
1271    /// /api/console/metrics/analyze/indexes and asserts:
1272    ///   1. Status is 503 (SERVICE_UNAVAILABLE), not 502 (BAD_GATEWAY).
1273    ///   2. JSON body has `status == "degraded"`.
1274    ///   3. JSON body has a non-empty `hint` mentioning the missing tool.
1275    /// Test: this test itself. Key regression for #1170.
1276    #[tokio::test]
1277    #[cfg(unix)]
1278    async fn test_analyze_indexes_tool_unavailable_returns_degraded_hint() {
1279        let state = make_test_state();
1280
1281        // Prime the analyze handle to Connected with list_analyze_indexes absent.
1282        {
1283            let analyze_handle = state.analyze_handle();
1284            analyze_handle
1285                .prime_connected_missing_tool_for_test("list_analyze_indexes")
1286                .await;
1287        }
1288
1289        let router = build_router(state);
1290        let req = Request::builder()
1291            .uri("/api/console/metrics/analyze/indexes")
1292            .body(Body::empty())
1293            .expect("request");
1294        let resp = router.oneshot(req).await.expect("response");
1295
1296        // Must be 503, not 502 — the capability gate fires, not the JSON-RPC call.
1297        assert_eq!(
1298            resp.status(),
1299            StatusCode::SERVICE_UNAVAILABLE,
1300            "missing tool must return 503 SERVICE_UNAVAILABLE, not 502 BAD_GATEWAY"
1301        );
1302
1303        let bytes = get_bytes(resp).await;
1304        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json body");
1305
1306        assert_eq!(
1307            body["status"], "degraded",
1308            "response body must have status=degraded, got: {body}"
1309        );
1310
1311        let hint = body["hint"].as_str().unwrap_or("");
1312        assert!(
1313            !hint.is_empty(),
1314            "response body must include a non-empty hint, got: {body}"
1315        );
1316        assert!(
1317            hint.contains("list_analyze_indexes"),
1318            "hint must mention the missing tool name, got: {hint}"
1319        );
1320    }
1321
1322    // ── same-origin guard on destructive session write routes (#1222 review #3) ──
1323
1324    /// Why: a cross-origin browser `POST` to a destructive session route is the
1325    /// CSRF threat the same-origin guard exists to block. With a non-loopback
1326    /// `Origin` header present, the write route must return `403 FORBIDDEN` and
1327    /// never reach the handler (which would otherwise return 503 absent-binary).
1328    /// What: issues `POST /api/console/sessions` with `Origin: http://evil.example`
1329    /// and asserts 403.
1330    /// Test: this test itself (review finding #3 regression guard).
1331    #[tokio::test]
1332    async fn write_route_rejects_cross_origin() {
1333        let router = build_router(make_test_state());
1334        let req = Request::builder()
1335            .method("POST")
1336            .uri("/api/console/sessions")
1337            .header("origin", "http://evil.example.com")
1338            .header("content-type", "application/json")
1339            .body(Body::from(
1340                serde_json::json!({"repo_url":"https://x/y","ref":"main","task":"t"}).to_string(),
1341            ))
1342            .expect("request");
1343        let resp = router.oneshot(req).await.expect("response");
1344        assert_eq!(
1345            resp.status(),
1346            StatusCode::FORBIDDEN,
1347            "cross-origin write must be rejected with 403"
1348        );
1349    }
1350
1351    /// Why: the legitimate operator surface (the SPA served from loopback) must
1352    /// still be able to drive write routes — a loopback `Origin` must pass the
1353    /// guard. With no trusty-mpm binary on PATH the handler then returns a 503,
1354    /// so the guard is proven transparent by asserting the status is NOT 403.
1355    /// What: issues `DELETE /api/console/sessions/abc` with a loopback Origin and
1356    /// asserts the response is not 403 (guard passed; handler degraded to 503).
1357    /// Test: this test itself.
1358    #[tokio::test]
1359    async fn write_route_allows_loopback_origin() {
1360        let router = build_router(make_test_state());
1361        let req = Request::builder()
1362            .method("DELETE")
1363            .uri("/api/console/sessions/abc")
1364            .header("origin", "http://127.0.0.1:7788")
1365            .body(Body::empty())
1366            .expect("request");
1367        let resp = router.oneshot(req).await.expect("response");
1368        assert_ne!(
1369            resp.status(),
1370            StatusCode::FORBIDDEN,
1371            "loopback-origin write must pass the same-origin guard"
1372        );
1373    }
1374
1375    /// Why: non-browser clients (curl, native tooling, the console's own
1376    /// server-side calls) send no `Origin` header and are not the CSRF threat;
1377    /// they must pass the guard. With no binary present the handler degrades to
1378    /// 503, so we assert the status is NOT 403.
1379    /// What: issues `POST /api/console/sessions/abc/stop` with no Origin header.
1380    /// Test: this test itself.
1381    #[tokio::test]
1382    async fn write_route_allows_missing_origin() {
1383        let router = build_router(make_test_state());
1384        let req = Request::builder()
1385            .method("POST")
1386            .uri("/api/console/sessions/abc/stop")
1387            .body(Body::empty())
1388            .expect("request");
1389        let resp = router.oneshot(req).await.expect("response");
1390        assert_ne!(
1391            resp.status(),
1392            StatusCode::FORBIDDEN,
1393            "missing-Origin write must pass the same-origin guard"
1394        );
1395    }
1396
1397    /// Why: the guard must NOT block safe cross-origin reads — the CORS policy is
1398    /// intentionally open for GETs so the SPA and tooling can read fleet state.
1399    /// A cross-origin `GET` must pass the guard (and then degrade to 503 with no
1400    /// binary), proving the middleware is method-aware.
1401    /// What: issues `GET /api/console/sessions` with a remote Origin; asserts the
1402    /// status is NOT 403.
1403    /// Test: this test itself.
1404    #[tokio::test]
1405    async fn read_route_allows_cross_origin() {
1406        let router = build_router(make_test_state());
1407        let req = Request::builder()
1408            .method("GET")
1409            .uri("/api/console/sessions")
1410            .header("origin", "http://evil.example.com")
1411            .body(Body::empty())
1412            .expect("request");
1413        let resp = router.oneshot(req).await.expect("response");
1414        assert_ne!(
1415            resp.status(),
1416            StatusCode::FORBIDDEN,
1417            "cross-origin GET (read) must not be blocked by the write guard"
1418        );
1419    }
1420}