1use std::sync::Arc;
15
16use thiserror::Error;
17use tokio::sync::broadcast;
18use tokio::task::JoinHandle;
19use tracing::warn;
20
21use super::agent::AgentRuntime;
22use super::event::AgentEvent;
23use super::event_store::{RuntimeEventStore, RuntimeEventStoreError};
24use super::run::RunId;
25use super::stream::{BoxRuntimeEventStream, RuntimeEventEnvelope, RuntimeRoom, RuntimeStreamError};
26use super::stream_adapter::RuntimeStreamAdapter;
27
28pub struct RuntimeSubscription {
35 pub replay: Vec<RuntimeEventEnvelope>,
37 pub live: BoxRuntimeEventStream,
39}
40
41#[derive(Debug, Error)]
43pub enum RuntimeSubscriptionError {
44 #[error(transparent)]
46 Replay(#[from] RuntimeEventStoreError),
47 #[error(transparent)]
49 Live(#[from] RuntimeStreamError),
50}
51
52#[derive(Debug, Error)]
54pub enum RuntimeEventBridgeError {
55 #[error("runtime event bridge task was cancelled")]
57 Cancelled,
58 #[error("runtime event bridge task panicked: {message}")]
60 TaskPanic {
61 message: String,
63 },
64}
65
66pub struct RuntimeSubscriptionHub {
72 event_store: Arc<dyn RuntimeEventStore>,
73 stream_adapter: Arc<dyn RuntimeStreamAdapter>,
74}
75
76impl RuntimeSubscriptionHub {
77 #[must_use]
79 pub fn new(
80 event_store: Arc<dyn RuntimeEventStore>,
81 stream_adapter: Arc<dyn RuntimeStreamAdapter>,
82 ) -> Self {
83 Self {
84 event_store,
85 stream_adapter,
86 }
87 }
88
89 pub async fn subscribe_run(
102 &self,
103 run_id: RunId,
104 after_seq: Option<u64>,
105 limit: usize,
106 ) -> Result<RuntimeSubscription, RuntimeSubscriptionError> {
107 let live = self
108 .stream_adapter
109 .subscribe(RuntimeRoom::Run(run_id))
110 .await
111 .map_err(RuntimeSubscriptionError::Live)?;
112 let replay = self
113 .event_store
114 .list_after(run_id, after_seq, limit)
115 .await
116 .map_err(RuntimeSubscriptionError::Replay)?;
117 Ok(RuntimeSubscription { replay, live })
118 }
119}
120
121pub struct RuntimeEventBridge {
129 runtime: Arc<AgentRuntime>,
130 event_store: Arc<dyn RuntimeEventStore>,
131 stream_adapter: Arc<dyn RuntimeStreamAdapter>,
132}
133
134impl RuntimeEventBridge {
135 #[must_use]
137 pub fn new(
138 runtime: Arc<AgentRuntime>,
139 event_store: Arc<dyn RuntimeEventStore>,
140 stream_adapter: Arc<dyn RuntimeStreamAdapter>,
141 ) -> Self {
142 Self {
143 runtime,
144 event_store,
145 stream_adapter,
146 }
147 }
148
149 #[must_use]
154 pub fn spawn(self: Arc<Self>) -> RuntimeEventBridgeHandle {
155 let rx = self.runtime.subscribe();
156 let event_store = self.event_store.clone();
157 let stream_adapter = self.stream_adapter.clone();
158 let task = tokio::spawn(forward_events(rx, event_store, stream_adapter));
159 RuntimeEventBridgeHandle { task }
160 }
161}
162
163async fn forward_events(
168 mut rx: broadcast::Receiver<AgentEvent>,
169 event_store: Arc<dyn RuntimeEventStore>,
170 stream_adapter: Arc<dyn RuntimeStreamAdapter>,
171) {
172 loop {
173 match rx.recv().await {
174 Ok(event) => {
175 let run_id = event.run_id();
176 match event_store.append(event).await {
177 Ok(envelope) => {
178 if let Err(error) = stream_adapter
179 .publish(RuntimeRoom::Run(run_id), envelope.clone())
180 .await
181 {
182 warn!(%error, "runtime event bridge publish to run room failed");
183 }
184 if let Some(session_id) = envelope.session_id
185 && let Err(error) = stream_adapter
186 .publish(RuntimeRoom::Session(session_id), envelope)
187 .await
188 {
189 warn!(%error, "runtime event bridge publish to session room failed");
190 }
191 }
192 Err(error) => {
193 warn!(
194 %error,
195 "runtime event bridge append failed; skipping live publish"
196 );
197 }
198 }
199 }
200 Err(broadcast::error::RecvError::Closed) => break,
201 Err(broadcast::error::RecvError::Lagged(skipped)) => {
202 warn!(
203 skipped,
204 "runtime event bridge lagged behind runtime broadcast"
205 );
206 }
207 }
208 }
209}
210
211pub struct RuntimeEventBridgeHandle {
217 task: JoinHandle<()>,
218}
219
220impl RuntimeEventBridgeHandle {
221 pub fn abort(&self) {
226 self.task.abort();
227 }
228
229 #[must_use]
231 pub fn is_finished(&self) -> bool {
232 self.task.is_finished()
233 }
234
235 pub async fn join(mut self) -> Result<(), RuntimeEventBridgeError> {
243 match (&mut self.task).await {
244 Ok(()) => Ok(()),
245 Err(err) if err.is_cancelled() => Err(RuntimeEventBridgeError::Cancelled),
246 Err(_) => Err(RuntimeEventBridgeError::TaskPanic {
247 message: "bridge task exited unexpectedly".to_owned(),
248 }),
249 }
250 }
251}
252
253impl Drop for RuntimeEventBridgeHandle {
254 fn drop(&mut self) {
255 self.task.abort();
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 #![allow(clippy::unwrap_used, clippy::expect_used)]
262
263 use std::sync::atomic::{AtomicUsize, Ordering};
264 use std::time::Duration;
265
266 use chrono::Utc;
267 use futures_util::StreamExt;
268 use uuid::Uuid;
269
270 use super::*;
271 use crate::event::{RunCompleted, RunStarted};
272 use crate::event_store::{FailingRuntimeEventStore, MemoryRuntimeEventStore};
273 use crate::stream::RuntimeEventId;
274 use crate::stream_adapter::MemoryRuntimeStreamAdapter;
275 use behest_provider::{FinishReason, ModelName, ProviderId};
276
277 fn started(run: RunId, session_id: Uuid) -> AgentEvent {
278 AgentEvent::RunStarted(RunStarted {
279 run_id: run,
280 session_id,
281 provider: ProviderId::new("acme"),
282 model: ModelName::new("gpt-test"),
283 timestamp: Utc::now(),
284 })
285 }
286
287 fn terminal(run: RunId) -> AgentEvent {
288 AgentEvent::RunCompleted(RunCompleted {
289 run_id: run,
290 finish_reason: FinishReason::Stop,
291 iterations: 1,
292 timestamp: Utc::now(),
293 })
294 }
295
296 fn envelope(
297 run: RunId,
298 seq: u64,
299 session_id: Option<Uuid>,
300 event: AgentEvent,
301 ) -> RuntimeEventEnvelope {
302 RuntimeEventEnvelope {
303 event_id: RuntimeEventId::new(),
304 seq,
305 run_id: run,
306 session_id,
307 event,
308 emitted_at: Utc::now(),
309 }
310 }
311
312 #[tokio::test]
313 async fn hub_returns_replay_and_live() {
314 let store: Arc<dyn RuntimeEventStore> = Arc::new(MemoryRuntimeEventStore::new());
315 let adapter: Arc<dyn RuntimeStreamAdapter> = Arc::new(MemoryRuntimeStreamAdapter::new());
316
317 let run = RunId::new();
318 let sid = Uuid::now_v7();
319 store.append(started(run, sid)).await.unwrap();
320 store.append(terminal(run)).await.unwrap();
321
322 let hub = RuntimeSubscriptionHub::new(store.clone(), adapter.clone());
323 let mut sub = hub.subscribe_run(run, None, 10).await.unwrap();
324 assert_eq!(sub.replay.len(), 2);
325
326 adapter
328 .publish(
329 RuntimeRoom::Run(run),
330 envelope(run, 3, Some(sid), terminal(run)),
331 )
332 .await
333 .unwrap();
334 let received = tokio::time::timeout(Duration::from_secs(1), sub.live.next())
335 .await
336 .expect("timed out waiting for live event")
337 .expect("stream ended")
338 .expect("lagged");
339 assert_eq!(received.seq, 3);
340 }
341
342 #[tokio::test]
343 async fn bridge_persists_and_publishes_events() {
344 let (tx, rx) = broadcast::channel::<AgentEvent>(16);
345 let store: Arc<dyn RuntimeEventStore> = Arc::new(MemoryRuntimeEventStore::new());
346 let adapter: Arc<dyn RuntimeStreamAdapter> = Arc::new(MemoryRuntimeStreamAdapter::new());
347
348 let run = RunId::new();
349 let sid = Uuid::now_v7();
350 let mut live = adapter.subscribe(RuntimeRoom::Run(run)).await.unwrap();
351
352 let _handle = tokio::spawn(forward_events(rx, store.clone(), adapter.clone()));
353 tx.send(started(run, sid)).unwrap();
354
355 let received = tokio::time::timeout(Duration::from_secs(1), live.next())
356 .await
357 .expect("timed out waiting for live event")
358 .expect("stream ended")
359 .expect("lagged");
360 assert_eq!(received.seq, 1);
361 assert_eq!(received.session_id, Some(sid));
362
363 let replayed = store.list_after(run, None, 10).await.unwrap();
364 assert_eq!(replayed.len(), 1);
365 assert_eq!(replayed[0].seq, 1);
366 }
367
368 #[derive(Default)]
369 struct CountingRuntimeStreamAdapter {
370 publishes: AtomicUsize,
371 }
372
373 #[async_trait::async_trait]
374 impl RuntimeStreamAdapter for CountingRuntimeStreamAdapter {
375 async fn publish(
376 &self,
377 _room: RuntimeRoom,
378 _event: RuntimeEventEnvelope,
379 ) -> Result<(), RuntimeStreamError> {
380 self.publishes.fetch_add(1, Ordering::Relaxed);
381 Ok(())
382 }
383
384 async fn subscribe(
385 &self,
386 _room: RuntimeRoom,
387 ) -> Result<BoxRuntimeEventStream, RuntimeStreamError> {
388 Err(RuntimeStreamError::Subscribe {
389 message: "counting adapter does not support subscribe".to_owned(),
390 })
391 }
392 }
393
394 #[tokio::test]
395 async fn append_failure_does_not_publish_live_event() {
396 let (tx, rx) = broadcast::channel::<AgentEvent>(16);
397 let store: Arc<dyn RuntimeEventStore> = Arc::new(FailingRuntimeEventStore::new());
398 let adapter = Arc::new(CountingRuntimeStreamAdapter::default());
399
400 let run = RunId::new();
401 let sid = Uuid::now_v7();
402 let _handle = tokio::spawn(forward_events(rx, store.clone(), adapter.clone()));
403 tx.send(started(run, sid)).unwrap();
404
405 tokio::time::sleep(Duration::from_millis(50)).await;
406 assert_eq!(
407 adapter.publishes.load(Ordering::Relaxed),
408 0,
409 "no live event should be published when append fails"
410 );
411 }
412}