Skip to main content

aion_client/
ops.rs

1//! start/signal/query/cancel/list/describe over the transport.
2
3use std::num::NonZeroU64;
4use std::time::Duration;
5
6use aion_core::{
7    Event, Payload, RunId, WorkflowFilter, WorkflowId, WorkflowStatus, WorkflowSummary,
8};
9use aion_proto::{
10    ProtoCancelRequest, ProtoDescribeWorkflowRequest, ProtoListWorkflowsRequest, ProtoPauseRequest,
11    ProtoPayload, ProtoQueryRequest, ProtoReopenRequest, ProtoResumeRequest, ProtoRunId,
12    ProtoSignalRequest, ProtoStartWorkflowRequest, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
13    decode_core_value, decode_event, decode_workflow_summary, encode_core_value,
14    proto_query_response,
15};
16use aion_store::visibility::ListWorkflowsFilter;
17
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20
21use crate::client::Client;
22use crate::error::ClientError;
23use crate::handle::WorkflowHandle;
24use crate::payload::{from_payload, to_payload};
25use crate::stream::{EventStream, SubscribeTarget, event_stream, event_stream_from};
26
27/// Options accepted by [`Client::start`].
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct StartOptions {
30    /// Namespace override for this start request.
31    pub namespace: Option<String>,
32    /// Caller-supplied idempotency key for safe local retry replay.
33    ///
34    /// The current AW protobuf has not added an idempotency field yet, so this is
35    /// enforced at the SDK boundary without inventing a client-owned wire field.
36    /// Reusing a key for a different start request returns
37    /// [`ClientError::AlreadyExists`].
38    pub idempotency_key: Option<String>,
39    /// R-4 steered-start routing key. When set, the cluster steers this start to
40    /// `shard_for(routing_key)`'s owner (forwarding there when the dialed node is
41    /// not the owner). `None` keeps the default unsteered placement.
42    pub routing_key: Option<String>,
43    /// Optional default task queue for this workflow's activities. When set, the
44    /// server records it durably on the start (the namespace × `task_queue`
45    /// targeting story); `None` keeps the namespace's default queue.
46    pub task_queue: Option<String>,
47}
48
49/// Pagination options accepted by [`Client::list`].
50///
51/// The current AW protobuf carries `request_id` through the filter envelope,
52/// but not `limit` or `cursor`; populated `limit`/`cursor` values return
53/// [`ClientError::InvalidArgument`] instead of being silently ignored.
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct ListPage {
56    /// Caller request identifier carried in the current filter envelope.
57    pub request_id: Option<String>,
58    /// Requested page size reserved by the contract.
59    pub limit: Option<usize>,
60    /// Continuation cursor reserved by the contract.
61    pub cursor: Option<String>,
62}
63
64/// Workflow detail returned by [`Client::describe`].
65#[derive(Clone, Debug, PartialEq)]
66pub struct WorkflowDescription {
67    /// Lightweight workflow summary reused from `aion-core`.
68    pub summary: WorkflowSummary,
69    /// Optional event history when the server includes it.
70    pub history: Vec<Event>,
71}
72
73/// Outcome of [`Client::reopen`]: the reopened run and its projected status.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct ReopenOutcome {
76    /// The reopened concrete run identifier (now live again).
77    pub run_id: RunId,
78    /// The projected status after the reopen (Running).
79    pub status: WorkflowStatus,
80}
81
82/// Outcome of [`Client::pause`]: the paused run and its projected status (#204).
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct PauseOutcome {
85    /// The paused concrete run identifier.
86    pub run_id: RunId,
87    /// The projected status after the pause (Paused).
88    pub status: WorkflowStatus,
89}
90
91/// Outcome of [`Client::resume`]: the resumed run and its projected status (#204).
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct ResumeOutcome {
94    /// The resumed concrete run identifier (now live again).
95    pub run_id: RunId,
96    /// The projected status after the resume (Running).
97    pub status: WorkflowStatus,
98}
99
100impl Client {
101    /// Starts a workflow and returns the assigned workflow and run identifiers.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ClientError`] when transport, server, or response conversion fails.
106    pub async fn start(
107        &self,
108        workflow_type: impl Into<String>,
109        input: Payload,
110        opts: StartOptions,
111    ) -> Result<WorkflowHandle, ClientError> {
112        validate_start_options(&opts)?;
113        let idempotency_key = opts.idempotency_key.clone();
114        let routing_key = opts.routing_key.clone();
115        let task_queue = opts.task_queue.clone();
116        let namespace = operation_namespace(self, opts.namespace);
117        let workflow_type = workflow_type.into();
118        let fingerprint = idempotency_key.as_ref().map(|key| {
119            StartFingerprint::new(
120                namespace.clone(),
121                workflow_type.clone(),
122                &input,
123                key.clone(),
124            )
125        });
126        if let Some(fingerprint) = &fingerprint {
127            if let Some(handle) = self.cached_start(fingerprint).await? {
128                return Ok(handle);
129            }
130        }
131        let response = self
132            .transport
133            .start_workflow(ProtoStartWorkflowRequest {
134                namespace,
135                workflow_type,
136                input: Some(ProtoPayload::from(input)),
137                routing_key,
138                task_queue,
139            })
140            .await?;
141        let workflow_id = decode_required_workflow_id(response.workflow_id, "start response")?;
142        let run_id = decode_required_run_id(response.run_id, "start response")?;
143        let handle = WorkflowHandle::from_ids(self.clone(), workflow_id, run_id);
144        if let Some(fingerprint) = fingerprint {
145            self.record_start(fingerprint, handle.clone()).await?;
146        }
147        Ok(handle)
148    }
149
150    /// Starts a workflow after serializing `input` as JSON.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`ClientError::InvalidArgument`] when serialization fails, or the
155    /// delegated start error otherwise.
156    pub async fn start_typed<T>(
157        &self,
158        workflow_type: impl Into<String>,
159        input: &T,
160        opts: StartOptions,
161    ) -> Result<WorkflowHandle, ClientError>
162    where
163        T: Serialize + ?Sized,
164    {
165        self.start(workflow_type, to_payload(input)?, opts).await
166    }
167
168    /// Sends a signal to the latest run, or to `run_id` when supplied.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`ClientError`] when transport, server, or request conversion fails.
173    pub async fn signal(
174        &self,
175        workflow_id: &WorkflowId,
176        run_id: Option<&RunId>,
177        name: impl Into<String>,
178        payload: Payload,
179    ) -> Result<(), ClientError> {
180        self.transport
181            .signal(ProtoSignalRequest {
182                namespace: self.namespace().to_owned(),
183                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
184                run_id: run_id.cloned().map(ProtoRunId::from),
185                signal_name: name.into(),
186                payload: Some(ProtoPayload::from(payload)),
187            })
188            .await?;
189        Ok(())
190    }
191
192    /// Serializes `value` as JSON and sends it as a signal payload.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`ClientError::InvalidArgument`] when serialization fails, or the
197    /// delegated signal error otherwise.
198    pub async fn signal_typed<T>(
199        &self,
200        workflow_id: &WorkflowId,
201        run_id: Option<&RunId>,
202        name: impl Into<String>,
203        value: &T,
204    ) -> Result<(), ClientError>
205    where
206        T: Serialize + ?Sized,
207    {
208        self.signal(workflow_id, run_id, name, to_payload(value)?)
209            .await
210    }
211
212    /// Queries the latest run, or `run_id` when supplied, with a local deadline.
213    ///
214    /// The current AW protobuf does not yet carry query argument payloads, so a
215    /// non-empty `args` payload returns [`ClientError::InvalidArgument`] instead
216    /// of being silently dropped.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`ClientError::QueryTimeout`] when `deadline` elapses.
221    pub async fn query(
222        &self,
223        workflow_id: &WorkflowId,
224        run_id: Option<&RunId>,
225        name: impl Into<String>,
226        args: Payload,
227        deadline: Duration,
228    ) -> Result<Payload, ClientError> {
229        validate_query_args(&args)?;
230        let response = tokio::time::timeout(
231            deadline,
232            self.transport.query(ProtoQueryRequest {
233                namespace: self.namespace().to_owned(),
234                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
235                run_id: run_id.cloned().map(ProtoRunId::from),
236                query_name: name.into(),
237            }),
238        )
239        .await
240        .map_err(|_| {
241            ClientError::query_timeout(format!(
242                "query deadline of {deadline:?} elapsed before the server replied"
243            ))
244        })??;
245
246        match response.outcome {
247            Some(proto_query_response::Outcome::Result(payload)) => {
248                Payload::try_from(payload).map_err(ClientError::from_wire_error)
249            }
250            Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
251            None => Err(ClientError::server("query response outcome is missing")),
252        }
253    }
254
255    /// Serializes `args` as JSON, queries a workflow, and deserializes the JSON result.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`ClientError::InvalidArgument`] when serialization or result
260    /// decoding fails, or the delegated query error otherwise.
261    pub async fn query_typed<A, R>(
262        &self,
263        workflow_id: &WorkflowId,
264        run_id: Option<&RunId>,
265        name: impl Into<String>,
266        args: &A,
267        deadline: Duration,
268    ) -> Result<R, ClientError>
269    where
270        A: Serialize + ?Sized,
271        R: DeserializeOwned,
272    {
273        let payload = self
274            .query(
275                workflow_id,
276                run_id,
277                name,
278                query_args_payload(args)?,
279                deadline,
280            )
281            .await?;
282        from_payload(&payload)
283    }
284
285    /// Requests cancellation of the latest run, or `run_id` when supplied.
286    ///
287    /// Success means the server accepted the cancellation request; it is not a
288    /// confirmation that the workflow has reached a terminal cancelled state.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`ClientError`] when transport, server, or request conversion fails.
293    pub async fn cancel(
294        &self,
295        workflow_id: &WorkflowId,
296        run_id: Option<&RunId>,
297        reason: impl Into<String>,
298    ) -> Result<(), ClientError> {
299        self.transport
300            .cancel(ProtoCancelRequest {
301                namespace: self.namespace().to_owned(),
302                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
303                run_id: run_id.cloned().map(ProtoRunId::from),
304                reason: reason.into(),
305            })
306            .await?;
307        Ok(())
308    }
309
310    /// Reopens a terminal-reopenable run (Failed or Cancelled), re-driving it
311    /// from where it left off. Targets the latest run, or `run_id` when supplied.
312    ///
313    /// Returns the reopened run and its projected status (Running). A run that is
314    /// not a reopenable terminal (not terminal, terminal for a non-reopenable
315    /// reason, or already Running) returns [`ClientError::InvalidState`]; an
316    /// absent workflow returns [`ClientError::NotFound`].
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ClientError`] when transport, server, or response conversion fails.
321    pub async fn reopen(
322        &self,
323        workflow_id: &WorkflowId,
324        run_id: Option<&RunId>,
325    ) -> Result<ReopenOutcome, ClientError> {
326        let response = self
327            .transport
328            .reopen(ProtoReopenRequest {
329                namespace: self.namespace().to_owned(),
330                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
331                run_id: run_id.cloned().map(ProtoRunId::from),
332            })
333            .await?;
334        let run_id = response
335            .run_id
336            .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
337            .try_into()
338            .map_err(ClientError::from_wire_error)?;
339        let status = ProtoWorkflowStatus::try_from(response.status)
340            .map_err(|_error| ClientError::server("reopen response status is unknown"))
341            .and_then(|status| {
342                WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
343            })?;
344        Ok(ReopenOutcome { run_id, status })
345    }
346
347    /// Pauses a live `Running` run, durably holding new activity dispatch (#204).
348    /// Targets the latest run, or `run_id` when supplied. Returns the run and its
349    /// projected status (Paused). A run that is not `Running` returns
350    /// [`ClientError::InvalidState`]; an absent workflow returns
351    /// [`ClientError::NotFound`].
352    ///
353    /// # Errors
354    ///
355    /// Returns [`ClientError`] when transport, server, or response conversion fails.
356    pub async fn pause(
357        &self,
358        workflow_id: &WorkflowId,
359        run_id: Option<&RunId>,
360        reason: impl Into<String>,
361    ) -> Result<PauseOutcome, ClientError> {
362        let response = self
363            .transport
364            .pause(ProtoPauseRequest {
365                namespace: self.namespace().to_owned(),
366                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
367                run_id: run_id.cloned().map(ProtoRunId::from),
368                reason: reason.into(),
369            })
370            .await?;
371        let run_id = response
372            .run_id
373            .ok_or_else(|| ClientError::server("pause response run id is missing"))?
374            .try_into()
375            .map_err(ClientError::from_wire_error)?;
376        let status = ProtoWorkflowStatus::try_from(response.status)
377            .map_err(|_error| ClientError::server("pause response status is unknown"))
378            .and_then(|status| {
379                WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
380            })?;
381        Ok(PauseOutcome { run_id, status })
382    }
383
384    /// Resumes a `Paused` run, releasing the dispatch hold (#204). Targets the
385    /// latest run, or `run_id` when supplied. Returns the run and its projected
386    /// status (Running). A run that is not `Paused` returns
387    /// [`ClientError::InvalidState`]; an absent workflow returns
388    /// [`ClientError::NotFound`].
389    ///
390    /// # Errors
391    ///
392    /// Returns [`ClientError`] when transport, server, or response conversion fails.
393    pub async fn resume(
394        &self,
395        workflow_id: &WorkflowId,
396        run_id: Option<&RunId>,
397    ) -> Result<ResumeOutcome, ClientError> {
398        let response = self
399            .transport
400            .resume(ProtoResumeRequest {
401                namespace: self.namespace().to_owned(),
402                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
403                run_id: run_id.cloned().map(ProtoRunId::from),
404            })
405            .await?;
406        let run_id = response
407            .run_id
408            .ok_or_else(|| ClientError::server("resume response run id is missing"))?
409            .try_into()
410            .map_err(ClientError::from_wire_error)?;
411        let status = ProtoWorkflowStatus::try_from(response.status)
412            .map_err(|_error| ClientError::server("resume response status is unknown"))
413            .and_then(|status| {
414                WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
415            })?;
416        Ok(ResumeOutcome { run_id, status })
417    }
418
419    /// Lists workflows matching a filter.
420    ///
421    /// # Errors
422    ///
423    /// Returns [`ClientError`] when transport, server, or response conversion fails.
424    pub async fn list(
425        &self,
426        filter: &WorkflowFilter,
427        page: ListPage,
428    ) -> Result<Vec<WorkflowSummary>, ClientError> {
429        validate_list_page(&page)?;
430        let namespace = self.namespace().to_owned();
431        let filter = workflow_filter_to_visibility(filter)?;
432        let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
433            .map_err(ClientError::from_wire_error)?;
434        let response = self
435            .transport
436            .list_workflows(ProtoListWorkflowsRequest {
437                namespace,
438                filter: Some(filter),
439            })
440            .await?;
441
442        response
443            .summaries
444            .iter()
445            .map(decode_visibility_summary)
446            .map(|result| result.map_err(ClientError::from_wire_error))
447            .collect()
448    }
449
450    /// Describes the latest run, or `run_id` when supplied.
451    ///
452    /// # Errors
453    ///
454    /// Returns [`ClientError`] when transport, server, or response conversion fails.
455    pub async fn describe(
456        &self,
457        workflow_id: &WorkflowId,
458        run_id: Option<&RunId>,
459    ) -> Result<WorkflowDescription, ClientError> {
460        let response = self
461            .transport
462            .describe_workflow(ProtoDescribeWorkflowRequest {
463                namespace: self.namespace().to_owned(),
464                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
465                run_id: run_id.cloned().map(ProtoRunId::from),
466                include_history: true,
467            })
468            .await?;
469        let summary = response
470            .summary
471            .as_ref()
472            .ok_or_else(|| ClientError::server("describe response summary is missing"))
473            .and_then(|summary| {
474                decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
475            })?;
476        let history = response
477            .history
478            .iter()
479            .map(decode_event)
480            .map(|result| result.map_err(ClientError::from_wire_error))
481            .collect::<Result<Vec<_>, _>>()?;
482        Ok(WorkflowDescription { summary, history })
483    }
484
485    /// Subscribes to events for a workflow.
486    #[must_use]
487    pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
488        event_stream(
489            self.transport.clone(),
490            self.namespace().to_owned(),
491            SubscribeTarget::Workflow {
492                workflow_id: workflow_id.clone(),
493            },
494        )
495    }
496
497    /// Subscribes to events for a workflow, attaching from an explicit
498    /// per-workflow sequence cursor.
499    ///
500    /// `resume_from` is the first sequence number wanted (`resume_from_seq`
501    /// on the wire); `1` replays the workflow's full recorded history before
502    /// splicing into the live stream, gap-free and duplicate-free.
503    #[must_use]
504    pub fn subscribe_workflow_from(
505        &self,
506        workflow_id: &WorkflowId,
507        resume_from: NonZeroU64,
508    ) -> EventStream {
509        event_stream_from(
510            self.transport.clone(),
511            self.namespace().to_owned(),
512            workflow_id.clone(),
513            resume_from,
514        )
515    }
516
517    /// Subscribes to events selected by the supplied workflow filter.
518    #[must_use]
519    pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
520        event_stream(
521            self.transport.clone(),
522            self.namespace().to_owned(),
523            SubscribeTarget::Filtered { filter },
524        )
525    }
526
527    /// Subscribes to every event visible to this client namespace.
528    #[must_use]
529    pub fn subscribe_firehose(&self) -> EventStream {
530        event_stream(
531            self.transport.clone(),
532            self.namespace().to_owned(),
533            SubscribeTarget::Firehose,
534        )
535    }
536}
537
538#[derive(Clone, Debug, PartialEq, Eq)]
539pub(crate) struct StartFingerprint {
540    namespace: String,
541    workflow_type: String,
542    content_type: aion_core::ContentType,
543    bytes: Vec<u8>,
544    idempotency_key: String,
545}
546
547impl StartFingerprint {
548    fn new(
549        namespace: String,
550        workflow_type: String,
551        input: &Payload,
552        idempotency_key: String,
553    ) -> Self {
554        Self {
555            namespace,
556            workflow_type,
557            content_type: input.content_type().clone(),
558            bytes: input.bytes().to_vec(),
559            idempotency_key,
560        }
561    }
562
563    pub(crate) fn key(&self) -> &str {
564        &self.idempotency_key
565    }
566}
567
568fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
569    namespace.unwrap_or_else(|| client.namespace().to_owned())
570}
571
572fn validate_start_options(opts: &StartOptions) -> Result<(), ClientError> {
573    if opts
574        .idempotency_key
575        .as_ref()
576        .is_some_and(std::string::String::is_empty)
577    {
578        return Err(ClientError::invalid_argument(
579            "idempotency_key must not be empty",
580        ));
581    }
582    Ok(())
583}
584
585fn validate_query_args(args: &Payload) -> Result<(), ClientError> {
586    if !args.bytes().is_empty() {
587        return Err(ClientError::invalid_argument(
588            "query arguments are not carried by the current wire contract; \
589             pass an empty payload",
590        ));
591    }
592    Ok(())
593}
594
595fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
596where
597    T: Serialize + ?Sized,
598{
599    let payload = to_payload(args)?;
600    if payload.bytes() == b"null" {
601        Ok(Payload::new(payload.content_type().clone(), Vec::new()))
602    } else {
603        Ok(payload)
604    }
605}
606
607fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
608    if page.limit.is_some() || page.cursor.is_some() {
609        return Err(ClientError::invalid_argument(
610            "list pagination limit/cursor are reserved by the contract and \
611             not yet carried by the wire",
612        ));
613    }
614    Ok(())
615}
616
617fn workflow_filter_to_visibility(
618    filter: &WorkflowFilter,
619) -> Result<ListWorkflowsFilter, ClientError> {
620    if filter.parent.is_some() {
621        return Err(ClientError::invalid_argument(
622            "parent workflow filters are not carried by the visibility wire contract",
623        ));
624    }
625
626    Ok(ListWorkflowsFilter {
627        workflow_type: filter.workflow_type.clone(),
628        status: filter.status,
629        started_after: filter.started_after,
630        started_before: filter.started_before,
631        ..ListWorkflowsFilter::default()
632    })
633}
634
635fn decode_visibility_summary(
636    envelope: &aion_proto::WireEnvelope,
637) -> Result<WorkflowSummary, WireError> {
638    let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
639    Ok(WorkflowSummary {
640        workflow_id: summary.workflow_id,
641        workflow_type: summary.workflow_type,
642        status: summary.status,
643        started_at: summary.start_time,
644        ended_at: summary.close_time,
645        parent: None,
646        failed_step: summary.failed_step,
647        failure_reason: summary.failure_reason,
648    })
649}
650
651fn decode_required_workflow_id(
652    value: Option<ProtoWorkflowId>,
653    context: &str,
654) -> Result<WorkflowId, ClientError> {
655    value
656        .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
657        .try_into()
658        .map_err(ClientError::from_wire_error)
659}
660
661fn decode_required_run_id(value: Option<ProtoRunId>, context: &str) -> Result<RunId, ClientError> {
662    value
663        .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
664        .try_into()
665        .map_err(ClientError::from_wire_error)
666}
667
668/// Maps a `QueryResponse.error` payload through the shared wire taxonomy.
669///
670/// The server reports query-handler application failures with the dedicated
671/// `query_failed` wire code, so the shared map yields [`ClientError::QueryFailed`]
672/// directly; `backend` stays an unexpected server fault.
673fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
674    ClientError::from_proto_wire_error(error)
675}
676
677#[cfg(test)]
678mod tests {
679    use std::sync::Arc;
680    use std::time::Duration;
681
682    use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
683    use aion_proto::{
684        ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
685        ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
686        ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
687        encode_core_value, encode_workflow_summary, proto_query_response,
688    };
689    use async_trait::async_trait;
690    use chrono::Utc;
691    use futures::StreamExt;
692    use futures::stream;
693    use tokio::sync::Mutex;
694
695    use super::{ListPage, StartOptions};
696    use crate::client::{Client, ClientBuilder, ClientConfig};
697    use crate::error::ClientError;
698    use crate::transport::{SubscriptionAttempt, WorkflowTransport};
699
700    #[derive(Default)]
701    struct StubTransport {
702        last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
703        last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
704        last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
705        last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
706        last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
707        last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
708        last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
709        start_error: Mutex<Option<ClientError>>,
710        signal_error: Mutex<Option<ClientError>>,
711        query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
712        reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
713    }
714
715    #[async_trait]
716    impl WorkflowTransport for StubTransport {
717        async fn start_workflow(
718            &self,
719            request: aion_proto::ProtoStartWorkflowRequest,
720        ) -> Result<ProtoStartWorkflowResponse, ClientError> {
721            *self.last_start.lock().await = Some(request);
722            if let Some(error) = self.start_error.lock().await.take() {
723                return Err(error);
724            }
725            Ok(ProtoStartWorkflowResponse {
726                workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
727                run_id: Some(ProtoRunId::from(run_id())),
728            })
729        }
730
731        async fn signal(
732            &self,
733            request: aion_proto::ProtoSignalRequest,
734        ) -> Result<ProtoSignalResponse, ClientError> {
735            *self.last_signal.lock().await = Some(request);
736            if let Some(error) = self.signal_error.lock().await.take() {
737                return Err(error);
738            }
739            Ok(ProtoSignalResponse {})
740        }
741
742        async fn query(
743            &self,
744            request: aion_proto::ProtoQueryRequest,
745        ) -> Result<ProtoQueryResponse, ClientError> {
746            *self.last_query.lock().await = Some(request);
747            if let Some(response) = self.query_response.lock().await.take() {
748                return response;
749            }
750            Ok(ProtoQueryResponse {
751                outcome: Some(proto_query_response::Outcome::Result(
752                    aion_proto::ProtoPayload::from(payload("result")),
753                )),
754            })
755        }
756
757        async fn cancel(
758            &self,
759            request: aion_proto::ProtoCancelRequest,
760        ) -> Result<ProtoCancelResponse, ClientError> {
761            *self.last_cancel.lock().await = Some(request);
762            Ok(ProtoCancelResponse {})
763        }
764
765        async fn reopen(
766            &self,
767            request: aion_proto::ProtoReopenRequest,
768        ) -> Result<ProtoReopenResponse, ClientError> {
769            *self.last_reopen.lock().await = Some(request);
770            if let Some(response) = self.reopen_response.lock().await.take() {
771                return response;
772            }
773            Ok(ProtoReopenResponse {
774                run_id: Some(ProtoRunId::from(run_id())),
775                status: ProtoWorkflowStatus::Running as i32,
776            })
777        }
778
779        async fn pause(
780            &self,
781            _request: aion_proto::ProtoPauseRequest,
782        ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
783            Ok(aion_proto::ProtoPauseResponse {
784                run_id: Some(ProtoRunId::from(run_id())),
785                status: ProtoWorkflowStatus::Paused as i32,
786            })
787        }
788
789        async fn resume(
790            &self,
791            _request: aion_proto::ProtoResumeRequest,
792        ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
793            Ok(aion_proto::ProtoResumeResponse {
794                run_id: Some(ProtoRunId::from(run_id())),
795                status: ProtoWorkflowStatus::Running as i32,
796            })
797        }
798
799        async fn list_workflows(
800            &self,
801            request: aion_proto::ProtoListWorkflowsRequest,
802        ) -> Result<ProtoListWorkflowsResponse, ClientError> {
803            *self.last_list.lock().await = Some(request);
804            Ok(ProtoListWorkflowsResponse {
805                summaries: vec![
806                    encode_core_value("tenant-a", None, &visibility_summary())
807                        .map_err(ClientError::from_wire_error)?,
808                ],
809            })
810        }
811
812        async fn describe_workflow(
813            &self,
814            request: aion_proto::ProtoDescribeWorkflowRequest,
815        ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
816            *self.last_describe.lock().await = Some(request);
817            Ok(ProtoDescribeWorkflowResponse {
818                summary: Some(
819                    encode_workflow_summary("tenant-a", None, &summary())
820                        .map_err(ClientError::from_wire_error)?,
821                ),
822                history: Vec::new(),
823            })
824        }
825
826        async fn subscribe(
827            &self,
828            _: aion_proto::SubscriptionRequest,
829            _: Option<u64>,
830        ) -> Result<SubscriptionAttempt, ClientError> {
831            Ok(SubscriptionAttempt::new(stream::empty().boxed()))
832        }
833    }
834
835    fn client_with(stub: Arc<StubTransport>) -> Client {
836        Client::from_transport(
837            ClientConfig::from(
838                ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
839            ),
840            stub,
841        )
842    }
843
844    fn workflow_id() -> WorkflowId {
845        WorkflowId::new_v4()
846    }
847
848    fn run_id() -> aion_core::RunId {
849        aion_core::RunId::new_v4()
850    }
851
852    fn payload(label: &str) -> Payload {
853        Payload::new(
854            ContentType::Json,
855            format!("{{\"label\":\"{label}\"}}").into_bytes(),
856        )
857    }
858
859    fn empty_payload() -> Payload {
860        Payload::new(ContentType::Json, Vec::new())
861    }
862
863    fn summary() -> aion_core::WorkflowSummary {
864        aion_core::WorkflowSummary {
865            workflow_id: workflow_id(),
866            workflow_type: String::from("checkout"),
867            status: WorkflowStatus::Running,
868            started_at: Utc::now(),
869            ended_at: None,
870            parent: None,
871            failed_step: None,
872            failure_reason: None,
873        }
874    }
875
876    fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
877        aion_store::visibility::WorkflowSummary {
878            workflow_id: workflow_id(),
879            run_id: run_id(),
880            workflow_type: String::from("checkout"),
881            status: WorkflowStatus::Running,
882            start_time: Utc::now(),
883            close_time: None,
884            failed_step: None,
885            failure_reason: None,
886            search_attributes: std::collections::HashMap::new(),
887        }
888    }
889
890    #[tokio::test]
891    async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
892        let stub = Arc::new(StubTransport::default());
893        let client = client_with(Arc::clone(&stub));
894
895        let result = client
896            .start("checkout", payload("input"), StartOptions::default())
897            .await?;
898
899        let recorded = stub.last_start.lock().await.clone();
900        assert!(recorded.is_some());
901        let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
902        assert_eq!(request.namespace, "tenant-a");
903        assert_eq!(request.workflow_type, "checkout");
904        assert!(request.input.is_some());
905        assert_ne!(result.workflow_id(), &WorkflowId::new(uuid::Uuid::nil()));
906        Ok(())
907    }
908
909    #[tokio::test]
910    async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
911    {
912        let stub = Arc::new(StubTransport::default());
913        let client = client_with(Arc::clone(&stub));
914        let opts = StartOptions {
915            namespace: None,
916            idempotency_key: Some(String::from("retry-key")),
917            routing_key: None,
918            task_queue: None,
919        };
920
921        let original = client
922            .start("checkout", payload("input"), opts.clone())
923            .await?;
924        let replayed = client
925            .start("checkout", payload("input"), opts.clone())
926            .await?;
927        let conflict = client.start("checkout", payload("other"), opts).await;
928
929        assert_eq!(replayed, original);
930        assert!(
931            matches!(conflict, Err(ClientError::AlreadyExists { .. })),
932            "got {conflict:?}"
933        );
934        Ok(())
935    }
936
937    #[tokio::test]
938    async fn signal_maps_latest_run_and_error() {
939        let stub = Arc::new(StubTransport::default());
940        *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
941        let client = client_with(Arc::clone(&stub));
942        let id = workflow_id();
943
944        let result = client.signal(&id, None, "approve", payload("signal")).await;
945
946        assert_eq!(
947            result,
948            Err(ClientError::not_found("workflow was not found"))
949        );
950        let recorded = stub.last_signal.lock().await.clone();
951        assert!(recorded.is_some());
952        let Some(request) = recorded else {
953            return;
954        };
955        assert!(request.run_id.is_none());
956    }
957
958    #[tokio::test]
959    async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
960        let stub = Arc::new(StubTransport::default());
961        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
962            outcome: Some(proto_query_response::Outcome::Error(
963                aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
964            )),
965        }));
966        let client = client_with(Arc::clone(&stub));
967        let id = workflow_id();
968
969        let result = client
970            .query(
971                &id,
972                Some(&run_id()),
973                "state",
974                empty_payload(),
975                Duration::from_secs(1),
976            )
977            .await;
978        let unsupported_args = client
979            .query(&id, None, "state", payload("args"), Duration::from_secs(1))
980            .await;
981
982        assert_eq!(result, Err(ClientError::query_timeout("slow")));
983        assert!(
984            matches!(unsupported_args, Err(ClientError::InvalidArgument { .. })),
985            "got {unsupported_args:?}"
986        );
987        let recorded = stub.last_query.lock().await.clone();
988        assert!(recorded.is_some());
989        let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
990        assert!(request.run_id.is_some());
991        Ok(())
992    }
993
994    #[tokio::test]
995    async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
996        let stub = Arc::new(StubTransport::default());
997        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
998            outcome: Some(proto_query_response::Outcome::Error(
999                aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1000            )),
1001        }));
1002        let client = client_with(Arc::clone(&stub));
1003
1004        let result = client
1005            .query(
1006                &workflow_id(),
1007                Some(&run_id()),
1008                "state",
1009                empty_payload(),
1010                Duration::from_secs(1),
1011            )
1012            .await;
1013
1014        assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1015        Ok(())
1016    }
1017
1018    #[tokio::test]
1019    async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1020        // `backend` in QueryResponse.error is an unexpected server fault; the
1021        // application-level handler failure has its own `query_failed` code.
1022        let stub = Arc::new(StubTransport::default());
1023        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1024            outcome: Some(proto_query_response::Outcome::Error(
1025                aion_proto::ProtoWireError::from(WireError::backend("store down")),
1026            )),
1027        }));
1028        let client = client_with(Arc::clone(&stub));
1029
1030        let result = client
1031            .query(
1032                &workflow_id(),
1033                Some(&run_id()),
1034                "state",
1035                empty_payload(),
1036                Duration::from_secs(1),
1037            )
1038            .await;
1039
1040        assert_eq!(result, Err(ClientError::server("store down")));
1041        Ok(())
1042    }
1043
1044    #[tokio::test]
1045    async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1046        #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1047        struct QueryResult {
1048            label: String,
1049        }
1050
1051        let stub = Arc::new(StubTransport::default());
1052        let client = client_with(Arc::clone(&stub));
1053        let id = workflow_id();
1054
1055        let result: QueryResult = client
1056            .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1057            .await?;
1058
1059        assert_eq!(
1060            result,
1061            QueryResult {
1062                label: String::from("result")
1063            }
1064        );
1065        assert!(stub.last_query.lock().await.is_some());
1066        Ok(())
1067    }
1068
1069    #[tokio::test]
1070    async fn query_typed_rejects_non_empty_args_without_silent_drop() {
1071        let stub = Arc::new(StubTransport::default());
1072        let client = client_with(Arc::clone(&stub));
1073        let id = workflow_id();
1074
1075        let result = client
1076            .query_typed::<_, serde_json::Value>(
1077                &id,
1078                Some(&run_id()),
1079                "state",
1080                &serde_json::json!({ "filter": "open" }),
1081                Duration::from_secs(1),
1082            )
1083            .await;
1084
1085        assert!(
1086            matches!(result, Err(ClientError::InvalidArgument { .. })),
1087            "got {result:?}"
1088        );
1089        assert!(stub.last_query.lock().await.is_none());
1090    }
1091
1092    #[tokio::test]
1093    async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1094        let stub = Arc::new(StubTransport::default());
1095        let client = client_with(Arc::clone(&stub));
1096        let id = workflow_id();
1097        let run = run_id();
1098
1099        client.cancel(&id, Some(&run), "not needed").await?;
1100        let listed = client
1101            .list(&WorkflowFilter::default(), ListPage::default())
1102            .await?;
1103        let described = client.describe(&id, None).await?;
1104
1105        assert!(stub.last_cancel.lock().await.is_some());
1106        assert!(stub.last_list.lock().await.is_some());
1107        let describe = stub
1108            .last_describe
1109            .lock()
1110            .await
1111            .clone()
1112            .ok_or_else(|| ClientError::server("missing describe"))?;
1113        assert!(describe.run_id.is_none());
1114        assert!(describe.include_history);
1115        assert_eq!(listed.len(), 1);
1116        assert_eq!(described.history.len(), 0);
1117        Ok(())
1118    }
1119
1120    #[tokio::test]
1121    async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1122        let stub = Arc::new(StubTransport::default());
1123        let client = client_with(Arc::clone(&stub));
1124        let id = workflow_id();
1125        let run = run_id();
1126
1127        let outcome = client.reopen(&id, Some(&run)).await?;
1128
1129        assert_eq!(outcome.status, WorkflowStatus::Running);
1130        let request = stub
1131            .last_reopen
1132            .lock()
1133            .await
1134            .clone()
1135            .ok_or_else(|| ClientError::server("missing reopen"))?;
1136        assert_eq!(request.namespace, "tenant-a");
1137        assert!(request.run_id.is_some());
1138        Ok(())
1139    }
1140
1141    /// The `InvalidState` wire code maps to the distinct typed
1142    /// [`ClientError::InvalidState`], never conflated with not-found.
1143    #[tokio::test]
1144    async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1145        let stub = Arc::new(StubTransport::default());
1146        *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1147            WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1148        )));
1149        let client = client_with(Arc::clone(&stub));
1150
1151        let result = client.reopen(&workflow_id(), None).await;
1152
1153        assert!(
1154            matches!(result, Err(ClientError::InvalidState { .. })),
1155            "got {result:?}"
1156        );
1157        Ok(())
1158    }
1159}