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 envelope = request.request.as_ref().ok_or_else(|| {
241 ClientError::invalid_argument(
242 "list request is missing: a list names its filter, sort, cursor, and limit",
243 )
244 })?;
245 let list_request =
246 aion_proto::decode_core_value::<aion_core::WorkflowListRequest>(envelope)
247 .map_err(ClientError::from_wire_error)?;
248 if list_request.namespace != request.namespace {
249 return Err(ClientError::invalid_argument(format!(
250 "list request names namespace `{}` but the call is scoped to `{}`",
251 list_request.namespace, request.namespace
252 )));
253 }
254 let page = self
255 .engine
256 .list_workflows(&list_request)
257 .await
258 .map_err(|error| map_engine_error(&error))?;
259 let page = aion_proto::encode_core_value(request.namespace, None, &page)
260 .map_err(ClientError::from_wire_error)?;
261 Ok(aion_proto::ProtoListWorkflowsResponse { page: Some(page) })
262 }
263
264 async fn describe_workflow(
265 &self,
266 request: aion_proto::ProtoDescribeWorkflowRequest,
267 ) -> Result<aion_proto::ProtoDescribeWorkflowResponse, ClientError> {
268 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
269 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
270 let history = self
271 .engine
272 .store()
273 .read_history(&workflow_id)
274 .await
275 .map_err(|error| store_error_class(&error, error.to_string()))?;
281 let Some(summary) = aion_core::WorkflowSummary::from_history(&history) else {
282 return Err(ClientError::not_found(format!(
283 "workflow {workflow_id} has no recorded history"
284 )));
285 };
286 let summary = Some(
287 aion_proto::encode_workflow_summary(request.namespace.clone(), None, &summary)
288 .map_err(ClientError::from_wire_error)?,
289 );
290 let history_head_seq = history.last().map_or(0, aion_core::Event::seq);
291 let terminal_event = aion_core::current_lease_terminal(&history)
292 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
293 .transpose()
294 .map_err(ClientError::from_wire_error)?;
295 let history = if request.include_history {
296 history
297 .iter()
298 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
299 .map(|result| result.map_err(ClientError::from_wire_error))
300 .collect::<Result<Vec<_>, _>>()?
301 } else {
302 Vec::new()
303 };
304 Ok(aion_proto::ProtoDescribeWorkflowResponse {
305 summary,
306 history,
307 run_id: Some(run_id.into()),
308 history_head_seq,
309 terminal_event,
310 provenance: Some(aion_proto::ProtoReadProvenance::default()),
311 lease_recording: None,
312 })
313 }
314
315 async fn read_history(
316 &self,
317 request: aion_proto::ProtoReadHistoryRequest,
318 ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
319 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
320 let from_seq = request.from_seq.unwrap_or(0);
321 let mut events = self
322 .engine
323 .store()
324 .read_history_from(&workflow_id, from_seq)
325 .await
326 .map_err(|error| store_error_class(&error, error.to_string()))?;
327 let head_seq = events.last().map_or(0, aion_core::Event::seq);
328 let next_from_seq = request
329 .limit
330 .and_then(|limit| events.get(limit as usize).map(aion_core::Event::seq));
331 if let Some(limit) = request.limit {
332 events.truncate(limit as usize);
333 }
334 let events = events
335 .iter()
336 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
337 .map(|result| result.map_err(ClientError::from_wire_error))
338 .collect::<Result<Vec<_>, _>>()?;
339 Ok(aion_proto::ProtoReadHistoryResponse {
340 events,
341 next_from_seq,
342 head_seq,
343 })
344 }
345
346 async fn subscribe(
347 &self,
348 request: aion_proto::SubscriptionRequest,
349 resume_from_sequence: Option<u64>,
350 ) -> Result<SubscriptionAttempt, ClientError> {
351 let (workflow_target, filter) = embedded_subscription_target(request)?;
352 let live = self.engine.subscribe(filter);
356 let events = match (&workflow_target, resume_from_sequence) {
357 (Some(workflow_id), Some(resume_from_seq)) => {
358 let history = self
361 .engine
362 .store()
363 .read_history(workflow_id)
364 .await
365 .map_err(|error| store_error_class(&error, error.to_string()))?;
366 splice_resume(live, history, resume_from_seq)?
367 }
368 (None, Some(_)) => {
369 return Err(ClientError::invalid_argument(
370 "filtered and firehose event streams are live-only by design; resume \
371 cursors are valid for per-workflow subscriptions only",
372 ));
373 }
374 (_, None) => map_lag(live),
375 };
376 Ok(SubscriptionAttempt::new(match workflow_target {
380 Some(_) => close_after_terminal(events),
381 None => events,
382 }))
383 }
384}
385
386fn splice_resume(
389 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
390 history: Vec<Event>,
391 resume_from_seq: u64,
392) -> Result<BoxStream<'static, Result<Event, ClientError>>, ClientError> {
393 if resume_from_seq == 0 {
394 return Err(ClientError::invalid_argument(
395 "resume_from_seq must be >= 1 (the first sequence number wanted)",
396 ));
397 }
398 let head = history.last().map_or(0, Event::seq);
399 if resume_from_seq > head.saturating_add(1) {
400 return Err(ClientError::invalid_argument(format!(
401 "resume_from_seq {resume_from_seq} is ahead of recorded history (head seq {head}); \
402 the largest valid cursor is {}",
403 head.saturating_add(1)
404 )));
405 }
406
407 let mut history = history;
408 let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
409 let replay = history.split_off(replay_start);
410 let tail = live.filter(move |item| {
411 let keep = match item {
412 Ok(event) => event.seq() > head,
413 Err(aion::EventStreamLagged { .. }) => true,
415 };
416 futures::future::ready(keep)
417 });
418
419 Ok(stream::iter(replay.into_iter().map(Ok))
420 .chain(map_lag(tail.boxed()))
421 .boxed())
422}
423
424fn map_lag(
427 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
428) -> BoxStream<'static, Result<Event, ClientError>> {
429 live.map(|item| {
430 item.map_err(|lagged| {
431 ClientError::from_wire_error(aion_proto::WireError::lagged(lagged.to_string()))
432 })
433 })
434 .boxed()
435}
436
437fn close_after_terminal(
440 events: BoxStream<'static, Result<Event, ClientError>>,
441) -> BoxStream<'static, Result<Event, ClientError>> {
442 stream::unfold(Some(events), |state| async move {
443 let mut events = state?;
444 let item = events.next().await?;
445 let closed = matches!(&item, Ok(event) if is_terminal_workflow_event(event));
449 Some((item, if closed { None } else { Some(events) }))
450 })
451 .boxed()
452}
453
454fn is_terminal_workflow_event(event: &Event) -> bool {
455 matches!(
456 event,
457 Event::WorkflowCompleted { .. }
458 | Event::WorkflowFailed { .. }
459 | Event::WorkflowCancelled { .. }
460 | Event::WorkflowTimedOut { .. }
461 | Event::WorkflowContinuedAsNew { .. }
462 )
463}
464
465fn decode_required_workflow_id(
466 value: Option<aion_proto::ProtoWorkflowId>,
467) -> Result<aion_core::WorkflowId, ClientError> {
468 value
469 .ok_or_else(|| ClientError::invalid_argument("request workflow id is missing"))?
470 .try_into()
471 .map_err(ClientError::from_wire_error)
472}
473
474fn decode_required_run_id(
475 value: Option<aion_proto::ProtoRunId>,
476) -> Result<aion_core::RunId, ClientError> {
477 value
478 .ok_or_else(|| ClientError::invalid_argument("request run id is missing"))?
479 .try_into()
480 .map_err(ClientError::from_wire_error)
481}
482
483fn embedded_subscription_target(
486 request: aion_proto::SubscriptionRequest,
487) -> Result<(Option<aion_core::WorkflowId>, aion::EventFilter), ClientError> {
488 match request.subscription {
489 Some(aion_proto::subscription_request::Subscription::PerWorkflow(subscription)) => {
490 let workflow_id = subscription
491 .workflow_id
492 .ok_or_else(|| {
493 ClientError::invalid_argument(
494 "per-workflow subscription requires a workflow id",
495 )
496 })?
497 .try_into()
498 .map_err(ClientError::from_wire_error)?;
499 Ok((
500 Some(aion_core::WorkflowId::clone(&workflow_id)),
501 aion::EventFilter {
502 workflow_id: Some(workflow_id),
503 run: None,
504 family: None,
505 },
506 ))
507 }
508 Some(
509 aion_proto::subscription_request::Subscription::Filtered(_)
510 | aion_proto::subscription_request::Subscription::Firehose(_),
511 ) => Ok((None, aion::EventFilter::default())),
512 Some(aion_proto::subscription_request::Subscription::Cluster(_)) => {
513 Err(ClientError::invalid_argument(
519 "cluster topology subscriptions are not supported by the embedded in-process \
520 transport; connect to an aion-server over gRPC/WebSocket to subscribe to the \
521 cluster channel",
522 ))
523 }
524 Some(aion_proto::subscription_request::Subscription::Transcript(_)) => {
525 Err(ClientError::invalid_argument(
531 "agent-observability transcript subscriptions are not supported by the embedded \
532 in-process transport; connect to an aion-server over gRPC/WebSocket to subscribe \
533 to the transcript channel",
534 ))
535 }
536 None => Err(ClientError::invalid_argument(
537 "subscription request is missing its subscription variant",
538 )),
539 }
540}
541
542fn map_engine_error(error: &aion::EngineError) -> ClientError {
563 match error {
564 aion::EngineError::WorkflowNotFound { .. } => ClientError::not_found(error.to_string()),
565 aion::EngineError::InvalidState { .. } => ClientError::invalid_state(error.to_string()),
568 aion::EngineError::StartInputRefused { .. } | aion::EngineError::SignalRefused { .. } => {
577 ClientError::invalid_argument(error.to_string())
578 }
579 aion::EngineError::TerminalWriterUnavailable { .. }
595 | aion::EngineError::TerminalWriterHeld { .. }
596 | aion::EngineError::RunIsRecoverable { .. }
597 | aion::EngineError::NoResidencyVerdict { .. }
598 | aion::EngineError::ContractIdentity { .. }
599 | aion::EngineError::NoQueueDeclaration { .. } => {
600 ClientError::invalid_state(error.to_string())
601 }
602 aion::EngineError::Store(store)
613 | aion::EngineError::Durability(aion::durability::DurabilityError::Store(store)) => {
614 store_error_class(store, error.to_string())
615 }
616 aion::EngineError::Query(query) => query_error_class(query, error),
624 aion::EngineError::ShuttingDown => ClientError::NotRunning {
666 detail: crate::ErrorDetail::with_type(error.to_string(), "ShuttingDown"),
667 },
668 _ => ClientError::server(error.to_string()),
669 }
670}
671
672fn store_error_class(error: &aion_store::StoreError, message: String) -> ClientError {
704 match error {
705 aion_store::StoreError::NotOwner { .. } => ClientError::not_owner(message),
706 aion_store::StoreError::NotFound { .. }
712 | aion_store::StoreError::AssistantSessionNotFound { .. } => {
713 ClientError::not_found(message)
714 }
715 aion_store::StoreError::SequenceConflict { .. }
722 | aion_store::StoreError::Backend(_)
723 | aion_store::StoreError::Serialization(_) => ClientError::server(message),
724 aion_store::StoreError::InvalidQuery(_) => ClientError::invalid_argument(message),
727 }
728}
729
730fn query_error_class(error: &aion::QueryError, source: &aion::EngineError) -> ClientError {
737 match error {
738 aion::QueryError::UnknownQuery(_) => ClientError::unknown_query(source.to_string()),
739 aion::QueryError::Timeout => ClientError::query_timeout(source.to_string()),
740 aion::QueryError::NotRunning(_) | aion::QueryError::ReplyDropped => {
743 ClientError::not_running(source.to_string())
744 }
745 aion::QueryError::Unknown(_) => ClientError::not_found(source.to_string()),
746 aion::QueryError::HandlerFailed { .. } => ClientError::query_failed(source.to_string()),
750 aion::QueryError::InvalidArguments { .. } => {
753 ClientError::invalid_argument(source.to_string())
754 }
755 aion::QueryError::Engine(_) => ClientError::server(source.to_string()),
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use std::num::NonZeroUsize;
762 use std::time::Duration;
763
764 use aion::EventStreamLagged;
765 use aion_core::{Event, EventEnvelope, Payload, RunId, WorkflowId};
766 use chrono::Utc;
767 use futures::{StreamExt, stream};
768
769 use super::{close_after_terminal, map_lag, splice_resume};
770 use crate::error::ClientError;
771
772 fn workflow_id() -> WorkflowId {
773 WorkflowId::new(uuid::Uuid::from_u128(1))
774 }
775
776 fn envelope(seq: u64) -> EventEnvelope {
777 EventEnvelope {
778 seq,
779 recorded_at: Utc::now(),
780 workflow_id: workflow_id(),
781 }
782 }
783
784 fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
785 Ok(Event::SignalReceived {
786 envelope: envelope(seq),
787 name: format!("signal-{seq}"),
788 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
789 })
790 }
791
792 fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
793 Ok(Event::WorkflowCompleted {
794 envelope: envelope(seq),
795 result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
796 })
797 }
798
799 fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
800 seqs.map(signal).collect()
801 }
802
803 fn live(
804 items: Vec<Result<Event, EventStreamLagged>>,
805 ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
806 stream::iter(items).boxed()
807 }
808
809 async fn delivered_seqs(
810 events: futures::stream::BoxStream<'static, Result<Event, ClientError>>,
811 ) -> Result<Vec<u64>, ClientError> {
812 events
813 .map(|item| item.map(|event| event.seq()))
814 .collect::<Vec<_>>()
815 .await
816 .into_iter()
817 .collect()
818 }
819
820 #[tokio::test]
821 async fn cursor_zero_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
822 let error = splice_resume(live(Vec::new()), history(1..=3)?, 0).err();
823
824 let Some(ClientError::InvalidArgument { detail }) = error else {
825 return Err(format!("cursor 0 must be InvalidArgument, got {error:?}").into());
826 };
827 assert!(detail.message.contains(">= 1"), "detail: {detail}");
828 Ok(())
829 }
830
831 #[tokio::test]
832 async fn cursor_ahead_of_history_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>>
833 {
834 let error = splice_resume(live(Vec::new()), history(1..=5)?, 7).err();
835
836 let Some(ClientError::InvalidArgument { detail }) = error else {
837 return Err(format!("cursor head+2 must be InvalidArgument, got {error:?}").into());
838 };
839 assert!(
840 detail.message.contains("ahead of recorded history"),
841 "{detail}"
842 );
843
844 let empty = splice_resume(live(Vec::new()), Vec::new(), 2).err();
845 assert!(
846 matches!(empty, Some(ClientError::InvalidArgument { .. })),
847 "cursor 2 over empty history must be rejected, got {empty:?}"
848 );
849 Ok(())
850 }
851
852 #[tokio::test]
853 async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
854 -> Result<(), Box<dyn std::error::Error>> {
855 let events = splice_resume(
858 live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
859 history(1..=5)?,
860 1,
861 )?;
862
863 assert_eq!(delivered_seqs(events).await?, vec![1, 2, 3, 4, 5, 6]);
864 Ok(())
865 }
866
867 #[tokio::test]
868 async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
869 let events = splice_resume(live(vec![Ok(signal(6)?)]), history(1..=5)?, 3)?;
870
871 assert_eq!(delivered_seqs(events).await?, vec![3, 4, 5, 6]);
872 Ok(())
873 }
874
875 #[tokio::test]
876 async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
877 -> Result<(), Box<dyn std::error::Error>> {
878 let events = splice_resume(
879 live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
880 history(1..=5)?,
881 6,
882 )?;
883
884 assert_eq!(delivered_seqs(events).await?, vec![6, 7]);
885 Ok(())
886 }
887
888 #[tokio::test]
889 async fn lag_mid_splice_surfaces_unavailable_after_the_replay()
890 -> Result<(), Box<dyn std::error::Error>> {
891 let events = splice_resume(
892 live(vec![Err(EventStreamLagged { skipped: 3 })]),
893 history(1..=2)?,
894 1,
895 )?;
896 let collected: Vec<_> = events.collect().await;
897
898 assert_eq!(collected.len(), 3, "two replay events then the lag item");
899 assert!(collected[0].is_ok() && collected[1].is_ok());
900 assert!(
901 matches!(
902 collected[2].as_ref().err(),
903 Some(ClientError::Unavailable { .. })
904 ),
905 "lag must surface as retryable Unavailable, never a silent gap, got {:?}",
906 collected[2]
907 );
908 Ok(())
909 }
910
911 #[tokio::test]
912 async fn per_workflow_stream_closes_after_terminal_event()
913 -> Result<(), Box<dyn std::error::Error>> {
914 let mut history = history(1..=2)?;
917 history.push(completed(3)?);
918 history.push(signal(4)?);
919 let events = splice_resume(live(vec![Ok(signal(5)?)]), history, 1)?;
920
921 assert_eq!(
922 delivered_seqs(close_after_terminal(events)).await?,
923 vec![1, 2, 3],
924 "the stream must close after the terminal event"
925 );
926 Ok(())
927 }
928
929 #[tokio::test]
930 async fn live_lag_maps_to_unavailable() -> Result<(), Box<dyn std::error::Error>> {
931 let events = map_lag(live(vec![
932 Ok(signal(1)?),
933 Err(EventStreamLagged { skipped: 9 }),
934 ]));
935 let collected: Vec<_> = events.collect().await;
936
937 assert_eq!(collected.len(), 2);
938 assert!(
939 matches!(
940 collected[1].as_ref().err(),
941 Some(ClientError::Unavailable { .. })
942 ),
943 "got {:?}",
944 collected[1]
945 );
946 Ok(())
947 }
948
949 #[tokio::test]
953 async fn embedded_resume_splices_recorded_history_with_live_appends()
954 -> Result<(), Box<dyn std::error::Error>> {
955 use crate::stream::SubscribeTarget;
956 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
957
958 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
959 let engine = std::sync::Arc::new(
960 aion::EngineBuilder::new()
961 .stop_drain_timeout(std::time::Duration::from_secs(5))
962 .store(aion_store::InMemoryStore::default())
963 .in_memory_visibility()
964 .event_streaming(capacity)
965 .build()
966 .await?,
967 );
968 let workflow_id = WorkflowId::new_v4();
969 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
970 recorder
971 .record_workflow_started(
972 Utc::now(),
973 aion::durability::WorkflowStartRecord {
974 workflow_type: String::from("checkout"),
975 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
976 run_id: RunId::new(uuid::Uuid::from_u128(7)),
977 parent_run_id: None,
978 parent_workflow_id: None,
979 package_version: aion_core::PackageVersion::new("a".repeat(64)),
980 },
981 )
982 .await?;
983 for seq in 2..=3 {
984 recorder
985 .record_signal_received(
986 Utc::now(),
987 format!("signal-{seq}"),
988 Payload::from_json(&serde_json::json!({ "seq": seq }))?,
989 )
990 .await?;
991 }
992
993 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
996 let request = SubscribeTarget::Workflow {
997 workflow_id: workflow_id.clone(),
998 }
999 .request("default");
1000 let attempt = transport.subscribe(request, Some(2)).await?;
1001 let mut events = attempt.events;
1002
1003 let mut delivered = Vec::new();
1004 for _ in 0..2 {
1005 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
1006 .await
1007 .map_err(|_| "timed out waiting for a replay event")?
1008 .ok_or("stream ended before the replay completed")?;
1009 delivered.push(item?.seq());
1010 }
1011 recorder
1012 .record_workflow_completed(
1013 Utc::now(),
1014 Payload::from_json(&serde_json::json!({ "done": true }))?,
1015 )
1016 .await?;
1017 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
1018 .await
1019 .map_err(|_| "timed out waiting for the live spliced event")?
1020 .ok_or("stream ended before the live event arrived")?;
1021 delivered.push(item?.seq());
1022 assert_eq!(delivered, vec![2, 3, 4]);
1023
1024 let end = tokio::time::timeout(Duration::from_secs(2), events.next())
1026 .await
1027 .map_err(|_| "timed out waiting for the post-terminal close")?;
1028 assert!(
1029 end.is_none(),
1030 "per-workflow stream must close after the terminal event, got {end:?}"
1031 );
1032
1033 let ahead = transport
1035 .subscribe(
1036 SubscribeTarget::Workflow { workflow_id }.request("default"),
1037 Some(9),
1038 )
1039 .await
1040 .err();
1041 assert!(
1042 matches!(ahead, Some(ClientError::InvalidArgument { .. })),
1043 "cursor ahead of history must be InvalidArgument, got {ahead:?}"
1044 );
1045
1046 engine.shutdown()?;
1047 Ok(())
1048 }
1049
1050 #[tokio::test]
1073 async fn a_directly_read_store_refusal_keeps_its_routing_class()
1074 -> Result<(), Box<dyn std::error::Error>> {
1075 use aion_store::testing::FencedHistoryStore;
1076
1077 use crate::stream::SubscribeTarget;
1078 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
1079
1080 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
1081 let store = std::sync::Arc::new(FencedHistoryStore::new());
1082 let engine = std::sync::Arc::new(
1083 aion::EngineBuilder::new()
1084 .stop_drain_timeout(std::time::Duration::from_secs(5))
1085 .store_arc(
1086 std::sync::Arc::clone(&store) as std::sync::Arc<dyn aion_store::EventStore>
1087 )
1088 .in_memory_visibility()
1089 .event_streaming(capacity)
1090 .build()
1091 .await?,
1092 );
1093 let workflow_id = WorkflowId::new_v4();
1094 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
1095 recorder
1096 .record_workflow_started(
1097 Utc::now(),
1098 aion::durability::WorkflowStartRecord {
1099 workflow_type: String::from("checkout"),
1100 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
1101 run_id: RunId::new(uuid::Uuid::from_u128(11)),
1102 parent_run_id: None,
1103 parent_workflow_id: None,
1104 package_version: aion_core::PackageVersion::new("b".repeat(64)),
1105 },
1106 )
1107 .await?;
1108
1109 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
1110 let describe = |include_history: bool| aion_proto::ProtoDescribeWorkflowRequest {
1111 namespace: String::from("default"),
1112 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1113 run_id: None,
1114 include_history,
1115 };
1116
1117 transport.describe_workflow(describe(true)).await?;
1131 transport
1132 .subscribe(
1133 SubscribeTarget::Workflow {
1134 workflow_id: workflow_id.clone(),
1135 }
1136 .request("default"),
1137 Some(1),
1138 )
1139 .await?;
1140 let reopen_request = || aion_proto::ProtoReopenRequest {
1141 namespace: String::from("default"),
1142 workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1143 run_id: None,
1144 };
1145 let reopen_control = transport.reopen(reopen_request()).await;
1146 assert_ne!(
1147 reopen_control.as_ref().err().map(ClientError::class),
1148 Some("not_owner"),
1149 "the reopen control answered `not_owner` with the fence DISARMED, so the armed \
1150 assertion below would prove nothing: {reopen_control:?}"
1151 );
1152
1153 store.arm_fence();
1154
1155 let described = transport.describe_workflow(describe(false)).await;
1156 assert_eq!(
1157 described.as_ref().err().map(ClientError::class),
1158 Some("not_owner"),
1159 "describe_workflow flattened a shard-ownership refusal into an unroutable class: \
1160 {described:?}"
1161 );
1162
1163 let subscribed = transport
1164 .subscribe(
1165 SubscribeTarget::Workflow {
1166 workflow_id: workflow_id.clone(),
1167 }
1168 .request("default"),
1169 Some(1),
1170 )
1171 .await;
1172 assert_eq!(
1173 subscribed.as_ref().err().map(ClientError::class),
1174 Some("not_owner"),
1175 "the resuming half of subscribe flattened a shard-ownership refusal into an \
1176 unroutable class"
1177 );
1178
1179 let reopened = transport.reopen(reopen_request()).await;
1183 assert_eq!(
1184 reopened.as_ref().err().map(ClientError::class),
1185 Some("not_owner"),
1186 "resolve_run_id flattened a shard-ownership refusal into an unroutable class: \
1187 {reopened:?}"
1188 );
1189
1190 store.disarm_fence();
1198 engine.shutdown()?;
1199 Ok(())
1200 }
1201
1202 #[test]
1245 fn every_named_engine_error_reaches_its_own_class() {
1246 use super::map_engine_error;
1247
1248 for (error, expected) in engine_error_class_table() {
1249 assert_eq!(
1250 map_engine_error(&error).class(),
1251 expected,
1252 "wrong class for {error}"
1253 );
1254 }
1255 }
1256
1257 #[test]
1269 fn shutting_down_carries_its_discriminator_not_only_its_class()
1270 -> Result<(), Box<dyn std::error::Error>> {
1271 let mapped = super::map_engine_error(&aion::EngineError::ShuttingDown);
1272 let ClientError::NotRunning { detail } = &mapped else {
1273 return Err(format!("ShuttingDown must keep the not_running CLASS: {mapped}").into());
1274 };
1275 assert_eq!(
1276 detail.error_type.as_deref(),
1277 Some("ShuttingDown"),
1278 "the wire surface builds `not_running_with_type(\"ShuttingDown\", …)`; an embedded \
1279 caller that loses the discriminator cannot be told anything true about why"
1280 );
1281 let other = super::map_engine_error(&aion::EngineError::Runtime {
1284 reason: "beamr scheduler refused".to_owned(),
1285 });
1286 assert_ne!(
1287 other.class(),
1288 "not_running",
1289 "control: the negative case must not share the class under test"
1290 );
1291 Ok(())
1292 }
1293
1294 fn engine_error_class_table() -> Vec<(aion::EngineError, &'static str)> {
1298 let mut cases = admission_and_run_state_cases();
1299 cases.extend(store_and_query_cases());
1300 cases
1301 }
1302
1303 fn admission_and_run_state_cases() -> Vec<(aion::EngineError, &'static str)> {
1306 use aion_core::{RunId, WorkflowId};
1307
1308 let version = aion::ContentHash::from_bytes([7u8; 32]);
1309 vec![
1310 (
1315 aion::EngineError::WorkflowNotFound {
1316 workflow_type: "orders".to_owned(),
1317 },
1318 "not_found",
1319 ),
1320 (
1321 aion::EngineError::InvalidState {
1322 reason: "workflow w run r is Running, not terminal".to_owned(),
1323 },
1324 "invalid_state",
1325 ),
1326 (
1327 aion::EngineError::StartInputRefused {
1328 workflow_type: "orders".to_owned(),
1329 version: version.clone(),
1330 reason: "field `total` is missing".to_owned(),
1331 },
1332 "invalid_input",
1333 ),
1334 (
1335 aion::EngineError::SignalRefused {
1336 workflow_id: WorkflowId::new_v4(),
1337 run_id: RunId::new_v4(),
1338 signal_name: "approve".to_owned(),
1339 version,
1340 reason: "undeclared signal".to_owned(),
1341 },
1342 "invalid_input",
1343 ),
1344 (
1345 aion::EngineError::TerminalWriterUnavailable {
1346 workflow_id: "w".to_owned(),
1347 run_id: "r".to_owned(),
1348 holder: "another reservation".to_owned(),
1349 },
1350 "invalid_state",
1351 ),
1352 (
1353 aion::EngineError::TerminalWriterHeld {
1354 workflow_id: "w".to_owned(),
1355 run_id: "r".to_owned(),
1356 },
1357 "invalid_state",
1358 ),
1359 (
1360 aion::EngineError::RunIsRecoverable {
1361 workflow_id: "w".to_owned(),
1362 run_id: "r".to_owned(),
1363 version: "abc".to_owned(),
1364 },
1365 "invalid_state",
1366 ),
1367 (
1368 aion::EngineError::NoResidencyVerdict {
1369 workflow_id: "w".to_owned(),
1370 run_id: "r".to_owned(),
1371 },
1372 "invalid_state",
1373 ),
1374 (
1375 aion::EngineError::ContractIdentity {
1376 workflow_type: "orders".to_owned(),
1377 source: aion::ContractIdentityError::RedeployRequired {
1378 stored_version: "orders$deadbeef".to_owned(),
1379 },
1380 },
1381 "invalid_state",
1382 ),
1383 (
1384 aion::EngineError::NoQueueDeclaration {
1385 workflow_type: "orders".to_owned(),
1386 version: aion::ContentHash::from_bytes([5u8; 32]),
1387 activities: "charge_card,send_receipt".to_owned(),
1388 },
1389 "invalid_state",
1390 ),
1391 (aion::EngineError::ShuttingDown, "not_running"),
1392 (
1397 aion::EngineError::Runtime {
1398 reason: "beamr scheduler refused".to_owned(),
1399 },
1400 "backend",
1401 ),
1402 ]
1403 }
1404
1405 fn store_and_query_cases() -> Vec<(aion::EngineError, &'static str)> {
1408 use aion_core::WorkflowId;
1409
1410 vec![
1411 (
1412 aion::EngineError::Store(aion_store::StoreError::NotOwner { shard: 3 }),
1413 "not_owner",
1414 ),
1415 (
1418 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1419 aion_store::StoreError::NotOwner { shard: 3 },
1420 )),
1421 "not_owner",
1422 ),
1423 (
1424 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1425 aion_store::StoreError::NotFound {
1426 workflow_id: WorkflowId::new_v4(),
1427 },
1428 )),
1429 "not_found",
1430 ),
1431 (
1435 aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1436 aion_store::StoreError::SequenceConflict {
1437 expected: 4,
1438 found: 7,
1439 },
1440 )),
1441 "backend",
1442 ),
1443 (
1444 aion::EngineError::Query(aion::QueryError::UnknownQuery("balance".to_owned())),
1445 "unknown_query",
1446 ),
1447 (
1448 aion::EngineError::Query(aion::QueryError::Timeout),
1449 "query_timeout",
1450 ),
1451 (
1452 aion::EngineError::Query(aion::QueryError::NotRunning(WorkflowId::new_v4())),
1453 "not_running",
1454 ),
1455 (
1456 aion::EngineError::Query(aion::QueryError::ReplyDropped),
1457 "not_running",
1458 ),
1459 (
1460 aion::EngineError::Query(aion::QueryError::Unknown(WorkflowId::new_v4())),
1461 "not_found",
1462 ),
1463 (
1464 aion::EngineError::Query(aion::QueryError::HandlerFailed {
1465 message: "handler panicked".to_owned(),
1466 }),
1467 "query_failed",
1468 ),
1469 (
1473 aion::EngineError::Query(aion::QueryError::InvalidArguments {
1474 reason: "arguments payload is not a well-formed JSON document".to_owned(),
1475 }),
1476 "invalid_input",
1477 ),
1478 (
1486 aion::EngineError::Query(aion::QueryError::Engine(
1487 aion::engine_seam::EngineSeamError::Delivery {
1488 reason: "mailbox send failed".to_owned(),
1489 },
1490 )),
1491 "backend",
1492 ),
1493 ]
1494 }
1495}