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