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}