1use crate::convert::{
4 ProtoWorkflowId, ProtoWorkflowStatus, WireEnvelope, decode_core_value, encode_core_value,
5};
6use crate::error::WireError;
7
8#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
10pub struct SubscriptionRequest {
11 #[prost(oneof = "subscription_request::Subscription", tags = "1, 2, 3, 4, 5")]
13 pub subscription: Option<subscription_request::Subscription>,
14}
15
16pub mod subscription_request {
18 #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Oneof)]
20 pub enum Subscription {
21 #[prost(message, tag = "1")]
23 PerWorkflow(super::PerWorkflowSubscription),
24 #[prost(message, tag = "2")]
26 Filtered(super::FilteredSubscription),
27 #[prost(message, tag = "3")]
29 Firehose(super::FirehoseSubscription),
30 #[prost(message, tag = "4")]
38 Cluster(super::ClusterSubscription),
39 #[prost(message, tag = "5")]
47 Transcript(super::TranscriptSubscription),
48 }
49}
50
51#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
59pub struct ClusterSubscription {
60 #[prost(uint64, tag = "1")]
64 pub after_seq: u64,
65}
66
67#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct StreamedClusterEvent {
75 pub kind: String,
79 pub event: aion_core::ClusterEvent,
81}
82
83impl StreamedClusterEvent {
84 pub const KIND: &'static str = "cluster_event";
86
87 #[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#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
103pub struct StreamedClusterSnapshot {
104 pub kind: String,
106 pub snapshot: aion_core::ClusterSnapshot,
108}
109
110impl StreamedClusterSnapshot {
111 pub const KIND: &'static str = "cluster_snapshot";
113
114 #[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#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
136pub struct TranscriptSubscription {
137 #[prost(string, tag = "1")]
140 pub namespace: String,
141 #[prost(message, optional, tag = "2")]
143 pub workflow_id: Option<ProtoWorkflowId>,
144 #[prost(message, optional, tag = "3")]
146 pub activity_id: Option<crate::convert::ProtoActivityId>,
147 #[prost(uint32, tag = "4")]
150 pub attempt: u32,
151 #[prost(uint64, optional, tag = "5")]
157 pub after_seq: Option<u64>,
158}
159
160#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
169pub struct StreamedActivityEvent {
170 pub kind: String,
174 pub event: aion_core::ActivityEvent,
176}
177
178impl StreamedActivityEvent {
179 pub const KIND: &'static str = "activity_event";
181
182 #[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#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
194pub struct PerWorkflowSubscription {
195 #[prost(string, tag = "1")]
197 pub namespace: String,
198 #[prost(message, optional, tag = "2")]
200 pub workflow_id: Option<ProtoWorkflowId>,
201 #[prost(uint64, optional, tag = "3")]
217 pub resume_from_seq: Option<u64>,
218}
219
220#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
227pub struct FilteredSubscription {
228 #[prost(string, tag = "1")]
230 pub namespace: String,
231 #[prost(string, optional, tag = "2")]
233 pub workflow_type: Option<String>,
234 #[prost(enumeration = "ProtoWorkflowStatus", optional, tag = "3")]
236 pub status: Option<i32>,
237 #[prost(string, optional, tag = "4")]
239 pub namespace_selector: Option<String>,
240}
241
242#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
249pub struct FirehoseSubscription {
250 #[prost(string, tag = "1")]
252 pub namespace: String,
253}
254
255#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
257pub struct StreamedEvent {
258 #[prost(string, tag = "1")]
260 pub namespace: String,
261 #[prost(message, optional, tag = "2")]
263 pub event: Option<WireEnvelope>,
264}
265
266impl StreamedEvent {
267 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 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
305pub 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}