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