Skip to main content

behest_runtime/
stream.rs

1//! Transport-neutral runtime stream primitives.
2//!
3//! This module defines the envelope, routing key, and stream type used by the
4//! runtime stream abstraction. The design is inspired by the Socket.IO Adapter
5//! rules (room-based fanout, per-room ordering, best-effort live delivery), but
6//! it is **not** a Socket.IO implementation and carries no transport coupling.
7//!
8//! - [`RuntimeStreamAdapter`](super::stream_adapter::RuntimeStreamAdapter)
9//!   performs best-effort live fanout only.
10//! - [`RuntimeEventStore`](super::event_store::RuntimeEventStore) is the
11//!   authoritative replay source.
12//! - Delivery is at-least-once; consumers deduplicate via [`RuntimeEventId`]
13//!   or [`RuntimeEventEnvelope::seq`].
14//! - Authorization is **not** modeled here; rooms express fanout routing only
15//!   and must be gated by the transport/service layer before subscription.
16
17use std::fmt;
18use std::pin::Pin;
19
20use chrono::{DateTime, Utc};
21use futures_util::Stream;
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24use uuid::Uuid;
25
26use super::run::RunId;
27use behest_event::AgentEvent;
28use behest_provider::ProviderId;
29
30/// Globally unique identifier for a single runtime event.
31///
32/// Use [`RuntimeEventId::new`] to mint a fresh id (UUIDv7, time-ordered). It is
33/// intentionally a newtype so callers cannot accidentally pass an arbitrary
34/// [`Uuid`] where an event identity is required.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub struct RuntimeEventId(Uuid);
37
38impl RuntimeEventId {
39    /// Mints a new time-ordered event id.
40    #[must_use]
41    pub fn new() -> Self {
42        Self(Uuid::now_v7())
43    }
44
45    /// Wraps an existing [`Uuid`] as a [`RuntimeEventId`].
46    #[must_use]
47    pub fn from_uuid(uuid: Uuid) -> Self {
48        Self(uuid)
49    }
50
51    /// Views the underlying [`Uuid`].
52    #[must_use]
53    pub fn as_uuid(&self) -> Uuid {
54        self.0
55    }
56}
57
58impl Default for RuntimeEventId {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl fmt::Display for RuntimeEventId {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "{}", self.0)
67    }
68}
69
70impl From<RuntimeEventId> for Uuid {
71    fn from(value: RuntimeEventId) -> Self {
72        value.0
73    }
74}
75
76/// Envelope wrapping an [`AgentEvent`] with cross-instance routing metadata.
77///
78/// `seq` is monotonic per `run_id`; it is the unit clients use to resume a
79/// stream after a disconnect (`run_id + seq`). `event_id` is globally unique
80/// and is the canonical deduplication key when the same envelope is delivered
81/// more than once.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct RuntimeEventEnvelope {
84    /// Globally unique event identity.
85    pub event_id: RuntimeEventId,
86    /// Per-run monotonic sequence number.
87    pub seq: u64,
88    /// Run this event belongs to.
89    pub run_id: RunId,
90    /// Session this event belongs to, when known from `RunStarted`.
91    pub session_id: Option<Uuid>,
92    /// The wrapped runtime event.
93    pub event: AgentEvent,
94    /// When the envelope was emitted by the runtime bridge.
95    pub emitted_at: DateTime<Utc>,
96}
97
98impl RuntimeEventEnvelope {
99    /// Returns the run identifier carried by the wrapped event.
100    #[must_use]
101    pub fn run_id(&self) -> RunId {
102        self.event.run_id()
103    }
104
105    /// Delegates to [`AgentEvent::is_terminal`].
106    ///
107    /// True for `RunCompleted` / `RunFailed` / `RunCancelled`.
108    #[must_use]
109    pub fn is_terminal(&self) -> bool {
110        self.event.is_terminal()
111    }
112}
113
114/// Fanout routing key, inspired by a Socket.IO room but transport-neutral.
115///
116/// A room expresses **where** an event should be live-fanned-out; it carries no
117/// authorization semantics. A transport/service layer must authorize
118/// subscriptions before they reach [`RuntimeRoom`].
119#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120pub enum RuntimeRoom {
121    /// All events for a specific run.
122    Run(RunId),
123    /// All events for a specific session.
124    Session(Uuid),
125    /// All events for a specific provider.
126    Provider(ProviderId),
127}
128
129impl fmt::Display for RuntimeRoom {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            RuntimeRoom::Run(run_id) => write!(f, "run:{run_id}"),
133            RuntimeRoom::Session(session_id) => write!(f, "session:{session_id}"),
134            RuntimeRoom::Provider(provider_id) => write!(f, "provider:{provider_id}"),
135        }
136    }
137}
138
139/// Owned, type-erased stream of runtime event envelopes.
140///
141/// Adapters return this from `subscribe`; it yields `Err` on transient lag or
142/// channel closure and `Ok(envelope)` for delivered events.
143pub type BoxRuntimeEventStream =
144    Pin<Box<dyn Stream<Item = Result<RuntimeEventEnvelope, RuntimeStreamError>> + Send + 'static>>;
145
146/// Errors raised by a [`RuntimeStreamAdapter`](super::stream_adapter::RuntimeStreamAdapter).
147///
148/// The adapter only models best-effort live fanout, so failures here are
149/// recoverable: callers are expected to fall back to the event store for
150/// replay and deduplicate by `event_id`/`seq`.
151#[derive(Debug, Error)]
152pub enum RuntimeStreamError {
153    /// A live publish could not be delivered to any live consumer.
154    #[error("runtime stream publish failed: {message}")]
155    Publish {
156        /// Human-readable diagnostic.
157        message: String,
158    },
159    /// A subscriber lagged behind the live stream and skipped events.
160    ///
161    /// The adapter surface this as an error so consumers can decide whether to
162    /// tolerate the gap or reconcile from the event store.
163    #[error("runtime stream subscriber lagged, skipped {skipped} events")]
164    Lagged {
165        /// Number of events the receiver skipped.
166        skipped: u64,
167    },
168    /// A subscription could not be established.
169    #[error("runtime stream subscribe failed: {message}")]
170    Subscribe {
171        /// Human-readable diagnostic.
172        message: String,
173    },
174    /// The live stream has been closed and will yield no further events.
175    #[error("runtime stream closed")]
176    Closed,
177}
178
179#[cfg(test)]
180mod tests {
181    #![allow(clippy::unwrap_used, clippy::expect_used)]
182
183    use super::*;
184
185    #[test]
186    fn run_room_display_is_stable() {
187        let id = RunId::from_uuid(Uuid::nil());
188        assert_eq!(RuntimeRoom::Run(id).to_string(), format!("run:{id}"));
189    }
190
191    #[test]
192    fn session_room_display_is_stable() {
193        let id = Uuid::nil();
194        assert_eq!(
195            RuntimeRoom::Session(id).to_string(),
196            format!("session:{id}")
197        );
198    }
199
200    #[test]
201    fn provider_room_display_is_stable() {
202        let id = ProviderId::new("acme");
203        let expected = format!("provider:{id}");
204        assert_eq!(RuntimeRoom::Provider(id).to_string(), expected);
205    }
206}