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