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