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, ProtoReadHistoryRequest, ProtoReopenRequest,
12 ProtoResumeRequest, ProtoRunId, ProtoSignalRequest, ProtoWorkflowId, ProtoWorkflowStatus,
13 WireError, 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::payload::{from_payload, to_payload};
24use crate::stream::{EventStream, SubscribeTarget, event_stream, event_stream_from};
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct ListPage {
33 pub request_id: Option<String>,
35 pub limit: Option<usize>,
37 pub cursor: Option<String>,
39}
40
41#[derive(Clone, Debug, PartialEq)]
43pub struct WorkflowDescription {
44 pub summary: WorkflowSummary,
46 pub run_id: RunId,
48 pub history_head_seq: u64,
50 pub terminal_event: Option<Event>,
52}
53
54#[derive(Clone, Debug, PartialEq)]
56pub struct HistoryPage {
57 pub events: Vec<Event>,
59 pub next_from_seq: Option<u64>,
61 pub head_seq: u64,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ReopenOutcome {
68 pub run_id: RunId,
70 pub status: WorkflowStatus,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct PauseOutcome {
77 pub run_id: RunId,
79 pub status: WorkflowStatus,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct ResumeOutcome {
86 pub run_id: RunId,
88 pub status: WorkflowStatus,
90}
91
92impl Client {
93 pub async fn signal(
99 &self,
100 workflow_id: &WorkflowId,
101 run_id: Option<&RunId>,
102 name: impl Into<String>,
103 payload: Payload,
104 ) -> Result<(), ClientError> {
105 self.transport
106 .signal(ProtoSignalRequest {
107 namespace: self.namespace().to_owned(),
108 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
109 run_id: run_id.cloned().map(ProtoRunId::from),
110 signal_name: name.into(),
111 payload: Some(ProtoPayload::from(payload)),
112 })
113 .await?;
114 Ok(())
115 }
116
117 pub async fn signal_typed<T>(
124 &self,
125 workflow_id: &WorkflowId,
126 run_id: Option<&RunId>,
127 name: impl Into<String>,
128 value: &T,
129 ) -> Result<(), ClientError>
130 where
131 T: Serialize + ?Sized,
132 {
133 self.signal(workflow_id, run_id, name, to_payload(value)?)
134 .await
135 }
136
137 pub async fn query(
149 &self,
150 workflow_id: &WorkflowId,
151 run_id: Option<&RunId>,
152 name: impl Into<String>,
153 args: Payload,
154 deadline: Duration,
155 ) -> Result<Payload, ClientError> {
156 let response = tokio::time::timeout(
157 deadline,
158 self.transport.query(ProtoQueryRequest {
159 namespace: self.namespace().to_owned(),
160 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
161 run_id: run_id.cloned().map(ProtoRunId::from),
162 query_name: name.into(),
163 arguments: Some(aion_proto::ProtoPayload::from(args)),
164 }),
165 )
166 .await
167 .map_err(|_| {
168 ClientError::query_timeout(format!(
169 "query deadline of {deadline:?} elapsed before the server replied"
170 ))
171 })??;
172
173 match response.outcome {
174 Some(proto_query_response::Outcome::Result(payload)) => {
175 Payload::try_from(payload).map_err(ClientError::from_wire_error)
176 }
177 Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
178 None => Err(ClientError::server("query response outcome is missing")),
179 }
180 }
181
182 pub async fn query_typed<A, R>(
189 &self,
190 workflow_id: &WorkflowId,
191 run_id: Option<&RunId>,
192 name: impl Into<String>,
193 args: &A,
194 deadline: Duration,
195 ) -> Result<R, ClientError>
196 where
197 A: Serialize + ?Sized,
198 R: DeserializeOwned,
199 {
200 let payload = self
201 .query(
202 workflow_id,
203 run_id,
204 name,
205 query_args_payload(args)?,
206 deadline,
207 )
208 .await?;
209 from_payload(&payload)
210 }
211
212 pub async fn cancel(
221 &self,
222 workflow_id: &WorkflowId,
223 run_id: Option<&RunId>,
224 reason: impl Into<String>,
225 ) -> Result<(), ClientError> {
226 self.transport
227 .cancel(ProtoCancelRequest {
228 namespace: self.namespace().to_owned(),
229 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
230 run_id: run_id.cloned().map(ProtoRunId::from),
231 reason: reason.into(),
232 })
233 .await?;
234 Ok(())
235 }
236
237 pub async fn retire_workloop(
258 &self,
259 workflow_id: &WorkflowId,
260 reason: impl Into<String>,
261 ) -> Result<String, ClientError> {
262 let response = self
263 .transport
264 .retire_workloop(aion_proto::ProtoRetireWorkloopRequest {
265 namespace: self.namespace().to_owned(),
266 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
267 reason: reason.into(),
268 })
269 .await?;
270 Ok(response.reason)
271 }
272
273 pub async fn reopen(
285 &self,
286 workflow_id: &WorkflowId,
287 run_id: Option<&RunId>,
288 ) -> Result<ReopenOutcome, ClientError> {
289 let response = self
290 .transport
291 .reopen(ProtoReopenRequest {
292 namespace: self.namespace().to_owned(),
293 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
294 run_id: run_id.cloned().map(ProtoRunId::from),
295 })
296 .await?;
297 let run_id = response
298 .run_id
299 .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
300 .try_into()
301 .map_err(ClientError::from_wire_error)?;
302 let status = ProtoWorkflowStatus::try_from(response.status)
303 .map_err(|_error| ClientError::server("reopen response status is unknown"))
304 .and_then(|status| {
305 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
306 })?;
307 Ok(ReopenOutcome { run_id, status })
308 }
309
310 pub async fn pause(
320 &self,
321 workflow_id: &WorkflowId,
322 run_id: Option<&RunId>,
323 reason: impl Into<String>,
324 ) -> Result<PauseOutcome, ClientError> {
325 let response = self
326 .transport
327 .pause(ProtoPauseRequest {
328 namespace: self.namespace().to_owned(),
329 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
330 run_id: run_id.cloned().map(ProtoRunId::from),
331 reason: reason.into(),
332 })
333 .await?;
334 let run_id = response
335 .run_id
336 .ok_or_else(|| ClientError::server("pause 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("pause response status is unknown"))
341 .and_then(|status| {
342 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
343 })?;
344 Ok(PauseOutcome { run_id, status })
345 }
346
347 pub async fn resume(
357 &self,
358 workflow_id: &WorkflowId,
359 run_id: Option<&RunId>,
360 ) -> Result<ResumeOutcome, ClientError> {
361 let response = self
362 .transport
363 .resume(ProtoResumeRequest {
364 namespace: self.namespace().to_owned(),
365 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
366 run_id: run_id.cloned().map(ProtoRunId::from),
367 })
368 .await?;
369 let run_id = response
370 .run_id
371 .ok_or_else(|| ClientError::server("resume response run id is missing"))?
372 .try_into()
373 .map_err(ClientError::from_wire_error)?;
374 let status = ProtoWorkflowStatus::try_from(response.status)
375 .map_err(|_error| ClientError::server("resume response status is unknown"))
376 .and_then(|status| {
377 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
378 })?;
379 Ok(ResumeOutcome { run_id, status })
380 }
381
382 pub async fn list(
388 &self,
389 filter: &WorkflowFilter,
390 page: ListPage,
391 ) -> Result<Vec<WorkflowSummary>, ClientError> {
392 validate_list_page(&page)?;
393 let namespace = self.namespace().to_owned();
394 let filter = workflow_filter_to_visibility(filter)?;
395 let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
396 .map_err(ClientError::from_wire_error)?;
397 let response = self
398 .transport
399 .list_workflows(ProtoListWorkflowsRequest {
400 namespace,
401 filter: Some(filter),
402 })
403 .await?;
404
405 response
406 .summaries
407 .iter()
408 .map(decode_visibility_summary)
409 .map(|result| result.map_err(ClientError::from_wire_error))
410 .collect()
411 }
412
413 pub async fn describe(
419 &self,
420 workflow_id: &WorkflowId,
421 run_id: Option<&RunId>,
422 ) -> Result<WorkflowDescription, ClientError> {
423 let response = self
424 .transport
425 .describe_workflow(ProtoDescribeWorkflowRequest {
426 namespace: self.namespace().to_owned(),
427 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
428 run_id: run_id.cloned().map(ProtoRunId::from),
429 include_history: false,
430 })
431 .await?;
432 let summary = response
433 .summary
434 .as_ref()
435 .ok_or_else(|| ClientError::server("describe response summary is missing"))
436 .and_then(|summary| {
437 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
438 })?;
439 let run_id = response
440 .run_id
441 .ok_or_else(|| ClientError::server("describe response run_id is missing"))?
442 .try_into()
443 .map_err(ClientError::from_wire_error)?;
444 let terminal_event = response
445 .terminal_event
446 .as_ref()
447 .map(decode_event)
448 .transpose()
449 .map_err(ClientError::from_wire_error)?;
450 Ok(WorkflowDescription {
451 summary,
452 run_id,
453 history_head_seq: response.history_head_seq,
454 terminal_event,
455 })
456 }
457
458 pub async fn read_history(
464 &self,
465 workflow_id: &WorkflowId,
466 from_seq: Option<u64>,
467 limit: Option<u32>,
468 ) -> Result<HistoryPage, ClientError> {
469 let response = self
470 .transport
471 .read_history(ProtoReadHistoryRequest {
472 namespace: self.namespace().to_owned(),
473 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
474 from_seq,
475 limit,
476 })
477 .await?;
478 let events = response
479 .events
480 .iter()
481 .map(decode_event)
482 .map(|result| result.map_err(ClientError::from_wire_error))
483 .collect::<Result<Vec<_>, _>>()?;
484 Ok(HistoryPage {
485 events,
486 next_from_seq: response.next_from_seq,
487 head_seq: response.head_seq,
488 })
489 }
490
491 #[must_use]
493 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
494 event_stream(
495 self.transport.clone(),
496 self.namespace().to_owned(),
497 SubscribeTarget::Workflow {
498 workflow_id: workflow_id.clone(),
499 },
500 )
501 }
502
503 #[must_use]
510 pub fn subscribe_workflow_from(
511 &self,
512 workflow_id: &WorkflowId,
513 resume_from: NonZeroU64,
514 ) -> EventStream {
515 event_stream_from(
516 self.transport.clone(),
517 self.namespace().to_owned(),
518 workflow_id.clone(),
519 resume_from,
520 )
521 }
522
523 #[must_use]
525 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
526 event_stream(
527 self.transport.clone(),
528 self.namespace().to_owned(),
529 SubscribeTarget::Filtered { filter },
530 )
531 }
532
533 #[must_use]
535 pub fn subscribe_firehose(&self) -> EventStream {
536 event_stream(
537 self.transport.clone(),
538 self.namespace().to_owned(),
539 SubscribeTarget::Firehose,
540 )
541 }
542}
543
544pub(crate) fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
545 namespace.unwrap_or_else(|| client.namespace().to_owned())
546}
547
548fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
554where
555 T: Serialize + ?Sized,
556{
557 to_payload(args)
558}
559
560fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
561 if page.limit.is_some() || page.cursor.is_some() {
562 return Err(ClientError::invalid_argument(
563 "list pagination limit/cursor are reserved by the contract and \
564 not yet carried by the wire",
565 ));
566 }
567 Ok(())
568}
569
570fn workflow_filter_to_visibility(
571 filter: &WorkflowFilter,
572) -> Result<ListWorkflowsFilter, ClientError> {
573 if filter.parent.is_some() {
574 return Err(ClientError::invalid_argument(
575 "parent workflow filters are not carried by the visibility wire contract",
576 ));
577 }
578
579 Ok(ListWorkflowsFilter {
580 workflow_type: filter.workflow_type.clone(),
581 status: filter.status,
582 started_after: filter.started_after,
583 started_before: filter.started_before,
584 ..ListWorkflowsFilter::default()
585 })
586}
587
588fn decode_visibility_summary(
589 envelope: &aion_proto::WireEnvelope,
590) -> Result<WorkflowSummary, WireError> {
591 let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
592 Ok(WorkflowSummary {
593 workflow_id: summary.workflow_id,
594 workflow_type: summary.workflow_type,
595 status: summary.status,
596 started_at: summary.start_time,
597 ended_at: summary.close_time,
598 parent: None,
599 failed_step: summary.failed_step,
600 failure_reason: summary.failure_reason,
601 display_name: aion_core::display_name_from_attributes(&summary.search_attributes),
602 kind: aion_core::workflow_kind_from_attributes(&summary.search_attributes),
603 })
604}
605
606pub(crate) fn decode_required_workflow_id(
607 value: Option<ProtoWorkflowId>,
608 context: &str,
609) -> Result<WorkflowId, ClientError> {
610 value
611 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
612 .try_into()
613 .map_err(ClientError::from_wire_error)
614}
615
616pub(crate) fn decode_required_run_id(
617 value: Option<ProtoRunId>,
618 context: &str,
619) -> Result<RunId, ClientError> {
620 value
621 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
622 .try_into()
623 .map_err(ClientError::from_wire_error)
624}
625
626fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
632 ClientError::from_proto_wire_error(error)
633}
634
635#[cfg(test)]
636mod tests {
637 use std::sync::Arc;
638 use std::time::Duration;
639
640 use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
641 use aion_proto::{
642 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
643 ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
644 ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
645 encode_core_value, encode_workflow_summary, proto_query_response,
646 };
647 use async_trait::async_trait;
648 use chrono::Utc;
649 use futures::StreamExt;
650 use futures::stream;
651 use tokio::sync::Mutex;
652
653 use super::ListPage;
654 use crate::client::{Client, ClientBuilder, ClientConfig};
655 use crate::error::ClientError;
656 use crate::start::{DisplayNameNotApplied, StartOptions};
657 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
658
659 #[derive(Default)]
660 struct StubTransport {
661 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
662 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
663 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
664 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
665 last_retire: Mutex<Option<aion_proto::ProtoRetireWorkloopRequest>>,
666 last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
667 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
668 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
669 start_error: Mutex<Option<ClientError>>,
670 signal_error: Mutex<Option<ClientError>>,
671 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
672 reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
673 start_calls: std::sync::atomic::AtomicUsize,
677 }
678
679 #[async_trait]
680 impl WorkflowTransport for StubTransport {
681 async fn start_workflow(
682 &self,
683 request: aion_proto::ProtoStartWorkflowRequest,
684 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
685 self.start_calls
686 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
687 *self.last_start.lock().await = Some(request);
688 if let Some(error) = self.start_error.lock().await.take() {
689 return Err(error);
690 }
691 Ok(ProtoStartWorkflowResponse {
692 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
693 run_id: Some(ProtoRunId::from(run_id())),
694 })
695 }
696
697 async fn signal(
698 &self,
699 request: aion_proto::ProtoSignalRequest,
700 ) -> Result<ProtoSignalResponse, ClientError> {
701 *self.last_signal.lock().await = Some(request);
702 if let Some(error) = self.signal_error.lock().await.take() {
703 return Err(error);
704 }
705 Ok(ProtoSignalResponse {})
706 }
707
708 async fn query(
709 &self,
710 request: aion_proto::ProtoQueryRequest,
711 ) -> Result<ProtoQueryResponse, ClientError> {
712 *self.last_query.lock().await = Some(request);
713 if let Some(response) = self.query_response.lock().await.take() {
714 return response;
715 }
716 Ok(ProtoQueryResponse {
717 outcome: Some(proto_query_response::Outcome::Result(
718 aion_proto::ProtoPayload::from(payload("result")),
719 )),
720 })
721 }
722
723 async fn cancel(
724 &self,
725 request: aion_proto::ProtoCancelRequest,
726 ) -> Result<ProtoCancelResponse, ClientError> {
727 *self.last_cancel.lock().await = Some(request);
728 Ok(ProtoCancelResponse {})
729 }
730
731 async fn retire_workloop(
732 &self,
733 request: aion_proto::ProtoRetireWorkloopRequest,
734 ) -> Result<aion_proto::ProtoRetireWorkloopResponse, ClientError> {
735 let reason = request.reason.clone();
736 *self.last_retire.lock().await = Some(request);
737 Ok(aion_proto::ProtoRetireWorkloopResponse { reason })
738 }
739
740 async fn reopen(
741 &self,
742 request: aion_proto::ProtoReopenRequest,
743 ) -> Result<ProtoReopenResponse, ClientError> {
744 *self.last_reopen.lock().await = Some(request);
745 if let Some(response) = self.reopen_response.lock().await.take() {
746 return response;
747 }
748 Ok(ProtoReopenResponse {
749 run_id: Some(ProtoRunId::from(run_id())),
750 status: ProtoWorkflowStatus::Running as i32,
751 })
752 }
753
754 async fn pause(
755 &self,
756 _request: aion_proto::ProtoPauseRequest,
757 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
758 Ok(aion_proto::ProtoPauseResponse {
759 run_id: Some(ProtoRunId::from(run_id())),
760 status: ProtoWorkflowStatus::Paused as i32,
761 })
762 }
763
764 async fn resume(
765 &self,
766 _request: aion_proto::ProtoResumeRequest,
767 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
768 Ok(aion_proto::ProtoResumeResponse {
769 run_id: Some(ProtoRunId::from(run_id())),
770 status: ProtoWorkflowStatus::Running as i32,
771 })
772 }
773
774 async fn list_workflows(
775 &self,
776 request: aion_proto::ProtoListWorkflowsRequest,
777 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
778 *self.last_list.lock().await = Some(request);
779 Ok(ProtoListWorkflowsResponse {
780 summaries: vec![
781 encode_core_value("tenant-a", None, &visibility_summary())
782 .map_err(ClientError::from_wire_error)?,
783 ],
784 })
785 }
786
787 async fn describe_workflow(
788 &self,
789 request: aion_proto::ProtoDescribeWorkflowRequest,
790 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
791 *self.last_describe.lock().await = Some(request);
792 Ok(ProtoDescribeWorkflowResponse {
793 summary: Some(
794 encode_workflow_summary("tenant-a", None, &summary())
795 .map_err(ClientError::from_wire_error)?,
796 ),
797 history: Vec::new(),
798 run_id: Some(ProtoRunId::from(run_id())),
799 history_head_seq: 0,
800 terminal_event: None,
801 })
802 }
803
804 async fn read_history(
805 &self,
806 _: aion_proto::ProtoReadHistoryRequest,
807 ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
808 Ok(aion_proto::ProtoReadHistoryResponse {
809 events: Vec::new(),
810 next_from_seq: None,
811 head_seq: 0,
812 })
813 }
814
815 async fn subscribe(
816 &self,
817 _: aion_proto::SubscriptionRequest,
818 _: Option<u64>,
819 ) -> Result<SubscriptionAttempt, ClientError> {
820 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
821 }
822 }
823
824 fn client_with(stub: Arc<StubTransport>) -> Client {
825 Client::from_transport(
826 ClientConfig::from(
827 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
828 ),
829 stub,
830 )
831 }
832
833 fn workflow_id() -> WorkflowId {
834 WorkflowId::new_v4()
835 }
836
837 fn run_id() -> aion_core::RunId {
838 aion_core::RunId::new(uuid::Uuid::from_u128(1))
839 }
840
841 fn payload(label: &str) -> Payload {
842 Payload::new(
843 ContentType::Json,
844 format!("{{\"label\":\"{label}\"}}").into_bytes(),
845 )
846 }
847
848 fn summary() -> aion_core::WorkflowSummary {
849 aion_core::WorkflowSummary {
850 workflow_id: workflow_id(),
851 workflow_type: String::from("checkout"),
852 status: WorkflowStatus::Running,
853 started_at: Utc::now(),
854 ended_at: None,
855 parent: None,
856 failed_step: None,
857 failure_reason: None,
858 display_name: None,
859 kind: None,
860 }
861 }
862
863 fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
864 aion_store::visibility::WorkflowSummary {
865 workflow_id: workflow_id(),
866 run_id: run_id(),
867 workflow_type: String::from("checkout"),
868 status: WorkflowStatus::Running,
869 start_time: Utc::now(),
870 close_time: None,
871 failed_step: None,
872 failure_reason: None,
873 search_attributes: std::collections::HashMap::new(),
874 }
875 }
876
877 #[tokio::test]
878 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
879 let stub = Arc::new(StubTransport::default());
880 let client = client_with(Arc::clone(&stub));
881
882 let result = client
883 .start("checkout", payload("input"), StartOptions::default())
884 .await?;
885
886 let recorded = stub.last_start.lock().await.clone();
887 assert!(recorded.is_some());
888 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
889 assert_eq!(request.namespace, "tenant-a");
890 assert_eq!(request.workflow_type, "checkout");
891 assert!(request.input.is_some());
892 assert_ne!(
893 result.handle.workflow_id(),
894 &WorkflowId::new(uuid::Uuid::nil())
895 );
896 assert_eq!(result.display_name_not_applied, None);
897 Ok(())
898 }
899
900 #[tokio::test]
901 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
902 {
903 let stub = Arc::new(StubTransport::default());
904 let client = client_with(Arc::clone(&stub));
905 let opts = StartOptions {
906 namespace: None,
907 idempotency_key: Some(String::from("retry-key")),
908 routing_key: None,
909 task_queue: None,
910 display_name: None,
911 };
912
913 let original = client
914 .start("checkout", payload("input"), opts.clone())
915 .await?;
916 let replayed = client
917 .start("checkout", payload("input"), opts.clone())
918 .await?;
919 let conflict = client.start("checkout", payload("other"), opts).await;
920
921 assert_eq!(replayed, original);
922 assert!(
923 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
924 "got {conflict:?}"
925 );
926 Ok(())
927 }
928
929 #[tokio::test]
935 async fn a_replay_with_a_different_name_dedupes_and_reports_the_unapplied_name()
936 -> Result<(), ClientError> {
937 let stub = Arc::new(StubTransport::default());
938 let client = client_with(Arc::clone(&stub));
939 let named = |name: Option<&str>| StartOptions {
940 idempotency_key: Some(String::from("retry-key")),
941 display_name: name.map(str::to_owned),
942 ..StartOptions::default()
943 };
944
945 let first = client
946 .start(
947 "checkout",
948 payload("input"),
949 named(Some("Nightly settlement")),
950 )
951 .await?;
952 assert_eq!(
953 first.display_name_not_applied, None,
954 "a first start applies the name it asked for"
955 );
956
957 let replayed = client
959 .start("checkout", payload("input"), named(Some("Something else")))
960 .await?;
961 assert_eq!(
962 replayed.handle, first.handle,
963 "the label carries no identity, so the act deduped to one run"
964 );
965 assert_eq!(
966 replayed.display_name_not_applied,
967 Some(DisplayNameNotApplied {
968 requested: String::from("Something else"),
969 standing: Some(String::from("Nightly settlement")),
970 }),
971 "the name that was NOT applied must be reported, never dropped in silence"
972 );
973
974 let replayed = client
978 .start("checkout", payload("input"), named(None))
979 .await?;
980 assert_eq!(
981 replayed.display_name_not_applied, None,
982 "a caller that requested no name had no name dropped"
983 );
984
985 let replayed = client
987 .start(
988 "checkout",
989 payload("input"),
990 named(Some("Nightly settlement")),
991 )
992 .await?;
993 assert_eq!(replayed.display_name_not_applied, None);
994
995 let replayed = client
998 .start(
999 "checkout",
1000 payload("input"),
1001 named(Some(" Nightly settlement ")),
1002 )
1003 .await?;
1004 assert_eq!(
1005 replayed.display_name_not_applied, None,
1006 "whitespace the server would have erased is not an unapplied name"
1007 );
1008
1009 assert_eq!(
1011 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1012 1
1013 );
1014 Ok(())
1015 }
1016
1017 #[tokio::test]
1027 async fn the_unapplied_name_report_fires_only_for_a_replay_that_asked_for_a_name()
1028 -> Result<(), ClientError> {
1029 struct Case {
1033 label: &'static str,
1034 standing: Option<&'static str>,
1035 requested: Option<&'static str>,
1036 expected: Option<DisplayNameNotApplied>,
1037 }
1038
1039 let cases = [
1040 Case {
1041 label: "requested none, standing none",
1042 standing: None,
1043 requested: None,
1044 expected: None,
1045 },
1046 Case {
1047 label: "requested none, standing some",
1048 standing: Some("Nightly"),
1049 requested: None,
1050 expected: None,
1051 },
1052 Case {
1053 label: "requested some, standing none",
1054 standing: None,
1055 requested: Some("Nightly"),
1056 expected: Some(DisplayNameNotApplied {
1057 requested: String::from("Nightly"),
1058 standing: None,
1059 }),
1060 },
1061 Case {
1062 label: "requested some, standing a different some",
1063 standing: Some("Nightly"),
1064 requested: Some("Weekly"),
1065 expected: Some(DisplayNameNotApplied {
1066 requested: String::from("Weekly"),
1067 standing: Some(String::from("Nightly")),
1068 }),
1069 },
1070 Case {
1071 label: "requested some, standing the same some",
1072 standing: Some("Nightly"),
1073 requested: Some("Nightly"),
1074 expected: None,
1075 },
1076 ];
1077
1078 for Case {
1079 label,
1080 standing,
1081 requested,
1082 expected,
1083 } in cases
1084 {
1085 let stub = Arc::new(StubTransport::default());
1086 let client = client_with(Arc::clone(&stub));
1087 let named = |name: Option<&str>| StartOptions {
1088 idempotency_key: Some(String::from("retry-key")),
1089 display_name: name.map(str::to_owned),
1090 ..StartOptions::default()
1091 };
1092
1093 let first = client
1094 .start("checkout", payload("input"), named(standing))
1095 .await?;
1096 assert_eq!(
1097 first.display_name_not_applied, None,
1098 "{label}: a first start applies the name it asked for"
1099 );
1100
1101 let replayed = client
1102 .start("checkout", payload("input"), named(requested))
1103 .await?;
1104 assert_eq!(
1105 replayed.handle, first.handle,
1106 "{label}: the name carries no identity, so the act deduped to one run"
1107 );
1108 assert_eq!(
1109 replayed.display_name_not_applied, expected,
1110 "{label}: the report must fire exactly when a REQUESTED name was not applied"
1111 );
1112 assert_eq!(
1113 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1114 1,
1115 "{label}: the replay must not reach the transport"
1116 );
1117 }
1118 Ok(())
1119 }
1120
1121 #[tokio::test]
1127 async fn a_blank_display_name_is_refused() -> Result<(), ClientError> {
1128 let stub = Arc::new(StubTransport::default());
1129 let client = client_with(Arc::clone(&stub));
1130
1131 for blank in ["", " ", "\t\n "] {
1132 let result = client
1133 .start(
1134 "checkout",
1135 payload("input"),
1136 StartOptions {
1137 display_name: Some(String::from(blank)),
1138 ..StartOptions::default()
1139 },
1140 )
1141 .await;
1142 assert!(
1143 matches!(result, Err(ClientError::InvalidArgument { .. })),
1144 "blank {blank:?} must be refused, got {result:?}"
1145 );
1146 }
1147 assert_eq!(
1148 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1149 0
1150 );
1151 Ok(())
1152 }
1153
1154 #[tokio::test]
1155 async fn start_idempotency_treats_a_changed_route_as_a_different_request()
1156 -> Result<(), ClientError> {
1157 for (label, second) in [
1158 (
1159 "task_queue",
1160 StartOptions {
1161 task_queue: Some(String::from("payments")),
1162 ..StartOptions::default()
1163 },
1164 ),
1165 (
1166 "routing_key",
1167 StartOptions {
1168 routing_key: Some(String::from("tenant-7")),
1169 ..StartOptions::default()
1170 },
1171 ),
1172 ] {
1173 let stub = Arc::new(StubTransport::default());
1174 let client = client_with(Arc::clone(&stub));
1175 let key = Some(String::from("retry-key"));
1176 let first = StartOptions {
1177 idempotency_key: key.clone(),
1178 ..StartOptions::default()
1179 };
1180 let second = StartOptions {
1181 idempotency_key: key,
1182 ..second
1183 };
1184
1185 client.start("checkout", payload("input"), first).await?;
1186 let conflict = client.start("checkout", payload("input"), second).await;
1187
1188 assert!(
1189 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
1190 "reusing a key with a different {label} must conflict, got {conflict:?}"
1191 );
1192 }
1193 Ok(())
1194 }
1195
1196 #[tokio::test]
1197 async fn signal_maps_latest_run_and_error() {
1198 let stub = Arc::new(StubTransport::default());
1199 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
1200 let client = client_with(Arc::clone(&stub));
1201 let id = workflow_id();
1202
1203 let result = client.signal(&id, None, "approve", payload("signal")).await;
1204
1205 assert_eq!(
1206 result,
1207 Err(ClientError::not_found("workflow was not found"))
1208 );
1209 let recorded = stub.last_signal.lock().await.clone();
1210 assert!(recorded.is_some());
1211 let Some(request) = recorded else {
1212 return;
1213 };
1214 assert!(request.run_id.is_none());
1215 }
1216
1217 #[tokio::test]
1218 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1219 let stub = Arc::new(StubTransport::default());
1220 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1221 outcome: Some(proto_query_response::Outcome::Error(
1222 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1223 )),
1224 }));
1225 let client = client_with(Arc::clone(&stub));
1226 let id = workflow_id();
1227
1228 let result = client
1229 .query(
1230 &id,
1231 Some(&run_id()),
1232 "state",
1233 Payload::json_null(),
1234 Duration::from_secs(1),
1235 )
1236 .await;
1237
1238 assert_eq!(result, Err(ClientError::query_timeout("slow")));
1239 let recorded = stub.last_query.lock().await.clone();
1240 assert!(recorded.is_some());
1241 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1242 assert!(request.run_id.is_some());
1243 Ok(())
1244 }
1245
1246 #[tokio::test]
1247 async fn query_forwards_its_arguments_onto_the_wire() -> Result<(), ClientError> {
1248 let stub = Arc::new(StubTransport::default());
1249 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1250 outcome: Some(proto_query_response::Outcome::Result(
1251 aion_proto::ProtoPayload::from(payload("answer")),
1252 )),
1253 }));
1254 let client = client_with(Arc::clone(&stub));
1255
1256 let returned = client
1257 .query(
1258 &workflow_id(),
1259 Some(&run_id()),
1260 "state",
1261 payload("args"),
1262 Duration::from_secs(1),
1263 )
1264 .await?;
1265
1266 assert_eq!(returned, payload("answer"));
1267 let request = stub
1268 .last_query
1269 .lock()
1270 .await
1271 .clone()
1272 .ok_or_else(|| ClientError::server("missing query"))?;
1273 assert_eq!(
1276 request.arguments,
1277 Some(aion_proto::ProtoPayload::from(payload("args")))
1278 );
1279 Ok(())
1280 }
1281
1282 #[tokio::test]
1283 async fn a_no_argument_query_sends_the_canonical_null_document() -> Result<(), ClientError> {
1284 let stub = Arc::new(StubTransport::default());
1285 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1286 outcome: Some(proto_query_response::Outcome::Result(
1287 aion_proto::ProtoPayload::from(payload("answer")),
1288 )),
1289 }));
1290 let client = client_with(Arc::clone(&stub));
1291
1292 let _: serde_json::Value = client
1296 .query_typed(
1297 &workflow_id(),
1298 Some(&run_id()),
1299 "state",
1300 &(),
1301 Duration::from_secs(1),
1302 )
1303 .await?;
1304
1305 let request = stub
1306 .last_query
1307 .lock()
1308 .await
1309 .clone()
1310 .ok_or_else(|| ClientError::server("missing query"))?;
1311 assert_eq!(
1312 request.arguments,
1313 Some(aion_proto::ProtoPayload::from(Payload::json_null()))
1314 );
1315 Ok(())
1316 }
1317
1318 #[tokio::test]
1319 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1320 let stub = Arc::new(StubTransport::default());
1321 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1322 outcome: Some(proto_query_response::Outcome::Error(
1323 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1324 )),
1325 }));
1326 let client = client_with(Arc::clone(&stub));
1327
1328 let result = client
1329 .query(
1330 &workflow_id(),
1331 Some(&run_id()),
1332 "state",
1333 Payload::json_null(),
1334 Duration::from_secs(1),
1335 )
1336 .await;
1337
1338 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1339 Ok(())
1340 }
1341
1342 #[tokio::test]
1343 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1344 let stub = Arc::new(StubTransport::default());
1347 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1348 outcome: Some(proto_query_response::Outcome::Error(
1349 aion_proto::ProtoWireError::from(WireError::backend("store down")),
1350 )),
1351 }));
1352 let client = client_with(Arc::clone(&stub));
1353
1354 let result = client
1355 .query(
1356 &workflow_id(),
1357 Some(&run_id()),
1358 "state",
1359 Payload::json_null(),
1360 Duration::from_secs(1),
1361 )
1362 .await;
1363
1364 assert_eq!(result, Err(ClientError::server("store down")));
1365 Ok(())
1366 }
1367
1368 #[tokio::test]
1369 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1370 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1371 struct QueryResult {
1372 label: String,
1373 }
1374
1375 let stub = Arc::new(StubTransport::default());
1376 let client = client_with(Arc::clone(&stub));
1377 let id = workflow_id();
1378
1379 let result: QueryResult = client
1380 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1381 .await?;
1382
1383 assert_eq!(
1384 result,
1385 QueryResult {
1386 label: String::from("result")
1387 }
1388 );
1389 assert!(stub.last_query.lock().await.is_some());
1390 Ok(())
1391 }
1392
1393 #[tokio::test]
1403 async fn query_typed_forwards_non_empty_args_without_silent_drop() -> Result<(), ClientError> {
1404 let stub = Arc::new(StubTransport::default());
1405 let client = client_with(Arc::clone(&stub));
1406 let id = workflow_id();
1407
1408 let _: serde_json::Value = client
1409 .query_typed(
1410 &id,
1411 Some(&run_id()),
1412 "state",
1413 &serde_json::json!({ "filter": "open" }),
1414 Duration::from_secs(1),
1415 )
1416 .await?;
1417
1418 let request = stub
1419 .last_query
1420 .lock()
1421 .await
1422 .clone()
1423 .ok_or_else(|| ClientError::server("missing query"))?;
1424 let arguments = request
1425 .arguments
1426 .ok_or_else(|| ClientError::server("query arguments were dropped"))?;
1427 assert_eq!(
1428 serde_json::from_slice::<serde_json::Value>(&arguments.bytes)
1429 .map_err(|error| ClientError::server(error.to_string()))?,
1430 serde_json::json!({ "filter": "open" })
1431 );
1432 Ok(())
1433 }
1434
1435 #[tokio::test]
1436 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1437 let stub = Arc::new(StubTransport::default());
1438 let client = client_with(Arc::clone(&stub));
1439 let id = workflow_id();
1440 let run = run_id();
1441
1442 client.cancel(&id, Some(&run), "not needed").await?;
1443 let listed = client
1444 .list(&WorkflowFilter::default(), ListPage::default())
1445 .await?;
1446 let described = client.describe(&id, None).await?;
1447
1448 assert!(stub.last_cancel.lock().await.is_some());
1449 assert!(stub.last_list.lock().await.is_some());
1450 let describe = stub
1451 .last_describe
1452 .lock()
1453 .await
1454 .clone()
1455 .ok_or_else(|| ClientError::server("missing describe"))?;
1456 assert!(describe.run_id.is_none());
1457 assert!(!describe.include_history);
1458 assert_eq!(listed.len(), 1);
1459 assert_eq!(described.run_id, run);
1460 assert_eq!(described.history_head_seq, 0);
1461 assert!(described.terminal_event.is_none());
1462 Ok(())
1463 }
1464
1465 #[tokio::test]
1466 async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1467 let stub = Arc::new(StubTransport::default());
1468 let client = client_with(Arc::clone(&stub));
1469 let id = workflow_id();
1470 let run = run_id();
1471
1472 let outcome = client.reopen(&id, Some(&run)).await?;
1473
1474 assert_eq!(outcome.status, WorkflowStatus::Running);
1475 let request = stub
1476 .last_reopen
1477 .lock()
1478 .await
1479 .clone()
1480 .ok_or_else(|| ClientError::server("missing reopen"))?;
1481 assert_eq!(request.namespace, "tenant-a");
1482 assert!(request.run_id.is_some());
1483 Ok(())
1484 }
1485
1486 #[tokio::test]
1489 async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1490 let stub = Arc::new(StubTransport::default());
1491 *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1492 WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1493 )));
1494 let client = client_with(Arc::clone(&stub));
1495
1496 let result = client.reopen(&workflow_id(), None).await;
1497
1498 assert!(
1499 matches!(result, Err(ClientError::InvalidState { .. })),
1500 "got {result:?}"
1501 );
1502 Ok(())
1503 }
1504}