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}