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