Skip to main content

aion_proto/
events.rs

1//! Event-streaming wire types.
2
3use crate::convert::{
4    ProtoWorkflowId, ProtoWorkflowStatus, WireEnvelope, decode_core_value, encode_core_value,
5};
6use crate::error::WireError;
7
8/// Proto representation of an event subscription request.
9#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
10pub struct SubscriptionRequest {
11    /// Requested subscription model.
12    #[prost(oneof = "subscription_request::Subscription", tags = "1, 2, 3, 4, 5")]
13    pub subscription: Option<subscription_request::Subscription>,
14}
15
16/// Types nested under [`SubscriptionRequest`].
17pub mod subscription_request {
18    /// Proto oneof for the available subscription models.
19    #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Oneof)]
20    pub enum Subscription {
21        /// Events for a single workflow in the caller's namespace.
22        #[prost(message, tag = "1")]
23        PerWorkflow(super::PerWorkflowSubscription),
24        /// Events matching optional selectors scoped by the caller's namespace.
25        #[prost(message, tag = "2")]
26        Filtered(super::FilteredSubscription),
27        /// All events visible in the caller's namespace.
28        #[prost(message, tag = "3")]
29        Firehose(super::FirehoseSubscription),
30        /// Cluster topology/ownership deltas (WS3). Deployment-scoped, not
31        /// namespace-scoped: authorized by the caller's deploy grant, not a
32        /// namespace grant. This is a NEW ARM of the existing single
33        /// subscription frame — the socket remains one-subscription-per-socket;
34        /// a client wanting both workflow and cluster streams opens two
35        /// `/events/stream` sockets (the second socket is honest and trivially
36        /// supported, unlike a non-existent multiplexing layer).
37        #[prost(message, tag = "4")]
38        Cluster(super::ClusterSubscription),
39        /// Agent-observability transcript for one `(workflow, activity, attempt)`
40        /// (NOI-5b). Namespace-scoped exactly like [`Self::PerWorkflow`] — the
41        /// transcript belongs to the workflow the activity runs under and is
42        /// authorized by the caller's namespace grant, never the deploy grant.
43        /// Like the other arms it is a NEW ARM of the single subscription frame;
44        /// a client wanting both a workflow stream and a transcript opens two
45        /// `/events/stream` sockets.
46        #[prost(message, tag = "5")]
47        Transcript(super::TranscriptSubscription),
48    }
49}
50
51/// Subscribe to the cluster topology/ownership delta stream (WS3).
52///
53/// Carries an `after_seq` resume cursor that suppresses the in-flight broadcast
54/// backlog on reconnect (cluster history is non-durable; a long disconnect
55/// surfaces `cluster_lagged` and the client re-requests a snapshot). Unlike the
56/// workflow subscriptions this carries no namespace — cluster topology is
57/// deployment-scoped.
58#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
59pub struct ClusterSubscription {
60    /// Highest `cluster_seq` the client has already applied; the server drops
61    /// buffered deltas with `cluster_seq <= after_seq` so a reconnect does not
62    /// re-deliver them. `0` (the default) requests the full in-flight backlog.
63    #[prost(uint64, tag = "1")]
64    pub after_seq: u64,
65}
66
67/// Server -> client frame wrapping a single [`aion_core::ClusterEvent`] on the
68/// cluster channel.
69///
70/// Mirrors [`StreamedEvent`] for the cluster path: the inner `aion-core` type is
71/// the only thing that crosses the ts-rs boundary; this outer envelope is
72/// hand-decoded on the TS side (same contract as the workflow path).
73#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct StreamedClusterEvent {
75    /// Frame discriminator pinned to `"cluster_event"` so the ops console's
76    /// hand-written frame parser can branch a cluster delta apart from a
77    /// `cluster_snapshot` priming reply or an `{"error": ...}` terminal frame.
78    pub kind: String,
79    /// The cluster delta.
80    pub event: aion_core::ClusterEvent,
81}
82
83impl StreamedClusterEvent {
84    /// Frame discriminator value for a live cluster delta.
85    pub const KIND: &'static str = "cluster_event";
86
87    /// Wrap a cluster event in its server->client frame.
88    #[must_use]
89    pub fn new(event: aion_core::ClusterEvent) -> Self {
90        Self {
91            kind: Self::KIND.to_owned(),
92            event,
93        }
94    }
95}
96
97/// Server -> client priming frame carrying the calm-state [`aion_core::ClusterSnapshot`].
98///
99/// Sent once at the head of a cluster subscription before any live delta so the
100/// ops console can render the "all clear" baseline (ADR-019) and apply only deltas
101/// with `cluster_seq > snapshot.as_of_seq`.
102#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
103pub struct StreamedClusterSnapshot {
104    /// Frame discriminator pinned to `"cluster_snapshot"`.
105    pub kind: String,
106    /// The calm-state baseline.
107    pub snapshot: aion_core::ClusterSnapshot,
108}
109
110impl StreamedClusterSnapshot {
111    /// Frame discriminator value for the priming snapshot.
112    pub const KIND: &'static str = "cluster_snapshot";
113
114    /// Wrap a snapshot in its server->client priming frame.
115    #[must_use]
116    pub fn new(snapshot: aion_core::ClusterSnapshot) -> Self {
117        Self {
118            kind: Self::KIND.to_owned(),
119            snapshot,
120        }
121    }
122}
123
124/// Subscribe to the agent-observability transcript for one
125/// `(workflow, activity, attempt)` (NOI-5b).
126///
127/// Namespace-scoped: the transcript belongs to the workflow the activity runs
128/// under, so this carries the same `namespace` + `workflow_id` the per-workflow
129/// event subscription does, plus the `activity_id`/`attempt` axes that pin the
130/// exact `O`-keyspace stream. The optional `after_seq` resume cursor is the
131/// highest `store_seq` the client has already applied; the server replays the
132/// durable `O` tail with `store_seq > after_seq` then splices onto the live
133/// broadcast with no gap and no duplicate (the same splice contract the workflow
134/// path's `resume_from_seq` uses, but keyed on the commit-allocated `store_seq`).
135#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
136pub struct TranscriptSubscription {
137    /// Caller namespace used for adapter-boundary authorisation (the workflow's
138    /// namespace, identical to the per-workflow event subscription).
139    #[prost(string, tag = "1")]
140    pub namespace: String,
141    /// Workflow whose activity transcript is requested.
142    #[prost(message, optional, tag = "2")]
143    pub workflow_id: Option<ProtoWorkflowId>,
144    /// Activity within the workflow whose transcript is requested.
145    #[prost(message, optional, tag = "3")]
146    pub activity_id: Option<crate::convert::ProtoActivityId>,
147    /// Attempt number — the third stream axis. Two attempts of one activity are
148    /// DISTINCT transcript streams.
149    #[prost(uint32, tag = "4")]
150    pub attempt: u32,
151    /// Highest `store_seq` already applied by the client; the server suppresses
152    /// durable records and live deltas with `store_seq <= after_seq` so a
153    /// reconnect does not re-deliver them. Absent (`None`) = a fresh subscriber
154    /// that has applied nothing and must see the full durable transcript
155    /// including `store_seq == 0`.
156    #[prost(uint64, optional, tag = "5")]
157    pub after_seq: Option<u64>,
158}
159
160/// Server -> client frame wrapping a single [`aion_core::ActivityEvent`] on the
161/// agent-observability transcript channel (NOI-5b).
162///
163/// Mirrors [`StreamedClusterEvent`] for the transcript path: the inner
164/// `aion-core` type is the only thing that crosses the ts-rs boundary; this
165/// outer envelope is hand-decoded on the TS side. A persisted event carries its
166/// commit-allocated `store_seq`; an ephemeral token delta carries `store_seq:
167/// None` and is forwarded live but never replayed.
168#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
169pub struct StreamedActivityEvent {
170    /// Frame discriminator pinned to `"activity_event"` so the ops console's
171    /// hand-written frame parser can branch a transcript event apart from an
172    /// `{"error": ...}` terminal frame.
173    pub kind: String,
174    /// The transcript event.
175    pub event: aion_core::ActivityEvent,
176}
177
178impl StreamedActivityEvent {
179    /// Frame discriminator value for a live transcript event.
180    pub const KIND: &'static str = "activity_event";
181
182    /// Wrap an activity event in its server->client frame.
183    #[must_use]
184    pub fn new(event: aion_core::ActivityEvent) -> Self {
185        Self {
186            kind: Self::KIND.to_owned(),
187            event,
188        }
189    }
190}
191
192/// Subscribe to events for one workflow.
193#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
194pub struct PerWorkflowSubscription {
195    /// Caller namespace used for adapter-boundary authorisation.
196    #[prost(string, tag = "1")]
197    pub namespace: String,
198    /// Workflow whose events are requested.
199    #[prost(message, optional, tag = "2")]
200    pub workflow_id: Option<ProtoWorkflowId>,
201    /// First per-workflow sequence number the caller wants — not the last seq
202    /// already seen. When present, the server replays recorded history events
203    /// with seq >= `resume_from_seq` in order, then splices into the live
204    /// stream with no gaps and no duplicates. Sequence numbers start at 1; 0
205    /// is rejected as `invalid_input`. Absent = live tail only (current
206    /// behaviour). `resume_from_seq` = 1 replays the full history.
207    ///
208    /// Only per-workflow subscriptions carry a resume cursor: per-workflow
209    /// seq is the only ordering that exists, so [`FilteredSubscription`] and
210    /// [`FirehoseSubscription`] are live-only by design.
211    ///
212    /// RESERVED compaction signal (documentation-only, no code yet): a cursor
213    /// older than the earliest retained event yields `not_found` with
214    /// `error_type` `"HistoryCompacted"`; callers restart with a fresh
215    /// subscription.
216    #[prost(uint64, optional, tag = "3")]
217    pub resume_from_seq: Option<u64>,
218}
219
220/// Subscribe to events selected by optional workflow metadata.
221///
222/// Filtered streams carry NO resume cursor and are live-only by design:
223/// per-workflow seq is the only ordering that exists, so resumption is not
224/// representable here. Disconnection after at least one delivered event
225/// surfaces Unavailable client-side — never a silent gapped reattach.
226#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
227pub struct FilteredSubscription {
228    /// Caller namespace used for adapter-boundary authorisation.
229    #[prost(string, tag = "1")]
230    pub namespace: String,
231    /// Optional workflow type selector.
232    #[prost(string, optional, tag = "2")]
233    pub workflow_type: Option<String>,
234    /// Optional workflow status selector.
235    #[prost(enumeration = "ProtoWorkflowStatus", optional, tag = "3")]
236    pub status: Option<i32>,
237    /// Optional namespace selector distinct from the caller namespace.
238    #[prost(string, optional, tag = "4")]
239    pub namespace_selector: Option<String>,
240}
241
242/// Subscribe to every event visible in the caller's namespace.
243///
244/// Firehose streams carry NO resume cursor and are live-only by design:
245/// per-workflow seq is the only ordering that exists, so resumption is not
246/// representable here. Disconnection after at least one delivered event
247/// surfaces Unavailable client-side — never a silent gapped reattach.
248#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
249pub struct FirehoseSubscription {
250    /// Caller namespace used for adapter-boundary authorisation.
251    #[prost(string, tag = "1")]
252    pub namespace: String,
253}
254
255/// Streamed event frame carrying an unmodified aion-core `Event` in a wire envelope.
256#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
257pub struct StreamedEvent {
258    /// Namespace that owns the event.
259    #[prost(string, tag = "1")]
260    pub namespace: String,
261    /// Serde-encoded aion-core `Event` envelope.
262    #[prost(message, optional, tag = "2")]
263    pub event: Option<WireEnvelope>,
264}
265
266impl StreamedEvent {
267    /// Serializes an aion-core event into a streamed event frame.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`WireError`] with code `backend` if the event cannot be
272    /// serialized into the shared core-value envelope.
273    pub fn encode(
274        namespace: impl Into<String>,
275        request_id: Option<String>,
276        event: &aion_core::Event,
277    ) -> Result<Self, WireError> {
278        let namespace = namespace.into();
279        let event = encode_core_value(namespace.clone(), request_id, event)?;
280        Ok(Self {
281            namespace,
282            event: Some(event),
283        })
284    }
285
286    /// Decodes the enclosed aion-core event after checking namespace consistency.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`WireError`] with code `backend` if the frame is missing its
291    /// event envelope, if the frame namespace differs from the envelope
292    /// namespace, or if the core event cannot be decoded.
293    pub fn decode_event(&self) -> Result<aion_core::Event, WireError> {
294        let event = self
295            .event
296            .as_ref()
297            .ok_or_else(|| WireError::backend("streamed event envelope is missing"))?;
298        if event.namespace != self.namespace {
299            return Err(WireError::backend("streamed event namespace mismatch"));
300        }
301        decode_core_value(event)
302    }
303}
304
305/// Serializes an aion-core event into a streamed event frame.
306///
307/// # Errors
308///
309/// Returns [`WireError`] with code `backend` if the event cannot be serialized.
310pub fn encode_streamed_event(
311    namespace: impl Into<String>,
312    request_id: Option<String>,
313    event: &aion_core::Event,
314) -> Result<StreamedEvent, WireError> {
315    StreamedEvent::encode(namespace, request_id, event)
316}
317
318#[cfg(test)]
319mod tests {
320    use chrono::{DateTime, Utc};
321    use prost::Message;
322    use serde_json::json;
323
324    use super::{
325        FilteredSubscription, FirehoseSubscription, PerWorkflowSubscription, StreamedEvent,
326        SubscriptionRequest, TranscriptSubscription, encode_streamed_event, subscription_request,
327    };
328    use crate::convert::{ProtoActivityId, ProtoWorkflowId, ProtoWorkflowStatus, WireEnvelope};
329    use crate::error::WireError;
330
331    fn workflow_id() -> aion_core::WorkflowId {
332        aion_core::WorkflowId::new(uuid::Uuid::nil())
333    }
334
335    fn recorded_at() -> Result<DateTime<Utc>, chrono::ParseError> {
336        Ok(DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")?.with_timezone(&Utc))
337    }
338
339    fn event_envelope() -> Result<aion_core::EventEnvelope, chrono::ParseError> {
340        Ok(aion_core::EventEnvelope {
341            seq: 1,
342            recorded_at: recorded_at()?,
343            workflow_id: workflow_id(),
344        })
345    }
346
347    #[test]
348    fn subscription_request_round_trips_all_variants() -> Result<(), Box<dyn std::error::Error>> {
349        let requests = [
350            SubscriptionRequest {
351                subscription: Some(subscription_request::Subscription::PerWorkflow(
352                    PerWorkflowSubscription {
353                        namespace: String::from("tenant-a"),
354                        workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
355                        resume_from_seq: None,
356                    },
357                )),
358            },
359            SubscriptionRequest {
360                subscription: Some(subscription_request::Subscription::PerWorkflow(
361                    PerWorkflowSubscription {
362                        namespace: String::from("tenant-a"),
363                        workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
364                        resume_from_seq: Some(42),
365                    },
366                )),
367            },
368            SubscriptionRequest {
369                subscription: Some(subscription_request::Subscription::Filtered(
370                    FilteredSubscription {
371                        namespace: String::from("tenant-a"),
372                        workflow_type: Some(String::from("checkout")),
373                        status: Some(ProtoWorkflowStatus::Running as i32),
374                        namespace_selector: Some(String::from("tenant-a")),
375                    },
376                )),
377            },
378            SubscriptionRequest {
379                subscription: Some(subscription_request::Subscription::Filtered(
380                    FilteredSubscription {
381                        namespace: String::from("tenant-a"),
382                        workflow_type: None,
383                        status: None,
384                        namespace_selector: None,
385                    },
386                )),
387            },
388            SubscriptionRequest {
389                subscription: Some(subscription_request::Subscription::Firehose(
390                    FirehoseSubscription {
391                        namespace: String::from("tenant-a"),
392                    },
393                )),
394            },
395            SubscriptionRequest {
396                subscription: Some(subscription_request::Subscription::Transcript(
397                    TranscriptSubscription {
398                        namespace: String::from("tenant-a"),
399                        workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
400                        activity_id: Some(ProtoActivityId {
401                            sequence_position: 3,
402                        }),
403                        attempt: 1,
404                        after_seq: Some(9),
405                    },
406                )),
407            },
408            SubscriptionRequest {
409                subscription: Some(subscription_request::Subscription::Transcript(
410                    TranscriptSubscription {
411                        namespace: String::from("tenant-a"),
412                        workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
413                        activity_id: Some(ProtoActivityId {
414                            sequence_position: 3,
415                        }),
416                        attempt: 0,
417                        after_seq: None,
418                    },
419                )),
420            },
421        ];
422
423        for request in requests {
424            let json = serde_json::to_vec(&request)?;
425            let from_json: SubscriptionRequest = serde_json::from_slice(&json)?;
426            assert_eq!(from_json, request);
427
428            let bytes = request.encode_to_vec();
429            let from_proto = SubscriptionRequest::decode(bytes.as_slice())?;
430            assert_eq!(from_proto, request);
431        }
432
433        Ok(())
434    }
435
436    #[test]
437    fn per_workflow_resume_cursor_round_trips_prost() -> Result<(), Box<dyn std::error::Error>> {
438        let with_cursor = PerWorkflowSubscription {
439            namespace: String::from("tenant-a"),
440            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
441            resume_from_seq: Some(7),
442        };
443        let decoded = PerWorkflowSubscription::decode(with_cursor.encode_to_vec().as_slice())?;
444        assert_eq!(decoded, with_cursor);
445        assert_eq!(decoded.resume_from_seq, Some(7));
446
447        let without_cursor = PerWorkflowSubscription {
448            namespace: String::from("tenant-a"),
449            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
450            resume_from_seq: None,
451        };
452        let decoded = PerWorkflowSubscription::decode(without_cursor.encode_to_vec().as_slice())?;
453        assert_eq!(decoded, without_cursor);
454        assert_eq!(decoded.resume_from_seq, None);
455
456        Ok(())
457    }
458
459    #[test]
460    fn per_workflow_resume_cursor_json_shape_is_pinned() -> Result<(), Box<dyn std::error::Error>> {
461        let with_cursor = PerWorkflowSubscription {
462            namespace: String::from("tenant-a"),
463            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
464            resume_from_seq: Some(7),
465        };
466        let value = serde_json::to_value(&with_cursor)?;
467        assert_eq!(
468            value,
469            json!({
470                "namespace": "tenant-a",
471                "workflow_id": { "uuid": "00000000-0000-0000-0000-000000000000" },
472                "resume_from_seq": 7,
473            })
474        );
475        let from_json: PerWorkflowSubscription = serde_json::from_value(value)?;
476        assert_eq!(from_json, with_cursor);
477
478        let without_cursor = PerWorkflowSubscription {
479            namespace: String::from("tenant-a"),
480            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
481            resume_from_seq: None,
482        };
483        let value = serde_json::to_value(&without_cursor)?;
484        assert_eq!(
485            value,
486            json!({
487                "namespace": "tenant-a",
488                "workflow_id": { "uuid": "00000000-0000-0000-0000-000000000000" },
489                "resume_from_seq": null,
490            })
491        );
492        let from_json: PerWorkflowSubscription = serde_json::from_value(value)?;
493        assert_eq!(from_json, without_cursor);
494
495        Ok(())
496    }
497
498    #[test]
499    fn subscription_request_without_resume_field_decodes_to_none()
500    -> Result<(), Box<dyn std::error::Error>> {
501        let request: SubscriptionRequest = serde_json::from_value(json!({
502            "subscription": {
503                "PerWorkflow": {
504                    "namespace": "tenant-a",
505                    "workflow_id": { "uuid": "00000000-0000-0000-0000-000000000000" },
506                }
507            }
508        }))?;
509
510        let Some(subscription_request::Subscription::PerWorkflow(per_workflow)) =
511            request.subscription
512        else {
513            return Err(Box::from("expected a per-workflow subscription"));
514        };
515        assert_eq!(per_workflow.namespace, "tenant-a");
516        assert_eq!(
517            per_workflow.workflow_id,
518            Some(ProtoWorkflowId::from(workflow_id()))
519        );
520        assert_eq!(per_workflow.resume_from_seq, None);
521
522        Ok(())
523    }
524
525    #[test]
526    fn streamed_event_round_trips_core_event() -> Result<(), Box<dyn std::error::Error>> {
527        let event = aion_core::Event::WorkflowStarted {
528            envelope: event_envelope()?,
529            workflow_type: String::from("checkout"),
530            input: aion_core::Payload::from_json(&json!({ "cart": ["sku-1"] }))?,
531            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
532            parent_run_id: None,
533            package_version: aion_core::PackageVersion::new("a".repeat(64)),
534        };
535
536        let frame = encode_streamed_event("tenant-a", Some(String::from("request-1")), &event)?;
537        assert_eq!(frame.namespace, "tenant-a");
538        let envelope = frame
539            .event
540            .as_ref()
541            .ok_or_else(|| WireError::backend("test streamed event envelope is missing"))?;
542        assert_eq!(envelope.namespace, "tenant-a");
543        assert_eq!(envelope.request_id.as_deref(), Some("request-1"));
544
545        let decoded = frame.decode_event()?;
546        assert_eq!(decoded, event);
547        Ok(())
548    }
549
550    #[test]
551    fn streamed_event_rejects_namespace_mismatch() {
552        let frame = StreamedEvent {
553            namespace: String::from("tenant-a"),
554            event: Some(WireEnvelope {
555                namespace: String::from("tenant-b"),
556                request_id: None,
557                payload: None,
558            }),
559        };
560
561        assert_eq!(
562            frame.decode_event(),
563            Err(WireError::backend("streamed event namespace mismatch"))
564        );
565    }
566
567    #[test]
568    fn streamed_event_rejects_missing_envelope() {
569        let frame = StreamedEvent {
570            namespace: String::from("tenant-a"),
571            event: None,
572        };
573
574        assert_eq!(
575            frame.decode_event(),
576            Err(WireError::backend("streamed event envelope is missing"))
577        );
578    }
579}