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}