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