Skip to main content

omni_dev/daemon/
selection.rs

1//! Build-time selection of which default-registry services the daemon hosts.
2//!
3//! By default the daemon hosts every service (see
4//! [`build_default_registry`](super::build_default_registry)). An operator can
5//! narrow that to a subset — e.g. only `worktrees`, with no Snowflake auth
6//! machinery and no browser-bridge TCP planes — via the `daemon run --services`
7//! flag or the [`SERVICES_ENV`] environment variable.
8//!
9//! The selection is resolved at daemon **build** time and gates each service's
10//! construction and registration; [`ServiceRegistry`](super::registry::ServiceRegistry)
11//! stays append-only, so a *runtime* enable/disable toggle remains a follow-up
12//! (#1318).
13//!
14//! Because launchd/systemd `exec` a fixed `daemon run` command with a minimal
15//! environment, a shell env var never reaches a service-managed daemon; `daemon
16//! start`/`restart` therefore bake the resolved selection into the generated
17//! plist / unit as a `--services a,b,c` argument (see [`ServiceSelection::to_csv`]).
18
19use clap::ValueEnum;
20
21use super::services::{bridge, sessions, snowflake, worktrees};
22
23/// Environment variable naming the service subset the daemon should host
24/// (comma-separated canonical names). Honored by a manually-run `daemon run`;
25/// overridden by an explicit `--services` flag.
26pub const SERVICES_ENV: &str = "OMNI_DEV_DAEMON_SERVICES";
27
28/// One of the daemon's default-registry services.
29///
30/// The `#[value(name = …)]` strings are the services' canonical
31/// `SERVICE_NAME`s, so `--services`, the `status` op, and the control-socket
32/// wire name all agree (guarded by a drift test below). The test-only `echo`
33/// service is intentionally excluded — it is never in the default registry.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
35pub enum DaemonServiceKind {
36    /// The browser bridge (`browser-bridge`).
37    #[value(name = "browser-bridge")]
38    Bridge,
39    /// The Snowflake query service (`snowflake`).
40    #[value(name = "snowflake")]
41    Snowflake,
42    /// The cross-window worktrees registry (`worktrees`).
43    #[value(name = "worktrees")]
44    Worktrees,
45    /// The Claude Code sessions tracker (`sessions`).
46    #[value(name = "sessions")]
47    Sessions,
48}
49
50impl DaemonServiceKind {
51    /// Every kind, in canonical registration order.
52    pub const ALL: [Self; 4] = [
53        Self::Bridge,
54        Self::Snowflake,
55        Self::Worktrees,
56        Self::Sessions,
57    ];
58
59    /// The service's canonical name — identical to its `SERVICE_NAME` constant
60    /// and its clap value name.
61    pub fn to_name(self) -> &'static str {
62        match self {
63            Self::Bridge => bridge::SERVICE_NAME,
64            Self::Snowflake => snowflake::SERVICE_NAME,
65            Self::Worktrees => worktrees::SERVICE_NAME,
66            Self::Sessions => sessions::SERVICE_NAME,
67        }
68    }
69
70    /// Resolves a canonical service name to its kind, or `None` for an unknown
71    /// token.
72    fn from_name(name: &str) -> Option<Self> {
73        Self::ALL.into_iter().find(|k| k.to_name() == name)
74    }
75}
76
77/// A comma-joined list of every known service name, for error/warning text.
78fn known_names() -> String {
79    DaemonServiceKind::ALL
80        .iter()
81        .map(|k| k.to_name())
82        .collect::<Vec<_>>()
83        .join(", ")
84}
85
86/// Which default-registry services a daemon should host.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum ServiceSelection {
89    /// Host every service — the default when nothing is specified.
90    All,
91    /// Host exactly these services (deduped, first-seen order, non-empty).
92    Only(Vec<DaemonServiceKind>),
93}
94
95impl ServiceSelection {
96    /// Whether `kind` is hosted under this selection.
97    pub fn includes(&self, kind: DaemonServiceKind) -> bool {
98        match self {
99            Self::All => true,
100            Self::Only(kinds) => kinds.contains(&kind),
101        }
102    }
103
104    /// The selection as a `--services` CSV value: `Some("a,b")` for a subset,
105    /// `None` when hosting everything (so the launcher bakes no argument).
106    pub fn to_csv(&self) -> Option<String> {
107        match self {
108            Self::All => None,
109            Self::Only(kinds) => Some(
110                kinds
111                    .iter()
112                    .map(|k| k.to_name())
113                    .collect::<Vec<_>>()
114                    .join(","),
115            ),
116        }
117    }
118
119    /// Resolves the selection from the CLI flag values and an optional env-var
120    /// value. Pure (no environment access) so it is unit-testable.
121    ///
122    /// Precedence: a non-empty `flag` wins outright; otherwise the `env` CSV is
123    /// parsed (blank and unknown tokens are warn-and-skipped); an empty result
124    /// means host everything ([`ServiceSelection::All`]).
125    pub fn resolve(flag: &[DaemonServiceKind], env: Option<&str>) -> Self {
126        if !flag.is_empty() {
127            return Self::from_kinds(flag.iter().copied());
128        }
129        if let Some(raw) = env {
130            let mut kinds = Vec::new();
131            for token in raw.split(',') {
132                let token = token.trim();
133                if token.is_empty() {
134                    continue;
135                }
136                if let Some(kind) = DaemonServiceKind::from_name(token) {
137                    kinds.push(kind);
138                } else {
139                    tracing::warn!(
140                        "ignoring unknown service `{token}` in {SERVICES_ENV} (known: {})",
141                        known_names()
142                    );
143                }
144            }
145            if !kinds.is_empty() {
146                return Self::from_kinds(kinds.into_iter());
147            }
148        }
149        Self::All
150    }
151
152    /// Reads [`SERVICES_ENV`] and delegates to [`resolve`](Self::resolve). Used by
153    /// `daemon run` (build side) and `daemon start` (bake side).
154    pub fn from_flag_or_env(flag: &[DaemonServiceKind]) -> Self {
155        Self::resolve(flag, std::env::var(SERVICES_ENV).ok().as_deref())
156    }
157
158    /// Builds a selection from live service names (a running daemon's `status`),
159    /// for `daemon restart`'s subset-preserving path. Unknown names are ignored;
160    /// an empty result means host everything.
161    pub fn from_service_names<'a>(names: impl IntoIterator<Item = &'a str>) -> Self {
162        Self::from_kinds(names.into_iter().filter_map(DaemonServiceKind::from_name))
163    }
164
165    /// Dedupes `kinds` preserving first-seen order. Two results collapse to
166    /// [`All`](Self::All): an *empty* set (a selection is never silently empty — a
167    /// daemon with no services would be useless), and a *full* known-set. The
168    /// full-set collapse means hosting every selectable service bakes no
169    /// `--services` argument, so a `restart` of a full daemon stays byte-identical
170    /// to its `start` and a future selectable service still auto-enables on a plain
171    /// `restart` rather than being frozen out of the baked list (#1352 review).
172    fn from_kinds(kinds: impl Iterator<Item = DaemonServiceKind>) -> Self {
173        let mut seen: Vec<DaemonServiceKind> = Vec::new();
174        for k in kinds {
175            if !seen.contains(&k) {
176                seen.push(k);
177            }
178        }
179        // `seen` is deduped and every element is a valid kind, so a length equal to
180        // the full set *is* the full set.
181        if seen.is_empty() || seen.len() == DaemonServiceKind::ALL.len() {
182            Self::All
183        } else {
184            Self::Only(seen)
185        }
186    }
187}
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used, clippy::expect_used)]
191mod tests {
192    use super::*;
193    use DaemonServiceKind::*;
194
195    #[test]
196    fn value_names_match_the_service_constants() {
197        // The clap value name, `to_name()`, and each service's `SERVICE_NAME`
198        // must all agree, so `--services`, `status`, and the wire name never
199        // drift apart.
200        for (kind, expected) in [
201            (Bridge, bridge::SERVICE_NAME),
202            (Snowflake, snowflake::SERVICE_NAME),
203            (Worktrees, worktrees::SERVICE_NAME),
204            (Sessions, sessions::SERVICE_NAME),
205        ] {
206            assert_eq!(kind.to_name(), expected);
207            assert_eq!(
208                kind.to_possible_value().unwrap().get_name(),
209                expected,
210                "clap value name drifted from SERVICE_NAME for {kind:?}"
211            );
212        }
213    }
214
215    #[test]
216    fn flag_wins_over_env() {
217        assert_eq!(
218            ServiceSelection::resolve(&[Worktrees], Some("sessions")),
219            ServiceSelection::Only(vec![Worktrees])
220        );
221    }
222
223    #[test]
224    fn env_is_read_when_the_flag_is_empty() {
225        assert_eq!(
226            ServiceSelection::resolve(&[], Some("worktrees,sessions")),
227            ServiceSelection::Only(vec![Worktrees, Sessions])
228        );
229    }
230
231    #[test]
232    fn unknown_and_blank_env_tokens_are_skipped() {
233        assert_eq!(
234            ServiceSelection::resolve(&[], Some(" worktrees , bogus ,, sessions ")),
235            ServiceSelection::Only(vec![Worktrees, Sessions])
236        );
237    }
238
239    #[test]
240    fn an_empty_or_all_unknown_selection_is_all() {
241        assert_eq!(ServiceSelection::resolve(&[], None), ServiceSelection::All);
242        assert_eq!(
243            ServiceSelection::resolve(&[], Some("")),
244            ServiceSelection::All
245        );
246        assert_eq!(
247            ServiceSelection::resolve(&[], Some("nope,also-nope")),
248            ServiceSelection::All
249        );
250    }
251
252    #[test]
253    fn selections_are_deduped_in_first_seen_order() {
254        assert_eq!(
255            ServiceSelection::resolve(&[Sessions, Worktrees, Sessions], None),
256            ServiceSelection::Only(vec![Sessions, Worktrees])
257        );
258        assert_eq!(
259            ServiceSelection::resolve(&[], Some("worktrees,worktrees")),
260            ServiceSelection::Only(vec![Worktrees])
261        );
262    }
263
264    #[test]
265    fn a_full_known_set_collapses_to_all() {
266        // Naming every selectable service is equivalent to `All`, so it bakes no
267        // `--services` argument (#1352 review) — whether it arrives via the flag,
268        // the env var, or a running daemon's status names (which also carry the
269        // always-on `github` service, dropped as non-selectable).
270        assert_eq!(
271            ServiceSelection::resolve(&[Bridge, Snowflake, Worktrees, Sessions], None),
272            ServiceSelection::All
273        );
274        assert_eq!(
275            ServiceSelection::resolve(&[], Some("browser-bridge,snowflake,worktrees,sessions")),
276            ServiceSelection::All
277        );
278        assert_eq!(
279            ServiceSelection::from_service_names([
280                "browser-bridge",
281                "snowflake",
282                "worktrees",
283                "sessions",
284                "github",
285            ]),
286            ServiceSelection::All
287        );
288        // A full set collapses even when it arrives out of order or with repeats.
289        assert_eq!(
290            ServiceSelection::resolve(&[Sessions, Worktrees, Snowflake, Bridge, Bridge], None),
291            ServiceSelection::All
292        );
293    }
294
295    #[test]
296    fn to_csv_round_trips_and_omits_for_all() {
297        assert_eq!(
298            ServiceSelection::Only(vec![Worktrees, Sessions]).to_csv(),
299            Some("worktrees,sessions".to_string())
300        );
301        assert_eq!(ServiceSelection::All.to_csv(), None);
302    }
303
304    #[test]
305    fn includes_matches_membership() {
306        assert!(ServiceSelection::All.includes(Bridge));
307        let only = ServiceSelection::Only(vec![Worktrees]);
308        assert!(only.includes(Worktrees));
309        assert!(!only.includes(Bridge));
310    }
311
312    #[test]
313    fn from_service_names_maps_and_ignores_unknowns() {
314        assert_eq!(
315            ServiceSelection::from_service_names(["worktrees", "browser-bridge"]),
316            ServiceSelection::Only(vec![Worktrees, Bridge])
317        );
318        // Unknown names are dropped; an all-unknown or empty list is `All`.
319        assert_eq!(
320            ServiceSelection::from_service_names(["mystery"]),
321            ServiceSelection::All
322        );
323        assert_eq!(
324            ServiceSelection::from_service_names(std::iter::empty::<&str>()),
325            ServiceSelection::All
326        );
327    }
328}