trusty-console 0.12.2

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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
//! 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_from(argv)` as the
//! canonical library entry point that parses an explicit argv vector and
//! dispatches to subcommands; `run()` is a thin wrapper passing the process's
//! global argv.
//! Test: `cargo test -p trusty-console` exercises the CLI parsing tests defined
//! in the submodules.

// docs.rs builds a release's documentation once, from the uploaded tarball,
// so a broken intra-doc link is baked into that version forever and only a new
// release can correct it. Deny keeps this crate at zero rather than letting the
// ratchet in `scripts/check_rustdoc_links.sh` absorb a new one.
#![deny(rustdoc::broken_intra_doc_links)]

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};

/// Default console HTTP bind address (used for both serve and `port` reporting).
///
/// Why: A single constant keeps the serve default and the `port` verb's
/// fallback in lock-step so `trusty-installer` (`tctl`) never discovers a port
/// the console would not actually bind to.
/// What: `127.0.0.1:7788` — the canonical localhost console address.
/// Test: `test_resolve_reported_addr_default` asserts the `port` verb falls
/// back to this value's host/port when no discovery file is present.
pub const DEFAULT_HTTP: &str = "127.0.0.1:7788";

/// Default console port, parsed once from [`DEFAULT_HTTP`].
///
/// Why: The `port` verb reports this when no running console has written a
/// discovery file yet.
/// What: `7788`.
/// Test: covered by the `port` verb tests below.
pub const DEFAULT_PORT: u16 = 7788;

pub mod bind;
// #6285: the console's own SPA, split out of `server` at the 500-SLOC cap.
pub mod connector;
pub mod console_ui;
pub mod detect;
// #6848: the console-hosted event bus core — UDS ingest, the bounded ring,
// dedup and eviction counters, and the subscriber fan-out a later slice
// (#6851) wires into an SSE route. No route surface in this crate yet.
pub(crate) mod event_bus;
// #6517: background whole-machine host-metrics sampler + cache feeding the
// machine-status route.
pub mod host_status;
// #6641: the bounded 10-minute sample window, the per-service transition log,
// and the SSE fan-out the Phase 3 graphs read.
pub mod machine_history;
pub mod mcp_handle;
pub mod metrics_poller;
pub mod poller;
pub mod proxy;
pub mod routes;
// #6285: the console's client to trusty-search, which no longer serves HTTP.
// #6155: the trusty-memory and trusty-analyze bridges, siblings of
// `search_uds`. Each maps one dashboard's REST-shaped paths onto that daemon's
// own `<domain>.*` methods.
pub mod analyze_uds;
pub mod memory_uds;
pub mod search_uds;
pub mod server;
pub mod service;
// #6642: per-service pid discovery + CPU sampling for the home-page graphs.
pub mod service_metrics;
// #9035: peer-identity gate for the `--tailscale` listener.
pub(crate) mod tailnet_peer;
// #6155: the trusty-search, trusty-memory and trusty-analyze SPAs, mounted
// under /tools/<tool>/.
pub mod tools_ui;
pub mod webhook;

/// How often the background sweep re-attempts pending webhook deliveries.
///
/// The sweep is the *recovery* mechanism, not the detection one — a stuck
/// delivery is detected by `GET /api/console/metrics/webhooks`, which scans the
/// spool on the request and therefore stays honest even if this loop dies.
const WEBHOOK_RETRY_INTERVAL: Duration = Duration::from_secs(60);
pub(crate) mod url_util;

// ─── 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: `serve` runs the dashboard; `port` is a non-serving contract verb that
/// reports the console's bound (or default) port so orchestrators like
/// `trusty-installer` (`tctl`) can discover/launch the console without parsing logs.
/// 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),
    /// Report the console's bound (or default) HTTP port and exit.
    Port(PortArgs),
    /// Manage inference provider configuration (API keys) — the universal
    /// `config keys set/list/test/unset` surface shared by every trusty-*
    /// binary (epic #2400 Wave 1, #2405).
    Config(trusty_common::inference::config::ConfigCommand),
    /// Manage the macOS launchd LaunchAgent for the console daemon (#2557).
    ///
    /// `install` writes `~/Library/LaunchAgents/com.trusty.console.plist` (#8253)
    /// (running `trusty-console serve`) and bootstraps it; `uninstall` unloads
    /// and removes it; `status` / `logs` inspect the running agent. macOS-only.
    /// `tctl install` / `tctl start` call `install` on the operator's behalf.
    Service {
        #[command(subcommand)]
        action: service::ServiceAction,
    },
}

/// Arguments for `trusty-console port`.
///
/// Why: `trusty-installer` (`tctl`) (the orchestrator, issue #1316) discovers
/// the console URL by spawning `trusty-console port --json` and parsing the
/// `{addr,port}` envelope (see trusty-installer `os_env.rs`). The verb must
/// exist and be machine-readable for that discovery to work after the console is
/// de-bundled from the host crates (#1318).
/// What: A single `--json` flag selecting JSON output (default is a single
/// human-readable port line).
/// Test: `test_port_args_json_flag` / `test_port_args_default` below.
#[derive(Debug, Parser)]
pub struct PortArgs {
    /// Emit a JSON envelope `{"addr":"<host>","port":<u16>}` instead of a bare
    /// port number. Consumed by `trusty-installer` (`tctl`) console discovery.
    #[arg(long, default_value_t = false)]
    pub json: bool,
}

/// 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,

    /// Host-metrics sampling interval in seconds (default: 1).
    ///
    /// #6641: the cadence of the real-time graphs, kept separate from
    /// `--poll-interval` because they answer different questions. The service
    /// pollers each spawn a stdio-MCP round trip, so 15 s is deliberate there;
    /// a host sample is a local sysinfo refresh.
    ///
    /// #6642: the owner's home-page ruling is a bar per SECOND, so the default
    /// is 1 s and the window is 600 points. Raising this stretches the span the
    /// same 600 points cover, and the history payload advertises the value so
    /// the graph's x-axis follows.
    #[arg(
        long,
        default_value_t = trusty_common::host_metrics::history::HOST_SAMPLE_INTERVAL_SECS
    )]
    pub host_sample_interval: u64,

    /// Disk-metrics refresh interval in seconds (default: 15).
    ///
    /// The disk half of a host sample is the expensive half — on macOS
    /// `sysinfo` walks every mounted volume — so it runs on its own slower
    /// clock while CPU, memory and network keep `--host-sample-interval`.
    /// Free space changes on a human timescale; lower this only to watch a
    /// disk fill in near real time, and expect the CPU cost back.
    #[arg(
        long,
        default_value_t = trusty_common::host_metrics::history::DISK_SAMPLE_INTERVAL_SECS
    )]
    pub disk_sample_interval: u64,
}

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

/// Library entry point for the trusty-console daemon, using the process argv.
///
/// Why: The standalone `trusty-console` crate is now the SOLE producer of the
/// `trusty-console` binary (#1318 — de-bundled from the 5 host crates). The
/// thin `main.rs` calls this, which simply forwards the process's global argv
/// to `run_from`.
/// What: Collects `std::env::args()` and delegates to [`run_from`].
/// Test: Indirectly via the `run_from` tests below and the binary smoke test.
pub async fn run() -> Result<()> {
    run_from(std::env::args().collect()).await
}

/// Library entry point parameterised on an explicit argv vector.
///
/// Why: Decoupling argument parsing from the process's global argv (#1318)
/// lets callers (tests, future embedders) drive the console deterministically
/// without mutating `std::env`. Previously `run()` called `Cli::parse()`,
/// which read global argv and could not be exercised in isolation.
/// What: Initialises tracing, parses `argv` via `Cli::parse_from`, and
/// dispatches to the matching subcommand handler. `argv[0]` is the program
/// name (clap convention). Returns `Ok(())` after clean shutdown.
/// Test: `test_run_from_port_json_outputs_envelope` drives this directly with
/// a synthetic argv; integration via `cargo test -p trusty-console`.
pub async fn run_from(argv: Vec<String>) -> Result<()> {
    init_tracing(1);

    let cli = Cli::parse_from(argv);

    match cli.command {
        Commands::Serve(args) => run_serve(args).await,
        Commands::Port(args) => run_port(args),
        Commands::Config(cmd) => cmd.run().await,
        // `service` drives macOS launchd synchronously; no async work needed.
        Commands::Service { action } => service::run_service_action(&action),
    }
}

/// Resolve the console's reportable HTTP address (host, port).
///
/// Why: The `port` verb must report the LIVE port of a running console when
/// one exists, falling back to the default otherwise — so `trusty-installer`
/// (`tctl`) discovery (issue #1316) points at the real dashboard, not a guess.
/// What: Reads the `trusty-console` discovery file via
/// `trusty_common::read_daemon_addr`; on a parseable `host:port` returns that
/// pair, else falls back to ([`DEFAULT_HTTP`] host, [`DEFAULT_PORT`]). Never
/// errors — discovery failures degrade to the default.
/// Test: `test_resolve_reported_addr_default` (no file → default).
pub fn resolve_reported_addr() -> (String, u16) {
    if let Ok(Some(recorded)) = trusty_common::read_daemon_addr("trusty-console")
        && let Ok(sa) = recorded.parse::<std::net::SocketAddr>()
    {
        return (sa.ip().to_string(), sa.port());
    }
    let default_host = DEFAULT_HTTP
        .rsplit_once(':')
        .map(|(h, _)| h.to_owned())
        .unwrap_or_else(|| "127.0.0.1".to_owned());
    (default_host, DEFAULT_PORT)
}

/// Run the `port` subcommand: print the console's bound/default port and exit.
///
/// Why: `trusty-installer` (`tctl`) console discovery spawns
/// `trusty-console port --json` and parses a `{addr,port}` envelope
/// (trusty-installer `os_env.rs`). This verb is the contract that makes that
/// discovery work; without it the call exits non-zero and console discovery is
/// silently broken (the latent bug fixed by #1318).
/// What: Resolves the reportable address; with `--json` prints
/// `{"addr":"<host>","port":<u16>}` to stdout, otherwise prints the bare port.
/// Returns `Ok(())`.
/// Test: `test_run_from_port_json_outputs_envelope`,
/// `test_port_envelope_is_valid_json`.
pub fn run_port(args: PortArgs) -> Result<()> {
    let (addr, port) = resolve_reported_addr();
    if args.json {
        let envelope = serde_json::json!({ "addr": addr, "port": port });
        println!("{envelope}");
    } else {
        println!("{port}");
    }
    Ok(())
}

/// 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<()> {
    // ── 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, DEFAULT_PORT);
    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"
            );
        }
    }

    // ── whole-machine host sampler (#6517) + history feed (#6641) ───────────
    // One loop writes both the point-in-time cache
    // (GET /api/console/machine-status) and the bounded 10-minute window
    // (GET /api/console/machine-status/history and its SSE stream), so the two
    // can never disagree about the newest sample. It runs on its own 1 s
    // cadence rather than the service pollers' 15 s: a host sample is a local
    // sysinfo refresh, not an MCP round trip. #6642: the same loop also records
    // one per-service status + CPU sample per tick.
    machine_history::sampler::start_with_disk_interval(
        state.clone(),
        Duration::from_secs(args.host_sample_interval),
        Duration::from_secs(args.disk_sample_interval),
    );

    // #3269: trust the console's own non-loopback bind address(es) (e.g. the
    // Tailscale CGNAT address in `--tailscale` mode) as write-origin
    // self-origins, so the console's own write UI served from that address is
    // not 403'd by the same-origin guard. Loopback stays trusted unconditionally
    // regardless of bind mode.
    let self_origins = routes::origin_guard::SelfOrigins::from_bind_addrs(&addrs);

    // ── webhook ingress (#5089 step 3, ADR-0034) ────────────────────────────
    // `?` on purpose: a console that cannot open its spool must not start and
    // serve `/api/webhooks/{source}` anyway, because a delivery it cannot
    // durably record is a delivery it must refuse — and an unmounted route
    // would 404 instead of 5xx, which GitHub logs and no one reads.
    let ingress = webhook::WebhookIngress::from_env()
        .context("open the webhook spool under the console data directory")?;
    info!(
        spool = %ingress.spool().root().display(),
        "webhook ingress ready at POST /api/webhooks/{{source}}"
    );
    webhook::start_retry_sweep(ingress.clone(), WEBHOOK_RETRY_INTERVAL);
    let router = server::build_router_with_webhooks(state.clone(), self_origins, ingress);

    // ── event-bus ingest (#6848, DOC-73 §4.1/§4.2) ──────────────────────────
    // Console hosts the one event bus in the workspace; `trusty-mpm`,
    // `trusty-code`, `trusty-agents` and `trusty-analyze` push `HarnessEvent`
    // frames here over a UDS socket. Best-effort, unlike the webhook ingress
    // above: no route in this crate reads the bus yet (that is #6850/#6851),
    // and DOC-73 §4.1's "non-blocking invariant" makes the bus's own
    // availability an observability concern, never one console's HTTP surface
    // depends on — a console that could not bind this socket still serves
    // everything else.
    match event_bus::ingest_socket_path() {
        Ok(socket) => match event_bus::bind_ingest(&socket).await {
            Ok(listener) => {
                info!(
                    socket = %socket.display(),
                    "console event-bus ingest ready"
                );
                // #6848 slice 3b: open the durable NDJSON log before the bus
                // itself, so seq numbering resumes from the log's recovered
                // high-water mark rather than restarting at 1 on every
                // console restart (DOC-73 §4.3). Failing to open it degrades
                // to `EventBus::new` (no durability, seq restarts at 1) per
                // the same non-blocking invariant the ingest-bind failure
                // above already follows — a console that cannot open its
                // event log still serves everything else.
                let bus = match event_bus::LogConfig::resolve_default() {
                    Ok(log_config) => match event_bus::DurableLog::open(log_config).await {
                        Ok((log, recovered)) => {
                            info!(
                                next_seq = recovered.next_seq,
                                "console event-bus durable log ready"
                            );
                            event_bus::EventBus::with_log(
                                event_bus::EventBusConfig::default(),
                                Some(log),
                                recovered.next_seq,
                            )
                        }
                        Err(e) => {
                            tracing::warn!(
                                error = %e,
                                "could not open the console event-bus durable log; \
                                 events this run will not survive a restart"
                            );
                            event_bus::EventBus::new(event_bus::EventBusConfig::default())
                        }
                    },
                    Err(e) => {
                        tracing::warn!(
                            error = %e,
                            "could not resolve the console event-bus durable log directory; \
                             events this run will not survive a restart"
                        );
                        event_bus::EventBus::new(event_bus::EventBusConfig::default())
                    }
                };
                let bus = Arc::new(bus);
                tokio::spawn(async move {
                    event_bus::serve_ingest(listener, bus, shutdown_signal()).await;
                });
            }
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "could not bind the console event-bus ingest socket; \
                     producers cannot push events this run"
                );
            }
        },
        Err(e) => {
            tracing::warn!(error = %e, "could not resolve the console event-bus ingest socket path");
        }
    }

    // ── 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) ──────────
    // #9035: each serves only nodes owned by this machine's own Tailscale
    // login, addressed to itself by exact Host/Origin; loopback is not gated.
    tailnet_peer::spawn_tailnet_listeners(
        addrs.get(1..).unwrap_or(&[]),
        &router,
        Arc::new(tailnet_peer::TailscaleCliResolver),
        shutdown_signal,
    )
    .await?;

    // ── 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::*;

    // #6661: this module used to declare its own `TRUSTY_DATA_DIR_OVERRIDE`
    // mutex. Two locks over one process-global variable serialise nothing
    // between them, so a `remove_var` here landed inside the connector tests'
    // critical section. `detect::ENV_LOCK` is the crate's single lock.
    use crate::detect::ENV_LOCK as DATA_DIR_ENV_LOCK;

    /// 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);
                // #6642: the owner's home-page cadence — 1 s, distinct from the
                // 15 s service poll. Read from the constant so the default and
                // the window ruling cannot drift apart.
                assert_eq!(
                    args.host_sample_interval,
                    trusty_common::host_metrics::history::HOST_SAMPLE_INTERVAL_SECS
                );
                // The disk half keeps its own, slower default so the 1 s graph
                // does not pay for a volume walk every tick.
                assert_eq!(
                    args.disk_sample_interval,
                    trusty_common::host_metrics::history::DISK_SAMPLE_INTERVAL_SECS
                );
            }
            other => panic!("expected Serve, got {other:?}"),
        }
    }

    /// Why (#6641, #6642): the 1 s cadence is the shipped default, not a hard-coded
    /// constant — an operator who wants a longer window must be able to say so,
    /// and the two intervals must stay independent.
    /// What: parses `serve --host-sample-interval 10` and asserts only that
    /// value moved.
    /// Test: this test itself.
    #[test]
    fn test_serve_args_custom_host_sample_interval() {
        let cli = Cli::parse_from(["trusty-console", "serve", "--host-sample-interval", "10"]);
        match cli.command {
            Commands::Serve(args) => {
                assert_eq!(args.host_sample_interval, 10);
                assert_eq!(args.poll_interval, 15, "the service poll is unaffected");
                assert_eq!(
                    args.disk_sample_interval,
                    trusty_common::host_metrics::history::DISK_SAMPLE_INTERVAL_SECS,
                    "the disk cadence is unaffected"
                );
            }
            other => panic!("expected Serve, got {other:?}"),
        }
    }

    /// 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");
            }
            other => panic!("expected Serve, got {other:?}"),
        }
    }

    /// 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");
            }
            other => panic!("expected Serve, got {other:?}"),
        }
    }

    /// 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);
            }
            other => panic!("expected Serve, got {other:?}"),
        }
    }

    /// Why: the `port` subcommand must parse with a default (non-JSON) form so
    /// the bare-port output path is reachable.
    /// What: parses `port` and asserts `--json` defaults to false.
    /// Test: this test itself.
    #[test]
    fn test_port_args_default() {
        let cli = Cli::parse_from(["trusty-console", "port"]);
        match cli.command {
            Commands::Port(args) => assert!(!args.json),
            other => panic!("expected Port, got {other:?}"),
        }
    }

    /// Why: `trusty-installer` (`tctl`) invokes `trusty-console port --json`; the flag must parse.
    /// What: parses `port --json` and asserts `json == true`.
    /// Test: this test itself.
    #[test]
    fn test_port_args_json_flag() {
        let cli = Cli::parse_from(["trusty-console", "port", "--json"]);
        match cli.command {
            Commands::Port(args) => assert!(args.json),
            other => panic!("expected Port, got {other:?}"),
        }
    }

    /// Why: when no console has written a discovery file, the reported port
    /// must fall back to the canonical default so `trusty-installer` (`tctl`)
    /// still gets a usable address.
    /// What: calls `resolve_reported_addr` under an isolated data dir (no
    /// discovery file present) and asserts the default host/port.
    /// Test: this test itself; uses the data-dir override env to avoid reading
    /// a real running console's file.
    #[test]
    fn test_resolve_reported_addr_default() {
        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let tmp = std::env::temp_dir().join(format!(
            "trusty-console-port-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&tmp).expect("create temp data dir");
        // SAFETY: guarded by data_dir_test_lock to serialise env mutation.
        unsafe {
            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
        }
        let (addr, port) = resolve_reported_addr();
        unsafe {
            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
        }
        assert_eq!(addr, "127.0.0.1");
        assert_eq!(port, DEFAULT_PORT);
    }

    /// Why: the JSON envelope emitted by `run_port` must be valid JSON with the
    /// `addr` and `port` keys that `trusty-installer` (`tctl`) `parse_console_port` consumes.
    /// What: builds the same envelope `run_port` prints and round-trips it
    /// through serde to assert structure.
    /// Test: this test itself.
    #[test]
    fn test_port_envelope_is_valid_json() {
        let envelope = serde_json::json!({ "addr": "127.0.0.1", "port": DEFAULT_PORT });
        let s = envelope.to_string();
        let v: serde_json::Value = serde_json::from_str(&s).expect("valid json");
        assert_eq!(v.get("addr").and_then(|a| a.as_str()), Some("127.0.0.1"));
        assert_eq!(
            v.get("port").and_then(|p| p.as_u64()),
            Some(DEFAULT_PORT as u64)
        );
    }

    /// Why: the #1318 decoupling requires that an explicit argv parses to the
    /// `Port` command and that the `port` handler runs without touching the
    /// process's global argv. This exercises that parse → dispatch path.
    /// What: parses `["trusty-console","port","--json"]` via `Cli::parse_from`
    /// (the same call `run_from` makes) and runs `run_port` synchronously under
    /// an isolated data dir; asserts the dispatch matches `Port` and the
    /// handler returns Ok. Kept synchronous so the env-override mutex is never
    /// held across an `await` (clippy::await_holding_lock).
    /// Test: this test itself.
    #[test]
    fn test_run_from_port_json_outputs_envelope() {
        let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let tmp = std::env::temp_dir().join(format!(
            "trusty-console-runfrom-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&tmp).expect("create temp data dir");
        // SAFETY: guarded by DATA_DIR_ENV_LOCK to serialise env mutation.
        unsafe {
            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, &tmp);
        }
        let argv = [
            "trusty-console".to_owned(),
            "port".to_owned(),
            "--json".to_owned(),
        ];
        let cli = Cli::parse_from(argv);
        let result = match cli.command {
            Commands::Port(args) => {
                assert!(args.json, "argv --json should parse to json=true");
                run_port(args)
            }
            other => panic!("expected Port, got {other:?}"),
        };
        unsafe {
            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
        }
        assert!(result.is_ok(), "run_port(port --json) should succeed");
    }

    /// Why: #8253 — the `service` clap doc comment (printed by `trusty-console
    /// service --help`) named a plist launchd never loaded, so an operator
    /// following it acted on a nonexistent unit.
    /// What: requires lib.rs's `Service` help text to name
    /// `<launchd_labels::CONSOLE>.plist` and never the pre-#4868 name.
    /// Test: pure string check on the embedded source, no fs side effects.
    #[test]
    fn service_help_names_the_registry_plist() {
        let src = include_str!("lib.rs");
        let want = format!(
            "~/Library/LaunchAgents/{}.plist",
            trusty_common::launchd_labels::CONSOLE
        );
        // Built piecewise so this test's own text never matches itself.
        let stale = format!("com.trusty.{}.plist", "trusty-console");
        assert!(src.contains(&want), "`service --help` must name {want}");
        assert!(
            !src.contains(&stale),
            "`service --help` names {stale}, a unit launchd never loaded"
        );
    }
}