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 retire_workloop(
159 &self,
160 request: aion_proto::ProtoRetireWorkloopRequest,
161 ) -> Result<aion_proto::ProtoRetireWorkloopResponse, ClientError> {
162 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
163 self.engine
164 .retire_declared_workloop(
165 &workflow_id,
166 request.reason.clone(),
167 aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
170 )
171 .await
172 .map_err(|error| map_engine_error(&error))?;
173 Ok(aion_proto::ProtoRetireWorkloopResponse {
174 reason: request.reason,
175 })
176 }
177
178 async fn reopen(
179 &self,
180 request: aion_proto::ProtoReopenRequest,
181 ) -> Result<aion_proto::ProtoReopenResponse, ClientError> {
182 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
183 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
184 let handle = self
185 .engine
186 .reopen_workflow(&workflow_id, &run_id)
187 .await
188 .map_err(|error| map_engine_error(&error))?;
189 Ok(aion_proto::ProtoReopenResponse {
190 run_id: Some(handle.run_id().clone().into()),
191 status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
192 })
193 }
194
195 async fn pause(
196 &self,
197 request: aion_proto::ProtoPauseRequest,
198 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
199 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
200 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
201 let reason = if request.reason.is_empty() {
202 None
203 } else {
204 Some(request.reason)
205 };
206 let handle = self
207 .engine
208 .pause_workflow(&workflow_id, &run_id, reason, None)
209 .await
210 .map_err(|error| map_engine_error(&error))?;
211 Ok(aion_proto::ProtoPauseResponse {
212 run_id: Some(handle.run_id().clone().into()),
213 status: aion_proto::ProtoWorkflowStatus::Paused as i32,
214 })
215 }
216
217 async fn resume(
218 &self,
219 request: aion_proto::ProtoResumeRequest,
220 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
221 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
222 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
223 let handle = self
224 .engine
225 .resume_paused_workflow(&workflow_id, &run_id, None)
226 .await
227 .map_err(|error| map_engine_error(&error))?;
228 Ok(aion_proto::ProtoResumeResponse {
229 run_id: Some(handle.run_id().clone().into()),
230 status: aion_proto::ProtoWorkflowStatus::Running as i32,
231 })
232 }
233
234 async fn list_workflows(
235 &self,
236 request: aion_proto::ProtoListWorkflowsRequest,
237 ) -> Result<aion_proto::ProtoListWorkflowsResponse, ClientError> {
238 let filter = match request.filter.as_ref() {
239 Some(filter) => {
240 aion_proto::decode_workflow_filter(filter).map_err(ClientError::from_wire_error)?
241 }
242 None => aion_core::WorkflowFilter::default(),
243 };
244 let summaries = self
245 .engine
246 .list_workflows(filter)
247 .await
248 .map_err(|error| map_engine_error(&error))?
249 .iter()
250 .map(|summary| {
251 aion_proto::encode_workflow_summary(request.namespace.clone(), None, summary)
252 })
253 .map(|result| result.map_err(ClientError::from_wire_error))
254 .collect::<Result<Vec<_>, _>>()?;
255 Ok(aion_proto::ProtoListWorkflowsResponse { summaries })
256 }
257
258 async fn describe_workflow(
259 &self,
260 request: aion_proto::ProtoDescribeWorkflowRequest,
261 ) -> Result<aion_proto::ProtoDescribeWorkflowResponse, ClientError> {
262 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
263 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
264 let history = self
265 .engine
266 .store()
267 .read_history(&workflow_id)
268 .await
269 .map_err(|error| store_error_class(&error, error.to_string()))?;
275 let Some(summary) = aion_core::WorkflowSummary::from_history(&history) else {
276 return Err(ClientError::not_found(format!(
277 "workflow {workflow_id} has no recorded history"
278 )));
279 };
280 let summary = Some(
281 aion_proto::encode_workflow_summary(request.namespace.clone(), None, &summary)
282 .map_err(ClientError::from_wire_error)?,
283 );
284 let history_head_seq = history.last().map_or(0, aion_core::Event::seq);
285 let terminal_event = aion_core::current_lease_terminal(&history)
286 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
287 .transpose()
288 .map_err(ClientError::from_wire_error)?;
289 let history = if request.include_history {
290 history
291 .iter()
292 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
293 .map(|result| result.map_err(ClientError::from_wire_error))
294 .collect::<Result<Vec<_>, _>>()?
295 } else {
296 Vec::new()
297 };
298 Ok(aion_proto::ProtoDescribeWorkflowResponse {
299 summary,
300 history,
301 run_id: Some(run_id.into()),
302 history_head_seq,
303 terminal_event,
304 })
305 }
306
307 async fn read_history(
308 &self,
309 request: aion_proto::ProtoReadHistoryRequest,
310 ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
311 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
312 let from_seq = request.from_seq.unwrap_or(0);
313 let mut events = self
314 .engine
315 .store()
316 .read_history_from(&workflow_id, from_seq)
317 .await
318 .map_err(|error| store_error_class(&error, error.to_string()))?;
319 let head_seq = events.last().map_or(0, aion_core::Event::seq);
320 let next_from_seq = request
321 .limit
322 .and_then(|limit| events.get(limit as usize).map(aion_core::Event::seq));
323 if let Some(limit) = request.limit {
324 events.truncate(limit as usize);
325 }
326 let events = events
327 .iter()
328 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
329 .map(|result| result.map_err(ClientError::from_wire_error))
330 .collect::<Result<Vec<_>, _>>()?;
331 Ok(aion_proto::ProtoReadHistoryResponse {
332 events,
333 next_from_seq,
334 head_seq,
335 })
336 }
337
338 async fn subscribe(
339 &self,
340 request: aion_proto::SubscriptionRequest,
341 resume_from_sequence: Option<u64>,
342 ) -> Result<SubscriptionAttempt, ClientError> {
343 let (workflow_target, filter) = embedded_subscription_target(request)?;
344 let live = self.engine.subscribe(filter);
348 let events = match (&workflow_target, resume_from_sequence) {
349 (Some(workflow_id), Some(resume_from_seq)) => {
350 let history = self
353 .engine
354 .store()
355 .read_history(workflow_id)
356 .await
357 .map_err(|error| store_error_class(&error, error.to_string()))?;
358 splice_resume(live, history, resume_from_seq)?
359 }
360 (None, Some(_)) => {
361 return Err(ClientError::invalid_argument(
362 "filtered and firehose event streams are live-only by design; resume \
363 cursors are valid for per-workflow subscriptions only",
364 ));
365 }
366 (_, None) => map_lag(live),
367 };
368 Ok(SubscriptionAttempt::new(match workflow_target {
372 Some(_) => close_after_terminal(events),
373 None => events,
374 }))
375 }
376}
377
378fn splice_resume(
381 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
382 history: Vec<Event>,
383 resume_from_seq: u64,
384) -> Result<BoxStream<'static, Result<Event, ClientError>>, ClientError> {
385 if resume_from_seq == 0 {
386 return Err(ClientError::invalid_argument(
387 "resume_from_seq must be >= 1 (the first sequence number wanted)",
388 ));
389 }
390 let head = history.last().map_or(0, Event::seq);
391 if resume_from_seq > head.saturating_add(1) {
392 return Err(ClientError::invalid_argument(format!(
393 "resume_from_seq {resume_from_seq} is ahead of recorded history (head seq {head}); \
394 the largest valid cursor is {}",
395 head.saturating_add(1)
396 )));
397 }
398
399 let mut history = history;
400 let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
401 let replay = history.split_off(replay_start);
402 let tail = live.filter(move |item| {
403 let keep = match item {
404 Ok(event) => event.seq() > head,
405 Err(aion::EventStreamLagged { .. }) => true,
407 };
408 futures::future::ready(keep)
409 });
410
411 Ok(stream::iter(replay.into_iter().map(Ok))
412 .chain(map_lag(tail.boxed()))
413 .boxed())
414}
415
416fn map_lag(
419 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
420) -> BoxStream<'static, Result<Event, ClientError>> {
421 live.map(|item| {
422 item.map_err(|lagged| {
423 ClientError::from_wire_error(aion_proto::WireError::lagged(lagged.to_string()))
424 })
425 })
426 .boxed()
427}
428
429fn close_after_terminal(
432 events: BoxStream<'static, Result<Event, ClientError>>,
433) -> BoxStream<'static, Result<Event, ClientError>> {
434 stream::unfold(Some(events), |state| async move {
435 let mut events = state?;
436 let item = events.next().await?;
437 let closed = matches!(&item, Ok(event) if is_terminal_workflow_event(event));
441 Some((item, if closed { None } else { Some(events) }))
442 })
443 .boxed()
444}
445
446fn is_terminal_workflow_event(event: &Event) -> bool {
447 matches!(
448 event,
449 Event::WorkflowCompleted { .. }
450 | Event::WorkflowFailed { .. }
451 | Event::WorkflowCancelled { .. }
452 | Event::WorkflowTimedOut { .. }
453 | Event::WorkflowContinuedAsNew { .. }
454 )
455}
456
457fn decode_required_workflow_id(
458 value: Option<aion_proto::ProtoWorkflowId>,
459) -> Result<aion_core::WorkflowId, ClientError> {
460 value
461 .ok_or_else(|| ClientError::invalid_argument("request workflow id is missing"))?
462 .try_into()
463 .map_err(ClientError::from_wire_error)
464}
465
466fn decode_required_run_id(
467 value: Option<aion_proto::ProtoRunId>,
468) -> Result<aion_core::RunId, ClientError> {
469 value
470 .ok_or_else(|| ClientError::invalid_argument("request run id is missing"))?
471 .try_into()
472 .map_err(ClientError::from_wire_error)
473}
474
475fn embedded_subscription_target(
478 request: aion_proto::SubscriptionRequest,
479) -> Result<(Option<aion_core::WorkflowId>, aion::EventFilter), ClientError> {
480 match request.subscription {
481 Some(aion_proto::subscription_request::Subscription::PerWorkflow(subscription)) => {
482 let workflow_id = subscription
483 .workflow_id
484 .ok_or_else(|| {
485 ClientError::invalid_argument(
486 "per-workflow subscription requires a workflow id",
487 )
488 })?
489 .try_into()
490 .map_err(ClientError::from_wire_error)?;
491 Ok((
492 Some(aion_core::WorkflowId::clone(&workflow_id)),
493 aion::EventFilter {
494 workflow_id: Some(workflow_id),
495 run: None,
496 family: None,
497 },
498 ))
499 }
500 Some(
501 aion_proto::subscription_request::Subscription::Filtered(_)
502 | aion_proto::subscription_request::Subscription::Firehose(_),
503 ) => Ok((None, aion::EventFilter::default())),
504 Some(aion_proto::subscription_request::Subscription::Cluster(_)) => {
505 Err(ClientError::invalid_argument(
511 "cluster topology subscriptions are not supported by the embedded in-process \
512 transport; connect to an aion-server over gRPC/WebSocket to subscribe to the \
513 cluster channel",
514 ))
515 }
516 Some(aion_proto::subscription_request::Subscription::Transcript(_)) => {
517 Err(ClientError::invalid_argument(
523 "agent-observability transcript subscriptions are not supported by the embedded \
524 in-process transport; connect to an aion-server over gRPC/WebSocket to subscribe \
525 to the transcript channel",
526 ))
527 }
528 None => Err(ClientError::invalid_argument(
529 "subscription request is missing its subscription variant",
530 )),
531 }
532}
533
534fn map_engine_error(error: &aion::EngineError) -> ClientError {
555 match error {
556 aion::EngineError::WorkflowNotFound { .. } => ClientError::not_found(error.to_string()),
557 aion::EngineError::InvalidState { .. } => ClientError::invalid_state(error.to_string()),
560 aion::EngineError::StartInputRefused { .. } | aion::EngineError::SignalRefused { .. } => {
569 ClientError::invalid_argument(error.to_string())
570 }
571 aion::EngineError::TerminalWriterUnavailable { .. }
587 | aion::EngineError::TerminalWriterHeld { .. }
588 | aion::EngineError::RunIsRecoverable { .. }
589 | aion::EngineError::NoResidencyVerdict { .. }
590 | aion::EngineError::ContractIdentity { .. }
591 | aion::EngineError::NoQueueDeclaration { .. } => {
592 ClientError::invalid_state(error.to_string())
593 }
594 aion::EngineError::Store(store)
605 | aion::EngineError::Durability(aion::durability::DurabilityError::Store(store)) => {
606 store_error_class(store, error.to_string())
607 }
608 aion::EngineError::Query(query) => query_error_class(query, error),
616 aion::EngineError::ShuttingDown => ClientError::NotRunning {
658 detail: crate::ErrorDetail::with_type(error.to_string(), "ShuttingDown"),
659 },
660 _ => ClientError::server(error.to_string()),
661 }
662}
663
664fn store_error_class(error: &aion_store::StoreError, message: String) -> ClientError {
696 match error {
697 aion_store::StoreError::NotOwner { .. } => ClientError::not_owner(message),
698 aion_store::StoreError::NotFound { .. } => ClientError::not_found(message),
699 aion_store::StoreError::SequenceConflict { .. }
706 | aion_store::StoreError::Backend(_)
707 | aion_store::StoreError::Serialization(_) => ClientError::server(message),
708 }
709}
710
711fn query_error_class(error: &aion::QueryError, source: &aion::EngineError) -> ClientError {
718 match error {
719 aion::QueryError::UnknownQuery(_) => ClientError::unknown_query(source.to_string()),
720 aion::QueryError::Timeout => ClientError::query_timeout(source.to_string()),
721 aion::QueryError::NotRunning(_) | aion::QueryError::ReplyDropped => {
724 ClientError::not_running(source.to_string())
725 }
726 aion::QueryError::Unknown(_) => ClientError::not_found(source.to_string()),
727 aion::QueryError::HandlerFailed { .. } => ClientError::query_failed(source.to_string()),
731 aion::QueryError::InvalidArguments { .. } => {
734 ClientError::invalid_argument(source.to_string())
735 }
736 aion::QueryError::Engine(_) => ClientError::server(source.to_string()),
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use std::num::NonZeroUsize;
743 use std::time::Duration;
744
745 use aion::EventStreamLagged;
746 use aion_core::{Event, EventEnvelope, Payload, RunId, WorkflowId};
747 use chrono::Utc;
748 use futures::{StreamExt, stream};
749
750 use super::{close_after_terminal, map_lag, splice_resume};
751 use crate::error::ClientError;
752
753 fn workflow_id() -> WorkflowId {
754 WorkflowId::new(uuid::Uuid::from_u128(1))
755 }
756
757 fn envelope(seq: u64) -> EventEnvelope {
758 EventEnvelope {
759 seq,
760 recorded_at: Utc::now(),
761 workflow_id: workflow_id(),
762 }
763 }
764
765 fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
766 Ok(Event::SignalReceived {
767 envelope: envelope(seq),
768 name: format!("signal-{seq}"),
769 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
770 })
771 }
772
773 fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
774 Ok(Event::WorkflowCompleted {
775 envelope: envelope(seq),
776 result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
777 })
778 }
779
780 fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
781 seqs.map(signal).collect()
782 }
783
784 fn live(
785 items: Vec<Result<Event, EventStreamLagged>>,
786 ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
787 stream::iter(items).boxed()
788 }
789
790 async fn delivered_seqs(
791 events: futures::stream::BoxStream<'static, Result<Event, ClientError>>,
792 ) -> Result<Vec<u64>, ClientError> {
793 events
794 .map(|item| item.map(|event| event.seq()))
795 .collect::<Vec<_>>()
796 .await
797 .into_iter()
798 .collect()
799 }
800
801 #[tokio::test]
802 async fn cursor_zero_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
803 let error = splice_resume(live(Vec::new()), history(1..=3)?, 0).err();
804
805 let Some(ClientError::InvalidArgument { detail }) = error else {
806 return Err(format!("cursor 0 must be InvalidArgument, got {error:?}").into());
807 };
808 assert!(detail.message.contains(">= 1"), "detail: {detail}");
809 Ok(())
810 }
811
812 #[tokio::test]
813 async fn cursor_ahead_of_history_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>>
814 {
815 let error = splice_resume(live(Vec::new()), history(1..=5)?, 7).err();
816
817 let Some(ClientError::InvalidArgument { detail }) = error else {
818 return Err(format!("cursor head+2 must be InvalidArgument, got {error:?}").into());
819 };
820 assert!(
821 detail.message.contains("ahead of recorded history"),
822 "{detail}"
823 );
824
825 let empty = splice_resume(live(Vec::new()), Vec::new(), 2).err();
826 assert!(
827 matches!(empty, Some(ClientError::InvalidArgument { .. })),
828 "cursor 2 over empty history must be rejected, got {empty:?}"
829 );
830 Ok(())
831 }
832
833 #[tokio::test]
834 async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
835 -> Result<(), Box<dyn std::error::Error>> {
836 let events = splice_resume(
839 live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
840 history(1..=5)?,
841 1,
842 )?;
843
844 assert_eq!(delivered_seqs(events).await?, vec![1, 2, 3, 4, 5, 6]);
845 Ok(())
846 }
847
848 #[tokio::test]
849 async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
850 let events = splice_resume(live(vec![Ok(signal(6)?)]), history(1..=5)?, 3)?;
851
852 assert_eq!(delivered_seqs(events).await?, vec![3, 4, 5, 6]);
853 Ok(())
854 }
855
856 #[tokio::test]
857 async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
858 -> Result<(), Box<dyn std::error::Error>> {
859 let events = splice_resume(
860 live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
861 history(1..=5)?,
862 6,
863 )?;
864
865 assert_eq!(delivered_seqs(events).await?, vec![6, 7]);
866 Ok(())
867 }
868
869 #[tokio::test]
870 async fn lag_mid_splice_surfaces_unavailable_after_the_replay()
871 -> Result<(), Box<dyn std::error::Error>> {
872 let events = splice_resume(
873 live(vec![Err(EventStreamLagged { skipped: 3 })]),
874 history(1..=2)?,
875 1,
876 )?;
877 let collected: Vec<_> = events.collect().await;
878
879 assert_eq!(collected.len(), 3, "two replay events then the lag item");
880 assert!(collected[0].is_ok() && collected[1].is_ok());
881 assert!(
882 matches!(
883 collected[2].as_ref().err(),
884 Some(ClientError::Unavailable { .. })
885 ),
886 "lag must surface as retryable Unavailable, never a silent gap, got {:?}",
887 collected[2]
888 );
889 Ok(())
890 }
891
892 #[tokio::test]
893 async fn per_workflow_stream_closes_after_terminal_event()
894 -> Result<(), Box<dyn std::error::Error>> {
895 let mut history = history(1..=2)?;
898 history.push(completed(3)?);
899 history.push(signal(4)?);
900 let events = splice_resume(live(vec![Ok(signal(5)?)]), history, 1)?;
901
902 assert_eq!(
903 delivered_seqs(close_after_terminal(events)).await?,
904 vec![1, 2, 3],
905 "the stream must close after the terminal event"
906 );
907 Ok(())
908 }
909
910 #[tokio::test]
911 async fn live_lag_maps_to_unavailable() -> Result<(), Box<dyn std::error::Error>> {
912 let events = map_lag(live(vec![
913 Ok(signal(1)?),
914 Err(EventStreamLagged { skipped: 9 }),
915 ]));
916 let collected: Vec<_> = events.collect().await;
917
918 assert_eq!(collected.len(), 2);
919 assert!(
920 matches!(
921 collected[1].as_ref().err(),
922 Some(ClientError::Unavailable { .. })
923 ),
924 "got {:?}",
925 collected[1]
926 );
927 Ok(())
928 }
929
930 #[tokio::test]
934 async fn embedded_resume_splices_recorded_history_with_live_appends()
935 -> Result<(), Box<dyn std::error::Error>> {
936 use crate::stream::SubscribeTarget;
937 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
938
939 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
940 let engine = std::sync::Arc::new(
941 aion::EngineBuilder::new()
942 .store(aion_store::InMemoryStore::default())
943 .in_memory_visibility()
944 .event_streaming(capacity)
945 .build()
946 .await?,
947 );
948 let workflow_id = WorkflowId::new_v4();
949 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
950 recorder
951 .record_workflow_started(
952 Utc::now(),
953 aion::durability::WorkflowStartRecord {
954 workflow_type: String::from("checkout"),
955 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
956 run_id: RunId::new(uuid::Uuid::from_u128(7)),
957 parent_run_id: None,
958 parent_workflow_id: None,
959 package_version: aion_core::PackageVersion::new("a".repeat(64)),
960 },
961 )
962 .await?;
963 for seq in 2..=3 {
964 recorder
965 .record_signal_received(
966 Utc::now(),
967 format!("signal-{seq}"),
968 Payload::from_json(&serde_json::json!({ "seq": seq }))?,
969 )
970 .await?;
971 }
972
973 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
976 let request = SubscribeTarget::Workflow {
977 workflow_id: workflow_id.clone(),
978 }
979 .request("default");
980 let attempt = transport.subscribe(request, Some(2)).await?;
981 let mut events = attempt.events;
982
983 let mut delivered = Vec::new();
984 for _ in 0..2 {
985 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
986 .await
987 .map_err(|_| "timed out waiting for a replay event")?
988 .ok_or("stream ended before the replay completed")?;
989 delivered.push(item?.seq());
990 }
991 recorder
992 .record_workflow_completed(
993 Utc::now(),
994 Payload::from_json(&serde_json::json!({ "done": true }))?,
995 )
996 .await?;
997 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
998 .await
999 .map_err(|_| "timed out waiting for the live spliced event")?
1000 .ok_or("stream ended before the live event arrived")?;
1001 delivered.push(item?.seq());
1002 assert_eq!(delivered, vec![2, 3, 4]);
1003
1004 let end = tokio::time::timeout(Duration::from_secs(2), events.next())
1006 .await
1007 .map_err(|_| "timed out waiting for the post-terminal close")?;
1008 assert!(
1009 end.is_none(),
1010 "per-workflow stream must close after the terminal event, got {end:?}"
1011 );
1012
1013 let ahead = transport
1015 .subscribe(
1016 SubscribeTarget::Workflow { workflow_id }.request("default"),
1017 Some(9),
1018 )
1019 .await
1020 .err();
1021 assert!(
1022 matches!(ahead, Some(ClientError::InvalidArgument { .. })),
1023 "cursor ahead of history must be InvalidArgument, got {ahead:?}"
1024 );
1025
1026 engine.shutdown()?;
1027 Ok(())
1028 }
1029
1030 #[tokio::test]
1053 async fn a_directly_read_store_refusal_keeps_its_routing_class()
1054 -> Result<(), Box<dyn std::error::Error>> {
1055 use aion_store::testing::FencedHistoryStore;
1056
1057 use crate::stream::SubscribeTarget;
1058 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
1059
1060 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
1061 let store = std::sync::Arc::new(FencedHistoryStore::new());
1062 let engine = std::sync::Arc::new(
1063 aion::EngineBuilder::new()
1064 .store_arc(
1065 std::sync::Arc::clone(&store) as std::sync::Arc<dyn aion_store::EventStore>
1066 )
1067 .in_memory_visibility()
1068 .event_streaming(capacity)
1069 .build()
1070 .await?,
1071 );
1072 let workflow_id = WorkflowId::new_v4();
1073 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
1074 recorder
1075 .record_workflow_started(
1076 Utc::now(),
1077 aion::durability::WorkflowStartRecord {
1078 workflow_type: String::from("checkout"),
1079 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
1080 run_id: RunId::new(uuid::Uuid::from_u128(11)),
1081 parent_run_id: None,
1082 parent_workflow_id: None,
1083 package_version: aion_core::PackageVersion::new("b".repeat(64)),
1084 },
1085 )
1086 .await?;
1087
1088 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
1089 let describe = |include_history: bool| aion_proto::ProtoDescribeWorkflowRequest {
1090 namespace: String::from("default"),
1091 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1092 run_id: None,
1093 include_history,
1094 };
1095
1096 transport.describe_workflow(describe(true)).await?;
1110 transport
1111 .subscribe(
1112 SubscribeTarget::Workflow {
1113 workflow_id: workflow_id.clone(),
1114 }
1115 .request("default"),
1116 Some(1),
1117 )
1118 .await?;
1119 let reopen_request = || aion_proto::ProtoReopenRequest {
1120 namespace: String::from("default"),
1121 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1122 run_id: None,
1123 };
1124 let reopen_control = transport.reopen(reopen_request()).await;
1125 assert_ne!(
1126 reopen_control.as_ref().err().map(ClientError::class),
1127 Some("not_owner"),
1128 "the reopen control answered `not_owner` with the fence DISARMED, so the armed \
1129 assertion below would prove nothing: {reopen_control:?}"
1130 );
1131
1132 store.arm_fence();
1133
1134 let described = transport.describe_workflow(describe(false)).await;
1135 assert_eq!(
1136 described.as_ref().err().map(ClientError::class),
1137 Some("not_owner"),
1138 "describe_workflow flattened a shard-ownership refusal into an unroutable class: \
1139 {described:?}"
1140 );
1141
1142 let subscribed = transport
1143 .subscribe(
1144 SubscribeTarget::Workflow {
1145 workflow_id: workflow_id.clone(),
1146 }
1147 .request("default"),
1148 Some(1),
1149 )
1150 .await;
1151 assert_eq!(
1152 subscribed.as_ref().err().map(ClientError::class),
1153 Some("not_owner"),
1154 "the resuming half of subscribe flattened a shard-ownership refusal into an \
1155 unroutable class"
1156 );
1157
1158 let reopened = transport.reopen(reopen_request()).await;
1162 assert_eq!(
1163 reopened.as_ref().err().map(ClientError::class),
1164 Some("not_owner"),
1165 "resolve_run_id flattened a shard-ownership refusal into an unroutable class: \
1166 {reopened:?}"
1167 );
1168
1169 store.disarm_fence();
1177 engine.shutdown()?;
1178 Ok(())
1179 }
1180
1181 #[test]
1224 fn every_named_engine_error_reaches_its_own_class() {
1225 use super::map_engine_error;
1226
1227 for (error, expected) in engine_error_class_table() {
1228 assert_eq!(
1229 map_engine_error(&error).class(),
1230 expected,
1231 "wrong class for {error}"
1232 );
1233 }
1234 }
1235
1236 #[test]
1248 fn shutting_down_carries_its_discriminator_not_only_its_class()
1249 -> Result<(), Box<dyn std::error::Error>> {
1250 let mapped = super::map_engine_error(&aion::EngineError::ShuttingDown);
1251 let ClientError::NotRunning { detail } = &mapped else {
1252 return Err(format!("ShuttingDown must keep the not_running CLASS: {mapped}").into());
1253 };
1254 assert_eq!(
1255 detail.error_type.as_deref(),
1256 Some("ShuttingDown"),
1257 "the wire surface builds `not_running_with_type(\"ShuttingDown\", …)`; an embedded \
1258 caller that loses the discriminator cannot be told anything true about why"
1259 );
1260 let other = super::map_engine_error(&aion::EngineError::Runtime {
1263 reason: "beamr scheduler refused".to_owned(),
1264 });
1265 assert_ne!(
1266 other.class(),
1267 "not_running",
1268 "control: the negative case must not share the class under test"
1269 );
1270 Ok(())
1271 }
1272
1273 fn engine_error_class_table() -> Vec<(aion::EngineError, &'static str)> {
1277 let mut cases = admission_and_run_state_cases();
1278 cases.extend(store_and_query_cases());
1279 cases
1280 }
1281
1282 fn admission_and_run_state_cases() -> Vec<(aion::EngineError, &'static str)> {
1285 use aion_core::{RunId, WorkflowId};
1286
1287 let version = aion::ContentHash::from_bytes([7u8; 32]);
1288 vec![
1289 (
1294 aion::EngineError::WorkflowNotFound {
1295 workflow_type: "orders".to_owned(),
1296 },
1297 "not_found",
1298 ),
1299 (
1300 aion::EngineError::InvalidState {
1301 reason: "workflow w run r is Running, not terminal".to_owned(),
1302 },
1303 "invalid_state",
1304 ),
1305 (
1306 aion::EngineError::StartInputRefused {
1307 workflow_type: "orders".to_owned(),
1308 version: version.clone(),
1309 reason: "field `total` is missing".to_owned(),
1310 },
1311 "invalid_input",
1312 ),
1313 (
1314 aion::EngineError::SignalRefused {
1315 workflow_id: WorkflowId::new_v4(),
1316 run_id: RunId::new_v4(),
1317 signal_name: "approve".to_owned(),
1318 version,
1319 reason: "undeclared signal".to_owned(),
1320 },
1321 "invalid_input",
1322 ),
1323 (
1324 aion::EngineError::TerminalWriterUnavailable {
1325 workflow_id: "w".to_owned(),
1326 run_id: "r".to_owned(),
1327 holder: "another reservation".to_owned(),
1328 },
1329 "invalid_state",
1330 ),
1331 (
1332 aion::EngineError::TerminalWriterHeld {
1333 workflow_id: "w".to_owned(),
1334 run_id: "r".to_owned(),
1335 },
1336 "invalid_state",
1337 ),
1338 (
1339 aion::EngineError::RunIsRecoverable {
1340 workflow_id: "w".to_owned(),
1341 run_id: "r".to_owned(),
1342 version: "abc".to_owned(),
1343 },
1344 "invalid_state",
1345 ),
1346 (
1347 aion::EngineError::NoResidencyVerdict {
1348 workflow_id: "w".to_owned(),
1349 run_id: "r".to_owned(),
1350 },
1351 "invalid_state",
1352 ),
1353 (
1354 aion::EngineError::ContractIdentity {
1355 workflow_type: "orders".to_owned(),
1356 source: aion::ContractIdentityError::RedeployRequired {
1357 stored_version: "orders$deadbeef".to_owned(),
1358 },
1359 },
1360 "invalid_state",
1361 ),
1362 (
1363 aion::EngineError::NoQueueDeclaration {
1364 workflow_type: "orders".to_owned(),
1365 version: aion::ContentHash::from_bytes([5u8; 32]),
1366 activities: "charge_card,send_receipt".to_owned(),
1367 },
1368 "invalid_state",
1369 ),
1370 (aion::EngineError::ShuttingDown, "not_running"),
1371 (
1376 aion::EngineError::Runtime {
1377 reason: "beamr scheduler refused".to_owned(),
1378 },
1379 "backend",
1380 ),
1381 ]
1382 }
1383
1384 fn store_and_query_cases() -> Vec<(aion::EngineError, &'static str)> {
1387 use aion_core::WorkflowId;
1388
1389 vec![
1390 (
1391 aion::EngineError::Store(aion_store::StoreError::NotOwner { shard: 3 }),
1392 "not_owner",
1393 ),
1394 (
1397 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1398 aion_store::StoreError::NotOwner { shard: 3 },
1399 )),
1400 "not_owner",
1401 ),
1402 (
1403 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1404 aion_store::StoreError::NotFound {
1405 workflow_id: WorkflowId::new_v4(),
1406 },
1407 )),
1408 "not_found",
1409 ),
1410 (
1414 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1415 aion_store::StoreError::SequenceConflict {
1416 expected: 4,
1417 found: 7,
1418 },
1419 )),
1420 "backend",
1421 ),
1422 (
1423 aion::EngineError::Query(aion::QueryError::UnknownQuery("balance".to_owned())),
1424 "unknown_query",
1425 ),
1426 (
1427 aion::EngineError::Query(aion::QueryError::Timeout),
1428 "query_timeout",
1429 ),
1430 (
1431 aion::EngineError::Query(aion::QueryError::NotRunning(WorkflowId::new_v4())),
1432 "not_running",
1433 ),
1434 (
1435 aion::EngineError::Query(aion::QueryError::ReplyDropped),
1436 "not_running",
1437 ),
1438 (
1439 aion::EngineError::Query(aion::QueryError::Unknown(WorkflowId::new_v4())),
1440 "not_found",
1441 ),
1442 (
1443 aion::EngineError::Query(aion::QueryError::HandlerFailed {
1444 message: "handler panicked".to_owned(),
1445 }),
1446 "query_failed",
1447 ),
1448 (
1452 aion::EngineError::Query(aion::QueryError::InvalidArguments {
1453 reason: "arguments payload is not a well-formed JSON document".to_owned(),
1454 }),
1455 "invalid_input",
1456 ),
1457 (
1465 aion::EngineError::Query(aion::QueryError::Engine(
1466 aion::engine_seam::EngineSeamError::Delivery {
1467 reason: "mailbox send failed".to_owned(),
1468 },
1469 )),
1470 "backend",
1471 ),
1472 ]
1473 }
1474}