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 reopen(
249 &self,
250 workflow_id: &WorkflowId,
251 run_id: Option<&RunId>,
252 ) -> Result<ReopenOutcome, ClientError> {
253 let response = self
254 .transport
255 .reopen(ProtoReopenRequest {
256 namespace: self.namespace().to_owned(),
257 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
258 run_id: run_id.cloned().map(ProtoRunId::from),
259 })
260 .await?;
261 let run_id = response
262 .run_id
263 .ok_or_else(|| ClientError::server("reopen response run id is missing"))?
264 .try_into()
265 .map_err(ClientError::from_wire_error)?;
266 let status = ProtoWorkflowStatus::try_from(response.status)
267 .map_err(|_error| ClientError::server("reopen response status is unknown"))
268 .and_then(|status| {
269 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
270 })?;
271 Ok(ReopenOutcome { run_id, status })
272 }
273
274 pub async fn pause(
284 &self,
285 workflow_id: &WorkflowId,
286 run_id: Option<&RunId>,
287 reason: impl Into<String>,
288 ) -> Result<PauseOutcome, ClientError> {
289 let response = self
290 .transport
291 .pause(ProtoPauseRequest {
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 reason: reason.into(),
296 })
297 .await?;
298 let run_id = response
299 .run_id
300 .ok_or_else(|| ClientError::server("pause response run id is missing"))?
301 .try_into()
302 .map_err(ClientError::from_wire_error)?;
303 let status = ProtoWorkflowStatus::try_from(response.status)
304 .map_err(|_error| ClientError::server("pause response status is unknown"))
305 .and_then(|status| {
306 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
307 })?;
308 Ok(PauseOutcome { run_id, status })
309 }
310
311 pub async fn resume(
321 &self,
322 workflow_id: &WorkflowId,
323 run_id: Option<&RunId>,
324 ) -> Result<ResumeOutcome, ClientError> {
325 let response = self
326 .transport
327 .resume(ProtoResumeRequest {
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 })
332 .await?;
333 let run_id = response
334 .run_id
335 .ok_or_else(|| ClientError::server("resume response run id is missing"))?
336 .try_into()
337 .map_err(ClientError::from_wire_error)?;
338 let status = ProtoWorkflowStatus::try_from(response.status)
339 .map_err(|_error| ClientError::server("resume response status is unknown"))
340 .and_then(|status| {
341 WorkflowStatus::try_from(status).map_err(ClientError::from_wire_error)
342 })?;
343 Ok(ResumeOutcome { run_id, status })
344 }
345
346 pub async fn list(
352 &self,
353 filter: &WorkflowFilter,
354 page: ListPage,
355 ) -> Result<Vec<WorkflowSummary>, ClientError> {
356 validate_list_page(&page)?;
357 let namespace = self.namespace().to_owned();
358 let filter = workflow_filter_to_visibility(filter)?;
359 let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
360 .map_err(ClientError::from_wire_error)?;
361 let response = self
362 .transport
363 .list_workflows(ProtoListWorkflowsRequest {
364 namespace,
365 filter: Some(filter),
366 })
367 .await?;
368
369 response
370 .summaries
371 .iter()
372 .map(decode_visibility_summary)
373 .map(|result| result.map_err(ClientError::from_wire_error))
374 .collect()
375 }
376
377 pub async fn describe(
383 &self,
384 workflow_id: &WorkflowId,
385 run_id: Option<&RunId>,
386 ) -> Result<WorkflowDescription, ClientError> {
387 let response = self
388 .transport
389 .describe_workflow(ProtoDescribeWorkflowRequest {
390 namespace: self.namespace().to_owned(),
391 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
392 run_id: run_id.cloned().map(ProtoRunId::from),
393 include_history: false,
394 })
395 .await?;
396 let summary = response
397 .summary
398 .as_ref()
399 .ok_or_else(|| ClientError::server("describe response summary is missing"))
400 .and_then(|summary| {
401 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
402 })?;
403 let run_id = response
404 .run_id
405 .ok_or_else(|| ClientError::server("describe response run_id is missing"))?
406 .try_into()
407 .map_err(ClientError::from_wire_error)?;
408 let terminal_event = response
409 .terminal_event
410 .as_ref()
411 .map(decode_event)
412 .transpose()
413 .map_err(ClientError::from_wire_error)?;
414 Ok(WorkflowDescription {
415 summary,
416 run_id,
417 history_head_seq: response.history_head_seq,
418 terminal_event,
419 })
420 }
421
422 pub async fn read_history(
428 &self,
429 workflow_id: &WorkflowId,
430 from_seq: Option<u64>,
431 limit: Option<u32>,
432 ) -> Result<HistoryPage, ClientError> {
433 let response = self
434 .transport
435 .read_history(ProtoReadHistoryRequest {
436 namespace: self.namespace().to_owned(),
437 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
438 from_seq,
439 limit,
440 })
441 .await?;
442 let events = response
443 .events
444 .iter()
445 .map(decode_event)
446 .map(|result| result.map_err(ClientError::from_wire_error))
447 .collect::<Result<Vec<_>, _>>()?;
448 Ok(HistoryPage {
449 events,
450 next_from_seq: response.next_from_seq,
451 head_seq: response.head_seq,
452 })
453 }
454
455 #[must_use]
457 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
458 event_stream(
459 self.transport.clone(),
460 self.namespace().to_owned(),
461 SubscribeTarget::Workflow {
462 workflow_id: workflow_id.clone(),
463 },
464 )
465 }
466
467 #[must_use]
474 pub fn subscribe_workflow_from(
475 &self,
476 workflow_id: &WorkflowId,
477 resume_from: NonZeroU64,
478 ) -> EventStream {
479 event_stream_from(
480 self.transport.clone(),
481 self.namespace().to_owned(),
482 workflow_id.clone(),
483 resume_from,
484 )
485 }
486
487 #[must_use]
489 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
490 event_stream(
491 self.transport.clone(),
492 self.namespace().to_owned(),
493 SubscribeTarget::Filtered { filter },
494 )
495 }
496
497 #[must_use]
499 pub fn subscribe_firehose(&self) -> EventStream {
500 event_stream(
501 self.transport.clone(),
502 self.namespace().to_owned(),
503 SubscribeTarget::Firehose,
504 )
505 }
506}
507
508pub(crate) fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
509 namespace.unwrap_or_else(|| client.namespace().to_owned())
510}
511
512fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
518where
519 T: Serialize + ?Sized,
520{
521 to_payload(args)
522}
523
524fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
525 if page.limit.is_some() || page.cursor.is_some() {
526 return Err(ClientError::invalid_argument(
527 "list pagination limit/cursor are reserved by the contract and \
528 not yet carried by the wire",
529 ));
530 }
531 Ok(())
532}
533
534fn workflow_filter_to_visibility(
535 filter: &WorkflowFilter,
536) -> Result<ListWorkflowsFilter, ClientError> {
537 if filter.parent.is_some() {
538 return Err(ClientError::invalid_argument(
539 "parent workflow filters are not carried by the visibility wire contract",
540 ));
541 }
542
543 Ok(ListWorkflowsFilter {
544 workflow_type: filter.workflow_type.clone(),
545 status: filter.status,
546 started_after: filter.started_after,
547 started_before: filter.started_before,
548 ..ListWorkflowsFilter::default()
549 })
550}
551
552fn decode_visibility_summary(
553 envelope: &aion_proto::WireEnvelope,
554) -> Result<WorkflowSummary, WireError> {
555 let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
556 Ok(WorkflowSummary {
557 workflow_id: summary.workflow_id,
558 workflow_type: summary.workflow_type,
559 status: summary.status,
560 started_at: summary.start_time,
561 ended_at: summary.close_time,
562 parent: None,
563 failed_step: summary.failed_step,
564 failure_reason: summary.failure_reason,
565 display_name: aion_core::display_name_from_attributes(&summary.search_attributes),
566 })
567}
568
569pub(crate) fn decode_required_workflow_id(
570 value: Option<ProtoWorkflowId>,
571 context: &str,
572) -> Result<WorkflowId, ClientError> {
573 value
574 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
575 .try_into()
576 .map_err(ClientError::from_wire_error)
577}
578
579pub(crate) fn decode_required_run_id(
580 value: Option<ProtoRunId>,
581 context: &str,
582) -> Result<RunId, ClientError> {
583 value
584 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
585 .try_into()
586 .map_err(ClientError::from_wire_error)
587}
588
589fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
595 ClientError::from_proto_wire_error(error)
596}
597
598#[cfg(test)]
599mod tests {
600 use std::sync::Arc;
601 use std::time::Duration;
602
603 use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
604 use aion_proto::{
605 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
606 ProtoQueryResponse, ProtoReopenResponse, ProtoRunId, ProtoSignalResponse,
607 ProtoStartWorkflowResponse, ProtoWorkflowId, ProtoWorkflowStatus, WireError,
608 encode_core_value, encode_workflow_summary, proto_query_response,
609 };
610 use async_trait::async_trait;
611 use chrono::Utc;
612 use futures::StreamExt;
613 use futures::stream;
614 use tokio::sync::Mutex;
615
616 use super::ListPage;
617 use crate::client::{Client, ClientBuilder, ClientConfig};
618 use crate::error::ClientError;
619 use crate::start::{DisplayNameNotApplied, StartOptions};
620 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
621
622 #[derive(Default)]
623 struct StubTransport {
624 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
625 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
626 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
627 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
628 last_reopen: Mutex<Option<aion_proto::ProtoReopenRequest>>,
629 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
630 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
631 start_error: Mutex<Option<ClientError>>,
632 signal_error: Mutex<Option<ClientError>>,
633 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
634 reopen_response: Mutex<Option<Result<ProtoReopenResponse, ClientError>>>,
635 start_calls: std::sync::atomic::AtomicUsize,
639 }
640
641 #[async_trait]
642 impl WorkflowTransport for StubTransport {
643 async fn start_workflow(
644 &self,
645 request: aion_proto::ProtoStartWorkflowRequest,
646 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
647 self.start_calls
648 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
649 *self.last_start.lock().await = Some(request);
650 if let Some(error) = self.start_error.lock().await.take() {
651 return Err(error);
652 }
653 Ok(ProtoStartWorkflowResponse {
654 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
655 run_id: Some(ProtoRunId::from(run_id())),
656 })
657 }
658
659 async fn signal(
660 &self,
661 request: aion_proto::ProtoSignalRequest,
662 ) -> Result<ProtoSignalResponse, ClientError> {
663 *self.last_signal.lock().await = Some(request);
664 if let Some(error) = self.signal_error.lock().await.take() {
665 return Err(error);
666 }
667 Ok(ProtoSignalResponse {})
668 }
669
670 async fn query(
671 &self,
672 request: aion_proto::ProtoQueryRequest,
673 ) -> Result<ProtoQueryResponse, ClientError> {
674 *self.last_query.lock().await = Some(request);
675 if let Some(response) = self.query_response.lock().await.take() {
676 return response;
677 }
678 Ok(ProtoQueryResponse {
679 outcome: Some(proto_query_response::Outcome::Result(
680 aion_proto::ProtoPayload::from(payload("result")),
681 )),
682 })
683 }
684
685 async fn cancel(
686 &self,
687 request: aion_proto::ProtoCancelRequest,
688 ) -> Result<ProtoCancelResponse, ClientError> {
689 *self.last_cancel.lock().await = Some(request);
690 Ok(ProtoCancelResponse {})
691 }
692
693 async fn reopen(
694 &self,
695 request: aion_proto::ProtoReopenRequest,
696 ) -> Result<ProtoReopenResponse, ClientError> {
697 *self.last_reopen.lock().await = Some(request);
698 if let Some(response) = self.reopen_response.lock().await.take() {
699 return response;
700 }
701 Ok(ProtoReopenResponse {
702 run_id: Some(ProtoRunId::from(run_id())),
703 status: ProtoWorkflowStatus::Running as i32,
704 })
705 }
706
707 async fn pause(
708 &self,
709 _request: aion_proto::ProtoPauseRequest,
710 ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
711 Ok(aion_proto::ProtoPauseResponse {
712 run_id: Some(ProtoRunId::from(run_id())),
713 status: ProtoWorkflowStatus::Paused as i32,
714 })
715 }
716
717 async fn resume(
718 &self,
719 _request: aion_proto::ProtoResumeRequest,
720 ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
721 Ok(aion_proto::ProtoResumeResponse {
722 run_id: Some(ProtoRunId::from(run_id())),
723 status: ProtoWorkflowStatus::Running as i32,
724 })
725 }
726
727 async fn list_workflows(
728 &self,
729 request: aion_proto::ProtoListWorkflowsRequest,
730 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
731 *self.last_list.lock().await = Some(request);
732 Ok(ProtoListWorkflowsResponse {
733 summaries: vec![
734 encode_core_value("tenant-a", None, &visibility_summary())
735 .map_err(ClientError::from_wire_error)?,
736 ],
737 })
738 }
739
740 async fn describe_workflow(
741 &self,
742 request: aion_proto::ProtoDescribeWorkflowRequest,
743 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
744 *self.last_describe.lock().await = Some(request);
745 Ok(ProtoDescribeWorkflowResponse {
746 summary: Some(
747 encode_workflow_summary("tenant-a", None, &summary())
748 .map_err(ClientError::from_wire_error)?,
749 ),
750 history: Vec::new(),
751 run_id: Some(ProtoRunId::from(run_id())),
752 history_head_seq: 0,
753 terminal_event: None,
754 })
755 }
756
757 async fn read_history(
758 &self,
759 _: aion_proto::ProtoReadHistoryRequest,
760 ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
761 Ok(aion_proto::ProtoReadHistoryResponse {
762 events: Vec::new(),
763 next_from_seq: None,
764 head_seq: 0,
765 })
766 }
767
768 async fn subscribe(
769 &self,
770 _: aion_proto::SubscriptionRequest,
771 _: Option<u64>,
772 ) -> Result<SubscriptionAttempt, ClientError> {
773 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
774 }
775 }
776
777 fn client_with(stub: Arc<StubTransport>) -> Client {
778 Client::from_transport(
779 ClientConfig::from(
780 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
781 ),
782 stub,
783 )
784 }
785
786 fn workflow_id() -> WorkflowId {
787 WorkflowId::new_v4()
788 }
789
790 fn run_id() -> aion_core::RunId {
791 aion_core::RunId::new(uuid::Uuid::from_u128(1))
792 }
793
794 fn payload(label: &str) -> Payload {
795 Payload::new(
796 ContentType::Json,
797 format!("{{\"label\":\"{label}\"}}").into_bytes(),
798 )
799 }
800
801 fn summary() -> aion_core::WorkflowSummary {
802 aion_core::WorkflowSummary {
803 workflow_id: workflow_id(),
804 workflow_type: String::from("checkout"),
805 status: WorkflowStatus::Running,
806 started_at: Utc::now(),
807 ended_at: None,
808 parent: None,
809 failed_step: None,
810 failure_reason: None,
811 display_name: None,
812 }
813 }
814
815 fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
816 aion_store::visibility::WorkflowSummary {
817 workflow_id: workflow_id(),
818 run_id: run_id(),
819 workflow_type: String::from("checkout"),
820 status: WorkflowStatus::Running,
821 start_time: Utc::now(),
822 close_time: None,
823 failed_step: None,
824 failure_reason: None,
825 search_attributes: std::collections::HashMap::new(),
826 }
827 }
828
829 #[tokio::test]
830 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
831 let stub = Arc::new(StubTransport::default());
832 let client = client_with(Arc::clone(&stub));
833
834 let result = client
835 .start("checkout", payload("input"), StartOptions::default())
836 .await?;
837
838 let recorded = stub.last_start.lock().await.clone();
839 assert!(recorded.is_some());
840 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
841 assert_eq!(request.namespace, "tenant-a");
842 assert_eq!(request.workflow_type, "checkout");
843 assert!(request.input.is_some());
844 assert_ne!(
845 result.handle.workflow_id(),
846 &WorkflowId::new(uuid::Uuid::nil())
847 );
848 assert_eq!(result.display_name_not_applied, None);
849 Ok(())
850 }
851
852 #[tokio::test]
853 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
854 {
855 let stub = Arc::new(StubTransport::default());
856 let client = client_with(Arc::clone(&stub));
857 let opts = StartOptions {
858 namespace: None,
859 idempotency_key: Some(String::from("retry-key")),
860 routing_key: None,
861 task_queue: None,
862 display_name: None,
863 };
864
865 let original = client
866 .start("checkout", payload("input"), opts.clone())
867 .await?;
868 let replayed = client
869 .start("checkout", payload("input"), opts.clone())
870 .await?;
871 let conflict = client.start("checkout", payload("other"), opts).await;
872
873 assert_eq!(replayed, original);
874 assert!(
875 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
876 "got {conflict:?}"
877 );
878 Ok(())
879 }
880
881 #[tokio::test]
887 async fn a_replay_with_a_different_name_dedupes_and_reports_the_unapplied_name()
888 -> Result<(), ClientError> {
889 let stub = Arc::new(StubTransport::default());
890 let client = client_with(Arc::clone(&stub));
891 let named = |name: Option<&str>| StartOptions {
892 idempotency_key: Some(String::from("retry-key")),
893 display_name: name.map(str::to_owned),
894 ..StartOptions::default()
895 };
896
897 let first = client
898 .start(
899 "checkout",
900 payload("input"),
901 named(Some("Nightly settlement")),
902 )
903 .await?;
904 assert_eq!(
905 first.display_name_not_applied, None,
906 "a first start applies the name it asked for"
907 );
908
909 let replayed = client
911 .start("checkout", payload("input"), named(Some("Something else")))
912 .await?;
913 assert_eq!(
914 replayed.handle, first.handle,
915 "the label carries no identity, so the act deduped to one run"
916 );
917 assert_eq!(
918 replayed.display_name_not_applied,
919 Some(DisplayNameNotApplied {
920 requested: String::from("Something else"),
921 standing: Some(String::from("Nightly settlement")),
922 }),
923 "the name that was NOT applied must be reported, never dropped in silence"
924 );
925
926 let replayed = client
930 .start("checkout", payload("input"), named(None))
931 .await?;
932 assert_eq!(
933 replayed.display_name_not_applied, None,
934 "a caller that requested no name had no name dropped"
935 );
936
937 let replayed = client
939 .start(
940 "checkout",
941 payload("input"),
942 named(Some("Nightly settlement")),
943 )
944 .await?;
945 assert_eq!(replayed.display_name_not_applied, None);
946
947 let replayed = client
950 .start(
951 "checkout",
952 payload("input"),
953 named(Some(" Nightly settlement ")),
954 )
955 .await?;
956 assert_eq!(
957 replayed.display_name_not_applied, None,
958 "whitespace the server would have erased is not an unapplied name"
959 );
960
961 assert_eq!(
963 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
964 1
965 );
966 Ok(())
967 }
968
969 #[tokio::test]
979 async fn the_unapplied_name_report_fires_only_for_a_replay_that_asked_for_a_name()
980 -> Result<(), ClientError> {
981 struct Case {
985 label: &'static str,
986 standing: Option<&'static str>,
987 requested: Option<&'static str>,
988 expected: Option<DisplayNameNotApplied>,
989 }
990
991 let cases = [
992 Case {
993 label: "requested none, standing none",
994 standing: None,
995 requested: None,
996 expected: None,
997 },
998 Case {
999 label: "requested none, standing some",
1000 standing: Some("Nightly"),
1001 requested: None,
1002 expected: None,
1003 },
1004 Case {
1005 label: "requested some, standing none",
1006 standing: None,
1007 requested: Some("Nightly"),
1008 expected: Some(DisplayNameNotApplied {
1009 requested: String::from("Nightly"),
1010 standing: None,
1011 }),
1012 },
1013 Case {
1014 label: "requested some, standing a different some",
1015 standing: Some("Nightly"),
1016 requested: Some("Weekly"),
1017 expected: Some(DisplayNameNotApplied {
1018 requested: String::from("Weekly"),
1019 standing: Some(String::from("Nightly")),
1020 }),
1021 },
1022 Case {
1023 label: "requested some, standing the same some",
1024 standing: Some("Nightly"),
1025 requested: Some("Nightly"),
1026 expected: None,
1027 },
1028 ];
1029
1030 for Case {
1031 label,
1032 standing,
1033 requested,
1034 expected,
1035 } in cases
1036 {
1037 let stub = Arc::new(StubTransport::default());
1038 let client = client_with(Arc::clone(&stub));
1039 let named = |name: Option<&str>| StartOptions {
1040 idempotency_key: Some(String::from("retry-key")),
1041 display_name: name.map(str::to_owned),
1042 ..StartOptions::default()
1043 };
1044
1045 let first = client
1046 .start("checkout", payload("input"), named(standing))
1047 .await?;
1048 assert_eq!(
1049 first.display_name_not_applied, None,
1050 "{label}: a first start applies the name it asked for"
1051 );
1052
1053 let replayed = client
1054 .start("checkout", payload("input"), named(requested))
1055 .await?;
1056 assert_eq!(
1057 replayed.handle, first.handle,
1058 "{label}: the name carries no identity, so the act deduped to one run"
1059 );
1060 assert_eq!(
1061 replayed.display_name_not_applied, expected,
1062 "{label}: the report must fire exactly when a REQUESTED name was not applied"
1063 );
1064 assert_eq!(
1065 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1066 1,
1067 "{label}: the replay must not reach the transport"
1068 );
1069 }
1070 Ok(())
1071 }
1072
1073 #[tokio::test]
1079 async fn a_blank_display_name_is_refused() -> Result<(), ClientError> {
1080 let stub = Arc::new(StubTransport::default());
1081 let client = client_with(Arc::clone(&stub));
1082
1083 for blank in ["", " ", "\t\n "] {
1084 let result = client
1085 .start(
1086 "checkout",
1087 payload("input"),
1088 StartOptions {
1089 display_name: Some(String::from(blank)),
1090 ..StartOptions::default()
1091 },
1092 )
1093 .await;
1094 assert!(
1095 matches!(result, Err(ClientError::InvalidArgument { .. })),
1096 "blank {blank:?} must be refused, got {result:?}"
1097 );
1098 }
1099 assert_eq!(
1100 stub.start_calls.load(std::sync::atomic::Ordering::SeqCst),
1101 0
1102 );
1103 Ok(())
1104 }
1105
1106 #[tokio::test]
1107 async fn start_idempotency_treats_a_changed_route_as_a_different_request()
1108 -> Result<(), ClientError> {
1109 for (label, second) in [
1110 (
1111 "task_queue",
1112 StartOptions {
1113 task_queue: Some(String::from("payments")),
1114 ..StartOptions::default()
1115 },
1116 ),
1117 (
1118 "routing_key",
1119 StartOptions {
1120 routing_key: Some(String::from("tenant-7")),
1121 ..StartOptions::default()
1122 },
1123 ),
1124 ] {
1125 let stub = Arc::new(StubTransport::default());
1126 let client = client_with(Arc::clone(&stub));
1127 let key = Some(String::from("retry-key"));
1128 let first = StartOptions {
1129 idempotency_key: key.clone(),
1130 ..StartOptions::default()
1131 };
1132 let second = StartOptions {
1133 idempotency_key: key,
1134 ..second
1135 };
1136
1137 client.start("checkout", payload("input"), first).await?;
1138 let conflict = client.start("checkout", payload("input"), second).await;
1139
1140 assert!(
1141 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
1142 "reusing a key with a different {label} must conflict, got {conflict:?}"
1143 );
1144 }
1145 Ok(())
1146 }
1147
1148 #[tokio::test]
1149 async fn signal_maps_latest_run_and_error() {
1150 let stub = Arc::new(StubTransport::default());
1151 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
1152 let client = client_with(Arc::clone(&stub));
1153 let id = workflow_id();
1154
1155 let result = client.signal(&id, None, "approve", payload("signal")).await;
1156
1157 assert_eq!(
1158 result,
1159 Err(ClientError::not_found("workflow was not found"))
1160 );
1161 let recorded = stub.last_signal.lock().await.clone();
1162 assert!(recorded.is_some());
1163 let Some(request) = recorded else {
1164 return;
1165 };
1166 assert!(request.run_id.is_none());
1167 }
1168
1169 #[tokio::test]
1170 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
1171 let stub = Arc::new(StubTransport::default());
1172 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1173 outcome: Some(proto_query_response::Outcome::Error(
1174 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
1175 )),
1176 }));
1177 let client = client_with(Arc::clone(&stub));
1178 let id = workflow_id();
1179
1180 let result = client
1181 .query(
1182 &id,
1183 Some(&run_id()),
1184 "state",
1185 Payload::json_null(),
1186 Duration::from_secs(1),
1187 )
1188 .await;
1189
1190 assert_eq!(result, Err(ClientError::query_timeout("slow")));
1191 let recorded = stub.last_query.lock().await.clone();
1192 assert!(recorded.is_some());
1193 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
1194 assert!(request.run_id.is_some());
1195 Ok(())
1196 }
1197
1198 #[tokio::test]
1199 async fn query_forwards_its_arguments_onto_the_wire() -> Result<(), ClientError> {
1200 let stub = Arc::new(StubTransport::default());
1201 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1202 outcome: Some(proto_query_response::Outcome::Result(
1203 aion_proto::ProtoPayload::from(payload("answer")),
1204 )),
1205 }));
1206 let client = client_with(Arc::clone(&stub));
1207
1208 let returned = client
1209 .query(
1210 &workflow_id(),
1211 Some(&run_id()),
1212 "state",
1213 payload("args"),
1214 Duration::from_secs(1),
1215 )
1216 .await?;
1217
1218 assert_eq!(returned, payload("answer"));
1219 let request = stub
1220 .last_query
1221 .lock()
1222 .await
1223 .clone()
1224 .ok_or_else(|| ClientError::server("missing query"))?;
1225 assert_eq!(
1228 request.arguments,
1229 Some(aion_proto::ProtoPayload::from(payload("args")))
1230 );
1231 Ok(())
1232 }
1233
1234 #[tokio::test]
1235 async fn a_no_argument_query_sends_the_canonical_null_document() -> Result<(), ClientError> {
1236 let stub = Arc::new(StubTransport::default());
1237 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1238 outcome: Some(proto_query_response::Outcome::Result(
1239 aion_proto::ProtoPayload::from(payload("answer")),
1240 )),
1241 }));
1242 let client = client_with(Arc::clone(&stub));
1243
1244 let _: serde_json::Value = client
1248 .query_typed(
1249 &workflow_id(),
1250 Some(&run_id()),
1251 "state",
1252 &(),
1253 Duration::from_secs(1),
1254 )
1255 .await?;
1256
1257 let request = stub
1258 .last_query
1259 .lock()
1260 .await
1261 .clone()
1262 .ok_or_else(|| ClientError::server("missing query"))?;
1263 assert_eq!(
1264 request.arguments,
1265 Some(aion_proto::ProtoPayload::from(Payload::json_null()))
1266 );
1267 Ok(())
1268 }
1269
1270 #[tokio::test]
1271 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
1272 let stub = Arc::new(StubTransport::default());
1273 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1274 outcome: Some(proto_query_response::Outcome::Error(
1275 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
1276 )),
1277 }));
1278 let client = client_with(Arc::clone(&stub));
1279
1280 let result = client
1281 .query(
1282 &workflow_id(),
1283 Some(&run_id()),
1284 "state",
1285 Payload::json_null(),
1286 Duration::from_secs(1),
1287 )
1288 .await;
1289
1290 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
1291 Ok(())
1292 }
1293
1294 #[tokio::test]
1295 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
1296 let stub = Arc::new(StubTransport::default());
1299 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
1300 outcome: Some(proto_query_response::Outcome::Error(
1301 aion_proto::ProtoWireError::from(WireError::backend("store down")),
1302 )),
1303 }));
1304 let client = client_with(Arc::clone(&stub));
1305
1306 let result = client
1307 .query(
1308 &workflow_id(),
1309 Some(&run_id()),
1310 "state",
1311 Payload::json_null(),
1312 Duration::from_secs(1),
1313 )
1314 .await;
1315
1316 assert_eq!(result, Err(ClientError::server("store down")));
1317 Ok(())
1318 }
1319
1320 #[tokio::test]
1321 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
1322 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
1323 struct QueryResult {
1324 label: String,
1325 }
1326
1327 let stub = Arc::new(StubTransport::default());
1328 let client = client_with(Arc::clone(&stub));
1329 let id = workflow_id();
1330
1331 let result: QueryResult = client
1332 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
1333 .await?;
1334
1335 assert_eq!(
1336 result,
1337 QueryResult {
1338 label: String::from("result")
1339 }
1340 );
1341 assert!(stub.last_query.lock().await.is_some());
1342 Ok(())
1343 }
1344
1345 #[tokio::test]
1355 async fn query_typed_forwards_non_empty_args_without_silent_drop() -> Result<(), ClientError> {
1356 let stub = Arc::new(StubTransport::default());
1357 let client = client_with(Arc::clone(&stub));
1358 let id = workflow_id();
1359
1360 let _: serde_json::Value = client
1361 .query_typed(
1362 &id,
1363 Some(&run_id()),
1364 "state",
1365 &serde_json::json!({ "filter": "open" }),
1366 Duration::from_secs(1),
1367 )
1368 .await?;
1369
1370 let request = stub
1371 .last_query
1372 .lock()
1373 .await
1374 .clone()
1375 .ok_or_else(|| ClientError::server("missing query"))?;
1376 let arguments = request
1377 .arguments
1378 .ok_or_else(|| ClientError::server("query arguments were dropped"))?;
1379 assert_eq!(
1380 serde_json::from_slice::<serde_json::Value>(&arguments.bytes)
1381 .map_err(|error| ClientError::server(error.to_string()))?,
1382 serde_json::json!({ "filter": "open" })
1383 );
1384 Ok(())
1385 }
1386
1387 #[tokio::test]
1388 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
1389 let stub = Arc::new(StubTransport::default());
1390 let client = client_with(Arc::clone(&stub));
1391 let id = workflow_id();
1392 let run = run_id();
1393
1394 client.cancel(&id, Some(&run), "not needed").await?;
1395 let listed = client
1396 .list(&WorkflowFilter::default(), ListPage::default())
1397 .await?;
1398 let described = client.describe(&id, None).await?;
1399
1400 assert!(stub.last_cancel.lock().await.is_some());
1401 assert!(stub.last_list.lock().await.is_some());
1402 let describe = stub
1403 .last_describe
1404 .lock()
1405 .await
1406 .clone()
1407 .ok_or_else(|| ClientError::server("missing describe"))?;
1408 assert!(describe.run_id.is_none());
1409 assert!(!describe.include_history);
1410 assert_eq!(listed.len(), 1);
1411 assert_eq!(described.run_id, run);
1412 assert_eq!(described.history_head_seq, 0);
1413 assert!(described.terminal_event.is_none());
1414 Ok(())
1415 }
1416
1417 #[tokio::test]
1418 async fn reopen_returns_running_run_and_maps_request() -> Result<(), ClientError> {
1419 let stub = Arc::new(StubTransport::default());
1420 let client = client_with(Arc::clone(&stub));
1421 let id = workflow_id();
1422 let run = run_id();
1423
1424 let outcome = client.reopen(&id, Some(&run)).await?;
1425
1426 assert_eq!(outcome.status, WorkflowStatus::Running);
1427 let request = stub
1428 .last_reopen
1429 .lock()
1430 .await
1431 .clone()
1432 .ok_or_else(|| ClientError::server("missing reopen"))?;
1433 assert_eq!(request.namespace, "tenant-a");
1434 assert!(request.run_id.is_some());
1435 Ok(())
1436 }
1437
1438 #[tokio::test]
1441 async fn reopen_maps_invalid_state_to_distinct_typed_error() -> Result<(), ClientError> {
1442 let stub = Arc::new(StubTransport::default());
1443 *stub.reopen_response.lock().await = Some(Err(ClientError::from_wire_error(
1444 WireError::invalid_state_with_type("InvalidState", "run is not reopenable"),
1445 )));
1446 let client = client_with(Arc::clone(&stub));
1447
1448 let result = client.reopen(&workflow_id(), None).await;
1449
1450 assert!(
1451 matches!(result, Err(ClientError::InvalidState { .. })),
1452 "got {result:?}"
1453 );
1454 Ok(())
1455 }
1456}