trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! Service discovery engine — probes declared services and caches the results.
//!
//! Why: agents frequently ask "is trusty-search running?" and "what port is it
//! on?" across multiple `tm services` invocations in one task. This module
//! issues the minimum probes needed (pgrep + optional HTTP health check) and
//! caches results for 5 seconds so a tight loop of `tm services port X` calls
//! does not flood the OS with subprocess spawns.
//! What: `Discoverer` holds the parsed manifest and a BTreeMap TTL cache.
//! Probers are injected via traits so unit tests can mock every I/O boundary
//! without spawning real processes or binding real ports.
//! Test: 13 unit tests in `tests.rs` exercise every probe path and the
//! cache TTL, all without a live daemon.

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};

use super::manifest::{
    HealthProbe, PortDiscovery, ServiceDecl, ServicesManifest, expand_tilde_owned,
};

// #9543: trusty-search is probed over its socket, never over TCP.
mod search_socket;
pub use search_socket::{RealSearchSocketProber, SearchSocketProber};

#[cfg(test)]
mod tests;

/// TTL for cached discovery results within a process lifetime.
///
/// Why: 5 seconds keeps results fresh enough for operational decisions while
/// eliminating duplicate probes during a single agent task.
/// What: compared against the `Instant` stored with each cache entry in
/// `Discoverer.cache`. Expired entries are re-probed on the next call.
/// Test: `status_uses_cache_within_ttl`, `status_probes_fresh_after_ttl`.
pub const CACHE_TTL: Duration = Duration::from_secs(5);

/// HTTP timeout for health probes.
///
/// Why: 1.5 s is short enough not to block a quick agent invocation but long
/// enough to survive a loaded localhost under mild memory pressure.
/// What: passed as the `timeout` to `HttpProber::get_health`.
/// Test: `probe_health_ok_on_2xx`.
pub const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_millis(1500);

/// Aggregate status of one service resolved from the manifest + live probes.
///
/// Why: callers need a single struct that answers every `tm services` subcommand
/// without issuing multiple probes. `Discoverer` populates all fields in one pass
/// and caches the result.
/// What: all optional fields are `None` when the probe was not applicable (e.g.
/// no health_url on a sidecar) or when the probe failed. The CLI renders `None`
/// as `—` in human output and `null` in JSON.
/// Test: `service_status_serialises_to_json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceStatus {
    /// Service name (key in the manifest).
    pub name: String,
    /// True when the service has a declaration in the manifest.
    pub declared: bool,
    /// True when a matching process was found via pgrep.
    pub running: bool,
    /// PID of the matching process (first hit from pgrep; None if not running).
    pub pid: Option<u32>,
    /// Actual bound port, discovered via port_file or default_port.
    pub port: Option<u16>,
    /// Full base URL derived from the discovered port.
    pub url: Option<String>,
    /// Version string from `version_cmd` stdout, first line, trimmed.
    pub version: Option<String>,
    /// Aggregated health state from the health endpoint probe.
    pub health: HealthState,
    /// Resolved, tilde-expanded path to the most-recent log file.
    pub log_path: Option<PathBuf>,
    /// Approximate uptime as seconds since process start.
    /// Serialised as `uptime_secs` in JSON for agent consumption.
    #[serde(rename = "uptime_secs")]
    pub uptime_secs: Option<u64>,
}

/// Health probe result.
///
/// Why: a three-state enum distinguishes "definitely healthy" from "no HTTP
/// surface" (sidecar daemons) from "definitely unhealthy". This prevents callers
/// from treating an absent health endpoint as a failure.
/// What: `Unknown` is for services with no `health_url`; `Ok` and `Fail` carry
/// the HTTP-level determination. `Fail` carries a detail string for display.
/// Test: `probe_health_ok_on_2xx`, `probe_health_fail_on_503`,
/// `probe_health_fail_on_connection_refused`, `health_state_serialises_correctly`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HealthState {
    /// Health URL returned 2xx. Service is healthy.
    Ok,
    /// Service has no health URL (UDS sidecar). Liveness from process only.
    Unknown,
    /// Health URL returned non-2xx or connection was refused.
    Fail {
        /// Human-readable detail (HTTP status code or connection error).
        detail: String,
    },
}

/// Thin result type for the `health()` method (status + message together).
///
/// Why: `tm services health <name>` needs both the `HealthState` for the exit
/// code and a pre-formatted `message` for stdout/stderr.
/// What: `state` drives the exit code (0 = Ok, 1 = Fail/Unknown), `message`
/// is printed on stdout (healthy) or stderr (unhealthy).
/// Test: `health_bypasses_cache`.
pub struct HealthResult {
    /// Service name.
    pub name: String,
    /// Health state.
    pub state: HealthState,
    /// Human-readable: "healthy", "no health endpoint", or "unhealthy: <detail>".
    pub message: String,
}

// ─── Prober traits ────────────────────────────────────────────────────────────

/// Trait for process discovery (pgrep wrapper).
///
/// Why: allows tests to inject a mock that returns predetermined PID or None
/// without running pgrep. The production impl is `RealProcessProber`.
/// What: `pgrep(pattern)` returns the PID of the first matching process, or
/// `None` when no match is found.
/// Test: `probe_process_returns_pid_when_running`, `probe_process_returns_none_when_not_running`.
pub trait ProcessProber: Send + Sync {
    /// Run `pgrep -f <pattern>` and return the first matched PID.
    fn pgrep(&self, pattern: &str) -> Option<u32>;

    /// PID of the process listening on `port`, if any.
    ///
    /// Why (#5951): name matching cannot tell a daemon from a same-named
    /// sibling; the process holding the listening socket can. Defaults to
    /// `None` so a prober that cannot answer falls back to `pgrep`.
    /// Test: `pid_prefers_port_owner_over_name_match`.
    fn pid_listening_on(&self, port: u16) -> Option<u32> {
        let _ = port;
        None
    }
}

/// Trait for port file reading.
///
/// Why: allows tests to return a predetermined port without reading a real file.
/// The production impl is `RealPortProber`.
/// What: `read_port_file(path)` reads a `host:port` line from `path` and parses
/// the port component, returning `None` on any error.
/// Test: `probe_port_reads_port_file`.
pub trait PortProber: Send + Sync {
    /// Read a `host:port` file and return the port component.
    fn read_port_file(&self, path: &std::path::Path) -> Option<u16>;
}

/// Trait for HTTP health probing.
///
/// Why: allows tests to return a predetermined health state without making real
/// HTTP calls. The production impl uses `reqwest::blocking::Client`.
/// What: `get_health(url, timeout)` issues a GET request and returns a
/// `HealthState` reflecting the response code or connection error.
/// Test: `probe_health_ok_on_2xx`, `probe_health_fail_on_503`,
/// `probe_health_fail_on_connection_refused`.
pub trait HttpProber: Send + Sync {
    /// Issue a GET request and return the corresponding `HealthState`.
    fn get_health(&self, url: &str, timeout: Duration) -> HealthState;
}

/// Trait for version command execution.
///
/// Why: allows tests to return a predetermined version string without spawning
/// real subprocesses. The production impl uses `std::process::Command`.
/// What: `run(cmd)` spawns `sh -c <cmd>` with a 2-second timeout and returns
/// the first non-empty line of stdout, or `None` on failure/timeout.
/// Test: `probe_version_returns_first_line` (implicit via mocked impl).
pub trait VersionRunner: Send + Sync {
    /// Run `sh -c <cmd>` and return the first stdout line.
    fn run(&self, cmd: &str) -> Option<String>;
}

// ─── Production probers ───────────────────────────────────────────────────────

/// Production process prober using `pgrep -f`.
///
/// Why: pgrep is universally available on macOS and Linux; `-f` matches the
/// full command line so partial binary names (e.g. `trusty-bm25-`) work.
/// What: spawns `pgrep -f <pattern>`, reads stdout, parses first token as u32.
/// Test: indirect — real daemon is tested via the integration test.
pub struct RealProcessProber;

impl ProcessProber for RealProcessProber {
    fn pgrep(&self, pattern: &str) -> Option<u32> {
        let out = std::process::Command::new("pgrep")
            .args(["-f", pattern])
            .output()
            .ok()?;
        if !out.status.success() {
            return None;
        }
        let stdout = String::from_utf8_lossy(&out.stdout);
        // pgrep may return multiple PIDs; take the first numeric token.
        stdout
            .split_whitespace()
            .next()
            .and_then(|s| s.parse::<u32>().ok())
    }

    fn pid_listening_on(&self, port: u16) -> Option<u32> {
        crate::core::daemon_identity::pid_listening_on(port)
    }
}

/// Production port prober — reads a `host:port` file.
///
/// Why: `trusty-memory` writes the bound address to `~/.trusty-memory/http_addr`
/// using the `write_daemon_addr` convention from `trusty-common`.
/// What: reads the file, trims whitespace, parses the port from `host:port`.
/// Test: `probe_port_reads_port_file`.
pub struct RealPortProber;

impl PortProber for RealPortProber {
    fn read_port_file(&self, path: &std::path::Path) -> Option<u16> {
        let content = std::fs::read_to_string(path).ok()?;
        let trimmed = content.trim();
        // Format is `host:port` — split on ':' and take the last component.
        let port_str = trimmed.rsplit(':').next()?;
        port_str.trim().parse::<u16>().ok()
    }
}

/// Production HTTP health prober using `reqwest::blocking`.
///
/// Why: the prober trait is synchronous, so the client has to be blocking — but
/// `tm`'s `main` is `#[tokio::main]`, so every caller reaches this from inside a
/// runtime. Building `reqwest::blocking`'s internal runtime there panics with
/// "Cannot drop a runtime in a context where blocking is not allowed", and even
/// in release (where reqwest's tripwire compiles out) it parks the calling
/// thread from inside `poll`. #5965: run the client on a dedicated OS thread and
/// join it, so the nested runtime never touches an async worker. Same remedy,
/// same reason as `trusty_common::search_index::best_effort_create_index`.
/// What: issues GET to `url` with the given timeout on a spawned thread; 2xx →
/// Ok, non-2xx → Fail with status code, connection error → Fail with error text,
/// worker panic → Fail.
/// Test: `probe_health_ok_on_2xx`, `probe_health_fail_on_503`,
/// `real_http_prober_survives_being_called_inside_a_tokio_runtime`.
pub struct RealHttpProber;

impl HttpProber for RealHttpProber {
    fn get_health(&self, url: &str, timeout: Duration) -> HealthState {
        let url = url.to_string();
        // #5965: the blocking client builds its own runtime, which cannot be
        // dropped inside an async context — keep it off the caller's thread.
        let worker = std::thread::spawn(move || {
            let client = match reqwest::blocking::Client::builder()
                .timeout(timeout)
                .build()
            {
                Ok(c) => c,
                Err(e) => {
                    return HealthState::Fail {
                        detail: format!("failed to build HTTP client: {e}"),
                    };
                }
            };
            match client.get(&url).send() {
                Ok(resp) if resp.status().is_success() => HealthState::Ok,
                Ok(resp) => HealthState::Fail {
                    detail: format!("HTTP {}", resp.status()),
                },
                Err(e) => HealthState::Fail {
                    detail: e.to_string(),
                },
            }
        });
        worker.join().unwrap_or_else(|_| HealthState::Fail {
            detail: "health probe thread panicked".to_string(),
        })
    }
}

/// Production version runner using `sh -c <cmd>`.
///
/// Why: version commands can be multi-word (e.g. `trusty-search --version`);
/// running via `sh -c` handles quoting and PATH consistently.
/// What: spawns `sh -c <cmd>` with a 2-second wall-clock timeout via a simple
/// thread-based approach, returns first non-empty stdout line.
/// Test: indirect — real binary is tested via smoke test.
pub struct RealVersionRunner;

impl VersionRunner for RealVersionRunner {
    fn run(&self, cmd: &str) -> Option<String> {
        let out = std::process::Command::new("sh")
            .args(["-c", cmd])
            .output()
            .ok()?;
        // Return the first non-empty line of stdout.
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .find(|l| !l.trim().is_empty())
            .map(|l| l.trim().to_string())
    }
}

// ─── Discoverer ───────────────────────────────────────────────────────────────

/// Service discovery engine.
///
/// Why: centralises all probe logic (process, port, HTTP, version) so CLI
/// handlers never issue subprocesses or HTTP calls directly. Trait-based probers
/// make unit testing straightforward by injecting mock implementations.
/// What: holds the parsed manifest and a BTreeMap TTL cache (5s). Probes are
/// issued lazily on `status()` / `list()` calls. Cache is per-process.
/// Test: 13 unit tests in `tests.rs` cover all probe paths and cache behaviour.
pub struct Discoverer {
    manifest: ServicesManifest,
    /// Cache: service name → (inserted_at, status).
    pub(crate) cache: BTreeMap<String, (Instant, ServiceStatus)>,
    process_prober: Box<dyn ProcessProber>,
    port_prober: Box<dyn PortProber>,
    http_prober: Box<dyn HttpProber>,
    version_runner: Box<dyn VersionRunner>,
    /// #9543: socket health for `health_probe: uds_search` services.
    socket_prober: Box<dyn SearchSocketProber>,
}

impl Discoverer {
    /// Create a `Discoverer` backed by the real OS probers.
    ///
    /// Why: production code path — separating construction from the trait objects
    /// lets tests inject mocks via `Discoverer::with_probers`.
    /// What: wraps `RealProcessProber`, `RealPortProber`, `RealHttpProber`,
    /// `RealVersionRunner` in `Box<dyn …>`.
    /// Test: indirect via smoke test against live trusty-search.
    pub fn new(manifest: ServicesManifest) -> Self {
        Self {
            manifest,
            cache: BTreeMap::new(),
            process_prober: Box::new(RealProcessProber),
            port_prober: Box::new(RealPortProber),
            http_prober: Box::new(RealHttpProber),
            version_runner: Box::new(RealVersionRunner),
            socket_prober: Box::new(RealSearchSocketProber),
        }
    }

    /// Create a `Discoverer` with injected probers (for testing).
    ///
    /// Why: tests need to inject mock probers so they do not spawn real processes
    /// or make real HTTP calls.
    /// What: accepts trait objects for all four probe boundaries.
    /// Test: every unit test in `tests.rs` uses this constructor.
    #[cfg(test)]
    pub fn with_probers(
        manifest: ServicesManifest,
        process_prober: Box<dyn ProcessProber>,
        port_prober: Box<dyn PortProber>,
        http_prober: Box<dyn HttpProber>,
        version_runner: Box<dyn VersionRunner>,
    ) -> Self {
        Self {
            manifest,
            cache: BTreeMap::new(),
            process_prober,
            port_prober,
            http_prober,
            version_runner,
            // #9543: no test reaches the operator's real trusty-search socket
            // unless it injects a prober with `with_socket_prober`.
            socket_prober: Box::new(search_socket::NoSearchDaemon),
        }
    }

    /// Replace the socket prober (for testing).
    ///
    /// Why (#9543): `with_probers` defaults to a prober with no daemon behind
    /// it; a socket test injects the real prober or a fixed verdict.
    /// Test: `uds_search_is_up_against_a_socket_only_daemon`.
    #[cfg(test)]
    pub fn with_socket_prober(mut self, socket_prober: Box<dyn SearchSocketProber>) -> Self {
        self.socket_prober = socket_prober;
        self
    }

    /// List status of every declared service.
    ///
    /// Why: `tm services list` needs all services in a single call.
    /// What: iterates `manifest.services`, calls `probe_or_cached` for each,
    /// returns a `Vec<ServiceStatus>` in manifest (BTreeMap) order.
    /// Test: `list_returns_all_manifest_services`.
    pub fn list(&mut self) -> Vec<ServiceStatus> {
        // Collect keys first to avoid borrow conflicts.
        let names: Vec<String> = self.manifest.services.keys().cloned().collect();
        names
            .into_iter()
            .map(|name| {
                let decl = self.manifest.services[&name].clone();
                self.probe_or_cached(&name, &decl)
            })
            .collect()
    }

    /// Status of one named service.
    ///
    /// Why: `tm services status <name>`, `tm services port <name>`, and
    /// `tm services url <name>` all need a single `ServiceStatus`.
    /// What: returns cached value if within TTL; otherwise probes and stores.
    /// Returns `None` when `name` is not in the manifest.
    /// Test: `status_returns_none_for_unknown_service`, `status_uses_cache_within_ttl`.
    pub fn status(&mut self, name: &str) -> Option<ServiceStatus> {
        let decl = self.manifest.services.get(name)?.clone();
        Some(self.probe_or_cached(name, &decl))
    }

    /// Issue a fresh health probe for one service (bypasses cache).
    ///
    /// Why: `tm services health <name>` should always reflect the current state,
    /// not a possibly-stale cached health value.
    /// What: re-probes the health endpoint regardless of cache age, updates the
    /// cached entry's health field, and returns a `HealthResult`.
    /// Test: `health_bypasses_cache`.
    pub fn health(&mut self, name: &str) -> Option<HealthResult> {
        let decl = self.manifest.services.get(name)?.clone();

        // #9543: a socket-probed service re-probes the socket, never a URL.
        if decl.health_probe == HealthProbe::UdsSearch {
            let status = self.probe_uds_search(name, &decl);
            return Some(self.finish_health(name, status));
        }

        // Get a current status (uses cache for port/pid/url, then re-probes health).
        let mut status = self.probe_or_cached(name, &decl);

        // Re-probe health unconditionally.
        let fresh_health = if let Some(url) = &status.url {
            if decl.health_url.is_some() {
                self.probe_health_url(url, &decl)
            } else if status.running {
                HealthState::Unknown
            } else {
                HealthState::Fail {
                    detail: "not running".into(),
                }
            }
        } else if decl.health_url.is_none() {
            if status.running {
                HealthState::Unknown
            } else {
                HealthState::Fail {
                    detail: "not running".into(),
                }
            }
        } else {
            HealthState::Fail {
                detail: "no URL available".into(),
            }
        };

        status.health = fresh_health;
        Some(self.finish_health(name, status))
    }

    /// Cache a freshly health-probed `status` and build its [`HealthResult`].
    fn finish_health(&mut self, name: &str, status: ServiceStatus) -> HealthResult {
        let fresh_health = status.health.clone();
        // Write back updated status to cache.
        self.cache
            .insert(name.to_string(), (Instant::now(), status));

        let message = match &fresh_health {
            HealthState::Ok => "healthy".to_string(),
            HealthState::Unknown => "no health endpoint (process-liveness only)".to_string(),
            HealthState::Fail { detail } => format!("unhealthy: {detail}"),
        };

        HealthResult {
            name: name.to_string(),
            state: fresh_health,
            message,
        }
    }

    // ─── Internal helpers ────────────────────────────────────────────────────

    /// Return cached status if fresh, otherwise probe and cache.
    fn probe_or_cached(&mut self, name: &str, decl: &ServiceDecl) -> ServiceStatus {
        if let Some((inserted_at, cached)) = self.cache.get(name)
            && inserted_at.elapsed() < CACHE_TTL
        {
            return cached.clone();
        }
        let status = self.probe(name, decl);
        self.cache
            .insert(name.to_string(), (Instant::now(), status.clone()));
        status
    }

    /// Probe all discovery dimensions for one ServiceDecl.
    ///
    /// Why: a single probe call populates every field of `ServiceStatus` in one
    /// pass, avoiding redundant subprocess spawns when callers access multiple
    /// fields (port + url + health in the same `status` call).
    /// What: runs process, port, health, version, and uptime probes in sequence.
    /// Test: covered by the mock-based discoverer unit tests.
    fn probe(&mut self, name: &str, decl: &ServiceDecl) -> ServiceStatus {
        // #9543: a socket-probed service has no port, URL or HTTP probe.
        if decl.health_probe == HealthProbe::UdsSearch {
            return self.probe_uds_search(name, decl);
        }
        let port = self.probe_port(decl);
        let pid = self.probe_pid(decl, port);
        let url = port.map(|p| format!("http://localhost:{p}"));

        let health = if let Some(u) = &url {
            if decl.health_url.is_some() {
                self.probe_health_url(u, decl)
            } else if pid.is_some() {
                HealthState::Unknown
            } else {
                HealthState::Fail {
                    detail: "not running".into(),
                }
            }
        } else if decl.health_url.is_none() {
            if pid.is_some() {
                HealthState::Unknown
            } else {
                HealthState::Fail {
                    detail: "not running".into(),
                }
            }
        } else {
            HealthState::Fail {
                detail: "not running".into(),
            }
        };

        let version = if pid.is_some() {
            self.probe_version(decl)
        } else {
            None
        };

        let uptime_secs = pid.and_then(|p| self.probe_uptime(p));

        let log_path = existing_log_path(decl);

        ServiceStatus {
            name: name.to_string(),
            declared: true,
            running: pid.is_some(),
            pid,
            port,
            url,
            version,
            health,
            log_path,
            uptime_secs,
        }
    }

    /// PID of the service: the port's owner when there is one, else a name match.
    ///
    /// Why (#5951): `tm services list` reported `trusty-mpm-daemon` as PID 7009,
    /// a `trusty-mpm serve --stdio` MCP bridge, because the manifest's
    /// `process_match: "trusty-mpm"` is a substring every sibling shares and
    /// `pgrep` returns the lowest PID first. Four independent sources —
    /// `launchctl list`, `ps`, the daemon's `/health`, and the console
    /// gateway — named the daemon, and `tm` named something else. Operators are
    /// told to use `tm services` instead of raw `ps`/`lsof`, so the wrong PID
    /// here is a PID somebody signals.
    /// What: when the service has a resolved port, the process holding its
    /// listening socket is the service, definitionally. Name matching stays the
    /// fallback for portless services (`trusty-embedderd`), for a port nothing
    /// is listening on, and for a host without `lsof`.
    /// Test: `pid_prefers_port_owner_over_name_match`,
    /// `pid_falls_back_to_name_match_without_port`,
    /// `pid_falls_back_to_name_match_when_port_unowned`.
    fn probe_pid(&self, decl: &ServiceDecl, port: Option<u16>) -> Option<u32> {
        port.and_then(|p| self.process_prober.pid_listening_on(p))
            .or_else(|| self.probe_process(decl))
    }

    /// Run pgrep for the service's `process_match` pattern.
    ///
    /// Why: pgrep is the simplest cross-platform process lookup; `-f` matches
    /// the full command line so partial names (e.g. `trusty-bm25-`) work.
    /// What: delegates to `self.process_prober.pgrep(pattern)`. Returns `None`
    /// when `process_match` is absent or pgrep finds nothing.
    /// Test: `probe_process_returns_pid_when_running`.
    fn probe_process(&self, decl: &ServiceDecl) -> Option<u32> {
        let pattern = decl.process_match.as_deref()?;
        self.process_prober.pgrep(pattern)
    }

    /// Discover the runtime port from port_file or default_port.
    ///
    /// Why: `trusty-memory` uses dynamic port selection; reading `port_file` is
    /// the only reliable way to find it.
    /// What: for `File` discovery: reads and parses `host:port` from `port_file`;
    /// for `Static`: returns `default_port` directly.
    /// Test: `probe_port_reads_port_file`, `probe_port_returns_default_port`.
    fn probe_port(&self, decl: &ServiceDecl) -> Option<u16> {
        match &decl.port_discovery {
            PortDiscovery::File => {
                let port_file_str = decl.port_file.as_deref()?;
                let path = expand_tilde_owned(port_file_str);
                self.port_prober.read_port_file(&path)
            }
            PortDiscovery::Static => decl.default_port,
        }
    }

    /// Issue an HTTP GET to the health URL template.
    ///
    /// Why: `health_url` contains a `{port}` template that must be expanded
    /// before the request is issued.
    /// What: replaces `{port}` in the template with the URL's port component,
    /// then delegates to `self.http_prober.get_health(expanded_url, timeout)`.
    /// Test: `probe_health_ok_on_2xx`, `probe_health_fail_on_503`.
    fn probe_health_url(&self, url: &str, decl: &ServiceDecl) -> HealthState {
        let template = match &decl.health_url {
            Some(t) => t,
            None => return HealthState::Unknown,
        };
        // Extract the port from the base URL (e.g. "http://localhost:7880").
        let port_str = url.rsplit(':').next().unwrap_or("0");
        let health_url = template.replace("{port}", port_str);
        self.http_prober
            .get_health(&health_url, HEALTH_PROBE_TIMEOUT)
    }

    /// Run the version command and return the first stdout line.
    ///
    /// Why: version strings are useful for diagnosing stale installs.
    /// What: delegates to `self.version_runner.run(cmd)`. Returns `None` when
    /// `version_cmd` is absent or the command fails.
    /// Test: covered by `probe_process_returns_pid_when_running`.
    fn probe_version(&self, decl: &ServiceDecl) -> Option<String> {
        let cmd = decl.version_cmd.as_deref()?;
        self.version_runner.run(cmd)
    }

    /// Compute process uptime via sysinfo.
    ///
    /// Why: sysinfo is already an always-on dep of trusty-mpm (`Cargo.toml:93`).
    /// What: creates a `System`, refreshes the process list, looks up `pid`,
    /// computes `SystemTime::now() - start_time`. Returns seconds as `u64`.
    /// Test: `probe_uptime_returns_none_for_unknown_pid`.
    fn probe_uptime(&self, pid: u32) -> Option<u64> {
        use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
        let mut sys = System::new_with_specifics(
            RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
        );
        sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
        let sysinfo_pid = Pid::from_u32(pid);
        let proc_ = sys.process(sysinfo_pid)?;
        let start = proc_.start_time(); // seconds since Unix epoch
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .ok()?
            .as_secs();
        Some(now.saturating_sub(start))
    }
}

/// The tilde-expanded `log_path` of `decl`, when that file exists.
///
/// Shared by the HTTP and the socket probe (#9543).
fn existing_log_path(decl: &ServiceDecl) -> Option<PathBuf> {
    decl.log_path.as_ref().and_then(|p| {
        let expanded = expand_tilde_owned(p);
        expanded.exists().then_some(expanded)
    })
}