1mod auth;
5pub(crate) mod convert;
9mod mint_resolve;
11mod plain_rpcs;
13#[cfg(feature = "haematite-backend")]
15mod routing_resolve;
16mod status;
18
19pub(crate) use auth::caller_from_metadata;
20pub(crate) use status::{status_from_wire_error, status_with_code};
21
22use aion_proto::generated::{self, workflow_service_server::WorkflowServiceServer};
23use tonic::{Request, Response, Status};
24
25use crate::namespace::MintCredentials;
26use crate::{CallerIdentity, ServerState, api::handlers};
27#[cfg(feature = "haematite-backend")]
28use convert::decode_workflow_id;
29use convert::{
30 decode_cancel_request, decode_pause_request, decode_query_request, decode_rename_request,
31 decode_reopen_request, decode_resume_request, decode_signal_request, decode_start_request,
32 encode_cancel_response, encode_pause_response, encode_query_response, encode_rename_response,
33 encode_reopen_response, encode_resume_response, encode_signal_response, encode_start_response,
34};
35#[cfg(feature = "haematite-backend")]
36use routing_resolve::{RouteResolution, StartResolution};
37
38#[derive(Clone)]
40pub struct WorkflowGrpcService {
41 state: ServerState,
42}
43
44impl WorkflowGrpcService {
45 #[must_use]
47 pub const fn new(state: ServerState) -> Self {
48 Self { state }
49 }
50
51 async fn caller<T>(&self, request: &Request<T>) -> Result<CallerIdentity, Status> {
52 caller_from_metadata(request.metadata(), &self.state).await
53 }
54}
55
56#[must_use]
58pub fn workflow_service(state: ServerState) -> WorkflowServiceServer<WorkflowGrpcService> {
59 WorkflowServiceServer::new(WorkflowGrpcService::new(state))
60}
61
62#[tonic::async_trait]
63impl generated::workflow_service_server::WorkflowService for WorkflowGrpcService {
64 async fn start_workflow(
65 &self,
66 request: Request<generated::StartWorkflowRequest>,
67 ) -> Result<Response<generated::StartWorkflowResponse>, Status> {
68 if self.state.drain_state().is_draining() {
69 return Err(Status::unavailable(
70 "server is draining and not accepting new workflow starts",
71 ));
72 }
73 let caller = self.caller(&request).await?;
74 #[cfg(feature = "haematite-backend")]
81 {
82 let (metadata, _ext, inner) = request.into_parts();
83 let placement = match self.resolve_start(&inner, &metadata).await {
84 StartResolution::Reject(status) => return Err(status),
85 StartResolution::Reply(reply) => return Ok(Response::new(reply)),
86 StartResolution::Local(placement) => placement,
87 };
88 let minter = self
97 .state
98 .namespace_minter()
99 .with_caller_credentials(MintCredentials::from_grpc_metadata(&metadata));
100 let response = handlers::start_with_placement(
101 self.state.namespace_guard(),
102 &caller,
103 decode_start_request(inner),
104 placement,
105 Some(&minter),
106 )
107 .await
108 .map_err(status_from_wire_error)?;
109 return Ok(Response::new(encode_start_response(response)));
110 }
111 #[cfg(not(feature = "haematite-backend"))]
112 {
113 let placement: Option<aion_core::WorkflowId> = None;
114 let minter = self.state.namespace_minter();
118 let response = handlers::start_with_placement(
119 self.state.namespace_guard(),
120 &caller,
121 decode_start_request(request.into_inner()),
122 placement,
123 Some(&minter),
124 )
125 .await
126 .map_err(status_from_wire_error)?;
127 Ok(Response::new(encode_start_response(response)))
128 }
129 }
130
131 async fn signal(
132 &self,
133 request: Request<generated::SignalRequest>,
134 ) -> Result<Response<generated::SignalResponse>, Status> {
135 let caller = self.caller(&request).await?;
136 #[cfg(feature = "haematite-backend")]
137 {
138 use crate::routing::{ForwardReply, ForwardRequest};
139 let (metadata, _ext, inner) = request.into_parts();
140 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
141 match self
142 .resolve_route(
143 workflow_id,
144 &metadata,
145 ForwardRequest::Signal(inner.clone()),
146 )
147 .await
148 {
149 RouteResolution::Reject(status) => return Err(status),
150 RouteResolution::Reply(ForwardReply::Signal(reply)) => {
151 return Ok(Response::new(reply));
152 }
153 RouteResolution::Reply(_) => {
154 return Err(Status::internal("forwarder returned a mismatched reply"));
155 }
156 RouteResolution::Local => {
157 let response = handlers::signal(
158 self.state.namespace_guard(),
159 &caller,
160 decode_signal_request(inner),
161 )
162 .await
163 .map_err(status_from_wire_error)?;
164 return Ok(Response::new(encode_signal_response(response)));
165 }
166 }
167 }
168 #[cfg(not(feature = "haematite-backend"))]
169 {
170 let response = handlers::signal(
171 self.state.namespace_guard(),
172 &caller,
173 decode_signal_request(request.into_inner()),
174 )
175 .await
176 .map_err(status_from_wire_error)?;
177 Ok(Response::new(encode_signal_response(response)))
178 }
179 }
180
181 async fn query(
182 &self,
183 request: Request<generated::QueryRequest>,
184 ) -> Result<Response<generated::QueryResponse>, Status> {
185 let caller = self.caller(&request).await?;
186 #[cfg(feature = "haematite-backend")]
187 {
188 use crate::routing::{ForwardReply, ForwardRequest};
189 let (metadata, _ext, inner) = request.into_parts();
190 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
191 match self
192 .resolve_route(workflow_id, &metadata, ForwardRequest::Query(inner.clone()))
193 .await
194 {
195 RouteResolution::Reject(status) => return Err(status),
196 RouteResolution::Reply(ForwardReply::Query(reply)) => {
197 return Ok(Response::new(reply));
198 }
199 RouteResolution::Reply(_) => {
200 return Err(Status::internal("forwarder returned a mismatched reply"));
201 }
202 RouteResolution::Local => {
203 let response = handlers::query(
204 self.state.namespace_guard(),
205 &caller,
206 decode_query_request(inner),
207 )
208 .await
209 .map_err(status_from_wire_error)?;
210 return Ok(Response::new(encode_query_response(response)));
211 }
212 }
213 }
214 #[cfg(not(feature = "haematite-backend"))]
215 {
216 let response = handlers::query(
217 self.state.namespace_guard(),
218 &caller,
219 decode_query_request(request.into_inner()),
220 )
221 .await
222 .map_err(status_from_wire_error)?;
223 Ok(Response::new(encode_query_response(response)))
224 }
225 }
226
227 async fn cancel(
228 &self,
229 request: Request<generated::CancelRequest>,
230 ) -> Result<Response<generated::CancelResponse>, Status> {
231 let caller = self.caller(&request).await?;
232 #[cfg(feature = "haematite-backend")]
233 {
234 use crate::routing::{ForwardReply, ForwardRequest};
235 let (metadata, _ext, inner) = request.into_parts();
236 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
237 match self
238 .resolve_route(
239 workflow_id,
240 &metadata,
241 ForwardRequest::Cancel(inner.clone()),
242 )
243 .await
244 {
245 RouteResolution::Reject(status) => return Err(status),
246 RouteResolution::Reply(ForwardReply::Cancel(reply)) => {
247 return Ok(Response::new(reply));
248 }
249 RouteResolution::Reply(_) => {
250 return Err(Status::internal("forwarder returned a mismatched reply"));
251 }
252 RouteResolution::Local => {
253 let response = handlers::cancel(
254 self.state.namespace_guard(),
255 &caller,
256 decode_cancel_request(inner),
257 )
258 .await
259 .map_err(status_from_wire_error)?;
260 return Ok(Response::new(encode_cancel_response(response)));
261 }
262 }
263 }
264 #[cfg(not(feature = "haematite-backend"))]
265 {
266 let response = handlers::cancel(
267 self.state.namespace_guard(),
268 &caller,
269 decode_cancel_request(request.into_inner()),
270 )
271 .await
272 .map_err(status_from_wire_error)?;
273 Ok(Response::new(encode_cancel_response(response)))
274 }
275 }
276
277 async fn reopen(
278 &self,
279 request: Request<generated::ReopenRequest>,
280 ) -> Result<Response<generated::ReopenResponse>, Status> {
281 let caller = self.caller(&request).await?;
282 #[cfg(feature = "haematite-backend")]
283 {
284 use crate::routing::{ForwardReply, ForwardRequest};
285 let (metadata, _ext, inner) = request.into_parts();
286 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
287 match self
288 .resolve_route(
289 workflow_id,
290 &metadata,
291 ForwardRequest::Reopen(inner.clone()),
292 )
293 .await
294 {
295 RouteResolution::Reject(status) => return Err(status),
296 RouteResolution::Reply(ForwardReply::Reopen(reply)) => {
297 return Ok(Response::new(reply));
298 }
299 RouteResolution::Reply(_) => {
300 return Err(Status::internal("forwarder returned a mismatched reply"));
301 }
302 RouteResolution::Local => {
303 let response = handlers::reopen(
304 self.state.namespace_guard(),
305 &caller,
306 decode_reopen_request(inner),
307 )
308 .await
309 .map_err(status_from_wire_error)?;
310 return Ok(Response::new(encode_reopen_response(response)));
311 }
312 }
313 }
314 #[cfg(not(feature = "haematite-backend"))]
315 {
316 let response = handlers::reopen(
317 self.state.namespace_guard(),
318 &caller,
319 decode_reopen_request(request.into_inner()),
320 )
321 .await
322 .map_err(status_from_wire_error)?;
323 Ok(Response::new(encode_reopen_response(response)))
324 }
325 }
326
327 async fn pause(
328 &self,
329 request: Request<generated::PauseRequest>,
330 ) -> Result<Response<generated::PauseResponse>, Status> {
331 let caller = self.caller(&request).await?;
332 #[cfg(feature = "haematite-backend")]
333 {
334 use crate::routing::{ForwardReply, ForwardRequest};
335 let (metadata, _ext, inner) = request.into_parts();
336 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
337 match self
338 .resolve_route(workflow_id, &metadata, ForwardRequest::Pause(inner.clone()))
339 .await
340 {
341 RouteResolution::Reject(status) => return Err(status),
342 RouteResolution::Reply(ForwardReply::Pause(reply)) => {
343 return Ok(Response::new(reply));
344 }
345 RouteResolution::Reply(_) => {
346 return Err(Status::internal("forwarder returned a mismatched reply"));
347 }
348 RouteResolution::Local => {
349 let response = handlers::pause(
350 self.state.namespace_guard(),
351 &caller,
352 decode_pause_request(inner),
353 )
354 .await
355 .map_err(status_from_wire_error)?;
356 return Ok(Response::new(encode_pause_response(response)));
357 }
358 }
359 }
360 #[cfg(not(feature = "haematite-backend"))]
361 {
362 let response = handlers::pause(
363 self.state.namespace_guard(),
364 &caller,
365 decode_pause_request(request.into_inner()),
366 )
367 .await
368 .map_err(status_from_wire_error)?;
369 Ok(Response::new(encode_pause_response(response)))
370 }
371 }
372
373 async fn resume(
374 &self,
375 request: Request<generated::ResumeRequest>,
376 ) -> Result<Response<generated::ResumeResponse>, Status> {
377 let caller = self.caller(&request).await?;
378 #[cfg(feature = "haematite-backend")]
379 {
380 use crate::routing::{ForwardReply, ForwardRequest};
381 let (metadata, _ext, inner) = request.into_parts();
382 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
383 match self
384 .resolve_route(
385 workflow_id,
386 &metadata,
387 ForwardRequest::Resume(inner.clone()),
388 )
389 .await
390 {
391 RouteResolution::Reject(status) => return Err(status),
392 RouteResolution::Reply(ForwardReply::Resume(reply)) => {
393 return Ok(Response::new(reply));
394 }
395 RouteResolution::Reply(_) => {
396 return Err(Status::internal("forwarder returned a mismatched reply"));
397 }
398 RouteResolution::Local => {
399 let response = handlers::resume(
400 self.state.namespace_guard(),
401 &caller,
402 decode_resume_request(inner),
403 )
404 .await
405 .map_err(status_from_wire_error)?;
406 return Ok(Response::new(encode_resume_response(response)));
407 }
408 }
409 }
410 #[cfg(not(feature = "haematite-backend"))]
411 {
412 let response = handlers::resume(
413 self.state.namespace_guard(),
414 &caller,
415 decode_resume_request(request.into_inner()),
416 )
417 .await
418 .map_err(status_from_wire_error)?;
419 Ok(Response::new(encode_resume_response(response)))
420 }
421 }
422
423 async fn rename(
424 &self,
425 request: Request<generated::RenameRequest>,
426 ) -> Result<Response<generated::RenameResponse>, Status> {
427 let caller = self.caller(&request).await?;
428 #[cfg(feature = "haematite-backend")]
429 {
430 use crate::routing::{ForwardReply, ForwardRequest};
431 let (metadata, _ext, inner) = request.into_parts();
432 let workflow_id = inner.workflow_id.clone().map(decode_workflow_id);
433 match self
434 .resolve_route(
435 workflow_id,
436 &metadata,
437 ForwardRequest::Rename(inner.clone()),
438 )
439 .await
440 {
441 RouteResolution::Reject(status) => return Err(status),
442 RouteResolution::Reply(ForwardReply::Rename(reply)) => {
443 return Ok(Response::new(reply));
444 }
445 RouteResolution::Reply(_) => {
446 return Err(Status::internal("forwarder returned a mismatched reply"));
447 }
448 RouteResolution::Local => {
449 let response = handlers::rename(
450 self.state.namespace_guard(),
451 &caller,
452 decode_rename_request(inner),
453 )
454 .await
455 .map_err(status_from_wire_error)?;
456 return Ok(Response::new(encode_rename_response(response)));
457 }
458 }
459 }
460 #[cfg(not(feature = "haematite-backend"))]
461 {
462 let response = handlers::rename(
463 self.state.namespace_guard(),
464 &caller,
465 decode_rename_request(request.into_inner()),
466 )
467 .await
468 .map_err(status_from_wire_error)?;
469 Ok(Response::new(encode_rename_response(response)))
470 }
471 }
472
473 async fn list_workflows(
474 &self,
475 request: Request<generated::ListWorkflowsRequest>,
476 ) -> Result<Response<generated::ListWorkflowsResponse>, Status> {
477 plain_rpcs::list_workflows(&self.state, request).await
478 }
479
480 async fn count_workflows(
481 &self,
482 request: Request<generated::CountWorkflowsRequest>,
483 ) -> Result<Response<generated::CountWorkflowsResponse>, Status> {
484 plain_rpcs::count_workflows(&self.state, request).await
485 }
486
487 async fn describe_workflow(
488 &self,
489 request: Request<generated::DescribeWorkflowRequest>,
490 ) -> Result<Response<generated::DescribeWorkflowResponse>, Status> {
491 plain_rpcs::describe_workflow(&self.state, request).await
492 }
493
494 async fn create_schedule(
495 &self,
496 request: Request<generated::CreateScheduleRequest>,
497 ) -> Result<Response<generated::CreateScheduleResponse>, Status> {
498 plain_rpcs::create_schedule(&self.state, request).await
499 }
500
501 async fn update_schedule(
502 &self,
503 request: Request<generated::UpdateScheduleRequest>,
504 ) -> Result<Response<generated::UpdateScheduleResponse>, Status> {
505 plain_rpcs::update_schedule(&self.state, request).await
506 }
507
508 async fn pause_schedule(
509 &self,
510 request: Request<generated::ScheduleIdRequest>,
511 ) -> Result<Response<generated::PauseScheduleResponse>, Status> {
512 plain_rpcs::pause_schedule(&self.state, request).await
513 }
514
515 async fn resume_schedule(
516 &self,
517 request: Request<generated::ScheduleIdRequest>,
518 ) -> Result<Response<generated::ResumeScheduleResponse>, Status> {
519 plain_rpcs::resume_schedule(&self.state, request).await
520 }
521
522 async fn delete_schedule(
523 &self,
524 request: Request<generated::ScheduleIdRequest>,
525 ) -> Result<Response<generated::DeleteScheduleResponse>, Status> {
526 plain_rpcs::delete_schedule(&self.state, request).await
527 }
528
529 async fn list_schedules(
530 &self,
531 request: Request<generated::ListSchedulesRequest>,
532 ) -> Result<Response<generated::ListSchedulesResponse>, Status> {
533 plain_rpcs::list_schedules(&self.state, request).await
534 }
535
536 async fn describe_schedule(
537 &self,
538 request: Request<generated::ScheduleIdRequest>,
539 ) -> Result<Response<generated::DescribeScheduleResponse>, Status> {
540 plain_rpcs::describe_schedule(&self.state, request).await
541 }
542
543 async fn mint_namespace(
548 &self,
549 request: Request<generated::MintNamespaceRequest>,
550 ) -> Result<Response<generated::MintNamespaceResponse>, Status> {
551 self.mint_namespace_here(request).await
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use std::{net::SocketAddr, sync::Arc};
558
559 use aion::EngineBuilder;
560 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
561 use aion_proto::{
562 ProtoWireError, WireError, WireErrorCode,
563 convert::{decode_core_value, encode_core_value},
564 generated::workflow_service_server::WorkflowService,
565 };
566 use aion_store::{
567 EventStore, InMemoryStore, WriteToken,
568 visibility::{VisibilityRecord, VisibilityStore},
569 };
570 use chrono::Utc;
571 use prost::Message;
572 use serde_json::json;
573 use tonic::{Code, Request};
574
575 use super::convert::{decode_envelope, encode_envelope, encode_payload};
576 use super::*;
577 use crate::{
578 NamespaceResolver,
579 config::{
580 AuthConfig, AuthoringConfig, DeployConfig, ListenConfig, MetricsConfig,
581 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig,
582 WebSocketConfig, WorkerConfig,
583 },
584 };
585
586 const NAMESPACE: &str = "tenant-a";
587 const TOKEN: &str = "test-token";
588
589 async fn server_state(
594 resolver: NamespaceResolver,
595 runtime: RuntimeConfig,
596 ) -> Result<ServerState, Box<dyn std::error::Error>> {
597 #[cfg(feature = "auth")]
598 {
599 let url = crate::auth::test_support::serve_jwks()?;
600 let refresh = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
601 let cache = crate::auth::JwksCache::new(url, refresh).await?;
602 Ok(ServerState::from_parts_with_jwks(resolver, runtime, cache))
603 }
604 #[cfg(not(feature = "auth"))]
605 {
606 tokio::task::yield_now().await;
608 Ok(ServerState::from_parts(resolver, runtime))
609 }
610 }
611
612 #[tokio::test]
613 async fn in_process_tonic_start_and_list_use_shared_handlers()
614 -> Result<(), Box<dyn std::error::Error>> {
615 let backing = Arc::new(InMemoryStore::default());
616 let store: Arc<dyn EventStore> = backing.clone();
617 let visibility_store: Arc<dyn VisibilityStore> = backing;
618 let engine = Arc::new(
619 EngineBuilder::new()
620 .store_arc(Arc::clone(&store))
621 .visibility_store_arc(Arc::clone(&visibility_store))
622 .scheduler_threads(1)
623 .build()
624 .await?,
625 );
626 store
627 .append(
628 WriteToken::recorder(),
629 &workflow_id(),
630 &[started_event()?],
631 0,
632 )
633 .await?;
634 visibility_store
635 .record_visibility(VisibilityRecord {
636 workflow_id: workflow_id(),
637 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(2)),
638 workflow_type: String::from("fixture"),
639 status: WorkflowStatus::Running,
640 start_time: Utc::now(),
641 close_time: None,
642 failed_step: None,
643 failure_reason: None,
644 search_attributes: std::collections::HashMap::from([(
645 crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
646 aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
647 )]),
648 })
649 .await?;
650 let resolver = NamespaceResolver::from_config(
651 crate::config::NamespaceConfig {
652 mode: NamespaceMode::SharedEngine,
653 },
654 engine,
655 );
656 let state = server_state(resolver.clone(), runtime_config()).await?;
657 let service = WorkflowGrpcService::new(state);
658
659 let mut start = Request::new(generated::StartWorkflowRequest {
660 namespace: NAMESPACE.to_owned(),
661 workflow_type: "missing-workflow".to_owned(),
662 input: Some(encode_payload(proto_payload()?)),
663 routing_key: None,
664 task_queue: None,
665 display_name: None,
666 });
667 apply_metadata(start.metadata_mut())?;
668 let start_error = service.start_workflow(start).await;
669 let status = start_error
670 .err()
671 .ok_or_else(|| WireError::backend("expected error"))?;
672 assert_eq!(status.code(), Code::NotFound);
673 let detail = ProtoWireError::decode(status.details())?;
674 assert_eq!(detail.error_type.as_deref(), Some("WorkflowTypeNotFound"));
675 assert!(detail.message.contains("missing-workflow"));
676
677 let list_filter = encode_core_value(
678 NAMESPACE,
679 None,
680 &aion_store::visibility::ListWorkflowsFilter {
681 workflow_type: Some(String::from("fixture")),
682 status: Some(WorkflowStatus::Running),
683 ..aion_store::visibility::ListWorkflowsFilter::default()
684 },
685 )?;
686 let mut list = Request::new(generated::ListWorkflowsRequest {
687 namespace: NAMESPACE.to_owned(),
688 filter: Some(encode_envelope(list_filter)),
689 });
690 apply_metadata(list.metadata_mut())?;
691 let response = service.list_workflows(list).await?.into_inner();
692
693 assert_eq!(response.summaries.len(), 1);
694 let summary = response
695 .summaries
696 .into_iter()
697 .next()
698 .map(decode_envelope)
699 .map(|envelope| decode_core_value::<aion_store::visibility::WorkflowSummary>(&envelope))
700 .transpose()?
701 .ok_or_else(|| WireError::backend("summary missing"))?;
702 assert_eq!(summary.workflow_id, workflow_id());
703 assert_eq!(
709 resolver
710 .verify_workflow_ownership(NAMESPACE, &workflow_id())
711 .await
712 .err()
713 .map(|error| error.to_wire_error().code),
714 Some(WireErrorCode::NotFound)
715 );
716 Ok(())
717 }
718
719 async fn rename_fixture(
730 register_display_name: bool,
731 ) -> Result<(WorkflowGrpcService, Arc<dyn EventStore>), Box<dyn std::error::Error>> {
732 rename_fixture_with_terminal(register_display_name, true).await
733 }
734
735 async fn rename_fixture_with_terminal(
739 register_display_name: bool,
740 terminal: bool,
741 ) -> Result<(WorkflowGrpcService, Arc<dyn EventStore>), Box<dyn std::error::Error>> {
742 let backing = Arc::new(InMemoryStore::default());
743 let store: Arc<dyn EventStore> = backing.clone();
744 let visibility_store: Arc<dyn VisibilityStore> = backing;
745 let mut schema = aion_core::SearchAttributeSchema::new();
746 schema.register(
747 crate::namespace::NAMESPACE_ATTRIBUTE,
748 aion_core::SearchAttributeType::String,
749 )?;
750 if register_display_name {
751 schema.register(
752 crate::namespace::DISPLAY_NAME_ATTRIBUTE,
753 aion_core::SearchAttributeType::String,
754 )?;
755 }
756 let engine = Arc::new(
757 EngineBuilder::new()
758 .store_arc(Arc::clone(&store))
759 .visibility_store_arc(Arc::clone(&visibility_store))
760 .search_attribute_schema(schema)
761 .scheduler_threads(1)
762 .build()
763 .await?,
764 );
765 let namespace_event = |seq: u64| Event::SearchAttributesUpdated {
766 envelope: EventEnvelope {
767 seq,
768 recorded_at: Utc::now(),
769 workflow_id: workflow_id(),
770 },
771 workflow_id: workflow_id(),
772 attributes: std::collections::HashMap::from([(
773 crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
774 aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
775 )]),
776 };
777 let mut events = vec![started_event()?, namespace_event(2)];
778 if terminal {
779 events.push(Event::WorkflowCompleted {
780 envelope: EventEnvelope {
781 seq: 3,
782 recorded_at: Utc::now(),
783 workflow_id: workflow_id(),
784 },
785 result: payload()?,
786 });
787 }
788 store
789 .append(WriteToken::recorder(), &workflow_id(), &events, 0)
790 .await?;
791 let resolver = NamespaceResolver::from_config(
792 crate::config::NamespaceConfig {
793 mode: NamespaceMode::SharedEngine,
794 },
795 engine,
796 );
797 let state = server_state(resolver, runtime_config()).await?;
798 Ok((WorkflowGrpcService::new(state), store))
799 }
800
801 #[tokio::test]
805 async fn in_process_tonic_rename_records_the_name_and_supersedes_it()
806 -> Result<(), Box<dyn std::error::Error>> {
807 let (service, store) = rename_fixture(true).await?;
808
809 let rename = |name: &str| {
810 let mut request = Request::new(generated::RenameRequest {
811 namespace: NAMESPACE.to_owned(),
812 workflow_id: Some(generated::WorkflowId {
813 uuid: workflow_id().to_string(),
814 }),
815 run_id: None,
817 display_name: name.to_owned(),
818 });
819 apply_metadata(request.metadata_mut()).map(|()| request)
820 };
821
822 let response = service.rename(rename(" Nightly settlement ")?).await?;
824 let response = response.into_inner();
825 assert_eq!(response.display_name, "Nightly settlement");
826 assert_eq!(
827 response.run_id.map(|id| id.uuid),
828 Some(aion_core::RunId::new(uuid::Uuid::from_u128(1)).to_string())
829 );
830
831 service
832 .rename(rename("Nightly settlement (rerun)")?)
833 .await?;
834
835 let history = store.read_history(&workflow_id()).await?;
836 assert_eq!(
837 aion_core::display_name(&history).as_deref(),
838 Some("Nightly settlement (rerun)"),
839 "the latest recorded name wins"
840 );
841 let names: Vec<_> = history
842 .iter()
843 .filter_map(|event| match event {
844 Event::SearchAttributesUpdated { attributes, .. } => attributes
845 .get(aion_core::DISPLAY_NAME_ATTRIBUTE)
846 .and_then(|value| match value {
847 aion_core::SearchAttributeValue::String(name) => Some(name.clone()),
848 _ => None,
849 }),
850 _ => None,
851 })
852 .collect();
853 assert_eq!(
854 names,
855 vec![
856 String::from("Nightly settlement"),
857 String::from("Nightly settlement (rerun)")
858 ],
859 "history keeps every name the run has worn"
860 );
861 Ok(())
862 }
863
864 #[tokio::test]
867 async fn in_process_tonic_rename_refuses_a_blank_name() -> Result<(), Box<dyn std::error::Error>>
868 {
869 let (service, store) = rename_fixture(false).await?;
870 let before = store.read_history(&workflow_id()).await?.len();
871
872 let mut request = Request::new(generated::RenameRequest {
873 namespace: NAMESPACE.to_owned(),
874 workflow_id: Some(generated::WorkflowId {
875 uuid: workflow_id().to_string(),
876 }),
877 run_id: None,
878 display_name: String::from(" "),
879 });
880 apply_metadata(request.metadata_mut())?;
881 let status = service
882 .rename(request)
883 .await
884 .err()
885 .ok_or_else(|| WireError::backend("expected a blank-name refusal"))?;
886 assert_eq!(status.code(), Code::InvalidArgument);
887 assert_eq!(
888 store.read_history(&workflow_id()).await?.len(),
889 before,
890 "a refused rename appends nothing"
891 );
892 Ok(())
893 }
894
895 #[tokio::test]
901 async fn in_process_tonic_rename_of_a_non_resident_running_run_is_failed_precondition()
902 -> Result<(), Box<dyn std::error::Error>> {
903 let (service, store) = rename_fixture_with_terminal(true, false).await?;
904 let before = store.read_history(&workflow_id()).await?.len();
905
906 let mut request = Request::new(generated::RenameRequest {
907 namespace: NAMESPACE.to_owned(),
908 workflow_id: Some(generated::WorkflowId {
909 uuid: workflow_id().to_string(),
910 }),
911 run_id: None,
912 display_name: String::from("Nightly settlement"),
913 });
914 apply_metadata(request.metadata_mut())?;
915 let status = service
916 .rename(request)
917 .await
918 .err()
919 .ok_or_else(|| WireError::backend("expected a residency refusal"))?;
920
921 assert_eq!(status.code(), Code::FailedPrecondition);
922 let detail = ProtoWireError::decode(status.details())?;
923 assert_eq!(detail.error_type.as_deref(), Some("InvalidState"));
924 assert!(
925 detail.message.contains("not resident"),
926 "the refusal must name the reason: {}",
927 detail.message
928 );
929 assert_eq!(
930 store.read_history(&workflow_id()).await?.len(),
931 before,
932 "a refused rename appends nothing"
933 );
934 Ok(())
935 }
936
937 #[tokio::test]
941 async fn in_process_tonic_reopen_completed_is_failed_precondition_invalid_state()
942 -> Result<(), Box<dyn std::error::Error>> {
943 let backing = Arc::new(InMemoryStore::default());
944 let store: Arc<dyn EventStore> = backing.clone();
945 let visibility_store: Arc<dyn VisibilityStore> = backing;
946 let engine = Arc::new(
947 EngineBuilder::new()
948 .store_arc(Arc::clone(&store))
949 .visibility_store_arc(Arc::clone(&visibility_store))
950 .scheduler_threads(1)
951 .build()
952 .await?,
953 );
954 store
958 .append(
959 WriteToken::recorder(),
960 &workflow_id(),
961 &[
962 started_event()?,
963 Event::SearchAttributesUpdated {
964 envelope: EventEnvelope {
965 seq: 2,
966 recorded_at: Utc::now(),
967 workflow_id: workflow_id(),
968 },
969 workflow_id: workflow_id(),
970 attributes: std::collections::HashMap::from([(
971 crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
972 aion_core::SearchAttributeValue::String(NAMESPACE.to_owned()),
973 )]),
974 },
975 Event::WorkflowCompleted {
976 envelope: EventEnvelope {
977 seq: 3,
978 recorded_at: Utc::now(),
979 workflow_id: workflow_id(),
980 },
981 result: payload()?,
982 },
983 ],
984 0,
985 )
986 .await?;
987 let resolver = NamespaceResolver::from_config(
988 crate::config::NamespaceConfig {
989 mode: NamespaceMode::SharedEngine,
990 },
991 engine,
992 );
993 let state = server_state(resolver, runtime_config()).await?;
994 let service = WorkflowGrpcService::new(state);
995
996 let mut reopen = Request::new(generated::ReopenRequest {
997 namespace: NAMESPACE.to_owned(),
998 workflow_id: Some(generated::WorkflowId {
999 uuid: workflow_id().to_string(),
1000 }),
1001 run_id: None,
1002 });
1003 apply_metadata(reopen.metadata_mut())?;
1004 let status = service
1005 .reopen(reopen)
1006 .await
1007 .err()
1008 .ok_or_else(|| WireError::backend("expected a reopen precondition error"))?;
1009 assert_eq!(status.code(), Code::FailedPrecondition);
1010 let detail = ProtoWireError::decode(status.details())?;
1011 assert_eq!(detail.error_type.as_deref(), Some("InvalidState"));
1012 assert_eq!(
1013 detail.code,
1014 aion_proto::ProtoWireErrorCode::InvalidState as i32
1015 );
1016 Ok(())
1017 }
1018
1019 fn apply_metadata(
1020 metadata: &mut tonic::metadata::MetadataMap,
1021 ) -> Result<(), Box<dyn std::error::Error>> {
1022 #[cfg(feature = "auth")]
1026 let bearer = crate::auth::test_support::mint_token("alice", NAMESPACE)?;
1027 #[cfg(not(feature = "auth"))]
1028 let bearer = TOKEN.to_owned();
1029 metadata.insert("authorization", format!("Bearer {bearer}").parse()?);
1030 metadata.insert("x-aion-subject", "alice".parse()?);
1031 metadata.insert("x-aion-namespaces", NAMESPACE.parse()?);
1032 Ok(())
1033 }
1034
1035 fn runtime_config() -> RuntimeConfig {
1039 RuntimeConfig {
1040 listen: ListenConfig {
1041 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
1042 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
1043 },
1044 tls: None,
1045 auth: AuthConfig {
1046 enabled: true,
1047 jwks_url: Some(TOKEN.to_owned()),
1048 jwks_refresh_seconds: 300,
1049 },
1050 ops_console: OpsConsoleConfig {
1051 source: OpsConsoleAssetSource::Embedded,
1052 },
1053 namespace: NamespaceConfig {
1054 mode: NamespaceMode::SharedEngine,
1055 },
1056 worker: WorkerConfig {
1057 heartbeat_window: std::time::Duration::from_secs(30),
1058 ..WorkerConfig::default()
1059 },
1060 websocket: WebSocketConfig {
1061 outbound_buffer_bound: 32,
1062 event_broadcast_capacity: Some(64),
1063 cluster_broadcast_capacity: Some(64),
1064 },
1065 workflow_packages: Vec::new(),
1066 deploy: DeployConfig::default(),
1067 authoring: AuthoringConfig::default(),
1068 dev: crate::config::DevConfig::default(),
1069 outbox: crate::config::OutboxConfig::default(),
1070 observability: crate::config::ObservabilityConfig::default(),
1071 mcp: crate::config::ResolvedMcpConfig::default(),
1072 scheduler_threads: 1,
1073 query_timeout: Some(std::time::Duration::from_secs(10)),
1074 default_namespace: "default".to_owned(),
1075 auto_create: crate::config::AutoCreate::Open,
1076 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1077 drain_timeout: std::time::Duration::from_secs(30),
1078 metrics: MetricsConfig { enabled: true },
1079 owned_shards: Vec::new(),
1080 cors_allowed_origins: Vec::new(),
1081 }
1082 }
1083
1084 fn started_event() -> Result<Event, aion_core::PayloadError> {
1085 Ok(Event::WorkflowStarted {
1086 envelope: EventEnvelope {
1087 seq: 1,
1088 recorded_at: Utc::now(),
1089 workflow_id: workflow_id(),
1090 },
1091 workflow_type: "fixture".to_owned(),
1092 input: payload()?,
1093 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
1094 parent_run_id: None,
1095 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1096 })
1097 }
1098
1099 fn proto_payload() -> Result<aion_proto::ProtoPayload, aion_core::PayloadError> {
1100 Ok(payload()?.into())
1101 }
1102
1103 fn payload() -> Result<Payload, aion_core::PayloadError> {
1104 Payload::from_json(&json!({ "fixture": "input" }))
1105 }
1106
1107 fn workflow_id() -> WorkflowId {
1108 WorkflowId::new(uuid::Uuid::from_u128(1))
1109 }
1110}