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