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