trusty-common 0.26.1

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
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
//! Shared "ensure daemon up" helper for MCP stdio bridge processes.
//!
//! Why: trusty-memory, trusty-search, and trusty-analyze all run an MCP stdio
//! bridge that must guarantee the corresponding HTTP daemon is reachable before
//! entering the JSON-RPC dispatch loop.  Each service had (or needed) the same
//! probe-spawn-poll pattern implemented locally.  This module centralises that
//! pattern so the three services share one tested implementation instead of
//! three diverging copies.
//!
//! What: `DaemonBridgeConfig` carries all service-specific knobs; `ensure_daemon_up`
//! probes the daemon's health endpoint, auto-starts it when absent, and polls
//! until the 30-second budget is exhausted or the daemon becomes ready.
//!
//! STDOUT hygiene: this module NEVER writes to stdout — stdout is the JSON-RPC
//! channel in all callers. All diagnostic output goes to stderr.
//!
//! Test: `daemon_bridge_config_health_url` validates URL construction; async tests
//! cover the fast-path (daemon already up) and the error path (refused port).

use std::time::{Duration, Instant};

use anyhow::{Result, anyhow};

/// Per-probe HTTP timeout for the health check inside `ensure_daemon_up`.
///
/// Why: a hung or half-started daemon must not block the stdio bridge indefinitely
/// on a single TCP connect.  750 ms is short enough to keep the bridge snappy
/// while being long enough for a busy machine to accept the connection.
/// Test: `fast_path_returns_quickly_for_live_listener` verifies the bound holds.
const DAEMON_PROBE_TIMEOUT: Duration = Duration::from_millis(750);

/// Default polling interval between health probes while waiting for the daemon.
///
/// Why: 500 ms keeps the bridge startup latency low while not hammering the
/// daemon with connection attempts during its own boot sequence.
pub const DAEMON_POLL_INTERVAL: Duration = Duration::from_millis(500);

/// Default hard-error budget for the daemon to become ready after being spawned.
///
/// Why: 30 s gives a cold-start daemon (first-run model download, redb open,
/// port selection) generous headroom while capping the worst-case wait at a
/// user-perceptible but finite interval.
pub const DAEMON_START_TIMEOUT: Duration = Duration::from_secs(30);

/// Configuration for a service's MCP daemon-bridge startup guard.
///
/// Why: each service (trusty-memory, trusty-search, trusty-analyze) has its own
/// daemon binary, spawn arguments, and health path.  `DaemonBridgeConfig` captures
/// those differences in a single struct so `ensure_daemon_up` can be a single
/// parameterised function rather than three near-identical functions.
/// What: holds the service name (for diagnostics), the arguments appended to
/// `current_exe()` when spawning the daemon, the path for health probing (e.g.
/// `/health` or `/api/v1/health`), a URL-resolver closure, optional timeout
/// overrides, the `no_spawn` flag (issue #1152), and an optional
/// `no_spawn_hint` override for the `no_spawn` error's operator guidance
/// (issue #2491).
/// Test: `daemon_bridge_config_health_url` unit test;
/// `no_spawn_returns_err_without_spawning` covers the `no_spawn` field;
/// `no_spawn_error_uses_hint_when_set` /
/// `no_spawn_error_falls_back_to_generic_when_hint_unset` cover
/// `no_spawn_hint`.
pub struct DaemonBridgeConfig {
    /// Human-readable service name, used in diagnostic messages.
    pub service_name: String,
    /// Arguments passed to `current_exe()` when the daemon is not running.
    /// Example: `&["serve", "--foreground", "--http", "127.0.0.1:0"]`.
    /// Ignored when `no_spawn` is `true`.
    pub spawn_args: Vec<String>,
    /// HTTP health endpoint path (including leading `/`).
    /// Example: `/health` or `/api/v1/health`.
    pub health_path: String,
    /// Closure that resolves the daemon's current base URL on each poll
    /// iteration.  Re-evaluated every iteration so a dynamic-port daemon (port
    /// 0) is discovered as soon as it writes its address file.
    pub base_url_fn: Box<dyn Fn() -> String + Send + Sync>,
    /// How long to wait for the daemon to become ready after spawning.
    /// Defaults to `DAEMON_START_TIMEOUT` when `None`.
    pub startup_timeout: Option<Duration>,
    /// Polling interval between health probes.
    /// Defaults to `DAEMON_POLL_INTERVAL` when `None`.
    pub poll_interval: Option<Duration>,
    /// When `true`, `ensure_daemon_up` will NEVER spawn a background process.
    ///
    /// Why (issue #1152): the trusty-memory stdio bridge previously auto-spawned
    /// an unmanaged `serve --foreground --http 127.0.0.1:0` daemon on every
    /// transient health-probe miss. That spawned daemon opened production palace
    /// redb files on a random OS-assigned port, squatting redb's exclusive
    /// single-writer lock and starving the real launchd daemon at :7070.
    /// Setting `no_spawn = true` in the trusty-memory bridge config converts the
    /// spawn-on-miss path into a clear `Err` that tells the user how to start the
    /// daemon properly.  Other callers (trusty-search, trusty-analyze) that still
    /// need auto-spawn keep the default `false`.
    /// What: when `true` and the fast-path health probe fails, `ensure_daemon_up`
    /// returns `Err` immediately with a human-readable message rather than
    /// spawning `current_exe() + spawn_args`.
    /// Test: `no_spawn_returns_err_without_spawning`.
    pub no_spawn: bool,
    /// Optional per-service override for the `no_spawn` error's operator
    /// guidance text.
    ///
    /// Why (#2491 review): the generic `no_spawn` error assumed every caller
    /// has a `{service_name} setup` subcommand and a
    /// `~/Library/LaunchAgents/io.trusty.{service_name}.plist` unit. Neither is
    /// true for trusty-mpm — there is no `trusty-mpm setup` subcommand, and the
    /// real plists are `com.trusty.mpm.plist` /
    /// `com.trusty.mpm.supervisor.plist` — so the generic hint told the
    /// operator to run a nonexistent command against a nonexistent path. When
    /// `Some`, this text REPLACES the generic setup/plist advice in the error
    /// message; when `None`, the original generic wording is used unchanged so
    /// existing callers (trusty-memory, trusty-search, trusty-analyze) are
    /// unaffected.
    /// What: appended verbatim after the "daemon is not reachable at {addr}"
    /// preamble instead of the generic `` `{name} start`/`{name} setup` ``
    /// text.
    /// Test: `no_spawn_error_uses_hint_when_set`,
    /// `no_spawn_error_falls_back_to_generic_when_hint_unset`.
    pub no_spawn_hint: Option<String>,
}

impl DaemonBridgeConfig {
    /// Build the health-probe URL from the current base URL and `health_path`.
    ///
    /// Why: `ensure_daemon_up` calls this on each iteration to produce the full
    /// probe URL without knowing the base URL ahead of time.
    /// What: concatenates `(self.base_url_fn)()` and `self.health_path`.
    /// Test: `daemon_bridge_config_health_url`.
    pub fn health_url(&self) -> String {
        format!("{}{}", (self.base_url_fn)(), self.health_path)
    }
}

/// Probe `GET <health_url>` once; returns `true` on any 2xx HTTP response.
///
/// Why: a fresh `reqwest::Client` per probe avoids connection-pool state
/// carrying over from a failed probe to a later successful one.
/// What: builds a one-shot client with `DAEMON_PROBE_TIMEOUT`, issues a GET,
/// returns `true` on 2xx, `false` on any error or non-2xx.
/// Test: `probe_health_once_returns_false_on_refused` (async unit test).
pub(crate) async fn probe_health_once(health_url: &str) -> bool {
    let client = match reqwest::Client::builder()
        .timeout(DAEMON_PROBE_TIMEOUT)
        .connect_timeout(DAEMON_PROBE_TIMEOUT)
        .build()
    {
        Ok(c) => c,
        Err(_) => return false,
    };
    matches!(
        client.get(health_url).send().await,
        Ok(resp) if resp.status().is_success()
    )
}

/// Ensure the daemon is running and return its live base URL.
///
/// Why: every daemon-backed MCP stdio bridge must guarantee the daemon is
/// reachable before forwarding requests.  Centralising this guarantee in one
/// tested function prevents three services from independently re-implementing
/// (and diverging in) the probe-spawn-poll pattern.
/// What: (1) fast-path probes the current health URL; returns immediately when
/// the daemon is already up.  (2a) When `config.no_spawn` is `true` and the
/// probe failed, returns a clear `Err` telling the user to start the daemon
/// manually — no process is spawned (issue #1152 fix).  (2b) When
/// `config.no_spawn` is `false`, spawns `current_exe() + spawn_args` as a
/// detached background process; all stdio fds are null-ed so the spawned
/// daemon outlives the MCP bridge process.  (3) Polls every `poll_interval`
/// (re-evaluating `base_url_fn` each iteration for dynamic-port support) until
/// the daemon responds on `/health` or `startup_timeout` is exceeded.  Hard-
/// errors on timeout — there is no silent fallback.  All output to stderr only.
/// Test: `ensure_daemon_up_returns_ok_when_already_healthy` (async integration
/// test); `probe_health_once_returns_false_on_refused` (unit);
/// `no_spawn_returns_err_without_spawning` (issue #1152).
pub async fn ensure_daemon_up(config: &DaemonBridgeConfig) -> Result<String> {
    let startup_timeout = config.startup_timeout.unwrap_or(DAEMON_START_TIMEOUT);
    let poll_interval = config.poll_interval.unwrap_or(DAEMON_POLL_INTERVAL);

    // Fast path: daemon already healthy.
    let initial_url = (config.base_url_fn)();
    if probe_health_once(&config.health_url()).await {
        return Ok(initial_url);
    }

    // Issue #1152: when no_spawn is set, refuse to spawn an unmanaged daemon.
    // The trusty-memory stdio bridge uses this to prevent squatting redb's
    // exclusive write lock on a random port. Return a clear Err so the user
    // knows exactly what to do.
    if config.no_spawn {
        let addr = (config.base_url_fn)();
        let hint = match &config.no_spawn_hint {
            Some(hint) => hint.clone(),
            None => format!(
                "start it with `{} start` (launchd-managed) before using the MCP bridge. \
                 If already installed via `{} setup`, run `launchctl bootstrap gui/$(id -u) \
                 ~/Library/LaunchAgents/io.trusty.{}.plist` or `{} start`.",
                config.service_name, config.service_name, config.service_name, config.service_name,
            ),
        };
        return Err(anyhow!(
            "{} daemon is not reachable at {} — {}",
            config.service_name,
            addr,
            hint,
        ));
    }

    // Slow path: spawn the daemon detached.
    eprintln!("\u{25cf} Starting {} daemon\u{2026}", config.service_name);

    let exe = std::env::current_exe().map_err(|e| anyhow!("could not resolve current_exe: {e}"))?;
    // Set a stable cwd so the spawned daemon never inherits a deleted directory.
    let stable_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
    std::process::Command::new(&exe)
        .args(&config.spawn_args)
        .current_dir(&stable_dir)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .map_err(|e| {
            anyhow!(
                "could not spawn `{} {}`: {e}",
                exe.display(),
                config.spawn_args.join(" "),
            )
        })?;

    // Poll until ready, re-reading the base URL each iteration so dynamic ports
    // are discovered as soon as the daemon writes its address file.
    let deadline = Instant::now() + startup_timeout;
    loop {
        tokio::time::sleep(poll_interval).await;
        let current_url = (config.base_url_fn)();
        let health_url = format!("{current_url}{}", config.health_path);
        if probe_health_once(&health_url).await {
            eprintln!("\u{2713} {} daemon ready.", config.service_name);
            return Ok(current_url);
        }
        if Instant::now() >= deadline {
            return Err(anyhow!(
                "{} daemon did not become ready within {}s. \
                 Check `{} doctor` for details. \
                 The MCP stdio bridge cannot operate without a running daemon.",
                config.service_name,
                startup_timeout.as_secs(),
                config.service_name,
            ));
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn make_config(base_url: &'static str, health_path: &str) -> DaemonBridgeConfig {
        DaemonBridgeConfig {
            service_name: "test-svc".to_string(),
            spawn_args: vec!["serve".to_string(), "--foreground".to_string()],
            health_path: health_path.to_string(),
            base_url_fn: Box::new(move || base_url.to_string()),
            startup_timeout: Some(Duration::from_millis(100)), // very short for tests
            poll_interval: Some(Duration::from_millis(20)),
            no_spawn: false,
            no_spawn_hint: None,
        }
    }

    /// Why: `health_url()` must concatenate base URL and health path exactly,
    /// with no double-slash or missing slash.
    /// Test: this test.
    #[test]
    fn daemon_bridge_config_health_url() {
        let cfg = make_config("http://127.0.0.1:9999", "/health");
        assert_eq!(cfg.health_url(), "http://127.0.0.1:9999/health");

        let cfg2 = make_config("http://127.0.0.1:9999", "/api/v1/health");
        assert_eq!(cfg2.health_url(), "http://127.0.0.1:9999/api/v1/health");
    }

    /// Why: `probe_health_once` against a refused port must return `false`
    /// quickly without hanging.
    /// Test: this test.
    #[tokio::test]
    async fn probe_health_once_returns_false_on_refused() {
        let started = std::time::Instant::now();
        let result = probe_health_once("http://127.0.0.1:65534/health").await;
        assert!(!result, "probe must fail against an unbound port");
        assert!(
            started.elapsed() < Duration::from_secs(6),
            "probe took too long: {:?}",
            started.elapsed()
        );
    }

    /// Why: when a live server answers `/health` with 200, `ensure_daemon_up`
    /// must return `Ok` immediately without spawning anything.
    /// What: binds a minimal TCP listener that returns `HTTP/1.1 200 OK` on
    /// every connection, feeds that port into `DaemonBridgeConfig`, and asserts
    /// `ensure_daemon_up` returns `Ok` within a short wall-clock bound.
    /// Test: this test.
    #[tokio::test]
    async fn ensure_daemon_up_returns_ok_when_already_healthy() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        tokio::spawn(async move {
            loop {
                if let Ok((mut stream, _)) = listener.accept().await {
                    tokio::spawn(async move {
                        use tokio::io::AsyncWriteExt;
                        let _ = stream
                            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
                            .await;
                    });
                }
            }
        });
        tokio::time::sleep(Duration::from_millis(20)).await;

        let base = format!("http://127.0.0.1:{port}");
        let cfg = DaemonBridgeConfig {
            service_name: "test-svc".to_string(),
            spawn_args: vec![],
            health_path: "/health".to_string(),
            base_url_fn: Box::new(move || base.clone()),
            startup_timeout: Some(Duration::from_secs(5)),
            poll_interval: Some(Duration::from_millis(50)),
            no_spawn: false,
            no_spawn_hint: None,
        };
        let result = ensure_daemon_up(&cfg).await;
        assert!(
            result.is_ok(),
            "must succeed when daemon is healthy: {result:?}"
        );
    }

    /// Why: when nothing starts within the budget, `ensure_daemon_up` must
    /// return `Err` rather than hanging forever.
    /// Test: this test.
    #[tokio::test]
    async fn ensure_daemon_up_errors_on_timeout() {
        // Port 1 is reserved/refused on all test hosts.
        let cfg = make_config("http://127.0.0.1:1", "/health");
        let result = ensure_daemon_up(&cfg).await;
        assert!(
            result.is_err(),
            "must fail when the daemon never becomes ready"
        );
    }

    /// Why (issue #1152): when `no_spawn = true` and the health probe fails,
    /// `ensure_daemon_up` must return `Err` immediately — it must NOT spawn a
    /// background process.  The trusty-memory stdio bridge uses this to prevent
    /// an unmanaged `serve --foreground --http :0` from squatting the production
    /// palace redb write lock.
    /// What: builds a config with `no_spawn = true` pointing at a refused port,
    /// calls `ensure_daemon_up`, and asserts `Err` is returned with a message
    /// that contains both the service name and "start".
    /// Test: this test (unit; no real daemon spawned — spawn would fail the test
    /// binary anyway since `current_exe()` is the test harness, not trusty-memory).
    #[tokio::test]
    async fn no_spawn_returns_err_without_spawning() {
        let cfg = DaemonBridgeConfig {
            service_name: "trusty-memory".to_string(),
            spawn_args: vec!["serve".to_string(), "--foreground".to_string()],
            health_path: "/health".to_string(),
            base_url_fn: Box::new(|| "http://127.0.0.1:65534".to_string()),
            startup_timeout: Some(Duration::from_millis(100)),
            poll_interval: Some(Duration::from_millis(20)),
            no_spawn: true,
            no_spawn_hint: None,
        };
        let result = ensure_daemon_up(&cfg).await;
        assert!(result.is_err(), "no_spawn must return Err when probe fails");
        let msg = format!("{}", result.unwrap_err());
        assert!(
            msg.contains("trusty-memory"),
            "error must name the service; got: {msg}"
        );
        assert!(
            msg.contains("start"),
            "error must mention how to start the daemon; got: {msg}"
        );
    }

    /// Why (#2491 review): a service with a `no_spawn_hint` set (e.g.
    /// trusty-mpm, whose real operator guidance differs from the generic
    /// `{name} setup` / `io.trusty.{name}.plist` text) must see ITS hint in the
    /// error, not the generic wording that names a nonexistent subcommand and
    /// plist path.
    /// What: builds a config with `no_spawn: true` and a distinctive
    /// `no_spawn_hint`, asserts the hint text appears verbatim and the generic
    /// `setup`/`io.trusty.` wording does not.
    /// Test: this test.
    #[tokio::test]
    async fn no_spawn_error_uses_hint_when_set() {
        let cfg = DaemonBridgeConfig {
            service_name: "trusty-mpm".to_string(),
            spawn_args: vec![],
            health_path: "/health".to_string(),
            base_url_fn: Box::new(|| "http://127.0.0.1:65534".to_string()),
            startup_timeout: Some(Duration::from_millis(100)),
            poll_interval: Some(Duration::from_millis(20)),
            no_spawn: true,
            no_spawn_hint: Some(
                "trusty-mpm daemon is launchd-managed on this machine — start it with \
                 `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.trusty.mpm.plist`."
                    .to_string(),
            ),
        };
        let result = ensure_daemon_up(&cfg).await;
        assert!(result.is_err(), "no_spawn must return Err when probe fails");
        let msg = format!("{}", result.unwrap_err());
        assert!(
            msg.contains("com.trusty.mpm.plist"),
            "error must contain the service-specific hint; got: {msg}"
        );
        assert!(
            !msg.contains("trusty-mpm setup"),
            "error must NOT contain the generic (wrong-for-trusty-mpm) setup advice; got: {msg}"
        );
        assert!(
            !msg.contains("io.trusty."),
            "error must NOT contain the generic io.trusty.* plist path; got: {msg}"
        );
    }

    /// Why (#2491 review): when `no_spawn_hint` is left `None` (every caller
    /// except trusty-mpm), the original generic setup/plist wording must still
    /// appear — the new field must not change existing behaviour for
    /// trusty-memory, trusty-search, or trusty-analyze.
    /// What: reuses `no_spawn_returns_err_without_spawning`'s config shape with
    /// `no_spawn_hint: None` and asserts the generic wording is present.
    /// Test: this test.
    #[tokio::test]
    async fn no_spawn_error_falls_back_to_generic_when_hint_unset() {
        let cfg = DaemonBridgeConfig {
            service_name: "trusty-memory".to_string(),
            spawn_args: vec![],
            health_path: "/health".to_string(),
            base_url_fn: Box::new(|| "http://127.0.0.1:65534".to_string()),
            startup_timeout: Some(Duration::from_millis(100)),
            poll_interval: Some(Duration::from_millis(20)),
            no_spawn: true,
            no_spawn_hint: None,
        };
        let result = ensure_daemon_up(&cfg).await;
        assert!(result.is_err(), "no_spawn must return Err when probe fails");
        let msg = format!("{}", result.unwrap_err());
        assert!(
            msg.contains("trusty-memory setup"),
            "generic hint must still mention `{{service}} setup`; got: {msg}"
        );
        assert!(
            msg.contains("io.trusty.trusty-memory.plist"),
            "generic hint must still mention the io.trusty.* plist path; got: {msg}"
        );
    }
}