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, ProtoStartWorkflowRequest, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
13 decode_core_value, decode_event, decode_workflow_summary, encode_core_value,
14 proto_query_response,
15};
16use aion_store::visibility::ListWorkflowsFilter;
17
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20
21use crate::client::Client;
22use crate::error::ClientError;
23use crate::handle::WorkflowHandle;
24use crate::payload::{from_payload, to_payload};
25use crate::stream::{EventStream, SubscribeTarget, event_stream, event_stream_from};
26
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct StartOptions {
30 pub namespace: Option<String>,
32 pub idempotency_key: Option<String>,
39 pub routing_key: Option<String>,
43 pub task_queue: Option<String>,
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct ListPage {
56 pub request_id: Option<String>,
58 pub limit: Option<usize>,
60 pub cursor: Option<String>,
62}
63
64#[derive(Clone, Debug, PartialEq)]
66pub struct WorkflowDescription {
67 pub summary: WorkflowSummary,
69 pub history: Vec<Event>,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct ReopenOutcome {
76 pub run_id: RunId,
78 pub status: WorkflowStatus,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct PauseOutcome {
85 pub run_id: RunId,
87 pub status: WorkflowStatus,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct ResumeOutcome {
94 pub run_id: RunId,
96 pub status: WorkflowStatus,
98}
99
100impl Client {
101 pub async fn start(
107 &self,
108 workflow_type: impl Into<String>,
109 input: Payload,
110 opts: StartOptions,
111 ) -> Result<WorkflowHandle, ClientError> {
112 validate_start_options(&opts)?;
113 let idempotency_key = opts.idempotency_key.clone();
114 let routing_key = opts.routing_key.clone();
115 let task_queue = opts.task_queue.clone();
116 let namespace = operation_namespace(self, opts.namespace);
117 let workflow_type = workflow_type.into();
118 let fingerprint = idempotency_key.as_ref().map(|key| {
119 StartFingerprint::new(
120 namespace.clone(),
121 workflow_type.clone(),
122 &input,
123 routing_key.clone(),
124 task_queue.clone(),
125 key.clone(),
126 )
127 });
128 if let Some(fingerprint) = &fingerprint {
129 if let Some(handle) = self.cached_start(fingerprint).await? {
130 return Ok(handle);
131 }
132 }
133 let response = self
134 .transport
135 .start_workflow(ProtoStartWorkflowRequest {
136 namespace,
137 workflow_type,
138 input: Some(ProtoPayload::from(input)),
139 routing_key,
140 task_queue,
141 })
142 .await?;
143 let workflow_id = decode_required_workflow_id(response.workflow_id, "start response")?;
144 let run_id = decode_required_run_id(response.run_id, "start response")?;
145 let handle = WorkflowHandle::from_ids(self.clone(), workflow_id, run_id);
146 if let Some(fingerprint) = fingerprint {
147 self.record_start(fingerprint, handle.clone()).await?;
148 }
149 Ok(handle)
150 }
151
152 pub async fn start_typed<T>(
159 &self,
160 workflow_type: impl Into<String>,
161 input: &T,
162 opts: StartOptions,
163 ) -> Result<WorkflowHandle, ClientError>
164 where
165 T: Serialize + ?Sized,
166 {
167 self.start(workflow_type, to_payload(input)?, opts).await
168 }
169
170 pub async fn signal(
176 &self,
177 workflow_id: &WorkflowId,
178 run_id: Option<&RunId>,
179 name: impl Into<String>,
180 payload: Payload,
181 ) -> Result<(), ClientError> {
182 self.transport
183 .signal(ProtoSignalRequest {
184 namespace: self.namespace().to_owned(),
185 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
186 run_id: run_id.cloned().map(ProtoRunId::from),
187 signal_name: name.into(),
188 payload: Some(ProtoPayload::from(payload)),
189 })
190 .await?;
191 Ok(())
192 }
193
194 pub async fn signal_typed<T>(
201 &self,
202 workflow_id: &WorkflowId,
203 run_id: Option<&RunId>,
204 name: impl Into<String>,
205 value: &T,
206 ) -> Result<(), ClientError>
207 where
208 T: Serialize + ?Sized,
209 {
210 self.signal(workflow_id, run_id, name, to_payload(value)?)
211 .await
212 }
213
214 pub async fn query(
224 &self,
225 workflow_id: &WorkflowId,
226 run_id: Option<&RunId>,
227 name: impl Into<String>,
228 args: Payload,
229 deadline: Duration,
230 ) -> Result<Payload, ClientError> {
231 validate_query_args(&args)?;
232 let response = tokio::time::timeout(
233 deadline,
234 self.transport.query(ProtoQueryRequest {
235 namespace: self.namespace().to_owned(),
236 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
237 run_id: run_id.cloned().map(ProtoRunId::from),
238 query_name: name.into(),
239 }),
240 )
241 .await
242 .map_err(|_| {
243 ClientError::query_timeout(format!(
244 "query deadline of {deadline:?} elapsed before the server replied"
245 ))
246 })??;
247
248 match response.outcome {
249 Some(proto_query_response::Outcome::Result(payload)) => {
250 Payload::try_from(payload).map_err(ClientError::from_wire_error)
251 }
252 Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
253 None => Err(ClientError::server("query response outcome is missing")),
254 }
255 }
256
257 pub async fn query_typed<A, R>(
264 &self,
265 workflow_id: &WorkflowId,
266 run_id: Option<&RunId>,
267 name: impl Into<String>,
268 args: &A,
269 deadline: Duration,
270 ) -> Result<R, ClientError>
271 where
272 A: Serialize + ?Sized,
273 R: DeserializeOwned,
274 {
275 let payload = self
276 .query(
277 workflow_id,
278 run_id,
279 name,
280 query_args_payload(args)?,
281 deadline,
282 )
283 .await?;
284 from_payload(&payload)
285 }
286
287 pub async fn cancel(
296 &self,
297 workflow_id: &WorkflowId,
298 run_id: Option<&RunId>,
299 reason: impl Into<String>,
300 ) -> Result<(), ClientError> {
301 self.transport
302 .cancel(ProtoCancelRequest {
303 namespace: self.namespace().to_owned(),
304 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
305 run_id: run_id.cloned().map(ProtoRunId::from),
306 reason: reason.into(),
307 })
308 .await?;
309 Ok(())
310 }
311
312 pub async fn reopen(
324 &self,
325 workflow_id: &WorkflowId,
326 run_id: Option<&RunId>,
327 ) -> Result<ReopenOutcome, ClientError> {
328 let response = self
329 .transport
330 .reopen(ProtoReopenRequest {
331 namespace: self.namespace().to_owned(),
332 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
333 run_id: run_id.cloned().map(ProtoRunId::from),
334 })
335 .await?;
336 let run_id = response
337 .run_id
338 .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
339 .try_into()
340 .map_err(ClientError::from_wire_error)?;
341 let status = ProtoWorkflowStatus::try_from(response.status)
342 .map_err(|_error| ClientError::server("reopen response status is unknown"))
343 .and_then(|status| {
344 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
345 })?;
346 Ok(ReopenOutcome { run_id, status })
347 }
348
349 pub async fn pause(
359 &self,
360 workflow_id: &WorkflowId,
361 run_id: Option<&RunId>,
362 reason: impl Into<String>,
363 ) -> Result<PauseOutcome, ClientError> {
364 let response = self
365 .transport
366 .pause(ProtoPauseRequest {
367 namespace: self.namespace().to_owned(),
368 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
369 run_id: run_id.cloned().map(ProtoRunId::from),
370 reason: reason.into(),
371 })
372 .await?;
373 let run_id = response
374 .run_id
375 .ok_or_else(|| ClientError::server("pause response run id is missing"))?
376 .try_into()
377 .map_err(ClientError::from_wire_error)?;
378 let status = ProtoWorkflowStatus::try_from(response.status)
379 .map_err(|_error| ClientError::server("pause response status is unknown"))
380 .and_then(|status| {
381 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
382 })?;
383 Ok(PauseOutcome { run_id, status })
384 }
385
386 pub async fn resume(
396 &self,
397 workflow_id: &WorkflowId,
398 run_id: Option<&RunId>,
399 ) -> Result<ResumeOutcome, ClientError> {
400 let response = self
401 .transport
402 .resume(ProtoResumeRequest {
403 namespace: self.namespace().to_owned(),
404 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
405 run_id: run_id.cloned().map(ProtoRunId::from),
406 })
407 .await?;
408 let run_id = response
409 .run_id
410 .ok_or_else(|| ClientError::server("resume response run id is missing"))?
411 .try_into()
412 .map_err(ClientError::from_wire_error)?;
413 let status = ProtoWorkflowStatus::try_from(response.status)
414 .map_err(|_error| ClientError::server("resume response status is unknown"))
415 .and_then(|status| {
416 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
417 })?;
418 Ok(ResumeOutcome { run_id, status })
419 }
420
421 pub async fn list(
427 &self,
428 filter: &WorkflowFilter,
429 page: ListPage,
430 ) -> Result<Vec<WorkflowSummary>, ClientError> {
431 validate_list_page(&page)?;
432 let namespace = self.namespace().to_owned();
433 let filter = workflow_filter_to_visibility(filter)?;
434 let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
435 .map_err(ClientError::from_wire_error)?;
436 let response = self
437 .transport
438 .list_workflows(ProtoListWorkflowsRequest {
439 namespace,
440 filter: Some(filter),
441 })
442 .await?;
443
444 response
445 .summaries
446 .iter()
447 .map(decode_visibility_summary)
448 .map(|result| result.map_err(ClientError::from_wire_error))
449 .collect()
450 }
451
452 pub async fn describe(
458 &self,
459 workflow_id: &WorkflowId,
460 run_id: Option<&RunId>,
461 ) -> Result<WorkflowDescription, ClientError> {
462 let response = self
463 .transport
464 .describe_workflow(ProtoDescribeWorkflowRequest {
465 namespace: self.namespace().to_owned(),
466 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
467 run_id: run_id.cloned().map(ProtoRunId::from),
468 include_history: true,
469 })
470 .await?;
471 let summary = response
472 .summary
473 .as_ref()
474 .ok_or_else(|| ClientError::server("describe response summary is missing"))
475 .and_then(|summary| {
476 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
477 })?;
478 let history = response
479 .history
480 .iter()
481 .map(decode_event)
482 .map(|result| result.map_err(ClientError::from_wire_error))
483 .collect::<Result<Vec<_>, _>>()?;
484 Ok(WorkflowDescription { summary, history })
485 }
486
487 #[must_use]
489 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
490 event_stream(
491 self.transport.clone(),
492 self.namespace().to_owned(),
493 SubscribeTarget::Workflow {
494 workflow_id: workflow_id.clone(),
495 },
496 )
497 }
498
499 #[must_use]
506 pub fn subscribe_workflow_from(
507 &self,
508 workflow_id: &WorkflowId,
509 resume_from: NonZeroU64,
510 ) -> EventStream {
511 event_stream_from(
512 self.transport.clone(),
513 self.namespace().to_owned(),
514 workflow_id.clone(),
515 resume_from,
516 )
517 }
518
519 #[must_use]
521 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
522 event_stream(
523 self.transport.clone(),
524 self.namespace().to_owned(),
525 SubscribeTarget::Filtered { filter },
526 )
527 }
528
529 #[must_use]
531 pub fn subscribe_firehose(&self) -> EventStream {
532 event_stream(
533 self.transport.clone(),
534 self.namespace().to_owned(),
535 SubscribeTarget::Firehose,
536 )
537 }
538}
539
540#[derive(Clone, Debug, PartialEq, Eq)]
551pub(crate) struct StartFingerprint {
552 namespace: String,
553 workflow_type: String,
554 content_type: aion_core::ContentType,
555 bytes: Vec<u8>,
556 routing_key: Option<String>,
557 task_queue: Option<String>,
558 idempotency_key: String,
559}
560
561impl StartFingerprint {
562 fn new(
563 namespace: String,
564 workflow_type: String,
565 input: &Payload,
566 routing_key: Option<String>,
567 task_queue: Option<String>,
568 idempotency_key: String,
569 ) -> Self {
570 Self {
571 namespace,
572 workflow_type,
573 content_type: input.content_type().clone(),
574 bytes: input.bytes().to_vec(),
575 routing_key,
576 task_queue,
577 idempotency_key,
578 }
579 }
580
581 pub(crate) fn key(&self) -> &str {
582 &self.idempotency_key
583 }
584}
585
586fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
587 namespace.unwrap_or_else(|| client.namespace().to_owned())
588}
589
590fn validate_start_options(opts: &StartOptions) -> Result<(), ClientError> {
591 if opts
592 .idempotency_key
593 .as_ref()
594 .is_some_and(std::string::String::is_empty)
595 {
596 return Err(ClientError::invalid_argument(
597 "idempotency_key must not be empty",
598 ));
599 }
600 Ok(())
601}
602
603fn validate_query_args(args: &Payload) -> Result<(), ClientError> {
604 if !args.bytes().is_empty() {
605 return Err(ClientError::invalid_argument(
606 "query arguments are not carried by the current wire contract; \
607 pass an empty payload",
608 ));
609 }
610 Ok(())
611}
612
613fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
614where
615 T: Serialize + ?Sized,
616{
617 let payload = to_payload(args)?;
618 if payload.bytes() == b"null" {
619 Ok(Payload::new(payload.content_type().clone(), Vec::new()))
620 } else {
621 Ok(payload)
622 }
623}
624
625fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
626 if page.limit.is_some() || page.cursor.is_some() {
627 return Err(ClientError::invalid_argument(
628 "list pagination limit/cursor are reserved by the contract and \
629 not yet carried by the wire",
630 ));
631 }
632 Ok(())
633}
634
635fn workflow_filter_to_visibility(
636 filter: &WorkflowFilter,
637) -> Result<ListWorkflowsFilter, ClientError> {
638 if filter.parent.is_some() {
639 return Err(ClientError::invalid_argument(
640 "parent workflow filters are not carried by the visibility wire contract",
641 ));
642 }
643
644 Ok(ListWorkflowsFilter {
645 workflow_type: filter.workflow_type.clone(),
646 status: filter.status,
647 started_after: filter.started_after,
648 started_before: filter.started_before,
649 ..ListWorkflowsFilter::default()
650 })
651}
652
653fn decode_visibility_summary(
654 envelope: &aion_proto::WireEnvelope,
655) -> Result<WorkflowSummary, WireError> {
656 let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
657 Ok(WorkflowSummary {
658 workflow_id: summary.workflow_id,
659 workflow_type: summary.workflow_type,
660 status: summary.status,
661 started_at: summary.start_time,
662 ended_at: summary.close_time,
663 parent: None,
664 failed_step: summary.failed_step,
665 failure_reason: summary.failure_reason,
666 })
667}
668
669fn decode_required_workflow_id(
670 value: Option<ProtoWorkflowId>,
671 context: &str,
672) -> Result<WorkflowId, ClientError> {
673 value
674 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
675 .try_into()
676 .map_err(ClientError::from_wire_error)
677}
678
679fn decode_required_run_id(value: Option<ProtoRunId>, context: &str) -> Result<RunId, ClientError> {
680 value
681 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
682 .try_into()
683 .map_err(ClientError::from_wire_error)
684}
685
686fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
692 ClientError::from_proto_wire_error(error)
693}
694
695#[cfg(test)]
696mod tests {
697 use std::sync::Arc;
698 use std::time::Duration;
699
700 use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
701 use aion_proto::{
702 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
703 ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
704 ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
705 encode_core_value, encode_workflow_summary, proto_query_response,
706 };
707 use async_trait::async_trait;
708 use chrono::Utc;
709 use futures::StreamExt;
710 use futures::stream;
711 use tokio::sync::Mutex;
712
713 use super::{ListPage, StartOptions};
714 use crate::client::{Client, ClientBuilder, ClientConfig};
715 use crate::error::ClientError;
716 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
717
718 #[derive(Default)]
719 struct StubTransport {
720 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
721 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
722 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
723 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
724 last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
725 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
726 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
727 start_error: Mutex<Option<ClientError>>,
728 signal_error: Mutex<Option<ClientError>>,
729 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
730 reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
731 }
732
733 #[async_trait]
734 impl WorkflowTransport for StubTransport {
735 async fn start_workflow(
736 &self,
737 request: aion_proto::ProtoStartWorkflowRequest,
738 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
739 *self.last_start.lock().await = Some(request);
740 if let Some(error) = self.start_error.lock().await.take() {
741 return Err(error);
742 }
743 Ok(ProtoStartWorkflowResponse {
744 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
745 run_id: Some(ProtoRunId::from(run_id())),
746 })
747 }
748
749 async fn signal(
750 &self,
751 request: aion_proto::ProtoSignalRequest,
752 ) -> Result<ProtoSignalResponse, ClientError> {
753 *self.last_signal.lock().await = Some(request);
754 if let Some(error) = self.signal_error.lock().await.take() {
755 return Err(error);
756 }
757 Ok(ProtoSignalResponse {})
758 }
759
760 async fn query(
761 &self,
762 request: aion_proto::ProtoQueryRequest,
763 ) -> Result<ProtoQueryResponse, ClientError> {
764 *self.last_query.lock().await = Some(request);
765 if let Some(response) = self.query_response.lock().await.take() {
766 return response;
767 }
768 Ok(ProtoQueryResponse {
769 outcome: Some(proto_query_response::Outcome::Result(
770 aion_proto::ProtoPayload::from(payload("result")),
771 )),
772 })
773 }
774
775 async fn cancel(
776 &self,
777 request: aion_proto::ProtoCancelRequest,
778 ) -> Result<ProtoCancelResponse, ClientError> {
779 *self.last_cancel.lock().await = Some(request);
780 Ok(ProtoCancelResponse {})
781 }
782
783 async fn reopen(
784 &self,
785 request: aion_proto::ProtoReopenRequest,
786 ) -> Result<ProtoReopenResponse, ClientError> {
787 *self.last_reopen.lock().await = Some(request);
788 if let Some(response) = self.reopen_response.lock().await.take() {
789 return response;
790 }
791 Ok(ProtoReopenResponse {
792 run_id: Some(ProtoRunId::from(run_id())),
793 status: ProtoWorkflowStatus::Running as i32,
794 })
795 }
796
797 async fn pause(
798 &self,
799 _request: aion_proto::ProtoPauseRequest,
800 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
801 Ok(aion_proto::ProtoPauseResponse {
802 run_id: Some(ProtoRunId::from(run_id())),
803 status: ProtoWorkflowStatus::Paused as i32,
804 })
805 }
806
807 async fn resume(
808 &self,
809 _request: aion_proto::ProtoResumeRequest,
810 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
811 Ok(aion_proto::ProtoResumeResponse {
812 run_id: Some(ProtoRunId::from(run_id())),
813 status: ProtoWorkflowStatus::Running as i32,
814 })
815 }
816
817 async fn list_workflows(
818 &self,
819 request: aion_proto::ProtoListWorkflowsRequest,
820 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
821 *self.last_list.lock().await = Some(request);
822 Ok(ProtoListWorkflowsResponse {
823 summaries: vec![
824 encode_core_value("tenant-a", None, &visibility_summary())
825 .map_err(ClientError::from_wire_error)?,
826 ],
827 })
828 }
829
830 async fn describe_workflow(
831 &self,
832 request: aion_proto::ProtoDescribeWorkflowRequest,
833 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
834 *self.last_describe.lock().await = Some(request);
835 Ok(ProtoDescribeWorkflowResponse {
836 summary: Some(
837 encode_workflow_summary("tenant-a", None, &summary())
838 .map_err(ClientError::from_wire_error)?,
839 ),
840 history: Vec::new(),
841 })
842 }
843
844 async fn subscribe(
845 &self,
846 _: aion_proto::SubscriptionRequest,
847 _: Option<u64>,
848 ) -> Result<SubscriptionAttempt, ClientError> {
849 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
850 }
851 }
852
853 fn client_with(stub: Arc<StubTransport>) -> Client {
854 Client::from_transport(
855 ClientConfig::from(
856 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
857 ),
858 stub,
859 )
860 }
861
862 fn workflow_id() -> WorkflowId {
863 WorkflowId::new_v4()
864 }
865
866 fn run_id() -> aion_core::RunId {
867 aion_core::RunId::new_v4()
868 }
869
870 fn payload(label: &str) -> Payload {
871 Payload::new(
872 ContentType::Json,
873 format!("{{\"label\":\"{label}\"}}").into_bytes(),
874 )
875 }
876
877 fn empty_payload() -> Payload {
878 Payload::new(ContentType::Json, Vec::new())
879 }
880
881 fn summary() -> aion_core::WorkflowSummary {
882 aion_core::WorkflowSummary {
883 workflow_id: workflow_id(),
884 workflow_type: String::from("checkout"),
885 status: WorkflowStatus::Running,
886 started_at: Utc::now(),
887 ended_at: None,
888 parent: None,
889 failed_step: None,
890 failure_reason: None,
891 }
892 }
893
894 fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
895 aion_store::visibility::WorkflowSummary {
896 workflow_id: workflow_id(),
897 run_id: run_id(),
898 workflow_type: String::from("checkout"),
899 status: WorkflowStatus::Running,
900 start_time: Utc::now(),
901 close_time: None,
902 failed_step: None,
903 failure_reason: None,
904 search_attributes: std::collections::HashMap::new(),
905 }
906 }
907
908 #[tokio::test]
909 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
910 let stub = Arc::new(StubTransport::default());
911 let client = client_with(Arc::clone(&stub));
912
913 let result = client
914 .start("checkout", payload("input"), StartOptions::default())
915 .await?;
916
917 let recorded = stub.last_start.lock().await.clone();
918 assert!(recorded.is_some());
919 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
920 assert_eq!(request.namespace, "tenant-a");
921 assert_eq!(request.workflow_type, "checkout");
922 assert!(request.input.is_some());
923 assert_ne!(result.workflow_id(), &WorkflowId::new(uuid::Uuid::nil()));
924 Ok(())
925 }
926
927 #[tokio::test]
928 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
929 {
930 let stub = Arc::new(StubTransport::default());
931 let client = client_with(Arc::clone(&stub));
932 let opts = StartOptions {
933 namespace: None,
934 idempotency_key: Some(String::from("retry-key")),
935 routing_key: None,
936 task_queue: None,
937 };
938
939 let original = client
940 .start("checkout", payload("input"), opts.clone())
941 .await?;
942 let replayed = client
943 .start("checkout", payload("input"), opts.clone())
944 .await?;
945 let conflict = client.start("checkout", payload("other"), opts).await;
946
947 assert_eq!(replayed, original);
948 assert!(
949 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
950 "got {conflict:?}"
951 );
952 Ok(())
953 }
954
955 #[tokio::test]
956 async fn start_idempotency_treats_a_changed_route_as_a_different_request()
957 -> Result<(), ClientError> {
958 for (label, second) in [
959 (
960 "task_queue",
961 StartOptions {
962 task_queue: Some(String::from("payments")),
963 ..StartOptions::default()
964 },
965 ),
966 (
967 "routing_key",
968 StartOptions {
969 routing_key: Some(String::from("tenant-7")),
970 ..StartOptions::default()
971 },
972 ),
973 ] {
974 let stub = Arc::new(StubTransport::default());
975 let client = client_with(Arc::clone(&stub));
976 let key = Some(String::from("retry-key"));
977 let first = StartOptions {
978 idempotency_key: key.clone(),
979 ..StartOptions::default()
980 };
981 let second = StartOptions {
982 idempotency_key: key,
983 ..second
984 };
985
986 client.start("checkout", payload("input"), first).await?;
987 let conflict = client.start("checkout", payload("input"), second).await;
988
989 assert!(
990 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
991 "reusing a key with a different {label} must conflict, got {conflict:?}"
992 );
993 }
994 Ok(())
995 }
996
997 #[tokio::test]
998 async fn signal_maps_latest_run_and_error() {
999 let stub = Arc::new(StubTransport::default());
1000 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
1001 let client = client_with(Arc::clone(&stub));
1002 let id = workflow_id();
1003
1004 let result = client.signal(&id, None, "approve", payload("signal")).await;
1005
1006 assert_eq!(
1007 result,
1008 Err(ClientError::not_found("workflow was not found"))
1009 );
1010 let recorded = stub.last_signal.lock().await.clone();
1011 assert!(recorded.is_some());
1012 let Some(request) = recorded else {
1013 return;
1014 };
1015 assert!(request.run_id.is_none());
1016 }
1017
1018 #[tokio::test]
1019 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1020 let stub = Arc::new(StubTransport::default());
1021 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1022 outcome: Some(proto_query_response::Outcome::Error(
1023 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1024 )),
1025 }));
1026 let client = client_with(Arc::clone(&stub));
1027 let id = workflow_id();
1028
1029 let result = client
1030 .query(
1031 &id,
1032 Some(&run_id()),
1033 "state",
1034 empty_payload(),
1035 Duration::from_secs(1),
1036 )
1037 .await;
1038 let unsupported_args = client
1039 .query(&id, None, "state", payload("args"), Duration::from_secs(1))
1040 .await;
1041
1042 assert_eq!(result, Err(ClientError::query_timeout("slow")));
1043 assert!(
1044 matches!(unsupported_args, Err(ClientError::InvalidArgument { .. })),
1045 "got {unsupported_args:?}"
1046 );
1047 let recorded = stub.last_query.lock().await.clone();
1048 assert!(recorded.is_some());
1049 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1050 assert!(request.run_id.is_some());
1051 Ok(())
1052 }
1053
1054 #[tokio::test]
1055 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1056 let stub = Arc::new(StubTransport::default());
1057 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1058 outcome: Some(proto_query_response::Outcome::Error(
1059 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1060 )),
1061 }));
1062 let client = client_with(Arc::clone(&stub));
1063
1064 let result = client
1065 .query(
1066 &workflow_id(),
1067 Some(&run_id()),
1068 "state",
1069 empty_payload(),
1070 Duration::from_secs(1),
1071 )
1072 .await;
1073
1074 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1075 Ok(())
1076 }
1077
1078 #[tokio::test]
1079 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1080 let stub = Arc::new(StubTransport::default());
1083 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1084 outcome: Some(proto_query_response::Outcome::Error(
1085 aion_proto::ProtoWireError::from(WireError::backend("store down")),
1086 )),
1087 }));
1088 let client = client_with(Arc::clone(&stub));
1089
1090 let result = client
1091 .query(
1092 &workflow_id(),
1093 Some(&run_id()),
1094 "state",
1095 empty_payload(),
1096 Duration::from_secs(1),
1097 )
1098 .await;
1099
1100 assert_eq!(result, Err(ClientError::server("store down")));
1101 Ok(())
1102 }
1103
1104 #[tokio::test]
1105 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1106 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1107 struct QueryResult {
1108 label: String,
1109 }
1110
1111 let stub = Arc::new(StubTransport::default());
1112 let client = client_with(Arc::clone(&stub));
1113 let id = workflow_id();
1114
1115 let result: QueryResult = client
1116 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1117 .await?;
1118
1119 assert_eq!(
1120 result,
1121 QueryResult {
1122 label: String::from("result")
1123 }
1124 );
1125 assert!(stub.last_query.lock().await.is_some());
1126 Ok(())
1127 }
1128
1129 #[tokio::test]
1130 async fn query_typed_rejects_non_empty_args_without_silent_drop() {
1131 let stub = Arc::new(StubTransport::default());
1132 let client = client_with(Arc::clone(&stub));
1133 let id = workflow_id();
1134
1135 let result = client
1136 .query_typed::<_, serde_json::Value>(
1137 &id,
1138 Some(&run_id()),
1139 "state",
1140 &serde_json::json!({ "filter": "open" }),
1141 Duration::from_secs(1),
1142 )
1143 .await;
1144
1145 assert!(
1146 matches!(result, Err(ClientError::InvalidArgument { .. })),
1147 "got {result:?}"
1148 );
1149 assert!(stub.last_query.lock().await.is_none());
1150 }
1151
1152 #[tokio::test]
1153 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1154 let stub = Arc::new(StubTransport::default());
1155 let client = client_with(Arc::clone(&stub));
1156 let id = workflow_id();
1157 let run = run_id();
1158
1159 client.cancel(&id, Some(&run), "not needed").await?;
1160 let listed = client
1161 .list(&WorkflowFilter::default(), ListPage::default())
1162 .await?;
1163 let described = client.describe(&id, None).await?;
1164
1165 assert!(stub.last_cancel.lock().await.is_some());
1166 assert!(stub.last_list.lock().await.is_some());
1167 let describe = stub
1168 .last_describe
1169 .lock()
1170 .await
1171 .clone()
1172 .ok_or_else(|| ClientError::server("missing describe"))?;
1173 assert!(describe.run_id.is_none());
1174 assert!(describe.include_history);
1175 assert_eq!(listed.len(), 1);
1176 assert_eq!(described.history.len(), 0);
1177 Ok(())
1178 }
1179
1180 #[tokio::test]
1181 async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1182 let stub = Arc::new(StubTransport::default());
1183 let client = client_with(Arc::clone(&stub));
1184 let id = workflow_id();
1185 let run = run_id();
1186
1187 let outcome = client.reopen(&id, Some(&run)).await?;
1188
1189 assert_eq!(outcome.status, WorkflowStatus::Running);
1190 let request = stub
1191 .last_reopen
1192 .lock()
1193 .await
1194 .clone()
1195 .ok_or_else(|| ClientError::server("missing reopen"))?;
1196 assert_eq!(request.namespace, "tenant-a");
1197 assert!(request.run_id.is_some());
1198 Ok(())
1199 }
1200
1201 #[tokio::test]
1204 async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1205 let stub = Arc::new(StubTransport::default());
1206 *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1207 WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1208 )));
1209 let client = client_with(Arc::clone(&stub));
1210
1211 let result = client.reopen(&workflow_id(), None).await;
1212
1213 assert!(
1214 matches!(result, Err(ClientError::InvalidState { .. })),
1215 "got {result:?}"
1216 );
1217 Ok(())
1218 }
1219}