1use aion_proto::{
4 ProtoCancelRequest, ProtoCancelResponse, ProtoPauseRequest, ProtoPauseResponse,
5 ProtoQueryRequest, ProtoQueryResponse, ProtoReopenRequest, ProtoReopenResponse,
6 ProtoResumeRequest, ProtoResumeResponse, ProtoSignalRequest, ProtoSignalResponse,
7 ProtoStartWorkflowRequest, ProtoStartWorkflowResponse, WireError, WireErrorCode,
8 proto_query_response,
9};
10use tracing::{Instrument, info_span};
11
12use super::error::{
13 cancel_terminal_error, log_server_error, map_start_error, map_workflow_operation_error,
14 signal_terminal_error,
15};
16use super::payload::{optional_payload, required_payload, required_workflow_id};
17use super::runs::{resolve_run_id, terminal_status};
18use crate::{
19 CallerIdentity, NamespaceGuard, NamespaceMinter, NamespaceOperation, ServerError, ServerState,
20 WorkflowTarget,
21};
22
23pub async fn start(
34 guard: &NamespaceGuard,
35 caller: &CallerIdentity,
36 request: ProtoStartWorkflowRequest,
37) -> Result<ProtoStartWorkflowResponse, WireError> {
38 start_with_placement(guard, caller, request, None, None).await
39}
40
41pub async fn start_with_placement(
67 guard: &NamespaceGuard,
68 caller: &CallerIdentity,
69 request: ProtoStartWorkflowRequest,
70 placement: Option<aion_core::WorkflowId>,
71 minter: Option<&NamespaceMinter>,
72) -> Result<ProtoStartWorkflowResponse, WireError> {
73 let scoped = guard
74 .scope(caller, &NamespaceOperation::start(&request))
75 .await
76 .map_err(|error| error.to_wire_error())?;
77 let namespace = scoped.namespace().to_owned();
78 if let Some(minter) = minter {
85 minter
86 .mint_or_gate(
87 std::slice::from_ref(&namespace),
88 aion_store::NamespaceOrigin::StartMint,
89 )
90 .await
91 .map_err(|error| error.to_wire_error())?;
92 }
93 let input = required_payload(request.input.clone())?;
94 let task_queue = request
97 .task_queue
98 .as_deref()
99 .map(str::trim)
100 .filter(|queue| !queue.is_empty());
101 let display_name = match request.display_name.as_deref().map(str::trim) {
112 Some("") => {
113 return Err(WireError::invalid_input(
114 "display_name must not be blank; omit it to start the run unnamed",
115 ));
116 }
117 other => other,
118 };
119 let span = info_span!(
120 "engine_operation",
121 operation = "start",
122 namespace = %namespace,
123 workflow_id = tracing::field::Empty,
124 workflow_type = %request.workflow_type,
125 );
126 let search_attributes = start_search_attributes(&namespace, task_queue, display_name);
127 let handle = async {
128 let engine = scoped
129 .engine()
130 .map_err(|error| log_server_error("start", Some(&namespace), None, &error))?;
131 let declared = engine
150 .declared_workloop_spec(&request.workflow_type)
151 .map_err(|error| map_start_error(error, &request.workflow_type))?;
152 match declared {
153 Some(spec) => engine
154 .start_workloop(
155 &request.workflow_type,
156 input,
157 search_attributes,
158 namespace.clone(),
159 spec,
160 )
161 .await
162 .map_err(|error| map_start_error(error, &request.workflow_type)),
163 None => engine
164 .start_workflow_with_id(
165 &request.workflow_type,
166 input,
167 search_attributes,
168 namespace.clone(),
169 placement,
170 None,
175 )
176 .await
177 .map_err(|error| map_start_error(error, &request.workflow_type)),
178 }
179 }
180 .instrument(span.clone())
181 .await?;
182 span.record("workflow_id", tracing::field::display(handle.workflow_id()));
183
184 Ok(ProtoStartWorkflowResponse {
185 workflow_id: Some(handle.workflow_id().clone().into()),
186 run_id: Some(handle.run_id().clone().into()),
187 })
188}
189
190pub(crate) fn start_search_attributes(
205 namespace: &str,
206 task_queue: Option<&str>,
207 display_name: Option<&str>,
208) -> std::collections::HashMap<String, aion_core::SearchAttributeValue> {
209 let mut attributes = std::collections::HashMap::from([(
210 crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
211 aion_core::SearchAttributeValue::String(namespace.to_owned()),
212 )]);
213 if let Some(task_queue) = task_queue {
214 attributes.insert(
215 crate::namespace::TASK_QUEUE_ATTRIBUTE.to_owned(),
216 aion_core::SearchAttributeValue::String(task_queue.to_owned()),
217 );
218 }
219 if let Some(display_name) = display_name {
220 attributes.insert(
221 crate::namespace::DISPLAY_NAME_ATTRIBUTE.to_owned(),
222 aion_core::SearchAttributeValue::String(display_name.to_owned()),
223 );
224 }
225 attributes
226}
227
228pub async fn signal(
235 guard: &NamespaceGuard,
236 caller: &CallerIdentity,
237 request: ProtoSignalRequest,
238) -> Result<ProtoSignalResponse, WireError> {
239 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
240 let target = WorkflowTarget::workflow(&workflow_id);
241 let scoped = guard
242 .scope(caller, &NamespaceOperation::signal(&request, target))
243 .await
244 .map_err(|error| error.to_wire_error())?;
245 let namespace = scoped.namespace().to_owned();
246 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
247 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
248 let payload = required_payload(request.payload.clone())?;
249 if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
250 return Err(signal_terminal_error(&workflow_id, status));
251 }
252
253 let signal_name = request.signal_name.clone();
254 let span = info_span!(
255 "engine_operation",
256 operation = "signal",
257 namespace = %namespace,
258 workflow_id = %workflow_id,
259 signal_name = %signal_name,
260 );
261
262 async {
263 engine
264 .signal(&workflow_id, &run_id, signal_name, payload)
265 .await
266 .map_err(|error| map_workflow_operation_error(error, &workflow_id))
267 }
268 .instrument(span)
269 .await?;
270
271 Ok(ProtoSignalResponse {})
272}
273
274pub async fn query(
281 guard: &NamespaceGuard,
282 caller: &CallerIdentity,
283 request: ProtoQueryRequest,
284) -> Result<ProtoQueryResponse, WireError> {
285 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
286 let target = WorkflowTarget::workflow(&workflow_id);
287 let scoped = guard
288 .scope(caller, &NamespaceOperation::query(&request, target))
289 .await
290 .map_err(|error| error.to_wire_error())?;
291 let namespace = scoped.namespace().to_owned();
292 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
293 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
294 let query_name = request.query_name.clone();
295 let arguments = optional_payload(request.arguments.clone())?;
298 let span = info_span!(
299 "engine_operation",
300 operation = "query",
301 namespace = %namespace,
302 workflow_id = %workflow_id,
303 query_name = %query_name,
304 );
305
306 let outcome = async {
307 engine
308 .query(&workflow_id, &run_id, query_name, arguments)
309 .await
310 }
311 .instrument(span)
312 .await;
313
314 match outcome {
315 Ok(result) => Ok(ProtoQueryResponse {
316 outcome: Some(proto_query_response::Outcome::Result(result.into())),
317 }),
318 Err(error @ aion::EngineError::Query(_)) => Ok(ProtoQueryResponse {
324 outcome: Some(proto_query_response::Outcome::Error(
325 ServerError::from(error).to_wire_error().into(),
326 )),
327 }),
328 Err(error) => Err(map_workflow_operation_error(error, &workflow_id)),
329 }
330}
331
332pub async fn cancel(
354 state: &ServerState,
355 guard: &NamespaceGuard,
356 caller: &CallerIdentity,
357 request: ProtoCancelRequest,
358) -> Result<ProtoCancelResponse, WireError> {
359 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
360 let target = WorkflowTarget::workflow(&workflow_id);
361 let scoped = guard
362 .scope(caller, &NamespaceOperation::cancel(&request, target))
363 .await
364 .map_err(|error| error.to_wire_error())?;
365 let namespace = scoped.namespace().to_owned();
366 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
367 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
368 if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
369 return Err(cancel_terminal_error(&workflow_id, status));
370 }
371
372 let span = info_span!(
373 "engine_operation",
374 operation = "cancel",
375 namespace = %namespace,
376 workflow_id = %workflow_id,
377 );
378
379 async {
380 engine
381 .cancel(&workflow_id, &run_id, request.reason)
382 .await
383 .map_err(|error| map_workflow_operation_error(error, &workflow_id))
384 }
385 .instrument(span)
386 .await?;
387
388 state
398 .cancel_in_flight_activities(&workflow_id)
399 .map_err(|error| WireError::new(WireErrorCode::Backend, error.to_string()))?;
400
401 Ok(ProtoCancelResponse {})
402}
403
404pub async fn retire_workloop(
429 guard: &NamespaceGuard,
430 caller: &CallerIdentity,
431 namespace: String,
432 workflow_id: String,
433 reason: String,
434) -> Result<(String, String), WireError> {
435 let parsed = aion_core::WorkflowId::new(
436 uuid::Uuid::parse_str(&workflow_id)
437 .map_err(|error| WireError::invalid_input(format!("workflow id: {error}")))?,
438 );
439 let cancel_request = ProtoCancelRequest {
440 namespace: namespace.clone(),
441 workflow_id: Some(parsed.clone().into()),
442 run_id: None,
443 reason: reason.clone(),
444 };
445 let target = WorkflowTarget::workflow(&parsed);
446 let scoped = guard
447 .scope(caller, &NamespaceOperation::cancel(&cancel_request, target))
448 .await
449 .map_err(|error| error.to_wire_error())?;
450 let namespace = scoped.namespace().to_owned();
451 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
452 let span = info_span!(
453 "engine_operation",
454 operation = "retire",
455 namespace = %namespace,
456 workflow_id = %parsed,
457 );
458 let recorded_reason = reason.clone();
459 async {
460 engine
461 .retire_declared_workloop(
462 &parsed,
463 reason,
464 aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
468 )
469 .await
470 .map_err(|error| map_workflow_operation_error(error, &parsed))
471 }
472 .instrument(span)
473 .await?;
474 Ok((parsed.to_string(), recorded_reason))
475}
476
477pub async fn reopen(
491 guard: &NamespaceGuard,
492 caller: &CallerIdentity,
493 request: ProtoReopenRequest,
494) -> Result<ProtoReopenResponse, WireError> {
495 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
496 let target = WorkflowTarget::workflow(&workflow_id);
497 let scoped = guard
498 .scope(caller, &NamespaceOperation::reopen(&request, target))
499 .await
500 .map_err(|error| error.to_wire_error())?;
501 let namespace = scoped.namespace().to_owned();
502 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
503 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
504
505 let span = info_span!(
506 "engine_operation",
507 operation = "reopen",
508 namespace = %namespace,
509 workflow_id = %workflow_id,
510 );
511
512 let handle = async {
513 engine
514 .reopen_workflow(&workflow_id, &run_id)
515 .await
516 .map_err(|error| map_workflow_operation_error(error, &workflow_id))
517 }
518 .instrument(span)
519 .await?;
520
521 Ok(ProtoReopenResponse {
522 run_id: Some(handle.run_id().clone().into()),
523 status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
524 })
525}
526
527pub async fn pause(
540 guard: &NamespaceGuard,
541 caller: &CallerIdentity,
542 request: ProtoPauseRequest,
543) -> Result<ProtoPauseResponse, WireError> {
544 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
545 let target = WorkflowTarget::workflow(&workflow_id);
546 let scoped = guard
547 .scope(
548 caller,
549 &NamespaceOperation::pause_workflow(&request, target),
550 )
551 .await
552 .map_err(|error| error.to_wire_error())?;
553 let namespace = scoped.namespace().to_owned();
554 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
555 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
556 let reason = if request.reason.is_empty() {
557 None
558 } else {
559 Some(request.reason.clone())
560 };
561
562 let span = info_span!(
563 "engine_operation",
564 operation = "pause",
565 namespace = %namespace,
566 workflow_id = %workflow_id,
567 );
568
569 let handle = async {
570 engine
571 .pause_workflow(&workflow_id, &run_id, reason, None)
572 .await
573 .map_err(|error| map_workflow_operation_error(error, &workflow_id))
574 }
575 .instrument(span)
576 .await?;
577
578 Ok(ProtoPauseResponse {
579 run_id: Some(handle.run_id().clone().into()),
580 status: aion_proto::ProtoWorkflowStatus::Paused as i32,
583 })
584}
585
586pub async fn rename(
605 guard: &NamespaceGuard,
606 caller: &CallerIdentity,
607 request: aion_proto::ProtoRenameRequest,
608) -> Result<aion_proto::ProtoRenameResponse, WireError> {
609 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
610 let display_name = request.display_name.trim();
614 if display_name.is_empty() {
615 return Err(WireError::invalid_input(
616 "display_name must not be empty; a rename records a non-empty label",
617 ));
618 }
619 let target = WorkflowTarget::workflow(&workflow_id);
620 let scoped = guard
621 .scope(
622 caller,
623 &NamespaceOperation::rename_workflow(&request, target),
624 )
625 .await
626 .map_err(|error| error.to_wire_error())?;
627 let namespace = scoped.namespace().to_owned();
628 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
629 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
630
631 let span = info_span!(
632 "engine_operation",
633 operation = "rename",
634 namespace = %namespace,
635 workflow_id = %workflow_id,
636 );
637
638 let recorded = async {
639 engine
640 .rename_workflow(&workflow_id, &run_id, display_name)
641 .await
642 .map_err(|error| map_workflow_operation_error(error, &workflow_id))
643 }
644 .instrument(span)
645 .await?;
646
647 Ok(aion_proto::ProtoRenameResponse {
648 run_id: Some(run_id.into()),
649 display_name: recorded,
650 })
651}
652
653pub async fn resume(
665 guard: &NamespaceGuard,
666 caller: &CallerIdentity,
667 request: ProtoResumeRequest,
668) -> Result<ProtoResumeResponse, WireError> {
669 let workflow_id = required_workflow_id(request.workflow_id.clone())?;
670 let target = WorkflowTarget::workflow(&workflow_id);
671 let scoped = guard
672 .scope(
673 caller,
674 &NamespaceOperation::resume_workflow(&request, target),
675 )
676 .await
677 .map_err(|error| error.to_wire_error())?;
678 let namespace = scoped.namespace().to_owned();
679 let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
680 let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
681
682 let span = info_span!(
683 "engine_operation",
684 operation = "resume",
685 namespace = %namespace,
686 workflow_id = %workflow_id,
687 );
688
689 let handle = async {
690 engine
691 .resume_paused_workflow(&workflow_id, &run_id, None)
692 .await
693 .map_err(|error| map_workflow_operation_error(error, &workflow_id))
694 }
695 .instrument(span)
696 .await?;
697
698 Ok(ProtoResumeResponse {
699 run_id: Some(handle.run_id().clone().into()),
700 status: aion_proto::ProtoWorkflowStatus::Running as i32,
701 })
702}
703
704#[cfg(test)]
705mod tests {
706 use aion_proto::{WireError, WireErrorCode};
707
708 use super::super::test_support::{
709 NAMESPACE, append_completed, append_failed, append_started, append_timed_out,
710 assert_workflow_not_found, cancel_request, context, denied_guard, proto_payload,
711 query_request, reopen_request, run_id, signal_request, workflow_id,
712 };
713 use super::*;
714
715 #[tokio::test]
716 async fn start_handler_scopes_then_invokes_engine_start()
717 -> Result<(), Box<dyn std::error::Error>> {
718 let context = context().await?;
719 let request = ProtoStartWorkflowRequest {
720 namespace: NAMESPACE.to_owned(),
721 workflow_type: "missing-workflow".to_owned(),
722 input: Some(proto_payload()?),
723 routing_key: None,
724 task_queue: None,
725 display_name: None,
726 };
727
728 let error = start(&context.guard, &context.caller, request).await;
729
730 let error = error
731 .err()
732 .ok_or_else(|| WireError::backend("expected error"))?;
733 assert_eq!(error.code, WireErrorCode::NotFound);
734 assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
735 assert_eq!(
736 error.message,
737 "workflow type missing-workflow is not registered"
738 );
739 Ok(())
740 }
741
742 #[test]
743 fn start_records_namespace_only_when_no_task_queue_selected() {
744 use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
745
746 let attributes = start_search_attributes("tenant-a", None, None);
747 assert_eq!(
748 attributes.get(NAMESPACE_ATTRIBUTE),
749 Some(&aion_core::SearchAttributeValue::String(
750 "tenant-a".to_owned()
751 ))
752 );
753 assert!(!attributes.contains_key(TASK_QUEUE_ATTRIBUTE));
756 }
757
758 #[test]
759 fn start_records_selected_task_queue_durably_like_namespace() {
760 use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
761
762 let attributes = start_search_attributes("tenant-a", Some("gpu"), None);
763 assert_eq!(
764 attributes.get(NAMESPACE_ATTRIBUTE),
765 Some(&aion_core::SearchAttributeValue::String(
766 "tenant-a".to_owned()
767 ))
768 );
769 assert_eq!(
773 attributes.get(TASK_QUEUE_ATTRIBUTE),
774 Some(&aion_core::SearchAttributeValue::String("gpu".to_owned()))
775 );
776 }
777
778 #[test]
781 fn start_records_no_display_name_when_unnamed() {
782 use crate::namespace::{DISPLAY_NAME_ATTRIBUTE, NAMESPACE_ATTRIBUTE};
783
784 let attributes = start_search_attributes("tenant-a", None, None);
785 assert_eq!(
786 attributes.get(NAMESPACE_ATTRIBUTE),
787 Some(&aion_core::SearchAttributeValue::String(
788 "tenant-a".to_owned()
789 ))
790 );
791 assert!(!attributes.contains_key(DISPLAY_NAME_ATTRIBUTE));
794 }
795
796 #[test]
800 fn start_records_display_name_durably_like_namespace() {
801 use crate::namespace::{DISPLAY_NAME_ATTRIBUTE, NAMESPACE_ATTRIBUTE};
802
803 let attributes = start_search_attributes("tenant-a", None, Some("Nightly settlement"));
804 assert_eq!(
805 attributes.get(NAMESPACE_ATTRIBUTE),
806 Some(&aion_core::SearchAttributeValue::String(
807 "tenant-a".to_owned()
808 ))
809 );
810 assert_eq!(
811 attributes.get(DISPLAY_NAME_ATTRIBUTE),
812 Some(&aion_core::SearchAttributeValue::String(
813 "Nightly settlement".to_owned()
814 ))
815 );
816 }
817
818 #[tokio::test]
823 async fn start_refuses_a_blank_display_name_rather_than_starting_unnamed()
824 -> Result<(), Box<dyn std::error::Error>> {
825 let context = context().await?;
826 for blank in ["", " ", "\t\n "] {
827 let request = aion_proto::ProtoStartWorkflowRequest {
828 namespace: NAMESPACE.to_owned(),
829 workflow_type: "checkout".to_owned(),
830 input: Some(proto_payload()?),
831 routing_key: None,
832 task_queue: None,
833 display_name: Some(blank.to_owned()),
834 };
835
836 let error = start(&context.guard, &context.caller, request)
837 .await
838 .err()
839 .ok_or_else(|| WireError::backend("expected a blank-name refusal"))?;
840 assert_eq!(error.code, WireErrorCode::InvalidInput, "blank {blank:?}");
841 }
842
843 let request = aion_proto::ProtoStartWorkflowRequest {
847 namespace: NAMESPACE.to_owned(),
848 workflow_type: "checkout".to_owned(),
849 input: Some(proto_payload()?),
850 routing_key: None,
851 task_queue: None,
852 display_name: None,
853 };
854 let error = start(&context.guard, &context.caller, request)
855 .await
856 .err()
857 .ok_or_else(|| WireError::backend("expected an error"))?;
858 assert_ne!(
859 error.code,
860 WireErrorCode::InvalidInput,
861 "an absent name must not be refused as invalid input"
862 );
863 Ok(())
864 }
865
866 #[tokio::test]
869 async fn rename_handler_rejects_blank_display_name() -> Result<(), Box<dyn std::error::Error>> {
870 let context = context().await?;
871 let request = aion_proto::ProtoRenameRequest {
872 namespace: NAMESPACE.to_owned(),
873 workflow_id: Some(workflow_id().into()),
874 run_id: Some(run_id().into()),
875 display_name: " ".to_owned(),
876 };
877
878 let error = rename(&context.guard, &context.caller, request).await;
879
880 let error = error
881 .err()
882 .ok_or_else(|| WireError::backend("expected error"))?;
883 assert_eq!(error.code, WireErrorCode::InvalidInput);
884 Ok(())
885 }
886
887 #[tokio::test]
891 async fn rename_handler_scopes_then_reports_absent_workflow()
892 -> Result<(), Box<dyn std::error::Error>> {
893 let context = context().await?;
894 context.ownership.record(workflow_id(), NAMESPACE)?;
895 let request = aion_proto::ProtoRenameRequest {
896 namespace: NAMESPACE.to_owned(),
897 workflow_id: Some(workflow_id().into()),
898 run_id: Some(run_id().into()),
899 display_name: "Nightly settlement".to_owned(),
900 };
901
902 let error = rename(&context.guard, &context.caller, request).await;
903
904 let error = error
905 .err()
906 .ok_or_else(|| WireError::backend("expected error"))?;
907 assert_eq!(error.code, WireErrorCode::NotFound);
908 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
909 Ok(())
910 }
911
912 #[tokio::test]
913 async fn signal_handler_scopes_then_invokes_engine_signal()
914 -> Result<(), Box<dyn std::error::Error>> {
915 let context = context().await?;
916 context.ownership.record(workflow_id(), NAMESPACE)?;
917
918 let error = signal(&context.guard, &context.caller, signal_request()?).await;
919
920 let error = error
921 .err()
922 .ok_or_else(|| WireError::backend("expected error"))?;
923 assert_eq!(error.code, WireErrorCode::NotFound);
924 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
925 assert_eq!(
926 error.message,
927 format!("workflow {} not found", workflow_id())
928 );
929 Ok(())
930 }
931
932 #[tokio::test]
933 async fn query_handler_scopes_then_invokes_engine_query()
934 -> Result<(), Box<dyn std::error::Error>> {
935 let context = context().await?;
936 context.ownership.record(workflow_id(), NAMESPACE)?;
937
938 let error = query(&context.guard, &context.caller, query_request()).await;
939
940 let error = error
941 .err()
942 .ok_or_else(|| WireError::backend("expected error"))?;
943 assert_eq!(error.code, WireErrorCode::NotFound);
944 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
945 assert_eq!(
946 error.message,
947 format!("workflow {} not found", workflow_id())
948 );
949 Ok(())
950 }
951
952 #[tokio::test]
953 async fn query_handler_returns_not_running_outcome_for_terminal_workflow()
954 -> Result<(), Box<dyn std::error::Error>> {
955 let context = context().await?;
956 context.ownership.record(workflow_id(), NAMESPACE)?;
957 append_completed(context.store.as_ref()).await?;
958 let mut request = query_request();
961 request.run_id = None;
962
963 let response = query(&context.guard, &context.caller, request).await?;
964
965 let Some(proto_query_response::Outcome::Error(error)) = response.outcome else {
968 return Err("expected a QueryResponse.error outcome".into());
969 };
970 let error = WireError::try_from(error)?;
971 assert_eq!(error.code, WireErrorCode::NotRunning);
972 assert_eq!(error.error_type.as_deref(), Some("QueryNotRunning"));
973 Ok(())
974 }
975
976 #[tokio::test]
977 async fn query_handler_keeps_non_resident_non_terminal_workflow_as_transport_not_found()
978 -> Result<(), Box<dyn std::error::Error>> {
979 let context = context().await?;
983 context.ownership.record(workflow_id(), NAMESPACE)?;
984 append_started(context.store.as_ref()).await?;
985 let mut request = query_request();
986 request.run_id = None;
987
988 let error = query(&context.guard, &context.caller, request).await;
989
990 let error = error
991 .err()
992 .ok_or_else(|| WireError::backend("expected error"))?;
993 assert_eq!(error.code, WireErrorCode::NotFound);
994 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
995 Ok(())
996 }
997
998 #[tokio::test]
999 async fn cancel_handler_scopes_then_invokes_engine_cancel()
1000 -> Result<(), Box<dyn std::error::Error>> {
1001 let context = context().await?;
1002 context.ownership.record(workflow_id(), NAMESPACE)?;
1003
1004 let error = cancel(
1005 &context.state,
1006 &context.guard,
1007 &context.caller,
1008 cancel_request(),
1009 )
1010 .await;
1011
1012 let error = error
1013 .err()
1014 .ok_or_else(|| WireError::backend("expected error"))?;
1015 assert_eq!(error.code, WireErrorCode::NotFound);
1016 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
1017 assert_eq!(
1018 error.message,
1019 format!("workflow {} not found", workflow_id())
1020 );
1021 Ok(())
1022 }
1023
1024 #[tokio::test]
1025 async fn reopen_handler_maps_missing_workflow_to_not_found()
1026 -> Result<(), Box<dyn std::error::Error>> {
1027 let context = context().await?;
1028 context.ownership.record(workflow_id(), NAMESPACE)?;
1029
1030 let error = reopen(&context.guard, &context.caller, reopen_request()).await;
1031
1032 let error = error
1033 .err()
1034 .ok_or_else(|| WireError::backend("expected error"))?;
1035 assert_eq!(error.code, WireErrorCode::NotFound);
1036 assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
1037 Ok(())
1038 }
1039
1040 #[tokio::test]
1041 async fn reopen_handler_rejects_completed_workflow_as_invalid_state()
1042 -> Result<(), Box<dyn std::error::Error>> {
1043 let context = context().await?;
1044 context.ownership.record(workflow_id(), NAMESPACE)?;
1045 append_completed(context.store.as_ref()).await?;
1046 let mut request = reopen_request();
1047 request.run_id = None;
1048
1049 let error = reopen(&context.guard, &context.caller, request).await;
1050
1051 let error = error
1052 .err()
1053 .ok_or_else(|| WireError::backend("expected error"))?;
1054 assert_eq!(error.code, WireErrorCode::InvalidState);
1055 assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
1056 Ok(())
1057 }
1058
1059 #[tokio::test]
1060 async fn reopen_handler_rejects_timed_out_workflow_as_invalid_state()
1061 -> Result<(), Box<dyn std::error::Error>> {
1062 let context = context().await?;
1063 context.ownership.record(workflow_id(), NAMESPACE)?;
1064 append_timed_out(context.store.as_ref()).await?;
1065 let mut request = reopen_request();
1066 request.run_id = None;
1067
1068 let error = reopen(&context.guard, &context.caller, request).await;
1069
1070 let error = error
1071 .err()
1072 .ok_or_else(|| WireError::backend("expected error"))?;
1073 assert_eq!(error.code, WireErrorCode::InvalidState);
1075 assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
1076 Ok(())
1077 }
1078
1079 #[tokio::test]
1080 async fn reopen_handler_maps_omitted_run_missing_workflow_to_not_found()
1081 -> Result<(), Box<dyn std::error::Error>> {
1082 let context = context().await?;
1083 context.ownership.record(workflow_id(), NAMESPACE)?;
1084 let mut request = reopen_request();
1085 request.run_id = None;
1086
1087 let error = reopen(&context.guard, &context.caller, request).await;
1088
1089 assert_workflow_not_found(error)?;
1090 Ok(())
1091 }
1092
1093 #[tokio::test]
1096 async fn denied_reopen_is_namespace_denied_before_engine_check()
1097 -> Result<(), Box<dyn std::error::Error>> {
1098 let (guard, caller) = denied_guard();
1099 let request = ProtoReopenRequest {
1100 namespace: NAMESPACE.to_owned(),
1101 workflow_id: Some(workflow_id().into()),
1102 run_id: Some(run_id().into()),
1103 };
1104
1105 let error = reopen(&guard, &caller, request).await;
1106
1107 assert_eq!(
1108 error.err().map(|error| error.code),
1109 Some(WireErrorCode::NamespaceDenied)
1110 );
1111 Ok(())
1112 }
1113
1114 #[tokio::test]
1115 async fn signal_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
1116 let context = context().await?;
1117 context.ownership.record(workflow_id(), NAMESPACE)?;
1118 append_completed(context.store.as_ref()).await?;
1119
1120 let error = signal(&context.guard, &context.caller, signal_request()?).await;
1121
1122 let error = error
1123 .err()
1124 .ok_or_else(|| WireError::backend("expected error"))?;
1125 assert_eq!(error.code, WireErrorCode::NotRunning);
1126 assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
1127 assert_eq!(
1128 error.message,
1129 format!(
1130 "workflow {} has already reached terminal state Completed",
1131 workflow_id()
1132 )
1133 );
1134 Ok(())
1135 }
1136
1137 #[tokio::test]
1138 async fn signal_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
1139 let context = context().await?;
1140 context.ownership.record(workflow_id(), NAMESPACE)?;
1141 append_failed(context.store.as_ref()).await?;
1142
1143 let error = signal(&context.guard, &context.caller, signal_request()?).await;
1144
1145 let error = error
1146 .err()
1147 .ok_or_else(|| WireError::backend("expected error"))?;
1148 assert_eq!(error.code, WireErrorCode::NotRunning);
1149 assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
1150 assert_eq!(
1151 error.message,
1152 format!(
1153 "workflow {} has already reached terminal state Failed",
1154 workflow_id()
1155 )
1156 );
1157 Ok(())
1158 }
1159
1160 #[tokio::test]
1161 async fn cancel_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
1162 let context = context().await?;
1163 context.ownership.record(workflow_id(), NAMESPACE)?;
1164 append_completed(context.store.as_ref()).await?;
1165
1166 let error = cancel(
1167 &context.state,
1168 &context.guard,
1169 &context.caller,
1170 cancel_request(),
1171 )
1172 .await;
1173
1174 let error = error
1175 .err()
1176 .ok_or_else(|| WireError::backend("expected error"))?;
1177 assert_eq!(error.code, WireErrorCode::NotRunning);
1178 assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
1179 assert_eq!(
1180 error.message,
1181 format!(
1182 "workflow {} has already completed with status Completed",
1183 workflow_id()
1184 )
1185 );
1186 assert!(!error.message.contains("process 0 is not live"));
1187 Ok(())
1188 }
1189
1190 #[tokio::test]
1191 async fn cancel_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
1192 let context = context().await?;
1193 context.ownership.record(workflow_id(), NAMESPACE)?;
1194 append_failed(context.store.as_ref()).await?;
1195
1196 let error = cancel(
1197 &context.state,
1198 &context.guard,
1199 &context.caller,
1200 cancel_request(),
1201 )
1202 .await;
1203
1204 let error = error
1205 .err()
1206 .ok_or_else(|| WireError::backend("expected error"))?;
1207 assert_eq!(error.code, WireErrorCode::NotRunning);
1208 assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
1209 assert_eq!(
1210 error.message,
1211 format!(
1212 "workflow {} has already completed with status Failed",
1213 workflow_id()
1214 )
1215 );
1216 assert!(!error.message.contains("process 0 is not live"));
1217 Ok(())
1218 }
1219
1220 #[tokio::test]
1221 async fn signal_handler_maps_omitted_run_missing_workflow_to_not_found()
1222 -> Result<(), Box<dyn std::error::Error>> {
1223 let context = context().await?;
1224 context.ownership.record(workflow_id(), NAMESPACE)?;
1225 let mut request = signal_request()?;
1226 request.run_id = None;
1227
1228 let error = signal(&context.guard, &context.caller, request).await;
1229
1230 assert_workflow_not_found(error)?;
1231 Ok(())
1232 }
1233
1234 #[tokio::test]
1235 async fn query_handler_maps_omitted_run_missing_workflow_to_not_found()
1236 -> Result<(), Box<dyn std::error::Error>> {
1237 let context = context().await?;
1238 context.ownership.record(workflow_id(), NAMESPACE)?;
1239 let mut request = query_request();
1240 request.run_id = None;
1241
1242 let error = query(&context.guard, &context.caller, request).await;
1243
1244 assert_workflow_not_found(error)?;
1245 Ok(())
1246 }
1247
1248 #[tokio::test]
1249 async fn cancel_handler_maps_omitted_run_missing_workflow_to_not_found()
1250 -> Result<(), Box<dyn std::error::Error>> {
1251 let context = context().await?;
1252 context.ownership.record(workflow_id(), NAMESPACE)?;
1253 let mut request = cancel_request();
1254 request.run_id = None;
1255
1256 let error = cancel(&context.state, &context.guard, &context.caller, request).await;
1257
1258 assert_workflow_not_found(error)?;
1259 Ok(())
1260 }
1261
1262 #[tokio::test]
1263 async fn denied_start_does_not_decode_missing_payload_before_namespace_check()
1264 -> Result<(), Box<dyn std::error::Error>> {
1265 let (guard, caller) = denied_guard();
1266 let request = ProtoStartWorkflowRequest {
1267 namespace: NAMESPACE.to_owned(),
1268 workflow_type: "fixture".to_owned(),
1269 input: None,
1270 routing_key: None,
1271 task_queue: None,
1272 display_name: None,
1273 };
1274
1275 let error = start(&guard, &caller, request).await;
1276
1277 assert_eq!(
1278 error.err().map(|error| error.code),
1279 Some(WireErrorCode::NamespaceDenied)
1280 );
1281 Ok(())
1282 }
1283
1284 #[tokio::test]
1285 async fn denied_signal_does_not_decode_missing_payload_before_namespace_check()
1286 -> Result<(), Box<dyn std::error::Error>> {
1287 let (guard, caller) = denied_guard();
1288 let request = ProtoSignalRequest {
1289 namespace: NAMESPACE.to_owned(),
1290 workflow_id: Some(workflow_id().into()),
1291 run_id: Some(run_id().into()),
1292 signal_name: "poke".to_owned(),
1293 payload: None,
1294 };
1295
1296 let error = signal(&guard, &caller, request).await;
1297
1298 assert_eq!(
1299 error.err().map(|error| error.code),
1300 Some(WireErrorCode::NamespaceDenied)
1301 );
1302 Ok(())
1303 }
1304
1305 use std::sync::Arc;
1308
1309 use aion_store::{NamespaceOrigin, NamespaceStore};
1310
1311 use crate::config::AutoCreate;
1312
1313 fn namespace_store() -> Arc<dyn NamespaceStore> {
1314 Arc::new(aion_store::InMemoryStore::default())
1315 }
1316
1317 fn minter(store: &Arc<dyn NamespaceStore>, policy: AutoCreate) -> NamespaceMinter {
1318 NamespaceMinter::new(Arc::clone(store), policy)
1319 }
1320
1321 fn fresh_start_request() -> Result<ProtoStartWorkflowRequest, aion_core::PayloadError> {
1322 Ok(ProtoStartWorkflowRequest {
1323 namespace: NAMESPACE.to_owned(),
1324 workflow_type: "missing-workflow".to_owned(),
1325 input: Some(proto_payload()?),
1326 routing_key: None,
1327 task_queue: None,
1328 display_name: None,
1329 })
1330 }
1331
1332 #[tokio::test]
1338 async fn open_start_mints_durable_record_and_is_idempotent()
1339 -> Result<(), Box<dyn std::error::Error>> {
1340 let context = context().await?;
1341 let store = namespace_store();
1342 let minter = minter(&store, AutoCreate::Open);
1343
1344 assert!(store.get_namespace(NAMESPACE).await?.is_none());
1346
1347 let first = start_with_placement(
1350 &context.guard,
1351 &context.caller,
1352 fresh_start_request()?,
1353 None,
1354 Some(&minter),
1355 )
1356 .await;
1357 assert!(
1358 first.is_err(),
1359 "the fixture start has no registered workflow type"
1360 );
1361 let record = store
1362 .get_namespace(NAMESPACE)
1363 .await?
1364 .ok_or("expected a durable record minted by the start")?;
1365 assert_eq!(record.name, NAMESPACE);
1366 assert_eq!(record.origin, NamespaceOrigin::StartMint);
1367
1368 let _second = start_with_placement(
1370 &context.guard,
1371 &context.caller,
1372 fresh_start_request()?,
1373 None,
1374 Some(&minter),
1375 )
1376 .await;
1377 let all = store.list_namespaces().await?;
1378 assert_eq!(
1379 all.iter().filter(|r| r.name == NAMESPACE).count(),
1380 1,
1381 "a second start must not create a duplicate namespace row"
1382 );
1383 Ok(())
1384 }
1385
1386 #[tokio::test]
1390 async fn closed_start_rejects_unknown_namespace_and_does_not_create_it()
1391 -> Result<(), Box<dyn std::error::Error>> {
1392 let context = context().await?;
1393 let store = namespace_store();
1394 let minter = minter(&store, AutoCreate::Closed);
1395
1396 let denied = start_with_placement(
1397 &context.guard,
1398 &context.caller,
1399 fresh_start_request()?,
1400 None,
1401 Some(&minter),
1402 )
1403 .await;
1404
1405 let error = denied
1406 .err()
1407 .ok_or_else(|| WireError::backend("expected a namespace-denied error"))?;
1408 assert_eq!(error.code, WireErrorCode::NamespaceDenied);
1409 assert!(
1410 store.get_namespace(NAMESPACE).await?.is_none(),
1411 "closed policy must NOT create the namespace it rejected"
1412 );
1413 Ok(())
1414 }
1415
1416 #[tokio::test]
1420 async fn closed_start_admits_a_known_namespace() -> Result<(), Box<dyn std::error::Error>> {
1421 let context = context().await?;
1422 let store = namespace_store();
1423 store
1424 .register_namespace(NAMESPACE, NamespaceOrigin::Explicit)
1425 .await?;
1426 let minter = minter(&store, AutoCreate::Closed);
1427
1428 let error = start_with_placement(
1431 &context.guard,
1432 &context.caller,
1433 fresh_start_request()?,
1434 None,
1435 Some(&minter),
1436 )
1437 .await
1438 .err()
1439 .ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
1440 assert_eq!(
1441 error.code,
1442 WireErrorCode::NotFound,
1443 "a known namespace must pass the gate and fail only at the engine"
1444 );
1445 assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
1446 Ok(())
1447 }
1448
1449 #[tokio::test]
1452 async fn no_minter_leaves_start_untouched() -> Result<(), Box<dyn std::error::Error>> {
1453 let context = context().await?;
1454 let error = start_with_placement(
1455 &context.guard,
1456 &context.caller,
1457 fresh_start_request()?,
1458 None,
1459 None,
1460 )
1461 .await
1462 .err()
1463 .ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
1464 assert_eq!(error.code, WireErrorCode::NotFound);
1465 assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
1466 Ok(())
1467 }
1468}