Skip to main content

aion_client/transport/
embedded.rs

1//! Transport backed by an in-process [`aion::Engine`].
2//!
3//! Event subscriptions honour the same resume/replay-splice contract as the
4//! server's `/events/stream` endpoint, built directly on [`aion::Engine`]
5//! seams (`Engine::subscribe` for the live broadcast, `engine.store()` for
6//! the history snapshot — never a client-held stream over engine internals):
7//!
8//! 1. attach the live broadcast subscription FIRST (time T0);
9//! 2. snapshot recorded history via `engine.store().read_history` (T1 > T0);
10//! 3. validate the cursor against the snapshot head;
11//! 4. splice: replay `[resume_from_seq ..= head]` from the snapshot, then the
12//!    live tail filtered to `seq > head`.
13//!
14//! Gap-free: publish strictly follows durable commit, so every event with
15//! `seq > head` was committed — and therefore broadcast — after T0.
16//! Duplicate-free: the live filter drops every `seq <= head`, so an event
17//! present in both the snapshot and the broadcast is emitted exactly once,
18//! from the snapshot. Engine-side lag is never silent: each
19//! `Err(EventStreamLagged)` item surfaces as `Err(ClientError::Unavailable)`
20//! so the resume loop reconnects with its cursor.
21
22use 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
32/// Transport backed by an in-process [`aion::Engine`].
33pub struct EmbeddedWorkflowTransport {
34    engine: Arc<aion::Engine>,
35}
36
37impl EmbeddedWorkflowTransport {
38    /// Creates an embedded transport for `engine`.
39    #[must_use]
40    pub fn new(engine: Arc<aion::Engine>) -> Self {
41        Self { engine }
42    }
43
44    /// Resolve the target run id: the supplied one, or the latest run from the
45    /// workflow's run chain when omitted (mirrors the server's `resolve_run_id`).
46    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        // The embedded engine is single-tenant and in-process: there is no
80        // namespace authority stamping visibility attributes, so the start
81        // carries no search attributes.
82        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        // T0: attach to the live broadcast BEFORE any history snapshot — one
269        // half of the gap-free splice proof (mirrors the server's
270        // subscribe-then-snapshot ordering).
271        let live = self.engine.subscribe(filter);
272        let events = match (&workflow_target, resume_from_sequence) {
273            (Some(workflow_id), Some(resume_from_seq)) => {
274                // T1 (> T0): snapshot recorded history, then validate the
275                // cursor against its head and build the dedupe splice.
276                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        // Per-workflow streams end at the run's terminal event, exactly like
293        // the server socket; callers walk continue-as-new chains by
294        // resubscribing with their cursor.
295        Ok(SubscriptionAttempt::new(match workflow_target {
296            Some(_) => close_after_terminal(events),
297            None => events,
298        }))
299    }
300}
301
302/// Validates a resume cursor against a history snapshot and builds the
303/// replay/live splice (see the module docs for the gap/duplicate proof).
304fn 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            // Lag is information, never filtered away.
330            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
340/// Maps engine-side lag items to retryable [`ClientError::Unavailable`] so
341/// the resume loop reconnects with its cursor instead of silently gapping.
342fn 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
353/// Ends the stream after the first terminal workflow event, mirroring the
354/// server socket's per-workflow run-boundary close.
355fn 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        // The terminal event is delivered and the inner stream is dropped
362        // immediately afterwards (releasing the broadcast receiver), so the
363        // close is eager — it never waits for a further event to be polled.
364        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
399/// Maps a wire subscription request onto the engine filter surface plus the
400/// per-workflow target the splice and run-boundary close key on.
401fn 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            // The WS3 cluster topology/ownership channel is a server-side
430            // projection of distributed cluster state. The embedded in-process
431            // transport drives a single local engine with no cluster topology to
432            // project, so a cluster subscription is not serviceable here; reject it
433            // cleanly rather than silently degrading to a workflow event stream.
434            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            // The NOI-5b agent-observability transcript channel is a server-side
442            // projection over the durable `O` keyspace + the server's transcript
443            // sequencer. The embedded in-process transport has no such server
444            // bridge, so a transcript subscription is not serviceable here; reject
445            // it cleanly rather than degrading to a workflow event stream.
446            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        // Reopen precondition failure (AD-012): distinct typed variant, never
462        // conflated with not-found or the generic server bucket.
463        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        // Snapshot holds 1..=5; the live broadcast re-emits 4 and 5 (arrived
566        // between attach and snapshot) before the genuinely new 6.
567        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        // Terminal at seq 3 mid-replay: deliver 1..=3 and close without
625        // draining the live tail (continue-as-new/terminal run boundary).
626        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    /// End-to-end through a real engine: the embedded resume splice delivers
660    /// recorded history and live appends gap-free and duplicate-free, built
661    /// on `Engine::subscribe` + `engine.store()` (the pin-note seams).
662    #[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        // Resume from seq 2: replay [2, 3] from the snapshot, then splice the
702        // live append (4) with no gaps and no duplicates.
703        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        // Seq 4 is terminal: the per-workflow stream must now close.
733        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        // A cursor beyond head + 1 is rejected against the same engine.
742        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}