Skip to main content

omni_dev/daemon/
registry.rs

1//! The [`ServiceRegistry`]: the daemon's set of hosted services, plus routing.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{anyhow, Result};
7use serde_json::Value;
8
9use crate::github_rate_limit::{RateLimitCache, RateLimitSnapshot};
10
11use super::service::{DaemonService, ServiceStatus, ServiceStream};
12
13/// Holds the daemon's registered services and routes control-socket envelopes
14/// to them by [`name`](DaemonService::name).
15#[derive(Clone, Default)]
16pub struct ServiceRegistry {
17    services: Vec<Arc<dyn DaemonService>>,
18    /// The GitHub API rate-limit monitor's cache (#1375), shared with the poller
19    /// that fills it (hosted by the worktrees service). `None` until the daemon
20    /// wires it in via [`set_github_rate_limit`](Self::set_github_rate_limit), so
21    /// the built-in `status` op can report machine-wide GitHub budget usage as a
22    /// top-level field without downcasting a specific service.
23    github_rate_limit: Option<Arc<RateLimitCache>>,
24}
25
26impl ServiceRegistry {
27    /// Creates an empty registry.
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Adds a service. Lookups match by [`DaemonService::name`] and the first
33    /// registration wins; a second service sharing a name would be dead code
34    /// for routing and would double-count in status/menu iteration, so it is
35    /// rejected with a warning rather than silently kept.
36    pub fn register(&mut self, service: Arc<dyn DaemonService>) {
37        let name = service.name();
38        if self.services.iter().any(|s| s.name() == name) {
39            tracing::warn!("ignoring duplicate registration of daemon service `{name}`");
40            return;
41        }
42        self.services.push(service);
43    }
44
45    /// Returns the registered service with the given name, if any.
46    pub fn get(&self, name: &str) -> Option<&Arc<dyn DaemonService>> {
47        self.services.iter().find(|s| s.name() == name)
48    }
49
50    /// All registered services, in registration order.
51    pub fn services(&self) -> &[Arc<dyn DaemonService>] {
52        &self.services
53    }
54
55    /// Wires in the GitHub rate-limit monitor's cache (#1375), so the built-in
56    /// `status` op can surface budget usage. Called once at daemon start with the
57    /// same `Arc` the worktrees service's poller writes.
58    pub fn set_github_rate_limit(&mut self, cache: Arc<RateLimitCache>) {
59        self.github_rate_limit = Some(cache);
60    }
61
62    /// The latest GitHub rate-limit snapshot, or `None` when the monitor is
63    /// unwired or has not polled successfully yet.
64    #[must_use]
65    pub fn github_rate_limit(&self) -> Option<RateLimitSnapshot> {
66        self.github_rate_limit.as_ref().and_then(|c| c.get())
67    }
68
69    /// Routes an operation to the named service, erroring if no such service is
70    /// registered.
71    pub async fn dispatch(&self, service: &str, op: &str, payload: Value) -> Result<Value> {
72        let svc = self
73            .get(service)
74            .ok_or_else(|| anyhow!("unknown service: {service}"))?;
75        svc.handle(op, payload).await
76    }
77
78    /// Opens a push subscription on the named service for a streaming `op`, or
79    /// `None` when the service is unknown or does not stream that op — in which
80    /// case the caller falls back to the normal [`dispatch`](Self::dispatch)
81    /// request→reply path (#1267).
82    pub fn subscribe(
83        &self,
84        service: &str,
85        op: &str,
86        payload: &Value,
87    ) -> Option<Box<dyn ServiceStream>> {
88        self.get(service)?.subscribe(op, payload)
89    }
90
91    /// Collects status from every service, in registration order.
92    pub async fn statuses(&self) -> Vec<ServiceStatus> {
93        let mut out = Vec::with_capacity(self.services.len());
94        for svc in &self.services {
95            out.push(svc.status().await);
96        }
97        out
98    }
99
100    /// Tells every service the control socket could not accept connections for
101    /// `outage` (see [`DaemonService::credit_accept_outage`]).
102    pub fn credit_accept_outage(&self, outage: Duration) {
103        for svc in &self.services {
104            svc.credit_accept_outage(outage);
105        }
106    }
107
108    /// Gracefully shuts down every service, in registration order.
109    pub async fn shutdown_all(&self) {
110        for svc in &self.services {
111            svc.shutdown().await;
112        }
113    }
114}
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used, clippy::expect_used)]
118mod tests {
119    use super::*;
120    use crate::daemon::services::echo::EchoService;
121    use serde_json::json;
122
123    #[tokio::test]
124    async fn routes_known_service_and_rejects_unknown() {
125        let mut registry = ServiceRegistry::new();
126        assert!(registry.services().is_empty());
127        registry.register(Arc::new(EchoService));
128
129        assert!(registry.get("echo").is_some());
130        assert!(registry.get("missing").is_none());
131
132        // Routed op reaches the service; an unknown service is an error.
133        assert_eq!(
134            registry
135                .dispatch("echo", "echo", json!({ "x": 1 }))
136                .await
137                .unwrap(),
138            json!({ "x": 1 })
139        );
140        let err = registry
141            .dispatch("missing", "echo", Value::Null)
142            .await
143            .unwrap_err();
144        assert!(err.to_string().contains("unknown service"));
145
146        // Aggregation iterates every registered service.
147        assert_eq!(registry.statuses().await.len(), 1);
148        registry.shutdown_all().await;
149    }
150
151    #[test]
152    fn credit_accept_outage_reaches_every_service() {
153        use crate::daemon::testutil::StubService;
154
155        let first = Arc::new(StubService::new("first"));
156        let second = Arc::new(StubService::new("second"));
157        let mut registry = ServiceRegistry::new();
158        registry.register(first.clone());
159        registry.register(second.clone());
160
161        registry.credit_accept_outage(Duration::from_secs(7));
162
163        assert_eq!(first.credited(), vec![Duration::from_secs(7)]);
164        assert_eq!(second.credited(), vec![Duration::from_secs(7)]);
165    }
166
167    #[test]
168    fn credit_accept_outage_is_a_no_op_for_a_service_that_ignores_it() {
169        // `EchoService` keeps the trait's default, which must simply do nothing.
170        let mut registry = ServiceRegistry::new();
171        registry.register(Arc::new(EchoService));
172        registry.credit_accept_outage(Duration::from_secs(7));
173    }
174
175    #[test]
176    fn duplicate_registration_is_ignored() {
177        let mut registry = ServiceRegistry::new();
178        registry.register(Arc::new(EchoService));
179        registry.register(Arc::new(EchoService));
180        // The second registration shares `echo`'s name, so it is dropped: the
181        // first wins on lookup and iteration is not double-counted.
182        assert_eq!(registry.services().len(), 1);
183    }
184}