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