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, ProtoWorkflowId, ProtoWorkflowStatus, WireError, decode_core_value,
13    decode_event, decode_workflow_summary, encode_core_value, proto_query_response,
14};
15use aion_store::visibility::ListWorkflowsFilter;
16
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use crate::client::Client;
21use crate::error::ClientError;
22use crate::payload::{from_payload, to_payload};
23use crate::stream::{EventStream, SubscribeTarget, event_stream, event_stream_from};
24
25/// Pagination options accepted by [`Client::list`].
26///
27/// The current AW protobuf carries `request_id` through the filter envelope,
28/// but not `limit` or `cursor`; populated `limit`/`cursor` values return
29/// [`ClientError::InvalidArgument`] instead of being silently ignored.
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct ListPage {
32    /// Caller request identifier carried in the current filter envelope.
33    pub request_id: Option<String>,
34    /// Requested page size reserved by the contract.
35    pub limit: Option<usize>,
36    /// Continuation cursor reserved by the contract.
37    pub cursor: Option<String>,
38}
39
40/// Workflow detail returned by [`Client::describe`].
41#[derive(Clone, Debug, PartialEq)]
42pub struct WorkflowDescription {
43    /// Lightweight workflow summary reused from `aion-core`.
44    pub summary: WorkflowSummary,
45    /// Optional event history when the server includes it.
46    pub history: Vec<Event>,
47}
48
49/// Outcome of [`Client::reopen`]: the reopened run and its projected status.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ReopenOutcome {
52    /// The reopened concrete run identifier (now live again).
53    pub run_id: RunId,
54    /// The projected status after the reopen (Running).
55    pub status: WorkflowStatus,
56}
57
58/// Outcome of [`Client::pause`]: the paused run and its projected status (#204).
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct PauseOutcome {
61    /// The paused concrete run identifier.
62    pub run_id: RunId,
63    /// The projected status after the pause (Paused).
64    pub status: WorkflowStatus,
65}
66
67/// Outcome of [`Client::resume`]: the resumed run and its projected status (#204).
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct ResumeOutcome {
70    /// The resumed concrete run identifier (now live again).
71    pub run_id: RunId,
72    /// The projected status after the resume (Running).
73    pub status: WorkflowStatus,
74}
75
76impl Client {
77    /// Sends a signal to the latest run, or to `run_id` when supplied.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`ClientError`] when transport, server, or request conversion fails.
82    pub async fn signal(
83        &self,
84        workflow_id: &WorkflowId,
85        run_id: Option<&RunId>,
86        name: impl Into<String>,
87        payload: Payload,
88    ) -> Result<(), ClientError> {
89        self.transport
90            .signal(ProtoSignalRequest {
91                namespace: self.namespace().to_owned(),
92                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
93                run_id: run_id.cloned().map(ProtoRunId::from),
94                signal_name: name.into(),
95                payload: Some(ProtoPayload::from(payload)),
96            })
97            .await?;
98        Ok(())
99    }
100
101    /// Serializes `value` as JSON and sends it as a signal payload.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ClientError::InvalidArgument`] when serialization fails, or the
106    /// delegated signal error otherwise.
107    pub async fn signal_typed<T>(
108        &self,
109        workflow_id: &WorkflowId,
110        run_id: Option<&RunId>,
111        name: impl Into<String>,
112        value: &T,
113    ) -> Result<(), ClientError>
114    where
115        T: Serialize + ?Sized,
116    {
117        self.signal(workflow_id, run_id, name, to_payload(value)?)
118            .await
119    }
120
121    /// Queries the latest run, or `run_id` when supplied, with a local deadline.
122    ///
123    /// `args` is the argument document handed to the workflow's registered
124    /// query handler. A query that takes no arguments passes
125    /// [`Payload::json_null`] — the canonical "nothing supplied" document
126    /// every carrier agrees on. The server refuses arguments that are not a
127    /// well-formed JSON document with [`ClientError::InvalidArgument`].
128    ///
129    /// # Errors
130    ///
131    /// Returns [`ClientError::QueryTimeout`] when `deadline` elapses.
132    pub async fn query(
133        &self,
134        workflow_id: &WorkflowId,
135        run_id: Option<&RunId>,
136        name: impl Into<String>,
137        args: Payload,
138        deadline: Duration,
139    ) -> Result<Payload, ClientError> {
140        let response = tokio::time::timeout(
141            deadline,
142            self.transport.query(ProtoQueryRequest {
143                namespace: self.namespace().to_owned(),
144                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
145                run_id: run_id.cloned().map(ProtoRunId::from),
146                query_name: name.into(),
147                arguments: Some(aion_proto::ProtoPayload::from(args)),
148            }),
149        )
150        .await
151        .map_err(|_| {
152            ClientError::query_timeout(format!(
153                "query deadline of {deadline:?} elapsed before the server replied"
154            ))
155        })??;
156
157        match response.outcome {
158            Some(proto_query_response::Outcome::Result(payload)) => {
159                Payload::try_from(payload).map_err(ClientError::from_wire_error)
160            }
161            Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
162            None => Err(ClientError::server("query response outcome is missing")),
163        }
164    }
165
166    /// Serializes `args` as JSON, queries a workflow, and deserializes the JSON result.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`ClientError::InvalidArgument`] when serialization or result
171    /// decoding fails, or the delegated query error otherwise.
172    pub async fn query_typed<A, R>(
173        &self,
174        workflow_id: &WorkflowId,
175        run_id: Option<&RunId>,
176        name: impl Into<String>,
177        args: &A,
178        deadline: Duration,
179    ) -> Result<R, ClientError>
180    where
181        A: Serialize + ?Sized,
182        R: DeserializeOwned,
183    {
184        let payload = self
185            .query(
186                workflow_id,
187                run_id,
188                name,
189                query_args_payload(args)?,
190                deadline,
191            )
192            .await?;
193        from_payload(&payload)
194    }
195
196    /// Requests cancellation of the latest run, or `run_id` when supplied.
197    ///
198    /// Success means the server accepted the cancellation request; it is not a
199    /// confirmation that the workflow has reached a terminal cancelled state.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`ClientError`] when transport, server, or request conversion fails.
204    pub async fn cancel(
205        &self,
206        workflow_id: &WorkflowId,
207        run_id: Option<&RunId>,
208        reason: impl Into<String>,
209    ) -> Result<(), ClientError> {
210        self.transport
211            .cancel(ProtoCancelRequest {
212                namespace: self.namespace().to_owned(),
213                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
214                run_id: run_id.cloned().map(ProtoRunId::from),
215                reason: reason.into(),
216            })
217            .await?;
218        Ok(())
219    }
220
221    /// Reopens a terminal-reopenable run (Failed or Cancelled), re-driving it
222    /// from where it left off. Targets the latest run, or `run_id` when supplied.
223    ///
224    /// Returns the reopened run and its projected status (Running). A run that is
225    /// not a reopenable terminal (not terminal, terminal for a non-reopenable
226    /// reason, or already Running) returns [`ClientError::InvalidState`]; an
227    /// absent workflow returns [`ClientError::NotFound`].
228    ///
229    /// # Errors
230    ///
231    /// Returns [`ClientError`] when transport, server, or response conversion fails.
232    pub async fn reopen(
233        &self,
234        workflow_id: &WorkflowId,
235        run_id: Option<&RunId>,
236    ) -> Result<ReopenOutcome, ClientError> {
237        let response = self
238            .transport
239            .reopen(ProtoReopenRequest {
240                namespace: self.namespace().to_owned(),
241                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
242                run_id: run_id.cloned().map(ProtoRunId::from),
243            })
244            .await?;
245        let run_id = response
246            .run_id
247            .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
248            .try_into()
249            .map_err(ClientError::from_wire_error)?;
250        let status = ProtoWorkflowStatus::try_from(response.status)
251            .map_err(|_error| ClientError::server("reopen response status is unknown"))
252            .and_then(|status| {
253                WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
254            })?;
255        Ok(ReopenOutcome { run_id, status })
256    }
257
258    /// Pauses a live `Running` run, durably holding new activity dispatch (#204).
259    /// Targets the latest run, or `run_id` when supplied. Returns the run and its
260    /// projected status (Paused). A run that is not `Running` returns
261    /// [`ClientError::InvalidState`]; an absent workflow returns
262    /// [`ClientError::NotFound`].
263    ///
264    /// # Errors
265    ///
266    /// Returns [`ClientError`] when transport, server, or response conversion fails.
267    pub async fn pause(
268        &self,
269        workflow_id: &WorkflowId,
270        run_id: Option<&RunId>,
271        reason: impl Into<String>,
272    ) -> Result<PauseOutcome, ClientError> {
273        let response = self
274            .transport
275            .pause(ProtoPauseRequest {
276                namespace: self.namespace().to_owned(),
277                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
278                run_id: run_id.cloned().map(ProtoRunId::from),
279                reason: reason.into(),
280            })
281            .await?;
282        let run_id = response
283            .run_id
284            .ok_or_else(|| ClientError::server("pause response run id is missing"))?
285            .try_into()
286            .map_err(ClientError::from_wire_error)?;
287        let status = ProtoWorkflowStatus::try_from(response.status)
288            .map_err(|_error| ClientError::server("pause response status is unknown"))
289            .and_then(|status| {
290                WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
291            })?;
292        Ok(PauseOutcome { run_id, status })
293    }
294
295    /// Resumes a `Paused` run, releasing the dispatch hold (#204). Targets the
296    /// latest run, or `run_id` when supplied. Returns the run and its projected
297    /// status (Running). A run that is not `Paused` returns
298    /// [`ClientError::InvalidState`]; an absent workflow returns
299    /// [`ClientError::NotFound`].
300    ///
301    /// # Errors
302    ///
303    /// Returns [`ClientError`] when transport, server, or response conversion fails.
304    pub async fn resume(
305        &self,
306        workflow_id: &WorkflowId,
307        run_id: Option<&RunId>,
308    ) -> Result<ResumeOutcome, ClientError> {
309        let response = self
310            .transport
311            .resume(ProtoResumeRequest {
312                namespace: self.namespace().to_owned(),
313                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
314                run_id: run_id.cloned().map(ProtoRunId::from),
315            })
316            .await?;
317        let run_id = response
318            .run_id
319            .ok_or_else(|| ClientError::server("resume response run id is missing"))?
320            .try_into()
321            .map_err(ClientError::from_wire_error)?;
322        let status = ProtoWorkflowStatus::try_from(response.status)
323            .map_err(|_error| ClientError::server("resume response status is unknown"))
324            .and_then(|status| {
325                WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
326            })?;
327        Ok(ResumeOutcome { run_id, status })
328    }
329
330    /// Lists workflows matching a filter.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`ClientError`] when transport, server, or response conversion fails.
335    pub async fn list(
336        &self,
337        filter: &WorkflowFilter,
338        page: ListPage,
339    ) -> Result<Vec<WorkflowSummary>, ClientError> {
340        validate_list_page(&page)?;
341        let namespace = self.namespace().to_owned();
342        let filter = workflow_filter_to_visibility(filter)?;
343        let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
344            .map_err(ClientError::from_wire_error)?;
345        let response = self
346            .transport
347            .list_workflows(ProtoListWorkflowsRequest {
348                namespace,
349                filter: Some(filter),
350            })
351            .await?;
352
353        response
354            .summaries
355            .iter()
356            .map(decode_visibility_summary)
357            .map(|result| result.map_err(ClientError::from_wire_error))
358            .collect()
359    }
360
361    /// Describes the latest run, or `run_id` when supplied.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`ClientError`] when transport, server, or response conversion fails.
366    pub async fn describe(
367        &self,
368        workflow_id: &WorkflowId,
369        run_id: Option<&RunId>,
370    ) -> Result<WorkflowDescription, ClientError> {
371        let response = self
372            .transport
373            .describe_workflow(ProtoDescribeWorkflowRequest {
374                namespace: self.namespace().to_owned(),
375                workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
376                run_id: run_id.cloned().map(ProtoRunId::from),
377                include_history: true,
378            })
379            .await?;
380        let summary = response
381            .summary
382            .as_ref()
383            .ok_or_else(|| ClientError::server("describe response summary is missing"))
384            .and_then(|summary| {
385                decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
386            })?;
387        let history = response
388            .history
389            .iter()
390            .map(decode_event)
391            .map(|result| result.map_err(ClientError::from_wire_error))
392            .collect::<Result<Vec<_>, _>>()?;
393        Ok(WorkflowDescription { summary, history })
394    }
395
396    /// Subscribes to events for a workflow.
397    #[must_use]
398    pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
399        event_stream(
400            self.transport.clone(),
401            self.namespace().to_owned(),
402            SubscribeTarget::Workflow {
403                workflow_id: workflow_id.clone(),
404            },
405        )
406    }
407
408    /// Subscribes to events for a workflow, attaching from an explicit
409    /// per-workflow sequence cursor.
410    ///
411    /// `resume_from` is the first sequence number wanted (`resume_from_seq`
412    /// on the wire); `1` replays the workflow's full recorded history before
413    /// splicing into the live stream, gap-free and duplicate-free.
414    #[must_use]
415    pub fn subscribe_workflow_from(
416        &self,
417        workflow_id: &WorkflowId,
418        resume_from: NonZeroU64,
419    ) -> EventStream {
420        event_stream_from(
421            self.transport.clone(),
422            self.namespace().to_owned(),
423            workflow_id.clone(),
424            resume_from,
425        )
426    }
427
428    /// Subscribes to events selected by the supplied workflow filter.
429    #[must_use]
430    pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
431        event_stream(
432            self.transport.clone(),
433            self.namespace().to_owned(),
434            SubscribeTarget::Filtered { filter },
435        )
436    }
437
438    /// Subscribes to every event visible to this client namespace.
439    #[must_use]
440    pub fn subscribe_firehose(&self) -> EventStream {
441        event_stream(
442            self.transport.clone(),
443            self.namespace().to_owned(),
444            SubscribeTarget::Firehose,
445        )
446    }
447}
448
449pub(crate) fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
450    namespace.unwrap_or_else(|| client.namespace().to_owned())
451}
452
453/// Serialize typed query arguments into the payload the wire carries.
454///
455/// A value that serializes to JSON `null` needs no special case: `null` *is*
456/// the canonical "no arguments" document (see [`Payload::json_null`]), so the
457/// serialized bytes are already exactly what a no-argument query sends.
458fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
459where
460    T: Serialize + ?Sized,
461{
462    to_payload(args)
463}
464
465fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
466    if page.limit.is_some() || page.cursor.is_some() {
467        return Err(ClientError::invalid_argument(
468            "list pagination limit/cursor are reserved by the contract and \
469             not yet carried by the wire",
470        ));
471    }
472    Ok(())
473}
474
475fn workflow_filter_to_visibility(
476    filter: &WorkflowFilter,
477) -> Result<ListWorkflowsFilter, ClientError> {
478    if filter.parent.is_some() {
479        return Err(ClientError::invalid_argument(
480            "parent workflow filters are not carried by the visibility wire contract",
481        ));
482    }
483
484    Ok(ListWorkflowsFilter {
485        workflow_type: filter.workflow_type.clone(),
486        status: filter.status,
487        started_after: filter.started_after,
488        started_before: filter.started_before,
489        ..ListWorkflowsFilter::default()
490    })
491}
492
493fn decode_visibility_summary(
494    envelope: &aion_proto::WireEnvelope,
495) -> Result<WorkflowSummary, WireError> {
496    let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
497    Ok(WorkflowSummary {
498        workflow_id: summary.workflow_id,
499        workflow_type: summary.workflow_type,
500        status: summary.status,
501        started_at: summary.start_time,
502        ended_at: summary.close_time,
503        parent: None,
504        failed_step: summary.failed_step,
505        failure_reason: summary.failure_reason,
506        display_name: aion_core::display_name_from_attributes(&summary.search_attributes),
507    })
508}
509
510pub(crate) fn decode_required_workflow_id(
511    value: Option<ProtoWorkflowId>,
512    context: &str,
513) -> Result<WorkflowId, ClientError> {
514    value
515        .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
516        .try_into()
517        .map_err(ClientError::from_wire_error)
518}
519
520pub(crate) fn decode_required_run_id(
521    value: Option<ProtoRunId>,
522    context: &str,
523) -> Result<RunId, ClientError> {
524    value
525        .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
526        .try_into()
527        .map_err(ClientError::from_wire_error)
528}
529
530/// Maps a `QueryResponse.error` payload through the shared wire taxonomy.
531///
532/// The server reports query-handler application failures with the dedicated
533/// `query_failed` wire code, so the shared map yields [`ClientError::QueryFailed`]
534/// directly; `backend` stays an unexpected server fault.
535fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
536    ClientError::from_proto_wire_error(error)
537}
538
539#[cfg(test)]
540mod tests {
541    use std::sync::Arc;
542    use std::time::Duration;
543
544    use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
545    use aion_proto::{
546        ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
547        ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
548        ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
549        encode_core_value, encode_workflow_summary, proto_query_response,
550    };
551    use async_trait::async_trait;
552    use chrono::Utc;
553    use futures::StreamExt;
554    use futures::stream;
555    use tokio::sync::Mutex;
556
557    use super::ListPage;
558    use crate::client::{Client, ClientBuilder, ClientConfig};
559    use crate::error::ClientError;
560    use crate::start::{DisplayNameNotApplied, StartOptions};
561    use crate::transport::{SubscriptionAttempt, WorkflowTransport};
562
563    #[derive(Default)]
564    struct StubTransport {
565        last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
566        last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
567        last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
568        last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
569        last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
570        last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
571        last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
572        start_error: Mutex<Option<ClientError>>,
573        signal_error: Mutex<Option<ClientError>>,
574        query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
575        reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
576        /// How many starts actually reached the transport. `last_start` alone
577        /// cannot distinguish "deduped, never sent" from "sent again with the
578        /// same values", which is exactly what the #211 replay tests assert.
579        start_calls: std::sync::atomic::AtomicUsize,
580    }
581
582    #[async_trait]
583    impl WorkflowTransport for StubTransport {
584        async fn start_workflow(
585            &self,
586            request: aion_proto::ProtoStartWorkflowRequest,
587        ) -> Result<ProtoStartWorkflowResponse, ClientError> {
588            self.start_calls
589                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
590            *self.last_start.lock().await = Some(request);
591            if let Some(error) = self.start_error.lock().await.take() {
592                return Err(error);
593            }
594            Ok(ProtoStartWorkflowResponse {
595                workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
596                run_id: Some(ProtoRunId::from(run_id())),
597            })
598        }
599
600        async fn signal(
601            &self,
602            request: aion_proto::ProtoSignalRequest,
603        ) -> Result<ProtoSignalResponse, ClientError> {
604            *self.last_signal.lock().await = Some(request);
605            if let Some(error) = self.signal_error.lock().await.take() {
606                return Err(error);
607            }
608            Ok(ProtoSignalResponse {})
609        }
610
611        async fn query(
612            &self,
613            request: aion_proto::ProtoQueryRequest,
614        ) -> Result<ProtoQueryResponse, ClientError> {
615            *self.last_query.lock().await = Some(request);
616            if let Some(response) = self.query_response.lock().await.take() {
617                return response;
618            }
619            Ok(ProtoQueryResponse {
620                outcome: Some(proto_query_response::Outcome::Result(
621                    aion_proto::ProtoPayload::from(payload("result")),
622                )),
623            })
624        }
625
626        async fn cancel(
627            &self,
628            request: aion_proto::ProtoCancelRequest,
629        ) -> Result<ProtoCancelResponse, ClientError> {
630            *self.last_cancel.lock().await = Some(request);
631            Ok(ProtoCancelResponse {})
632        }
633
634        async fn reopen(
635            &self,
636            request: aion_proto::ProtoReopenRequest,
637        ) -> Result<ProtoReopenResponse, ClientError> {
638            *self.last_reopen.lock().await = Some(request);
639            if let Some(response) = self.reopen_response.lock().await.take() {
640                return response;
641            }
642            Ok(ProtoReopenResponse {
643                run_id: Some(ProtoRunId::from(run_id())),
644                status: ProtoWorkflowStatus::Running as i32,
645            })
646        }
647
648        async fn pause(
649            &self,
650            _request: aion_proto::ProtoPauseRequest,
651        ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
652            Ok(aion_proto::ProtoPauseResponse {
653                run_id: Some(ProtoRunId::from(run_id())),
654                status: ProtoWorkflowStatus::Paused as i32,
655            })
656        }
657
658        async fn resume(
659            &self,
660            _request: aion_proto::ProtoResumeRequest,
661        ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
662            Ok(aion_proto::ProtoResumeResponse {
663                run_id: Some(ProtoRunId::from(run_id())),
664                status: ProtoWorkflowStatus::Running as i32,
665            })
666        }
667
668        async fn list_workflows(
669            &self,
670            request: aion_proto::ProtoListWorkflowsRequest,
671        ) -> Result<ProtoListWorkflowsResponse, ClientError> {
672            *self.last_list.lock().await = Some(request);
673            Ok(ProtoListWorkflowsResponse {
674                summaries: vec![
675                    encode_core_value("tenant-a", None, &visibility_summary())
676                        .map_err(ClientError::from_wire_error)?,
677                ],
678            })
679        }
680
681        async fn describe_workflow(
682            &self,
683            request: aion_proto::ProtoDescribeWorkflowRequest,
684        ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
685            *self.last_describe.lock().await = Some(request);
686            Ok(ProtoDescribeWorkflowResponse {
687                summary: Some(
688                    encode_workflow_summary("tenant-a", None, &summary())
689                        .map_err(ClientError::from_wire_error)?,
690                ),
691                history: Vec::new(),
692            })
693        }
694
695        async fn subscribe(
696            &self,
697            _: aion_proto::SubscriptionRequest,
698            _: Option<u64>,
699        ) -> Result<SubscriptionAttempt, ClientError> {
700            Ok(SubscriptionAttempt::new(stream::empty().boxed()))
701        }
702    }
703
704    fn client_with(stub: Arc<StubTransport>) -> Client {
705        Client::from_transport(
706            ClientConfig::from(
707                ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
708            ),
709            stub,
710        )
711    }
712
713    fn workflow_id() -> WorkflowId {
714        WorkflowId::new_v4()
715    }
716
717    fn run_id() -> aion_core::RunId {
718        aion_core::RunId::new_v4()
719    }
720
721    fn payload(label: &str) -> Payload {
722        Payload::new(
723            ContentType::Json,
724            format!("{{\"label\":\"{label}\"}}").into_bytes(),
725        )
726    }
727
728    fn summary() -> aion_core::WorkflowSummary {
729        aion_core::WorkflowSummary {
730            workflow_id: workflow_id(),
731            workflow_type: String::from("checkout"),
732            status: WorkflowStatus::Running,
733            started_at: Utc::now(),
734            ended_at: None,
735            parent: None,
736            failed_step: None,
737            failure_reason: None,
738            display_name: None,
739        }
740    }
741
742    fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
743        aion_store::visibility::WorkflowSummary {
744            workflow_id: workflow_id(),
745            run_id: run_id(),
746            workflow_type: String::from("checkout"),
747            status: WorkflowStatus::Running,
748            start_time: Utc::now(),
749            close_time: None,
750            failed_step: None,
751            failure_reason: None,
752            search_attributes: std::collections::HashMap::new(),
753        }
754    }
755
756    #[tokio::test]
757    async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
758        let stub = Arc::new(StubTransport::default());
759        let client = client_with(Arc::clone(&stub));
760
761        let result = client
762            .start("checkout", payload("input"), StartOptions::default())
763            .await?;
764
765        let recorded = stub.last_start.lock().await.clone();
766        assert!(recorded.is_some());
767        let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
768        assert_eq!(request.namespace, "tenant-a");
769        assert_eq!(request.workflow_type, "checkout");
770        assert!(request.input.is_some());
771        assert_ne!(
772            result.handle.workflow_id(),
773            &WorkflowId::new(uuid::Uuid::nil())
774        );
775        assert_eq!(result.display_name_not_applied, None);
776        Ok(())
777    }
778
779    #[tokio::test]
780    async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
781    {
782        let stub = Arc::new(StubTransport::default());
783        let client = client_with(Arc::clone(&stub));
784        let opts = StartOptions {
785            namespace: None,
786            idempotency_key: Some(String::from("retry-key")),
787            routing_key: None,
788            task_queue: None,
789            display_name: None,
790        };
791
792        let original = client
793            .start("checkout", payload("input"), opts.clone())
794            .await?;
795        let replayed = client
796            .start("checkout", payload("input"), opts.clone())
797            .await?;
798        let conflict = client.start("checkout", payload("other"), opts).await;
799
800        assert_eq!(replayed, original);
801        assert!(
802            matches!(conflict, Err(ClientError::AlreadyExists { .. })),
803            "got {conflict:?}"
804        );
805        Ok(())
806    }
807
808    /// #211 fingerprint ruling: a display name is NOT part of the idempotency
809    /// fingerprint, so two starts differing only in name are the SAME act and
810    /// dedupe to one run — but the second name is not silently applied and not
811    /// silently dropped. The caller gets the EXISTING run wearing its EXISTING
812    /// name, plus a note saying which name was not applied.
813    #[tokio::test]
814    async fn a_replay_with_a_different_name_dedupes_and_reports_the_unapplied_name()
815    -> Result<(), ClientError> {
816        let stub = Arc::new(StubTransport::default());
817        let client = client_with(Arc::clone(&stub));
818        let named = |name: Option<&str>| StartOptions {
819            idempotency_key: Some(String::from("retry-key")),
820            display_name: name.map(str::to_owned),
821            ..StartOptions::default()
822        };
823
824        let first = client
825            .start(
826                "checkout",
827                payload("input"),
828                named(Some("Nightly settlement")),
829            )
830            .await?;
831        assert_eq!(
832            first.display_name_not_applied, None,
833            "a first start applies the name it asked for"
834        );
835
836        // Same act, different label: one run, and the difference is REPORTED.
837        let replayed = client
838            .start("checkout", payload("input"), named(Some("Something else")))
839            .await?;
840        assert_eq!(
841            replayed.handle, first.handle,
842            "the label carries no identity, so the act deduped to one run"
843        );
844        assert_eq!(
845            replayed.display_name_not_applied,
846            Some(DisplayNameNotApplied {
847                requested: String::from("Something else"),
848                standing: Some(String::from("Nightly settlement")),
849            }),
850            "the name that was NOT applied must be reported, never dropped in silence"
851        );
852
853        // Asking for NO name drops nothing, so it reports nothing — even
854        // against a run that wears a name of its own. See the four-combination
855        // test below for the whole rule.
856        let replayed = client
857            .start("checkout", payload("input"), named(None))
858            .await?;
859        assert_eq!(
860            replayed.display_name_not_applied, None,
861            "a caller that requested no name had no name dropped"
862        );
863
864        // Asking for the SAME name is not a difference and reports nothing.
865        let replayed = client
866            .start(
867                "checkout",
868                payload("input"),
869                named(Some("Nightly settlement")),
870            )
871            .await?;
872        assert_eq!(replayed.display_name_not_applied, None);
873
874        // Surrounding whitespace is not a difference either: the server trims
875        // before recording, so the run already wears the trimmed name.
876        let replayed = client
877            .start(
878                "checkout",
879                payload("input"),
880                named(Some("  Nightly settlement  ")),
881            )
882            .await?;
883        assert_eq!(
884            replayed.display_name_not_applied, None,
885            "whitespace the server would have erased is not an unapplied name"
886        );
887
888        // Exactly ONE start reached the transport across all five calls.
889        assert_eq!(
890            stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
891            1
892        );
893        Ok(())
894    }
895
896    /// #211: the WHOLE rule for when an idempotent replay reports an unapplied
897    /// display name, one case per combination of what the replay asked for and
898    /// what the standing run wears.
899    ///
900    /// The report is raised only when the caller ASKED for a name the standing
901    /// run does not wear. A caller that asked for nothing dropped nothing, so
902    /// it is told nothing — the report's absence is exactly the statement that
903    /// no requested name went missing, and firing it for an empty request would
904    /// say the opposite of the truth.
905    #[tokio::test]
906    async fn the_unapplied_name_report_fires_only_for_a_replay_that_asked_for_a_name()
907    -> Result<(), ClientError> {
908        /// One combination: what the FIRST start requested (and so what the
909        /// standing run wears), what the REPLAY requests, and the report the
910        /// replay must produce.
911        struct Case {
912            label: &'static str,
913            standing: Option<&'static str>,
914            requested: Option<&'static str>,
915            expected: Option<DisplayNameNotApplied>,
916        }
917
918        let cases = [
919            Case {
920                label: "requested none, standing none",
921                standing: None,
922                requested: None,
923                expected: None,
924            },
925            Case {
926                label: "requested none, standing some",
927                standing: Some("Nightly"),
928                requested: None,
929                expected: None,
930            },
931            Case {
932                label: "requested some, standing none",
933                standing: None,
934                requested: Some("Nightly"),
935                expected: Some(DisplayNameNotApplied {
936                    requested: String::from("Nightly"),
937                    standing: None,
938                }),
939            },
940            Case {
941                label: "requested some, standing a different some",
942                standing: Some("Nightly"),
943                requested: Some("Weekly"),
944                expected: Some(DisplayNameNotApplied {
945                    requested: String::from("Weekly"),
946                    standing: Some(String::from("Nightly")),
947                }),
948            },
949            Case {
950                label: "requested some, standing the same some",
951                standing: Some("Nightly"),
952                requested: Some("Nightly"),
953                expected: None,
954            },
955        ];
956
957        for Case {
958            label,
959            standing,
960            requested,
961            expected,
962        } in cases
963        {
964            let stub = Arc::new(StubTransport::default());
965            let client = client_with(Arc::clone(&stub));
966            let named = |name: Option<&str>| StartOptions {
967                idempotency_key: Some(String::from("retry-key")),
968                display_name: name.map(str::to_owned),
969                ..StartOptions::default()
970            };
971
972            let first = client
973                .start("checkout", payload("input"), named(standing))
974                .await?;
975            assert_eq!(
976                first.display_name_not_applied, None,
977                "{label}: a first start applies the name it asked for"
978            );
979
980            let replayed = client
981                .start("checkout", payload("input"), named(requested))
982                .await?;
983            assert_eq!(
984                replayed.handle, first.handle,
985                "{label}: the name carries no identity, so the act deduped to one run"
986            );
987            assert_eq!(
988                replayed.display_name_not_applied, expected,
989                "{label}: the report must fire exactly when a REQUESTED name was not applied"
990            );
991            assert_eq!(
992                stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
993                1,
994                "{label}: the replay must not reach the transport"
995            );
996        }
997        Ok(())
998    }
999
1000    /// #211: a blank display name is refused at the SDK boundary, matching the
1001    /// server, which refuses a present-but-blank name with `invalid_input`
1002    /// rather than reading it as "unnamed". Absence is how a caller says
1003    /// "unnamed"; forwarding a blank would only buy a round trip to the same
1004    /// refusal.
1005    #[tokio::test]
1006    async fn a_blank_display_name_is_refused() -> Result<(), ClientError> {
1007        let stub = Arc::new(StubTransport::default());
1008        let client = client_with(Arc::clone(&stub));
1009
1010        for blank in ["", "   ", "\t\n "] {
1011            let result = client
1012                .start(
1013                    "checkout",
1014                    payload("input"),
1015                    StartOptions {
1016                        display_name: Some(String::from(blank)),
1017                        ..StartOptions::default()
1018                    },
1019                )
1020                .await;
1021            assert!(
1022                matches!(result, Err(ClientError::InvalidArgument { .. })),
1023                "blank {blank:?} must be refused, got {result:?}"
1024            );
1025        }
1026        assert_eq!(
1027            stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1028            0
1029        );
1030        Ok(())
1031    }
1032
1033    #[tokio::test]
1034    async fn start_idempotency_treats_a_changed_route_as_a_different_request()
1035    -> Result<(), ClientError> {
1036        for (label, second) in [
1037            (
1038                "task_queue",
1039                StartOptions {
1040                    task_queue: Some(String::from("payments")),
1041                    ..StartOptions::default()
1042                },
1043            ),
1044            (
1045                "routing_key",
1046                StartOptions {
1047                    routing_key: Some(String::from("tenant-7")),
1048                    ..StartOptions::default()
1049                },
1050            ),
1051        ] {
1052            let stub = Arc::new(StubTransport::default());
1053            let client = client_with(Arc::clone(&stub));
1054            let key = Some(String::from("retry-key"));
1055            let first = StartOptions {
1056                idempotency_key: key.clone(),
1057                ..StartOptions::default()
1058            };
1059            let second = StartOptions {
1060                idempotency_key: key,
1061                ..second
1062            };
1063
1064            client.start("checkout", payload("input"), first).await?;
1065            let conflict = client.start("checkout", payload("input"), second).await;
1066
1067            assert!(
1068                matches!(conflict, Err(ClientError::AlreadyExists { .. })),
1069                "reusing a key with a different {label} must conflict, got {conflict:?}"
1070            );
1071        }
1072        Ok(())
1073    }
1074
1075    #[tokio::test]
1076    async fn signal_maps_latest_run_and_error() {
1077        let stub = Arc::new(StubTransport::default());
1078        *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
1079        let client = client_with(Arc::clone(&stub));
1080        let id = workflow_id();
1081
1082        let result = client.signal(&id, None, "approve", payload("signal")).await;
1083
1084        assert_eq!(
1085            result,
1086            Err(ClientError::not_found("workflow was not found"))
1087        );
1088        let recorded = stub.last_signal.lock().await.clone();
1089        assert!(recorded.is_some());
1090        let Some(request) = recorded else {
1091            return;
1092        };
1093        assert!(request.run_id.is_none());
1094    }
1095
1096    #[tokio::test]
1097    async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1098        let stub = Arc::new(StubTransport::default());
1099        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1100            outcome: Some(proto_query_response::Outcome::Error(
1101                aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1102            )),
1103        }));
1104        let client = client_with(Arc::clone(&stub));
1105        let id = workflow_id();
1106
1107        let result = client
1108            .query(
1109                &id,
1110                Some(&run_id()),
1111                "state",
1112                Payload::json_null(),
1113                Duration::from_secs(1),
1114            )
1115            .await;
1116
1117        assert_eq!(result, Err(ClientError::query_timeout("slow")));
1118        let recorded = stub.last_query.lock().await.clone();
1119        assert!(recorded.is_some());
1120        let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1121        assert!(request.run_id.is_some());
1122        Ok(())
1123    }
1124
1125    #[tokio::test]
1126    async fn query_forwards_its_arguments_onto_the_wire() -> Result<(), ClientError> {
1127        let stub = Arc::new(StubTransport::default());
1128        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1129            outcome: Some(proto_query_response::Outcome::Result(
1130                aion_proto::ProtoPayload::from(payload("answer")),
1131            )),
1132        }));
1133        let client = client_with(Arc::clone(&stub));
1134
1135        let returned = client
1136            .query(
1137                &workflow_id(),
1138                Some(&run_id()),
1139                "state",
1140                payload("args"),
1141                Duration::from_secs(1),
1142            )
1143            .await?;
1144
1145        assert_eq!(returned, payload("answer"));
1146        let request = stub
1147            .last_query
1148            .lock()
1149            .await
1150            .clone()
1151            .ok_or_else(|| ClientError::server("missing query"))?;
1152        // The caller's arguments reach the wire byte-exact: this client is a
1153        // carrier, not a place that reshapes or drops the request.
1154        assert_eq!(
1155            request.arguments,
1156            Some(aion_proto::ProtoPayload::from(payload("args")))
1157        );
1158        Ok(())
1159    }
1160
1161    #[tokio::test]
1162    async fn a_no_argument_query_sends_the_canonical_null_document() -> Result<(), ClientError> {
1163        let stub = Arc::new(StubTransport::default());
1164        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1165            outcome: Some(proto_query_response::Outcome::Result(
1166                aion_proto::ProtoPayload::from(payload("answer")),
1167            )),
1168        }));
1169        let client = client_with(Arc::clone(&stub));
1170
1171        // `&()` is how a caller says "this query takes nothing"; it must reach
1172        // the wire as the same `null` document every other carrier sends, not
1173        // as empty bytes no decoder can read.
1174        let _: serde_json::Value = client
1175            .query_typed(
1176                &workflow_id(),
1177                Some(&run_id()),
1178                "state",
1179                &(),
1180                Duration::from_secs(1),
1181            )
1182            .await?;
1183
1184        let request = stub
1185            .last_query
1186            .lock()
1187            .await
1188            .clone()
1189            .ok_or_else(|| ClientError::server("missing query"))?;
1190        assert_eq!(
1191            request.arguments,
1192            Some(aion_proto::ProtoPayload::from(Payload::json_null()))
1193        );
1194        Ok(())
1195    }
1196
1197    #[tokio::test]
1198    async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1199        let stub = Arc::new(StubTransport::default());
1200        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1201            outcome: Some(proto_query_response::Outcome::Error(
1202                aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1203            )),
1204        }));
1205        let client = client_with(Arc::clone(&stub));
1206
1207        let result = client
1208            .query(
1209                &workflow_id(),
1210                Some(&run_id()),
1211                "state",
1212                Payload::json_null(),
1213                Duration::from_secs(1),
1214            )
1215            .await;
1216
1217        assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1218        Ok(())
1219    }
1220
1221    #[tokio::test]
1222    async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1223        // `backend` in QueryResponse.error is an unexpected server fault; the
1224        // application-level handler failure has its own `query_failed` code.
1225        let stub = Arc::new(StubTransport::default());
1226        *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1227            outcome: Some(proto_query_response::Outcome::Error(
1228                aion_proto::ProtoWireError::from(WireError::backend("store down")),
1229            )),
1230        }));
1231        let client = client_with(Arc::clone(&stub));
1232
1233        let result = client
1234            .query(
1235                &workflow_id(),
1236                Some(&run_id()),
1237                "state",
1238                Payload::json_null(),
1239                Duration::from_secs(1),
1240            )
1241            .await;
1242
1243        assert_eq!(result, Err(ClientError::server("store down")));
1244        Ok(())
1245    }
1246
1247    #[tokio::test]
1248    async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1249        #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1250        struct QueryResult {
1251            label: String,
1252        }
1253
1254        let stub = Arc::new(StubTransport::default());
1255        let client = client_with(Arc::clone(&stub));
1256        let id = workflow_id();
1257
1258        let result: QueryResult = client
1259            .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1260            .await?;
1261
1262        assert_eq!(
1263            result,
1264            QueryResult {
1265                label: String::from("result")
1266            }
1267        );
1268        assert!(stub.last_query.lock().await.is_some());
1269        Ok(())
1270    }
1271
1272    /// The anti-silent-drop property, stated positively.
1273    ///
1274    /// This test used to assert that non-empty typed arguments were REFUSED,
1275    /// because the wire could not carry them and dropping them silently was
1276    /// the failure to avoid. The wire carries them now, so the same property
1277    /// is proven the other way: the exact serialized document reaches the
1278    /// request. A regression that dropped arguments again would leave
1279    /// `arguments` absent and fail here — the refusal test could not have
1280    /// caught that, since it never inspected a forwarded request.
1281    #[tokio::test]
1282    async fn query_typed_forwards_non_empty_args_without_silent_drop() -> Result<(), ClientError> {
1283        let stub = Arc::new(StubTransport::default());
1284        let client = client_with(Arc::clone(&stub));
1285        let id = workflow_id();
1286
1287        let _: serde_json::Value = client
1288            .query_typed(
1289                &id,
1290                Some(&run_id()),
1291                "state",
1292                &serde_json::json!({ "filter": "open" }),
1293                Duration::from_secs(1),
1294            )
1295            .await?;
1296
1297        let request = stub
1298            .last_query
1299            .lock()
1300            .await
1301            .clone()
1302            .ok_or_else(|| ClientError::server("missing query"))?;
1303        let arguments = request
1304            .arguments
1305            .ok_or_else(|| ClientError::server("query arguments were dropped"))?;
1306        assert_eq!(
1307            serde_json::from_slice::<serde_json::Value>(&arguments.bytes)
1308                .map_err(|error| ClientError::server(error.to_string()))?,
1309            serde_json::json!({ "filter": "open" })
1310        );
1311        Ok(())
1312    }
1313
1314    #[tokio::test]
1315    async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1316        let stub = Arc::new(StubTransport::default());
1317        let client = client_with(Arc::clone(&stub));
1318        let id = workflow_id();
1319        let run = run_id();
1320
1321        client.cancel(&id, Some(&run), "not needed").await?;
1322        let listed = client
1323            .list(&WorkflowFilter::default(), ListPage::default())
1324            .await?;
1325        let described = client.describe(&id, None).await?;
1326
1327        assert!(stub.last_cancel.lock().await.is_some());
1328        assert!(stub.last_list.lock().await.is_some());
1329        let describe = stub
1330            .last_describe
1331            .lock()
1332            .await
1333            .clone()
1334            .ok_or_else(|| ClientError::server("missing describe"))?;
1335        assert!(describe.run_id.is_none());
1336        assert!(describe.include_history);
1337        assert_eq!(listed.len(), 1);
1338        assert_eq!(described.history.len(), 0);
1339        Ok(())
1340    }
1341
1342    #[tokio::test]
1343    async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1344        let stub = Arc::new(StubTransport::default());
1345        let client = client_with(Arc::clone(&stub));
1346        let id = workflow_id();
1347        let run = run_id();
1348
1349        let outcome = client.reopen(&id, Some(&run)).await?;
1350
1351        assert_eq!(outcome.status, WorkflowStatus::Running);
1352        let request = stub
1353            .last_reopen
1354            .lock()
1355            .await
1356            .clone()
1357            .ok_or_else(|| ClientError::server("missing reopen"))?;
1358        assert_eq!(request.namespace, "tenant-a");
1359        assert!(request.run_id.is_some());
1360        Ok(())
1361    }
1362
1363    /// The `InvalidState` wire code maps to the distinct typed
1364    /// [`ClientError::InvalidState`], never conflated with not-found.
1365    #[tokio::test]
1366    async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1367        let stub = Arc::new(StubTransport::default());
1368        *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1369            WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1370        )));
1371        let client = client_with(Arc::clone(&stub));
1372
1373        let result = client.reopen(&workflow_id(), None).await;
1374
1375        assert!(
1376            matches!(result, Err(ClientError::InvalidState { .. })),
1377            "got {result:?}"
1378        );
1379        Ok(())
1380    }
1381}