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| ClientError::server(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 payload = self
126 .engine
127 .query(&workflow_id, &run_id, request.query_name)
128 .await
129 .map_err(|error| map_engine_error(&error))?;
130 Ok(aion_proto::ProtoQueryResponse {
131 outcome: Some(aion_proto::proto_query_response::Outcome::Result(
132 aion_proto::ProtoPayload::from(payload),
133 )),
134 })
135 }
136
137 async fn cancel(
138 &self,
139 request: aion_proto::ProtoCancelRequest,
140 ) -> Result<aion_proto::ProtoCancelResponse, ClientError> {
141 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
142 let run_id = decode_required_run_id(request.run_id)?;
143 self.engine
144 .cancel(&workflow_id, &run_id, request.reason)
145 .await
146 .map_err(|error| map_engine_error(&error))?;
147 Ok(aion_proto::ProtoCancelResponse {})
148 }
149
150 async fn reopen(
151 &self,
152 request: aion_proto::ProtoReopenRequest,
153 ) -> Result<aion_proto::ProtoReopenResponse, ClientError> {
154 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
155 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
156 let handle = self
157 .engine
158 .reopen_workflow(&workflow_id, &run_id)
159 .await
160 .map_err(|error| map_engine_error(&error))?;
161 Ok(aion_proto::ProtoReopenResponse {
162 run_id: Some(handle.run_id().clone().into()),
163 status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
164 })
165 }
166
167 async fn pause(
168 &self,
169 request: aion_proto::ProtoPauseRequest,
170 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
171 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
172 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
173 let reason = if request.reason.is_empty() {
174 None
175 } else {
176 Some(request.reason)
177 };
178 let handle = self
179 .engine
180 .pause_workflow(&workflow_id, &run_id, reason, None)
181 .await
182 .map_err(|error| map_engine_error(&error))?;
183 Ok(aion_proto::ProtoPauseResponse {
184 run_id: Some(handle.run_id().clone().into()),
185 status: aion_proto::ProtoWorkflowStatus::Paused as i32,
186 })
187 }
188
189 async fn resume(
190 &self,
191 request: aion_proto::ProtoResumeRequest,
192 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
193 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
194 let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
195 let handle = self
196 .engine
197 .resume_paused_workflow(&workflow_id, &run_id, None)
198 .await
199 .map_err(|error| map_engine_error(&error))?;
200 Ok(aion_proto::ProtoResumeResponse {
201 run_id: Some(handle.run_id().clone().into()),
202 status: aion_proto::ProtoWorkflowStatus::Running as i32,
203 })
204 }
205
206 async fn list_workflows(
207 &self,
208 request: aion_proto::ProtoListWorkflowsRequest,
209 ) -> Result<aion_proto::ProtoListWorkflowsResponse, ClientError> {
210 let filter = match request.filter.as_ref() {
211 Some(filter) => {
212 aion_proto::decode_workflow_filter(filter).map_err(ClientError::from_wire_error)?
213 }
214 None => aion_core::WorkflowFilter::default(),
215 };
216 let summaries = self
217 .engine
218 .list_workflows(filter)
219 .await
220 .map_err(|error| map_engine_error(&error))?
221 .iter()
222 .map(|summary| {
223 aion_proto::encode_workflow_summary(request.namespace.clone(), None, summary)
224 })
225 .map(|result| result.map_err(ClientError::from_wire_error))
226 .collect::<Result<Vec<_>, _>>()?;
227 Ok(aion_proto::ProtoListWorkflowsResponse { summaries })
228 }
229
230 async fn describe_workflow(
231 &self,
232 request: aion_proto::ProtoDescribeWorkflowRequest,
233 ) -> Result<aion_proto::ProtoDescribeWorkflowResponse, ClientError> {
234 let workflow_id = decode_required_workflow_id(request.workflow_id)?;
235 let history = self
236 .engine
237 .store()
238 .read_history(&workflow_id)
239 .await
240 .map_err(|error| ClientError::server(error.to_string()))?;
241 let Some(summary) = aion_core::WorkflowSummary::from_history(&history) else {
242 return Err(ClientError::not_found(format!(
243 "workflow {workflow_id} has no recorded history"
244 )));
245 };
246 let summary = Some(
247 aion_proto::encode_workflow_summary(request.namespace.clone(), None, &summary)
248 .map_err(ClientError::from_wire_error)?,
249 );
250 let history = if request.include_history {
251 history
252 .iter()
253 .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
254 .map(|result| result.map_err(ClientError::from_wire_error))
255 .collect::<Result<Vec<_>, _>>()?
256 } else {
257 Vec::new()
258 };
259 Ok(aion_proto::ProtoDescribeWorkflowResponse { summary, history })
260 }
261
262 async fn subscribe(
263 &self,
264 request: aion_proto::SubscriptionRequest,
265 resume_from_sequence: Option<u64>,
266 ) -> Result<SubscriptionAttempt, ClientError> {
267 let (workflow_target, filter) = embedded_subscription_target(request)?;
268 let live = self.engine.subscribe(filter);
272 let events = match (&workflow_target, resume_from_sequence) {
273 (Some(workflow_id), Some(resume_from_seq)) => {
274 let history = self
277 .engine
278 .store()
279 .read_history(workflow_id)
280 .await
281 .map_err(|error| ClientError::server(error.to_string()))?;
282 splice_resume(live, history, resume_from_seq)?
283 }
284 (None, Some(_)) => {
285 return Err(ClientError::invalid_argument(
286 "filtered and firehose event streams are live-only by design; resume \
287 cursors are valid for per-workflow subscriptions only",
288 ));
289 }
290 (_, None) => map_lag(live),
291 };
292 Ok(SubscriptionAttempt::new(match workflow_target {
296 Some(_) => close_after_terminal(events),
297 None => events,
298 }))
299 }
300}
301
302fn splice_resume(
305 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
306 history: Vec<Event>,
307 resume_from_seq: u64,
308) -> Result<BoxStream<'static, Result<Event, ClientError>>, ClientError> {
309 if resume_from_seq == 0 {
310 return Err(ClientError::invalid_argument(
311 "resume_from_seq must be >= 1 (the first sequence number wanted)",
312 ));
313 }
314 let head = history.last().map_or(0, Event::seq);
315 if resume_from_seq > head.saturating_add(1) {
316 return Err(ClientError::invalid_argument(format!(
317 "resume_from_seq {resume_from_seq} is ahead of recorded history (head seq {head}); \
318 the largest valid cursor is {}",
319 head.saturating_add(1)
320 )));
321 }
322
323 let mut history = history;
324 let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
325 let replay = history.split_off(replay_start);
326 let tail = live.filter(move |item| {
327 let keep = match item {
328 Ok(event) => event.seq() > head,
329 Err(aion::EventStreamLagged { .. }) => true,
331 };
332 futures::future::ready(keep)
333 });
334
335 Ok(stream::iter(replay.into_iter().map(Ok))
336 .chain(map_lag(tail.boxed()))
337 .boxed())
338}
339
340fn map_lag(
343 live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
344) -> BoxStream<'static, Result<Event, ClientError>> {
345 live.map(|item| {
346 item.map_err(|lagged| {
347 ClientError::from_wire_error(aion_proto::WireError::lagged(lagged.to_string()))
348 })
349 })
350 .boxed()
351}
352
353fn close_after_terminal(
356 events: BoxStream<'static, Result<Event, ClientError>>,
357) -> BoxStream<'static, Result<Event, ClientError>> {
358 stream::unfold(Some(events), |state| async move {
359 let mut events = state?;
360 let item = events.next().await?;
361 let closed = matches!(&item, Ok(event) if is_terminal_workflow_event(event));
365 Some((item, if closed { None } else { Some(events) }))
366 })
367 .boxed()
368}
369
370fn is_terminal_workflow_event(event: &Event) -> bool {
371 matches!(
372 event,
373 Event::WorkflowCompleted { .. }
374 | Event::WorkflowFailed { .. }
375 | Event::WorkflowCancelled { .. }
376 | Event::WorkflowTimedOut { .. }
377 | Event::WorkflowContinuedAsNew { .. }
378 )
379}
380
381fn decode_required_workflow_id(
382 value: Option<aion_proto::ProtoWorkflowId>,
383) -> Result<aion_core::WorkflowId, ClientError> {
384 value
385 .ok_or_else(|| ClientError::invalid_argument("request workflow id is missing"))?
386 .try_into()
387 .map_err(ClientError::from_wire_error)
388}
389
390fn decode_required_run_id(
391 value: Option<aion_proto::ProtoRunId>,
392) -> Result<aion_core::RunId, ClientError> {
393 value
394 .ok_or_else(|| ClientError::invalid_argument("request run id is missing"))?
395 .try_into()
396 .map_err(ClientError::from_wire_error)
397}
398
399fn embedded_subscription_target(
402 request: aion_proto::SubscriptionRequest,
403) -> Result<(Option<aion_core::WorkflowId>, aion::EventFilter), ClientError> {
404 match request.subscription {
405 Some(aion_proto::subscription_request::Subscription::PerWorkflow(subscription)) => {
406 let workflow_id = subscription
407 .workflow_id
408 .ok_or_else(|| {
409 ClientError::invalid_argument(
410 "per-workflow subscription requires a workflow id",
411 )
412 })?
413 .try_into()
414 .map_err(ClientError::from_wire_error)?;
415 Ok((
416 Some(aion_core::WorkflowId::clone(&workflow_id)),
417 aion::EventFilter {
418 workflow_id: Some(workflow_id),
419 run: None,
420 family: None,
421 },
422 ))
423 }
424 Some(
425 aion_proto::subscription_request::Subscription::Filtered(_)
426 | aion_proto::subscription_request::Subscription::Firehose(_),
427 ) => Ok((None, aion::EventFilter::default())),
428 Some(aion_proto::subscription_request::Subscription::Cluster(_)) => {
429 Err(ClientError::invalid_argument(
435 "cluster topology subscriptions are not supported by the embedded in-process \
436 transport; connect to an aion-server over gRPC/WebSocket to subscribe to the \
437 cluster channel",
438 ))
439 }
440 Some(aion_proto::subscription_request::Subscription::Transcript(_)) => {
441 Err(ClientError::invalid_argument(
447 "agent-observability transcript subscriptions are not supported by the embedded \
448 in-process transport; connect to an aion-server over gRPC/WebSocket to subscribe \
449 to the transcript channel",
450 ))
451 }
452 None => Err(ClientError::invalid_argument(
453 "subscription request is missing its subscription variant",
454 )),
455 }
456}
457
458fn map_engine_error(error: &aion::EngineError) -> ClientError {
459 match error {
460 aion::EngineError::WorkflowNotFound { .. } => ClientError::not_found(error.to_string()),
461 aion::EngineError::InvalidState { .. } => ClientError::invalid_state(error.to_string()),
464 aion::EngineError::ShuttingDown => ClientError::unavailable(error.to_string()),
465 _ => ClientError::server(error.to_string()),
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use std::num::NonZeroUsize;
472 use std::time::Duration;
473
474 use aion::EventStreamLagged;
475 use aion_core::{Event, EventEnvelope, Payload, RunId, WorkflowId};
476 use chrono::Utc;
477 use futures::{StreamExt, stream};
478
479 use super::{close_after_terminal, map_lag, splice_resume};
480 use crate::error::ClientError;
481
482 fn workflow_id() -> WorkflowId {
483 WorkflowId::new(uuid::Uuid::from_u128(1))
484 }
485
486 fn envelope(seq: u64) -> EventEnvelope {
487 EventEnvelope {
488 seq,
489 recorded_at: Utc::now(),
490 workflow_id: workflow_id(),
491 }
492 }
493
494 fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
495 Ok(Event::SignalReceived {
496 envelope: envelope(seq),
497 name: format!("signal-{seq}"),
498 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
499 })
500 }
501
502 fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
503 Ok(Event::WorkflowCompleted {
504 envelope: envelope(seq),
505 result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
506 })
507 }
508
509 fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
510 seqs.map(signal).collect()
511 }
512
513 fn live(
514 items: Vec<Result<Event, EventStreamLagged>>,
515 ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
516 stream::iter(items).boxed()
517 }
518
519 async fn delivered_seqs(
520 events: futures::stream::BoxStream<'static, Result<Event, ClientError>>,
521 ) -> Result<Vec<u64>, ClientError> {
522 events
523 .map(|item| item.map(|event| event.seq()))
524 .collect::<Vec<_>>()
525 .await
526 .into_iter()
527 .collect()
528 }
529
530 #[tokio::test]
531 async fn cursor_zero_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
532 let error = splice_resume(live(Vec::new()), history(1..=3)?, 0).err();
533
534 let Some(ClientError::InvalidArgument { detail }) = error else {
535 return Err(format!("cursor 0 must be InvalidArgument, got {error:?}").into());
536 };
537 assert!(detail.message.contains(">= 1"), "detail: {detail}");
538 Ok(())
539 }
540
541 #[tokio::test]
542 async fn cursor_ahead_of_history_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>>
543 {
544 let error = splice_resume(live(Vec::new()), history(1..=5)?, 7).err();
545
546 let Some(ClientError::InvalidArgument { detail }) = error else {
547 return Err(format!("cursor head+2 must be InvalidArgument, got {error:?}").into());
548 };
549 assert!(
550 detail.message.contains("ahead of recorded history"),
551 "{detail}"
552 );
553
554 let empty = splice_resume(live(Vec::new()), Vec::new(), 2).err();
555 assert!(
556 matches!(empty, Some(ClientError::InvalidArgument { .. })),
557 "cursor 2 over empty history must be rejected, got {empty:?}"
558 );
559 Ok(())
560 }
561
562 #[tokio::test]
563 async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
564 -> Result<(), Box<dyn std::error::Error>> {
565 let events = splice_resume(
568 live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
569 history(1..=5)?,
570 1,
571 )?;
572
573 assert_eq!(delivered_seqs(events).await?, vec![1, 2, 3, 4, 5, 6]);
574 Ok(())
575 }
576
577 #[tokio::test]
578 async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
579 let events = splice_resume(live(vec![Ok(signal(6)?)]), history(1..=5)?, 3)?;
580
581 assert_eq!(delivered_seqs(events).await?, vec![3, 4, 5, 6]);
582 Ok(())
583 }
584
585 #[tokio::test]
586 async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
587 -> Result<(), Box<dyn std::error::Error>> {
588 let events = splice_resume(
589 live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
590 history(1..=5)?,
591 6,
592 )?;
593
594 assert_eq!(delivered_seqs(events).await?, vec![6, 7]);
595 Ok(())
596 }
597
598 #[tokio::test]
599 async fn lag_mid_splice_surfaces_unavailable_after_the_replay()
600 -> Result<(), Box<dyn std::error::Error>> {
601 let events = splice_resume(
602 live(vec![Err(EventStreamLagged { skipped: 3 })]),
603 history(1..=2)?,
604 1,
605 )?;
606 let collected: Vec<_> = events.collect().await;
607
608 assert_eq!(collected.len(), 3, "two replay events then the lag item");
609 assert!(collected[0].is_ok() && collected[1].is_ok());
610 assert!(
611 matches!(
612 collected[2].as_ref().err(),
613 Some(ClientError::Unavailable { .. })
614 ),
615 "lag must surface as retryable Unavailable, never a silent gap, got {:?}",
616 collected[2]
617 );
618 Ok(())
619 }
620
621 #[tokio::test]
622 async fn per_workflow_stream_closes_after_terminal_event()
623 -> Result<(), Box<dyn std::error::Error>> {
624 let mut history = history(1..=2)?;
627 history.push(completed(3)?);
628 history.push(signal(4)?);
629 let events = splice_resume(live(vec![Ok(signal(5)?)]), history, 1)?;
630
631 assert_eq!(
632 delivered_seqs(close_after_terminal(events)).await?,
633 vec![1, 2, 3],
634 "the stream must close after the terminal event"
635 );
636 Ok(())
637 }
638
639 #[tokio::test]
640 async fn live_lag_maps_to_unavailable() -> Result<(), Box<dyn std::error::Error>> {
641 let events = map_lag(live(vec![
642 Ok(signal(1)?),
643 Err(EventStreamLagged { skipped: 9 }),
644 ]));
645 let collected: Vec<_> = events.collect().await;
646
647 assert_eq!(collected.len(), 2);
648 assert!(
649 matches!(
650 collected[1].as_ref().err(),
651 Some(ClientError::Unavailable { .. })
652 ),
653 "got {:?}",
654 collected[1]
655 );
656 Ok(())
657 }
658
659 #[tokio::test]
663 async fn embedded_resume_splices_recorded_history_with_live_appends()
664 -> Result<(), Box<dyn std::error::Error>> {
665 use crate::stream::SubscribeTarget;
666 use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
667
668 let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
669 let engine = std::sync::Arc::new(
670 aion::EngineBuilder::new()
671 .store(aion_store::InMemoryStore::default())
672 .in_memory_visibility()
673 .event_streaming(capacity)
674 .build()
675 .await?,
676 );
677 let workflow_id = WorkflowId::new_v4();
678 let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
679 recorder
680 .record_workflow_started(
681 Utc::now(),
682 aion::durability::WorkflowStartRecord {
683 workflow_type: String::from("checkout"),
684 input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
685 run_id: RunId::new(uuid::Uuid::from_u128(7)),
686 parent_run_id: None,
687 package_version: aion_core::PackageVersion::new("a".repeat(64)),
688 },
689 )
690 .await?;
691 for seq in 2..=3 {
692 recorder
693 .record_signal_received(
694 Utc::now(),
695 format!("signal-{seq}"),
696 Payload::from_json(&serde_json::json!({ "seq": seq }))?,
697 )
698 .await?;
699 }
700
701 let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
704 let request = SubscribeTarget::Workflow {
705 workflow_id: workflow_id.clone(),
706 }
707 .request("default");
708 let attempt = transport.subscribe(request, Some(2)).await?;
709 let mut events = attempt.events;
710
711 let mut delivered = Vec::new();
712 for _ in 0..2 {
713 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
714 .await
715 .map_err(|_| "timed out waiting for a replay event")?
716 .ok_or("stream ended before the replay completed")?;
717 delivered.push(item?.seq());
718 }
719 recorder
720 .record_workflow_completed(
721 Utc::now(),
722 Payload::from_json(&serde_json::json!({ "done": true }))?,
723 )
724 .await?;
725 let item = tokio::time::timeout(Duration::from_secs(2), events.next())
726 .await
727 .map_err(|_| "timed out waiting for the live spliced event")?
728 .ok_or("stream ended before the live event arrived")?;
729 delivered.push(item?.seq());
730 assert_eq!(delivered, vec![2, 3, 4]);
731
732 let end = tokio::time::timeout(Duration::from_secs(2), events.next())
734 .await
735 .map_err(|_| "timed out waiting for the post-terminal close")?;
736 assert!(
737 end.is_none(),
738 "per-workflow stream must close after the terminal event, got {end:?}"
739 );
740
741 let ahead = transport
743 .subscribe(
744 SubscribeTarget::Workflow { workflow_id }.request("default"),
745 Some(9),
746 )
747 .await
748 .err();
749 assert!(
750 matches!(ahead, Some(ClientError::InvalidArgument { .. })),
751 "cursor ahead of history must be InvalidArgument, got {ahead:?}"
752 );
753
754 engine.shutdown()?;
755 Ok(())
756 }
757}