1mod boot_required;
8
9pub(crate) use boot_required::{
10 BOOT_REQUIRED_FIELD_DEFAULTS, NOT_UPGRADE_HEALABLE, RequiredFieldDefault, boot_required_probe,
11};
12
13use std::{path::PathBuf, sync::Arc};
14
15use aion::{
16 ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
17};
18use aion_store::{EventStore, NamespaceStore, OutboxStore, WorkerDeploymentStore};
19
20use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
21
22#[cfg(feature = "auth")]
23use crate::auth::JwksCache;
24use crate::{
25 config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
26 error::ServerError,
27 namespace::{NamespaceGuard, NamespaceMinter, resolver::NamespaceResolver},
28 observability::{
29 Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
30 metrics::MetricsError,
31 },
32 shutdown::DrainState,
33 worker::{
34 ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
35 supervisor::WorkerSupervisor,
36 },
37};
38
39fn assistant_mcp_endpoint(
61 runtime: &RuntimeConfig,
62) -> Option<crate::assistant::sessions::launch::AssistantEndpoints> {
63 let configured = runtime.listen.http;
64 if configured.port() == 0 {
65 tracing::warn!(
66 "`server.listen_address` names port 0, so this server cannot state an address for an \
67 assistant agent to dial before it has bound; assistant sessions will be opened with \
68 no MCP server of ours — including the `assistant_context` tool, so an agent will not \
69 be able to read what is on the operator's screen"
70 );
71 return None;
72 }
73 let host = if configured.ip().is_unspecified() {
74 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
75 } else {
76 configured.ip()
77 };
78 let authority = std::net::SocketAddr::new(host, configured.port());
79 Some(crate::assistant::sessions::launch::AssistantEndpoints {
80 base: format!("http://{authority}"),
81 aion_mcp_enabled: runtime.mcp.enabled,
82 })
83}
84
85fn build_assistant_sessions(
89 store: Arc<dyn aion_store::assistant::AssistantSessionStore>,
90 runtime: &RuntimeConfig,
91) -> crate::assistant::sessions::AssistantSessions {
92 crate::assistant::sessions::AssistantSessions::new(
93 store,
94 runtime.assistant.clone(),
95 assistant_mcp_endpoint(runtime),
96 aion_integration_acp::catalogue::CATALOGUE,
97 )
98}
99
100fn new_supervisor(
111 store: &Arc<dyn WorkerDeploymentStore>,
112 publisher: &crate::cluster_publisher::ClusterEventPublisher,
113) -> Arc<WorkerSupervisor> {
114 Arc::new(WorkerSupervisor::new(Arc::clone(store), publisher.clone()))
115}
116
117#[derive(Clone)]
119pub struct ServerState {
120 inner: Arc<ServerStateInner>,
121}
122
123struct ServerStateInner {
124 namespace_guard: NamespaceGuard,
125 runtime: RuntimeConfig,
126 worker_registry: ConnectedWorkerRegistry,
127 pending_activities: PendingActivities,
128 heartbeat_tracker: HeartbeatTracker,
129 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters,
136 drain_state: DrainState,
137 metrics: Option<Metrics>,
138 health: Option<HealthState>,
139 activity_mock_registry: Option<ActivityMockRegistry>,
142 outbox_store: Option<Arc<dyn OutboxStore>>,
145 namespace_store: Arc<dyn NamespaceStore>,
154 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
156 worker_supervisor: Arc<WorkerSupervisor>,
161 outbox_wake: Arc<tokio::sync::Notify>,
168 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
172 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
181 attempt_owners: crate::worker::AttemptOwnerIndex,
188 queue_service_state: crate::worker::QueueServiceState,
194 queue_declarations: crate::worker::QueueDeclarationSource,
198 declared_attempts: crate::worker::DeclaredCommandAttempts,
205 declared_bodies: crate::worker::DeclaredBodySource,
210 update_status: crate::update_check::UpdateStatusState,
216 workspace_root: crate::worker::WorkspaceRoot,
224 cluster_self_node: Option<String>,
229 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
234 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
237 watched_peers: Vec<crate::cluster::WatchedPeer>,
240 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
245 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
249 assistant_sessions: crate::assistant::sessions::AssistantSessions,
257 #[cfg(feature = "auth")]
258 jwks_cache: Option<JwksCache>,
259}
260
261impl ServerState {
262 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
271 match std::num::NonZeroUsize::new(64) {
272 Some(value) => value,
273 None => std::num::NonZeroUsize::MIN,
274 };
275
276 pub async fn build(
283 config: ServerConfig,
284 stage: &crate::control::StageReporter,
285 ) -> Result<Self, ServerError> {
286 let (store_config, runtime) = config.into_parts();
287 let connected = connect_store(store_config, stage).await?;
288 Self::build_with_connected_store(connected, runtime, stage).await
289 }
290
291 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
297 where
298 S: EventStore
299 + NamespaceStore
300 + WorkerDeploymentStore
301 + aion_store::workloop::WorkloopStore
302 + aion_store::visibility::VisibilityStore
303 + aion_store::AssistantSessionStore,
304 {
305 let leaf = Arc::new(store);
310 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
311 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
312 let workloop_store: Arc<dyn aion_store::workloop::WorkloopStore> = leaf.clone();
313 let visibility_store: Arc<dyn aion_store::visibility::VisibilityStore> = leaf.clone();
314 let assistant_store: Arc<dyn aion_store::AssistantSessionStore> = leaf.clone();
315 Self::build_with_connected_store(
319 ConnectedStore::local(
320 leaf,
321 None,
322 namespace_store,
323 worker_deployment_store,
324 workloop_store,
325 visibility_store,
326 assistant_store,
327 ),
328 runtime,
329 &crate::control::StageReporter::detached(),
330 )
331 .await
332 }
333
334 async fn build_with_connected_store(
335 connected: ConnectedStore,
336 runtime: RuntimeConfig,
337 stage: &crate::control::StageReporter,
338 ) -> Result<Self, ServerError> {
339 let cluster_self_node = connected.cluster_self_node();
340 let outbox_store = connected.outbox_store;
341 let bootstrap_coordinator = connected.bootstrap_coordinator;
342 let cluster_responder = connected.cluster_responder;
343 let cluster_store = connected.cluster_store;
344 let watched_peers = connected.watched_peers;
345 let RoutingState {
349 shard_directory,
350 request_forwarder,
351 mint_routing,
352 } = build_routing_state(
353 cluster_store.as_ref(),
354 connected.directory_peers,
355 connected.self_node_id,
356 );
357 let (event_broadcast_capacity, query_timeout, workloop_sweep_interval) =
358 required_engine_seams(&runtime)?;
359 let (cluster_publisher, transcript_publisher) =
360 build_real_time_publishers(&runtime, connected.observability_store)?;
361 let (metrics, outbox_wake, instrumented_store) =
362 build_instrumented_store(&runtime, connected.event_store)?;
363 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
364 let seams = build_worker_seams(
365 &runtime,
366 &cluster_publisher,
367 &metrics,
368 &connected.namespace_store,
369 &connected.worker_deployment_store,
370 mint_routing,
371 );
372 let (
373 activity_dispatcher,
374 activity_mock_registry,
375 attempt_owners,
376 workspace_root,
377 update_status,
378 ) = build_decorated_dispatcher(&runtime, &seams, transcript_publisher.clone());
379
380 let engine = boot_engine(EngineAssembly {
381 seams: &seams,
382 instrumented_store: &instrumented_store,
383 event_broadcast_capacity,
384 query_timeout,
385 workloop_store: Arc::clone(&connected.workloop_store),
386 visibility_store: Arc::clone(&connected.visibility_store),
387 workloop_sweep_interval,
388 activity_dispatcher,
389 active_registry: Arc::new(aion::Registry::default()),
390 bootstrap_coordinator,
391 runtime: &runtime,
392 stage,
393 })
394 .await?;
395 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
396 let worker_supervisor =
397 new_supervisor(&connected.worker_deployment_store, &cluster_publisher);
398 #[cfg(feature = "auth")]
399 let jwks_cache = build_jwks_cache(&runtime).await?;
400 let assistant_sessions =
401 build_assistant_sessions(Arc::clone(&connected.assistant_store), &runtime);
402 Ok(Self {
403 inner: Arc::new(ServerStateInner {
404 namespace_guard: NamespaceGuard::new(resolver),
405 assistant_sessions,
406 runtime,
407 metrics: exported_metrics,
408 worker_registry: seams.worker_registry,
409 pending_activities: seams.pending_activities,
410 heartbeat_tracker: seams.heartbeat_tracker,
411 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
412 drain_state: seams.drain_state,
413 health: Some(HealthState::new(instrumented_store, true)),
414 activity_mock_registry,
415 outbox_store,
416 namespace_store: connected.namespace_store,
417 worker_supervisor,
418 worker_deployment_store: connected.worker_deployment_store,
419 outbox_wake,
420 cluster_publisher,
421 transcript_publisher,
422 attempt_owners,
423 queue_service_state: seams.queue_service_state,
424 queue_declarations: seams.queue_declarations,
425 declared_attempts: seams.declared_attempts,
426 declared_bodies: seams.declared_bodies.clone(),
427 update_status,
428 workspace_root,
429 cluster_self_node,
430 cluster_responder,
431 cluster_store,
432 watched_peers,
433 shard_directory,
434 request_forwarder,
435 #[cfg(feature = "auth")]
436 jwks_cache,
437 }),
438 })
439 }
440
441 #[must_use]
443 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
444 Self::from_parts_with_namespace_store(
449 namespace_resolver,
450 runtime,
451 Arc::new(aion_store::InMemoryStore::default()),
452 )
453 }
454
455 #[must_use]
462 pub fn from_parts_with_namespace_store<S>(
463 namespace_resolver: NamespaceResolver,
464 runtime: RuntimeConfig,
465 store: Arc<S>,
466 ) -> Self
467 where
468 S: NamespaceStore + WorkerDeploymentStore,
469 {
470 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
471 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
472 Self::from_parts_with_control_stores(
473 namespace_resolver,
474 runtime,
475 namespace_store,
476 worker_deployment_store,
477 )
478 }
479
480 #[must_use]
485 pub fn from_parts_with_control_stores(
486 namespace_resolver: NamespaceResolver,
487 runtime: RuntimeConfig,
488 namespace_store: Arc<dyn NamespaceStore>,
489 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
490 ) -> Self {
491 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
492 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
498 let bounds = transcript_bounds(&runtime);
502 let batch = required_transcript_batch_policy(&runtime)
506 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
507 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
508 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
509 );
510 let drain_state = DrainState::default();
511 let assistant_sessions =
512 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
513 Self {
514 inner: Arc::new(ServerStateInner {
515 namespace_guard: NamespaceGuard::new(namespace_resolver),
516 assistant_sessions,
517 runtime,
518 worker_registry: ConnectedWorkerRegistry::default()
519 .with_worker_deployment_store(worker_deployment_store.clone())
520 .with_cluster_publisher(cluster_publisher.clone()),
521 pending_activities,
522 heartbeat_tracker,
523 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
524 drain_state: drain_state.clone(),
525 metrics: None,
526 health: None,
527 activity_mock_registry: None,
528 outbox_store: None,
529 namespace_store,
530 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
531 worker_deployment_store,
532 outbox_wake: Arc::new(tokio::sync::Notify::new()),
533 cluster_publisher,
534 transcript_publisher: build_transcript_publisher(
538 None,
539 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
540 bounds,
541 batch,
542 ),
543 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
544 queue_service_state: crate::worker::QueueServiceState::default(),
545 queue_declarations: crate::worker::QueueDeclarationSource::default(),
546 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
550 declared_bodies: crate::worker::DeclaredBodySource::default(),
551 update_status: crate::update_check::UpdateStatusState::default(),
555 workspace_root: crate::worker::WorkspaceRoot::resolve(),
559 cluster_self_node: None,
560 cluster_responder: None,
561 cluster_store: None,
562 watched_peers: Vec::new(),
563 shard_directory: None,
564 request_forwarder: None,
565 #[cfg(feature = "auth")]
566 jwks_cache: None,
567 }),
568 }
569 }
570
571 #[cfg(feature = "auth")]
580 #[must_use]
581 pub fn from_parts_with_namespace_store_and_jwks<S>(
582 namespace_resolver: NamespaceResolver,
583 runtime: RuntimeConfig,
584 store: Arc<S>,
585 jwks_cache: JwksCache,
586 ) -> Self
587 where
588 S: NamespaceStore + WorkerDeploymentStore,
589 {
590 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
591 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
592 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
593 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
599 let bounds = transcript_bounds(&runtime);
603 let batch = required_transcript_batch_policy(&runtime)
607 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
608 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
609 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
610 );
611 let drain_state = DrainState::default();
612 let assistant_sessions =
613 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
614 Self {
615 inner: Arc::new(ServerStateInner {
616 namespace_guard: NamespaceGuard::new(namespace_resolver),
617 assistant_sessions,
618 runtime,
619 worker_registry: ConnectedWorkerRegistry::default()
620 .with_worker_deployment_store(worker_deployment_store.clone())
621 .with_cluster_publisher(cluster_publisher.clone()),
622 pending_activities,
623 heartbeat_tracker,
624 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
625 drain_state: drain_state.clone(),
626 metrics: None,
627 health: None,
628 activity_mock_registry: None,
629 outbox_store: None,
630 namespace_store,
631 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
632 worker_deployment_store,
633 outbox_wake: Arc::new(tokio::sync::Notify::new()),
634 cluster_publisher,
635 transcript_publisher: build_transcript_publisher(
639 None,
640 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
641 bounds,
642 batch,
643 ),
644 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
645 queue_service_state: crate::worker::QueueServiceState::default(),
646 queue_declarations: crate::worker::QueueDeclarationSource::default(),
647 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
651 declared_bodies: crate::worker::DeclaredBodySource::default(),
652 update_status: crate::update_check::UpdateStatusState::default(),
656 workspace_root: crate::worker::WorkspaceRoot::resolve(),
660 cluster_self_node: None,
661 cluster_responder: None,
662 cluster_store: None,
663 watched_peers: Vec::new(),
664 shard_directory: None,
665 request_forwarder: None,
666 jwks_cache: Some(jwks_cache),
667 }),
668 }
669 }
670
671 #[cfg(feature = "auth")]
677 #[must_use]
678 pub fn from_parts_with_jwks(
679 namespace_resolver: NamespaceResolver,
680 runtime: RuntimeConfig,
681 jwks_cache: JwksCache,
682 ) -> Self {
683 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
687 Arc::new(aion_store::InMemoryStore::default());
688 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
689 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
695 let bounds = transcript_bounds(&runtime);
699 let batch = required_transcript_batch_policy(&runtime)
703 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
704 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
705 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
706 );
707 let drain_state = DrainState::default();
708 let assistant_sessions =
709 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
710 Self {
711 inner: Arc::new(ServerStateInner {
712 namespace_guard: NamespaceGuard::new(namespace_resolver),
713 assistant_sessions,
714 runtime,
715 worker_registry: ConnectedWorkerRegistry::default(),
716 pending_activities,
717 heartbeat_tracker,
718 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
719 drain_state: drain_state.clone(),
720 metrics: None,
721 health: None,
722 activity_mock_registry: None,
723 outbox_store: None,
724 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
729 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
730 worker_deployment_store: fallback_deployment_store,
731 outbox_wake: Arc::new(tokio::sync::Notify::new()),
732 cluster_publisher,
733 transcript_publisher: build_transcript_publisher(
737 None,
738 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
739 bounds,
740 batch,
741 ),
742 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
743 queue_service_state: crate::worker::QueueServiceState::default(),
744 queue_declarations: crate::worker::QueueDeclarationSource::default(),
745 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
749 declared_bodies: crate::worker::DeclaredBodySource::default(),
750 update_status: crate::update_check::UpdateStatusState::default(),
754 workspace_root: crate::worker::WorkspaceRoot::resolve(),
758 cluster_self_node: None,
759 cluster_responder: None,
760 cluster_store: None,
761 watched_peers: Vec::new(),
762 shard_directory: None,
763 request_forwarder: None,
764 jwks_cache: Some(jwks_cache),
765 }),
766 }
767 }
768
769 #[must_use]
771 pub fn from_parts_with_registry(
772 namespace_resolver: NamespaceResolver,
773 runtime: RuntimeConfig,
774 worker_registry: ConnectedWorkerRegistry,
775 ) -> Self {
776 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
780 Arc::new(aion_store::InMemoryStore::default());
781 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
782 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
788 let bounds = transcript_bounds(&runtime);
792 let batch = required_transcript_batch_policy(&runtime)
796 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
797 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
798 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
799 );
800 let drain_state = DrainState::default();
801 let assistant_sessions =
802 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
803 Self {
804 inner: Arc::new(ServerStateInner {
805 namespace_guard: NamespaceGuard::new(namespace_resolver),
806 assistant_sessions,
807 runtime,
808 worker_registry,
809 pending_activities,
810 heartbeat_tracker,
811 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
812 drain_state: drain_state.clone(),
813 metrics: None,
814 health: None,
815 activity_mock_registry: None,
816 outbox_store: None,
817 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
822 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
823 worker_deployment_store: fallback_deployment_store,
824 outbox_wake: Arc::new(tokio::sync::Notify::new()),
825 cluster_publisher,
826 transcript_publisher: build_transcript_publisher(
830 None,
831 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
832 bounds,
833 batch,
834 ),
835 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
836 queue_service_state: crate::worker::QueueServiceState::default(),
837 queue_declarations: crate::worker::QueueDeclarationSource::default(),
838 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
842 declared_bodies: crate::worker::DeclaredBodySource::default(),
843 update_status: crate::update_check::UpdateStatusState::default(),
847 workspace_root: crate::worker::WorkspaceRoot::resolve(),
851 cluster_self_node: None,
852 cluster_responder: None,
853 cluster_store: None,
854 watched_peers: Vec::new(),
855 shard_directory: None,
856 request_forwarder: None,
857 #[cfg(feature = "auth")]
858 jwks_cache: None,
859 }),
860 }
861 }
862
863 #[must_use]
865 pub fn namespace_guard(&self) -> &NamespaceGuard {
866 &self.inner.namespace_guard
867 }
868
869 #[must_use]
871 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
872 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
873 }
874
875 #[must_use]
877 pub fn runtime_config(&self) -> &RuntimeConfig {
878 &self.inner.runtime
879 }
880
881 #[must_use]
889 pub(crate) fn update_status(&self) -> &crate::update_check::UpdateStatusState {
890 &self.inner.update_status
891 }
892
893 #[must_use]
903 pub fn workspace_root(&self) -> &crate::worker::WorkspaceRoot {
904 &self.inner.workspace_root
905 }
906
907 #[must_use]
915 pub fn assistant_sessions(&self) -> &crate::assistant::sessions::AssistantSessions {
916 &self.inner.assistant_sessions
917 }
918
919 #[must_use]
921 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
922 &self.inner.worker_registry
923 }
924
925 pub fn cancel_in_flight_activities(
944 &self,
945 workflow_id: &aion_core::WorkflowId,
946 ) -> Result<crate::worker::InFlightCancellation, ServerError> {
947 crate::worker::cancel_in_flight_activities(
948 self.heartbeat_tracker(),
949 self.worker_registry(),
950 self.declared_attempts(),
951 workflow_id,
952 )
953 }
954
955 #[must_use]
960 pub fn declared_attempts(&self) -> &crate::worker::DeclaredCommandAttempts {
961 &self.inner.declared_attempts
962 }
963
964 #[must_use]
969 pub fn declared_bodies(&self) -> &crate::worker::DeclaredBodySource {
970 &self.inner.declared_bodies
971 }
972
973 #[must_use]
977 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
978 &self.inner.cluster_publisher
979 }
980
981 #[must_use]
986 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
987 &self.inner.transcript_publisher
988 }
989
990 #[must_use]
994 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
995 &self.inner.attempt_owners
996 }
997
998 #[must_use]
1001 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
1002 &self.inner.queue_service_state
1003 }
1004
1005 #[must_use]
1009 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
1010 &self.inner.queue_declarations
1011 }
1012
1013 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
1024 self.inner.queue_service_state.unserved()
1025 }
1026
1027 pub fn unrecoverable_runs(
1045 &self,
1046 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
1047 self.engine()?
1048 .registry()
1049 .unrecoverable()
1050 .list()
1051 .map_err(ServerError::from)
1052 }
1053
1054 #[must_use]
1066 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
1067 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
1068 #[cfg(feature = "liminal-transport")]
1069 {
1070 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
1071 }
1072 #[cfg(not(feature = "liminal-transport"))]
1073 {
1074 std::sync::Arc::new(NullInterventionTransport)
1075 }
1076 };
1077 crate::worker::InterventionRouter::new(
1078 self.inner.worker_registry.clone(),
1079 self.inner.attempt_owners.clone(),
1080 transport,
1081 )
1082 .with_transcript_publisher(self.inner.transcript_publisher.clone())
1085 }
1086
1087 #[must_use]
1091 pub fn cluster_self_node(&self) -> Option<&str> {
1092 self.inner.cluster_self_node.as_deref()
1093 }
1094
1095 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
1108 self.inner
1109 .namespace_guard
1110 .resolver()
1111 .engine()
1112 .map(Arc::clone)
1113 }
1114
1115 #[must_use]
1117 pub fn pending_activities(&self) -> &PendingActivities {
1118 &self.inner.pending_activities
1119 }
1120
1121 #[must_use]
1125 pub fn read_provenance(&self) -> aion_core::ReadProvenance {
1126 aion_core::ReadProvenance::new(
1127 self.inner
1128 .pending_activities
1129 .lease_recorder()
1130 .ledger()
1131 .failures(),
1132 )
1133 }
1134
1135 #[must_use]
1137 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
1138 &self.inner.heartbeat_tracker
1139 }
1140
1141 #[must_use]
1147 pub fn grpc_liveness_waiters(&self) -> &crate::worker::GrpcLivenessWaiters {
1148 &self.inner.grpc_liveness_waiters
1149 }
1150
1151 #[must_use]
1153 pub fn drain_state(&self) -> &DrainState {
1154 &self.inner.drain_state
1155 }
1156
1157 #[must_use]
1159 pub fn metrics(&self) -> Option<&Metrics> {
1160 self.inner.metrics.as_ref()
1161 }
1162
1163 #[must_use]
1165 pub fn health(&self) -> Option<&HealthState> {
1166 self.inner.health.as_ref()
1167 }
1168
1169 #[must_use]
1174 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
1175 self.inner.activity_mock_registry.as_ref()
1176 }
1177
1178 #[must_use]
1184 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
1185 self.inner.outbox_store.clone()
1186 }
1187
1188 #[must_use]
1197 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
1198 &self.inner.namespace_store
1199 }
1200
1201 #[must_use]
1203 pub fn worker_deployment_store(&self) -> &Arc<dyn WorkerDeploymentStore> {
1204 &self.inner.worker_deployment_store
1205 }
1206
1207 #[must_use]
1214 pub fn worker_supervisor(&self) -> &Arc<WorkerSupervisor> {
1215 &self.inner.worker_supervisor
1216 }
1217
1218 #[must_use]
1227 pub fn namespace_minter(&self) -> NamespaceMinter {
1228 let minter = NamespaceMinter::new(
1229 Arc::clone(&self.inner.namespace_store),
1230 self.inner.runtime.auto_create,
1231 )
1232 .with_cluster_publisher(self.inner.cluster_publisher.clone());
1237 match self.namespace_routing() {
1242 Some(routing) => minter.with_routing(routing),
1243 None => minter,
1244 }
1245 }
1246
1247 #[must_use]
1257 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1258 build_namespace_routing(
1259 self.cluster_store(),
1260 self.shard_directory(),
1261 self.request_forwarder(),
1262 )
1263 }
1264
1265 #[must_use]
1271 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
1272 Arc::clone(&self.inner.outbox_wake)
1273 }
1274
1275 #[must_use]
1281 pub fn is_clustered(&self) -> bool {
1282 self.inner.cluster_responder.is_some()
1283 }
1284
1285 #[must_use]
1290 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
1291 self.inner.cluster_store.as_ref()
1292 }
1293
1294 #[must_use]
1298 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
1299 self.inner.shard_directory.as_ref()
1300 }
1301
1302 #[must_use]
1305 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
1306 self.inner.request_forwarder.as_ref()
1307 }
1308
1309 #[must_use]
1326 pub fn spawn_heartbeat_sweeper(
1327 &self,
1328 shutdown: tokio::sync::watch::Receiver<bool>,
1329 ) -> tokio::task::JoinHandle<()> {
1330 let sweeper = crate::worker::HeartbeatSweeper::new(
1331 self.inner.heartbeat_tracker.clone(),
1332 self.inner.worker_registry.clone(),
1333 self.inner.pending_activities.clone(),
1334 self.inner.drain_state.clone(),
1335 self.inner.runtime.worker.heartbeat_window,
1336 )
1337 .with_queue_state(self.inner.queue_service_state.clone());
1341 tokio::spawn(sweeper.run(shutdown))
1342 }
1343
1344 pub fn spawn_startup_catchup(
1368 &self,
1369 mut shutdown: tokio::sync::watch::Receiver<bool>,
1370 ) -> Result<tokio::task::JoinHandle<()>, ServerError> {
1371 let engine = self.engine()?;
1372 Ok(tokio::spawn(async move {
1373 tracing::info!(
1374 "startup catch-up running behind open doors: owed timer fires, \
1375 schedule catch-up"
1376 );
1377 let catchup = engine.run_startup_catchup();
1378 tokio::pin!(catchup);
1379 tokio::select! {
1380 result = &mut catchup => match result {
1381 Ok(()) => {
1382 tracing::info!("startup catch-up complete");
1383 }
1384 Err(error) => {
1385 tracing::error!(
1386 %error,
1387 "startup catch-up failed; owed timer fires and schedule \
1388 catch-up remain undelivered — restart the server to re-run \
1389 the sweep from durable state"
1390 );
1391 }
1392 },
1393 _ = shutdown.changed() => {
1394 tracing::info!(
1395 "startup catch-up interrupted by shutdown; the next boot's \
1396 sweep resumes from durable state"
1397 );
1398 }
1399 }
1400 }))
1401 }
1402
1403 #[cfg(feature = "liminal-transport")]
1414 #[must_use]
1415 pub fn spawn_liminal_liveness_probe(
1416 &self,
1417 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1418 shutdown: tokio::sync::watch::Receiver<bool>,
1419 ) -> tokio::task::JoinHandle<()> {
1420 let probe = crate::worker::LivenessProbe::across_transports(
1421 Some(notifier),
1422 Some(self.inner.grpc_liveness_waiters.clone()),
1427 self.inner.heartbeat_tracker.clone(),
1428 self.inner.worker_registry.clone(),
1429 self.inner.runtime.worker.heartbeat_window,
1430 );
1431 tokio::spawn(probe.run(shutdown))
1432 }
1433
1434 pub fn spawn_cluster_supervisor(
1450 &self,
1451 config: crate::cluster::SupervisorConfig,
1452 shutdown: tokio::sync::watch::Receiver<bool>,
1453 ) -> Result<bool, ServerError> {
1454 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1455 return Ok(false);
1456 };
1457 if self.inner.watched_peers.is_empty() {
1458 return Ok(false);
1459 }
1460 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1461 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1465 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1466 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1472 engine,
1473 self.inner.outbox_store.clone(),
1474 ));
1475 let supervisor = crate::cluster::ClusterSupervisor::new(
1476 cluster_store,
1477 adopter,
1478 self.inner.watched_peers.clone(),
1479 config,
1480 )
1481 .with_publisher(publisher, self_node);
1482 if !supervisor.watches_any() {
1483 return Ok(false);
1484 }
1485 tokio::spawn(supervisor.run(shutdown));
1486 Ok(true)
1487 }
1488
1489 #[cfg(feature = "auth")]
1491 #[must_use]
1492 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1493 self.inner.jwks_cache.as_ref()
1494 }
1495
1496 pub fn shutdown(&self) -> Result<(), ServerError> {
1503 self.inner.namespace_guard.resolver().shutdown_engine()
1504 }
1505}
1506
1507#[cfg(feature = "auth")]
1508async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1509 if !runtime.auth.enabled {
1510 return Ok(None);
1511 }
1512 let Some(url) = runtime.auth.jwks_url.clone() else {
1513 return Err(ServerError::Config {
1514 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1515 });
1516 };
1517 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1518 let cache = JwksCache::new(url, interval)
1519 .await
1520 .map_err(|error| ServerError::Config {
1521 message: format!("auth jwks initial fetch failed: {error}"),
1522 })?;
1523 Ok(Some(cache))
1524}
1525
1526fn metrics_config_error(error: &MetricsError) -> ServerError {
1527 ServerError::Config {
1528 message: error.to_string(),
1529 }
1530}
1531
1532struct EngineAssembly<'a> {
1534 seams: &'a WorkerSeams,
1536 instrumented_store: &'a Arc<InstrumentedEventStore>,
1538 event_broadcast_capacity: std::num::NonZeroUsize,
1540 query_timeout: std::time::Duration,
1542 workloop_store: Arc<dyn aion_store::workloop::WorkloopStore>,
1546 visibility_store: Arc<dyn aion_store::visibility::VisibilityStore>,
1550 workloop_sweep_interval: std::time::Duration,
1553 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1555 active_registry: Arc<aion::Registry>,
1557 bootstrap_coordinator: bool,
1559 runtime: &'a RuntimeConfig,
1561 stage: &'a crate::control::StageReporter,
1565}
1566
1567fn server_search_attribute_schema() -> Result<aion_core::SearchAttributeSchema, ServerError> {
1576 let mut schema = aion_core::SearchAttributeSchema::new();
1577 for (name, label) in [
1578 (crate::namespace::NAMESPACE_ATTRIBUTE, "namespace"),
1579 (crate::namespace::TASK_QUEUE_ATTRIBUTE, "task_queue"),
1580 (crate::namespace::DISPLAY_NAME_ATTRIBUTE, "display_name"),
1581 ] {
1582 schema
1583 .register(name, aion_core::SearchAttributeType::String)
1584 .map_err(|error| ServerError::Config {
1585 message: format!("failed to register {label} search attribute: {error}"),
1586 })?;
1587 }
1588 Ok(schema)
1589}
1590
1591async fn boot_engine(assembly: EngineAssembly<'_>) -> Result<Arc<aion::Engine>, ServerError> {
1601 let search_attribute_schema = server_search_attribute_schema()?;
1602 let runtime = assembly.runtime;
1603 let builder = EngineBuilder::new()
1604 .store_arc(assembly.instrumented_store.clone())
1605 .event_streaming(assembly.event_broadcast_capacity)
1606 .visibility_store_arc(assembly.visibility_store)
1607 .search_attribute_schema(search_attribute_schema)
1608 .scheduler_threads(runtime.scheduler_threads)
1609 .stop_drain_timeout(
1613 runtime
1614 .stop_drain_timeout
1615 .ok_or_else(|| ServerError::Config {
1616 message: String::from(crate::config::STOP_DRAIN_TIMEOUT_REQUIRED),
1617 })?,
1618 )
1619 .outbox_enabled(runtime.outbox.enabled)
1620 .activity_dispatcher(assembly.activity_dispatcher)
1621 .active_registry(assembly.active_registry)
1622 .production_recovery_seam()
1623 .defer_startup_recovery()
1630 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1631 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1632 })
1633 .query_timeout(assembly.query_timeout)
1634 .with_workloop_service(
1642 Arc::clone(&assembly.workloop_store),
1643 assembly.workloop_sweep_interval,
1644 )
1645 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1651 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1652 let builder = if runtime.owned_shards.is_empty() {
1658 builder
1659 } else {
1660 builder.owned_shards(runtime.owned_shards.iter().copied())
1661 };
1662 let builder = match runtime.jit_threshold {
1668 Some(threshold) => builder.scheduler_jit_threshold(threshold),
1669 None => builder,
1670 };
1671 let engine = Arc::new(builder.build().await.map_err(ServerError::from)?);
1672 install_engine_backed_seams(assembly.seams, &engine, runtime.outbox.enabled);
1673 assembly.stage.report(
1686 crate::control::stage::STAGE_ENGINE_RECOVERY,
1687 "recovering resident workflows from durable state".to_owned(),
1688 );
1689 engine
1690 .recover_workflows_on_startup()
1691 .await
1692 .map_err(ServerError::from)?;
1693 assembly.stage.report(
1694 crate::control::stage::STAGE_ENGINE_RECOVERY,
1695 "resident workflows recovered".to_owned(),
1696 );
1697 Ok(engine)
1698}
1699
1700fn required_engine_seams(
1705 runtime: &RuntimeConfig,
1706) -> Result<
1707 (
1708 std::num::NonZeroUsize,
1709 std::time::Duration,
1710 std::time::Duration,
1711 ),
1712 ServerError,
1713> {
1714 let event_broadcast_capacity = runtime
1715 .websocket
1716 .event_broadcast_capacity
1717 .and_then(std::num::NonZeroUsize::new)
1718 .ok_or_else(|| ServerError::Config {
1719 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1720 })?;
1721 let query_timeout = runtime
1722 .query_timeout
1723 .filter(|timeout| !timeout.is_zero())
1724 .ok_or_else(|| ServerError::Config {
1725 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1726 })?;
1727 let workloop_sweep_interval = runtime
1732 .workloop_sweep_interval
1733 .filter(|interval| !interval.is_zero())
1734 .ok_or_else(|| ServerError::Config {
1735 message: crate::config::WORKLOOP_SWEEP_INTERVAL_REQUIRED.to_owned(),
1736 })?;
1737 Ok((
1738 event_broadcast_capacity,
1739 query_timeout,
1740 workloop_sweep_interval,
1741 ))
1742}
1743
1744fn install_outbox_delivery(
1751 pending_activities: &PendingActivities,
1752 engine: &Arc<aion::Engine>,
1753 outbox_enabled: bool,
1754) {
1755 if outbox_enabled {
1756 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1757 Arc::clone(engine),
1758 ));
1759 pending_activities.set_outbox_delivery(callback);
1760 }
1761}
1762
1763struct WorkerSeams {
1765 worker_registry: ConnectedWorkerRegistry,
1766 pending_activities: PendingActivities,
1767 heartbeat_tracker: HeartbeatTracker,
1768 drain_state: DrainState,
1769 queue_declarations: crate::worker::QueueDeclarationSource,
1770 queue_service_state: crate::worker::QueueServiceState,
1771 declared_bodies: crate::worker::DeclaredBodySource,
1772 declared_attempts: crate::worker::DeclaredCommandAttempts,
1778 metrics: Metrics,
1781 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
1785}
1786
1787fn build_worker_seams(
1792 runtime: &RuntimeConfig,
1793 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1794 metrics: &Metrics,
1795 namespace_store: &Arc<dyn NamespaceStore>,
1796 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1797 mint_routing: Option<crate::namespace::NamespaceRouting>,
1798) -> WorkerSeams {
1799 let worker_registry = ConnectedWorkerRegistry::default()
1800 .with_cluster_publisher(cluster_publisher.clone())
1801 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1802 .with_worker_deployment_store(worker_deployment_store.clone());
1803 let worker_registry = match mint_routing {
1809 Some(routing) => worker_registry.with_namespace_routing(routing),
1810 None => worker_registry,
1811 };
1812 let drain_state = DrainState::default();
1813 WorkerSeams {
1814 worker_registry,
1815 pending_activities: PendingActivities::new(runtime.worker.heartbeat_window),
1820 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1821 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
1822 drain_state,
1823 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1824 queue_service_state: crate::worker::QueueServiceState::default(),
1825 declared_bodies: crate::worker::DeclaredBodySource::default(),
1826 metrics: metrics.clone(),
1827 cluster_publisher: cluster_publisher.clone(),
1828 }
1829}
1830
1831fn install_queue_declarations(
1838 queue_declarations: &crate::worker::QueueDeclarationSource,
1839 engine: &Arc<aion::Engine>,
1840) {
1841 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1842 Arc::clone(engine),
1843 )));
1844}
1845
1846fn install_declared_bodies(
1861 declared_bodies: &crate::worker::DeclaredBodySource,
1862 engine: &Arc<aion::Engine>,
1863) {
1864 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1865 Arc::clone(engine),
1866 )));
1867}
1868
1869fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1875 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1876 seams.pending_activities.set_lease_recorder(
1880 Arc::new(crate::worker::EngineLeaseRecorder::new(Arc::clone(engine))),
1881 Some(seams.metrics.clone()),
1882 );
1883 install_queue_declarations(&seams.queue_declarations, engine);
1884 install_declared_bodies(&seams.declared_bodies, engine);
1885}
1886
1887fn build_bridge_dispatcher(
1900 runtime: &RuntimeConfig,
1901 seams: &WorkerSeams,
1902) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1903 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1904 let dispatcher = WorkerActivityDispatcher::new(
1905 seams.worker_registry.clone(),
1906 runtime.default_namespace.clone(),
1907 seams.heartbeat_tracker.clone(),
1908 )
1909 .with_pending(seams.pending_activities.clone())
1910 .with_drain_state(seams.drain_state.clone())
1911 .with_tokio_handle(tokio::runtime::Handle::current())
1912 .with_attempt_owners(attempt_owners.clone())
1913 .with_queue_service(runtime.worker.queue_service.clone())
1914 .with_queue_declarations(seams.queue_declarations.clone())
1915 .with_queue_state(seams.queue_service_state.clone())
1916 .with_cluster_publisher(seams.cluster_publisher.clone());
1917 (dispatcher, attempt_owners)
1918}
1919
1920fn build_instrumented_store(
1933 runtime: &RuntimeConfig,
1934 event_store: Arc<dyn EventStore>,
1935) -> Result<
1936 (
1937 Metrics,
1938 Arc<tokio::sync::Notify>,
1939 Arc<InstrumentedEventStore>,
1940 ),
1941 ServerError,
1942> {
1943 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1944 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1945 let instrumented_store = Arc::new(
1946 InstrumentedEventStore::new(
1947 event_store,
1948 metrics.clone(),
1949 runtime.default_namespace.clone(),
1950 )
1951 .with_outbox_wake(Arc::clone(&outbox_wake)),
1952 );
1953 Ok((metrics, outbox_wake, instrumented_store))
1954}
1955
1956fn build_decorated_dispatcher(
1961 runtime: &RuntimeConfig,
1962 seams: &WorkerSeams,
1963 transcript: crate::activity_publisher::ActivityEventPublisher,
1964) -> (
1965 Arc<dyn ActivityDispatcher>,
1966 Option<ActivityMockRegistry>,
1967 crate::worker::AttemptOwnerIndex,
1968 crate::worker::WorkspaceRoot,
1969 crate::update_check::UpdateStatusState,
1970) {
1971 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1975 let update_status = crate::update_check::UpdateStatusState::default();
1978 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1979 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1980 dispatcher,
1981 seams.declared_bodies.clone(),
1982 seams.declared_attempts.clone(),
1983 workspace_root.clone(),
1984 transcript,
1985 update_status.clone(),
1986 runtime.dev.enabled,
1987 );
1988 (
1989 activity_dispatcher,
1990 activity_mock_registry,
1991 attempt_owners,
1992 workspace_root,
1993 update_status,
1994 )
1995}
1996
1997fn decorate_activity_dispatcher(
1998 dispatcher: WorkerActivityDispatcher,
1999 declared_bodies: crate::worker::DeclaredBodySource,
2000 declared_attempts: crate::worker::DeclaredCommandAttempts,
2001 workspace_root: crate::worker::WorkspaceRoot,
2002 transcript: crate::activity_publisher::ActivityEventPublisher,
2003 update_status: crate::update_check::UpdateStatusState,
2004 dev_enabled: bool,
2005) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
2006 let declared = crate::worker::DeclaredCommandDispatcher::new(
2014 Arc::new(dispatcher),
2015 declared_bodies.clone(),
2016 declared_attempts,
2017 tokio::runtime::Handle::current(),
2018 workspace_root,
2019 transcript,
2020 );
2021 let observed = crate::update_check::UpdateCheckObserver::new(
2022 Arc::new(declared),
2023 declared_bodies,
2024 update_status,
2025 );
2026 if dev_enabled {
2027 let registry = ActivityMockRegistry::new();
2028 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
2029 (Arc::new(decorated), Some(registry))
2030 } else {
2031 (Arc::new(observed), None)
2032 }
2033}
2034
2035fn required_cluster_broadcast_capacity(
2040 runtime: &RuntimeConfig,
2041) -> Result<std::num::NonZeroUsize, ServerError> {
2042 runtime
2043 .websocket
2044 .cluster_broadcast_capacity
2045 .and_then(std::num::NonZeroUsize::new)
2046 .ok_or_else(|| ServerError::Config {
2047 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
2048 })
2049}
2050
2051fn build_real_time_publishers(
2064 runtime: &RuntimeConfig,
2065 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2066) -> Result<
2067 (
2068 crate::cluster_publisher::ClusterEventPublisher,
2069 crate::activity_publisher::ActivityEventPublisher,
2070 ),
2071 ServerError,
2072> {
2073 let capacity = required_cluster_broadcast_capacity(runtime)?;
2074 let batch = required_transcript_batch_policy(runtime)?;
2075 Ok((
2076 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
2077 build_transcript_publisher(
2078 observability_store,
2079 capacity,
2080 transcript_bounds(runtime),
2081 batch,
2082 ),
2083 ))
2084}
2085
2086pub(crate) fn required_node_cache_budget(
2105 config: &StoreConfig,
2106) -> Result<haematite::NodeCacheBudget, ServerError> {
2107 config.node_cache_budget.ok_or_else(|| ServerError::Config {
2108 message: crate::config::STORE_NODE_CACHE_BUDGET_REQUIRED.to_owned(),
2109 })
2110}
2111
2112fn warn_retired_lock_acquisition_keys(config: &StoreConfig) {
2119 if config.lock_acquisition_patience_ms.is_some()
2120 || config.lock_acquisition_retry_cadence_ms.is_some()
2121 {
2122 tracing::warn!(
2123 "store.lock_acquisition_patience_ms and store.lock_acquisition_retry_cadence_ms \
2124 are retired and ignored: the server waits indefinitely for the data-directory \
2125 writer lock (a kernel-released flock whose holder is either live mid-handover \
2126 or already gone) and reports while it waits. Remove the keys"
2127 );
2128 }
2129}
2130
2131pub(crate) fn required_transcript_batch_policy(
2151 runtime: &RuntimeConfig,
2152) -> Result<crate::activity_publisher::TranscriptBatchPolicy, ServerError> {
2153 let max_batch_events = runtime
2154 .observability
2155 .max_batch_events
2156 .and_then(std::num::NonZeroUsize::new)
2157 .ok_or_else(|| ServerError::Config {
2158 message: crate::config::OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED.to_owned(),
2159 })?;
2160 let max_batch_hold_ms =
2163 runtime
2164 .observability
2165 .max_batch_hold_ms
2166 .ok_or_else(|| ServerError::Config {
2167 message: crate::config::OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED.to_owned(),
2168 })?;
2169 Ok(crate::activity_publisher::TranscriptBatchPolicy {
2170 max_batch_events,
2171 max_hold: std::time::Duration::from_millis(max_batch_hold_ms),
2172 })
2173}
2174
2175fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
2177 crate::activity_bounds::TranscriptBounds {
2178 max_event_bytes: runtime.observability.max_event_bytes,
2179 max_stream_events: runtime.observability.max_stream_events,
2180 }
2181}
2182
2183fn build_transcript_publisher(
2194 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2195 capacity: std::num::NonZeroUsize,
2196 bounds: crate::activity_bounds::TranscriptBounds,
2197 batch: crate::activity_publisher::TranscriptBatchPolicy,
2198) -> crate::activity_publisher::ActivityEventPublisher {
2199 let store = observability_store
2200 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
2201 crate::activity_publisher::ActivityEventPublisher::new(store, capacity, batch)
2202 .with_bounds(bounds)
2203}
2204
2205struct RoutingState {
2207 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
2208 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
2209 mint_routing: Option<crate::namespace::NamespaceRouting>,
2214}
2215
2216fn build_routing_state(
2220 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
2221 directory_peers: Vec<crate::routing::DirectoryPeer>,
2222 self_node_id: Option<String>,
2223) -> RoutingState {
2224 let Some(store) = cluster_store else {
2225 return RoutingState {
2226 shard_directory: None,
2227 request_forwarder: None,
2228 mint_routing: None,
2229 };
2230 };
2231 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
2232 Arc::clone(store),
2233 directory_peers,
2234 self_node_id,
2235 ));
2236 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
2237 Arc::new(crate::routing::GrpcRequestForwarder::new());
2238 let mint_routing = build_namespace_routing(
2239 Some(store),
2240 Some(&shard_directory),
2241 Some(&request_forwarder),
2242 );
2243 RoutingState {
2244 shard_directory: Some(shard_directory),
2245 request_forwarder: Some(request_forwarder),
2246 mint_routing,
2247 }
2248}
2249
2250fn build_namespace_routing(
2260 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
2261 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
2262 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
2263) -> Option<crate::namespace::NamespaceRouting> {
2264 use crate::namespace::{
2265 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
2266 };
2267 let store = Arc::clone(cluster_store?);
2268 let directory = Arc::clone(shard_directory?);
2269 let shards: Arc<dyn NamespaceShardResolver> = store;
2270 let owners: Arc<dyn MintShardOwners> = directory;
2271 let forwarder: Arc<dyn MintForwarder> =
2272 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
2273 Some(NamespaceRouting::new(shards, owners, forwarder))
2274}
2275
2276struct ConnectedStore {
2286 event_store: Arc<dyn EventStore>,
2287 outbox_store: Option<Arc<dyn OutboxStore>>,
2288 namespace_store: Arc<dyn NamespaceStore>,
2295 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2297 workloop_store: Arc<dyn aion_store::workloop::WorkloopStore>,
2307 visibility_store: Arc<dyn aion_store::visibility::VisibilityStore>,
2312 assistant_store: Arc<dyn aion_store::AssistantSessionStore>,
2319 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2327 bootstrap_coordinator: bool,
2328 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
2329 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
2333 watched_peers: Vec<crate::cluster::WatchedPeer>,
2336 directory_peers: Vec<crate::routing::DirectoryPeer>,
2340 self_node_id: Option<String>,
2344}
2345
2346impl ConnectedStore {
2347 fn local(
2354 event_store: Arc<dyn EventStore>,
2355 outbox_store: Option<Arc<dyn OutboxStore>>,
2356 namespace_store: Arc<dyn NamespaceStore>,
2357 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2358 workloop_store: Arc<dyn aion_store::workloop::WorkloopStore>,
2359 visibility_store: Arc<dyn aion_store::visibility::VisibilityStore>,
2360 assistant_store: Arc<dyn aion_store::AssistantSessionStore>,
2361 ) -> Self {
2362 Self {
2363 event_store,
2364 outbox_store,
2365 namespace_store,
2366 worker_deployment_store,
2367 workloop_store,
2368 visibility_store,
2369 assistant_store,
2370 observability_store: None,
2376 bootstrap_coordinator: true,
2377 cluster_responder: None,
2378 cluster_store: None,
2379 watched_peers: Vec::new(),
2380 directory_peers: Vec::new(),
2381 self_node_id: None,
2382 }
2383 }
2384
2385 fn cluster_self_node(&self) -> Option<String> {
2388 self.self_node_id.clone()
2389 }
2390}
2391
2392async fn connect_store(
2395 config: StoreConfig,
2396 stage: &crate::control::StageReporter,
2397) -> Result<ConnectedStore, ServerError> {
2398 match config.backend {
2399 StoreBackend::Memory => {
2400 let leaf = Arc::new(aion_store::InMemoryStore::default());
2403 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2404 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2405 let workloop_store: Arc<dyn aion_store::workloop::WorkloopStore> = leaf.clone();
2406 let visibility_store: Arc<dyn aion_store::visibility::VisibilityStore> = leaf.clone();
2407 let assistant_store: Arc<dyn aion_store::AssistantSessionStore> = leaf.clone();
2408 Ok(ConnectedStore::local(
2409 leaf,
2410 None,
2411 namespace_store,
2412 worker_deployment_store,
2413 workloop_store,
2414 visibility_store,
2415 assistant_store,
2416 ))
2417 }
2418 StoreBackend::Haematite => connect_haematite_store(config, stage).await,
2419 }
2420}
2421
2422async fn connect_haematite_store(
2442 config: StoreConfig,
2443 stage: &crate::control::StageReporter,
2444) -> Result<ConnectedStore, ServerError> {
2445 let node_cache_budget = required_node_cache_budget(&config)?;
2451 warn_retired_lock_acquisition_keys(&config);
2452 let Some(data_dir) = config.data_dir else {
2453 return Err(ServerError::Config {
2454 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
2455 });
2456 };
2457 let shard_count = config.shard_count;
2458 let owned_shards = config.owned_shards.clone();
2459 let cluster = config.cluster.clone();
2460 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
2465 .as_ref()
2466 .map(|cluster| {
2467 cluster
2468 .peers
2469 .iter()
2470 .map(|peer| crate::cluster::WatchedPeer {
2471 name: peer.name.clone(),
2472 owned_shards: peer.owned_shards.clone(),
2473 })
2474 .collect()
2475 })
2476 .unwrap_or_default();
2477 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
2480 .as_ref()
2481 .map(|cluster| {
2482 cluster
2483 .peers
2484 .iter()
2485 .map(|peer| crate::routing::DirectoryPeer {
2486 name: peer.name.clone(),
2487 owned_shards: peer.owned_shards.clone(),
2488 grpc_addr: peer.grpc_address,
2489 })
2490 .collect()
2491 })
2492 .unwrap_or_default();
2493 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2496 stage.report(
2500 crate::control::stage::STAGE_STORE_OPEN,
2501 format!("opening the haematite store at {data_dir}"),
2502 );
2503 let build_stage = stage.clone();
2508 let (store, responder) = tokio::task::spawn_blocking(move || {
2509 build_haematite_store(
2510 &data_dir,
2511 shard_count,
2512 cluster,
2513 node_cache_budget,
2514 &build_stage,
2515 )
2516 })
2517 .await
2518 .map_err(|error| ServerError::Config {
2519 message: format!("haematite store initialization task failed: {error}"),
2520 })??;
2521
2522 let bootstrap_coordinator = if owned_shards.is_empty() {
2526 true
2527 } else {
2528 store.set_owned_shards(owned_shards.iter().copied());
2529 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2530 };
2531
2532 let leaf = Arc::new(store);
2533 let event_store: Arc<dyn EventStore> = leaf.clone();
2534 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2535 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2539 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2540 let workloop_store: Arc<dyn aion_store::workloop::WorkloopStore> = leaf.clone();
2541 let visibility_store: Arc<dyn aion_store::visibility::VisibilityStore> = leaf.clone();
2544 let assistant_store: Arc<dyn aion_store::AssistantSessionStore> = leaf.clone();
2545 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2550 let cluster_store = responder.as_ref().map(|_| leaf);
2554 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2555 (watched_peers, directory_peers, self_node_id)
2556 } else {
2557 (Vec::new(), Vec::new(), None)
2558 };
2559 Ok(ConnectedStore {
2560 event_store,
2561 outbox_store: Some(outbox_store),
2562 namespace_store,
2563 worker_deployment_store,
2564 workloop_store,
2565 visibility_store,
2566 assistant_store,
2567 observability_store: Some(observability_store),
2568 bootstrap_coordinator,
2569 cluster_responder: responder,
2570 cluster_store,
2571 watched_peers,
2572 directory_peers,
2573 self_node_id,
2574 })
2575}
2576
2577fn build_haematite_store(
2592 data_dir: &str,
2593 shard_count: usize,
2594 cluster: Option<crate::config::ClusterConfig>,
2595 node_cache_budget: haematite::NodeCacheBudget,
2596 stage: &crate::control::StageReporter,
2597) -> Result<
2598 (
2599 aion_store_haematite::HaematiteStore,
2600 Option<aion_store_haematite::ClusterResponder>,
2601 ),
2602 ServerError,
2603> {
2604 build_haematite_store_with_hook(
2605 data_dir,
2606 shard_count,
2607 cluster,
2608 node_cache_budget,
2609 stage,
2610 || Ok(()),
2611 )
2612}
2613
2614fn build_haematite_store_with_hook(
2615 data_dir: &str,
2616 shard_count: usize,
2617 cluster: Option<crate::config::ClusterConfig>,
2618 node_cache_budget: haematite::NodeCacheBudget,
2619 stage: &crate::control::StageReporter,
2620 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2621) -> Result<
2622 (
2623 aion_store_haematite::HaematiteStore,
2624 Option<aion_store_haematite::ClusterResponder>,
2625 ),
2626 ServerError,
2627> {
2628 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2629
2630 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2638 data_dir,
2639 ))
2640 .map_err(|error| ServerError::Config {
2641 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2642 })?;
2643
2644 stage.report(
2653 crate::control::stage::STAGE_STORE_OPEN,
2654 format!("checking the data root and {shard_count} shard directories under {data_dir}"),
2655 );
2656 for shard in 0..shard_count {
2657 let relative = std::path::PathBuf::from(format!("shard-{shard}"));
2658 private_root
2659 .create_dir_all(&relative)
2660 .map_err(|error| ServerError::Config {
2661 message: format!(
2662 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2663 ),
2664 })?;
2665 private_root
2666 .ensure_child_dir_private(&relative)
2667 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2668 }
2669
2670 before_backend_touch().map_err(|error| ServerError::Config {
2673 message: format!("store.data_dir pre-open hook failed: {error}"),
2674 })?;
2675
2676 #[cfg(unix)]
2677 let backend_path = private_root
2678 .backend_path()
2679 .map_err(|error| ServerError::Config {
2680 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2681 })?;
2682 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2683 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2684 let (component, reason) = error.into_parts();
2685 ServerError::UnsafeDataRootAncestor {
2686 data_root: backend_path.clone(),
2687 component,
2688 reason,
2689 }
2690 })?;
2691 #[cfg(not(unix))]
2692 let backend_path = std::path::PathBuf::from(data_dir);
2693
2694 let Some(cluster) = cluster else {
2695 let store = if backend_path.join("config.json").exists() {
2696 HaematiteStore::open(
2701 &backend_path,
2702 node_cache_budget,
2703 writer_lock_wait_reporter(stage),
2704 )
2705 .map_err(ServerError::from)?
2706 } else {
2707 HaematiteStore::create_with_shard_count(&backend_path, shard_count, node_cache_budget)
2708 .map_err(ServerError::from)?
2709 };
2710 materialize_narrated(&store, shard_count, stage)?;
2711 let store = store.retain_data_root_capability(private_root);
2712 return Ok((store, None));
2713 };
2714
2715 let boot = ClusterBootstrap {
2716 node_id: cluster.node_id,
2717 bind_address: cluster.bind_address,
2718 members: cluster.members,
2719 peers: cluster
2720 .peers
2721 .into_iter()
2722 .map(|peer| (peer.name, peer.address))
2723 .collect(),
2724 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2725 };
2726 let (store, responder) = HaematiteStore::open_or_create_distributed(
2727 &backend_path,
2728 shard_count,
2729 boot,
2730 node_cache_budget,
2731 )
2732 .map_err(ServerError::from)?;
2733 materialize_narrated(&store, shard_count, stage)?;
2734 let store = store.retain_data_root_capability(private_root);
2735 Ok((store, Some(responder)))
2736}
2737
2738fn writer_lock_wait_reporter(
2746 stage: &crate::control::StageReporter,
2747) -> impl FnMut(&std::path::Path, std::time::Duration) + use<'_> {
2748 move |lock_path, waited| {
2749 stage.report(
2750 crate::control::stage::STAGE_WRITER_LOCK_WAIT,
2751 format!(
2752 "waiting {}s on the store writer lock at {} — another live process \
2753 holds it (a draining predecessor, or another server on this data \
2754 directory); this boot proceeds the moment it is released",
2755 waited.as_secs(),
2756 lock_path.display()
2757 ),
2758 );
2759 }
2760}
2761
2762fn materialize_narrated(
2773 store: &aion_store_haematite::HaematiteStore,
2774 shard_count: usize,
2775 stage: &crate::control::StageReporter,
2776) -> Result<(), ServerError> {
2777 stage.report(
2778 crate::control::stage::STAGE_STORE_OPEN,
2779 format!("haematite store opened; materializing {shard_count} shards"),
2780 );
2781 store
2782 .materialize_all_shards(|shard, materialized, total| {
2783 stage.report(
2784 crate::control::stage::STAGE_WAL_RECOVERY,
2785 format!("materializing shard {shard} ({materialized} of {total})"),
2786 );
2787 })
2788 .map_err(ServerError::from)?;
2789 stage.report(
2790 crate::control::stage::STAGE_WAL_RECOVERY,
2791 format!("all {shard_count} shards materialized"),
2792 );
2793 Ok(())
2794}
2795
2796fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2797 ServerError::Config {
2798 message: format!(
2799 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2800 ),
2801 }
2802}
2803
2804const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2806
2807#[cfg(not(feature = "liminal-transport"))]
2816#[derive(Clone, Debug)]
2817struct NullInterventionTransport;
2818
2819#[cfg(not(feature = "liminal-transport"))]
2820#[async_trait::async_trait]
2821impl crate::worker::InterventionTransport for NullInterventionTransport {
2822 async fn push(
2823 &self,
2824 _worker: &crate::worker::WorkerHandle,
2825 _command: aion_core::InterventionCommand,
2826 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2827 Err(ServerError::worker_connection_lost(
2828 "intervention",
2829 "no intervention push transport is compiled in".to_owned(),
2830 ))
2831 }
2832}
2833
2834#[cfg(test)]
2835mod tests {
2836 use std::{net::SocketAddr, time::Duration};
2837
2838 use aion_store::InMemoryStore;
2839
2840 use super::ServerState;
2841 use crate::config::{
2842 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2843 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2844 RuntimeConfig, WebSocketConfig, WorkerConfig,
2845 };
2846
2847 fn runtime_config() -> RuntimeConfig {
2848 RuntimeConfig {
2849 listen: ListenConfig {
2850 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2851 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2852 },
2853 tls: None,
2854 auth: AuthConfig {
2855 enabled: false,
2856 jwks_url: None,
2857 jwks_refresh_seconds: 300,
2858 },
2859 ops_console: OpsConsoleConfig {
2860 source: OpsConsoleAssetSource::Embedded,
2861 },
2862 namespace: NamespaceConfig {
2863 mode: NamespaceMode::SharedEngine,
2864 },
2865 worker: WorkerConfig {
2866 heartbeat_window: Duration::from_secs(30),
2867 ..WorkerConfig::default()
2868 },
2869 websocket: WebSocketConfig {
2870 outbound_buffer_bound: 32,
2871 event_broadcast_capacity: Some(64),
2872 cluster_broadcast_capacity: Some(64),
2873 },
2874 workflow_packages: Vec::new(),
2875 deploy: DeployConfig::default(),
2876 authoring: AuthoringConfig::default(),
2877 dev: DevConfig::default(),
2878 outbox: OutboxConfig::default(),
2879 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
2880 mcp: crate::config::ResolvedMcpConfig::default(),
2881 assistant: crate::config::ResolvedAssistantConfig::default(),
2882 scheduler_threads: 1,
2883 stop_drain_timeout: Some(std::time::Duration::from_secs(5)),
2884 jit_threshold: None,
2885 query_timeout: Some(Duration::from_secs(10)),
2886 workloop_sweep_interval: Some(Duration::from_millis(50)),
2887 default_namespace: "default".to_owned(),
2888 auto_create: crate::config::AutoCreate::Open,
2889 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2890 drain_timeout: Duration::from_secs(30),
2891 metrics: MetricsConfig { enabled: true },
2892 owned_shards: Vec::new(),
2893 cors_allowed_origins: Vec::new(),
2894 }
2895 }
2896
2897 #[test]
2902 fn an_unruled_flush_policy_refuses_to_build_the_publisher() {
2903 for (mutate, expected_key) in [
2904 (
2905 Box::new(|runtime: &mut RuntimeConfig| {
2906 runtime.observability.max_batch_events = None;
2907 }) as Box<dyn Fn(&mut RuntimeConfig)>,
2908 "observability.max_batch_events",
2909 ),
2910 (
2911 Box::new(|runtime: &mut RuntimeConfig| {
2912 runtime.observability.max_batch_events = Some(0);
2913 }),
2914 "observability.max_batch_events",
2915 ),
2916 (
2917 Box::new(|runtime: &mut RuntimeConfig| {
2918 runtime.observability.max_batch_hold_ms = None;
2919 }),
2920 "observability.max_batch_hold_ms",
2921 ),
2922 ] {
2923 let mut runtime = runtime_config();
2924 mutate(&mut runtime);
2925 let message = super::required_transcript_batch_policy(&runtime)
2926 .err()
2927 .map_or_else(String::new, |error| error.to_string());
2928 assert!(
2929 message.contains(expected_key),
2930 "the refusal must name {expected_key}: {message}"
2931 );
2932 assert!(
2933 message.contains("no default"),
2934 "and must say the key has no default: {message}"
2935 );
2936 }
2937 }
2938
2939 #[test]
2942 fn a_stated_flush_policy_is_carried_through_verbatim() -> Result<(), Box<dyn std::error::Error>>
2943 {
2944 let mut runtime = runtime_config();
2945 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(32, 0);
2946 let policy = super::required_transcript_batch_policy(&runtime)?;
2947 assert_eq!(policy.max_batch_events.get(), 32);
2948 assert_eq!(policy.max_hold, Duration::ZERO);
2949
2950 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(8, 250);
2951 let policy = super::required_transcript_batch_policy(&runtime)?;
2952 assert_eq!(policy.max_batch_events.get(), 8);
2953 assert_eq!(policy.max_hold, Duration::from_millis(250));
2954 Ok(())
2955 }
2956
2957 #[test]
2968 fn engine_schema_accepts_every_attribute_the_start_writer_records()
2969 -> Result<(), Box<dyn std::error::Error>> {
2970 let schema = super::server_search_attribute_schema()?;
2971 let recorded = crate::api::handlers::workflows::start_search_attributes(
2972 "tenant-a",
2973 Some("gpu"),
2974 Some("Nightly settlement"),
2975 );
2976
2977 assert!(
2978 recorded.contains_key(crate::namespace::DISPLAY_NAME_ATTRIBUTE),
2979 "the fixture must exercise the display-name attribute, or this test \
2980 cannot see its registration go missing"
2981 );
2982 for (name, value) in &recorded {
2983 schema.validate(name, value).map_err(|error| {
2984 format!(
2985 "the start writer records {name}, but the engine's schema refuses it: {error}"
2986 )
2987 })?;
2988 }
2989 Ok(())
2990 }
2991
2992 #[tokio::test]
2993 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2994 let state =
2995 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2996
2997 std::hint::black_box(state.namespace_guard());
2998 std::hint::black_box(state.worker_registry());
2999
3000 Ok(())
3001 }
3002
3003 #[tokio::test]
3011 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
3012 -> Result<(), Box<dyn std::error::Error>> {
3013 use aion::{ActivityDispatch, ActivityDispatcher as _};
3014 use aion_core::{ActivityId, RunId, WorkflowId};
3015 use std::sync::Arc;
3016
3017 let state =
3018 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
3019 assert!(
3020 state.unserved_queues()?.is_empty(),
3021 "a calm boot has no unserved queues"
3022 );
3023 assert!(state.queue_declarations().is_installed());
3027 assert_eq!(
3028 state
3029 .queue_declarations()
3030 .declaration_for("nobody-serves-this"),
3031 crate::worker::QueueDeclaration::Unknown
3032 );
3033
3034 let dispatcher = Arc::new(
3035 crate::worker::WorkerActivityDispatcher::new(
3036 state.worker_registry().clone(),
3037 "default",
3038 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
3039 )
3040 .with_queue_state(state.queue_service_state().clone())
3041 .with_queue_declarations(state.queue_declarations().clone()),
3042 );
3043 let workflow_id = WorkflowId::new_v4();
3044 let request = ActivityDispatch {
3045 namespace: "default".to_owned(),
3046 task_queue: "nobody-serves-this".to_owned(),
3047 node: None,
3048 workflow_id: workflow_id.clone(),
3049 run_id: RunId::new_v4(),
3050 activity_id: ActivityId::from_sequence_position(0),
3051 name: "greet".to_owned(),
3052 input: "{}".to_owned(),
3053 config: "{}".to_owned(),
3054 attempt: 1,
3055 advisory: false,
3056 labels: std::collections::BTreeMap::new(),
3057 };
3058 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
3059
3060 let mut unserved = Vec::new();
3061 for _ in 0..30 {
3062 unserved = state.unserved_queues()?;
3063 if !unserved.is_empty() {
3064 break;
3065 }
3066 tokio::time::sleep(Duration::from_millis(100)).await;
3067 }
3068 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
3069 assert_eq!(
3070 unserved[0].reason,
3071 crate::worker::QueueServiceReason::NoLivePollers,
3072 "an empty catalog must not be read as a structural refusal"
3073 );
3074 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
3075 assert_eq!(unserved[0].waiting.len(), 1);
3076 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
3077
3078 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
3080 drop(worker_rx);
3081 let registration = state.worker_registry().register_namespaces(
3082 [String::from("default")],
3083 "nobody-serves-this",
3084 None,
3085 [String::from("greet")].iter(),
3086 worker_tx,
3087 )?;
3088 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
3089 assert!(outcome.is_err(), "the released dispatch must resolve");
3090 assert!(
3091 state.unserved_queues()?.is_empty(),
3092 "a resolved dispatch must leave the unserved state"
3093 );
3094 registration.deregister()?;
3095 Ok(())
3096 }
3097
3098 #[tokio::test]
3099 async fn namespace_store_is_reachable_and_functional_after_default_boot()
3100 -> Result<(), Box<dyn std::error::Error>> {
3101 use aion_store::{MintOutcome, NamespaceOrigin};
3102
3103 let state =
3107 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
3108
3109 let store = state.namespace_store();
3110
3111 let outcome = store
3113 .register_namespace("orders", NamespaceOrigin::WorkerMint)
3114 .await?;
3115 assert_eq!(
3116 outcome,
3117 MintOutcome::Created,
3118 "the first reference to a namespace mints it"
3119 );
3120
3121 let again = store
3123 .register_namespace("orders", NamespaceOrigin::WorkerMint)
3124 .await?;
3125 assert_eq!(
3126 again,
3127 MintOutcome::AlreadyExisted,
3128 "a second reference touches the existing record rather than re-creating it"
3129 );
3130
3131 let fetched = store.get_namespace("orders").await?;
3133 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
3134 assert_eq!(record.name, "orders");
3135 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
3136
3137 let listed = store.list_namespaces().await?;
3139 assert!(
3140 listed.iter().any(|record| record.name == "orders"),
3141 "list_namespaces returns the minted namespace"
3142 );
3143
3144 Ok(())
3145 }
3146
3147 #[tokio::test(flavor = "multi_thread")]
3148 async fn connect_store_haematite_round_trips_through_event_store()
3149 -> Result<(), Box<dyn std::error::Error>> {
3150 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3151 use aion_store::WriteToken;
3152 use chrono::Utc;
3153
3154 use crate::config::{StoreBackend, StoreConfig};
3155
3156 let data_dir = crate::test_support::private_tempdir()?;
3157 let connected = super::connect_store(
3161 StoreConfig {
3162 backend: StoreBackend::Haematite,
3163 owned_shards: Vec::new(),
3164 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
3165 shard_count: 1,
3166 cluster: None,
3167 node_cache_budget: Some(test_node_cache_budget()?),
3168 ..StoreConfig::default()
3169 },
3170 &crate::control::StageReporter::detached(),
3171 )
3172 .await?;
3173 let event_store = connected.event_store;
3174 assert!(
3175 connected.outbox_store.is_some(),
3176 "the haematite backend shares its leaf store as the dispatcher's outbox store"
3177 );
3178 assert!(
3179 connected.bootstrap_coordinator,
3180 "a single-node haematite boot owns all shards and bootstraps the coordinator"
3181 );
3182 assert!(
3183 connected.cluster_responder.is_none(),
3184 "a single-node (no [cluster]) haematite boot has no distributed responder"
3185 );
3186
3187 let workflow_id = WorkflowId::new_v4();
3188 let event = aion_core::Event::WorkflowStarted {
3189 envelope: EventEnvelope {
3190 seq: 1,
3191 recorded_at: Utc::now(),
3192 workflow_id: workflow_id.clone(),
3193 },
3194 workflow_type: String::from("checkout"),
3195 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3196 run_id: RunId::new_v4(),
3197 parent_run_id: None,
3198 parent_workflow_id: None,
3199 package_version: PackageVersion::new("a".repeat(64)),
3200 };
3201 event_store
3202 .append(
3203 WriteToken::recorder(),
3204 &workflow_id,
3205 std::slice::from_ref(&event),
3206 0,
3207 )
3208 .await?;
3209 let history = event_store.read_history(&workflow_id).await?;
3210 assert_eq!(
3211 history.len(),
3212 1,
3213 "an event appended through the server's dyn EventStore reads back"
3214 );
3215 Ok(())
3216 }
3217
3218 fn test_node_cache_budget() -> Result<haematite::NodeCacheBudget, Box<dyn std::error::Error>> {
3224 Ok(haematite::NodeCacheBudget::bytes(1 << 30)?)
3225 }
3226
3227 const BOOT_COST_WORKFLOWS: usize = 800;
3232
3233 const BOOT_OPENS_PER_DIRECTORY: u64 = 8;
3239
3240 #[cfg(unix)]
3257 #[tokio::test]
3258 async fn booting_over_a_store_of_many_object_files_opens_per_shard_not_per_file()
3259 -> Result<(), Box<dyn std::error::Error>> {
3260 let sandbox = crate::test_support::private_tempdir()?;
3261 let data_root = sandbox.path().join("data");
3262 let data_dir = data_root
3263 .to_str()
3264 .ok_or("temporary data path was not UTF-8")?
3265 .to_owned();
3266 let shard_count = 4;
3267
3268 populate_boot_cost_store(&data_root, shard_count).await?;
3269 let object_files = count_regular_files(&data_root)?;
3270 let directories = u64::try_from(shard_count)? + 1;
3271 let budget = BOOT_OPENS_PER_DIRECTORY * directories;
3272 assert!(
3273 object_files > budget * 4,
3274 "the specimen must carry far more object files ({object_files}) than the open \
3275 budget ({budget}), or a walk would fit inside the budget and this pin proves \
3276 nothing"
3277 );
3278
3279 crate::filesystem::reset_capability_opens();
3280 haematite::store::reset_file_opens();
3281 let (store, responder) = tokio::task::spawn_blocking(move || {
3282 super::build_haematite_store_with_hook(
3283 &data_dir,
3284 shard_count,
3285 None,
3286 haematite::NodeCacheBudget::bytes(1 << 30).map_err(|error| error.to_string())?,
3287 &crate::control::StageReporter::detached(),
3288 || Ok(()),
3289 )
3290 .map_err(|error| error.to_string())
3291 })
3292 .await??;
3293 let opens = crate::filesystem::capability_opens();
3294 let haematite_opens = haematite::store::file_opens();
3295 assert!(responder.is_none());
3296 assert_post_boot_node_is_private(&store, &data_root).await?;
3297 drop(store);
3298 println!(
3299 "boot-cost pin: {object_files} object files in {shard_count} shards; {opens} opens \
3300 through the server's capability layer and {haematite_opens} inside haematite during \
3301 the open (budget {budget} each)"
3302 );
3303
3304 assert!(
3305 opens <= budget,
3306 "booting over {object_files} object files in {shard_count} shards issued {opens} \
3307 opens through the server's capability layer; the budget is {budget} \
3308 ({BOOT_OPENS_PER_DIRECTORY} per directory) — the boot is walking the tree again"
3309 );
3310 assert!(
3311 haematite_opens <= budget,
3312 "opening the store over {object_files} object files issued {haematite_opens} opens \
3313 inside haematite; the budget is {budget} — the shard open is doing per-object work"
3314 );
3315 Ok(())
3316 }
3317
3318 #[cfg(unix)]
3322 #[test]
3323 fn a_permissive_shard_directory_we_own_is_tightened_at_boot()
3324 -> Result<(), Box<dyn std::error::Error>> {
3325 use std::os::unix::fs::PermissionsExt as _;
3326
3327 let sandbox = crate::test_support::private_tempdir()?;
3328 let data_root = sandbox.path().join("data");
3329 let loose = data_root.join("shard-2");
3330 std::fs::create_dir_all(&loose)?;
3331 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
3332 std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o755))?;
3333 let data_dir = data_root
3334 .to_str()
3335 .ok_or("temporary data path was not UTF-8")?;
3336
3337 let (captured, built) = crate::test_support::CapturedLogs::capture(|| {
3338 super::build_haematite_store_with_hook(
3339 data_dir,
3340 4,
3341 None,
3342 haematite::NodeCacheBudget::bytes(1 << 30).map_err(|error| error.to_string())?,
3343 &crate::control::StageReporter::detached(),
3344 || Ok(()),
3345 )
3346 .map_err(|error| error.to_string())
3347 });
3348 let (store, _) = built?;
3349 drop(store);
3350
3351 assert_eq!(
3352 std::fs::metadata(&loose)?.permissions().mode() & 0o777,
3353 0o700,
3354 "the loose shard directory is tightened by the boot"
3355 );
3356 let text = captured.text()?;
3357 assert!(
3358 text.contains("tightened a sensitive root") && text.contains("shard-2"),
3359 "the repair is logged and names the directory: {text}"
3360 );
3361 Ok(())
3362 }
3363
3364 #[test]
3374 fn the_store_build_narrates_each_shard_by_id_before_opening_it()
3375 -> Result<(), Box<dyn std::error::Error>> {
3376 const SHARDS: usize = 6;
3377 let sandbox = crate::test_support::private_tempdir()?;
3378 let data_root = sandbox.path().join("data");
3379 let data_dir = data_root
3380 .to_str()
3381 .ok_or("temporary data path was not UTF-8")?;
3382
3383 let (captured, built) = crate::test_support::CapturedLogs::capture(|| {
3384 super::build_haematite_store_with_hook(
3385 data_dir,
3386 SHARDS,
3387 None,
3388 haematite::NodeCacheBudget::bytes(1 << 30).map_err(|error| error.to_string())?,
3389 &crate::control::StageReporter::detached(),
3390 || Ok(()),
3391 )
3392 .map_err(|error| error.to_string())
3393 });
3394 let (store, _) = built?;
3395 drop(store);
3396
3397 let text = captured.text()?;
3398 let position = |needle: &str| -> Result<usize, String> {
3399 text.find(needle)
3400 .ok_or_else(|| format!("missing stage line `{needle}` in:\n{text}"))
3401 };
3402 let checking = position(&format!(
3403 "checking the data root and {SHARDS} shard directories"
3404 ))?;
3405 let opened = position(&format!(
3406 "haematite store opened; materializing {SHARDS} shards"
3407 ))?;
3408 let done = position(&format!("all {SHARDS} shards materialized"))?;
3409 assert!(
3410 checking < opened && opened < done,
3411 "stage lines out of order:\n{text}"
3412 );
3413 for shard in 0..SHARDS {
3414 let line = position(&format!("materializing shard {shard} ("))?;
3415 assert!(
3416 opened < line && line < done,
3417 "shard {shard}'s start line must sit between open and done:\n{text}"
3418 );
3419 }
3420 assert!(
3421 !text.contains(&format!("materializing shard {SHARDS} (")),
3422 "a line names a shard id; ids run 0..{SHARDS}, never the count"
3423 );
3424 Ok(())
3425 }
3426
3427 async fn populate_boot_cost_store(
3430 data_root: &std::path::Path,
3431 shard_count: usize,
3432 ) -> Result<(), Box<dyn std::error::Error>> {
3433 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3434 use aion_store::{WritableEventStore as _, WriteToken};
3435 use chrono::Utc;
3436
3437 let store = aion_store_haematite::HaematiteStore::create_with_shard_count(
3438 data_root,
3439 shard_count,
3440 test_node_cache_budget()?,
3441 )?;
3442 for index in 0..BOOT_COST_WORKFLOWS {
3443 let workflow_id = WorkflowId::new_v4();
3444 let event = aion_core::Event::WorkflowStarted {
3445 envelope: EventEnvelope {
3446 seq: 1,
3447 recorded_at: Utc::now(),
3448 workflow_id: workflow_id.clone(),
3449 },
3450 workflow_type: format!("boot-cost-{index}"),
3451 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3452 run_id: RunId::new_v4(),
3453 parent_run_id: None,
3454 parent_workflow_id: None,
3455 package_version: PackageVersion::new("a".repeat(64)),
3456 };
3457 store
3458 .append(
3459 WriteToken::recorder(),
3460 &workflow_id,
3461 std::slice::from_ref(&event),
3462 0,
3463 )
3464 .await?;
3465 }
3466 Ok(())
3467 }
3468
3469 async fn assert_post_boot_node_is_private(
3477 store: &aion_store_haematite::HaematiteStore,
3478 data_root: &std::path::Path,
3479 ) -> Result<(), Box<dyn std::error::Error>> {
3480 use std::os::unix::fs::PermissionsExt as _;
3481
3482 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3483 use aion_store::{WritableEventStore as _, WriteToken};
3484 use chrono::Utc;
3485
3486 let workflow_id = WorkflowId::new_v4();
3487 let event = aion_core::Event::WorkflowStarted {
3488 envelope: EventEnvelope {
3489 seq: 1,
3490 recorded_at: Utc::now(),
3491 workflow_id: workflow_id.clone(),
3492 },
3493 workflow_type: String::from("boot-cost-after-boot"),
3494 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3495 run_id: RunId::new_v4(),
3496 parent_run_id: None,
3497 parent_workflow_id: None,
3498 package_version: PackageVersion::new("a".repeat(64)),
3499 };
3500 store
3501 .append(
3502 WriteToken::recorder(),
3503 &workflow_id,
3504 std::slice::from_ref(&event),
3505 0,
3506 )
3507 .await?;
3508 let (newest, parent) = newest_regular_file(data_root)?
3509 .ok_or("the post-boot append must have written a node file")?;
3510 assert_eq!(
3511 std::fs::metadata(&newest)?.permissions().mode() & 0o777,
3512 0o600,
3513 "a node written after boot is private by creation: {}",
3514 newest.display()
3515 );
3516 assert_eq!(
3517 std::fs::metadata(&parent)?.permissions().mode() & 0o777,
3518 0o700,
3519 "its prefix directory is private by creation: {}",
3520 parent.display()
3521 );
3522 Ok(())
3523 }
3524
3525 fn newest_regular_file(
3527 root: &std::path::Path,
3528 ) -> std::io::Result<Option<(std::path::PathBuf, std::path::PathBuf)>> {
3529 let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
3530 let mut pending = vec![root.to_path_buf()];
3531 while let Some(dir) = pending.pop() {
3532 for entry in std::fs::read_dir(&dir)? {
3533 let entry = entry?;
3534 let file_type = entry.file_type()?;
3535 if file_type.is_dir() {
3536 pending.push(entry.path());
3537 } else if file_type.is_file() {
3538 let modified = entry.metadata()?.modified()?;
3539 if newest.as_ref().is_none_or(|(when, _)| modified > *when) {
3540 newest = Some((modified, entry.path()));
3541 }
3542 }
3543 }
3544 }
3545 Ok(newest.and_then(|(_, path)| {
3546 let parent = path.parent()?.to_path_buf();
3547 Some((path, parent))
3548 }))
3549 }
3550
3551 fn count_regular_files(root: &std::path::Path) -> std::io::Result<u64> {
3552 let mut total = 0;
3553 for entry in std::fs::read_dir(root)? {
3554 let entry = entry?;
3555 let file_type = entry.file_type()?;
3556 if file_type.is_dir() {
3557 total += count_regular_files(&entry.path())?;
3558 } else if file_type.is_file() {
3559 total += 1;
3560 }
3561 }
3562 Ok(total)
3563 }
3564
3565 #[cfg(unix)]
3566 #[test]
3567 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
3568 -> Result<(), Box<dyn std::error::Error>> {
3569 use std::os::unix::fs::symlink;
3570
3571 let sandbox = crate::test_support::private_tempdir()?;
3572 let configured_root = sandbox.path().join("data");
3573 let held_root = sandbox.path().join("held-data");
3574 let outside = sandbox.path().join("outside");
3575 std::fs::create_dir(&outside)?;
3576 let configured = configured_root
3577 .to_str()
3578 .ok_or("temporary data path was not UTF-8")?;
3579
3580 let (store, responder) = super::build_haematite_store_with_hook(
3581 configured,
3582 4,
3583 None,
3584 test_node_cache_budget()?,
3585 &crate::control::StageReporter::detached(),
3586 || {
3587 std::fs::rename(&configured_root, &held_root)?;
3592 symlink(&outside, &configured_root)?;
3593 Ok(())
3594 },
3595 )?;
3596 assert!(responder.is_none());
3597
3598 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
3599 assert!(
3600 outside_entries.is_empty(),
3601 "Haematite followed the replaced ambient root and wrote outside"
3602 );
3603 assert!(held_root.join("config.json").is_file());
3604 for shard in 0..4 {
3605 let shard_path = held_root.join(format!("shard-{shard}"));
3606 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
3607 assert!(
3608 std::fs::read_dir(&shard_path)?
3609 .next()
3610 .transpose()?
3611 .is_some(),
3612 "shard {shard} did not run Haematite's materialization path"
3613 );
3614 }
3615
3616 drop(store);
3617 Ok(())
3618 }
3619
3620 #[cfg(any(target_os = "linux", target_os = "android"))]
3621 #[tokio::test]
3622 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
3623 -> Result<(), Box<dyn std::error::Error>> {
3624 use std::os::unix::fs::symlink;
3625
3626 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3627 use aion_store::{WritableEventStore as _, WriteToken};
3628 use chrono::Utc;
3629
3630 let sandbox = crate::test_support::private_tempdir()?;
3631 let configured_root = sandbox.path().join("data");
3632 let held_root = sandbox.path().join("held-data");
3633 let capture = sandbox.path().join("capture");
3634 std::fs::create_dir(&capture)?;
3635 let configured = configured_root
3636 .to_str()
3637 .ok_or("temporary data path was not UTF-8")?;
3638
3639 let (store, responder) = super::build_haematite_store(
3640 configured,
3641 4,
3642 None,
3643 test_node_cache_budget()?,
3644 &crate::control::StageReporter::detached(),
3645 )?;
3646 assert!(responder.is_none());
3647 std::fs::rename(&configured_root, &held_root)?;
3648 symlink(&capture, &configured_root)?;
3649
3650 let workflow_id = WorkflowId::new_v4();
3651 let event = aion_core::Event::WorkflowStarted {
3652 envelope: EventEnvelope {
3653 seq: 1,
3654 recorded_at: Utc::now(),
3655 workflow_id: workflow_id.clone(),
3656 },
3657 workflow_type: String::from("post-startup-root-swap"),
3658 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3659 run_id: RunId::new_v4(),
3660 parent_run_id: None,
3661 parent_workflow_id: None,
3662 package_version: PackageVersion::new("a".repeat(64)),
3663 };
3664 store
3665 .append(
3666 WriteToken::recorder(),
3667 &workflow_id,
3668 std::slice::from_ref(&event),
3669 0,
3670 )
3671 .await?;
3672
3673 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
3674 assert!(
3675 captured.is_empty(),
3676 "post-startup append followed the replacement symlink into capture"
3677 );
3678 assert!(held_root.join("config.json").is_file());
3679 drop(store);
3680 Ok(())
3681 }
3682
3683 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
3684 #[test]
3685 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
3686 -> Result<(), Box<dyn std::error::Error>> {
3687 use std::os::unix::fs::PermissionsExt as _;
3688
3689 let sandbox = crate::test_support::private_tempdir()?;
3690 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3691
3692 for mode in [0o770, 0o1777] {
3693 let shared = sandbox.path().join(format!("shared-{mode:o}"));
3694 let data_root = shared.join("data");
3695 std::fs::create_dir(&shared)?;
3696 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
3697 std::fs::create_dir(&data_root)?;
3698 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
3699 let configured = data_root
3700 .to_str()
3701 .ok_or("temporary data path was not UTF-8")?;
3702
3703 let Err(error) = super::build_haematite_store(
3704 configured,
3705 4,
3706 None,
3707 test_node_cache_budget()?,
3708 &crate::control::StageReporter::detached(),
3709 ) else {
3710 return Err(format!("mode {mode:04o} ancestor was accepted").into());
3711 };
3712 let message = error.to_string();
3713 let crate::ServerError::UnsafeDataRootAncestor {
3714 data_root: resolved_root,
3715 component,
3716 reason,
3717 } = error
3718 else {
3719 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3720 };
3721 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
3722 assert_eq!(component, std::fs::canonicalize(&shared)?);
3723 assert!(
3724 reason.contains(&format!("mode {mode:04o}")),
3725 "unexpected reason: {reason}"
3726 );
3727 if mode & 0o1000 != 0 {
3728 assert!(reason.contains("sticky bit is not accepted"));
3729 }
3730 assert!(message.contains("private Aion home"));
3731 assert!(
3732 !data_root.join("config.json").exists(),
3733 "Haematite touched its ambient path before the refusal"
3734 );
3735 }
3736 Ok(())
3737 }
3738
3739 #[cfg(target_os = "macos")]
3740 #[test]
3741 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
3742 -> Result<(), Box<dyn std::error::Error>> {
3743 use std::os::unix::fs::PermissionsExt as _;
3744
3745 let sandbox = crate::test_support::private_tempdir()?;
3746 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3747 let shared = sandbox.path().join("acl-shared");
3748 let data_root = shared.join("data");
3749 std::fs::create_dir(&shared)?;
3750 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
3751 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
3752 let status = std::process::Command::new("chmod")
3753 .arg("+a")
3754 .arg(acl)
3755 .arg(&shared)
3756 .status()?;
3757 assert!(status.success(), "failed to install Darwin regression ACL");
3758 let configured = data_root
3759 .to_str()
3760 .ok_or("temporary data path was not UTF-8")?;
3761
3762 let result = super::build_haematite_store(
3763 configured,
3764 4,
3765 None,
3766 test_node_cache_budget()?,
3767 &crate::control::StageReporter::detached(),
3768 );
3769 let cleanup = std::process::Command::new("chmod")
3770 .arg("-RN")
3771 .arg(&shared)
3772 .status()?;
3773 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
3774
3775 let Err(error) = result else {
3776 return Err("mutating non-euid allow ACL ancestor was accepted".into());
3777 };
3778 let message = error.to_string();
3779 let crate::ServerError::UnsafeDataRootAncestor {
3780 component, reason, ..
3781 } = error
3782 else {
3783 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3784 };
3785 assert_eq!(component, std::fs::canonicalize(&shared)?);
3786 assert!(
3787 reason.contains("allow"),
3788 "reason did not name the ACE: {reason}"
3789 );
3790 assert!(
3791 reason.contains("everyone"),
3792 "reason did not name the ACE principal: {reason}"
3793 );
3794 assert!(
3795 !data_root.join("config.json").exists(),
3796 "Haematite touched its ambient path before the ACL refusal"
3797 );
3798 Ok(())
3799 }
3800
3801 #[cfg(target_os = "macos")]
3802 #[test]
3803 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
3804 -> Result<(), Box<dyn std::error::Error>> {
3805 use std::os::unix::fs::PermissionsExt as _;
3806
3807 use exacl::{AclEntry, AclOption, Perm};
3808
3809 let sandbox = crate::test_support::private_tempdir()?;
3810 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3811 let private_parent = sandbox.path().join("euid-uuid-allow");
3812 let data_root = private_parent.join("data");
3813 std::fs::create_dir(&private_parent)?;
3814 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3815
3816 let server_uid = rustix::process::geteuid().as_raw();
3817 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
3818 let entry = AclEntry::allow_user(
3819 &ace_qualifier.to_string(),
3820 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3821 None,
3822 );
3823 exacl::setfacl(
3824 &[private_parent.as_path()],
3825 &[entry],
3826 AclOption::SYMLINK_ACL,
3827 )?;
3828 let configured = data_root
3829 .to_str()
3830 .ok_or("temporary data path was not UTF-8")?;
3831
3832 let result = super::build_haematite_store(
3833 configured,
3834 4,
3835 None,
3836 test_node_cache_budget()?,
3837 &crate::control::StageReporter::detached(),
3838 );
3839 let cleanup = std::process::Command::new("chmod")
3840 .arg("-RN")
3841 .arg(&private_parent)
3842 .status()?;
3843 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
3844
3845 let (store, responder) = result?;
3846 assert!(responder.is_none());
3847 assert!(data_root.join("config.json").is_file());
3848 drop(store);
3849 Ok(())
3850 }
3851
3852 #[cfg(target_os = "macos")]
3853 #[test]
3854 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
3855 -> Result<(), Box<dyn std::error::Error>> {
3856 use std::os::unix::fs::PermissionsExt as _;
3857
3858 use exacl::{AclEntry, AclOption, Perm};
3859
3860 let sandbox = crate::test_support::private_tempdir()?;
3861 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3862 let shared = sandbox.path().join("non-euid-uuid-allow");
3863 let data_root = shared.join("data");
3864 std::fs::create_dir(&shared)?;
3865 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
3866
3867 let server_uid = rustix::process::geteuid().as_raw();
3868 let foreign_uid = u32::from(server_uid == 0);
3869 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
3870 let entry = AclEntry::allow_user(
3871 &foreign_qualifier.to_string(),
3872 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3873 None,
3874 );
3875 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
3876 let configured = data_root
3877 .to_str()
3878 .ok_or("temporary data path was not UTF-8")?;
3879
3880 let result = super::build_haematite_store(
3881 configured,
3882 4,
3883 None,
3884 test_node_cache_budget()?,
3885 &crate::control::StageReporter::detached(),
3886 );
3887 let cleanup = std::process::Command::new("chmod")
3888 .arg("-RN")
3889 .arg(&shared)
3890 .status()?;
3891 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
3892
3893 let Err(error) = result else {
3894 return Err("mutating non-euid user UUID allow ACE was accepted".into());
3895 };
3896 let message = error.to_string();
3897 let crate::ServerError::UnsafeDataRootAncestor {
3898 component, reason, ..
3899 } = error
3900 else {
3901 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3902 };
3903 assert_eq!(component, std::fs::canonicalize(&shared)?);
3904 assert!(
3905 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
3906 "reason did not name the rejected ACE: {reason}"
3907 );
3908 assert!(
3909 !data_root.join("config.json").exists(),
3910 "Haematite touched its ambient path before the UUID ACL refusal"
3911 );
3912 Ok(())
3913 }
3914
3915 #[cfg(target_os = "macos")]
3916 #[test]
3917 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
3918 -> Result<(), Box<dyn std::error::Error>> {
3919 use std::os::unix::fs::PermissionsExt as _;
3920
3921 let sandbox = crate::test_support::private_tempdir()?;
3922 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3923 let private_parent = sandbox.path().join("deny-only");
3924 let data_root = private_parent.join("data");
3925 std::fs::create_dir(&private_parent)?;
3926 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3927 let status = std::process::Command::new("chmod")
3928 .arg("+a")
3929 .arg("everyone deny delete")
3930 .arg(&private_parent)
3931 .status()?;
3932 assert!(status.success(), "failed to install Darwin deny-only ACL");
3933 let configured = data_root
3934 .to_str()
3935 .ok_or("temporary data path was not UTF-8")?;
3936
3937 let result = super::build_haematite_store(
3938 configured,
3939 4,
3940 None,
3941 test_node_cache_budget()?,
3942 &crate::control::StageReporter::detached(),
3943 );
3944 let cleanup = std::process::Command::new("chmod")
3945 .arg("-RN")
3946 .arg(&private_parent)
3947 .status()?;
3948 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
3949
3950 let (store, responder) = result?;
3951 assert!(responder.is_none());
3952 assert!(data_root.join("config.json").is_file());
3953 drop(store);
3954 Ok(())
3955 }
3956
3957 #[cfg(target_os = "macos")]
3958 #[test]
3959 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
3960 -> Result<(), Box<dyn std::error::Error>> {
3961 use std::os::unix::fs::PermissionsExt as _;
3962 use users::os::unix::UserExt as _;
3963
3964 let effective_uid = rustix::process::geteuid().as_raw();
3965 let effective_user = users::get_user_by_uid(effective_uid)
3966 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
3967 let sandbox = tempfile::Builder::new()
3968 .prefix(".aion-acl-home-proof-")
3969 .tempdir_in(effective_user.home_dir())?;
3970 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3971 let data_root = sandbox.path().join("data");
3972 let configured = data_root
3973 .to_str()
3974 .ok_or("temporary data path was not UTF-8")?;
3975
3976 let (store, responder) = super::build_haematite_store(
3977 configured,
3978 4,
3979 None,
3980 test_node_cache_budget()?,
3981 &crate::control::StageReporter::detached(),
3982 )?;
3983 assert!(responder.is_none());
3984 assert!(data_root.join("config.json").is_file());
3985 drop(store);
3986 Ok(())
3987 }
3988
3989 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
3990 #[test]
3991 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
3992 -> Result<(), Box<dyn std::error::Error>> {
3993 use std::os::unix::fs::PermissionsExt as _;
3994
3995 let sandbox = crate::test_support::private_tempdir()?;
3996 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3997 let private_parent = sandbox.path().join("private");
3998 let data_root = private_parent.join("data");
3999 std::fs::create_dir(&private_parent)?;
4000 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
4001 let configured = data_root
4002 .to_str()
4003 .ok_or("temporary data path was not UTF-8")?;
4004
4005 let (store, responder) = super::build_haematite_store(
4006 configured,
4007 4,
4008 None,
4009 test_node_cache_budget()?,
4010 &crate::control::StageReporter::detached(),
4011 )?;
4012 assert!(responder.is_none());
4013 assert!(data_root.join("config.json").is_file());
4014 for shard in 0..4 {
4015 assert!(data_root.join(format!("shard-{shard}")).is_dir());
4016 }
4017 drop(store);
4018 Ok(())
4019 }
4020
4021 #[tokio::test]
4022 async fn connect_store_memory_backend_exposes_no_outbox_store()
4023 -> Result<(), Box<dyn std::error::Error>> {
4024 use crate::config::{StoreBackend, StoreConfig};
4025
4026 let connected = super::connect_store(
4029 StoreConfig {
4030 backend: StoreBackend::Memory,
4031 owned_shards: Vec::new(),
4032 data_dir: None,
4033 shard_count: 1,
4034 cluster: None,
4035 node_cache_budget: None,
4036 ..StoreConfig::default()
4037 },
4038 &crate::control::StageReporter::detached(),
4039 )
4040 .await?;
4041 assert!(
4042 connected.outbox_store.is_none(),
4043 "the in-memory backend exposes no outbox store"
4044 );
4045 Ok(())
4046 }
4047
4048 #[tokio::test]
4054 async fn haematite_boot_refuses_without_a_node_cache_budget()
4055 -> Result<(), Box<dyn std::error::Error>> {
4056 use crate::ServerError;
4057 use crate::config::{StoreBackend, StoreConfig};
4058
4059 let sandbox = crate::test_support::private_tempdir()?;
4060 let data_dir = sandbox.path().join("data");
4061 let error = super::connect_haematite_store(
4062 StoreConfig {
4063 backend: StoreBackend::Haematite,
4064 data_dir: Some(
4065 data_dir
4066 .to_str()
4067 .ok_or("temporary data path was not UTF-8")?
4068 .to_owned(),
4069 ),
4070 shard_count: 4,
4071 ..StoreConfig::default()
4072 },
4073 &crate::control::StageReporter::detached(),
4074 )
4075 .await
4076 .err()
4077 .ok_or("the haematite boot path must refuse a store config with no node_cache_budget")?;
4078 let ServerError::Config { message } = error else {
4079 return Err(format!("expected a config refusal, got {error:?}").into());
4080 };
4081 assert!(
4082 message.contains("store.node_cache_budget"),
4083 "the refusal must name the missing key, got: {message}"
4084 );
4085 assert!(
4086 message.contains("AION_STORE_NODE_CACHE_BUDGET"),
4087 "the refusal must name the environment override, got: {message}"
4088 );
4089 Ok(())
4090 }
4091
4092 #[tokio::test]
4097 async fn configured_node_cache_budget_reaches_the_created_database()
4098 -> Result<(), Box<dyn std::error::Error>> {
4099 use crate::config::{StoreBackend, StoreConfig};
4100
4101 const ONE_GIB: usize = 1 << 30;
4102
4103 let sandbox = crate::test_support::private_tempdir()?;
4104 let data_dir = sandbox.path().join("data");
4105 let connected = super::connect_haematite_store(
4106 StoreConfig {
4107 backend: StoreBackend::Haematite,
4108 data_dir: Some(
4109 data_dir
4110 .to_str()
4111 .ok_or("temporary data path was not UTF-8")?
4112 .to_owned(),
4113 ),
4114 shard_count: 4,
4115 node_cache_budget: Some(haematite::NodeCacheBudget::bytes(ONE_GIB)?),
4116 ..StoreConfig::default()
4117 },
4118 &crate::control::StageReporter::detached(),
4119 )
4120 .await?;
4121 drop(connected);
4122
4123 let recorded: serde_json::Value =
4124 serde_json::from_slice(&std::fs::read(data_dir.join("config.json"))?)?;
4125 assert_eq!(
4126 recorded.get("node_cache_budget"),
4127 Some(&serde_json::json!({ "bytes": ONE_GIB })),
4128 "the operator's budget must be the one haematite created the database with"
4129 );
4130 Ok(())
4131 }
4132
4133 #[tokio::test]
4134 async fn state_build_fails_without_event_broadcast_capacity()
4135 -> Result<(), Box<dyn std::error::Error>> {
4136 let mut runtime = runtime_config();
4137 runtime.websocket.event_broadcast_capacity = None;
4138
4139 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
4140 .await
4141 .err()
4142 .ok_or("state build must fail when event streaming is unsized")?;
4143
4144 assert!(error.is_config(), "expected a config error, got {error}");
4145 assert!(
4146 error
4147 .to_string()
4148 .contains("websocket.event_broadcast_capacity"),
4149 "error must name the missing key: {error}"
4150 );
4151 Ok(())
4152 }
4153
4154 #[tokio::test]
4155 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
4156 let mut runtime = runtime_config();
4157 runtime.query_timeout = None;
4158
4159 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
4160 .await
4161 .err()
4162 .ok_or("state build must fail when the query reply deadline is unset")?;
4163
4164 assert!(error.is_config(), "expected a config error, got {error}");
4165 assert!(
4166 error.to_string().contains("runtime.query_timeout_ms"),
4167 "error must name the missing key: {error}"
4168 );
4169 assert!(
4170 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
4171 "error must name the environment override: {error}"
4172 );
4173 Ok(())
4174 }
4175
4176 #[tokio::test]
4177 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
4178 let mut runtime = runtime_config();
4179 runtime.query_timeout = Some(Duration::ZERO);
4180
4181 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
4182 .await
4183 .err()
4184 .ok_or("state build must fail when the query reply deadline is zero")?;
4185
4186 assert!(error.is_config(), "expected a config error, got {error}");
4187 assert!(
4188 error.to_string().contains("runtime.query_timeout_ms"),
4189 "error must name the zero-valued key: {error}"
4190 );
4191 Ok(())
4192 }
4193
4194 #[tokio::test(flavor = "multi_thread")]
4217 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
4218 -> Result<(), Box<dyn std::error::Error>> {
4219 use std::collections::{BTreeMap, VecDeque};
4220 use std::sync::{Arc, Mutex};
4221
4222 use aion::ActivityDispatch;
4223 use aion_core::{ActivityId, RunId, WorkflowId};
4224 use aion_package::ActionBodyContract;
4225
4226 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
4227 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
4228
4229 struct SequencedBodies {
4232 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
4233 }
4234
4235 impl DeclaredBodies for SequencedBodies {
4236 fn body_for(
4237 &self,
4238 _task_queue: &str,
4239 _action: &str,
4240 _run: DispatchingRun<'_>,
4241 ) -> DeclaredBodyLookup {
4242 let mut replies = match self.replies.lock() {
4243 Ok(replies) => replies,
4244 Err(poisoned) => poisoned.into_inner(),
4245 };
4246 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
4247 }
4248 }
4249
4250 let runtime = runtime_config();
4251 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
4252 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
4253 );
4254 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
4255 Arc::new(InMemoryStore::default());
4256 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
4257 Arc::new(InMemoryStore::default());
4258 let metrics = crate::observability::Metrics::new()?;
4259 let seams = super::build_worker_seams(
4260 &runtime,
4261 &cluster_publisher,
4262 &metrics,
4263 &namespace_store,
4264 &worker_deployment_store,
4265 None,
4266 );
4267
4268 let fixture = concat!(
4271 env!("CARGO_MANIFEST_DIR"),
4272 "/src/update_check/fixtures/aion-cli-index.jsonl"
4273 );
4274 seams.declared_bodies.install(Arc::new(SequencedBodies {
4275 replies: Mutex::new(VecDeque::from([
4276 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
4277 command: FETCH_COMMAND.to_owned(),
4278 }),
4279 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
4280 command: format!("cat {fixture}"),
4281 }),
4282 ])),
4283 }));
4284
4285 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
4286 Arc::new(aion_store::InMemoryObservabilityStore::default()),
4287 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
4288 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
4289 );
4290 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
4291 super::build_decorated_dispatcher(&runtime, &seams, transcript);
4292
4293 assert_eq!(
4294 update_status.last(),
4295 None,
4296 "the returned slot must start honestly empty"
4297 );
4298
4299 let dispatch = ActivityDispatch {
4300 namespace: "default".to_owned(),
4301 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
4302 node: None,
4303 workflow_id: WorkflowId::new_v4(),
4304 run_id: RunId::new_v4(),
4305 activity_id: ActivityId::from_sequence_position(1),
4306 name: FETCH_ACTION.to_owned(),
4307 input: "{}".to_owned(),
4308 config: "{}".to_owned(),
4309 attempt: 1,
4310 labels: BTreeMap::new(),
4311 advisory: false,
4312 };
4313 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
4314 let encoded = handle
4315 .await?
4316 .map_err(|error| format!("the check dispatch failed: {error}"))?;
4317 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
4318 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
4319
4320 let recorded = update_status.last().ok_or(
4321 "the completed check must land in the RETURNED slot — the one the boot path \
4322 stores and /update-status serves; an empty slot here is the disconnected-\
4323 producer mis-wire the r1 review proved unmeasured",
4324 )?;
4325 assert_eq!(recorded.latest_known, "0.13.7");
4326 Ok(())
4327 }
4328}