Skip to main content

trusty_console/
lib.rs

1//! trusty-console library entry point.
2//!
3//! Why: Expose the console daemon's startup sequence as a public `run()`
4//! function so bundled shim binaries inside host crates (trusty-search,
5//! trusty-memory, trusty-analyze, trusty-review, trusty-mpm) can call
6//! `trusty_console::run()` without duplicating any logic. This mirrors the
7//! exact pattern used by trusty-embedderd (bundled into trusty-search via
8//! issue #187) and trusty-bm25-daemon (bundled into trusty-memory via PR #190).
9//! What: Re-exports all public submodules and provides `run()` as the
10//! canonical library entry point that parses argv and dispatches to subcommands.
11//! Test: `cargo test -p trusty-console` exercises the CLI parsing tests defined
12//! in the submodules.
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use anyhow::{Context, Result};
18use clap::{Parser, Subcommand};
19use tracing::info;
20use trusty_common::{init_tracing, shutdown_signal, write_daemon_addr};
21
22pub mod bind;
23pub mod connector;
24pub mod detect;
25pub mod mcp_handle;
26pub mod metrics_poller;
27pub mod poller;
28pub mod proxy;
29pub mod routes;
30pub mod server;
31
32// ─── CLI ─────────────────────────────────────────────────────────────────────
33
34/// trusty-console: web dashboard for trusty services.
35///
36/// Why: Provides a single entry point for all console subcommands so future
37/// phases (status, doctor, open) can be added without breaking existing usage.
38/// What: Parses top-level arguments and delegates to subcommand handlers.
39/// Test: `cargo run -p trusty-console -- serve --help` must succeed.
40#[derive(Debug, Parser)]
41#[command(
42    name = "trusty-console",
43    version,
44    about = "Web dashboard for trusty services"
45)]
46pub struct Cli {
47    #[command(subcommand)]
48    pub command: Commands,
49}
50
51/// Available subcommands.
52///
53/// Why: P0/P1 only has `serve`; future phases add `status` (CLI-only) etc.
54/// What: Clap enum; each variant carries its own args.
55/// Test: Subcommand selection tested via `Cli::parse_from`.
56#[derive(Debug, Subcommand)]
57pub enum Commands {
58    /// Start the HTTP server and serve the console dashboard.
59    Serve(ServeArgs),
60}
61
62/// Arguments for `trusty-console serve`.
63///
64/// Why: The bind address must be configurable so users can change the port when
65/// 7788 is taken; `--open` is a convenience for developers; `--poll-interval`
66/// lets operators tune the background health-poll frequency; `--tailscale`
67/// enables durable tailnet exposure without requiring `--http 0.0.0.0`.
68/// What: Optional `--http` (default `127.0.0.1:7788`), `--open`,
69/// `--poll-interval`, and `--tailscale` flags.
70/// Env overrides: `TRUSTY_CONSOLE_BIND` sets the default bind mode so a
71/// supervised/relaunched daemon stays tailnet-reachable without extra flags.
72/// Test: Default address tested in `test_serve_args_defaults` below.
73#[derive(Debug, Parser)]
74pub struct ServeArgs {
75    /// Address to listen on (default: 127.0.0.1:7788).
76    ///
77    /// Takes precedence over --tailscale and TRUSTY_CONSOLE_BIND when set to a
78    /// non-default value.
79    #[arg(long, default_value = "127.0.0.1:7788")]
80    pub http: String,
81
82    /// Expose the console on both 127.0.0.1 and the machine's Tailscale IPv4,
83    /// enabling tailnet clients to reach the console without LAN exposure.
84    ///
85    /// The Tailscale IP is detected via `tailscale ip -4`. If Tailscale is not
86    /// running, prints a warning and falls back to localhost-only.
87    ///
88    /// Can also be set persistently via the TRUSTY_CONSOLE_BIND=tailscale env
89    /// var so a supervised/relaunched console stays tailnet-reachable without
90    /// manually passing this flag.
91    #[arg(long, default_value_t = false)]
92    pub tailscale: bool,
93
94    /// Open the console in the default browser after starting.
95    #[arg(long, default_value_t = false)]
96    pub open: bool,
97
98    /// Background poll interval in seconds (default: 15).
99    ///
100    /// Controls BOTH the health poller (`poller::start`) AND the metrics
101    /// poller (`metrics_poller::start`). Increasing this value reduces the
102    /// frequency of both the HTTP health checks against each connector AND
103    /// the stdio MCP `console_metrics` tool calls against trusty-analyze.
104    #[arg(long, default_value_t = 15u64)]
105    pub poll_interval: u64,
106}
107
108// ─── public entry point ────────────────────────────────────────────────────
109
110/// Library entry point for the trusty-console daemon.
111///
112/// Why: Bundled shim binaries inside host crates (trusty-search, trusty-memory,
113/// trusty-analyze, trusty-review, trusty-mpm) call this function so all daemon
114/// logic stays here in the library crate — no duplication. This mirrors the
115/// pattern of `trusty_embedderd::run()` (issue #187) and
116/// `trusty_bm25_daemon::run()` (PR #190).
117/// What: Initialises tracing, parses argv via `Cli::parse()`, and dispatches
118/// to the matching subcommand handler. Returns `Ok(())` after clean shutdown.
119/// Test: Direct CLI-arg tests in `tests` module below; integration via
120/// `cargo test -p trusty-console`.
121pub async fn run() -> Result<()> {
122    init_tracing(1);
123
124    let cli = Cli::parse();
125
126    match cli.command {
127        Commands::Serve(args) => run_serve(args).await,
128    }
129}
130
131/// Run the `serve` subcommand.
132///
133/// Why: Separating the serve logic from `run()` keeps `run()` thin and allows
134/// this function to be called from integration tests.
135/// What: Resolves bind addresses (respecting `--tailscale`, `--http`, and
136/// `TRUSTY_CONSOLE_BIND`), builds the router, binds TCP listener(s), writes
137/// the discovery file, starts the background health-poll task, optionally opens
138/// a browser, then serves until SIGTERM/SIGINT with graceful shutdown.
139/// Additional addresses beyond the primary get their own spawned `axum::serve`
140/// task that runs concurrently until the shared shutdown signal fires.
141/// Test: Server integration tests in `server.rs` cover the router directly
142/// without exercising this function (to avoid real TCP binding in unit tests).
143pub async fn run_serve(args: ServeArgs) -> Result<()> {
144    const DEFAULT_HTTP: &str = "127.0.0.1:7788";
145
146    // ── resolve bind mode ───────────────────────────────────────────────────
147    let mode = bind::BindMode::from_env_and_flags(&args.http, DEFAULT_HTTP, args.tailscale);
148    let port = bind::port_from_addr(&args.http, 7788);
149    let addrs = bind::resolve_bind_addrs(&mode, port, bind::detect_tailscale_ipv4);
150
151    // ── service setup ───────────────────────────────────────────────────────
152    let connectors = detect::all_connectors();
153    let state = server::AppState::new(connectors);
154
155    // Kick off an eager first poll so the cache is warm before the first
156    // HTTP request arrives.
157    {
158        let cache = state.poller_cache().clone();
159        let c = state.connectors();
160        cache.poll_once(c).await;
161    }
162
163    // Start the background poller that refreshes the cache on the configured
164    // interval.
165    poller::start(
166        state.poller_cache().clone(),
167        state.connectors(),
168        Duration::from_secs(args.poll_interval),
169    );
170
171    // ── metrics MCP poll (trusty-analyze) ───────────────────────────────────
172    // The analyze handle is stored in AppState so on-demand routes
173    // (/api/console/metrics/analyze/indexes, /api/console/metrics/analyze/visualize)
174    // share the same child process. Here we hand a clone of that Arc to the
175    // background metrics poller so both paths reuse one stdio connection.
176    //
177    // Why "mcp" not "serve --mcp":
178    // `serve --mcp` starts BOTH the HTTP daemon and an MCP stdio loop; it
179    // requires trusty-search to be reachable at startup and tries to open the
180    // redb facts store (which may already be locked by the running daemon).
181    // `mcp` only runs a pure stdio bridge pointing at the running HTTP daemon;
182    // if the HTTP daemon is not yet up, `ensure_mcp_daemon_up` in analyze's
183    // `mcp` subcommand starts it automatically. This is the correct invocation
184    // for a lightweight stdio-only console_metrics child.
185    metrics_poller::start(
186        state.analyze_handle(),
187        state.metrics_cache().clone(),
188        Duration::from_secs(args.poll_interval),
189    );
190
191    // ── metrics MCP poll (trusty-memory) ────────────────────────────────────
192    // trusty-memory's stdio MCP mode is `serve --stdio` (see main.rs).
193    // The bridge forwards all JSON-RPC calls to the running HTTP daemon and
194    // auto-starts it if absent. On machines without trusty-memory the handle
195    // marks it Absent immediately; the cache stays None;
196    // /api/console/metrics/memory returns 503 (graceful degradation).
197    //
198    // The handle comes from AppState::mcp_handles so the services route and
199    // the metrics poller share the same McpServiceHandle (and thus the same
200    // tools/list probe result — once the probe marks the handle Degraded,
201    // that state is visible to both paths without a second probe).
202    {
203        let handles = state.mcp_handles();
204        if let Some(h) = handles.get("trusty-memory") {
205            metrics_poller::start(
206                Arc::clone(h),
207                state.memory_metrics_cache().clone(),
208                Duration::from_secs(args.poll_interval),
209            );
210        } else {
211            tracing::warn!(
212                service = "trusty-memory",
213                "run_serve: no MCP handle registered for trusty-memory — \
214                 metrics poller will not start for this service"
215            );
216        }
217    }
218
219    // ── metrics MCP poll (trusty-search) ────────────────────────────────────
220    // trusty-search's stdio MCP mode is `serve` (see serve_stdio in main.rs).
221    // On machines without trusty-search the handle marks it Absent immediately;
222    // the cache stays None; /api/console/metrics/search returns 503.
223    //
224    // Same shared-handle pattern as trusty-memory above.
225    {
226        let handles = state.mcp_handles();
227        if let Some(h) = handles.get("trusty-search") {
228            metrics_poller::start(
229                Arc::clone(h),
230                state.search_metrics_cache().clone(),
231                Duration::from_secs(args.poll_interval),
232            );
233        } else {
234            tracing::warn!(
235                service = "trusty-search",
236                "run_serve: no MCP handle registered for trusty-search — \
237                 metrics poller will not start for this service"
238            );
239        }
240    }
241
242    // ── metrics MCP poll (trusty-review) ────────────────────────────────────
243    // trusty-review's stdio MCP mode is `serve --stdio` (see commands/serve.rs).
244    // When in stdio mode, trusty-review does NOT start an HTTP daemon — it runs
245    // a pure MCP JSON-RPC loop over stdin/stdout, connected to the LLM directly.
246    // This is the correct invocation for the console's lightweight metrics poll.
247    // On machines without trusty-review the handle marks it Absent immediately;
248    // the cache stays None; /api/console/metrics/review returns 503.
249    //
250    // Same shared-handle pattern as trusty-memory and trusty-search above.
251    {
252        let handles = state.mcp_handles();
253        if let Some(h) = handles.get("trusty-review") {
254            metrics_poller::start(
255                Arc::clone(h),
256                state.review_metrics_cache().clone(),
257                Duration::from_secs(args.poll_interval),
258            );
259        } else {
260            tracing::warn!(
261                service = "trusty-review",
262                "run_serve: no MCP handle registered for trusty-review — \
263                 metrics poller will not start for this service"
264            );
265        }
266    }
267
268    // ── metrics MCP poll (trusty-mpm) ───────────────────────────────────────
269    // trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge that
270    // auto-starts the durable daemon and forwards JSON-RPC to its loopback
271    // POST /rpc). The console_metrics poll keeps the coarse session-fleet +
272    // supervisor health cache warm for /api/console/metrics/mpm; the Sessions
273    // tab itself polls /api/console/sessions live at a faster cadence (#1222).
274    // On machines without trusty-mpm the handle marks it Absent immediately; the
275    // cache stays None; /api/console/metrics/mpm returns 503 (graceful).
276    {
277        let handles = state.mcp_handles();
278        if let Some(h) = handles.get("trusty-mpm") {
279            metrics_poller::start(
280                Arc::clone(h),
281                state.mpm_metrics_cache().clone(),
282                Duration::from_secs(args.poll_interval),
283            );
284        } else {
285            tracing::warn!(
286                service = "trusty-mpm",
287                "run_serve: no MCP handle registered for trusty-mpm — \
288                 metrics poller will not start for this service"
289            );
290        }
291    }
292
293    let router = server::build_router(state.clone());
294
295    // ── bind primary listener ───────────────────────────────────────────────
296    let primary_addr = *addrs.first().context("bind address list is empty")?;
297    let primary_listener = bind::bind_listener(primary_addr).await?;
298    let primary_local = primary_listener.local_addr().context("get local addr")?;
299    let addr_string = primary_local.to_string();
300    info!("trusty-console listening on http://{primary_local}");
301
302    // ── bind additional listeners (Tailscale mode: secondary addr) ──────────
303    for &extra_addr in addrs.get(1..).unwrap_or(&[]) {
304        let extra_listener = bind::bind_listener(extra_addr).await?;
305        let extra_local = extra_listener
306            .local_addr()
307            .context("get extra local addr")?;
308        info!("trusty-console also listening on http://{extra_local}");
309        eprintln!("trusty-console (tailnet): http://{extra_local}");
310        let r = router.clone();
311        tokio::spawn(async move {
312            if let Err(e) = axum::serve(extra_listener, r)
313                .with_graceful_shutdown(trusty_common::shutdown_signal())
314                .await
315            {
316                tracing::warn!("extra listener {extra_local} exited: {e}");
317            }
318        });
319    }
320
321    // ── write discovery file (primary address) ──────────────────────────────
322    // Best-effort: log a warning on failure but do not abort the serve.
323    if let Err(e) = write_daemon_addr("trusty-console", &addr_string) {
324        tracing::warn!("could not write trusty-console discovery file: {e}");
325    }
326
327    let console_url = format!("http://{primary_local}");
328    eprintln!("trusty-console: {console_url}");
329
330    if args.open {
331        // Best-effort browser open; ignore errors.
332        let _ = open::that(&console_url);
333    }
334
335    axum::serve(primary_listener, router)
336        .with_graceful_shutdown(shutdown_signal())
337        .await
338        .context("server error")?;
339
340    // Best-effort removal of the discovery file on clean shutdown.
341    // Only remove the file if it still points to our address; another
342    // instance may have already written a new one.
343    //
344    // RESIDUAL RACE: the read → compare → delete sequence is not atomic. A
345    // second instance could write a new address between our read and our
346    // remove_file, causing us to delete a file we should not. The window is
347    // tiny (milliseconds) and the consequence is cosmetic (a stale `port`
348    // invocation returns the default rather than the live address). No
349    // behavior change is required — this comment documents the known race.
350    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
351        && recorded == addr_string
352        && let Ok(dir) = trusty_common::resolve_data_dir("trusty-console")
353    {
354        let _ = std::fs::remove_file(dir.join("http_addr"));
355    }
356
357    Ok(())
358}
359
360// ─── tests ───────────────────────────────────────────────────────────────────
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    /// Why: default http address must be 127.0.0.1:7788 and tailscale off.
367    /// What: parses `serve` with no flags and checks all defaults.
368    /// Test: this test itself.
369    #[test]
370    fn test_serve_args_defaults() {
371        let cli = Cli::parse_from(["trusty-console", "serve"]);
372        match cli.command {
373            Commands::Serve(args) => {
374                assert_eq!(args.http, "127.0.0.1:7788");
375                assert!(!args.open);
376                assert!(!args.tailscale);
377                assert_eq!(args.poll_interval, 15);
378            }
379        }
380    }
381
382    /// Why: --tailscale flag must be parsed correctly.
383    /// What: parses `serve --tailscale`; asserts tailscale=true.
384    /// Test: this test itself.
385    #[test]
386    fn test_serve_args_tailscale_flag() {
387        let cli = Cli::parse_from(["trusty-console", "serve", "--tailscale"]);
388        match cli.command {
389            Commands::Serve(args) => {
390                assert!(args.tailscale);
391                assert_eq!(args.http, "127.0.0.1:7788");
392            }
393        }
394    }
395
396    /// Why: custom --http flag must override the default.
397    /// What: parses `serve --http 0.0.0.0:9000`.
398    /// Test: this test itself.
399    #[test]
400    fn test_serve_args_custom_http() {
401        let cli = Cli::parse_from(["trusty-console", "serve", "--http", "0.0.0.0:9000"]);
402        match cli.command {
403            Commands::Serve(args) => {
404                assert_eq!(args.http, "0.0.0.0:9000");
405            }
406        }
407    }
408
409    /// Why: --poll-interval must override the default.
410    /// What: parses `serve --poll-interval 30`.
411    /// Test: this test itself.
412    #[test]
413    fn test_serve_args_custom_poll_interval() {
414        let cli = Cli::parse_from(["trusty-console", "serve", "--poll-interval", "30"]);
415        match cli.command {
416            Commands::Serve(args) => {
417                assert_eq!(args.poll_interval, 30);
418            }
419        }
420    }
421}