Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

crates/signald/src/hub.rs

149 lines · 4850 bytes

  1//! # The signal hub — last-value cache + live fan-out
  2//!
  3//! The in-memory heart of the publish side. Collectors [`Hub::publish`] signals;
  4//! subscribers [`Hub::subscribe`] and receive the current value of every cached
  5//! metric immediately, then a live stream of updates.
  6//!
  7//! Subscription semantics: *last-value-cache + live stream.* On
  8//! subscribe, the caller is handed the latest value of each `name` (keyed by
  9//! name + audited tag), so a renderer paints correct state at once; thereafter
 10//! it receives updates as they are published. Channels are unbounded, so a slow
 11//! subscriber never blocks a publisher.
 12//!
 13//! When a `Hub` is built [`Hub::with_history`], every published signal is also
 14//! persisted to the SQLite history store, so live and persisted paths share one
 15//! write point.
 16
 17use std::collections::BTreeMap;
 18use std::sync::mpsc::{channel, Receiver, Sender};
 19use std::sync::{Arc, Mutex};
 20
 21use signal_schema::Signal;
 22
 23use crate::history::History;
 24
 25/// Cache key: metric name discriminant + optional audited tag. Distinct
 26/// per-repo / per-core signals coexist; a new value for the same key replaces
 27/// the old one (keep-latest).
 28type Key = (u8, Option<String>);
 29
 30/// A cloneable handle to the shared hub. Clones share one cache + subscriber
 31/// set behind a mutex.
 32#[derive(Clone)]
 33pub struct Hub {
 34    inner: Arc<Mutex<Inner>>,
 35}
 36
 37struct Inner {
 38    cache: BTreeMap<Key, Signal>,
 39    subs: Vec<Sender<Signal>>,
 40    history: Option<History>,
 41}
 42
 43impl Hub {
 44    /// A hub with no history (live-only). Useful for tests and for a renderer
 45    /// that never persists.
 46    pub fn new() -> Hub {
 47        Hub::build(None)
 48    }
 49
 50    /// A hub that also persists every published signal to `history`.
 51    pub fn with_history(history: History) -> Hub {
 52        Hub::build(Some(history))
 53    }
 54
 55    fn build(history: Option<History>) -> Hub {
 56        Hub {
 57            inner: Arc::new(Mutex::new(Inner {
 58                cache: BTreeMap::new(),
 59                subs: Vec::new(),
 60                history,
 61            })),
 62        }
 63    }
 64
 65    /// Publish one signal: persist it (if a history is attached), update the
 66    /// last-value cache, and fan it out to every live subscriber. Dead
 67    /// subscribers (whose receiver was dropped) are pruned here.
 68    pub fn publish(&self, s: Signal) {
 69        let mut guard = self.inner.lock().unwrap();
 70        let Inner {
 71            cache,
 72            subs,
 73            history,
 74        } = &mut *guard;
 75
 76        if let Some(h) = history {
 77            // A history write failing must not stop the live stream.
 78            let _ = h.record(&s);
 79        }
 80        let key = (s.name.to_u8(), s.tag.as_ref().map(|t| t.as_str().to_string()));
 81        cache.insert(key, s.clone());
 82        subs.retain(|tx| tx.send(s.clone()).is_ok());
 83    }
 84
 85    /// Subscribe: return the current last-value snapshot plus a receiver that
 86    /// yields every signal published after this call.
 87    pub fn subscribe(&self) -> (Vec<Signal>, Receiver<Signal>) {
 88        let mut guard = self.inner.lock().unwrap();
 89        let snapshot = guard.cache.values().cloned().collect();
 90        let (tx, rx) = channel();
 91        guard.subs.push(tx);
 92        (snapshot, rx)
 93    }
 94
 95    /// The current last-value snapshot without subscribing.
 96    pub fn snapshot(&self) -> Vec<Signal> {
 97        self.inner.lock().unwrap().cache.values().cloned().collect()
 98    }
 99}
100
101impl Default for Hub {
102    fn default() -> Self {
103        Hub::new()
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use signal_schema::{SignalName, Source, Value, SCHEMA_VERSION};
111
112    fn sig(name: SignalName, value: f64) -> Signal {
113        Signal {
114            schema_version: SCHEMA_VERSION,
115            ts: 1,
116            source: Source::Terminal,
117            name,
118            value: Value(value),
119            tag: None,
120        }
121    }
122
123    #[test]
124    fn subscriber_gets_cached_value_then_live_update() {
125        let hub = Hub::new();
126        // A value published before anyone subscribes must still be seen (cache).
127        hub.publish(sig(SignalName::KeysPerMin, 10.0));
128
129        let (snapshot, rx) = hub.subscribe();
130        assert_eq!(snapshot.len(), 1);
131        assert_eq!(snapshot[0].value, Value(10.0));
132
133        // A change after subscribing arrives live.
134        hub.publish(sig(SignalName::KeysPerMin, 25.0));
135        let update = rx.recv().expect("live update");
136        assert_eq!(update.name, SignalName::KeysPerMin);
137        assert_eq!(update.value, Value(25.0));
138    }
139
140    #[test]
141    fn cache_keeps_latest_per_name() {
142        let hub = Hub::new();
143        hub.publish(sig(SignalName::KeysPerMin, 1.0));
144        hub.publish(sig(SignalName::KeysPerMin, 2.0));
145        let snap = hub.snapshot();
146        assert_eq!(snap.len(), 1, "same name collapses to keep-latest");
147        assert_eq!(snap[0].value, Value(2.0));
148    }
149}