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(
226 &self,
227 workflow_id: &WorkflowId,
228 run_id: Option<&RunId>,
229 name: impl Into<String>,
230 args: Payload,
231 deadline: Duration,
232 ) -> Result<Payload, ClientError> {
233 let response = tokio::time::timeout(
234 deadline,
235 self.transport.query(ProtoQueryRequest {
236 namespace: self.namespace().to_owned(),
237 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
238 run_id: run_id.cloned().map(ProtoRunId::from),
239 query_name: name.into(),
240 arguments: Some(aion_proto::ProtoPayload::from(args)),
241 }),
242 )
243 .await
244 .map_err(|_| {
245 ClientError::query_timeout(format!(
246 "query deadline of {deadline:?} elapsed before the server replied"
247 ))
248 })??;
249
250 match response.outcome {
251 Some(proto_query_response::Outcome::Result(payload)) => {
252 Payload::try_from(payload).map_err(ClientError::from_wire_error)
253 }
254 Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
255 None => Err(ClientError::server("query response outcome is missing")),
256 }
257 }
258
259 pub async fn query_typed<A, R>(
266 &self,
267 workflow_id: &WorkflowId,
268 run_id: Option<&RunId>,
269 name: impl Into<String>,
270 args: &A,
271 deadline: Duration,
272 ) -> Result<R, ClientError>
273 where
274 A: Serialize + ?Sized,
275 R: DeserializeOwned,
276 {
277 let payload = self
278 .query(
279 workflow_id,
280 run_id,
281 name,
282 query_args_payload(args)?,
283 deadline,
284 )
285 .await?;
286 from_payload(&payload)
287 }
288
289 pub async fn cancel(
298 &self,
299 workflow_id: &WorkflowId,
300 run_id: Option<&RunId>,
301 reason: impl Into<String>,
302 ) -> Result<(), ClientError> {
303 self.transport
304 .cancel(ProtoCancelRequest {
305 namespace: self.namespace().to_owned(),
306 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
307 run_id: run_id.cloned().map(ProtoRunId::from),
308 reason: reason.into(),
309 })
310 .await?;
311 Ok(())
312 }
313
314 pub async fn reopen(
326 &self,
327 workflow_id: &WorkflowId,
328 run_id: Option<&RunId>,
329 ) -> Result<ReopenOutcome, ClientError> {
330 let response = self
331 .transport
332 .reopen(ProtoReopenRequest {
333 namespace: self.namespace().to_owned(),
334 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
335 run_id: run_id.cloned().map(ProtoRunId::from),
336 })
337 .await?;
338 let run_id = response
339 .run_id
340 .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
341 .try_into()
342 .map_err(ClientError::from_wire_error)?;
343 let status = ProtoWorkflowStatus::try_from(response.status)
344 .map_err(|_error| ClientError::server("reopen response status is unknown"))
345 .and_then(|status| {
346 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
347 })?;
348 Ok(ReopenOutcome { run_id, status })
349 }
350
351 pub async fn pause(
361 &self,
362 workflow_id: &WorkflowId,
363 run_id: Option<&RunId>,
364 reason: impl Into<String>,
365 ) -> Result<PauseOutcome, ClientError> {
366 let response = self
367 .transport
368 .pause(ProtoPauseRequest {
369 namespace: self.namespace().to_owned(),
370 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
371 run_id: run_id.cloned().map(ProtoRunId::from),
372 reason: reason.into(),
373 })
374 .await?;
375 let run_id = response
376 .run_id
377 .ok_or_else(|| ClientError::server("pause response run id is missing"))?
378 .try_into()
379 .map_err(ClientError::from_wire_error)?;
380 let status = ProtoWorkflowStatus::try_from(response.status)
381 .map_err(|_error| ClientError::server("pause response status is unknown"))
382 .and_then(|status| {
383 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
384 })?;
385 Ok(PauseOutcome { run_id, status })
386 }
387
388 pub async fn resume(
398 &self,
399 workflow_id: &WorkflowId,
400 run_id: Option<&RunId>,
401 ) -> Result<ResumeOutcome, ClientError> {
402 let response = self
403 .transport
404 .resume(ProtoResumeRequest {
405 namespace: self.namespace().to_owned(),
406 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
407 run_id: run_id.cloned().map(ProtoRunId::from),
408 })
409 .await?;
410 let run_id = response
411 .run_id
412 .ok_or_else(|| ClientError::server("resume response run id is missing"))?
413 .try_into()
414 .map_err(ClientError::from_wire_error)?;
415 let status = ProtoWorkflowStatus::try_from(response.status)
416 .map_err(|_error| ClientError::server("resume response status is unknown"))
417 .and_then(|status| {
418 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
419 })?;
420 Ok(ResumeOutcome { run_id, status })
421 }
422
423 pub async fn list(
429 &self,
430 filter: &WorkflowFilter,
431 page: ListPage,
432 ) -> Result<Vec<WorkflowSummary>, ClientError> {
433 validate_list_page(&page)?;
434 let namespace = self.namespace().to_owned();
435 let filter = workflow_filter_to_visibility(filter)?;
436 let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
437 .map_err(ClientError::from_wire_error)?;
438 let response = self
439 .transport
440 .list_workflows(ProtoListWorkflowsRequest {
441 namespace,
442 filter: Some(filter),
443 })
444 .await?;
445
446 response
447 .summaries
448 .iter()
449 .map(decode_visibility_summary)
450 .map(|result| result.map_err(ClientError::from_wire_error))
451 .collect()
452 }
453
454 pub async fn describe(
460 &self,
461 workflow_id: &WorkflowId,
462 run_id: Option<&RunId>,
463 ) -> Result<WorkflowDescription, ClientError> {
464 let response = self
465 .transport
466 .describe_workflow(ProtoDescribeWorkflowRequest {
467 namespace: self.namespace().to_owned(),
468 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
469 run_id: run_id.cloned().map(ProtoRunId::from),
470 include_history: true,
471 })
472 .await?;
473 let summary = response
474 .summary
475 .as_ref()
476 .ok_or_else(|| ClientError::server("describe response summary is missing"))
477 .and_then(|summary| {
478 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
479 })?;
480 let history = response
481 .history
482 .iter()
483 .map(decode_event)
484 .map(|result| result.map_err(ClientError::from_wire_error))
485 .collect::<Result<Vec<_>, _>>()?;
486 Ok(WorkflowDescription { summary, history })
487 }
488
489 #[must_use]
491 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
492 event_stream(
493 self.transport.clone(),
494 self.namespace().to_owned(),
495 SubscribeTarget::Workflow {
496 workflow_id: workflow_id.clone(),
497 },
498 )
499 }
500
501 #[must_use]
508 pub fn subscribe_workflow_from(
509 &self,
510 workflow_id: &WorkflowId,
511 resume_from: NonZeroU64,
512 ) -> EventStream {
513 event_stream_from(
514 self.transport.clone(),
515 self.namespace().to_owned(),
516 workflow_id.clone(),
517 resume_from,
518 )
519 }
520
521 #[must_use]
523 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
524 event_stream(
525 self.transport.clone(),
526 self.namespace().to_owned(),
527 SubscribeTarget::Filtered { filter },
528 )
529 }
530
531 #[must_use]
533 pub fn subscribe_firehose(&self) -> EventStream {
534 event_stream(
535 self.transport.clone(),
536 self.namespace().to_owned(),
537 SubscribeTarget::Firehose,
538 )
539 }
540}
541
542#[derive(Clone, Debug, PartialEq, Eq)]
553pub(crate) struct StartFingerprint {
554 namespace: String,
555 workflow_type: String,
556 content_type: aion_core::ContentType,
557 bytes: Vec<u8>,
558 routing_key: Option<String>,
559 task_queue: Option<String>,
560 idempotency_key: String,
561}
562
563impl StartFingerprint {
564 fn new(
565 namespace: String,
566 workflow_type: String,
567 input: &Payload,
568 routing_key: Option<String>,
569 task_queue: Option<String>,
570 idempotency_key: String,
571 ) -> Self {
572 Self {
573 namespace,
574 workflow_type,
575 content_type: input.content_type().clone(),
576 bytes: input.bytes().to_vec(),
577 routing_key,
578 task_queue,
579 idempotency_key,
580 }
581 }
582
583 pub(crate) fn key(&self) -> &str {
584 &self.idempotency_key
585 }
586}
587
588fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
589 namespace.unwrap_or_else(|| client.namespace().to_owned())
590}
591
592fn validate_start_options(opts: &StartOptions) -> Result<(), ClientError> {
593 if opts
594 .idempotency_key
595 .as_ref()
596 .is_some_and(std::string::String::is_empty)
597 {
598 return Err(ClientError::invalid_argument(
599 "idempotency_key must not be empty",
600 ));
601 }
602 Ok(())
603}
604
605fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
611where
612 T: Serialize + ?Sized,
613{
614 to_payload(args)
615}
616
617fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
618 if page.limit.is_some() || page.cursor.is_some() {
619 return Err(ClientError::invalid_argument(
620 "list pagination limit/cursor are reserved by the contract and \
621 not yet carried by the wire",
622 ));
623 }
624 Ok(())
625}
626
627fn workflow_filter_to_visibility(
628 filter: &WorkflowFilter,
629) -> Result<ListWorkflowsFilter, ClientError> {
630 if filter.parent.is_some() {
631 return Err(ClientError::invalid_argument(
632 "parent workflow filters are not carried by the visibility wire contract",
633 ));
634 }
635
636 Ok(ListWorkflowsFilter {
637 workflow_type: filter.workflow_type.clone(),
638 status: filter.status,
639 started_after: filter.started_after,
640 started_before: filter.started_before,
641 ..ListWorkflowsFilter::default()
642 })
643}
644
645fn decode_visibility_summary(
646 envelope: &aion_proto::WireEnvelope,
647) -> Result<WorkflowSummary, WireError> {
648 let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
649 Ok(WorkflowSummary {
650 workflow_id: summary.workflow_id,
651 workflow_type: summary.workflow_type,
652 status: summary.status,
653 started_at: summary.start_time,
654 ended_at: summary.close_time,
655 parent: None,
656 failed_step: summary.failed_step,
657 failure_reason: summary.failure_reason,
658 })
659}
660
661fn decode_required_workflow_id(
662 value: Option<ProtoWorkflowId>,
663 context: &str,
664) -> Result<WorkflowId, ClientError> {
665 value
666 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
667 .try_into()
668 .map_err(ClientError::from_wire_error)
669}
670
671fn decode_required_run_id(value: Option<ProtoRunId>, context: &str) -> Result<RunId, ClientError> {
672 value
673 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
674 .try_into()
675 .map_err(ClientError::from_wire_error)
676}
677
678fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
684 ClientError::from_proto_wire_error(error)
685}
686
687#[cfg(test)]
688mod tests {
689 use std::sync::Arc;
690 use std::time::Duration;
691
692 use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
693 use aion_proto::{
694 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
695 ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
696 ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
697 encode_core_value, encode_workflow_summary, proto_query_response,
698 };
699 use async_trait::async_trait;
700 use chrono::Utc;
701 use futures::StreamExt;
702 use futures::stream;
703 use tokio::sync::Mutex;
704
705 use super::{ListPage, StartOptions};
706 use crate::client::{Client, ClientBuilder, ClientConfig};
707 use crate::error::ClientError;
708 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
709
710 #[derive(Default)]
711 struct StubTransport {
712 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
713 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
714 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
715 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
716 last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
717 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
718 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
719 start_error: Mutex<Option<ClientError>>,
720 signal_error: Mutex<Option<ClientError>>,
721 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
722 reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
723 }
724
725 #[async_trait]
726 impl WorkflowTransport for StubTransport {
727 async fn start_workflow(
728 &self,
729 request: aion_proto::ProtoStartWorkflowRequest,
730 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
731 *self.last_start.lock().await = Some(request);
732 if let Some(error) = self.start_error.lock().await.take() {
733 return Err(error);
734 }
735 Ok(ProtoStartWorkflowResponse {
736 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
737 run_id: Some(ProtoRunId::from(run_id())),
738 })
739 }
740
741 async fn signal(
742 &self,
743 request: aion_proto::ProtoSignalRequest,
744 ) -> Result<ProtoSignalResponse, ClientError> {
745 *self.last_signal.lock().await = Some(request);
746 if let Some(error) = self.signal_error.lock().await.take() {
747 return Err(error);
748 }
749 Ok(ProtoSignalResponse {})
750 }
751
752 async fn query(
753 &self,
754 request: aion_proto::ProtoQueryRequest,
755 ) -> Result<ProtoQueryResponse, ClientError> {
756 *self.last_query.lock().await = Some(request);
757 if let Some(response) = self.query_response.lock().await.take() {
758 return response;
759 }
760 Ok(ProtoQueryResponse {
761 outcome: Some(proto_query_response::Outcome::Result(
762 aion_proto::ProtoPayload::from(payload("result")),
763 )),
764 })
765 }
766
767 async fn cancel(
768 &self,
769 request: aion_proto::ProtoCancelRequest,
770 ) -> Result<ProtoCancelResponse, ClientError> {
771 *self.last_cancel.lock().await = Some(request);
772 Ok(ProtoCancelResponse {})
773 }
774
775 async fn reopen(
776 &self,
777 request: aion_proto::ProtoReopenRequest,
778 ) -> Result<ProtoReopenResponse, ClientError> {
779 *self.last_reopen.lock().await = Some(request);
780 if let Some(response) = self.reopen_response.lock().await.take() {
781 return response;
782 }
783 Ok(ProtoReopenResponse {
784 run_id: Some(ProtoRunId::from(run_id())),
785 status: ProtoWorkflowStatus::Running as i32,
786 })
787 }
788
789 async fn pause(
790 &self,
791 _request: aion_proto::ProtoPauseRequest,
792 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
793 Ok(aion_proto::ProtoPauseResponse {
794 run_id: Some(ProtoRunId::from(run_id())),
795 status: ProtoWorkflowStatus::Paused as i32,
796 })
797 }
798
799 async fn resume(
800 &self,
801 _request: aion_proto::ProtoResumeRequest,
802 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
803 Ok(aion_proto::ProtoResumeResponse {
804 run_id: Some(ProtoRunId::from(run_id())),
805 status: ProtoWorkflowStatus::Running as i32,
806 })
807 }
808
809 async fn list_workflows(
810 &self,
811 request: aion_proto::ProtoListWorkflowsRequest,
812 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
813 *self.last_list.lock().await = Some(request);
814 Ok(ProtoListWorkflowsResponse {
815 summaries: vec![
816 encode_core_value("tenant-a", None, &visibility_summary())
817 .map_err(ClientError::from_wire_error)?,
818 ],
819 })
820 }
821
822 async fn describe_workflow(
823 &self,
824 request: aion_proto::ProtoDescribeWorkflowRequest,
825 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
826 *self.last_describe.lock().await = Some(request);
827 Ok(ProtoDescribeWorkflowResponse {
828 summary: Some(
829 encode_workflow_summary("tenant-a", None, &summary())
830 .map_err(ClientError::from_wire_error)?,
831 ),
832 history: Vec::new(),
833 })
834 }
835
836 async fn subscribe(
837 &self,
838 _: aion_proto::SubscriptionRequest,
839 _: Option<u64>,
840 ) -> Result<SubscriptionAttempt, ClientError> {
841 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
842 }
843 }
844
845 fn client_with(stub: Arc<StubTransport>) -> Client {
846 Client::from_transport(
847 ClientConfig::from(
848 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
849 ),
850 stub,
851 )
852 }
853
854 fn workflow_id() -> WorkflowId {
855 WorkflowId::new_v4()
856 }
857
858 fn run_id() -> aion_core::RunId {
859 aion_core::RunId::new_v4()
860 }
861
862 fn payload(label: &str) -> Payload {
863 Payload::new(
864 ContentType::Json,
865 format!("{{\"label\":\"{label}\"}}").into_bytes(),
866 )
867 }
868
869 fn summary() -> aion_core::WorkflowSummary {
870 aion_core::WorkflowSummary {
871 workflow_id: workflow_id(),
872 workflow_type: String::from("checkout"),
873 status: WorkflowStatus::Running,
874 started_at: Utc::now(),
875 ended_at: None,
876 parent: None,
877 failed_step: None,
878 failure_reason: None,
879 }
880 }
881
882 fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
883 aion_store::visibility::WorkflowSummary {
884 workflow_id: workflow_id(),
885 run_id: run_id(),
886 workflow_type: String::from("checkout"),
887 status: WorkflowStatus::Running,
888 start_time: Utc::now(),
889 close_time: None,
890 failed_step: None,
891 failure_reason: None,
892 search_attributes: std::collections::HashMap::new(),
893 }
894 }
895
896 #[tokio::test]
897 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
898 let stub = Arc::new(StubTransport::default());
899 let client = client_with(Arc::clone(&stub));
900
901 let result = client
902 .start("checkout", payload("input"), StartOptions::default())
903 .await?;
904
905 let recorded = stub.last_start.lock().await.clone();
906 assert!(recorded.is_some());
907 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
908 assert_eq!(request.namespace, "tenant-a");
909 assert_eq!(request.workflow_type, "checkout");
910 assert!(request.input.is_some());
911 assert_ne!(result.workflow_id(), &WorkflowId::new(uuid::Uuid::nil()));
912 Ok(())
913 }
914
915 #[tokio::test]
916 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
917 {
918 let stub = Arc::new(StubTransport::default());
919 let client = client_with(Arc::clone(&stub));
920 let opts = StartOptions {
921 namespace: None,
922 idempotency_key: Some(String::from("retry-key")),
923 routing_key: None,
924 task_queue: None,
925 };
926
927 let original = client
928 .start("checkout", payload("input"), opts.clone())
929 .await?;
930 let replayed = client
931 .start("checkout", payload("input"), opts.clone())
932 .await?;
933 let conflict = client.start("checkout", payload("other"), opts).await;
934
935 assert_eq!(replayed, original);
936 assert!(
937 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
938 "got {conflict:?}"
939 );
940 Ok(())
941 }
942
943 #[tokio::test]
944 async fn start_idempotency_treats_a_changed_route_as_a_different_request()
945 -> Result<(), ClientError> {
946 for (label, second) in [
947 (
948 "task_queue",
949 StartOptions {
950 task_queue: Some(String::from("payments")),
951 ..StartOptions::default()
952 },
953 ),
954 (
955 "routing_key",
956 StartOptions {
957 routing_key: Some(String::from("tenant-7")),
958 ..StartOptions::default()
959 },
960 ),
961 ] {
962 let stub = Arc::new(StubTransport::default());
963 let client = client_with(Arc::clone(&stub));
964 let key = Some(String::from("retry-key"));
965 let first = StartOptions {
966 idempotency_key: key.clone(),
967 ..StartOptions::default()
968 };
969 let second = StartOptions {
970 idempotency_key: key,
971 ..second
972 };
973
974 client.start("checkout", payload("input"), first).await?;
975 let conflict = client.start("checkout", payload("input"), second).await;
976
977 assert!(
978 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
979 "reusing a key with a different {label} must conflict, got {conflict:?}"
980 );
981 }
982 Ok(())
983 }
984
985 #[tokio::test]
986 async fn signal_maps_latest_run_and_error() {
987 let stub = Arc::new(StubTransport::default());
988 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
989 let client = client_with(Arc::clone(&stub));
990 let id = workflow_id();
991
992 let result = client.signal(&id, None, "approve", payload("signal")).await;
993
994 assert_eq!(
995 result,
996 Err(ClientError::not_found("workflow was not found"))
997 );
998 let recorded = stub.last_signal.lock().await.clone();
999 assert!(recorded.is_some());
1000 let Some(request) = recorded else {
1001 return;
1002 };
1003 assert!(request.run_id.is_none());
1004 }
1005
1006 #[tokio::test]
1007 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1008 let stub = Arc::new(StubTransport::default());
1009 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1010 outcome: Some(proto_query_response::Outcome::Error(
1011 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1012 )),
1013 }));
1014 let client = client_with(Arc::clone(&stub));
1015 let id = workflow_id();
1016
1017 let result = client
1018 .query(
1019 &id,
1020 Some(&run_id()),
1021 "state",
1022 Payload::json_null(),
1023 Duration::from_secs(1),
1024 )
1025 .await;
1026
1027 assert_eq!(result, Err(ClientError::query_timeout("slow")));
1028 let recorded = stub.last_query.lock().await.clone();
1029 assert!(recorded.is_some());
1030 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1031 assert!(request.run_id.is_some());
1032 Ok(())
1033 }
1034
1035 #[tokio::test]
1036 async fn query_forwards_its_arguments_onto_the_wire() -> Result<(), ClientError> {
1037 let stub = Arc::new(StubTransport::default());
1038 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1039 outcome: Some(proto_query_response::Outcome::Result(
1040 aion_proto::ProtoPayload::from(payload("answer")),
1041 )),
1042 }));
1043 let client = client_with(Arc::clone(&stub));
1044
1045 let returned = client
1046 .query(
1047 &workflow_id(),
1048 Some(&run_id()),
1049 "state",
1050 payload("args"),
1051 Duration::from_secs(1),
1052 )
1053 .await?;
1054
1055 assert_eq!(returned, payload("answer"));
1056 let request = stub
1057 .last_query
1058 .lock()
1059 .await
1060 .clone()
1061 .ok_or_else(|| ClientError::server("missing query"))?;
1062 assert_eq!(
1065 request.arguments,
1066 Some(aion_proto::ProtoPayload::from(payload("args")))
1067 );
1068 Ok(())
1069 }
1070
1071 #[tokio::test]
1072 async fn a_no_argument_query_sends_the_canonical_null_document() -> Result<(), ClientError> {
1073 let stub = Arc::new(StubTransport::default());
1074 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1075 outcome: Some(proto_query_response::Outcome::Result(
1076 aion_proto::ProtoPayload::from(payload("answer")),
1077 )),
1078 }));
1079 let client = client_with(Arc::clone(&stub));
1080
1081 let _: serde_json::Value = client
1085 .query_typed(
1086 &workflow_id(),
1087 Some(&run_id()),
1088 "state",
1089 &(),
1090 Duration::from_secs(1),
1091 )
1092 .await?;
1093
1094 let request = stub
1095 .last_query
1096 .lock()
1097 .await
1098 .clone()
1099 .ok_or_else(|| ClientError::server("missing query"))?;
1100 assert_eq!(
1101 request.arguments,
1102 Some(aion_proto::ProtoPayload::from(Payload::json_null()))
1103 );
1104 Ok(())
1105 }
1106
1107 #[tokio::test]
1108 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1109 let stub = Arc::new(StubTransport::default());
1110 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1111 outcome: Some(proto_query_response::Outcome::Error(
1112 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1113 )),
1114 }));
1115 let client = client_with(Arc::clone(&stub));
1116
1117 let result = client
1118 .query(
1119 &workflow_id(),
1120 Some(&run_id()),
1121 "state",
1122 Payload::json_null(),
1123 Duration::from_secs(1),
1124 )
1125 .await;
1126
1127 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1128 Ok(())
1129 }
1130
1131 #[tokio::test]
1132 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1133 let stub = Arc::new(StubTransport::default());
1136 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1137 outcome: Some(proto_query_response::Outcome::Error(
1138 aion_proto::ProtoWireError::from(WireError::backend("store down")),
1139 )),
1140 }));
1141 let client = client_with(Arc::clone(&stub));
1142
1143 let result = client
1144 .query(
1145 &workflow_id(),
1146 Some(&run_id()),
1147 "state",
1148 Payload::json_null(),
1149 Duration::from_secs(1),
1150 )
1151 .await;
1152
1153 assert_eq!(result, Err(ClientError::server("store down")));
1154 Ok(())
1155 }
1156
1157 #[tokio::test]
1158 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1159 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1160 struct QueryResult {
1161 label: String,
1162 }
1163
1164 let stub = Arc::new(StubTransport::default());
1165 let client = client_with(Arc::clone(&stub));
1166 let id = workflow_id();
1167
1168 let result: QueryResult = client
1169 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1170 .await?;
1171
1172 assert_eq!(
1173 result,
1174 QueryResult {
1175 label: String::from("result")
1176 }
1177 );
1178 assert!(stub.last_query.lock().await.is_some());
1179 Ok(())
1180 }
1181
1182 #[tokio::test]
1192 async fn query_typed_forwards_non_empty_args_without_silent_drop() -> Result<(), ClientError> {
1193 let stub = Arc::new(StubTransport::default());
1194 let client = client_with(Arc::clone(&stub));
1195 let id = workflow_id();
1196
1197 let _: serde_json::Value = client
1198 .query_typed(
1199 &id,
1200 Some(&run_id()),
1201 "state",
1202 &serde_json::json!({ "filter": "open" }),
1203 Duration::from_secs(1),
1204 )
1205 .await?;
1206
1207 let request = stub
1208 .last_query
1209 .lock()
1210 .await
1211 .clone()
1212 .ok_or_else(|| ClientError::server("missing query"))?;
1213 let arguments = request
1214 .arguments
1215 .ok_or_else(|| ClientError::server("query arguments were dropped"))?;
1216 assert_eq!(
1217 serde_json::from_slice::<serde_json::Value>(&arguments.bytes)
1218 .map_err(|error| ClientError::server(error.to_string()))?,
1219 serde_json::json!({ "filter": "open" })
1220 );
1221 Ok(())
1222 }
1223
1224 #[tokio::test]
1225 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1226 let stub = Arc::new(StubTransport::default());
1227 let client = client_with(Arc::clone(&stub));
1228 let id = workflow_id();
1229 let run = run_id();
1230
1231 client.cancel(&id, Some(&run), "not needed").await?;
1232 let listed = client
1233 .list(&WorkflowFilter::default(), ListPage::default())
1234 .await?;
1235 let described = client.describe(&id, None).await?;
1236
1237 assert!(stub.last_cancel.lock().await.is_some());
1238 assert!(stub.last_list.lock().await.is_some());
1239 let describe = stub
1240 .last_describe
1241 .lock()
1242 .await
1243 .clone()
1244 .ok_or_else(|| ClientError::server("missing describe"))?;
1245 assert!(describe.run_id.is_none());
1246 assert!(describe.include_history);
1247 assert_eq!(listed.len(), 1);
1248 assert_eq!(described.history.len(), 0);
1249 Ok(())
1250 }
1251
1252 #[tokio::test]
1253 async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1254 let stub = Arc::new(StubTransport::default());
1255 let client = client_with(Arc::clone(&stub));
1256 let id = workflow_id();
1257 let run = run_id();
1258
1259 let outcome = client.reopen(&id, Some(&run)).await?;
1260
1261 assert_eq!(outcome.status, WorkflowStatus::Running);
1262 let request = stub
1263 .last_reopen
1264 .lock()
1265 .await
1266 .clone()
1267 .ok_or_else(|| ClientError::server("missing reopen"))?;
1268 assert_eq!(request.namespace, "tenant-a");
1269 assert!(request.run_id.is_some());
1270 Ok(())
1271 }
1272
1273 #[tokio::test]
1276 async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1277 let stub = Arc::new(StubTransport::default());
1278 *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1279 WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1280 )));
1281 let client = client_with(Arc::clone(&stub));
1282
1283 let result = client.reopen(&workflow_id(), None).await;
1284
1285 assert!(
1286 matches!(result, Err(ClientError::InvalidState { .. })),
1287 "got {result:?}"
1288 );
1289 Ok(())
1290 }
1291}