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