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}