1use std::num::NonZeroU64;
4use std::time::Duration;
5
6use aion_core::{
7 Event, Payload, RunId, WorkflowFilter, WorkflowId, WorkflowStatus, WorkflowSummary,
8};
9use aion_proto::{
10 ProtoCancelRequest, ProtoDescribeWorkflowRequest, ProtoListWorkflowsRequest, ProtoPauseRequest,
11 ProtoPayload, ProtoQueryRequest, ProtoReopenRequest, ProtoResumeRequest, ProtoRunId,
12 ProtoSignalRequest, ProtoWorkflowId, ProtoWorkflowStatus, WireError, decode_core_value,
13 decode_event, decode_workflow_summary, encode_core_value, proto_query_response,
14};
15use aion_store::visibility::ListWorkflowsFilter;
16
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use crate::client::Client;
21use crate::error::ClientError;
22use crate::payload::{from_payload, to_payload};
23use crate::stream::{EventStream, SubscribeTarget, event_stream, event_stream_from};
24
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct ListPage {
32 pub request_id: Option<String>,
34 pub limit: Option<usize>,
36 pub cursor: Option<String>,
38}
39
40#[derive(Clone, Debug, PartialEq)]
42pub struct WorkflowDescription {
43 pub summary: WorkflowSummary,
45 pub history: Vec<Event>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ReopenOutcome {
52 pub run_id: RunId,
54 pub status: WorkflowStatus,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct PauseOutcome {
61 pub run_id: RunId,
63 pub status: WorkflowStatus,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct ResumeOutcome {
70 pub run_id: RunId,
72 pub status: WorkflowStatus,
74}
75
76impl Client {
77 pub async fn signal(
83 &self,
84 workflow_id: &WorkflowId,
85 run_id: Option<&RunId>,
86 name: impl Into<String>,
87 payload: Payload,
88 ) -> Result<(), ClientError> {
89 self.transport
90 .signal(ProtoSignalRequest {
91 namespace: self.namespace().to_owned(),
92 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
93 run_id: run_id.cloned().map(ProtoRunId::from),
94 signal_name: name.into(),
95 payload: Some(ProtoPayload::from(payload)),
96 })
97 .await?;
98 Ok(())
99 }
100
101 pub async fn signal_typed<T>(
108 &self,
109 workflow_id: &WorkflowId,
110 run_id: Option<&RunId>,
111 name: impl Into<String>,
112 value: &T,
113 ) -> Result<(), ClientError>
114 where
115 T: Serialize + ?Sized,
116 {
117 self.signal(workflow_id, run_id, name, to_payload(value)?)
118 .await
119 }
120
121 pub async fn query(
133 &self,
134 workflow_id: &WorkflowId,
135 run_id: Option<&RunId>,
136 name: impl Into<String>,
137 args: Payload,
138 deadline: Duration,
139 ) -> Result<Payload, ClientError> {
140 let response = tokio::time::timeout(
141 deadline,
142 self.transport.query(ProtoQueryRequest {
143 namespace: self.namespace().to_owned(),
144 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
145 run_id: run_id.cloned().map(ProtoRunId::from),
146 query_name: name.into(),
147 arguments: Some(aion_proto::ProtoPayload::from(args)),
148 }),
149 )
150 .await
151 .map_err(|_| {
152 ClientError::query_timeout(format!(
153 "query deadline of {deadline:?} elapsed before the server replied"
154 ))
155 })??;
156
157 match response.outcome {
158 Some(proto_query_response::Outcome::Result(payload)) => {
159 Payload::try_from(payload).map_err(ClientError::from_wire_error)
160 }
161 Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
162 None => Err(ClientError::server("query response outcome is missing")),
163 }
164 }
165
166 pub async fn query_typed<A, R>(
173 &self,
174 workflow_id: &WorkflowId,
175 run_id: Option<&RunId>,
176 name: impl Into<String>,
177 args: &A,
178 deadline: Duration,
179 ) -> Result<R, ClientError>
180 where
181 A: Serialize + ?Sized,
182 R: DeserializeOwned,
183 {
184 let payload = self
185 .query(
186 workflow_id,
187 run_id,
188 name,
189 query_args_payload(args)?,
190 deadline,
191 )
192 .await?;
193 from_payload(&payload)
194 }
195
196 pub async fn cancel(
205 &self,
206 workflow_id: &WorkflowId,
207 run_id: Option<&RunId>,
208 reason: impl Into<String>,
209 ) -> Result<(), ClientError> {
210 self.transport
211 .cancel(ProtoCancelRequest {
212 namespace: self.namespace().to_owned(),
213 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
214 run_id: run_id.cloned().map(ProtoRunId::from),
215 reason: reason.into(),
216 })
217 .await?;
218 Ok(())
219 }
220
221 pub async fn reopen(
233 &self,
234 workflow_id: &WorkflowId,
235 run_id: Option<&RunId>,
236 ) -> Result<ReopenOutcome, ClientError> {
237 let response = self
238 .transport
239 .reopen(ProtoReopenRequest {
240 namespace: self.namespace().to_owned(),
241 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
242 run_id: run_id.cloned().map(ProtoRunId::from),
243 })
244 .await?;
245 let run_id = response
246 .run_id
247 .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
248 .try_into()
249 .map_err(ClientError::from_wire_error)?;
250 let status = ProtoWorkflowStatus::try_from(response.status)
251 .map_err(|_error| ClientError::server("reopen response status is unknown"))
252 .and_then(|status| {
253 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
254 })?;
255 Ok(ReopenOutcome { run_id, status })
256 }
257
258 pub async fn pause(
268 &self,
269 workflow_id: &WorkflowId,
270 run_id: Option<&RunId>,
271 reason: impl Into<String>,
272 ) -> Result<PauseOutcome, ClientError> {
273 let response = self
274 .transport
275 .pause(ProtoPauseRequest {
276 namespace: self.namespace().to_owned(),
277 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
278 run_id: run_id.cloned().map(ProtoRunId::from),
279 reason: reason.into(),
280 })
281 .await?;
282 let run_id = response
283 .run_id
284 .ok_or_else(|| ClientError::server("pause response run id is missing"))?
285 .try_into()
286 .map_err(ClientError::from_wire_error)?;
287 let status = ProtoWorkflowStatus::try_from(response.status)
288 .map_err(|_error| ClientError::server("pause response status is unknown"))
289 .and_then(|status| {
290 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
291 })?;
292 Ok(PauseOutcome { run_id, status })
293 }
294
295 pub async fn resume(
305 &self,
306 workflow_id: &WorkflowId,
307 run_id: Option<&RunId>,
308 ) -> Result<ResumeOutcome, ClientError> {
309 let response = self
310 .transport
311 .resume(ProtoResumeRequest {
312 namespace: self.namespace().to_owned(),
313 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
314 run_id: run_id.cloned().map(ProtoRunId::from),
315 })
316 .await?;
317 let run_id = response
318 .run_id
319 .ok_or_else(|| ClientError::server("resume response run id is missing"))?
320 .try_into()
321 .map_err(ClientError::from_wire_error)?;
322 let status = ProtoWorkflowStatus::try_from(response.status)
323 .map_err(|_error| ClientError::server("resume response status is unknown"))
324 .and_then(|status| {
325 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
326 })?;
327 Ok(ResumeOutcome { run_id, status })
328 }
329
330 pub async fn list(
336 &self,
337 filter: &WorkflowFilter,
338 page: ListPage,
339 ) -> Result<Vec<WorkflowSummary>, ClientError> {
340 validate_list_page(&page)?;
341 let namespace = self.namespace().to_owned();
342 let filter = workflow_filter_to_visibility(filter)?;
343 let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
344 .map_err(ClientError::from_wire_error)?;
345 let response = self
346 .transport
347 .list_workflows(ProtoListWorkflowsRequest {
348 namespace,
349 filter: Some(filter),
350 })
351 .await?;
352
353 response
354 .summaries
355 .iter()
356 .map(decode_visibility_summary)
357 .map(|result| result.map_err(ClientError::from_wire_error))
358 .collect()
359 }
360
361 pub async fn describe(
367 &self,
368 workflow_id: &WorkflowId,
369 run_id: Option<&RunId>,
370 ) -> Result<WorkflowDescription, ClientError> {
371 let response = self
372 .transport
373 .describe_workflow(ProtoDescribeWorkflowRequest {
374 namespace: self.namespace().to_owned(),
375 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
376 run_id: run_id.cloned().map(ProtoRunId::from),
377 include_history: true,
378 })
379 .await?;
380 let summary = response
381 .summary
382 .as_ref()
383 .ok_or_else(|| ClientError::server("describe response summary is missing"))
384 .and_then(|summary| {
385 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
386 })?;
387 let history = response
388 .history
389 .iter()
390 .map(decode_event)
391 .map(|result| result.map_err(ClientError::from_wire_error))
392 .collect::<Result<Vec<_>, _>>()?;
393 Ok(WorkflowDescription { summary, history })
394 }
395
396 #[must_use]
398 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
399 event_stream(
400 self.transport.clone(),
401 self.namespace().to_owned(),
402 SubscribeTarget::Workflow {
403 workflow_id: workflow_id.clone(),
404 },
405 )
406 }
407
408 #[must_use]
415 pub fn subscribe_workflow_from(
416 &self,
417 workflow_id: &WorkflowId,
418 resume_from: NonZeroU64,
419 ) -> EventStream {
420 event_stream_from(
421 self.transport.clone(),
422 self.namespace().to_owned(),
423 workflow_id.clone(),
424 resume_from,
425 )
426 }
427
428 #[must_use]
430 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
431 event_stream(
432 self.transport.clone(),
433 self.namespace().to_owned(),
434 SubscribeTarget::Filtered { filter },
435 )
436 }
437
438 #[must_use]
440 pub fn subscribe_firehose(&self) -> EventStream {
441 event_stream(
442 self.transport.clone(),
443 self.namespace().to_owned(),
444 SubscribeTarget::Firehose,
445 )
446 }
447}
448
449pub(crate) fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
450 namespace.unwrap_or_else(|| client.namespace().to_owned())
451}
452
453fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
459where
460 T: Serialize + ?Sized,
461{
462 to_payload(args)
463}
464
465fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
466 if page.limit.is_some() || page.cursor.is_some() {
467 return Err(ClientError::invalid_argument(
468 "list pagination limit/cursor are reserved by the contract and \
469 not yet carried by the wire",
470 ));
471 }
472 Ok(())
473}
474
475fn workflow_filter_to_visibility(
476 filter: &WorkflowFilter,
477) -> Result<ListWorkflowsFilter, ClientError> {
478 if filter.parent.is_some() {
479 return Err(ClientError::invalid_argument(
480 "parent workflow filters are not carried by the visibility wire contract",
481 ));
482 }
483
484 Ok(ListWorkflowsFilter {
485 workflow_type: filter.workflow_type.clone(),
486 status: filter.status,
487 started_after: filter.started_after,
488 started_before: filter.started_before,
489 ..ListWorkflowsFilter::default()
490 })
491}
492
493fn decode_visibility_summary(
494 envelope: &aion_proto::WireEnvelope,
495) -> Result<WorkflowSummary, WireError> {
496 let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
497 Ok(WorkflowSummary {
498 workflow_id: summary.workflow_id,
499 workflow_type: summary.workflow_type,
500 status: summary.status,
501 started_at: summary.start_time,
502 ended_at: summary.close_time,
503 parent: None,
504 failed_step: summary.failed_step,
505 failure_reason: summary.failure_reason,
506 display_name: aion_core::display_name_from_attributes(&summary.search_attributes),
507 })
508}
509
510pub(crate) fn decode_required_workflow_id(
511 value: Option<ProtoWorkflowId>,
512 context: &str,
513) -> Result<WorkflowId, ClientError> {
514 value
515 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
516 .try_into()
517 .map_err(ClientError::from_wire_error)
518}
519
520pub(crate) fn decode_required_run_id(
521 value: Option<ProtoRunId>,
522 context: &str,
523) -> Result<RunId, ClientError> {
524 value
525 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
526 .try_into()
527 .map_err(ClientError::from_wire_error)
528}
529
530fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
536 ClientError::from_proto_wire_error(error)
537}
538
539#[cfg(test)]
540mod tests {
541 use std::sync::Arc;
542 use std::time::Duration;
543
544 use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
545 use aion_proto::{
546 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
547 ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
548 ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
549 encode_core_value, encode_workflow_summary, proto_query_response,
550 };
551 use async_trait::async_trait;
552 use chrono::Utc;
553 use futures::StreamExt;
554 use futures::stream;
555 use tokio::sync::Mutex;
556
557 use super::ListPage;
558 use crate::client::{Client, ClientBuilder, ClientConfig};
559 use crate::error::ClientError;
560 use crate::start::{DisplayNameNotApplied, StartOptions};
561 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
562
563 #[derive(Default)]
564 struct StubTransport {
565 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
566 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
567 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
568 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
569 last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
570 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
571 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
572 start_error: Mutex<Option<ClientError>>,
573 signal_error: Mutex<Option<ClientError>>,
574 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
575 reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
576 start_calls: std::sync::atomic::AtomicUsize,
580 }
581
582 #[async_trait]
583 impl WorkflowTransport for StubTransport {
584 async fn start_workflow(
585 &self,
586 request: aion_proto::ProtoStartWorkflowRequest,
587 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
588 self.start_calls
589 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
590 *self.last_start.lock().await = Some(request);
591 if let Some(error) = self.start_error.lock().await.take() {
592 return Err(error);
593 }
594 Ok(ProtoStartWorkflowResponse {
595 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
596 run_id: Some(ProtoRunId::from(run_id())),
597 })
598 }
599
600 async fn signal(
601 &self,
602 request: aion_proto::ProtoSignalRequest,
603 ) -> Result<ProtoSignalResponse, ClientError> {
604 *self.last_signal.lock().await = Some(request);
605 if let Some(error) = self.signal_error.lock().await.take() {
606 return Err(error);
607 }
608 Ok(ProtoSignalResponse {})
609 }
610
611 async fn query(
612 &self,
613 request: aion_proto::ProtoQueryRequest,
614 ) -> Result<ProtoQueryResponse, ClientError> {
615 *self.last_query.lock().await = Some(request);
616 if let Some(response) = self.query_response.lock().await.take() {
617 return response;
618 }
619 Ok(ProtoQueryResponse {
620 outcome: Some(proto_query_response::Outcome::Result(
621 aion_proto::ProtoPayload::from(payload("result")),
622 )),
623 })
624 }
625
626 async fn cancel(
627 &self,
628 request: aion_proto::ProtoCancelRequest,
629 ) -> Result<ProtoCancelResponse, ClientError> {
630 *self.last_cancel.lock().await = Some(request);
631 Ok(ProtoCancelResponse {})
632 }
633
634 async fn reopen(
635 &self,
636 request: aion_proto::ProtoReopenRequest,
637 ) -> Result<ProtoReopenResponse, ClientError> {
638 *self.last_reopen.lock().await = Some(request);
639 if let Some(response) = self.reopen_response.lock().await.take() {
640 return response;
641 }
642 Ok(ProtoReopenResponse {
643 run_id: Some(ProtoRunId::from(run_id())),
644 status: ProtoWorkflowStatus::Running as i32,
645 })
646 }
647
648 async fn pause(
649 &self,
650 _request: aion_proto::ProtoPauseRequest,
651 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
652 Ok(aion_proto::ProtoPauseResponse {
653 run_id: Some(ProtoRunId::from(run_id())),
654 status: ProtoWorkflowStatus::Paused as i32,
655 })
656 }
657
658 async fn resume(
659 &self,
660 _request: aion_proto::ProtoResumeRequest,
661 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
662 Ok(aion_proto::ProtoResumeResponse {
663 run_id: Some(ProtoRunId::from(run_id())),
664 status: ProtoWorkflowStatus::Running as i32,
665 })
666 }
667
668 async fn list_workflows(
669 &self,
670 request: aion_proto::ProtoListWorkflowsRequest,
671 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
672 *self.last_list.lock().await = Some(request);
673 Ok(ProtoListWorkflowsResponse {
674 summaries: vec![
675 encode_core_value("tenant-a", None, &visibility_summary())
676 .map_err(ClientError::from_wire_error)?,
677 ],
678 })
679 }
680
681 async fn describe_workflow(
682 &self,
683 request: aion_proto::ProtoDescribeWorkflowRequest,
684 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
685 *self.last_describe.lock().await = Some(request);
686 Ok(ProtoDescribeWorkflowResponse {
687 summary: Some(
688 encode_workflow_summary("tenant-a", None, &summary())
689 .map_err(ClientError::from_wire_error)?,
690 ),
691 history: Vec::new(),
692 })
693 }
694
695 async fn subscribe(
696 &self,
697 _: aion_proto::SubscriptionRequest,
698 _: Option<u64>,
699 ) -> Result<SubscriptionAttempt, ClientError> {
700 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
701 }
702 }
703
704 fn client_with(stub: Arc<StubTransport>) -> Client {
705 Client::from_transport(
706 ClientConfig::from(
707 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
708 ),
709 stub,
710 )
711 }
712
713 fn workflow_id() -> WorkflowId {
714 WorkflowId::new_v4()
715 }
716
717 fn run_id() -> aion_core::RunId {
718 aion_core::RunId::new_v4()
719 }
720
721 fn payload(label: &str) -> Payload {
722 Payload::new(
723 ContentType::Json,
724 format!("{{\"label\":\"{label}\"}}").into_bytes(),
725 )
726 }
727
728 fn summary() -> aion_core::WorkflowSummary {
729 aion_core::WorkflowSummary {
730 workflow_id: workflow_id(),
731 workflow_type: String::from("checkout"),
732 status: WorkflowStatus::Running,
733 started_at: Utc::now(),
734 ended_at: None,
735 parent: None,
736 failed_step: None,
737 failure_reason: None,
738 display_name: None,
739 }
740 }
741
742 fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
743 aion_store::visibility::WorkflowSummary {
744 workflow_id: workflow_id(),
745 run_id: run_id(),
746 workflow_type: String::from("checkout"),
747 status: WorkflowStatus::Running,
748 start_time: Utc::now(),
749 close_time: None,
750 failed_step: None,
751 failure_reason: None,
752 search_attributes: std::collections::HashMap::new(),
753 }
754 }
755
756 #[tokio::test]
757 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
758 let stub = Arc::new(StubTransport::default());
759 let client = client_with(Arc::clone(&stub));
760
761 let result = client
762 .start("checkout", payload("input"), StartOptions::default())
763 .await?;
764
765 let recorded = stub.last_start.lock().await.clone();
766 assert!(recorded.is_some());
767 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
768 assert_eq!(request.namespace, "tenant-a");
769 assert_eq!(request.workflow_type, "checkout");
770 assert!(request.input.is_some());
771 assert_ne!(
772 result.handle.workflow_id(),
773 &WorkflowId::new(uuid::Uuid::nil())
774 );
775 assert_eq!(result.display_name_not_applied, None);
776 Ok(())
777 }
778
779 #[tokio::test]
780 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
781 {
782 let stub = Arc::new(StubTransport::default());
783 let client = client_with(Arc::clone(&stub));
784 let opts = StartOptions {
785 namespace: None,
786 idempotency_key: Some(String::from("retry-key")),
787 routing_key: None,
788 task_queue: None,
789 display_name: None,
790 };
791
792 let original = client
793 .start("checkout", payload("input"), opts.clone())
794 .await?;
795 let replayed = client
796 .start("checkout", payload("input"), opts.clone())
797 .await?;
798 let conflict = client.start("checkout", payload("other"), opts).await;
799
800 assert_eq!(replayed, original);
801 assert!(
802 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
803 "got {conflict:?}"
804 );
805 Ok(())
806 }
807
808 #[tokio::test]
814 async fn a_replay_with_a_different_name_dedupes_and_reports_the_unapplied_name()
815 -> Result<(), ClientError> {
816 let stub = Arc::new(StubTransport::default());
817 let client = client_with(Arc::clone(&stub));
818 let named = |name: Option<&str>| StartOptions {
819 idempotency_key: Some(String::from("retry-key")),
820 display_name: name.map(str::to_owned),
821 ..StartOptions::default()
822 };
823
824 let first = client
825 .start(
826 "checkout",
827 payload("input"),
828 named(Some("Nightly settlement")),
829 )
830 .await?;
831 assert_eq!(
832 first.display_name_not_applied, None,
833 "a first start applies the name it asked for"
834 );
835
836 let replayed = client
838 .start("checkout", payload("input"), named(Some("Something else")))
839 .await?;
840 assert_eq!(
841 replayed.handle, first.handle,
842 "the label carries no identity, so the act deduped to one run"
843 );
844 assert_eq!(
845 replayed.display_name_not_applied,
846 Some(DisplayNameNotApplied {
847 requested: String::from("Something else"),
848 standing: Some(String::from("Nightly settlement")),
849 }),
850 "the name that was NOT applied must be reported, never dropped in silence"
851 );
852
853 let replayed = client
857 .start("checkout", payload("input"), named(None))
858 .await?;
859 assert_eq!(
860 replayed.display_name_not_applied, None,
861 "a caller that requested no name had no name dropped"
862 );
863
864 let replayed = client
866 .start(
867 "checkout",
868 payload("input"),
869 named(Some("Nightly settlement")),
870 )
871 .await?;
872 assert_eq!(replayed.display_name_not_applied, None);
873
874 let replayed = client
877 .start(
878 "checkout",
879 payload("input"),
880 named(Some(" Nightly settlement ")),
881 )
882 .await?;
883 assert_eq!(
884 replayed.display_name_not_applied, None,
885 "whitespace the server would have erased is not an unapplied name"
886 );
887
888 assert_eq!(
890 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
891 1
892 );
893 Ok(())
894 }
895
896 #[tokio::test]
906 async fn the_unapplied_name_report_fires_only_for_a_replay_that_asked_for_a_name()
907 -> Result<(), ClientError> {
908 struct Case {
912 label: &'static str,
913 standing: Option<&'static str>,
914 requested: Option<&'static str>,
915 expected: Option<DisplayNameNotApplied>,
916 }
917
918 let cases = [
919 Case {
920 label: "requested none, standing none",
921 standing: None,
922 requested: None,
923 expected: None,
924 },
925 Case {
926 label: "requested none, standing some",
927 standing: Some("Nightly"),
928 requested: None,
929 expected: None,
930 },
931 Case {
932 label: "requested some, standing none",
933 standing: None,
934 requested: Some("Nightly"),
935 expected: Some(DisplayNameNotApplied {
936 requested: String::from("Nightly"),
937 standing: None,
938 }),
939 },
940 Case {
941 label: "requested some, standing a different some",
942 standing: Some("Nightly"),
943 requested: Some("Weekly"),
944 expected: Some(DisplayNameNotApplied {
945 requested: String::from("Weekly"),
946 standing: Some(String::from("Nightly")),
947 }),
948 },
949 Case {
950 label: "requested some, standing the same some",
951 standing: Some("Nightly"),
952 requested: Some("Nightly"),
953 expected: None,
954 },
955 ];
956
957 for Case {
958 label,
959 standing,
960 requested,
961 expected,
962 } in cases
963 {
964 let stub = Arc::new(StubTransport::default());
965 let client = client_with(Arc::clone(&stub));
966 let named = |name: Option<&str>| StartOptions {
967 idempotency_key: Some(String::from("retry-key")),
968 display_name: name.map(str::to_owned),
969 ..StartOptions::default()
970 };
971
972 let first = client
973 .start("checkout", payload("input"), named(standing))
974 .await?;
975 assert_eq!(
976 first.display_name_not_applied, None,
977 "{label}: a first start applies the name it asked for"
978 );
979
980 let replayed = client
981 .start("checkout", payload("input"), named(requested))
982 .await?;
983 assert_eq!(
984 replayed.handle, first.handle,
985 "{label}: the name carries no identity, so the act deduped to one run"
986 );
987 assert_eq!(
988 replayed.display_name_not_applied, expected,
989 "{label}: the report must fire exactly when a REQUESTED name was not applied"
990 );
991 assert_eq!(
992 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
993 1,
994 "{label}: the replay must not reach the transport"
995 );
996 }
997 Ok(())
998 }
999
1000 #[tokio::test]
1006 async fn a_blank_display_name_is_refused() -> Result<(), ClientError> {
1007 let stub = Arc::new(StubTransport::default());
1008 let client = client_with(Arc::clone(&stub));
1009
1010 for blank in ["", " ", "\t\n "] {
1011 let result = client
1012 .start(
1013 "checkout",
1014 payload("input"),
1015 StartOptions {
1016 display_name: Some(String::from(blank)),
1017 ..StartOptions::default()
1018 },
1019 )
1020 .await;
1021 assert!(
1022 matches!(result, Err(ClientError::InvalidArgument { .. })),
1023 "blank {blank:?} must be refused, got {result:?}"
1024 );
1025 }
1026 assert_eq!(
1027 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1028 0
1029 );
1030 Ok(())
1031 }
1032
1033 #[tokio::test]
1034 async fn start_idempotency_treats_a_changed_route_as_a_different_request()
1035 -> Result<(), ClientError> {
1036 for (label, second) in [
1037 (
1038 "task_queue",
1039 StartOptions {
1040 task_queue: Some(String::from("payments")),
1041 ..StartOptions::default()
1042 },
1043 ),
1044 (
1045 "routing_key",
1046 StartOptions {
1047 routing_key: Some(String::from("tenant-7")),
1048 ..StartOptions::default()
1049 },
1050 ),
1051 ] {
1052 let stub = Arc::new(StubTransport::default());
1053 let client = client_with(Arc::clone(&stub));
1054 let key = Some(String::from("retry-key"));
1055 let first = StartOptions {
1056 idempotency_key: key.clone(),
1057 ..StartOptions::default()
1058 };
1059 let second = StartOptions {
1060 idempotency_key: key,
1061 ..second
1062 };
1063
1064 client.start("checkout", payload("input"), first).await?;
1065 let conflict = client.start("checkout", payload("input"), second).await;
1066
1067 assert!(
1068 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
1069 "reusing a key with a different {label} must conflict, got {conflict:?}"
1070 );
1071 }
1072 Ok(())
1073 }
1074
1075 #[tokio::test]
1076 async fn signal_maps_latest_run_and_error() {
1077 let stub = Arc::new(StubTransport::default());
1078 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
1079 let client = client_with(Arc::clone(&stub));
1080 let id = workflow_id();
1081
1082 let result = client.signal(&id, None, "approve", payload("signal")).await;
1083
1084 assert_eq!(
1085 result,
1086 Err(ClientError::not_found("workflow was not found"))
1087 );
1088 let recorded = stub.last_signal.lock().await.clone();
1089 assert!(recorded.is_some());
1090 let Some(request) = recorded else {
1091 return;
1092 };
1093 assert!(request.run_id.is_none());
1094 }
1095
1096 #[tokio::test]
1097 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1098 let stub = Arc::new(StubTransport::default());
1099 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1100 outcome: Some(proto_query_response::Outcome::Error(
1101 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1102 )),
1103 }));
1104 let client = client_with(Arc::clone(&stub));
1105 let id = workflow_id();
1106
1107 let result = client
1108 .query(
1109 &id,
1110 Some(&run_id()),
1111 "state",
1112 Payload::json_null(),
1113 Duration::from_secs(1),
1114 )
1115 .await;
1116
1117 assert_eq!(result, Err(ClientError::query_timeout("slow")));
1118 let recorded = stub.last_query.lock().await.clone();
1119 assert!(recorded.is_some());
1120 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1121 assert!(request.run_id.is_some());
1122 Ok(())
1123 }
1124
1125 #[tokio::test]
1126 async fn query_forwards_its_arguments_onto_the_wire() -> Result<(), ClientError> {
1127 let stub = Arc::new(StubTransport::default());
1128 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1129 outcome: Some(proto_query_response::Outcome::Result(
1130 aion_proto::ProtoPayload::from(payload("answer")),
1131 )),
1132 }));
1133 let client = client_with(Arc::clone(&stub));
1134
1135 let returned = client
1136 .query(
1137 &workflow_id(),
1138 Some(&run_id()),
1139 "state",
1140 payload("args"),
1141 Duration::from_secs(1),
1142 )
1143 .await?;
1144
1145 assert_eq!(returned, payload("answer"));
1146 let request = stub
1147 .last_query
1148 .lock()
1149 .await
1150 .clone()
1151 .ok_or_else(|| ClientError::server("missing query"))?;
1152 assert_eq!(
1155 request.arguments,
1156 Some(aion_proto::ProtoPayload::from(payload("args")))
1157 );
1158 Ok(())
1159 }
1160
1161 #[tokio::test]
1162 async fn a_no_argument_query_sends_the_canonical_null_document() -> Result<(), ClientError> {
1163 let stub = Arc::new(StubTransport::default());
1164 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1165 outcome: Some(proto_query_response::Outcome::Result(
1166 aion_proto::ProtoPayload::from(payload("answer")),
1167 )),
1168 }));
1169 let client = client_with(Arc::clone(&stub));
1170
1171 let _: serde_json::Value = client
1175 .query_typed(
1176 &workflow_id(),
1177 Some(&run_id()),
1178 "state",
1179 &(),
1180 Duration::from_secs(1),
1181 )
1182 .await?;
1183
1184 let request = stub
1185 .last_query
1186 .lock()
1187 .await
1188 .clone()
1189 .ok_or_else(|| ClientError::server("missing query"))?;
1190 assert_eq!(
1191 request.arguments,
1192 Some(aion_proto::ProtoPayload::from(Payload::json_null()))
1193 );
1194 Ok(())
1195 }
1196
1197 #[tokio::test]
1198 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1199 let stub = Arc::new(StubTransport::default());
1200 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1201 outcome: Some(proto_query_response::Outcome::Error(
1202 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1203 )),
1204 }));
1205 let client = client_with(Arc::clone(&stub));
1206
1207 let result = client
1208 .query(
1209 &workflow_id(),
1210 Some(&run_id()),
1211 "state",
1212 Payload::json_null(),
1213 Duration::from_secs(1),
1214 )
1215 .await;
1216
1217 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1218 Ok(())
1219 }
1220
1221 #[tokio::test]
1222 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1223 let stub = Arc::new(StubTransport::default());
1226 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1227 outcome: Some(proto_query_response::Outcome::Error(
1228 aion_proto::ProtoWireError::from(WireError::backend("store down")),
1229 )),
1230 }));
1231 let client = client_with(Arc::clone(&stub));
1232
1233 let result = client
1234 .query(
1235 &workflow_id(),
1236 Some(&run_id()),
1237 "state",
1238 Payload::json_null(),
1239 Duration::from_secs(1),
1240 )
1241 .await;
1242
1243 assert_eq!(result, Err(ClientError::server("store down")));
1244 Ok(())
1245 }
1246
1247 #[tokio::test]
1248 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1249 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1250 struct QueryResult {
1251 label: String,
1252 }
1253
1254 let stub = Arc::new(StubTransport::default());
1255 let client = client_with(Arc::clone(&stub));
1256 let id = workflow_id();
1257
1258 let result: QueryResult = client
1259 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1260 .await?;
1261
1262 assert_eq!(
1263 result,
1264 QueryResult {
1265 label: String::from("result")
1266 }
1267 );
1268 assert!(stub.last_query.lock().await.is_some());
1269 Ok(())
1270 }
1271
1272 #[tokio::test]
1282 async fn query_typed_forwards_non_empty_args_without_silent_drop() -> Result<(), ClientError> {
1283 let stub = Arc::new(StubTransport::default());
1284 let client = client_with(Arc::clone(&stub));
1285 let id = workflow_id();
1286
1287 let _: serde_json::Value = client
1288 .query_typed(
1289 &id,
1290 Some(&run_id()),
1291 "state",
1292 &serde_json::json!({ "filter": "open" }),
1293 Duration::from_secs(1),
1294 )
1295 .await?;
1296
1297 let request = stub
1298 .last_query
1299 .lock()
1300 .await
1301 .clone()
1302 .ok_or_else(|| ClientError::server("missing query"))?;
1303 let arguments = request
1304 .arguments
1305 .ok_or_else(|| ClientError::server("query arguments were dropped"))?;
1306 assert_eq!(
1307 serde_json::from_slice::<serde_json::Value>(&arguments.bytes)
1308 .map_err(|error| ClientError::server(error.to_string()))?,
1309 serde_json::json!({ "filter": "open" })
1310 );
1311 Ok(())
1312 }
1313
1314 #[tokio::test]
1315 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1316 let stub = Arc::new(StubTransport::default());
1317 let client = client_with(Arc::clone(&stub));
1318 let id = workflow_id();
1319 let run = run_id();
1320
1321 client.cancel(&id, Some(&run), "not needed").await?;
1322 let listed = client
1323 .list(&WorkflowFilter::default(), ListPage::default())
1324 .await?;
1325 let described = client.describe(&id, None).await?;
1326
1327 assert!(stub.last_cancel.lock().await.is_some());
1328 assert!(stub.last_list.lock().await.is_some());
1329 let describe = stub
1330 .last_describe
1331 .lock()
1332 .await
1333 .clone()
1334 .ok_or_else(|| ClientError::server("missing describe"))?;
1335 assert!(describe.run_id.is_none());
1336 assert!(describe.include_history);
1337 assert_eq!(listed.len(), 1);
1338 assert_eq!(described.history.len(), 0);
1339 Ok(())
1340 }
1341
1342 #[tokio::test]
1343 async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1344 let stub = Arc::new(StubTransport::default());
1345 let client = client_with(Arc::clone(&stub));
1346 let id = workflow_id();
1347 let run = run_id();
1348
1349 let outcome = client.reopen(&id, Some(&run)).await?;
1350
1351 assert_eq!(outcome.status, WorkflowStatus::Running);
1352 let request = stub
1353 .last_reopen
1354 .lock()
1355 .await
1356 .clone()
1357 .ok_or_else(|| ClientError::server("missing reopen"))?;
1358 assert_eq!(request.namespace, "tenant-a");
1359 assert!(request.run_id.is_some());
1360 Ok(())
1361 }
1362
1363 #[tokio::test]
1366 async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1367 let stub = Arc::new(StubTransport::default());
1368 *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1369 WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1370 )));
1371 let client = client_with(Arc::clone(&stub));
1372
1373 let result = client.reopen(&workflow_id(), None).await;
1374
1375 assert!(
1376 matches!(result, Err(ClientError::InvalidState { .. })),
1377 "got {result:?}"
1378 );
1379 Ok(())
1380 }
1381}