Skip to main content

bamboo_server/app_state/
session_events.rs

1//! Session event sender management + the always-on notification relay.
2//!
3//! Re-exports the shared `get_or_create_event_sender` function from the
4//! runtime crate. The `impl AppState` methods delegate to it and to the
5//! free [`ensure_notification_relay`] below.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use bamboo_agent_core::AgentEvent;
11use tokio::sync::{broadcast, RwLock};
12
13use super::watchers::SessionWatchers;
14
15pub use bamboo_engine::execution::session_events::get_or_create_event_sender;
16
17/// Dependencies the always-on notification relay needs, independent of a
18/// full `AppState`.
19///
20/// Two call sites start this relay: [`super::AppState::ensure_notification_relay`]
21/// (interactive/resume execution, and SSE/WS client subscribe) and the
22/// schedule manager (`schedule_app::manager`, headless/scheduled runs — which
23/// hold their own trimmed `ScheduleContext` rather than a full `AppState`,
24/// since scheduled runs use a minimal tool/session surface). Bundling the
25/// four `Arc`/cheap-clone fields the relay task actually touches lets both
26/// callers share one implementation instead of two copies drifting apart.
27#[derive(Clone)]
28pub struct NotificationRelayDeps {
29    pub notification_service: Arc<bamboo_notification::NotificationService>,
30    pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
31    pub session_watchers: Arc<SessionWatchers>,
32    pub config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
33}
34
35/// Server-owned observer setup injected into the engine's canonical child
36/// scheduler. Both queued tool launches and reserved SessionInbox activations
37/// therefore start the same always-on relay before child execution.
38pub struct NotificationRelayLaunchHook {
39    deps: NotificationRelayDeps,
40}
41
42impl NotificationRelayLaunchHook {
43    pub fn new(deps: NotificationRelayDeps) -> Self {
44        Self { deps }
45    }
46}
47
48impl bamboo_engine::execution::ChildRunLaunchHook for NotificationRelayLaunchHook {
49    fn before_child_launch(
50        &self,
51        job: &bamboo_engine::execution::SpawnJob,
52        child_events: broadcast::Sender<AgentEvent>,
53    ) {
54        ensure_notification_relay(&self.deps, &job.child_session_id, child_events);
55    }
56}
57
58/// Ensure a notification relay task is running for `session_id`.
59///
60/// The relay subscribes to the session's event broadcast, runs the backend
61/// notification policy on each event, and:
62/// - re-broadcasts any resulting `AgentEvent::Notification` onto the same
63///   channel so connected SSE/WS clients receive it, and
64/// - fans it out to the configured delivery sinks (command/desktop/ntfy/bark — see
65///   [`crate::notify_sinks::dispatch_to_sinks`]), reading the CURRENT config
66///   on every notification so a hot-reloaded topic/token/toggle takes effect
67///   immediately.
68///
69/// Idempotent: at most one relay per session (tracked by the notification
70/// service via `try_begin_relay`). The relay does not hold a `Sender` clone,
71/// so the channel still closes naturally (`RecvError::Closed`) when the
72/// session is torn down, and this task exits cleanly with it — nothing leaks.
73pub fn ensure_notification_relay(
74    deps: &NotificationRelayDeps,
75    session_id: &str,
76    sender: broadcast::Sender<AgentEvent>,
77) {
78    if !deps.notification_service.try_begin_relay(session_id) {
79        return;
80    }
81    let service = deps.notification_service.clone();
82    let senders = deps.session_event_senders.clone();
83    let watchers = deps.session_watchers.clone();
84    let config = deps.config.clone();
85    let sid = session_id.to_string();
86    let mut rx = sender.subscribe();
87    drop(sender);
88    tokio::spawn(async move {
89        use tokio::sync::broadcast::error::RecvError;
90        loop {
91            match rx.recv().await {
92                Ok(event) => {
93                    if let Some(notification) = service.notify(&sid, &event) {
94                        // Build the sink payload before `notification` is moved
95                        // into `tx.send` below.
96                        let sink_notification =
97                            crate::notify_sinks::SinkNotification::from_event(&notification);
98
99                        let tx = senders.read().await.get(&sid).cloned();
100                        if let Some(tx) = tx {
101                            let _ = tx.send(notification);
102                        }
103
104                        if let Some(sink_notification) = sink_notification {
105                            let has_watcher = watchers.has_watcher(&sid);
106                            let config_snapshot = config.read().await.clone();
107                            super::AppState::dispatch_to_sinks(
108                                &config_snapshot,
109                                has_watcher,
110                                &sink_notification,
111                            );
112                        }
113                    }
114                }
115                Err(RecvError::Lagged(_)) => continue,
116                Err(RecvError::Closed) => break,
117            }
118        }
119        service.end_relay(&sid);
120    });
121}
122
123impl super::AppState {
124    /// Get (or create) a long-lived session event sender for a session id.
125    ///
126    /// This stream is intended for UI consumption and background activity; it should remain
127    /// available even when no agent execution is running.
128    pub async fn get_session_event_sender(
129        &self,
130        session_id: &str,
131    ) -> tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent> {
132        get_or_create_event_sender(&self.session_event_senders, session_id).await
133    }
134
135    /// Bundles this `AppState`'s notification-relay dependencies (see
136    /// [`NotificationRelayDeps`]).
137    pub fn notification_relay_deps(&self) -> NotificationRelayDeps {
138        NotificationRelayDeps {
139            notification_service: self.notification_service.clone(),
140            session_event_senders: self.session_event_senders.clone(),
141            session_watchers: self.session_watchers.clone(),
142            config: self.config.clone(),
143        }
144    }
145
146    /// Ensure a notification relay task is running for `session_id`. See
147    /// [`ensure_notification_relay`].
148    pub fn ensure_notification_relay(
149        &self,
150        session_id: &str,
151        sender: tokio::sync::broadcast::Sender<bamboo_agent_core::AgentEvent>,
152    ) {
153        ensure_notification_relay(&self.notification_relay_deps(), session_id, sender);
154    }
155
156    /// Fans a classified notification out to configured delivery sinks
157    /// (command/desktop/ntfy/bark). Free of `&self` — the relay task started by
158    /// [`ensure_notification_relay`] only holds cloned `Arc`s (not a full
159    /// `AppState`), so this is a plain associated function over the values
160    /// it needs; see [`crate::notify_sinks::dispatch_to_sinks`].
161    pub fn dispatch_to_sinks(
162        config: &bamboo_llm::Config,
163        has_watcher: bool,
164        notification: &crate::notify_sinks::SinkNotification,
165    ) {
166        crate::notify_sinks::dispatch_to_sinks(config, has_watcher, notification);
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::time::Duration;
174
175    fn test_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
176        let dir = tempfile::tempdir().unwrap();
177        let notification_service = Arc::new(bamboo_notification::NotificationService::new(
178            dir.path().join("prefs.json"),
179        ));
180        let deps = NotificationRelayDeps {
181            notification_service,
182            session_event_senders: Arc::new(RwLock::new(HashMap::new())),
183            session_watchers: SessionWatchers::new(),
184            config: Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default())),
185        };
186        (deps, dir)
187    }
188
189    #[tokio::test]
190    async fn ensure_notification_relay_is_idempotent_and_classifies_events() {
191        let (deps, _dir) = test_deps();
192        let (tx, mut rx) = broadcast::channel(16);
193        deps.session_event_senders
194            .write()
195            .await
196            .insert("sess-1".to_string(), tx.clone());
197
198        ensure_notification_relay(&deps, "sess-1", tx.clone());
199        // A second call for the same session must be a no-op: `try_begin_relay`
200        // only returns `true` once per session while a relay is active.
201        assert!(!deps.notification_service.try_begin_relay("sess-1"));
202
203        tx.send(AgentEvent::NeedClarification {
204            question: "Which file?".to_string(),
205            options: None,
206            tool_call_id: Some("tc-1".to_string()),
207            tool_name: None,
208            allow_custom: true,
209        })
210        .unwrap();
211
212        // The relay re-broadcasts the classified `Notification` onto the same
213        // channel, after the raw event this subscriber also echoes back to
214        // itself — loop past that echo (bounded by the outer timeout).
215        let category = tokio::time::timeout(Duration::from_secs(2), async {
216            loop {
217                if let AgentEvent::Notification { category, .. } = rx.recv().await.unwrap() {
218                    return category;
219                }
220            }
221        })
222        .await
223        .expect("relay should classify and re-broadcast within the timeout");
224        assert_eq!(category, "needs_clarification");
225    }
226
227    #[tokio::test]
228    async fn ensure_notification_relay_exits_when_broadcast_channel_closes() {
229        let (deps, _dir) = test_deps();
230        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
231
232        ensure_notification_relay(&deps, "sess-closed", tx.clone());
233        // Drop every `Sender` clone (the relay's own clone is dropped
234        // internally right after subscribing) so the channel actually closes.
235        drop(tx);
236        drop(rx);
237
238        // On `RecvError::Closed` the relay breaks its loop and calls
239        // `end_relay`, which frees the session for `try_begin_relay` again.
240        // Poll (bounded) instead of asserting immediately — the task's exit
241        // is asynchronous from this test's perspective.
242        let relay_ended = tokio::time::timeout(Duration::from_secs(2), async {
243            loop {
244                if deps.notification_service.try_begin_relay("sess-closed") {
245                    return;
246                }
247                tokio::time::sleep(Duration::from_millis(10)).await;
248            }
249        })
250        .await;
251        assert!(
252            relay_ended.is_ok(),
253            "relay task did not exit after its broadcast channel closed"
254        );
255    }
256}