Skip to main content

aion_proto/
events.rs

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