trusty-console 0.2.1

Web console that detects and surfaces running trusty services as a home page with service cards
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! trusty-console library entry point.
//!
//! Why: Expose the console daemon's startup sequence as a public `run()`
//! function so bundled shim binaries inside host crates (trusty-search,
//! trusty-memory, trusty-analyze, trusty-review, trusty-mpm) can call
//! `trusty_console::run()` without duplicating any logic. This mirrors the
//! exact pattern used by trusty-embedderd (bundled into trusty-search via
//! issue #187) and trusty-bm25-daemon (bundled into trusty-memory via PR #190).
//! What: Re-exports all public submodules and provides `run()` as the
//! canonical library entry point that parses argv and dispatches to subcommands.
//! Test: `cargo test -p trusty-console` exercises the CLI parsing tests defined
//! in the submodules.

use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tracing::info;
use trusty_common::{init_tracing, shutdown_signal, write_daemon_addr};

pub mod bind;
pub mod connector;
pub mod detect;
pub mod mcp_handle;
pub mod metrics_poller;
pub mod poller;
pub mod proxy;
pub mod routes;
pub mod server;

// ─── CLI ─────────────────────────────────────────────────────────────────────

/// trusty-console: web dashboard for trusty services.
///
/// Why: Provides a single entry point for all console subcommands so future
/// phases (status, doctor, open) can be added without breaking existing usage.
/// What: Parses top-level arguments and delegates to subcommand handlers.
/// Test: `cargo run -p trusty-console -- serve --help` must succeed.
#[derive(Debug, Parser)]
#[command(
    name = "trusty-console",
    version,
    about = "Web dashboard for trusty services"
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,
}

/// Available subcommands.
///
/// Why: P0/P1 only has `serve`; future phases add `status` (CLI-only) etc.
/// What: Clap enum; each variant carries its own args.
/// Test: Subcommand selection tested via `Cli::parse_from`.
#[derive(Debug, Subcommand)]
pub enum Commands {
    /// Start the HTTP server and serve the console dashboard.
    Serve(ServeArgs),
}

/// Arguments for `trusty-console serve`.
///
/// Why: The bind address must be configurable so users can change the port when
/// 7788 is taken; `--open` is a convenience for developers; `--poll-interval`
/// lets operators tune the background health-poll frequency; `--tailscale`
/// enables durable tailnet exposure without requiring `--http 0.0.0.0`.
/// What: Optional `--http` (default `127.0.0.1:7788`), `--open`,
/// `--poll-interval`, and `--tailscale` flags.
/// Env overrides: `TRUSTY_CONSOLE_BIND` sets the default bind mode so a
/// supervised/relaunched daemon stays tailnet-reachable without extra flags.
/// Test: Default address tested in `test_serve_args_defaults` below.
#[derive(Debug, Parser)]
pub struct ServeArgs {
    /// Address to listen on (default: 127.0.0.1:7788).
    ///
    /// Takes precedence over --tailscale and TRUSTY_CONSOLE_BIND when set to a
    /// non-default value.
    #[arg(long, default_value = "127.0.0.1:7788")]
    pub http: String,

    /// Expose the console on both 127.0.0.1 and the machine's Tailscale IPv4,
    /// enabling tailnet clients to reach the console without LAN exposure.
    ///
    /// The Tailscale IP is detected via `tailscale ip -4`. If Tailscale is not
    /// running, prints a warning and falls back to localhost-only.
    ///
    /// Can also be set persistently via the TRUSTY_CONSOLE_BIND=tailscale env
    /// var so a supervised/relaunched console stays tailnet-reachable without
    /// manually passing this flag.
    #[arg(long, default_value_t = false)]
    pub tailscale: bool,

    /// Open the console in the default browser after starting.
    #[arg(long, default_value_t = false)]
    pub open: bool,

    /// Background poll interval in seconds (default: 15).
    ///
    /// Controls BOTH the health poller (`poller::start`) AND the metrics
    /// poller (`metrics_poller::start`). Increasing this value reduces the
    /// frequency of both the HTTP health checks against each connector AND
    /// the stdio MCP `console_metrics` tool calls against trusty-analyze.
    #[arg(long, default_value_t = 15u64)]
    pub poll_interval: u64,
}

// ─── public entry point ────────────────────────────────────────────────────

/// Library entry point for the trusty-console daemon.
///
/// Why: Bundled shim binaries inside host crates (trusty-search, trusty-memory,
/// trusty-analyze, trusty-review, trusty-mpm) call this function so all daemon
/// logic stays here in the library crate — no duplication. This mirrors the
/// pattern of `trusty_embedderd::run()` (issue #187) and
/// `trusty_bm25_daemon::run()` (PR #190).
/// What: Initialises tracing, parses argv via `Cli::parse()`, and dispatches
/// to the matching subcommand handler. Returns `Ok(())` after clean shutdown.
/// Test: Direct CLI-arg tests in `tests` module below; integration via
/// `cargo test -p trusty-console`.
pub async fn run() -> Result<()> {
    init_tracing(1);

    let cli = Cli::parse();

    match cli.command {
        Commands::Serve(args) => run_serve(args).await,
    }
}

/// Run the `serve` subcommand.
///
/// Why: Separating the serve logic from `run()` keeps `run()` thin and allows
/// this function to be called from integration tests.
/// What: Resolves bind addresses (respecting `--tailscale`, `--http`, and
/// `TRUSTY_CONSOLE_BIND`), builds the router, binds TCP listener(s), writes
/// the discovery file, starts the background health-poll task, optionally opens
/// a browser, then serves until SIGTERM/SIGINT with graceful shutdown.
/// Additional addresses beyond the primary get their own spawned `axum::serve`
/// task that runs concurrently until the shared shutdown signal fires.
/// Test: Server integration tests in `server.rs` cover the router directly
/// without exercising this function (to avoid real TCP binding in unit tests).
pub async fn run_serve(args: ServeArgs) -> Result<()> {
    const DEFAULT_HTTP: &str = "127.0.0.1:7788";

    // ── resolve bind mode ───────────────────────────────────────────────────
    let mode = bind::BindMode::from_env_and_flags(&args.http, DEFAULT_HTTP, args.tailscale);
    let port = bind::port_from_addr(&args.http, 7788);
    let addrs = bind::resolve_bind_addrs(&mode, port, bind::detect_tailscale_ipv4);

    // ── service setup ───────────────────────────────────────────────────────
    let connectors = detect::all_connectors();
    let state = server::AppState::new(connectors);

    // Kick off an eager first poll so the cache is warm before the first
    // HTTP request arrives.
    {
        let cache = state.poller_cache().clone();
        let c = state.connectors();
        cache.poll_once(c).await;
    }

    // Start the background poller that refreshes the cache on the configured
    // interval.
    poller::start(
        state.poller_cache().clone(),
        state.connectors(),
        Duration::from_secs(args.poll_interval),
    );

    // ── metrics MCP poll (trusty-analyze) ───────────────────────────────────
    // The analyze handle is stored in AppState so on-demand routes
    // (/api/console/metrics/analyze/indexes, /api/console/metrics/analyze/visualize)
    // share the same child process. Here we hand a clone of that Arc to the
    // background metrics poller so both paths reuse one stdio connection.
    //
    // Why "mcp" not "serve --mcp":
    // `serve --mcp` starts BOTH the HTTP daemon and an MCP stdio loop; it
    // requires trusty-search to be reachable at startup and tries to open the
    // redb facts store (which may already be locked by the running daemon).
    // `mcp` only runs a pure stdio bridge pointing at the running HTTP daemon;
    // if the HTTP daemon is not yet up, `ensure_mcp_daemon_up` in analyze's
    // `mcp` subcommand starts it automatically. This is the correct invocation
    // for a lightweight stdio-only console_metrics child.
    metrics_poller::start(
        state.analyze_handle(),
        state.metrics_cache().clone(),
        Duration::from_secs(args.poll_interval),
    );

    // ── metrics MCP poll (trusty-memory) ────────────────────────────────────
    // trusty-memory's stdio MCP mode is `serve --stdio` (see main.rs).
    // The bridge forwards all JSON-RPC calls to the running HTTP daemon and
    // auto-starts it if absent. On machines without trusty-memory the handle
    // marks it Absent immediately; the cache stays None;
    // /api/console/metrics/memory returns 503 (graceful degradation).
    //
    // The handle comes from AppState::mcp_handles so the services route and
    // the metrics poller share the same McpServiceHandle (and thus the same
    // tools/list probe result — once the probe marks the handle Degraded,
    // that state is visible to both paths without a second probe).
    {
        let handles = state.mcp_handles();
        if let Some(h) = handles.get("trusty-memory") {
            metrics_poller::start(
                Arc::clone(h),
                state.memory_metrics_cache().clone(),
                Duration::from_secs(args.poll_interval),
            );
        } else {
            tracing::warn!(
                service = "trusty-memory",
                "run_serve: no MCP handle registered for trusty-memory — \
                 metrics poller will not start for this service"
            );
        }
    }

    // ── metrics MCP poll (trusty-search) ────────────────────────────────────
    // trusty-search's stdio MCP mode is `serve` (see serve_stdio in main.rs).
    // On machines without trusty-search the handle marks it Absent immediately;
    // the cache stays None; /api/console/metrics/search returns 503.
    //
    // Same shared-handle pattern as trusty-memory above.
    {
        let handles = state.mcp_handles();
        if let Some(h) = handles.get("trusty-search") {
            metrics_poller::start(
                Arc::clone(h),
                state.search_metrics_cache().clone(),
                Duration::from_secs(args.poll_interval),
            );
        } else {
            tracing::warn!(
                service = "trusty-search",
                "run_serve: no MCP handle registered for trusty-search — \
                 metrics poller will not start for this service"
            );
        }
    }

    // ── metrics MCP poll (trusty-review) ────────────────────────────────────
    // trusty-review's stdio MCP mode is `serve --stdio` (see commands/serve.rs).
    // When in stdio mode, trusty-review does NOT start an HTTP daemon — it runs
    // a pure MCP JSON-RPC loop over stdin/stdout, connected to the LLM directly.
    // This is the correct invocation for the console's lightweight metrics poll.
    // On machines without trusty-review the handle marks it Absent immediately;
    // the cache stays None; /api/console/metrics/review returns 503.
    //
    // Same shared-handle pattern as trusty-memory and trusty-search above.
    {
        let handles = state.mcp_handles();
        if let Some(h) = handles.get("trusty-review") {
            metrics_poller::start(
                Arc::clone(h),
                state.review_metrics_cache().clone(),
                Duration::from_secs(args.poll_interval),
            );
        } else {
            tracing::warn!(
                service = "trusty-review",
                "run_serve: no MCP handle registered for trusty-review — \
                 metrics poller will not start for this service"
            );
        }
    }

    // ── metrics MCP poll (trusty-mpm) ───────────────────────────────────────
    // trusty-mpm's stdio MCP mode is `serve --stdio` (the #1221 bridge that
    // auto-starts the durable daemon and forwards JSON-RPC to its loopback
    // POST /rpc). The console_metrics poll keeps the coarse session-fleet +
    // supervisor health cache warm for /api/console/metrics/mpm; the Sessions
    // tab itself polls /api/console/sessions live at a faster cadence (#1222).
    // On machines without trusty-mpm the handle marks it Absent immediately; the
    // cache stays None; /api/console/metrics/mpm returns 503 (graceful).
    {
        let handles = state.mcp_handles();
        if let Some(h) = handles.get("trusty-mpm") {
            metrics_poller::start(
                Arc::clone(h),
                state.mpm_metrics_cache().clone(),
                Duration::from_secs(args.poll_interval),
            );
        } else {
            tracing::warn!(
                service = "trusty-mpm",
                "run_serve: no MCP handle registered for trusty-mpm — \
                 metrics poller will not start for this service"
            );
        }
    }

    let router = server::build_router(state.clone());

    // ── bind primary listener ───────────────────────────────────────────────
    let primary_addr = *addrs.first().context("bind address list is empty")?;
    let primary_listener = bind::bind_listener(primary_addr).await?;
    let primary_local = primary_listener.local_addr().context("get local addr")?;
    let addr_string = primary_local.to_string();
    info!("trusty-console listening on http://{primary_local}");

    // ── bind additional listeners (Tailscale mode: secondary addr) ──────────
    for &extra_addr in addrs.get(1..).unwrap_or(&[]) {
        let extra_listener = bind::bind_listener(extra_addr).await?;
        let extra_local = extra_listener
            .local_addr()
            .context("get extra local addr")?;
        info!("trusty-console also listening on http://{extra_local}");
        eprintln!("trusty-console (tailnet): http://{extra_local}");
        let r = router.clone();
        tokio::spawn(async move {
            if let Err(e) = axum::serve(extra_listener, r)
                .with_graceful_shutdown(trusty_common::shutdown_signal())
                .await
            {
                tracing::warn!("extra listener {extra_local} exited: {e}");
            }
        });
    }

    // ── write discovery file (primary address) ──────────────────────────────
    // Best-effort: log a warning on failure but do not abort the serve.
    if let Err(e) = write_daemon_addr("trusty-console", &addr_string) {
        tracing::warn!("could not write trusty-console discovery file: {e}");
    }

    let console_url = format!("http://{primary_local}");
    eprintln!("trusty-console: {console_url}");

    if args.open {
        // Best-effort browser open; ignore errors.
        let _ = open::that(&console_url);
    }

    axum::serve(primary_listener, router)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .context("server error")?;

    // Best-effort removal of the discovery file on clean shutdown.
    // Only remove the file if it still points to our address; another
    // instance may have already written a new one.
    //
    // RESIDUAL RACE: the read → compare → delete sequence is not atomic. A
    // second instance could write a new address between our read and our
    // remove_file, causing us to delete a file we should not. The window is
    // tiny (milliseconds) and the consequence is cosmetic (a stale `port`
    // invocation returns the default rather than the live address). No
    // behavior change is required — this comment documents the known race.
    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
        && recorded == addr_string
        && let Ok(dir) = trusty_common::resolve_data_dir("trusty-console")
    {
        let _ = std::fs::remove_file(dir.join("http_addr"));
    }

    Ok(())
}

// ─── tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    /// Why: default http address must be 127.0.0.1:7788 and tailscale off.
    /// What: parses `serve` with no flags and checks all defaults.
    /// Test: this test itself.
    #[test]
    fn test_serve_args_defaults() {
        let cli = Cli::parse_from(["trusty-console", "serve"]);
        match cli.command {
            Commands::Serve(args) => {
                assert_eq!(args.http, "127.0.0.1:7788");
                assert!(!args.open);
                assert!(!args.tailscale);
                assert_eq!(args.poll_interval, 15);
            }
        }
    }

    /// Why: --tailscale flag must be parsed correctly.
    /// What: parses `serve --tailscale`; asserts tailscale=true.
    /// Test: this test itself.
    #[test]
    fn test_serve_args_tailscale_flag() {
        let cli = Cli::parse_from(["trusty-console", "serve", "--tailscale"]);
        match cli.command {
            Commands::Serve(args) => {
                assert!(args.tailscale);
                assert_eq!(args.http, "127.0.0.1:7788");
            }
        }
    }

    /// Why: custom --http flag must override the default.
    /// What: parses `serve --http 0.0.0.0:9000`.
    /// Test: this test itself.
    #[test]
    fn test_serve_args_custom_http() {
        let cli = Cli::parse_from(["trusty-console", "serve", "--http", "0.0.0.0:9000"]);
        match cli.command {
            Commands::Serve(args) => {
                assert_eq!(args.http, "0.0.0.0:9000");
            }
        }
    }

    /// Why: --poll-interval must override the default.
    /// What: parses `serve --poll-interval 30`.
    /// Test: this test itself.
    #[test]
    fn test_serve_args_custom_poll_interval() {
        let cli = Cli::parse_from(["trusty-console", "serve", "--poll-interval", "30"]);
        match cli.command {
            Commands::Serve(args) => {
                assert_eq!(args.poll_interval, 30);
            }
        }
    }
}