1use std::num::NonZeroU64;
4use std::time::Duration;
5
6use aion_core::{
7 Event, Payload, RunId, WorkflowFilter, WorkflowId, WorkflowListFilter, WorkflowListPage,
8 WorkflowListRequest, WorkflowSort, WorkflowStatus, WorkflowSummary,
9};
10use aion_proto::{
11 ProtoCancelRequest, ProtoDescribeWorkflowRequest, ProtoListWorkflowsRequest, ProtoPauseRequest,
12 ProtoPayload, ProtoQueryRequest, ProtoReadHistoryRequest, ProtoReopenRequest,
13 ProtoResumeRequest, ProtoRunId, ProtoSignalRequest, ProtoWorkflowId, ProtoWorkflowStatus,
14 decode_core_value, decode_event, decode_workflow_summary, encode_core_value,
15 proto_query_response,
16};
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, PartialEq, Eq)]
35pub struct ListRequest {
36 pub filter: WorkflowListFilter,
38 pub sort: WorkflowSort,
40 pub cursor: Option<String>,
42 pub limit: u32,
44 pub request_id: Option<String>,
46}
47
48#[derive(Clone, Debug, PartialEq)]
50pub struct WorkflowDescription {
51 pub summary: WorkflowSummary,
53 pub run_id: RunId,
55 pub history_head_seq: u64,
57 pub terminal_event: Option<Event>,
59 pub provenance: Option<aion_core::ReadProvenance>,
64 pub lease_recording: Option<aion_core::LeaseRecording>,
67}
68
69#[derive(Clone, Debug, PartialEq)]
71pub struct HistoryPage {
72 pub events: Vec<Event>,
74 pub next_from_seq: Option<u64>,
76 pub head_seq: u64,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct ReopenOutcome {
83 pub run_id: RunId,
85 pub status: WorkflowStatus,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct PauseOutcome {
92 pub run_id: RunId,
94 pub status: WorkflowStatus,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct ResumeOutcome {
101 pub run_id: RunId,
103 pub status: WorkflowStatus,
105}
106
107impl Client {
108 pub async fn signal(
114 &self,
115 workflow_id: &WorkflowId,
116 run_id: Option<&RunId>,
117 name: impl Into<String>,
118 payload: Payload,
119 ) -> Result<(), ClientError> {
120 self.transport
121 .signal(ProtoSignalRequest {
122 namespace: self.namespace().to_owned(),
123 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
124 run_id: run_id.cloned().map(ProtoRunId::from),
125 signal_name: name.into(),
126 payload: Some(ProtoPayload::from(payload)),
127 })
128 .await?;
129 Ok(())
130 }
131
132 pub async fn signal_typed<T>(
139 &self,
140 workflow_id: &WorkflowId,
141 run_id: Option<&RunId>,
142 name: impl Into<String>,
143 value: &T,
144 ) -> Result<(), ClientError>
145 where
146 T: Serialize + ?Sized,
147 {
148 self.signal(workflow_id, run_id, name, to_payload(value)?)
149 .await
150 }
151
152 pub async fn query(
164 &self,
165 workflow_id: &WorkflowId,
166 run_id: Option<&RunId>,
167 name: impl Into<String>,
168 args: Payload,
169 deadline: Duration,
170 ) -> Result<Payload, ClientError> {
171 let response = tokio::time::timeout(
172 deadline,
173 self.transport.query(ProtoQueryRequest {
174 namespace: self.namespace().to_owned(),
175 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
176 run_id: run_id.cloned().map(ProtoRunId::from),
177 query_name: name.into(),
178 arguments: Some(aion_proto::ProtoPayload::from(args)),
179 }),
180 )
181 .await
182 .map_err(|_| {
183 ClientError::query_timeout(format!(
184 "query deadline of {deadline:?} elapsed before the server replied"
185 ))
186 })??;
187
188 match response.outcome {
189 Some(proto_query_response::Outcome::Result(payload)) => {
190 Payload::try_from(payload).map_err(ClientError::from_wire_error)
191 }
192 Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
193 None => Err(ClientError::server("query response outcome is missing")),
194 }
195 }
196
197 pub async fn query_typed<A, R>(
204 &self,
205 workflow_id: &WorkflowId,
206 run_id: Option<&RunId>,
207 name: impl Into<String>,
208 args: &A,
209 deadline: Duration,
210 ) -> Result<R, ClientError>
211 where
212 A: Serialize + ?Sized,
213 R: DeserializeOwned,
214 {
215 let payload = self
216 .query(
217 workflow_id,
218 run_id,
219 name,
220 query_args_payload(args)?,
221 deadline,
222 )
223 .await?;
224 from_payload(&payload)
225 }
226
227 pub async fn cancel(
236 &self,
237 workflow_id: &WorkflowId,
238 run_id: Option<&RunId>,
239 reason: impl Into<String>,
240 ) -> Result<(), ClientError> {
241 self.transport
242 .cancel(ProtoCancelRequest {
243 namespace: self.namespace().to_owned(),
244 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
245 run_id: run_id.cloned().map(ProtoRunId::from),
246 reason: reason.into(),
247 })
248 .await?;
249 Ok(())
250 }
251
252 pub async fn retire_workloop(
273 &self,
274 workflow_id: &WorkflowId,
275 reason: impl Into<String>,
276 ) -> Result<String, ClientError> {
277 let response = self
278 .transport
279 .retire_workloop(aion_proto::ProtoRetireWorkloopRequest {
280 namespace: self.namespace().to_owned(),
281 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
282 reason: reason.into(),
283 })
284 .await?;
285 Ok(response.reason)
286 }
287
288 pub async fn reopen(
300 &self,
301 workflow_id: &WorkflowId,
302 run_id: Option<&RunId>,
303 ) -> Result<ReopenOutcome, ClientError> {
304 let response = self
305 .transport
306 .reopen(ProtoReopenRequest {
307 namespace: self.namespace().to_owned(),
308 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
309 run_id: run_id.cloned().map(ProtoRunId::from),
310 })
311 .await?;
312 let run_id = response
313 .run_id
314 .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
315 .try_into()
316 .map_err(ClientError::from_wire_error)?;
317 let status = ProtoWorkflowStatus::try_from(response.status)
318 .map_err(|_error| ClientError::server("reopen response status is unknown"))
319 .and_then(|status| {
320 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
321 })?;
322 Ok(ReopenOutcome { run_id, status })
323 }
324
325 pub async fn pause(
335 &self,
336 workflow_id: &WorkflowId,
337 run_id: Option<&RunId>,
338 reason: impl Into<String>,
339 ) -> Result<PauseOutcome, ClientError> {
340 let response = self
341 .transport
342 .pause(ProtoPauseRequest {
343 namespace: self.namespace().to_owned(),
344 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
345 run_id: run_id.cloned().map(ProtoRunId::from),
346 reason: reason.into(),
347 })
348 .await?;
349 let run_id = response
350 .run_id
351 .ok_or_else(|| ClientError::server("pause response run id is missing"))?
352 .try_into()
353 .map_err(ClientError::from_wire_error)?;
354 let status = ProtoWorkflowStatus::try_from(response.status)
355 .map_err(|_error| ClientError::server("pause response status is unknown"))
356 .and_then(|status| {
357 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
358 })?;
359 Ok(PauseOutcome { run_id, status })
360 }
361
362 pub async fn resume(
372 &self,
373 workflow_id: &WorkflowId,
374 run_id: Option<&RunId>,
375 ) -> Result<ResumeOutcome, ClientError> {
376 let response = self
377 .transport
378 .resume(ProtoResumeRequest {
379 namespace: self.namespace().to_owned(),
380 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
381 run_id: run_id.cloned().map(ProtoRunId::from),
382 })
383 .await?;
384 let run_id = response
385 .run_id
386 .ok_or_else(|| ClientError::server("resume response run id is missing"))?
387 .try_into()
388 .map_err(ClientError::from_wire_error)?;
389 let status = ProtoWorkflowStatus::try_from(response.status)
390 .map_err(|_error| ClientError::server("resume response status is unknown"))
391 .and_then(|status| {
392 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
393 })?;
394 Ok(ResumeOutcome { run_id, status })
395 }
396
397 pub async fn list(&self, request: ListRequest) -> Result<WorkflowListPage, ClientError> {
408 let namespace = self.namespace().to_owned();
409 let contract = WorkflowListRequest {
410 namespace: namespace.clone(),
411 filter: request.filter,
412 sort: request.sort,
413 cursor: request.cursor,
414 limit: request.limit,
415 };
416 let envelope = encode_core_value(namespace.clone(), request.request_id, &contract)
417 .map_err(ClientError::from_wire_error)?;
418 let response = self
419 .transport
420 .list_workflows(ProtoListWorkflowsRequest {
421 namespace,
422 request: Some(envelope),
423 })
424 .await?;
425 let page = response
426 .page
427 .ok_or_else(|| ClientError::server("list response carries no page"))?;
428 decode_core_value(&page).map_err(ClientError::from_wire_error)
429 }
430
431 pub async fn describe(
437 &self,
438 workflow_id: &WorkflowId,
439 run_id: Option<&RunId>,
440 ) -> Result<WorkflowDescription, ClientError> {
441 let response = self
442 .transport
443 .describe_workflow(ProtoDescribeWorkflowRequest {
444 namespace: self.namespace().to_owned(),
445 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
446 run_id: run_id.cloned().map(ProtoRunId::from),
447 include_history: false,
448 })
449 .await?;
450 let summary = response
451 .summary
452 .as_ref()
453 .ok_or_else(|| ClientError::server("describe response summary is missing"))
454 .and_then(|summary| {
455 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
456 })?;
457 let run_id = response
458 .run_id
459 .ok_or_else(|| ClientError::server("describe response run_id is missing"))?
460 .try_into()
461 .map_err(ClientError::from_wire_error)?;
462 let terminal_event = response
463 .terminal_event
464 .as_ref()
465 .map(decode_event)
466 .transpose()
467 .map_err(ClientError::from_wire_error)?;
468 Ok(WorkflowDescription {
469 summary,
470 run_id,
471 history_head_seq: response.history_head_seq,
472 terminal_event,
473 provenance: aion_proto::decode_read_provenance(response.provenance),
474 lease_recording: aion_proto::decode_lease_recording(response.lease_recording),
475 })
476 }
477
478 pub async fn read_history(
484 &self,
485 workflow_id: &WorkflowId,
486 from_seq: Option<u64>,
487 limit: Option<u32>,
488 ) -> Result<HistoryPage, ClientError> {
489 let response = self
490 .transport
491 .read_history(ProtoReadHistoryRequest {
492 namespace: self.namespace().to_owned(),
493 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
494 from_seq,
495 limit,
496 })
497 .await?;
498 let events = response
499 .events
500 .iter()
501 .map(decode_event)
502 .map(|result| result.map_err(ClientError::from_wire_error))
503 .collect::<Result<Vec<_>, _>>()?;
504 Ok(HistoryPage {
505 events,
506 next_from_seq: response.next_from_seq,
507 head_seq: response.head_seq,
508 })
509 }
510
511 #[must_use]
513 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
514 event_stream(
515 self.transport.clone(),
516 self.namespace().to_owned(),
517 SubscribeTarget::Workflow {
518 workflow_id: workflow_id.clone(),
519 },
520 )
521 }
522
523 #[must_use]
530 pub fn subscribe_workflow_from(
531 &self,
532 workflow_id: &WorkflowId,
533 resume_from: NonZeroU64,
534 ) -> EventStream {
535 event_stream_from(
536 self.transport.clone(),
537 self.namespace().to_owned(),
538 workflow_id.clone(),
539 resume_from,
540 )
541 }
542
543 #[must_use]
545 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
546 event_stream(
547 self.transport.clone(),
548 self.namespace().to_owned(),
549 SubscribeTarget::Filtered { filter },
550 )
551 }
552
553 #[must_use]
555 pub fn subscribe_firehose(&self) -> EventStream {
556 event_stream(
557 self.transport.clone(),
558 self.namespace().to_owned(),
559 SubscribeTarget::Firehose,
560 )
561 }
562}
563
564pub(crate) fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
565 namespace.unwrap_or_else(|| client.namespace().to_owned())
566}
567
568fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
574where
575 T: Serialize + ?Sized,
576{
577 to_payload(args)
578}
579
580pub(crate) fn decode_required_workflow_id(
581 value: Option<ProtoWorkflowId>,
582 context: &str,
583) -> Result<WorkflowId, ClientError> {
584 value
585 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
586 .try_into()
587 .map_err(ClientError::from_wire_error)
588}
589
590pub(crate) fn decode_required_run_id(
591 value: Option<ProtoRunId>,
592 context: &str,
593) -> Result<RunId, ClientError> {
594 value
595 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
596 .try_into()
597 .map_err(ClientError::from_wire_error)
598}
599
600fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
606 ClientError::from_proto_wire_error(error)
607}
608
609#[cfg(test)]
610mod tests {
611 use std::sync::Arc;
612 use std::time::Duration;
613
614 use aion_core::{
615 ContentType, Payload, WorkflowId, WorkflowListFilter, WorkflowListPage, WorkflowSort,
616 WorkflowStatus,
617 };
618 use aion_proto::{
619 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
620 ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
621 ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
622 encode_core_value, encode_workflow_summary, proto_query_response,
623 };
624 use async_trait::async_trait;
625 use chrono::Utc;
626 use futures::StreamExt;
627 use futures::stream;
628 use tokio::sync::Mutex;
629
630 use super::ListRequest;
631 use crate::client::{Client, ClientBuilder, ClientConfig};
632 use crate::error::ClientError;
633 use crate::start::{DisplayNameNotApplied, StartOptions};
634 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
635
636 #[derive(Default)]
637 struct StubTransport {
638 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
639 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
640 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
641 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
642 last_retire: Mutex<Option<aion_proto::ProtoRetireWorkloopRequest>>,
643 last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
644 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
645 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
646 start_error: Mutex<Option<ClientError>>,
647 signal_error: Mutex<Option<ClientError>>,
648 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
649 reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
650 start_calls: std::sync::atomic::AtomicUsize,
654 }
655
656 #[async_trait]
657 impl WorkflowTransport for StubTransport {
658 async fn start_workflow(
659 &self,
660 request: aion_proto::ProtoStartWorkflowRequest,
661 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
662 self.start_calls
663 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
664 *self.last_start.lock().await = Some(request);
665 if let Some(error) = self.start_error.lock().await.take() {
666 return Err(error);
667 }
668 Ok(ProtoStartWorkflowResponse {
669 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
670 run_id: Some(ProtoRunId::from(run_id())),
671 })
672 }
673
674 async fn signal(
675 &self,
676 request: aion_proto::ProtoSignalRequest,
677 ) -> Result<ProtoSignalResponse, ClientError> {
678 *self.last_signal.lock().await = Some(request);
679 if let Some(error) = self.signal_error.lock().await.take() {
680 return Err(error);
681 }
682 Ok(ProtoSignalResponse {})
683 }
684
685 async fn query(
686 &self,
687 request: aion_proto::ProtoQueryRequest,
688 ) -> Result<ProtoQueryResponse, ClientError> {
689 *self.last_query.lock().await = Some(request);
690 if let Some(response) = self.query_response.lock().await.take() {
691 return response;
692 }
693 Ok(ProtoQueryResponse {
694 outcome: Some(proto_query_response::Outcome::Result(
695 aion_proto::ProtoPayload::from(payload("result")),
696 )),
697 })
698 }
699
700 async fn cancel(
701 &self,
702 request: aion_proto::ProtoCancelRequest,
703 ) -> Result<ProtoCancelResponse, ClientError> {
704 *self.last_cancel.lock().await = Some(request);
705 Ok(ProtoCancelResponse {})
706 }
707
708 async fn retire_workloop(
709 &self,
710 request: aion_proto::ProtoRetireWorkloopRequest,
711 ) -> Result<aion_proto::ProtoRetireWorkloopResponse, ClientError> {
712 let reason = request.reason.clone();
713 *self.last_retire.lock().await = Some(request);
714 Ok(aion_proto::ProtoRetireWorkloopResponse { reason })
715 }
716
717 async fn reopen(
718 &self,
719 request: aion_proto::ProtoReopenRequest,
720 ) -> Result<ProtoReopenResponse, ClientError> {
721 *self.last_reopen.lock().await = Some(request);
722 if let Some(response) = self.reopen_response.lock().await.take() {
723 return response;
724 }
725 Ok(ProtoReopenResponse {
726 run_id: Some(ProtoRunId::from(run_id())),
727 status: ProtoWorkflowStatus::Running as i32,
728 })
729 }
730
731 async fn pause(
732 &self,
733 _request: aion_proto::ProtoPauseRequest,
734 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
735 Ok(aion_proto::ProtoPauseResponse {
736 run_id: Some(ProtoRunId::from(run_id())),
737 status: ProtoWorkflowStatus::Paused as i32,
738 })
739 }
740
741 async fn resume(
742 &self,
743 _request: aion_proto::ProtoResumeRequest,
744 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
745 Ok(aion_proto::ProtoResumeResponse {
746 run_id: Some(ProtoRunId::from(run_id())),
747 status: ProtoWorkflowStatus::Running as i32,
748 })
749 }
750
751 async fn list_workflows(
752 &self,
753 request: aion_proto::ProtoListWorkflowsRequest,
754 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
755 *self.last_list.lock().await = Some(request);
756 let page = WorkflowListPage {
757 items: vec![summary()],
758 next_cursor: None,
759 count: 1,
760 provenance: None,
761 };
762 Ok(ProtoListWorkflowsResponse {
763 page: Some(
764 encode_core_value("tenant-a", None, &page)
765 .map_err(ClientError::from_wire_error)?,
766 ),
767 })
768 }
769
770 async fn describe_workflow(
771 &self,
772 request: aion_proto::ProtoDescribeWorkflowRequest,
773 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
774 *self.last_describe.lock().await = Some(request);
775 Ok(ProtoDescribeWorkflowResponse {
776 summary: Some(
777 encode_workflow_summary("tenant-a", None, &summary())
778 .map_err(ClientError::from_wire_error)?,
779 ),
780 history: Vec::new(),
781 run_id: Some(ProtoRunId::from(run_id())),
782 history_head_seq: 0,
783 terminal_event: None,
784 provenance: Some(aion_proto::ProtoReadProvenance::default()),
785 lease_recording: None,
786 })
787 }
788
789 async fn read_history(
790 &self,
791 _: aion_proto::ProtoReadHistoryRequest,
792 ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
793 Ok(aion_proto::ProtoReadHistoryResponse {
794 events: Vec::new(),
795 next_from_seq: None,
796 head_seq: 0,
797 })
798 }
799
800 async fn subscribe(
801 &self,
802 _: aion_proto::SubscriptionRequest,
803 _: Option<u64>,
804 ) -> Result<SubscriptionAttempt, ClientError> {
805 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
806 }
807 }
808
809 fn client_with(stub: Arc<StubTransport>) -> Client {
810 Client::from_transport(
811 ClientConfig::from(
812 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
813 ),
814 stub,
815 )
816 }
817
818 fn workflow_id() -> WorkflowId {
819 WorkflowId::new_v4()
820 }
821
822 fn run_id() -> aion_core::RunId {
823 aion_core::RunId::new(uuid::Uuid::from_u128(1))
824 }
825
826 fn payload(label: &str) -> Payload {
827 Payload::new(
828 ContentType::Json,
829 format!("{{\"label\":\"{label}\"}}").into_bytes(),
830 )
831 }
832
833 fn summary() -> aion_core::WorkflowSummary {
834 aion_core::WorkflowSummary {
835 workflow_id: workflow_id(),
836 run_id: run_id(),
837 workflow_type: String::from("checkout"),
838 status: WorkflowStatus::Running,
839 started_at: Utc::now(),
840 updated_at: Utc::now(),
841 ended_at: None,
842 parent: None,
843 failed_step: None,
844 failure_reason: None,
845 display_name: None,
846 kind: None,
847 current_worker: None,
848 package_version: None,
849 }
850 }
851
852 #[tokio::test]
853 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
854 let stub = Arc::new(StubTransport::default());
855 let client = client_with(Arc::clone(&stub));
856
857 let result = client
858 .start("checkout", payload("input"), StartOptions::default())
859 .await?;
860
861 let recorded = stub.last_start.lock().await.clone();
862 assert!(recorded.is_some());
863 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
864 assert_eq!(request.namespace, "tenant-a");
865 assert_eq!(request.workflow_type, "checkout");
866 assert!(request.input.is_some());
867 assert_ne!(
868 result.handle.workflow_id(),
869 &WorkflowId::new(uuid::Uuid::nil())
870 );
871 assert_eq!(result.display_name_not_applied, None);
872 Ok(())
873 }
874
875 #[tokio::test]
876 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
877 {
878 let stub = Arc::new(StubTransport::default());
879 let client = client_with(Arc::clone(&stub));
880 let opts = StartOptions {
881 namespace: None,
882 idempotency_key: Some(String::from("retry-key")),
883 routing_key: None,
884 task_queue: None,
885 display_name: None,
886 };
887
888 let original = client
889 .start("checkout", payload("input"), opts.clone())
890 .await?;
891 let replayed = client
892 .start("checkout", payload("input"), opts.clone())
893 .await?;
894 let conflict = client.start("checkout", payload("other"), opts).await;
895
896 assert_eq!(replayed, original);
897 assert!(
898 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
899 "got {conflict:?}"
900 );
901 Ok(())
902 }
903
904 #[tokio::test]
910 async fn a_replay_with_a_different_name_dedupes_and_reports_the_unapplied_name()
911 -> Result<(), ClientError> {
912 let stub = Arc::new(StubTransport::default());
913 let client = client_with(Arc::clone(&stub));
914 let named = |name: Option<&str>| StartOptions {
915 idempotency_key: Some(String::from("retry-key")),
916 display_name: name.map(str::to_owned),
917 ..StartOptions::default()
918 };
919
920 let first = client
921 .start(
922 "checkout",
923 payload("input"),
924 named(Some("Nightly settlement")),
925 )
926 .await?;
927 assert_eq!(
928 first.display_name_not_applied, None,
929 "a first start applies the name it asked for"
930 );
931
932 let replayed = client
934 .start("checkout", payload("input"), named(Some("Something else")))
935 .await?;
936 assert_eq!(
937 replayed.handle, first.handle,
938 "the label carries no identity, so the act deduped to one run"
939 );
940 assert_eq!(
941 replayed.display_name_not_applied,
942 Some(DisplayNameNotApplied {
943 requested: String::from("Something else"),
944 standing: Some(String::from("Nightly settlement")),
945 }),
946 "the name that was NOT applied must be reported, never dropped in silence"
947 );
948
949 let replayed = client
953 .start("checkout", payload("input"), named(None))
954 .await?;
955 assert_eq!(
956 replayed.display_name_not_applied, None,
957 "a caller that requested no name had no name dropped"
958 );
959
960 let replayed = client
962 .start(
963 "checkout",
964 payload("input"),
965 named(Some("Nightly settlement")),
966 )
967 .await?;
968 assert_eq!(replayed.display_name_not_applied, None);
969
970 let replayed = client
973 .start(
974 "checkout",
975 payload("input"),
976 named(Some(" Nightly settlement ")),
977 )
978 .await?;
979 assert_eq!(
980 replayed.display_name_not_applied, None,
981 "whitespace the server would have erased is not an unapplied name"
982 );
983
984 assert_eq!(
986 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
987 1
988 );
989 Ok(())
990 }
991
992 #[tokio::test]
1002 async fn the_unapplied_name_report_fires_only_for_a_replay_that_asked_for_a_name()
1003 -> Result<(), ClientError> {
1004 struct Case {
1008 label: &'static str,
1009 standing: Option<&'static str>,
1010 requested: Option<&'static str>,
1011 expected: Option<DisplayNameNotApplied>,
1012 }
1013
1014 let cases = [
1015 Case {
1016 label: "requested none, standing none",
1017 standing: None,
1018 requested: None,
1019 expected: None,
1020 },
1021 Case {
1022 label: "requested none, standing some",
1023 standing: Some("Nightly"),
1024 requested: None,
1025 expected: None,
1026 },
1027 Case {
1028 label: "requested some, standing none",
1029 standing: None,
1030 requested: Some("Nightly"),
1031 expected: Some(DisplayNameNotApplied {
1032 requested: String::from("Nightly"),
1033 standing: None,
1034 }),
1035 },
1036 Case {
1037 label: "requested some, standing a different some",
1038 standing: Some("Nightly"),
1039 requested: Some("Weekly"),
1040 expected: Some(DisplayNameNotApplied {
1041 requested: String::from("Weekly"),
1042 standing: Some(String::from("Nightly")),
1043 }),
1044 },
1045 Case {
1046 label: "requested some, standing the same some",
1047 standing: Some("Nightly"),
1048 requested: Some("Nightly"),
1049 expected: None,
1050 },
1051 ];
1052
1053 for Case {
1054 label,
1055 standing,
1056 requested,
1057 expected,
1058 } in cases
1059 {
1060 let stub = Arc::new(StubTransport::default());
1061 let client = client_with(Arc::clone(&stub));
1062 let named = |name: Option<&str>| StartOptions {
1063 idempotency_key: Some(String::from("retry-key")),
1064 display_name: name.map(str::to_owned),
1065 ..StartOptions::default()
1066 };
1067
1068 let first = client
1069 .start("checkout", payload("input"), named(standing))
1070 .await?;
1071 assert_eq!(
1072 first.display_name_not_applied, None,
1073 "{label}: a first start applies the name it asked for"
1074 );
1075
1076 let replayed = client
1077 .start("checkout", payload("input"), named(requested))
1078 .await?;
1079 assert_eq!(
1080 replayed.handle, first.handle,
1081 "{label}: the name carries no identity, so the act deduped to one run"
1082 );
1083 assert_eq!(
1084 replayed.display_name_not_applied, expected,
1085 "{label}: the report must fire exactly when a REQUESTED name was not applied"
1086 );
1087 assert_eq!(
1088 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1089 1,
1090 "{label}: the replay must not reach the transport"
1091 );
1092 }
1093 Ok(())
1094 }
1095
1096 #[tokio::test]
1102 async fn a_blank_display_name_is_refused() -> Result<(), ClientError> {
1103 let stub = Arc::new(StubTransport::default());
1104 let client = client_with(Arc::clone(&stub));
1105
1106 for blank in ["", " ", "\t\n "] {
1107 let result = client
1108 .start(
1109 "checkout",
1110 payload("input"),
1111 StartOptions {
1112 display_name: Some(String::from(blank)),
1113 ..StartOptions::default()
1114 },
1115 )
1116 .await;
1117 assert!(
1118 matches!(result, Err(ClientError::InvalidArgument { .. })),
1119 "blank {blank:?} must be refused, got {result:?}"
1120 );
1121 }
1122 assert_eq!(
1123 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1124 0
1125 );
1126 Ok(())
1127 }
1128
1129 #[tokio::test]
1130 async fn start_idempotency_treats_a_changed_route_as_a_different_request()
1131 -> Result<(), ClientError> {
1132 for (label, second) in [
1133 (
1134 "task_queue",
1135 StartOptions {
1136 task_queue: Some(String::from("payments")),
1137 ..StartOptions::default()
1138 },
1139 ),
1140 (
1141 "routing_key",
1142 StartOptions {
1143 routing_key: Some(String::from("tenant-7")),
1144 ..StartOptions::default()
1145 },
1146 ),
1147 ] {
1148 let stub = Arc::new(StubTransport::default());
1149 let client = client_with(Arc::clone(&stub));
1150 let key = Some(String::from("retry-key"));
1151 let first = StartOptions {
1152 idempotency_key: key.clone(),
1153 ..StartOptions::default()
1154 };
1155 let second = StartOptions {
1156 idempotency_key: key,
1157 ..second
1158 };
1159
1160 client.start("checkout", payload("input"), first).await?;
1161 let conflict = client.start("checkout", payload("input"), second).await;
1162
1163 assert!(
1164 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
1165 "reusing a key with a different {label} must conflict, got {conflict:?}"
1166 );
1167 }
1168 Ok(())
1169 }
1170
1171 #[tokio::test]
1172 async fn signal_maps_latest_run_and_error() {
1173 let stub = Arc::new(StubTransport::default());
1174 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
1175 let client = client_with(Arc::clone(&stub));
1176 let id = workflow_id();
1177
1178 let result = client.signal(&id, None, "approve", payload("signal")).await;
1179
1180 assert_eq!(
1181 result,
1182 Err(ClientError::not_found("workflow was not found"))
1183 );
1184 let recorded = stub.last_signal.lock().await.clone();
1185 assert!(recorded.is_some());
1186 let Some(request) = recorded else {
1187 return;
1188 };
1189 assert!(request.run_id.is_none());
1190 }
1191
1192 #[tokio::test]
1193 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1194 let stub = Arc::new(StubTransport::default());
1195 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1196 outcome: Some(proto_query_response::Outcome::Error(
1197 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1198 )),
1199 }));
1200 let client = client_with(Arc::clone(&stub));
1201 let id = workflow_id();
1202
1203 let result = client
1204 .query(
1205 &id,
1206 Some(&run_id()),
1207 "state",
1208 Payload::json_null(),
1209 Duration::from_secs(1),
1210 )
1211 .await;
1212
1213 assert_eq!(result, Err(ClientError::query_timeout("slow")));
1214 let recorded = stub.last_query.lock().await.clone();
1215 assert!(recorded.is_some());
1216 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1217 assert!(request.run_id.is_some());
1218 Ok(())
1219 }
1220
1221 #[tokio::test]
1222 async fn query_forwards_its_arguments_onto_the_wire() -> Result<(), ClientError> {
1223 let stub = Arc::new(StubTransport::default());
1224 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1225 outcome: Some(proto_query_response::Outcome::Result(
1226 aion_proto::ProtoPayload::from(payload("answer")),
1227 )),
1228 }));
1229 let client = client_with(Arc::clone(&stub));
1230
1231 let returned = client
1232 .query(
1233 &workflow_id(),
1234 Some(&run_id()),
1235 "state",
1236 payload("args"),
1237 Duration::from_secs(1),
1238 )
1239 .await?;
1240
1241 assert_eq!(returned, payload("answer"));
1242 let request = stub
1243 .last_query
1244 .lock()
1245 .await
1246 .clone()
1247 .ok_or_else(|| ClientError::server("missing query"))?;
1248 assert_eq!(
1251 request.arguments,
1252 Some(aion_proto::ProtoPayload::from(payload("args")))
1253 );
1254 Ok(())
1255 }
1256
1257 #[tokio::test]
1258 async fn a_no_argument_query_sends_the_canonical_null_document() -> Result<(), ClientError> {
1259 let stub = Arc::new(StubTransport::default());
1260 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1261 outcome: Some(proto_query_response::Outcome::Result(
1262 aion_proto::ProtoPayload::from(payload("answer")),
1263 )),
1264 }));
1265 let client = client_with(Arc::clone(&stub));
1266
1267 let _: serde_json::Value = client
1271 .query_typed(
1272 &workflow_id(),
1273 Some(&run_id()),
1274 "state",
1275 &(),
1276 Duration::from_secs(1),
1277 )
1278 .await?;
1279
1280 let request = stub
1281 .last_query
1282 .lock()
1283 .await
1284 .clone()
1285 .ok_or_else(|| ClientError::server("missing query"))?;
1286 assert_eq!(
1287 request.arguments,
1288 Some(aion_proto::ProtoPayload::from(Payload::json_null()))
1289 );
1290 Ok(())
1291 }
1292
1293 #[tokio::test]
1294 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1295 let stub = Arc::new(StubTransport::default());
1296 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1297 outcome: Some(proto_query_response::Outcome::Error(
1298 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1299 )),
1300 }));
1301 let client = client_with(Arc::clone(&stub));
1302
1303 let result = client
1304 .query(
1305 &workflow_id(),
1306 Some(&run_id()),
1307 "state",
1308 Payload::json_null(),
1309 Duration::from_secs(1),
1310 )
1311 .await;
1312
1313 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1314 Ok(())
1315 }
1316
1317 #[tokio::test]
1318 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1319 let stub = Arc::new(StubTransport::default());
1322 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1323 outcome: Some(proto_query_response::Outcome::Error(
1324 aion_proto::ProtoWireError::from(WireError::backend("store down")),
1325 )),
1326 }));
1327 let client = client_with(Arc::clone(&stub));
1328
1329 let result = client
1330 .query(
1331 &workflow_id(),
1332 Some(&run_id()),
1333 "state",
1334 Payload::json_null(),
1335 Duration::from_secs(1),
1336 )
1337 .await;
1338
1339 assert_eq!(result, Err(ClientError::server("store down")));
1340 Ok(())
1341 }
1342
1343 #[tokio::test]
1344 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1345 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1346 struct QueryResult {
1347 label: String,
1348 }
1349
1350 let stub = Arc::new(StubTransport::default());
1351 let client = client_with(Arc::clone(&stub));
1352 let id = workflow_id();
1353
1354 let result: QueryResult = client
1355 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1356 .await?;
1357
1358 assert_eq!(
1359 result,
1360 QueryResult {
1361 label: String::from("result")
1362 }
1363 );
1364 assert!(stub.last_query.lock().await.is_some());
1365 Ok(())
1366 }
1367
1368 #[tokio::test]
1378 async fn query_typed_forwards_non_empty_args_without_silent_drop() -> Result<(), ClientError> {
1379 let stub = Arc::new(StubTransport::default());
1380 let client = client_with(Arc::clone(&stub));
1381 let id = workflow_id();
1382
1383 let _: serde_json::Value = client
1384 .query_typed(
1385 &id,
1386 Some(&run_id()),
1387 "state",
1388 &serde_json::json!({ "filter": "open" }),
1389 Duration::from_secs(1),
1390 )
1391 .await?;
1392
1393 let request = stub
1394 .last_query
1395 .lock()
1396 .await
1397 .clone()
1398 .ok_or_else(|| ClientError::server("missing query"))?;
1399 let arguments = request
1400 .arguments
1401 .ok_or_else(|| ClientError::server("query arguments were dropped"))?;
1402 assert_eq!(
1403 serde_json::from_slice::<serde_json::Value>(&arguments.bytes)
1404 .map_err(|error| ClientError::server(error.to_string()))?,
1405 serde_json::json!({ "filter": "open" })
1406 );
1407 Ok(())
1408 }
1409
1410 #[tokio::test]
1411 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1412 let stub = Arc::new(StubTransport::default());
1413 let client = client_with(Arc::clone(&stub));
1414 let id = workflow_id();
1415 let run = run_id();
1416
1417 client.cancel(&id, Some(&run), "not needed").await?;
1418 let listed = client
1419 .list(ListRequest {
1420 filter: WorkflowListFilter::default(),
1421 sort: WorkflowSort {
1422 field: aion_core::WorkflowSortField::StartedAt,
1423 direction: aion_core::SortDirection::Desc,
1424 },
1425 cursor: None,
1426 limit: 10,
1427 request_id: None,
1428 })
1429 .await?;
1430 let described = client.describe(&id, None).await?;
1431
1432 assert!(stub.last_cancel.lock().await.is_some());
1433 assert!(stub.last_list.lock().await.is_some());
1434 let describe = stub
1435 .last_describe
1436 .lock()
1437 .await
1438 .clone()
1439 .ok_or_else(|| ClientError::server("missing describe"))?;
1440 assert!(describe.run_id.is_none());
1441 assert!(!describe.include_history);
1442 assert_eq!(listed.items.len(), 1);
1443 assert_eq!(listed.count, 1);
1444 assert_eq!(described.run_id, run);
1445 assert_eq!(described.history_head_seq, 0);
1446 assert!(described.terminal_event.is_none());
1447 Ok(())
1448 }
1449
1450 #[tokio::test]
1451 async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1452 let stub = Arc::new(StubTransport::default());
1453 let client = client_with(Arc::clone(&stub));
1454 let id = workflow_id();
1455 let run = run_id();
1456
1457 let outcome = client.reopen(&id, Some(&run)).await?;
1458
1459 assert_eq!(outcome.status, WorkflowStatus::Running);
1460 let request = stub
1461 .last_reopen
1462 .lock()
1463 .await
1464 .clone()
1465 .ok_or_else(|| ClientError::server("missing reopen"))?;
1466 assert_eq!(request.namespace, "tenant-a");
1467 assert!(request.run_id.is_some());
1468 Ok(())
1469 }
1470
1471 #[tokio::test]
1474 async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1475 let stub = Arc::new(StubTransport::default());
1476 *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1477 WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1478 )));
1479 let client = client_with(Arc::clone(&stub));
1480
1481 let result = client.reopen(&workflow_id(), None).await;
1482
1483 assert!(
1484 matches!(result, Err(ClientError::InvalidState { .. })),
1485 "got {result:?}"
1486 );
1487 Ok(())
1488 }
1489}