trusty_console/server/mod.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//! - `POST /api/webhooks/{source}` — GitHub webhook ingress: verify once,
11//! spool durably, relay over UDS (#5089 step 3, ADR-0034). Mounted only by
12//! [`build_router_with_webhooks`].
13//! - `GET /api/console/metrics/webhooks` — oldest-pending spool age as a red
14//! health state.
15//! - `DELETE /api/console/memory/palaces/{id}` — delete one palace via
16//! trusty-memory's `palace_delete` on its socket (#6360).
17//! - `DELETE /api/console/search/indexes/{id}` — delete one index via
18//! trusty-search's own `search.index.delete` on its socket (#6360, #6285).
19//! - `GET /api/console/metrics/analyze/indexes` — analyze index list via stdio MCP.
20//! - `GET /api/console/metrics/analyze/visualize?index=<id>` — graph+entities+clusters.
21//! - `…/api/console/sessions/*` — the single HTTP front door for the trusty-mpm
22//! session manager (#1222); handlers live in `crate::routes::sessions`.
23//! - `ANY /api/search/{*path}` — translate the request into a trusty-search
24//! RPC call on its socket (#6285); see `crate::search_uds`.
25//! - `ANY /api/{service}/{*path}` — reverse-proxy to live daemon via clean path
26//! (#1849 Phase 2); `{service}` ∈ {review, mpm, agents}.
27//! - `ANY /proxy/{daemon}/{*path}` — DEPRECATED alias; routes to the same
28//! handler with a trace-level deprecation note.
29//! - `GET /` and `GET /ui/*path` — serve the embedded Svelte SPA; the
30//! handlers live in `crate::console_ui` (#6285, 500-SLOC split).
31//! - `GET /tools/search/*path` — serve the embedded trusty-search SPA
32//! (#6155); see `crate::tools_ui`.
33//!
34//! All logs go to stderr; stdout is clean.
35//!
36//! Test: The `tests` module starts the router in a real axum test client.
37
38use std::collections::HashMap;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::time::Duration;
42
43use axum::{
44 Router,
45 extract::{Query, State},
46 http::StatusCode,
47 response::IntoResponse,
48 routing::{any, get, post},
49};
50use serde::Deserialize;
51use serde_json::json;
52use tower_http::cors::CorsLayer;
53use tower_http::trace::TraceLayer;
54
55use crate::connector::{ServiceConnector, ServiceInfo, ServiceStatus};
56use crate::mcp_handle::{McpHandleError, McpServiceHandle};
57use crate::metrics_poller::MetricsCache;
58use crate::poller::PollerCache;
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 /// Whole-machine host-metrics cache (#6517). Populated by the background
90 /// host sampler (`crate::host_status`); served by
91 /// `GET /api/console/machine-status`.
92 host_metrics_cache: crate::host_status::HostMetricsCache,
93 http_client: Arc<reqwest::Client>,
94 /// The client the proxy uses for Server-Sent Events (#6155).
95 ///
96 /// Why: `http_client` sets a 30-second whole-request timeout, which is
97 /// right for a request/response API call and fatal for a stream that is
98 /// meant to stay open — the console-served search SPA opens
99 /// `/status/stream` on every page load, and under the shared client that
100 /// connection would be cut every 30 seconds and reopened by `EventSource`
101 /// forever.
102 /// What: identical to `http_client` except the total timeout is replaced by
103 /// a 60-second read timeout, which bounds an upstream that has gone silent
104 /// without bounding one that is still sending. Both search streams stay
105 /// well inside it: `/status/stream` pushes every 2 seconds and
106 /// `/reindex/stream` heartbeats every 20.
107 ///
108 /// The absent total deadline is why `proxy_handler` does not hand this
109 /// client's body straight to the caller. `Accept` is a caller claim, so a
110 /// response the upstream did not label `text/event-stream` is read under
111 /// `NON_STREAM_BODY_TIMEOUT` instead — otherwise any proxied GET could opt
112 /// into an unbounded connection by naming that Accept type.
113 /// Test: `proxy::routes::tests::test_wants_event_stream_*` covers which
114 /// client a request gets; the timeout itself is construction, exercised by
115 /// the live `/status/stream` smoke run recorded on #6155.
116 stream_client: Arc<reqwest::Client>,
117 /// Analyze stdio MCP handle — shared with the metrics poller so both the
118 /// background poll and on-demand route calls reuse the same child process.
119 analyze_handle: Arc<McpServiceHandle>,
120 /// All per-service MCP handles keyed by service id.
121 ///
122 /// Why: The services route reads each handle's degraded state to override
123 /// the connector-reported status when a reachable service is missing
124 /// `console_metrics`. Using a HashMap avoids adding individual Arc fields
125 /// for every future service.
126 /// What: Populated by `AppState::new`; read by `apply_handle_overrides`.
127 mcp_handles: Arc<HashMap<String, Arc<McpServiceHandle>>>,
128 /// Override for the trusty-search socket path (#6285).
129 ///
130 /// Why: `search_uds` and `detect::SearchConnector` resolve that path through
131 /// `trusty_common::daemon_socket_path`, which reads the process-global
132 /// `TRUSTY_DATA_DIR_OVERRIDE`. A test that redirected it would redirect five
133 /// sibling connectors running in the same binary at the same time, so the
134 /// override is carried here instead — the same argument
135 /// `detect::AnalyzeConnector::with_socket` records.
136 /// What: `None` in production, which resolves the real path.
137 /// Test: `tests/search_uds_bridge.rs` sets it on every case.
138 pub(crate) search_socket: Option<Arc<PathBuf>>,
139}
140
141impl AppState {
142 /// Create a new `AppState` from a list of connectors.
143 ///
144 /// Why: Lets tests inject a custom connector list and fresh caches.
145 /// What: Wraps `connectors` in `Arc`; initialises empty `PollerCache`,
146 /// three `MetricsCache` instances (analyze / memory / search), and a
147 /// `reqwest::Client` with idle-connection pooling disabled (#1984 — see the
148 /// builder comment below). Creates the analyze stdio MCP handle that is
149 /// shared between the background metrics poller and on-demand routes.
150 /// Populates `mcp_handles` with all three per-service handles so the
151 /// services route can read their degraded state.
152 /// Test: Used in `build_router` and directly in `tests`.
153 pub fn new(connectors: Vec<Box<dyn ServiceConnector>>) -> Self {
154 // Why pool_max_idle_per_host(0): the proxy client must survive an upstream
155 // daemon restart (#1984). With the default keep-alive pool, the FIRST
156 // proxied request after an upstream restart reuses a stale idle connection
157 // to the now-dead process and fails — an instant RST → 502, a half-open
158 // hang → 30s-timeout → 502, or a partial write the restarted daemon
159 // rejects → 500 — even though a direct curl (which never pools across
160 // invocations) always opens a fresh connection and succeeds. reqwest does
161 // NOT retry a non-idempotent POST on a broken pooled connection, so the
162 // failure is surfaced to the caller (e.g. `tm session new`). Disabling
163 // idle-connection reuse forces every proxied request to open a fresh
164 // connection to whatever process currently owns the port, eliminating the
165 // stale-reuse failure at the root. Loopback connect cost is negligible.
166 // #6360: reqwest follows up to 10 redirects by default, and every
167 // loopback check in this crate — the proxy's `is_local_upstream`, the
168 // delete routes' reuse of it — validates only the URL it was handed. A
169 // 3xx from an upstream would re-issue the request, body and method
170 // intact, at whatever host the `Location` names, which for a DELETE
171 // means a destructive call to an address nothing checked. Refusing to
172 // follow leaves the 3xx as the response: the proxy hands it to the
173 // browser (which is what a reverse proxy should do with a redirect the
174 // upstream chose) and the delete routes read it as a non-2xx refusal.
175 // Nothing in this crate relied on following one.
176 let client = reqwest::Client::builder()
177 .timeout(Duration::from_secs(30))
178 .pool_max_idle_per_host(0)
179 .redirect(reqwest::redirect::Policy::none())
180 .build()
181 .expect("reqwest client init");
182 // #6155: same connection policy, no whole-request deadline — see the
183 // `stream_client` field doc.
184 let stream_client = reqwest::Client::builder()
185 .read_timeout(Duration::from_secs(60))
186 .pool_max_idle_per_host(0)
187 .redirect(reqwest::redirect::Policy::none())
188 .build()
189 .expect("reqwest stream client init");
190 let analyze_handle = Arc::new(McpServiceHandle::new(
191 "trusty-analyze",
192 vec!["mcp".to_string()],
193 ));
194 let memory_handle = Arc::new(McpServiceHandle::new(
195 "trusty-memory",
196 vec!["serve".to_string(), "--stdio".to_string()],
197 ));
198 let search_handle = Arc::new(McpServiceHandle::new(
199 "trusty-search",
200 vec!["serve".to_string()],
201 ));
202 // Why: trusty-review's stdio MCP mode is `serve --stdio` (see ServeArgs
203 // in commands/serve.rs). This is the canonical command the console spawns
204 // to poll `console_metrics` without requiring the HTTP daemon to be running.
205 let review_handle = Arc::new(McpServiceHandle::new(
206 "trusty-review",
207 vec!["serve".to_string(), "--stdio".to_string()],
208 ));
209 // Why: trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge
210 // that auto-starts the durable daemon and forwards JSON-RPC to its
211 // loopback POST /rpc). The console spawns this to render the Sessions tab
212 // natively (#1222) without ever touching the daemon's HTTP port (#1104).
213 let mpm_handle = Arc::new(McpServiceHandle::new(
214 "trusty-mpm",
215 vec!["serve".to_string(), "--stdio".to_string()],
216 ));
217 let mut handles: HashMap<String, Arc<McpServiceHandle>> = HashMap::new();
218 handles.insert("trusty-analyze".to_string(), Arc::clone(&analyze_handle));
219 handles.insert("trusty-memory".to_string(), Arc::clone(&memory_handle));
220 handles.insert("trusty-search".to_string(), Arc::clone(&search_handle));
221 handles.insert("trusty-review".to_string(), Arc::clone(&review_handle));
222 handles.insert("trusty-mpm".to_string(), Arc::clone(&mpm_handle));
223 Self {
224 connectors: Arc::new(connectors),
225 poller_cache: PollerCache::new(),
226 metrics_cache: MetricsCache::new(),
227 memory_metrics_cache: MetricsCache::new(),
228 search_metrics_cache: MetricsCache::new(),
229 review_metrics_cache: MetricsCache::new(),
230 mpm_metrics_cache: MetricsCache::new(),
231 host_metrics_cache: crate::host_status::HostMetricsCache::new(),
232 http_client: Arc::new(client),
233 stream_client: Arc::new(stream_client),
234 analyze_handle,
235 mcp_handles: Arc::new(handles),
236 search_socket: None,
237 }
238 }
239
240 /// Access the per-service MCP handle map.
241 ///
242 /// Why: The services route reads handles from this map to overlay connector
243 /// statuses with the tools/list probe result.
244 /// What: Returns a clone of the `Arc<HashMap>` (cheap).
245 /// Test: Used by `apply_handle_overrides` and the services handler.
246 pub fn mcp_handles(&self) -> Arc<HashMap<String, Arc<McpServiceHandle>>> {
247 Arc::clone(&self.mcp_handles)
248 }
249
250 /// Access the shared analyze MCP handle.
251 ///
252 /// Why: On-demand routes (`/api/console/metrics/analyze/indexes`,
253 /// `/api/console/metrics/analyze/visualize`) call the analyze stdio MCP
254 /// without touching the analyze daemon HTTP directly (architecture: console
255 /// is a stdio MCP client only, per #1104).
256 /// What: Returns a clone of the `Arc<McpServiceHandle>` (cheap).
257 /// Test: Exercised by the analyze index and visualize route tests.
258 pub fn analyze_handle(&self) -> Arc<McpServiceHandle> {
259 Arc::clone(&self.analyze_handle)
260 }
261
262 /// Access the shared connector list.
263 ///
264 /// Why: The background poller and the fallback `spawn_blocking` path both
265 /// need the connector list.
266 /// What: Returns a clone of the `Arc` (cheap).
267 /// Test: Used by `run_serve` in `main.rs`.
268 pub fn connectors(&self) -> Arc<Vec<Box<dyn ServiceConnector>>> {
269 Arc::clone(&self.connectors)
270 }
271
272 /// Access the background poll cache.
273 ///
274 /// Why: Routes read from the cache; the background task writes to it.
275 /// What: Returns a clone of the `PollerCache` handle (cheap — it's an Arc).
276 /// Test: Used by `services_handler` and `proxy_handler`.
277 pub fn poller_cache(&self) -> &PollerCache {
278 &self.poller_cache
279 }
280
281 /// Access the metrics cache for the trusty-analyze stdio MCP poller.
282 ///
283 /// Why: The metrics poller writes `ConsoleMetricsReport`s here; the
284 /// `/api/console/metrics/analyze` route reads from it.
285 /// What: Returns a reference to the `MetricsCache` handle.
286 /// Test: `test_metrics_analyze_route_cold_cache_returns_503`.
287 pub fn metrics_cache(&self) -> &MetricsCache {
288 &self.metrics_cache
289 }
290
291 /// Access the metrics cache for the trusty-memory stdio MCP poller.
292 ///
293 /// Why: Separate cache per service so memory and analyze reports can be
294 /// updated and served independently.
295 /// What: Returns a reference to the `MetricsCache` handle for memory.
296 /// Test: `test_metrics_memory_route_cold_cache_returns_503`.
297 pub fn memory_metrics_cache(&self) -> &MetricsCache {
298 &self.memory_metrics_cache
299 }
300
301 /// Access the metrics cache for the trusty-search stdio MCP poller.
302 ///
303 /// Why: Separate cache per service so search and analyze reports can be
304 /// updated and served independently.
305 /// What: Returns a reference to the `MetricsCache` handle for search.
306 /// Test: `test_metrics_search_route_cold_cache_returns_503`.
307 pub fn search_metrics_cache(&self) -> &MetricsCache {
308 &self.search_metrics_cache
309 }
310
311 /// Access the metrics cache for the trusty-review stdio MCP poller.
312 ///
313 /// Why: Separate cache per service so review reports can be updated and
314 /// served independently from the other service caches.
315 /// What: Returns a reference to the `MetricsCache` handle for review.
316 /// Test: `test_metrics_review_route_cold_cache_returns_503`.
317 pub fn review_metrics_cache(&self) -> &MetricsCache {
318 &self.review_metrics_cache
319 }
320
321 /// Access the metrics cache for the trusty-mpm stdio MCP poller (#1222).
322 ///
323 /// Why: separate cache per service so the mpm session/supervisor report can
324 /// be updated and served independently from the other service caches.
325 /// What: returns a reference to the `MetricsCache` handle for mpm.
326 /// Test: `test_metrics_mpm_route_cold_cache_returns_503`.
327 pub fn mpm_metrics_cache(&self) -> &MetricsCache {
328 &self.mpm_metrics_cache
329 }
330
331 /// Access the whole-machine host-metrics cache (#6517).
332 ///
333 /// Why: the background host sampler writes here; the machine-status route
334 /// reads it. `run_serve` clones it to start the sampler.
335 /// What: returns a reference to the `HostMetricsCache` handle.
336 /// Test: `machine_status_route_cold_cache_returns_503`.
337 pub fn host_metrics_cache(&self) -> &crate::host_status::HostMetricsCache {
338 &self.host_metrics_cache
339 }
340
341 /// Gather whichever per-service `ConsoleMetricsReport`s are currently cached
342 /// (#6517).
343 ///
344 /// Why: the machine-status rollup counts and lists every service that has a
345 /// warm report. A service whose cache is still `None` (binary absent or not
346 /// yet polled) is simply omitted — the rollup describes what is reachable.
347 /// What: reads all five per-service metrics caches and collects the `Some`
348 /// reports into a `Vec` in a stable service order.
349 /// Test: `machine_status_route_warm_cache_returns_json`.
350 pub async fn collect_service_reports(
351 &self,
352 ) -> Vec<trusty_common::console_metrics::ConsoleMetricsReport> {
353 let caches = [
354 &self.metrics_cache,
355 &self.memory_metrics_cache,
356 &self.search_metrics_cache,
357 &self.review_metrics_cache,
358 &self.mpm_metrics_cache,
359 ];
360 let mut reports = Vec::with_capacity(caches.len());
361 for cache in caches {
362 if let Some(report) = cache.get().await {
363 reports.push(report);
364 }
365 }
366 reports
367 }
368
369 /// Access the shared `reqwest::Client`.
370 ///
371 /// Why: Re-using one client enables connection pooling across proxy requests.
372 /// What: Returns a clone of the `Arc<reqwest::Client>` (cheap).
373 /// Test: Used by `proxy_handler`.
374 pub fn http_client(&self) -> Arc<reqwest::Client> {
375 Arc::clone(&self.http_client)
376 }
377
378 /// The client to proxy a Server-Sent Events request with (#6155).
379 pub fn stream_client(&self) -> Arc<reqwest::Client> {
380 Arc::clone(&self.stream_client)
381 }
382}
383
384// ─── router ──────────────────────────────────────────────────────────────────
385
386/// Build the axum `Router` with all routes wired, trusting only loopback as
387/// the write-origin self-origin.
388///
389/// Why: Extracting the router into its own function allows both `main` and the
390/// test harness to share the same routing configuration without running a real
391/// TCP server. This loopback-only entry point is what every existing test and
392/// `Local`/`Explicit` (non-Tailscale) bind mode use; Tailscale deployments use
393/// [`build_router_with_self_origins`] instead so their own bind address is
394/// also trusted (#3269).
395/// What: Returns a `Router<()>` with CORS, tracing middleware, and all routes.
396/// Test: Called from `tests::test_services_route_returns_json` below.
397pub fn build_router(state: AppState) -> Router {
398 build_router_with_self_origins(state, crate::routes::origin_guard::SelfOrigins::default())
399}
400
401/// Build the router with the webhook ingress mounted (#5089 step 3).
402///
403/// Why: the ingress owns a spool directory, so constructing it can fail — and
404/// it must fail loudly at startup rather than silently leaving
405/// `/api/webhooks/{source}` unrouted, which would turn every delivery into a
406/// `404` GitHub records as a failure nobody looks at. Keeping it a separate
407/// parameter lets `run_serve` do that fallible construction once while the
408/// existing infallible `build_router` call sites (and every test that does not
409/// exercise webhooks) stay unchanged.
410/// What: identical to [`build_router_with_self_origins`], plus
411/// `POST /api/webhooks/{source}` and `GET /api/console/metrics/webhooks`, both
412/// carrying `WebhookIngress` as their own state.
413/// Test: the `route_*` and `metrics_route_*` cases in `webhook/tests.rs`.
414pub fn build_router_with_webhooks(
415 state: AppState,
416 self_origins: crate::routes::origin_guard::SelfOrigins,
417 ingress: crate::webhook::WebhookIngress,
418) -> Router {
419 build_router_inner(state, self_origins, Some(ingress))
420}
421
422/// Build the axum `Router` with all routes wired, additionally trusting the
423/// given bind-derived, non-loopback self-origins for the write-origin guard.
424///
425/// Why: #3269 — in Tailscale bind mode the console's own write UI is served
426/// from a non-loopback address; the guard must trust that exact address
427/// (derived from the server's actually-resolved bind addresses) without
428/// opening up to arbitrary remote origins. Splitting this out from
429/// `build_router` keeps every existing (loopback-only) call site and test
430/// unchanged.
431/// What: Identical router to `build_router`, except the write-origin guard
432/// (see below) is constructed with `self_origins` instead of the default
433/// empty set.
434/// Test: `server/tests.rs` tests `proxy_route_allows_self_origin_write` /
435/// `proxy_route_rejects_cross_origin_write`; `bind.rs`/`lib.rs` wire the real
436/// resolved addresses in `run_serve`.
437pub fn build_router_with_self_origins(
438 state: AppState,
439 self_origins: crate::routes::origin_guard::SelfOrigins,
440) -> Router {
441 build_router_inner(state, self_origins, None)
442}
443
444/// The one router definition both public builders delegate to, so the mounted
445/// route set cannot drift between them.
446fn build_router_inner(
447 state: AppState,
448 self_origins: crate::routes::origin_guard::SelfOrigins,
449 webhook: Option<crate::webhook::WebhookIngress>,
450) -> Router {
451 let core = Router::new()
452 .route("/health", get(health_handler))
453 .route("/api/console/services", get(services_handler))
454 .route("/api/console/metrics/analyze", get(metrics_analyze_handler))
455 .route("/api/console/metrics/memory", get(metrics_memory_handler))
456 .route("/api/console/metrics/search", get(metrics_search_handler))
457 .route("/api/console/metrics/review", get(metrics_review_handler))
458 .route("/api/console/metrics/mpm", get(metrics_mpm_handler))
459 // #6517: aggregated whole-machine host resources + per-service rollup.
460 .route(
461 "/api/console/machine-status",
462 get(crate::routes::machine_status::machine_status_handler),
463 )
464 // ── trusty-mpm session-manager surface (#1222: P2 tab + P3 front door) ──
465 // The console is the SINGLE HTTP front door for the session REST API;
466 // every handler calls a trusty-mpm MCP tool via the stdio bridge — never
467 // the daemon's HTTP port (#1104).
468 //
469 // Route precedence (verified, NOT declaration-order dependent): axum 0.8
470 // routes via matchit 0.8, which prioritises a literal/static path segment
471 // over a `{param}` capture at the same position regardless of the order
472 // routes are added. So `/sessions/supervisor` and
473 // `/sessions/supervisor/auto-resume` always win over `/sessions/{id}` —
474 // a request for `…/supervisor` reaches `supervisor_handler`, never
475 // `get_handler` with id="supervisor". This is asserted directly by
476 // `routes::sessions::tests::supervisor_route_is_not_shadowed_by_id_capture`
477 // and `…::auto_resume_route_is_not_shadowed`.
478 .route(
479 "/api/console/sessions",
480 get(crate::routes::sessions::list_handler).post(crate::routes::sessions::new_handler),
481 )
482 .route(
483 "/api/console/sessions/supervisor",
484 get(crate::routes::sessions::supervisor_handler),
485 )
486 .route(
487 "/api/console/sessions/supervisor/auto-resume",
488 axum::routing::post(crate::routes::sessions::auto_resume_handler),
489 )
490 // #6431: record-only bulk delete. A static segment, so it wins over the
491 // `{id}` capture below — pinned by `bulk_delete_route_is_not_shadowed`.
492 .route(
493 "/api/console/sessions/bulk-delete",
494 axum::routing::post(crate::routes::sessions::bulk_delete_handler),
495 )
496 .route(
497 "/api/console/sessions/{id}",
498 get(crate::routes::sessions::get_handler)
499 .delete(crate::routes::sessions::decommission_handler),
500 )
501 .route(
502 "/api/console/sessions/{id}/activity",
503 get(crate::routes::sessions::activity_handler),
504 )
505 .route(
506 "/api/console/sessions/{id}/stop",
507 axum::routing::post(crate::routes::sessions::stop_handler),
508 )
509 .route(
510 "/api/console/sessions/{id}/resume",
511 axum::routing::post(crate::routes::sessions::resume_handler),
512 )
513 // #1220 Config tab: read/write the `~/.trusty-tools/trusty-mpm/config.yaml`
514 // convention via the trusty-mpm `config_read` / `config_write` MCP tools.
515 // The POST is a state-changing write; the router-wide origin guard
516 // (see the `.layer()` call near the bottom of this router) covers it.
517 .route(
518 "/api/console/config/mpm",
519 get(crate::routes::config::get_handler).post(crate::routes::config::post_handler),
520 )
521 // #6360: operator-driven deletion of one palace / one index. Both call
522 // the owning daemon's existing teardown and report what it actually did
523 // — the console implements no deletion of its own. The router-wide
524 // origin guard below covers them, as it does every other write route.
525 .route(
526 "/api/console/memory/palaces/{id}",
527 axum::routing::delete(crate::routes::deletes::delete_palace_handler),
528 )
529 .route(
530 "/api/console/search/indexes/{id}",
531 axum::routing::delete(crate::routes::deletes::delete_index_handler),
532 )
533 // #6371: batch prune of stale index registrations, and palace
534 // compaction. `prune-indexes` is not `indexes/prune` because a static
535 // segment beside `indexes/{id}` would shadow an index named `prune`.
536 .route(
537 "/api/console/search/prune-indexes",
538 post(crate::routes::cleanup::prune_indexes_handler),
539 )
540 .route(
541 "/api/console/memory/palaces/{id}/compact",
542 post(crate::routes::cleanup::compact_palace_handler),
543 )
544 // #6423: settle ONE registration the daemon could not check, after the
545 // operator reviewed it. Per-row on purpose — the batch prune above
546 // reads the census's `orphans` list alone and cannot reach these.
547 .route(
548 "/api/console/search/deregister-unjudged",
549 post(crate::routes::unjudged::deregister_unjudged_handler),
550 )
551 // Analyze on-demand routes — call the analyze stdio MCP directly (no /proxy).
552 .route(
553 "/api/console/metrics/analyze/indexes",
554 get(analyze_indexes_handler),
555 )
556 .route(
557 "/api/console/metrics/analyze/visualize",
558 get(analyze_visualize_handler),
559 )
560 // #6285: `search` is NOT a reverse-proxy row any more. trusty-search
561 // moved onto a Unix socket (ADR-0032) and stopped writing the
562 // `http_addr` file the proxy resolves a base URL from, so this literal
563 // route takes the prefix and translates each request into an RPC call.
564 // matchit prefers the static `search` segment over the `{service}`
565 // capture below, so the two cannot collide.
566 .route(
567 "/api/search/{*path}",
568 any(crate::search_uds::routes::search_api_handler),
569 )
570 .route(
571 "/proxy/search/{*path}",
572 any(crate::search_uds::routes::deprecated_search_api_handler),
573 )
574 // Primary reverse-proxy: /api/{service}/{*path} (#1849 Phase 2).
575 // {service} ∈ {review, mpm, agents}.
576 // No collision with /api/console/*: axum (matchit 0.8) routes literal
577 // segments before wildcard captures, so /api/console/* always wins.
578 // The proxy handler also rejects service_key == "console" explicitly as // pragma: allowlist secret
579 // a routing-independent second layer of defence.
580 .route("/api/{service}/{*path}", any(crate::proxy::proxy_handler))
581 // Deprecated alias: /proxy/{daemon}/{*path} → same handler with a trace log.
582 // Kept for backward compatibility; callers should migrate to /api/{service}/*.
583 .route(
584 "/proxy/{daemon}/{*path}",
585 any(crate::proxy::deprecated_proxy_handler),
586 )
587 // #6155: the trusty-search SPA, served from this binary under
588 // /tools/search/. Its API calls resolve to /api/search/*, which the
589 // proxy route above forwards — so the dashboard keeps working once
590 // trusty-search drops its own HTTP surface (#6285, ADR-0032).
591 .route("/tools/search", get(crate::tools_ui::search_ui_redirect))
592 .route("/tools/search/", get(crate::tools_ui::search_ui_index))
593 .route(
594 "/tools/search/{*path}",
595 get(crate::tools_ui::search_ui_asset),
596 )
597 .route("/", get(crate::console_ui::spa_index_handler))
598 // #6519: /ui/screensaver already reaches the shell through the SPA
599 // fallback in the wildcard below; this is the top-level alias, which has
600 // no wildcard to fall through and so needs its own route.
601 .route("/screensaver", get(crate::console_ui::spa_index_handler))
602 .route("/ui", get(crate::console_ui::spa_index_handler))
603 .route("/ui/", get(crate::console_ui::spa_index_handler))
604 .route("/ui/{*path}", get(crate::console_ui::spa_asset_handler))
605 .with_state(state);
606
607 // Webhook ingress (#5089 step 3, ADR-0034). Merged as its own state-typed
608 // sub-router. `/api/webhooks/{source}` cannot be shadowed by the
609 // `/api/{service}/{*path}` proxy above: matchit 0.8 prefers a static
610 // segment over a `{param}` capture at the same position, so `webhooks`
611 // wins regardless of declaration order — the same precedence rule the
612 // `/api/console/*` routes already rely on.
613 let router = match webhook {
614 Some(ingress) => core.merge(
615 Router::new()
616 .route(
617 "/api/webhooks/{source}",
618 axum::routing::post(crate::webhook::webhook_handler),
619 )
620 .route(
621 "/api/console/metrics/webhooks",
622 get(crate::webhook::metrics_webhooks_handler),
623 )
624 .with_state(ingress)
625 // axum's DefaultBodyLimit is 2 MiB, which silently 413s a real
626 // delivery before the handler runs: no spool entry, no metric,
627 // no ack — the exact invisible drop this route exists to
628 // prevent. GitHub payloads are legal to 25 MB and `push` /
629 // `pull_request` bodies routinely pass 2 MiB. Scoped to this
630 // sub-router so the proxy and SPA routes keep the default.
631 // (`trusty-search` sets 64 MiB the same way, at
632 // `service/server/mod.rs:251`.)
633 .layer(axum::extract::DefaultBodyLimit::max(
634 crate::webhook::MAX_WEBHOOK_BODY_BYTES,
635 )),
636 ),
637 None => core,
638 };
639
640 router
641 // Same-origin guard for ALL destructive write routes, applied
642 // router-wide (#3268 fix). The console serves a permissive CORS
643 // policy (open reads), so without this guard any web page the
644 // operator visited could fire a cross-origin `fetch` and
645 // spawn/stop/decommission sessions, or — since this is a plain
646 // `.layer()`, not `route_layer` — reach destructive daemon endpoints
647 // through the reverse-proxy routes above (`/api/{service}/{*path}`,
648 // `/proxy/{daemon}/{*path}`), which a route-scoped `route_layer`
649 // placed earlier in the chain would miss entirely (the #3268 root
650 // cause). The middleware is method-aware — it only blocks
651 // state-changing methods whose `Origin` header is present and
652 // neither loopback nor a trusted self-origin, so GET reads (and the
653 // read-only daemon proxy traffic) pass through untouched.
654 .layer(axum::middleware::from_fn_with_state(
655 self_origins,
656 crate::routes::origin_guard::guard_write_origin,
657 ))
658 .layer(CorsLayer::permissive())
659 .layer(TraceLayer::new_for_http())
660}
661
662// ─── handlers ────────────────────────────────────────────────────────────────
663
664/// `GET /health` — liveness probe.
665///
666/// Why: Required by process monitors and the `trusty-console status` CLI
667/// subcommand. Returns a minimal JSON body so callers can confirm the server
668/// is up and which version is running.
669/// What: Returns `{"status":"ok","version":"<CARGO_PKG_VERSION>"}`.
670/// Test: Tested by `test_health_route` below.
671async fn health_handler() -> impl IntoResponse {
672 axum::Json(json!({
673 "status": "ok",
674 "version": env!("CARGO_PKG_VERSION"),
675 }))
676}
677
678/// Apply per-service MCP handle state on top of connector-reported statuses.
679///
680/// Why: The connector `detect()` path (TCP probe / `which`) can only report
681/// `Running`, `Available`, or `Absent`. It has no knowledge of the MCP
682/// `tools/list` probe result. When a service is reachable but the
683/// `console_metrics` tool is absent (`HandleState::Degraded`), the connector
684/// still reports `Running` or `Available` — the UI incorrectly shows a healthy
685/// badge. This function overlays the handle's known state: if a handle is
686/// Degraded, the corresponding `ServiceInfo` is updated in-place to
687/// `status = Degraded` and `hint = DEGRADED_HINT`. If a handle is Connected, the
688/// daemon version from the `initialize` response is surfaced (unless the connector
689/// already reported a version from the HTTP `/health` endpoint).
690/// What: Iterates `infos` in place; for each entry looks up the matching handle
691/// by `id`. If `handle.degraded_hint()` returns `Some(hint)` and the current
692/// status is NOT already `Absent`, sets `status = Degraded` and `hint = Some`.
693/// If `info.version` is `None` and `handle.daemon_version()` returns `Some`,
694/// sets `info.version` from the MCP `serverInfo.version`.
695/// A process-down (`Absent`) service is never overridden — only reachable ones.
696/// Skipping only `Absent` is safe: `Available` handles always return `None`
697/// from `degraded_hint` (no tools/list probe runs until the first poll), so
698/// they pass through unchanged.
699/// Test: `test_services_route_handle_degraded_overlay` and
700/// `test_services_route_daemon_version_overlay` below.
701async fn apply_handle_overrides(
702 infos: &mut [ServiceInfo],
703 handles: &HashMap<String, Arc<McpServiceHandle>>,
704) {
705 for info in infos.iter_mut() {
706 if info.status == ServiceStatus::Absent {
707 continue;
708 }
709 if let Some(handle) = handles.get(&info.id) {
710 if let Some(hint) = handle.degraded_hint().await {
711 info.status = ServiceStatus::Degraded;
712 info.hint = Some(hint);
713 }
714 // Surface the MCP daemon version when the connector hasn't
715 // already provided one (e.g. when the HTTP daemon isn't running
716 // but the stdio MCP process is up and has responded to initialize).
717 if info.version.is_none()
718 && let Some(ver) = handle.daemon_version().await
719 {
720 info.version = Some(ver);
721 }
722 }
723 }
724}
725
726/// `GET /api/console/services` — return cached snapshot of all services.
727///
728/// Why: The Svelte SPA fetches this endpoint on load to render service cards.
729/// With the background poller in place the response is instant (no per-
730/// request TCP probes).
731/// What: Reads the latest `CachedSnapshot` from the `PollerCache`. If the first
732/// poll has not completed yet, falls back to a synchronous on-demand detection
733/// so the UI always gets data (the first-boot latency is acceptable; after that
734/// every response is cache-backed). A panic in the fallback blocking task
735/// surfaces as HTTP 500 rather than an empty 200.
736/// After obtaining the base service list (from cache or fallback), applies
737/// per-service handle degraded overrides via `apply_handle_overrides` so
738/// reachable services missing `console_metrics` surface as `status: degraded`,
739/// then sorts the list with `detect::order_for_display` so the Overview grid
740/// leads with the services that are actually live (#6370).
741/// Test: `test_services_route_returns_json`,
742/// `test_services_handler_returns_500_on_panic`,
743/// `test_services_route_handle_degraded_overlay`, and
744/// `test_services_route_orders_running_before_absent` below.
745async fn services_handler(State(state): State<AppState>) -> axum::response::Response {
746 let handles = state.mcp_handles();
747
748 if let Some(snap) = state.poller_cache().snapshot().await {
749 let mut services = snap.services;
750 apply_handle_overrides(&mut services, &handles).await;
751 // #6370: sort AFTER the overrides so a service demoted to Degraded here
752 // ranks as degraded, not as the Running the poller recorded.
753 crate::detect::order_for_display(&mut services);
754 return axum::Json(services).into_response();
755 }
756
757 // First-boot fallback: run a one-shot detection synchronously.
758 let connectors = state.connectors();
759 match tokio::task::spawn_blocking(move || {
760 connectors.iter().map(|c| c.detect()).collect::<Vec<_>>()
761 })
762 .await
763 {
764 Ok(mut infos) => {
765 apply_handle_overrides(&mut infos, &handles).await;
766 crate::detect::order_for_display(&mut infos); // #6370
767 axum::Json(infos).into_response()
768 }
769 Err(e) => {
770 tracing::error!("service detection task panicked: {e}");
771 StatusCode::INTERNAL_SERVER_ERROR.into_response()
772 }
773 }
774}
775
776/// `GET /api/console/metrics/analyze` — return the latest metrics report.
777///
778/// Why: Surfaces trusty-analyze health/metrics to the SPA without per-request
779/// MCP calls (the background poller keeps the cache warm).
780/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
781/// no poll has completed yet (binary absent or first boot).
782/// Test: `test_metrics_analyze_route_cold_cache_returns_503` below.
783async fn metrics_analyze_handler(State(state): State<AppState>) -> axum::response::Response {
784 match state.metrics_cache().get().await {
785 Some(report) => axum::Json(report).into_response(),
786 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
787 }
788}
789
790/// `GET /api/console/metrics/memory` — return the latest memory metrics report.
791///
792/// Why: Surfaces trusty-memory health/metrics to the SPA without per-request
793/// MCP calls (the background poller keeps the cache warm).
794/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
795/// no poll has completed yet (binary absent or first boot).
796/// Test: `test_metrics_memory_route_cold_cache_returns_503` below.
797async fn metrics_memory_handler(State(state): State<AppState>) -> axum::response::Response {
798 match state.memory_metrics_cache().get().await {
799 Some(report) => axum::Json(report).into_response(),
800 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
801 }
802}
803
804/// `GET /api/console/metrics/search` — return the latest search metrics report.
805///
806/// Why: Surfaces trusty-search health/metrics to the SPA without per-request
807/// MCP calls (the background poller keeps the cache warm).
808/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
809/// no poll has completed yet (binary absent or first boot).
810/// Test: `test_metrics_search_route_cold_cache_returns_503` below.
811async fn metrics_search_handler(State(state): State<AppState>) -> axum::response::Response {
812 match state.search_metrics_cache().get().await {
813 Some(report) => axum::Json(report).into_response(),
814 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
815 }
816}
817
818/// `GET /api/console/metrics/review` — return the latest review metrics report.
819///
820/// Why: Surfaces trusty-review health/metrics to the SPA without per-request
821/// MCP calls (the background poller keeps the cache warm).
822/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
823/// no poll has completed yet (binary absent or first boot).
824/// Test: `test_metrics_review_route_cold_cache_returns_503` below.
825async fn metrics_review_handler(State(state): State<AppState>) -> axum::response::Response {
826 match state.review_metrics_cache().get().await {
827 Some(report) => axum::Json(report).into_response(),
828 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
829 }
830}
831
832/// `GET /api/console/metrics/mpm` — return the latest trusty-mpm metrics report.
833///
834/// Why: surfaces trusty-mpm session-fleet + supervisor health to the SPA without
835/// per-request MCP calls (the background poller keeps the cache warm). This is
836/// the coarse, low-frequency health cache; the Sessions tab polls the live
837/// `/api/console/sessions` list at a faster cadence for active monitoring.
838/// What: returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when no
839/// poll has completed yet (binary absent or first boot).
840/// Test: `test_metrics_mpm_route_cold_cache_returns_503` below.
841async fn metrics_mpm_handler(State(state): State<AppState>) -> axum::response::Response {
842 match state.mpm_metrics_cache().get().await {
843 Some(report) => axum::Json(report).into_response(),
844 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
845 }
846}
847
848/// Query params for the analyze visualize route.
849///
850/// Why: The index id must be a query param so the Svelte component can change
851/// the selected index without a page navigation.
852/// What: `index` is the analyze index id (string). Optional: no default —
853/// returns 400 when absent.
854/// Test: `test_analyze_visualize_handler_no_index_returns_400` below.
855#[derive(Deserialize)]
856struct VisualizeQuery {
857 index: Option<String>,
858}
859
860/// `GET /api/console/metrics/analyze/indexes` — list analyze indexes via stdio.
861///
862/// Why: The Analyze tab needs a list of indexes to populate the dropdown.
863/// This route calls the analyze stdio MCP (via `McpServiceHandle::call_tool_checked`)
864/// instead of the browser hitting the analyze daemon HTTP directly, honouring
865/// the #1104 architecture principle: the console is a stdio MCP client only.
866/// Using `call_tool_checked` instead of `call_tool_raw` prevents a raw -32601
867/// JSON-RPC error from reaching the browser as a 502 when the stale daemon lacks
868/// the `list_analyze_indexes` tool — the capability-gate returns `ToolUnavailable`
869/// which maps to a clean 503 with an actionable hint.
870/// What: Calls the `list_analyze_indexes` MCP tool (which proxies `GET /indexes`
871/// on the daemon). Returns the JSON array on 200, 503+hint when the analyze binary
872/// is absent, in backoff, degraded, or the tool is not in the cached tool set;
873/// 502 on any other error.
874/// Test: `test_analyze_indexes_absent_binary_returns_503` and
875/// `test_analyze_indexes_tool_unavailable_returns_degraded_hint` below.
876async fn analyze_indexes_handler(State(state): State<AppState>) -> axum::response::Response {
877 match state
878 .analyze_handle()
879 .call_tool_checked("list_analyze_indexes", serde_json::json!({}))
880 .await
881 {
882 Ok(val) => axum::Json(val).into_response(),
883 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
884 tracing::warn!(
885 tool = %tool,
886 hint = %hint,
887 "analyze_indexes_handler: tool not available — capability-gate triggered"
888 );
889 (
890 StatusCode::SERVICE_UNAVAILABLE,
891 axum::Json(serde_json::json!({
892 "status": "degraded",
893 "hint": hint,
894 })),
895 )
896 .into_response()
897 }
898 Err(
899 McpHandleError::Absent
900 | McpHandleError::Backoff { .. }
901 | McpHandleError::Degraded { .. },
902 ) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
903 Err(e) => {
904 tracing::warn!("analyze_indexes_handler error: {e:#}");
905 StatusCode::BAD_GATEWAY.into_response()
906 }
907 }
908}
909
910/// `GET /api/console/metrics/analyze/visualize?index=<id>` — combined viz data.
911///
912/// Why: The Analyze tab needs graph nodes, entities, and clusters in one round
913/// trip. This route calls the analyze stdio MCP for all three without the
914/// browser hitting the analyze daemon HTTP directly (#1104 architecture).
915/// Using `call_tool_checked` prevents a raw -32601 from reaching the browser
916/// as a 502 when a stale daemon lacks `extract_graph`/`list_entities`/
917/// `cluster_concepts` — the capability-gate returns `ToolUnavailable` which maps
918/// to a clean 503+hint response.
919/// What: Calls `extract_graph`, `list_entities`, and `cluster_concepts` (k=8)
920/// via `McpServiceHandle::call_tool_checked` and returns a combined JSON object:
921/// `{"graph": ..., "entities": ..., "clusters": ...}`. Missing index param
922/// returns 400 (BAD_REQUEST). Absent binary, backoff, degraded, or tool
923/// unavailable returns 503 (SERVICE_UNAVAILABLE) with optional hint JSON.
924/// A hard graph error (non-absent/backoff/tool-unavailable) returns 502 (BAD_GATEWAY).
925/// Test: `test_analyze_visualize_handler_no_index_returns_400` and
926/// `test_analyze_visualize_handler_absent_binary_returns_503` below.
927async fn analyze_visualize_handler(
928 State(state): State<AppState>,
929 Query(params): Query<VisualizeQuery>,
930) -> axum::response::Response {
931 let index_id = match params.index {
932 Some(id) if !id.is_empty() => id,
933 _ => {
934 return (
935 StatusCode::BAD_REQUEST,
936 axum::Json(json!({"error": "missing required query param: index"})),
937 )
938 .into_response();
939 }
940 };
941
942 let handle = state.analyze_handle();
943 let args = serde_json::json!({ "index_id": index_id });
944
945 // NOTE: although `tokio::join!` normally drives all three futures
946 // concurrently, these three `call_tool_checked` calls share a single stdio
947 // child process behind `McpServiceHandle`'s inner `Arc<Mutex<StdioMcpClient>>`.
948 // Each call acquires that inner mutex for the full duration of its
949 // JSON-RPC round trip, so the three futures effectively serialize behind
950 // the lock — `join!` does not provide real I/O parallelism here. The
951 // `join!` form is retained for code readability (all three results
952 // collected symmetrically) and because the serialization is transparent
953 // to callers. If the analyze MCP child ever supports multiplexed requests
954 // (separate stdin/stdout framing per call), this join would gain true
955 // concurrency automatically without changing the call sites.
956 let (graph_res, entities_res, clusters_res) = tokio::join!(
957 handle.call_tool_checked("extract_graph", args.clone()),
958 handle.call_tool_checked("list_entities", args.clone()),
959 handle.call_tool_checked("cluster_concepts", {
960 let mut a = args.clone();
961 if let Some(m) = a.as_object_mut() {
962 m.insert("k".to_string(), serde_json::json!(8));
963 }
964 a
965 }),
966 );
967
968 // Classify the graph result: tool unavailable → 503+hint, absent/backoff/degraded → 503,
969 // hard error → 502, success → combine with best-effort entities and clusters.
970 match &graph_res {
971 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
972 tracing::warn!(
973 tool = %tool,
974 hint = %hint,
975 "analyze_visualize_handler: tool not available — capability-gate triggered"
976 );
977 return (
978 StatusCode::SERVICE_UNAVAILABLE,
979 axum::Json(serde_json::json!({
980 "status": "degraded",
981 "hint": hint,
982 })),
983 )
984 .into_response();
985 }
986 Err(
987 McpHandleError::Absent
988 | McpHandleError::Backoff { .. }
989 | McpHandleError::Degraded { .. },
990 ) => {
991 return StatusCode::SERVICE_UNAVAILABLE.into_response();
992 }
993 Err(e) => {
994 tracing::warn!("analyze_visualize_handler graph error: {e:#}");
995 return StatusCode::BAD_GATEWAY.into_response();
996 }
997 Ok(_) => {}
998 }
999
1000 // Log a warning when a best-effort tool is missing (e.g. stale daemon that
1001 // predates list_entities or cluster_concepts). We do NOT return 503 here —
1002 // these two are genuinely best-effort and the route still returns a useful
1003 // partial payload. The primary `extract_graph` gate above is the hard 503
1004 // path; these are only observable degradation signals.
1005 if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &entities_res {
1006 tracing::warn!(
1007 tool = %tool,
1008 "analyze_visualize_handler: list_entities tool unavailable — returning partial payload"
1009 );
1010 }
1011 if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &clusters_res {
1012 tracing::warn!(
1013 tool = %tool,
1014 "analyze_visualize_handler: cluster_concepts tool unavailable — returning partial payload"
1015 );
1016 }
1017
1018 let combined = json!({
1019 "graph": graph_res.unwrap_or(serde_json::Value::Null),
1020 "entities": entities_res.unwrap_or(serde_json::Value::Null),
1021 "clusters": clusters_res.unwrap_or(serde_json::Value::Null),
1022 });
1023 axum::Json(combined).into_response()
1024}
1025
1026// ─── tests ───────────────────────────────────────────────────────────────────
1027
1028// ─── tests ───────────────────────────────────────────────────────────────────
1029
1030#[cfg(test)]
1031mod tests;