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