1use std::sync::Arc;
23
24use aion_core::Event;
25use async_trait::async_trait;
26use futures::stream::BoxStream;
27use futures::{StreamExt, stream};
28
29use crate::error::ClientError;
30use crate::transport::contract::{SubscriptionAttempt, WorkflowTransport};
31
32pub struct EmbeddedWorkflowTransport {
34 engine: Arc<aion::Engine>,
35}
36
37impl EmbeddedWorkflowTransport {
38 #[must_use]
40 pub fn new(engine: Arc<aion::Engine>) -> Self {
41 Self { engine }
42 }
43
44 async fn resolve_run_id(
47 &self,
48 workflow_id: &aion_core::WorkflowId,
49 run_id: Option<aion_proto::ProtoRunId>,
50 ) -> Result<aion_core::RunId, ClientError> {
51 if let Some(run_id) = run_id {
52 return run_id.try_into().map_err(ClientError::from_wire_error);
53 }
54 let chain = self
55 .engine
56 .store()
57 .read_run_chain(workflow_id)
58 .await
59 .map_err(|error| store_error_class(&error, error.to_string()))?;
60 chain
61 .last()
62 .map(|summary| summary.run_id.clone())
63 .ok_or_else(|| ClientError::not_found(format!("workflow {workflow_id} not found")))
64 }
65}
66
67#[async_trait]
68impl WorkflowTransport for EmbeddedWorkflowTransport {
69 async fn start_workflow(
70 &self,
71 request: aion_proto::ProtoStartWorkflowRequest,
72 ) -> Result<aion_proto::ProtoStartWorkflowResponse, ClientError> {
73 let input = request
74 .input
75 .ok_or_else(|| ClientError::invalid_argument("start request input payload is missing"))
76 .and_then(|payload| {
77 aion_core::Payload::try_from(payload).map_err(ClientError::from_wire_error)
78 })?;
79 let handle = self
83 .engine
84 .start_workflow(
85 &request.workflow_type,
86 input,
87 std::collections::HashMap::new(),
88 String::from("default"),
89 )
90 .await
91 .map_err(|error| map_engine_error(&error))?;
92 Ok(aion_proto::ProtoStartWorkflowResponse {
93 workflow_id: Some(aion_proto::ProtoWorkflowId::from(
94 handle.workflow_id().clone(),
95 )),
96 run_id: Some(aion_proto::ProtoRunId::from(handle.run_id().clone())),
97 })
98 }
99
100 async fn signal(
101 &self,
102 request: aion_proto::ProtoSignalRequest,
103 ) -> Result<aion_proto::ProtoSignalResponse, ClientError> {
104 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
105 let run_id = decode_required_run_id(request.run_id)?;
106 let payload = request
107 .payload
108 .ok_or_else(|| ClientError::invalid_argument("signal request payload is missing"))
109 .and_then(|payload| {
110 aion_core::Payload::try_from(payload).map_err(ClientError::from_wire_error)
111 })?;
112 self.engine
113 .signal(&workflow_id, &run_id, request.signal_name, payload)
114 .await
115 .map_err(|error| map_engine_error(&error))?;
116 Ok(aion_proto::ProtoSignalResponse {})
117 }
118
119 async fn query(
120 &self,
121 request: aion_proto::ProtoQueryRequest,
122 ) -> Result<aion_proto::ProtoQueryResponse, ClientError> {
123 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
124 let run_id = decode_required_run_id(request.run_id)?;
125 let arguments = request.arguments.map_or_else(
128 || Ok(aion_core::Payload::json_null()),
129 |arguments| {
130 aion_core::Payload::try_from(arguments).map_err(ClientError::from_wire_error)
131 },
132 )?;
133 let payload = self
134 .engine
135 .query(&workflow_id, &run_id, request.query_name, arguments)
136 .await
137 .map_err(|error| map_engine_error(&error))?;
138 Ok(aion_proto::ProtoQueryResponse {
139 outcome: Some(aion_proto::proto_query_response::Outcome::Result(
140 aion_proto::ProtoPayload::from(payload),
141 )),
142 })
143 }
144
145 async fn cancel(
146 &self,
147 request: aion_proto::ProtoCancelRequest,
148 ) -> Result<aion_proto::ProtoCancelResponse, ClientError> {
149 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
150 let run_id = decode_required_run_id(request.run_id)?;
151 self.engine
152 .cancel(&workflow_id, &run_id, request.reason)
153 .await
154 .map_err(|error| map_engine_error(&error))?;
155 Ok(aion_proto::ProtoCancelResponse {})
156 }
157
158 async fn reopen(
159 &self,
160 request: aion_proto::ProtoReopenRequest,
161 ) -> Result<aion_proto::ProtoReopenResponse, ClientError> {
162 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
163 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
164 let handle = self
165 .engine
166 .reopen_workflow(&workflow_id, &run_id)
167 .await
168 .map_err(|error| map_engine_error(&error))?;
169 Ok(aion_proto::ProtoReopenResponse {
170 run_id: Some(handle.run_id().clone().into()),
171 status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
172 })
173 }
174
175 async fn pause(
176 &self,
177 request: aion_proto::ProtoPauseRequest,
178 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
179 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
180 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
181 let reason = if request.reason.is_empty() {
182 None
183 } else {
184 Some(request.reason)
185 };
186 let handle = self
187 .engine
188 .pause_workflow(&workflow_id, &run_id, reason, None)
189 .await
190 .map_err(|error| map_engine_error(&error))?;
191 Ok(aion_proto::ProtoPauseResponse {
192 run_id: Some(handle.run_id().clone().into()),
193 status: aion_proto::ProtoWorkflowStatus::Paused as i32,
194 })
195 }
196
197 async fn resume(
198 &self,
199 request: aion_proto::ProtoResumeRequest,
200 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
201 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
202 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
203 let handle = self
204 .engine
205 .resume_paused_workflow(&workflow_id, &run_id, None)
206 .await
207 .map_err(|error| map_engine_error(&error))?;
208 Ok(aion_proto::ProtoResumeResponse {
209 run_id: Some(handle.run_id().clone().into()),
210 status: aion_proto::ProtoWorkflowStatus::Running as i32,
211 })
212 }
213
214 async fn list_workflows(
215 &self,
216 request: aion_proto::ProtoListWorkflowsRequest,
217 ) -> Result<aion_proto::ProtoListWorkflowsResponse, ClientError> {
218 let filter = match request.filter.as_ref() {
219 Some(filter) => {
220 aion_proto::decode_workflow_filter(filter).map_err(ClientError::from_wire_error)?
221 }
222 None => aion_core::WorkflowFilter::default(),
223 };
224 let summaries = self
225 .engine
226 .list_workflows(filter)
227 .await
228 .map_err(|error| map_engine_error(&error))?
229 .iter()
230 .map(|summary| {
231 aion_proto::encode_workflow_summary(request.namespace.clone(), None, summary)
232 })
233 .map(|result| result.map_err(ClientError::from_wire_error))
234 .collect::<Result<Vec<_>, _>>()?;
235 Ok(aion_proto::ProtoListWorkflowsResponse { summaries })
236 }
237
238 async fn describe_workflow(
239 &self,
240 request: aion_proto::ProtoDescribeWorkflowRequest,
241 ) -> Result<aion_proto::ProtoDescribeWorkflowResponse, ClientError> {
242 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
243 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
244 let history = self
245 .engine
246 .store()
247 .read_history(&workflow_id)
248 .await
249 .map_err(|error| store_error_class(&error, error.to_string()))?;
255 let Some(summary) = aion_core::WorkflowSummary::from_history(&history) else {
256 return Err(ClientError::not_found(format!(
257 "workflow {workflow_id} has no recorded history"
258 )));
259 };
260 let summary = Some(
261 aion_proto::encode_workflow_summary(request.namespace.clone(), None, &summary)
262 .map_err(ClientError::from_wire_error)?,
263 );
264 let history_head_seq = history.last().map_or(0, aion_core::Event::seq);
265 let terminal_event = aion_core::current_lease_terminal(&history)
266 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
267 .transpose()
268 .map_err(ClientError::from_wire_error)?;
269 let history = if request.include_history {
270 history
271 .iter()
272 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
273 .map(|result| result.map_err(ClientError::from_wire_error))
274 .collect::<Result<Vec<_>, _>>()?
275 } else {
276 Vec::new()
277 };
278 Ok(aion_proto::ProtoDescribeWorkflowResponse {
279 summary,
280 history,
281 run_id: Some(run_id.into()),
282 history_head_seq,
283 terminal_event,
284 })
285 }
286
287 async fn read_history(
288 &self,
289 request: aion_proto::ProtoReadHistoryRequest,
290 ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
291 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
292 let from_seq = request.from_seq.unwrap_or(0);
293 let mut events = self
294 .engine
295 .store()
296 .read_history_from(&workflow_id, from_seq)
297 .await
298 .map_err(|error| store_error_class(&error, error.to_string()))?;
299 let head_seq = events.last().map_or(0, aion_core::Event::seq);
300 let next_from_seq = request
301 .limit
302 .and_then(|limit| events.get(limit as usize).map(aion_core::Event::seq));
303 if let Some(limit) = request.limit {
304 events.truncate(limit as usize);
305 }
306 let events = events
307 .iter()
308 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
309 .map(|result| result.map_err(ClientError::from_wire_error))
310 .collect::<Result<Vec<_>, _>>()?;
311 Ok(aion_proto::ProtoReadHistoryResponse {
312 events,
313 next_from_seq,
314 head_seq,
315 })
316 }
317
318 async fn subscribe(
319 &self,
320 request: aion_proto::SubscriptionRequest,
321 resume_from_sequence: Option<u64>,
322 ) -> Result<SubscriptionAttempt, ClientError> {
323 let (workflow_target, filter) = embedded_subscription_target(request)?;
324 let live = self.engine.subscribe(filter);
328 let events = match (&workflow_target, resume_from_sequence) {
329 (Some(workflow_id), Some(resume_from_seq)) => {
330 let history = self
333 .engine
334 .store()
335 .read_history(workflow_id)
336 .await
337 .map_err(|error| store_error_class(&error, error.to_string()))?;
338 splice_resume(live, history, resume_from_seq)?
339 }
340 (None, Some(_)) => {
341 return Err(ClientError::invalid_argument(
342 "filtered and firehose event streams are live-only by design; resume \
343 cursors are valid for per-workflow subscriptions only",
344 ));
345 }
346 (_, None) => map_lag(live),
347 };
348 Ok(SubscriptionAttempt::new(match workflow_target {
352 Some(_) => close_after_terminal(events),
353 None => events,
354 }))
355 }
356}
357
358fn splice_resume(
361 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
362 history: Vec<Event>,
363 resume_from_seq: u64,
364) -> Result<BoxStream<'static, Result<Event, ClientError>>, ClientError> {
365 if resume_from_seq == 0 {
366 return Err(ClientError::invalid_argument(
367 "resume_from_seq must be >= 1 (the first sequence number wanted)",
368 ));
369 }
370 let head = history.last().map_or(0, Event::seq);
371 if resume_from_seq > head.saturating_add(1) {
372 return Err(ClientError::invalid_argument(format!(
373 "resume_from_seq {resume_from_seq} is ahead of recorded history (head seq {head}); \
374 the largest valid cursor is {}",
375 head.saturating_add(1)
376 )));
377 }
378
379 let mut history = history;
380 let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
381 let replay = history.split_off(replay_start);
382 let tail = live.filter(move |item| {
383 let keep = match item {
384 Ok(event) => event.seq() > head,
385 Err(aion::EventStreamLagged { .. }) => true,
387 };
388 futures::future::ready(keep)
389 });
390
391 Ok(stream::iter(replay.into_iter().map(Ok))
392 .chain(map_lag(tail.boxed()))
393 .boxed())
394}
395
396fn map_lag(
399 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
400) -> BoxStream<'static, Result<Event, ClientError>> {
401 live.map(|item| {
402 item.map_err(|lagged| {
403 ClientError::from_wire_error(aion_proto::WireError::lagged(lagged.to_string()))
404 })
405 })
406 .boxed()
407}
408
409fn close_after_terminal(
412 events: BoxStream<'static, Result<Event, ClientError>>,
413) -> BoxStream<'static, Result<Event, ClientError>> {
414 stream::unfold(Some(events), |state| async move {
415 let mut events = state?;
416 let item = events.next().await?;
417 let closed = matches!(&item, Ok(event) if is_terminal_workflow_event(event));
421 Some((item, if closed { None } else { Some(events) }))
422 })
423 .boxed()
424}
425
426fn is_terminal_workflow_event(event: &Event) -> bool {
427 matches!(
428 event,
429 Event::WorkflowCompleted { .. }
430 | Event::WorkflowFailed { .. }
431 | Event::WorkflowCancelled { .. }
432 | Event::WorkflowTimedOut { .. }
433 | Event::WorkflowContinuedAsNew { .. }
434 )
435}
436
437fn decode_required_workflow_id(
438 value: Option<aion_proto::ProtoWorkflowId>,
439) -> Result<aion_core::WorkflowId, ClientError> {
440 value
441 .ok_or_else(|| ClientError::invalid_argument("request workflow id is missing"))?
442 .try_into()
443 .map_err(ClientError::from_wire_error)
444}
445
446fn decode_required_run_id(
447 value: Option<aion_proto::ProtoRunId>,
448) -> Result<aion_core::RunId, ClientError> {
449 value
450 .ok_or_else(|| ClientError::invalid_argument("request run id is missing"))?
451 .try_into()
452 .map_err(ClientError::from_wire_error)
453}
454
455fn embedded_subscription_target(
458 request: aion_proto::SubscriptionRequest,
459) -> Result<(Option<aion_core::WorkflowId>, aion::EventFilter), ClientError> {
460 match request.subscription {
461 Some(aion_proto::subscription_request::Subscription::PerWorkflow(subscription)) => {
462 let workflow_id = subscription
463 .workflow_id
464 .ok_or_else(|| {
465 ClientError::invalid_argument(
466 "per-workflow subscription requires a workflow id",
467 )
468 })?
469 .try_into()
470 .map_err(ClientError::from_wire_error)?;
471 Ok((
472 Some(aion_core::WorkflowId::clone(&workflow_id)),
473 aion::EventFilter {
474 workflow_id: Some(workflow_id),
475 run: None,
476 family: None,
477 },
478 ))
479 }
480 Some(
481 aion_proto::subscription_request::Subscription::Filtered(_)
482 | aion_proto::subscription_request::Subscription::Firehose(_),
483 ) => Ok((None, aion::EventFilter::default())),
484 Some(aion_proto::subscription_request::Subscription::Cluster(_)) => {
485 Err(ClientError::invalid_argument(
491 "cluster topology subscriptions are not supported by the embedded in-process \
492 transport; connect to an aion-server over gRPC/WebSocket to subscribe to the \
493 cluster channel",
494 ))
495 }
496 Some(aion_proto::subscription_request::Subscription::Transcript(_)) => {
497 Err(ClientError::invalid_argument(
503 "agent-observability transcript subscriptions are not supported by the embedded \
504 in-process transport; connect to an aion-server over gRPC/WebSocket to subscribe \
505 to the transcript channel",
506 ))
507 }
508 None => Err(ClientError::invalid_argument(
509 "subscription request is missing its subscription variant",
510 )),
511 }
512}
513
514fn map_engine_error(error: &aion::EngineError) -> ClientError {
535 match error {
536 aion::EngineError::WorkflowNotFound { .. } => ClientError::not_found(error.to_string()),
537 aion::EngineError::InvalidState { .. } => ClientError::invalid_state(error.to_string()),
540 aion::EngineError::StartInputRefused { .. } | aion::EngineError::SignalRefused { .. } => {
549 ClientError::invalid_argument(error.to_string())
550 }
551 aion::EngineError::TerminalWriterUnavailable { .. }
567 | aion::EngineError::TerminalWriterHeld { .. }
568 | aion::EngineError::RunIsRecoverable { .. }
569 | aion::EngineError::NoResidencyVerdict { .. }
570 | aion::EngineError::ContractIdentity { .. }
571 | aion::EngineError::NoQueueDeclaration { .. } => {
572 ClientError::invalid_state(error.to_string())
573 }
574 aion::EngineError::Store(store)
585 | aion::EngineError::Durability(aion::durability::DurabilityError::Store(store)) => {
586 store_error_class(store, error.to_string())
587 }
588 aion::EngineError::Query(query) => query_error_class(query, error),
596 aion::EngineError::ShuttingDown => ClientError::NotRunning {
638 detail: crate::ErrorDetail::with_type(error.to_string(), "ShuttingDown"),
639 },
640 _ => ClientError::server(error.to_string()),
641 }
642}
643
644fn store_error_class(error: &aion_store::StoreError, message: String) -> ClientError {
676 match error {
677 aion_store::StoreError::NotOwner { .. } => ClientError::not_owner(message),
678 aion_store::StoreError::NotFound { .. } => ClientError::not_found(message),
679 aion_store::StoreError::SequenceConflict { .. }
686 | aion_store::StoreError::Backend(_)
687 | aion_store::StoreError::Serialization(_) => ClientError::server(message),
688 }
689}
690
691fn query_error_class(error: &aion::QueryError, source: &aion::EngineError) -> ClientError {
698 match error {
699 aion::QueryError::UnknownQuery(_) => ClientError::unknown_query(source.to_string()),
700 aion::QueryError::Timeout => ClientError::query_timeout(source.to_string()),
701 aion::QueryError::NotRunning(_) | aion::QueryError::ReplyDropped => {
704 ClientError::not_running(source.to_string())
705 }
706 aion::QueryError::Unknown(_) => ClientError::not_found(source.to_string()),
707 aion::QueryError::HandlerFailed { .. } => ClientError::query_failed(source.to_string()),
711 aion::QueryError::InvalidArguments { .. } => {
714 ClientError::invalid_argument(source.to_string())
715 }
716 aion::QueryError::Engine(_) => ClientError::server(source.to_string()),
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use std::num::NonZeroUsize;
723 use std::time::Duration;
724
725 use aion::EventStreamLagged;
726 use aion_core::{Event, EventEnvelope, Payload, RunId, WorkflowId};
727 use chrono::Utc;
728 use futures::{StreamExt, stream};
729
730 use super::{close_after_terminal, map_lag, splice_resume};
731 use crate::error::ClientError;
732
733 fn workflow_id() -> WorkflowId {
734 WorkflowId::new(uuid::Uuid::from_u128(1))
735 }
736
737 fn envelope(seq: u64) -> EventEnvelope {
738 EventEnvelope {
739 seq,
740 recorded_at: Utc::now(),
741 workflow_id: workflow_id(),
742 }
743 }
744
745 fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
746 Ok(Event::SignalReceived {
747 envelope: envelope(seq),
748 name: format!("signal-{seq}"),
749 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
750 })
751 }
752
753 fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
754 Ok(Event::WorkflowCompleted {
755 envelope: envelope(seq),
756 result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
757 })
758 }
759
760 fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
761 seqs.map(signal).collect()
762 }
763
764 fn live(
765 items: Vec<Result<Event, EventStreamLagged>>,
766 ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
767 stream::iter(items).boxed()
768 }
769
770 async fn delivered_seqs(
771 events: futures::stream::BoxStream<'static, Result<Event, ClientError>>,
772 ) -> Result<Vec<u64>, ClientError> {
773 events
774 .map(|item| item.map(|event| event.seq()))
775 .collect::<Vec<_>>()
776 .await
777 .into_iter()
778 .collect()
779 }
780
781 #[tokio::test]
782 async fn cursor_zero_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
783 let error = splice_resume(live(Vec::new()), history(1..=3)?, 0).err();
784
785 let Some(ClientError::InvalidArgument { detail }) = error else {
786 return Err(format!("cursor 0 must be InvalidArgument, got {error:?}").into());
787 };
788 assert!(detail.message.contains(">= 1"), "detail: {detail}");
789 Ok(())
790 }
791
792 #[tokio::test]
793 async fn cursor_ahead_of_history_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>>
794 {
795 let error = splice_resume(live(Vec::new()), history(1..=5)?, 7).err();
796
797 let Some(ClientError::InvalidArgument { detail }) = error else {
798 return Err(format!("cursor head+2 must be InvalidArgument, got {error:?}").into());
799 };
800 assert!(
801 detail.message.contains("ahead of recorded history"),
802 "{detail}"
803 );
804
805 let empty = splice_resume(live(Vec::new()), Vec::new(), 2).err();
806 assert!(
807 matches!(empty, Some(ClientError::InvalidArgument { .. })),
808 "cursor 2 over empty history must be rejected, got {empty:?}"
809 );
810 Ok(())
811 }
812
813 #[tokio::test]
814 async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
815 -> Result<(), Box<dyn std::error::Error>> {
816 let events = splice_resume(
819 live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
820 history(1..=5)?,
821 1,
822 )?;
823
824 assert_eq!(delivered_seqs(events).await?, vec![1, 2, 3, 4, 5, 6]);
825 Ok(())
826 }
827
828 #[tokio::test]
829 async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
830 let events = splice_resume(live(vec![Ok(signal(6)?)]), history(1..=5)?, 3)?;
831
832 assert_eq!(delivered_seqs(events).await?, vec![3, 4, 5, 6]);
833 Ok(())
834 }
835
836 #[tokio::test]
837 async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
838 -> Result<(), Box<dyn std::error::Error>> {
839 let events = splice_resume(
840 live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
841 history(1..=5)?,
842 6,
843 )?;
844
845 assert_eq!(delivered_seqs(events).await?, vec![6, 7]);
846 Ok(())
847 }
848
849 #[tokio::test]
850 async fn lag_mid_splice_surfaces_unavailable_after_the_replay()
851 -> Result<(), Box<dyn std::error::Error>> {
852 let events = splice_resume(
853 live(vec![Err(EventStreamLagged { skipped: 3 })]),
854 history(1..=2)?,
855 1,
856 )?;
857 let collected: Vec<_> = events.collect().await;
858
859 assert_eq!(collected.len(), 3, "two replay events then the lag item");
860 assert!(collected[0].is_ok() && collected[1].is_ok());
861 assert!(
862 matches!(
863 collected[2].as_ref().err(),
864 Some(ClientError::Unavailable { .. })
865 ),
866 "lag must surface as retryable Unavailable, never a silent gap, got {:?}",
867 collected[2]
868 );
869 Ok(())
870 }
871
872 #[tokio::test]
873 async fn per_workflow_stream_closes_after_terminal_event()
874 -> Result<(), Box<dyn std::error::Error>> {
875 let mut history = history(1..=2)?;
878 history.push(completed(3)?);
879 history.push(signal(4)?);
880 let events = splice_resume(live(vec![Ok(signal(5)?)]), history, 1)?;
881
882 assert_eq!(
883 delivered_seqs(close_after_terminal(events)).await?,
884 vec![1, 2, 3],
885 "the stream must close after the terminal event"
886 );
887 Ok(())
888 }
889
890 #[tokio::test]
891 async fn live_lag_maps_to_unavailable() -> Result<(), Box<dyn std::error::Error>> {
892 let events = map_lag(live(vec![
893 Ok(signal(1)?),
894 Err(EventStreamLagged { skipped: 9 }),
895 ]));
896 let collected: Vec<_> = events.collect().await;
897
898 assert_eq!(collected.len(), 2);
899 assert!(
900 matches!(
901 collected[1].as_ref().err(),
902 Some(ClientError::Unavailable { .. })
903 ),
904 "got {:?}",
905 collected[1]
906 );
907 Ok(())
908 }
909
910 #[tokio::test]
914 async fn embedded_resume_splices_recorded_history_with_live_appends()
915 -> Result<(), Box<dyn std::error::Error>> {
916 use crate::stream::SubscribeTarget;
917 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
918
919 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
920 let engine = std::sync::Arc::new(
921 aion::EngineBuilder::new()
922 .store(aion_store::InMemoryStore::default())
923 .in_memory_visibility()
924 .event_streaming(capacity)
925 .build()
926 .await?,
927 );
928 let workflow_id = WorkflowId::new_v4();
929 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
930 recorder
931 .record_workflow_started(
932 Utc::now(),
933 aion::durability::WorkflowStartRecord {
934 workflow_type: String::from("checkout"),
935 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
936 run_id: RunId::new(uuid::Uuid::from_u128(7)),
937 parent_run_id: None,
938 parent_workflow_id: None,
939 package_version: aion_core::PackageVersion::new("a".repeat(64)),
940 },
941 )
942 .await?;
943 for seq in 2..=3 {
944 recorder
945 .record_signal_received(
946 Utc::now(),
947 format!("signal-{seq}"),
948 Payload::from_json(&serde_json::json!({ "seq": seq }))?,
949 )
950 .await?;
951 }
952
953 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
956 let request = SubscribeTarget::Workflow {
957 workflow_id: workflow_id.clone(),
958 }
959 .request("default");
960 let attempt = transport.subscribe(request, Some(2)).await?;
961 let mut events = attempt.events;
962
963 let mut delivered = Vec::new();
964 for _ in 0..2 {
965 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
966 .await
967 .map_err(|_| "timed out waiting for a replay event")?
968 .ok_or("stream ended before the replay completed")?;
969 delivered.push(item?.seq());
970 }
971 recorder
972 .record_workflow_completed(
973 Utc::now(),
974 Payload::from_json(&serde_json::json!({ "done": true }))?,
975 )
976 .await?;
977 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
978 .await
979 .map_err(|_| "timed out waiting for the live spliced event")?
980 .ok_or("stream ended before the live event arrived")?;
981 delivered.push(item?.seq());
982 assert_eq!(delivered, vec![2, 3, 4]);
983
984 let end = tokio::time::timeout(Duration::from_secs(2), events.next())
986 .await
987 .map_err(|_| "timed out waiting for the post-terminal close")?;
988 assert!(
989 end.is_none(),
990 "per-workflow stream must close after the terminal event, got {end:?}"
991 );
992
993 let ahead = transport
995 .subscribe(
996 SubscribeTarget::Workflow { workflow_id }.request("default"),
997 Some(9),
998 )
999 .await
1000 .err();
1001 assert!(
1002 matches!(ahead, Some(ClientError::InvalidArgument { .. })),
1003 "cursor ahead of history must be InvalidArgument, got {ahead:?}"
1004 );
1005
1006 engine.shutdown()?;
1007 Ok(())
1008 }
1009
1010 #[tokio::test]
1033 async fn a_directly_read_store_refusal_keeps_its_routing_class()
1034 -> Result<(), Box<dyn std::error::Error>> {
1035 use aion_store::testing::FencedHistoryStore;
1036
1037 use crate::stream::SubscribeTarget;
1038 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
1039
1040 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
1041 let store = std::sync::Arc::new(FencedHistoryStore::new());
1042 let engine = std::sync::Arc::new(
1043 aion::EngineBuilder::new()
1044 .store_arc(
1045 std::sync::Arc::clone(&store) as std::sync::Arc<dyn aion_store::EventStore>
1046 )
1047 .in_memory_visibility()
1048 .event_streaming(capacity)
1049 .build()
1050 .await?,
1051 );
1052 let workflow_id = WorkflowId::new_v4();
1053 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
1054 recorder
1055 .record_workflow_started(
1056 Utc::now(),
1057 aion::durability::WorkflowStartRecord {
1058 workflow_type: String::from("checkout"),
1059 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
1060 run_id: RunId::new(uuid::Uuid::from_u128(11)),
1061 parent_run_id: None,
1062 parent_workflow_id: None,
1063 package_version: aion_core::PackageVersion::new("b".repeat(64)),
1064 },
1065 )
1066 .await?;
1067
1068 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
1069 let describe = |include_history: bool| aion_proto::ProtoDescribeWorkflowRequest {
1070 namespace: String::from("default"),
1071 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1072 run_id: None,
1073 include_history,
1074 };
1075
1076 transport.describe_workflow(describe(true)).await?;
1090 transport
1091 .subscribe(
1092 SubscribeTarget::Workflow {
1093 workflow_id: workflow_id.clone(),
1094 }
1095 .request("default"),
1096 Some(1),
1097 )
1098 .await?;
1099 let reopen_request = || aion_proto::ProtoReopenRequest {
1100 namespace: String::from("default"),
1101 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1102 run_id: None,
1103 };
1104 let reopen_control = transport.reopen(reopen_request()).await;
1105 assert_ne!(
1106 reopen_control.as_ref().err().map(ClientError::class),
1107 Some("not_owner"),
1108 "the reopen control answered `not_owner` with the fence DISARMED, so the armed \
1109 assertion below would prove nothing: {reopen_control:?}"
1110 );
1111
1112 store.arm_fence();
1113
1114 let described = transport.describe_workflow(describe(false)).await;
1115 assert_eq!(
1116 described.as_ref().err().map(ClientError::class),
1117 Some("not_owner"),
1118 "describe_workflow flattened a shard-ownership refusal into an unroutable class: \
1119 {described:?}"
1120 );
1121
1122 let subscribed = transport
1123 .subscribe(
1124 SubscribeTarget::Workflow {
1125 workflow_id: workflow_id.clone(),
1126 }
1127 .request("default"),
1128 Some(1),
1129 )
1130 .await;
1131 assert_eq!(
1132 subscribed.as_ref().err().map(ClientError::class),
1133 Some("not_owner"),
1134 "the resuming half of subscribe flattened a shard-ownership refusal into an \
1135 unroutable class"
1136 );
1137
1138 let reopened = transport.reopen(reopen_request()).await;
1142 assert_eq!(
1143 reopened.as_ref().err().map(ClientError::class),
1144 Some("not_owner"),
1145 "resolve_run_id flattened a shard-ownership refusal into an unroutable class: \
1146 {reopened:?}"
1147 );
1148
1149 store.disarm_fence();
1157 engine.shutdown()?;
1158 Ok(())
1159 }
1160
1161 #[test]
1204 fn every_named_engine_error_reaches_its_own_class() {
1205 use super::map_engine_error;
1206
1207 for (error, expected) in engine_error_class_table() {
1208 assert_eq!(
1209 map_engine_error(&error).class(),
1210 expected,
1211 "wrong class for {error}"
1212 );
1213 }
1214 }
1215
1216 #[test]
1228 fn shutting_down_carries_its_discriminator_not_only_its_class()
1229 -> Result<(), Box<dyn std::error::Error>> {
1230 let mapped = super::map_engine_error(&aion::EngineError::ShuttingDown);
1231 let ClientError::NotRunning { detail } = &mapped else {
1232 return Err(format!("ShuttingDown must keep the not_running CLASS: {mapped}").into());
1233 };
1234 assert_eq!(
1235 detail.error_type.as_deref(),
1236 Some("ShuttingDown"),
1237 "the wire surface builds `not_running_with_type(\"ShuttingDown\", …)`; an embedded \
1238 caller that loses the discriminator cannot be told anything true about why"
1239 );
1240 let other = super::map_engine_error(&aion::EngineError::Runtime {
1243 reason: "beamr scheduler refused".to_owned(),
1244 });
1245 assert_ne!(
1246 other.class(),
1247 "not_running",
1248 "control: the negative case must not share the class under test"
1249 );
1250 Ok(())
1251 }
1252
1253 fn engine_error_class_table() -> Vec<(aion::EngineError, &'static str)> {
1257 let mut cases = admission_and_run_state_cases();
1258 cases.extend(store_and_query_cases());
1259 cases
1260 }
1261
1262 fn admission_and_run_state_cases() -> Vec<(aion::EngineError, &'static str)> {
1265 use aion_core::{RunId, WorkflowId};
1266
1267 let version = aion::ContentHash::from_bytes([7u8; 32]);
1268 vec![
1269 (
1274 aion::EngineError::WorkflowNotFound {
1275 workflow_type: "orders".to_owned(),
1276 },
1277 "not_found",
1278 ),
1279 (
1280 aion::EngineError::InvalidState {
1281 reason: "workflow w run r is Running, not terminal".to_owned(),
1282 },
1283 "invalid_state",
1284 ),
1285 (
1286 aion::EngineError::StartInputRefused {
1287 workflow_type: "orders".to_owned(),
1288 version: version.clone(),
1289 reason: "field `total` is missing".to_owned(),
1290 },
1291 "invalid_input",
1292 ),
1293 (
1294 aion::EngineError::SignalRefused {
1295 workflow_id: WorkflowId::new_v4(),
1296 run_id: RunId::new_v4(),
1297 signal_name: "approve".to_owned(),
1298 version,
1299 reason: "undeclared signal".to_owned(),
1300 },
1301 "invalid_input",
1302 ),
1303 (
1304 aion::EngineError::TerminalWriterUnavailable {
1305 workflow_id: "w".to_owned(),
1306 run_id: "r".to_owned(),
1307 holder: "another reservation".to_owned(),
1308 },
1309 "invalid_state",
1310 ),
1311 (
1312 aion::EngineError::TerminalWriterHeld {
1313 workflow_id: "w".to_owned(),
1314 run_id: "r".to_owned(),
1315 },
1316 "invalid_state",
1317 ),
1318 (
1319 aion::EngineError::RunIsRecoverable {
1320 workflow_id: "w".to_owned(),
1321 run_id: "r".to_owned(),
1322 version: "abc".to_owned(),
1323 },
1324 "invalid_state",
1325 ),
1326 (
1327 aion::EngineError::NoResidencyVerdict {
1328 workflow_id: "w".to_owned(),
1329 run_id: "r".to_owned(),
1330 },
1331 "invalid_state",
1332 ),
1333 (
1334 aion::EngineError::ContractIdentity {
1335 workflow_type: "orders".to_owned(),
1336 source: aion::ContractIdentityError::RedeployRequired {
1337 stored_version: "orders$deadbeef".to_owned(),
1338 },
1339 },
1340 "invalid_state",
1341 ),
1342 (
1343 aion::EngineError::NoQueueDeclaration {
1344 workflow_type: "orders".to_owned(),
1345 version: aion::ContentHash::from_bytes([5u8; 32]),
1346 activities: "charge_card,send_receipt".to_owned(),
1347 },
1348 "invalid_state",
1349 ),
1350 (aion::EngineError::ShuttingDown, "not_running"),
1351 (
1356 aion::EngineError::Runtime {
1357 reason: "beamr scheduler refused".to_owned(),
1358 },
1359 "backend",
1360 ),
1361 ]
1362 }
1363
1364 fn store_and_query_cases() -> Vec<(aion::EngineError, &'static str)> {
1367 use aion_core::WorkflowId;
1368
1369 vec![
1370 (
1371 aion::EngineError::Store(aion_store::StoreError::NotOwner { shard: 3 }),
1372 "not_owner",
1373 ),
1374 (
1377 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1378 aion_store::StoreError::NotOwner { shard: 3 },
1379 )),
1380 "not_owner",
1381 ),
1382 (
1383 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1384 aion_store::StoreError::NotFound {
1385 workflow_id: WorkflowId::new_v4(),
1386 },
1387 )),
1388 "not_found",
1389 ),
1390 (
1394 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1395 aion_store::StoreError::SequenceConflict {
1396 expected: 4,
1397 found: 7,
1398 },
1399 )),
1400 "backend",
1401 ),
1402 (
1403 aion::EngineError::Query(aion::QueryError::UnknownQuery("balance".to_owned())),
1404 "unknown_query",
1405 ),
1406 (
1407 aion::EngineError::Query(aion::QueryError::Timeout),
1408 "query_timeout",
1409 ),
1410 (
1411 aion::EngineError::Query(aion::QueryError::NotRunning(WorkflowId::new_v4())),
1412 "not_running",
1413 ),
1414 (
1415 aion::EngineError::Query(aion::QueryError::ReplyDropped),
1416 "not_running",
1417 ),
1418 (
1419 aion::EngineError::Query(aion::QueryError::Unknown(WorkflowId::new_v4())),
1420 "not_found",
1421 ),
1422 (
1423 aion::EngineError::Query(aion::QueryError::HandlerFailed {
1424 message: "handler panicked".to_owned(),
1425 }),
1426 "query_failed",
1427 ),
1428 (
1432 aion::EngineError::Query(aion::QueryError::InvalidArguments {
1433 reason: "arguments payload is not a well-formed JSON document".to_owned(),
1434 }),
1435 "invalid_input",
1436 ),
1437 (
1445 aion::EngineError::Query(aion::QueryError::Engine(
1446 aion::engine_seam::EngineSeamError::Delivery {
1447 reason: "mailbox send failed".to_owned(),
1448 },
1449 )),
1450 "backend",
1451 ),
1452 ]
1453 }
1454}