Skip to main content

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;