1use crate::convert::{
4 ProtoRunId, ProtoWorkflowId, ProtoWorkflowStatus, WireEnvelope, decode_core_value,
5 encode_core_value,
6};
7use crate::error::WireError;
8
9#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
11pub struct SubscriptionRequest {
12 #[prost(oneof = "subscription_request::Subscription", tags = "1, 2, 3, 4, 5")]
14 pub subscription: Option<subscription_request::Subscription>,
15}
16
17pub mod subscription_request {
19 #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Oneof)]
21 pub enum Subscription {
22 #[prost(message, tag = "1")]
24 PerWorkflow(super::PerWorkflowSubscription),
25 #[prost(message, tag = "2")]
27 Filtered(super::FilteredSubscription),
28 #[prost(message, tag = "3")]
30 Firehose(super::FirehoseSubscription),
31 #[prost(message, tag = "4")]
39 Cluster(super::ClusterSubscription),
40 #[prost(message, tag = "5")]
48 Transcript(super::TranscriptSubscription),
49 }
50}
51
52#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
60pub struct ClusterSubscription {
61 #[prost(uint64, tag = "1")]
65 pub after_seq: u64,
66}
67
68#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
75pub struct StreamedClusterEvent {
76 pub kind: String,
80 pub event: aion_core::ClusterEvent,
82}
83
84impl StreamedClusterEvent {
85 pub const KIND: &'static str = "cluster_event";
87
88 #[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#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104pub struct StreamedClusterSnapshot {
105 pub kind: String,
107 pub snapshot: aion_core::ClusterSnapshot,
109}
110
111impl StreamedClusterSnapshot {
112 pub const KIND: &'static str = "cluster_snapshot";
114
115 #[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#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
137pub struct TranscriptSubscription {
138 #[prost(string, tag = "1")]
141 pub namespace: String,
142 #[prost(message, optional, tag = "2")]
144 pub workflow_id: Option<ProtoWorkflowId>,
145 #[prost(message, optional, tag = "6")]
155 pub run_id: Option<ProtoRunId>,
156 #[prost(message, optional, tag = "3")]
158 pub activity_id: Option<crate::convert::ProtoActivityId>,
159 #[prost(uint32, tag = "4")]
162 pub attempt: u32,
163 #[prost(uint64, optional, tag = "5")]
169 pub after_seq: Option<u64>,
170}
171
172#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
181pub struct StreamedActivityEvent {
182 pub kind: String,
186 pub event: aion_core::ActivityEvent,
188}
189
190impl StreamedActivityEvent {
191 pub const KIND: &'static str = "activity_event";
193
194 #[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#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
206pub struct PerWorkflowSubscription {
207 #[prost(string, tag = "1")]
209 pub namespace: String,
210 #[prost(message, optional, tag = "2")]
212 pub workflow_id: Option<ProtoWorkflowId>,
213 #[prost(uint64, optional, tag = "3")]
229 pub resume_from_seq: Option<u64>,
230}
231
232#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
239pub struct FilteredSubscription {
240 #[prost(string, tag = "1")]
242 pub namespace: String,
243 #[prost(string, optional, tag = "2")]
245 pub workflow_type: Option<String>,
246 #[prost(enumeration = "ProtoWorkflowStatus", optional, tag = "3")]
248 pub status: Option<i32>,
249 #[prost(string, optional, tag = "4")]
251 pub namespace_selector: Option<String>,
252}
253
254#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
261pub struct FirehoseSubscription {
262 #[prost(string, tag = "1")]
264 pub namespace: String,
265}
266
267#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
269pub struct StreamedEvent {
270 #[prost(string, tag = "1")]
272 pub namespace: String,
273 #[prost(message, optional, tag = "2")]
275 pub event: Option<WireEnvelope>,
276}
277
278impl StreamedEvent {
279 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 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
317pub 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}