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}