Skip to main content

trusty_console/
connector.rs

1//! ServiceConnector trait — the extensibility seam for all per-service adapters.
2//!
3//! Why: P0 only needs read-only detection (`detect()`), but P1+ must add
4//! `spawn()` and typed tool-call methods. Defining the trait now with those
5//! method stubs keeps the architecture clean and avoids large breaking
6//! refactors when those phases land.
7//! What: Defines `ServiceStatus`, the `ServiceConnector` trait, and a
8//! `ServiceInfo` result struct that the API layer serialises.
9//! Test: Each concrete connector implements `#[cfg(test)]` unit tests that
10//! exercise `detect()` against fake data-dir fixtures; see `detect.rs`.
11
12use serde::{Deserialize, Serialize};
13
14/// Runtime status of one detected service.
15///
16/// Why: Four states capture everything the console needs — whether the binary
17/// exists, whether a daemon is running, whether the MCP handshake succeeded but
18/// the expected tool is missing (Degraded), and whether the binary is absent.
19/// What: Serialises to a lowercase string for the JSON API.
20/// Test: Asserted by unit tests in `detect.rs` and `server.rs`.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ServiceStatus {
24    /// Daemon is reachable and health-checked OK.
25    Running,
26    /// Binary found on PATH but no daemon discovery file / TCP probe.
27    Available,
28    /// Binary not found on PATH.
29    Absent,
30    /// Process is reachable (MCP handshake succeeded) but the expected
31    /// `console_metrics` tool was not listed in `tools/list` — check that
32    /// the daemon is started in `serve --stdio` mode with the correct wiring.
33    Degraded,
34}
35
36/// How a service is meant to run when it is healthy.
37///
38/// Why (#6416): `ServiceStatus::Available` means "the binary is installed and
39/// nothing is serving", and the console rendered that one sentence for every
40/// member: "Binary found but daemon is not running." For trusty-review, which
41/// #6290 retired the daemon of, and trusty-analyze, which #6287 moved to an
42/// on-demand socket server, that IS the healthy resting state — so the console
43/// was rendering the correct state as a fault, in amber, with remediation text
44/// for a daemon the operator cannot start. `status` alone cannot tell those two
45/// cases apart; this says which reading applies.
46///
47/// What: a per-member constant, not an observation. A connector picks it once
48/// and every `ServiceInfo` it builds carries it, `Absent` rows included.
49/// Test: `service_lifecycle_serialises_to_snake_case`,
50/// `on_demand_members_are_exactly_review_and_analyze`.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ServiceLifecycle {
54    /// A resident daemon. `Running` is the healthy state and `Available` means
55    /// it is installed but stopped.
56    #[default]
57    Daemon,
58    /// A per-invocation binary or an on-demand server. Installed IS healthy:
59    /// `Available` is the resting state and `Running` merely means a server
60    /// happens to be up right now.
61    OnDemand,
62}
63
64/// All facts gathered about a service in one detection pass.
65///
66/// Why: The API handler turns this struct directly into JSON for
67/// `GET /api/console/services`, so callers get a stable shape to render cards.
68/// What: `id` is the stable machine identifier; `display_name` is human-
69/// readable; `status` is the current runtime state; `version` is the version
70/// string from `/health` when the daemon is running (absent otherwise);
71/// `url` is the daemon base URL when reachable; `hint` is an optional
72/// actionable remediation message surfaced when `status` is `Degraded`;
73/// `lifecycle` says whether a stopped daemon or an installed on-demand binary
74/// is the healthy reading of that status (#6416).
75/// Test: Tested via the server integration test in `server.rs`.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ServiceInfo {
78    /// Stable machine identifier (e.g. `"trusty-search"`).
79    pub id: String,
80    /// Human-readable display name (e.g. `"Trusty Search"`).
81    pub display_name: String,
82    /// Current runtime state.
83    pub status: ServiceStatus,
84    /// Version string reported by the running daemon's `/health` endpoint.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub version: Option<String>,
87    /// Base URL of the running daemon (e.g. `"http://127.0.0.1:7879"`).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub url: Option<String>,
90    /// Actionable remediation hint — present when `status` is `Degraded`.
91    /// Example: "reachable but `console_metrics` tool not registered — check
92    /// `serve --stdio` wiring / restart the daemon".
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub hint: Option<String>,
95    /// Whether this member runs as a resident daemon or on demand (#6416).
96    ///
97    /// Serialised unconditionally — the UI branches on it — and `#[serde(default)]`
98    /// so a payload written before #6416 still deserialises, as a daemon.
99    #[serde(default)]
100    pub lifecycle: ServiceLifecycle,
101}
102
103/// Per-service adapter contract.
104///
105/// Why: Decouples the orchestration loop from service-specific knowledge.
106/// P0 only calls `detect()`; P1+ will add `spawn()` and typed MCP/tool-call
107/// methods without modifying existing code paths.
108/// What: A synchronous detect() that returns a `ServiceInfo`. The trait is
109/// object-safe so connectors can be boxed (`Box<dyn ServiceConnector>`).
110/// Test: Each impl has unit tests in `detect.rs` exercising the three status
111/// outcomes.
112pub trait ServiceConnector: Send + Sync {
113    /// Stable machine identifier for this service (must be unique per instance).
114    ///
115    /// Why: Used as the `id` field in `ServiceInfo` and as a log tag.
116    /// What: Returns a `'static str` reference so no allocation is needed.
117    /// Test: Compared against expected strings in tests.
118    fn id(&self) -> &'static str;
119
120    /// Human-readable name shown in the console UI.
121    ///
122    /// Why: Separates the stable ID from the display label so either can
123    /// change independently.
124    /// What: Returns a `'static str`.
125    /// Test: Asserted by the connector construction tests.
126    fn display_name(&self) -> &'static str;
127
128    /// How this member runs when it is healthy (#6416).
129    ///
130    /// Why: a roster-level test can read this without probing sockets or
131    /// spawning binaries, so "which members are on-demand" is assertable as the
132    /// constant it is rather than inferred from a live detection pass.
133    /// What: defaults to `Daemon`; the de-daemonized members override it.
134    /// Test: `on_demand_members_are_exactly_review_and_analyze`.
135    fn lifecycle(&self) -> ServiceLifecycle {
136        ServiceLifecycle::Daemon
137    }
138
139    /// Detect the current runtime status of this service.
140    ///
141    /// Why: P0 detection is purely read-only — reads files, probes TCP, looks
142    /// for binaries — so it never mutates any service state.
143    /// What: Returns a `ServiceInfo` with `status`, optional `version`, and
144    /// optional `url`. Never returns an error; degrades gracefully to `Absent`.
145    /// Test: Unit tests in `detect.rs` inject a temp home dir and fake files.
146    fn detect(&self) -> ServiceInfo;
147}
148
149// ─── tests ────────────────────────────────────────────────────────────────────
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// Why: The JSON API contract requires `ServiceStatus` variants to serialise
156    /// to exact lowercase strings. A missing or mismatched `#[serde(rename_all)]`
157    /// attribute would silently change the wire format and break the Svelte UI.
158    /// What: Serialises each variant via `serde_json::to_value` and asserts the
159    /// exact expected string so any future rename is caught at test time.
160    /// Test: This test.
161    #[test]
162    fn service_status_serialises_to_lowercase_strings() {
163        assert_eq!(
164            serde_json::to_value(ServiceStatus::Running).unwrap(),
165            serde_json::json!("running"),
166            "Running must serialise to \"running\""
167        );
168        assert_eq!(
169            serde_json::to_value(ServiceStatus::Available).unwrap(),
170            serde_json::json!("available"),
171            "Available must serialise to \"available\""
172        );
173        assert_eq!(
174            serde_json::to_value(ServiceStatus::Absent).unwrap(),
175            serde_json::json!("absent"),
176            "Absent must serialise to \"absent\""
177        );
178        assert_eq!(
179            serde_json::to_value(ServiceStatus::Degraded).unwrap(),
180            serde_json::json!("degraded"),
181            "Degraded must serialise to \"degraded\""
182        );
183    }
184
185    /// Why (#6416): the Svelte card branches on this string. A rename that only
186    /// changed the Rust identifier would leave every on-demand row rendering the
187    /// daemon sentence again, with nothing red anywhere to say so.
188    /// What: asserts both variants' wire spellings and that an older payload
189    /// with no `lifecycle` key still reads as a daemon.
190    /// Test: this test.
191    #[test]
192    fn service_lifecycle_serialises_to_snake_case() {
193        assert_eq!(
194            serde_json::to_value(ServiceLifecycle::Daemon).unwrap(),
195            serde_json::json!("daemon")
196        );
197        assert_eq!(
198            serde_json::to_value(ServiceLifecycle::OnDemand).unwrap(),
199            serde_json::json!("on_demand")
200        );
201
202        let legacy: ServiceInfo = serde_json::from_value(serde_json::json!({
203            "id": "trusty-search",
204            "display_name": "Trusty Search",
205            "status": "available",
206        }))
207        .expect("a payload predating #6416 must still deserialise");
208        assert_eq!(legacy.lifecycle, ServiceLifecycle::Daemon);
209    }
210}