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 &outbox_wake,
372 );
373 let (
374 activity_dispatcher,
375 activity_mock_registry,
376 attempt_owners,
377 workspace_root,
378 update_status,
379 ) = build_decorated_dispatcher(&runtime, &seams, transcript_publisher.clone());
380
381 let engine = boot_engine(EngineAssembly {
382 seams: &seams,
383 instrumented_store: &instrumented_store,
384 event_broadcast_capacity,
385 query_timeout,
386 workloop_store: Arc::clone(&connected.workloop_store),
387 visibility_store: Arc::clone(&connected.visibility_store),
388 workloop_sweep_interval,
389 activity_dispatcher,
390 active_registry: Arc::new(aion::Registry::default()),
391 bootstrap_coordinator,
392 runtime: &runtime,
393 stage,
394 })
395 .await?;
396 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
397 let worker_supervisor =
398 new_supervisor(&connected.worker_deployment_store, &cluster_publisher);
399 #[cfg(feature = "auth")]
400 let jwks_cache = build_jwks_cache(&runtime).await?;
401 let assistant_sessions =
402 build_assistant_sessions(Arc::clone(&connected.assistant_store), &runtime);
403 Ok(Self {
404 inner: Arc::new(ServerStateInner {
405 namespace_guard: NamespaceGuard::new(resolver),
406 assistant_sessions,
407 runtime,
408 metrics: exported_metrics,
409 worker_registry: seams.worker_registry,
410 pending_activities: seams.pending_activities,
411 heartbeat_tracker: seams.heartbeat_tracker,
412 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
413 drain_state: seams.drain_state,
414 health: Some(HealthState::new(instrumented_store, true)),
415 activity_mock_registry,
416 outbox_store,
417 namespace_store: connected.namespace_store,
418 worker_supervisor,
419 worker_deployment_store: connected.worker_deployment_store,
420 outbox_wake,
421 cluster_publisher,
422 transcript_publisher,
423 attempt_owners,
424 queue_service_state: seams.queue_service_state,
425 queue_declarations: seams.queue_declarations,
426 declared_attempts: seams.declared_attempts,
427 declared_bodies: seams.declared_bodies.clone(),
428 update_status,
429 workspace_root,
430 cluster_self_node,
431 cluster_responder,
432 cluster_store,
433 watched_peers,
434 shard_directory,
435 request_forwarder,
436 #[cfg(feature = "auth")]
437 jwks_cache,
438 }),
439 })
440 }
441
442 #[must_use]
444 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
445 Self::from_parts_with_namespace_store(
450 namespace_resolver,
451 runtime,
452 Arc::new(aion_store::InMemoryStore::default()),
453 )
454 }
455
456 #[must_use]
463 pub fn from_parts_with_namespace_store<S>(
464 namespace_resolver: NamespaceResolver,
465 runtime: RuntimeConfig,
466 store: Arc<S>,
467 ) -> Self
468 where
469 S: NamespaceStore + WorkerDeploymentStore,
470 {
471 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
472 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
473 Self::from_parts_with_control_stores(
474 namespace_resolver,
475 runtime,
476 namespace_store,
477 worker_deployment_store,
478 )
479 }
480
481 #[must_use]
486 pub fn from_parts_with_control_stores(
487 namespace_resolver: NamespaceResolver,
488 runtime: RuntimeConfig,
489 namespace_store: Arc<dyn NamespaceStore>,
490 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
491 ) -> Self {
492 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
493 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
499 let bounds = transcript_bounds(&runtime);
503 let batch = required_transcript_batch_policy(&runtime)
507 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
508 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
509 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
510 );
511 let drain_state = DrainState::default();
512 let assistant_sessions =
513 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
514 Self {
515 inner: Arc::new(ServerStateInner {
516 namespace_guard: NamespaceGuard::new(namespace_resolver),
517 assistant_sessions,
518 runtime,
519 worker_registry: ConnectedWorkerRegistry::default()
520 .with_worker_deployment_store(worker_deployment_store.clone())
521 .with_cluster_publisher(cluster_publisher.clone()),
522 pending_activities,
523 heartbeat_tracker,
524 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
525 drain_state: drain_state.clone(),
526 metrics: None,
527 health: None,
528 activity_mock_registry: None,
529 outbox_store: None,
530 namespace_store,
531 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
532 worker_deployment_store,
533 outbox_wake: Arc::new(tokio::sync::Notify::new()),
534 cluster_publisher,
535 transcript_publisher: build_transcript_publisher(
539 None,
540 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
541 bounds,
542 batch,
543 ),
544 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
545 queue_service_state: crate::worker::QueueServiceState::default(),
546 queue_declarations: crate::worker::QueueDeclarationSource::default(),
547 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
551 declared_bodies: crate::worker::DeclaredBodySource::default(),
552 update_status: crate::update_check::UpdateStatusState::default(),
556 workspace_root: crate::worker::WorkspaceRoot::resolve(),
560 cluster_self_node: None,
561 cluster_responder: None,
562 cluster_store: None,
563 watched_peers: Vec::new(),
564 shard_directory: None,
565 request_forwarder: None,
566 #[cfg(feature = "auth")]
567 jwks_cache: None,
568 }),
569 }
570 }
571
572 #[cfg(feature = "auth")]
581 #[must_use]
582 pub fn from_parts_with_namespace_store_and_jwks<S>(
583 namespace_resolver: NamespaceResolver,
584 runtime: RuntimeConfig,
585 store: Arc<S>,
586 jwks_cache: JwksCache,
587 ) -> Self
588 where
589 S: NamespaceStore + WorkerDeploymentStore,
590 {
591 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
592 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
593 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
594 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
600 let bounds = transcript_bounds(&runtime);
604 let batch = required_transcript_batch_policy(&runtime)
608 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
609 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
610 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
611 );
612 let drain_state = DrainState::default();
613 let assistant_sessions =
614 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
615 Self {
616 inner: Arc::new(ServerStateInner {
617 namespace_guard: NamespaceGuard::new(namespace_resolver),
618 assistant_sessions,
619 runtime,
620 worker_registry: ConnectedWorkerRegistry::default()
621 .with_worker_deployment_store(worker_deployment_store.clone())
622 .with_cluster_publisher(cluster_publisher.clone()),
623 pending_activities,
624 heartbeat_tracker,
625 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
626 drain_state: drain_state.clone(),
627 metrics: None,
628 health: None,
629 activity_mock_registry: None,
630 outbox_store: None,
631 namespace_store,
632 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
633 worker_deployment_store,
634 outbox_wake: Arc::new(tokio::sync::Notify::new()),
635 cluster_publisher,
636 transcript_publisher: build_transcript_publisher(
640 None,
641 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
642 bounds,
643 batch,
644 ),
645 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
646 queue_service_state: crate::worker::QueueServiceState::default(),
647 queue_declarations: crate::worker::QueueDeclarationSource::default(),
648 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
652 declared_bodies: crate::worker::DeclaredBodySource::default(),
653 update_status: crate::update_check::UpdateStatusState::default(),
657 workspace_root: crate::worker::WorkspaceRoot::resolve(),
661 cluster_self_node: None,
662 cluster_responder: None,
663 cluster_store: None,
664 watched_peers: Vec::new(),
665 shard_directory: None,
666 request_forwarder: None,
667 jwks_cache: Some(jwks_cache),
668 }),
669 }
670 }
671
672 #[cfg(feature = "auth")]
678 #[must_use]
679 pub fn from_parts_with_jwks(
680 namespace_resolver: NamespaceResolver,
681 runtime: RuntimeConfig,
682 jwks_cache: JwksCache,
683 ) -> Self {
684 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
688 Arc::new(aion_store::InMemoryStore::default());
689 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
690 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
696 let bounds = transcript_bounds(&runtime);
700 let batch = required_transcript_batch_policy(&runtime)
704 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
705 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
706 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
707 );
708 let drain_state = DrainState::default();
709 let assistant_sessions =
710 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
711 Self {
712 inner: Arc::new(ServerStateInner {
713 namespace_guard: NamespaceGuard::new(namespace_resolver),
714 assistant_sessions,
715 runtime,
716 worker_registry: ConnectedWorkerRegistry::default(),
717 pending_activities,
718 heartbeat_tracker,
719 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
720 drain_state: drain_state.clone(),
721 metrics: None,
722 health: None,
723 activity_mock_registry: None,
724 outbox_store: None,
725 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
730 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
731 worker_deployment_store: fallback_deployment_store,
732 outbox_wake: Arc::new(tokio::sync::Notify::new()),
733 cluster_publisher,
734 transcript_publisher: build_transcript_publisher(
738 None,
739 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
740 bounds,
741 batch,
742 ),
743 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
744 queue_service_state: crate::worker::QueueServiceState::default(),
745 queue_declarations: crate::worker::QueueDeclarationSource::default(),
746 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
750 declared_bodies: crate::worker::DeclaredBodySource::default(),
751 update_status: crate::update_check::UpdateStatusState::default(),
755 workspace_root: crate::worker::WorkspaceRoot::resolve(),
759 cluster_self_node: None,
760 cluster_responder: None,
761 cluster_store: None,
762 watched_peers: Vec::new(),
763 shard_directory: None,
764 request_forwarder: None,
765 jwks_cache: Some(jwks_cache),
766 }),
767 }
768 }
769
770 #[must_use]
772 pub fn from_parts_with_registry(
773 namespace_resolver: NamespaceResolver,
774 runtime: RuntimeConfig,
775 worker_registry: ConnectedWorkerRegistry,
776 ) -> Self {
777 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
781 Arc::new(aion_store::InMemoryStore::default());
782 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
783 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
789 let bounds = transcript_bounds(&runtime);
793 let batch = required_transcript_batch_policy(&runtime)
797 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
798 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
799 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
800 );
801 let drain_state = DrainState::default();
802 let assistant_sessions =
803 build_assistant_sessions(Arc::new(aion_store::InMemoryStore::default()), &runtime);
804 Self {
805 inner: Arc::new(ServerStateInner {
806 namespace_guard: NamespaceGuard::new(namespace_resolver),
807 assistant_sessions,
808 runtime,
809 worker_registry,
810 pending_activities,
811 heartbeat_tracker,
812 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
813 drain_state: drain_state.clone(),
814 metrics: None,
815 health: None,
816 activity_mock_registry: None,
817 outbox_store: None,
818 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
823 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
824 worker_deployment_store: fallback_deployment_store,
825 outbox_wake: Arc::new(tokio::sync::Notify::new()),
826 cluster_publisher,
827 transcript_publisher: build_transcript_publisher(
831 None,
832 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
833 bounds,
834 batch,
835 ),
836 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
837 queue_service_state: crate::worker::QueueServiceState::default(),
838 queue_declarations: crate::worker::QueueDeclarationSource::default(),
839 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
843 declared_bodies: crate::worker::DeclaredBodySource::default(),
844 update_status: crate::update_check::UpdateStatusState::default(),
848 workspace_root: crate::worker::WorkspaceRoot::resolve(),
852 cluster_self_node: None,
853 cluster_responder: None,
854 cluster_store: None,
855 watched_peers: Vec::new(),
856 shard_directory: None,
857 request_forwarder: None,
858 #[cfg(feature = "auth")]
859 jwks_cache: None,
860 }),
861 }
862 }
863
864 #[must_use]
866 pub fn namespace_guard(&self) -> &NamespaceGuard {
867 &self.inner.namespace_guard
868 }
869
870 #[must_use]
872 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
873 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
874 }
875
876 #[must_use]
878 pub fn runtime_config(&self) -> &RuntimeConfig {
879 &self.inner.runtime
880 }
881
882 #[must_use]
890 pub(crate) fn update_status(&self) -> &crate::update_check::UpdateStatusState {
891 &self.inner.update_status
892 }
893
894 #[must_use]
904 pub fn workspace_root(&self) -> &crate::worker::WorkspaceRoot {
905 &self.inner.workspace_root
906 }
907
908 #[must_use]
916 pub fn assistant_sessions(&self) -> &crate::assistant::sessions::AssistantSessions {
917 &self.inner.assistant_sessions
918 }
919
920 #[must_use]
922 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
923 &self.inner.worker_registry
924 }
925
926 pub fn cancel_in_flight_activities(
945 &self,
946 workflow_id: &aion_core::WorkflowId,
947 ) -> Result<crate::worker::InFlightCancellation, ServerError> {
948 crate::worker::cancel_in_flight_activities(
949 self.heartbeat_tracker(),
950 self.worker_registry(),
951 self.declared_attempts(),
952 workflow_id,
953 )
954 }
955
956 #[must_use]
961 pub fn declared_attempts(&self) -> &crate::worker::DeclaredCommandAttempts {
962 &self.inner.declared_attempts
963 }
964
965 #[must_use]
970 pub fn declared_bodies(&self) -> &crate::worker::DeclaredBodySource {
971 &self.inner.declared_bodies
972 }
973
974 #[must_use]
978 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
979 &self.inner.cluster_publisher
980 }
981
982 #[must_use]
987 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
988 &self.inner.transcript_publisher
989 }
990
991 #[must_use]
995 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
996 &self.inner.attempt_owners
997 }
998
999 #[must_use]
1002 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
1003 &self.inner.queue_service_state
1004 }
1005
1006 #[must_use]
1010 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
1011 &self.inner.queue_declarations
1012 }
1013
1014 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
1025 self.inner.queue_service_state.unserved()
1026 }
1027
1028 pub fn unrecoverable_runs(
1046 &self,
1047 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
1048 self.engine()?
1049 .registry()
1050 .unrecoverable()
1051 .list()
1052 .map_err(ServerError::from)
1053 }
1054
1055 #[must_use]
1067 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
1068 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
1069 #[cfg(feature = "liminal-transport")]
1070 {
1071 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
1072 }
1073 #[cfg(not(feature = "liminal-transport"))]
1074 {
1075 std::sync::Arc::new(NullInterventionTransport)
1076 }
1077 };
1078 crate::worker::InterventionRouter::new(
1079 self.inner.worker_registry.clone(),
1080 self.inner.attempt_owners.clone(),
1081 transport,
1082 )
1083 .with_transcript_publisher(self.inner.transcript_publisher.clone())
1086 }
1087
1088 #[must_use]
1092 pub fn cluster_self_node(&self) -> Option<&str> {
1093 self.inner.cluster_self_node.as_deref()
1094 }
1095
1096 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
1109 self.inner
1110 .namespace_guard
1111 .resolver()
1112 .engine()
1113 .map(Arc::clone)
1114 }
1115
1116 #[must_use]
1118 pub fn pending_activities(&self) -> &PendingActivities {
1119 &self.inner.pending_activities
1120 }
1121
1122 #[must_use]
1126 pub fn read_provenance(&self) -> aion_core::ReadProvenance {
1127 aion_core::ReadProvenance::new(
1128 self.inner
1129 .pending_activities
1130 .lease_recorder()
1131 .ledger()
1132 .failures(),
1133 )
1134 }
1135
1136 #[must_use]
1138 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
1139 &self.inner.heartbeat_tracker
1140 }
1141
1142 #[must_use]
1148 pub fn grpc_liveness_waiters(&self) -> &crate::worker::GrpcLivenessWaiters {
1149 &self.inner.grpc_liveness_waiters
1150 }
1151
1152 #[must_use]
1154 pub fn drain_state(&self) -> &DrainState {
1155 &self.inner.drain_state
1156 }
1157
1158 #[must_use]
1160 pub fn metrics(&self) -> Option<&Metrics> {
1161 self.inner.metrics.as_ref()
1162 }
1163
1164 #[must_use]
1166 pub fn health(&self) -> Option<&HealthState> {
1167 self.inner.health.as_ref()
1168 }
1169
1170 #[must_use]
1175 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
1176 self.inner.activity_mock_registry.as_ref()
1177 }
1178
1179 #[must_use]
1185 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
1186 self.inner.outbox_store.clone()
1187 }
1188
1189 #[must_use]
1198 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
1199 &self.inner.namespace_store
1200 }
1201
1202 #[must_use]
1204 pub fn worker_deployment_store(&self) -> &Arc<dyn WorkerDeploymentStore> {
1205 &self.inner.worker_deployment_store
1206 }
1207
1208 #[must_use]
1215 pub fn worker_supervisor(&self) -> &Arc<WorkerSupervisor> {
1216 &self.inner.worker_supervisor
1217 }
1218
1219 #[must_use]
1228 pub fn namespace_minter(&self) -> NamespaceMinter {
1229 let minter = NamespaceMinter::new(
1230 Arc::clone(&self.inner.namespace_store),
1231 self.inner.runtime.auto_create,
1232 )
1233 .with_cluster_publisher(self.inner.cluster_publisher.clone());
1238 match self.namespace_routing() {
1243 Some(routing) => minter.with_routing(routing),
1244 None => minter,
1245 }
1246 }
1247
1248 #[must_use]
1258 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1259 build_namespace_routing(
1260 self.cluster_store(),
1261 self.shard_directory(),
1262 self.request_forwarder(),
1263 )
1264 }
1265
1266 #[must_use]
1272 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
1273 Arc::clone(&self.inner.outbox_wake)
1274 }
1275
1276 #[must_use]
1282 pub fn is_clustered(&self) -> bool {
1283 self.inner.cluster_responder.is_some()
1284 }
1285
1286 #[must_use]
1291 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
1292 self.inner.cluster_store.as_ref()
1293 }
1294
1295 #[must_use]
1299 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
1300 self.inner.shard_directory.as_ref()
1301 }
1302
1303 #[must_use]
1306 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
1307 self.inner.request_forwarder.as_ref()
1308 }
1309
1310 #[must_use]
1327 pub fn spawn_heartbeat_sweeper(
1328 &self,
1329 shutdown: tokio::sync::watch::Receiver<bool>,
1330 ) -> tokio::task::JoinHandle<()> {
1331 let sweeper = crate::worker::HeartbeatSweeper::new(
1332 self.inner.heartbeat_tracker.clone(),
1333 self.inner.worker_registry.clone(),
1334 self.inner.pending_activities.clone(),
1335 self.inner.drain_state.clone(),
1336 self.inner.runtime.worker.heartbeat_window,
1337 )
1338 .with_queue_state(self.inner.queue_service_state.clone());
1342 tokio::spawn(sweeper.run(shutdown))
1343 }
1344
1345 pub fn spawn_startup_catchup(
1369 &self,
1370 mut shutdown: tokio::sync::watch::Receiver<bool>,
1371 ) -> Result<tokio::task::JoinHandle<()>, ServerError> {
1372 let engine = self.engine()?;
1373 Ok(tokio::spawn(async move {
1374 tracing::info!(
1375 "startup catch-up running behind open doors: owed timer fires, \
1376 schedule catch-up"
1377 );
1378 let catchup = engine.run_startup_catchup();
1379 tokio::pin!(catchup);
1380 tokio::select! {
1381 result = &mut catchup => match result {
1382 Ok(()) => {
1383 tracing::info!("startup catch-up complete");
1384 }
1385 Err(error) => {
1386 tracing::error!(
1387 %error,
1388 "startup catch-up failed; owed timer fires and schedule \
1389 catch-up remain undelivered — restart the server to re-run \
1390 the sweep from durable state"
1391 );
1392 }
1393 },
1394 _ = shutdown.changed() => {
1395 tracing::info!(
1396 "startup catch-up interrupted by shutdown; the next boot's \
1397 sweep resumes from durable state"
1398 );
1399 }
1400 }
1401 }))
1402 }
1403
1404 #[cfg(feature = "liminal-transport")]
1415 #[must_use]
1416 pub fn spawn_liminal_liveness_probe(
1417 &self,
1418 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1419 shutdown: tokio::sync::watch::Receiver<bool>,
1420 ) -> tokio::task::JoinHandle<()> {
1421 let probe = crate::worker::LivenessProbe::across_transports(
1422 Some(notifier),
1423 Some(self.inner.grpc_liveness_waiters.clone()),
1428 self.inner.heartbeat_tracker.clone(),
1429 self.inner.worker_registry.clone(),
1430 self.inner.runtime.worker.heartbeat_window,
1431 );
1432 tokio::spawn(probe.run(shutdown))
1433 }
1434
1435 pub fn spawn_cluster_supervisor(
1451 &self,
1452 config: crate::cluster::SupervisorConfig,
1453 shutdown: tokio::sync::watch::Receiver<bool>,
1454 ) -> Result<bool, ServerError> {
1455 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1456 return Ok(false);
1457 };
1458 if self.inner.watched_peers.is_empty() {
1459 return Ok(false);
1460 }
1461 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1462 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1466 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1467 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1473 engine,
1474 self.inner.outbox_store.clone(),
1475 ));
1476 let supervisor = crate::cluster::ClusterSupervisor::new(
1477 cluster_store,
1478 adopter,
1479 self.inner.watched_peers.clone(),
1480 config,
1481 )
1482 .with_publisher(publisher, self_node);
1483 if !supervisor.watches_any() {
1484 return Ok(false);
1485 }
1486 tokio::spawn(supervisor.run(shutdown));
1487 Ok(true)
1488 }
1489
1490 #[cfg(feature = "auth")]
1492 #[must_use]
1493 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1494 self.inner.jwks_cache.as_ref()
1495 }
1496
1497 pub fn shutdown(&self) -> Result<(), ServerError> {
1504 self.inner.namespace_guard.resolver().shutdown_engine()
1505 }
1506}
1507
1508#[cfg(feature = "auth")]
1509async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1510 if !runtime.auth.enabled {
1511 return Ok(None);
1512 }
1513 let Some(url) = runtime.auth.jwks_url.clone() else {
1514 return Err(ServerError::Config {
1515 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1516 });
1517 };
1518 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1519 let cache = JwksCache::new(url, interval)
1520 .await
1521 .map_err(|error| ServerError::Config {
1522 message: format!("auth jwks initial fetch failed: {error}"),
1523 })?;
1524 Ok(Some(cache))
1525}
1526
1527fn metrics_config_error(error: &MetricsError) -> ServerError {
1528 ServerError::Config {
1529 message: error.to_string(),
1530 }
1531}
1532
1533struct EngineAssembly<'a> {
1535 seams: &'a WorkerSeams,
1537 instrumented_store: &'a Arc<InstrumentedEventStore>,
1539 event_broadcast_capacity: std::num::NonZeroUsize,
1541 query_timeout: std::time::Duration,
1543 workloop_store: Arc<dyn aion_store::workloop::WorkloopStore>,
1547 visibility_store: Arc<dyn aion_store::visibility::VisibilityStore>,
1551 workloop_sweep_interval: std::time::Duration,
1554 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1556 active_registry: Arc<aion::Registry>,
1558 bootstrap_coordinator: bool,
1560 runtime: &'a RuntimeConfig,
1562 stage: &'a crate::control::StageReporter,
1566}
1567
1568fn server_search_attribute_schema() -> Result<aion_core::SearchAttributeSchema, ServerError> {
1577 let mut schema = aion_core::SearchAttributeSchema::new();
1578 for (name, label) in [
1579 (crate::namespace::NAMESPACE_ATTRIBUTE, "namespace"),
1580 (crate::namespace::TASK_QUEUE_ATTRIBUTE, "task_queue"),
1581 (crate::namespace::DISPLAY_NAME_ATTRIBUTE, "display_name"),
1582 ] {
1583 schema
1584 .register(name, aion_core::SearchAttributeType::String)
1585 .map_err(|error| ServerError::Config {
1586 message: format!("failed to register {label} search attribute: {error}"),
1587 })?;
1588 }
1589 Ok(schema)
1590}
1591
1592async fn boot_engine(assembly: EngineAssembly<'_>) -> Result<Arc<aion::Engine>, ServerError> {
1602 let search_attribute_schema = server_search_attribute_schema()?;
1603 let runtime = assembly.runtime;
1604 let builder = EngineBuilder::new()
1605 .store_arc(assembly.instrumented_store.clone())
1606 .event_streaming(assembly.event_broadcast_capacity)
1607 .visibility_store_arc(assembly.visibility_store)
1608 .search_attribute_schema(search_attribute_schema)
1609 .scheduler_threads(runtime.scheduler_threads)
1610 .stop_drain_timeout(
1614 runtime
1615 .stop_drain_timeout
1616 .ok_or_else(|| ServerError::Config {
1617 message: String::from(crate::config::STOP_DRAIN_TIMEOUT_REQUIRED),
1618 })?,
1619 )
1620 .outbox_enabled(runtime.outbox.enabled)
1621 .activity_dispatcher(assembly.activity_dispatcher)
1622 .active_registry(assembly.active_registry)
1623 .production_recovery_seam()
1624 .defer_startup_recovery()
1631 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1632 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1633 })
1634 .query_timeout(assembly.query_timeout)
1635 .with_workloop_service(
1643 Arc::clone(&assembly.workloop_store),
1644 assembly.workloop_sweep_interval,
1645 )
1646 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1652 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1653 let builder = if runtime.owned_shards.is_empty() {
1659 builder
1660 } else {
1661 builder.owned_shards(runtime.owned_shards.iter().copied())
1662 };
1663 let builder = match runtime.jit_threshold {
1669 Some(threshold) => builder.scheduler_jit_threshold(threshold),
1670 None => builder,
1671 };
1672 let engine = Arc::new(builder.build().await.map_err(ServerError::from)?);
1673 install_engine_backed_seams(assembly.seams, &engine, runtime.outbox.enabled);
1674 assembly.stage.report(
1687 crate::control::stage::STAGE_ENGINE_RECOVERY,
1688 "recovering resident workflows from durable state".to_owned(),
1689 );
1690 engine
1691 .recover_workflows_on_startup()
1692 .await
1693 .map_err(ServerError::from)?;
1694 assembly.stage.report(
1695 crate::control::stage::STAGE_ENGINE_RECOVERY,
1696 "resident workflows recovered".to_owned(),
1697 );
1698 Ok(engine)
1699}
1700
1701fn required_engine_seams(
1706 runtime: &RuntimeConfig,
1707) -> Result<
1708 (
1709 std::num::NonZeroUsize,
1710 std::time::Duration,
1711 std::time::Duration,
1712 ),
1713 ServerError,
1714> {
1715 let event_broadcast_capacity = runtime
1716 .websocket
1717 .event_broadcast_capacity
1718 .and_then(std::num::NonZeroUsize::new)
1719 .ok_or_else(|| ServerError::Config {
1720 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1721 })?;
1722 let query_timeout = runtime
1723 .query_timeout
1724 .filter(|timeout| !timeout.is_zero())
1725 .ok_or_else(|| ServerError::Config {
1726 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1727 })?;
1728 let workloop_sweep_interval = runtime
1733 .workloop_sweep_interval
1734 .filter(|interval| !interval.is_zero())
1735 .ok_or_else(|| ServerError::Config {
1736 message: crate::config::WORKLOOP_SWEEP_INTERVAL_REQUIRED.to_owned(),
1737 })?;
1738 Ok((
1739 event_broadcast_capacity,
1740 query_timeout,
1741 workloop_sweep_interval,
1742 ))
1743}
1744
1745fn install_outbox_delivery(
1752 pending_activities: &PendingActivities,
1753 engine: &Arc<aion::Engine>,
1754 outbox_enabled: bool,
1755) {
1756 if outbox_enabled {
1757 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1758 Arc::clone(engine),
1759 ));
1760 pending_activities.set_outbox_delivery(callback);
1761 }
1762}
1763
1764struct WorkerSeams {
1766 worker_registry: ConnectedWorkerRegistry,
1767 pending_activities: PendingActivities,
1768 heartbeat_tracker: HeartbeatTracker,
1769 drain_state: DrainState,
1770 queue_declarations: crate::worker::QueueDeclarationSource,
1771 queue_service_state: crate::worker::QueueServiceState,
1772 declared_bodies: crate::worker::DeclaredBodySource,
1773 declared_attempts: crate::worker::DeclaredCommandAttempts,
1779 metrics: Metrics,
1782 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
1786}
1787
1788fn build_worker_seams(
1793 runtime: &RuntimeConfig,
1794 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1795 metrics: &Metrics,
1796 namespace_store: &Arc<dyn NamespaceStore>,
1797 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1798 mint_routing: Option<crate::namespace::NamespaceRouting>,
1799 outbox_wake: &Arc<tokio::sync::Notify>,
1802) -> WorkerSeams {
1803 let worker_registry = ConnectedWorkerRegistry::default()
1804 .with_capacity_wake(Arc::clone(outbox_wake))
1808 .with_cluster_publisher(cluster_publisher.clone())
1809 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1810 .with_worker_deployment_store(worker_deployment_store.clone());
1811 let worker_registry = match mint_routing {
1817 Some(routing) => worker_registry.with_namespace_routing(routing),
1818 None => worker_registry,
1819 };
1820 let drain_state = DrainState::default();
1821 WorkerSeams {
1822 worker_registry,
1823 pending_activities: PendingActivities::new(runtime.worker.heartbeat_window),
1828 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1829 declared_attempts: crate::worker::DeclaredCommandAttempts::new(drain_state.clone()),
1830 drain_state,
1831 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1832 queue_service_state: crate::worker::QueueServiceState::default(),
1833 declared_bodies: crate::worker::DeclaredBodySource::default(),
1834 metrics: metrics.clone(),
1835 cluster_publisher: cluster_publisher.clone(),
1836 }
1837}
1838
1839fn install_queue_declarations(
1846 queue_declarations: &crate::worker::QueueDeclarationSource,
1847 engine: &Arc<aion::Engine>,
1848) {
1849 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1850 Arc::clone(engine),
1851 )));
1852}
1853
1854fn install_declared_bodies(
1869 declared_bodies: &crate::worker::DeclaredBodySource,
1870 engine: &Arc<aion::Engine>,
1871) {
1872 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1873 Arc::clone(engine),
1874 )));
1875}
1876
1877fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1883 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1884 seams.pending_activities.set_lease_recorder(
1888 Arc::new(crate::worker::EngineLeaseRecorder::new(Arc::clone(engine))),
1889 Some(seams.metrics.clone()),
1890 );
1891 install_queue_declarations(&seams.queue_declarations, engine);
1892 install_declared_bodies(&seams.declared_bodies, engine);
1893}
1894
1895fn build_bridge_dispatcher(
1908 runtime: &RuntimeConfig,
1909 seams: &WorkerSeams,
1910) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1911 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1912 let dispatcher = WorkerActivityDispatcher::new(
1913 seams.worker_registry.clone(),
1914 runtime.default_namespace.clone(),
1915 seams.heartbeat_tracker.clone(),
1916 )
1917 .with_pending(seams.pending_activities.clone())
1918 .with_drain_state(seams.drain_state.clone())
1919 .with_tokio_handle(tokio::runtime::Handle::current())
1920 .with_attempt_owners(attempt_owners.clone())
1921 .with_queue_service(runtime.worker.queue_service.clone())
1922 .with_queue_declarations(seams.queue_declarations.clone())
1923 .with_queue_state(seams.queue_service_state.clone())
1924 .with_cluster_publisher(seams.cluster_publisher.clone());
1925 (dispatcher, attempt_owners)
1926}
1927
1928fn build_instrumented_store(
1941 runtime: &RuntimeConfig,
1942 event_store: Arc<dyn EventStore>,
1943) -> Result<
1944 (
1945 Metrics,
1946 Arc<tokio::sync::Notify>,
1947 Arc<InstrumentedEventStore>,
1948 ),
1949 ServerError,
1950> {
1951 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1952 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1953 let instrumented_store = Arc::new(
1954 InstrumentedEventStore::new(
1955 event_store,
1956 metrics.clone(),
1957 runtime.default_namespace.clone(),
1958 )
1959 .with_outbox_wake(Arc::clone(&outbox_wake)),
1960 );
1961 Ok((metrics, outbox_wake, instrumented_store))
1962}
1963
1964fn build_decorated_dispatcher(
1969 runtime: &RuntimeConfig,
1970 seams: &WorkerSeams,
1971 transcript: crate::activity_publisher::ActivityEventPublisher,
1972) -> (
1973 Arc<dyn ActivityDispatcher>,
1974 Option<ActivityMockRegistry>,
1975 crate::worker::AttemptOwnerIndex,
1976 crate::worker::WorkspaceRoot,
1977 crate::update_check::UpdateStatusState,
1978) {
1979 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1983 let update_status = crate::update_check::UpdateStatusState::default();
1986 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1987 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1988 dispatcher,
1989 seams.declared_bodies.clone(),
1990 seams.declared_attempts.clone(),
1991 workspace_root.clone(),
1992 transcript,
1993 update_status.clone(),
1994 runtime.dev.enabled,
1995 );
1996 (
1997 activity_dispatcher,
1998 activity_mock_registry,
1999 attempt_owners,
2000 workspace_root,
2001 update_status,
2002 )
2003}
2004
2005fn decorate_activity_dispatcher(
2006 dispatcher: WorkerActivityDispatcher,
2007 declared_bodies: crate::worker::DeclaredBodySource,
2008 declared_attempts: crate::worker::DeclaredCommandAttempts,
2009 workspace_root: crate::worker::WorkspaceRoot,
2010 transcript: crate::activity_publisher::ActivityEventPublisher,
2011 update_status: crate::update_check::UpdateStatusState,
2012 dev_enabled: bool,
2013) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
2014 let declared = crate::worker::DeclaredCommandDispatcher::new(
2022 Arc::new(dispatcher),
2023 declared_bodies.clone(),
2024 declared_attempts,
2025 tokio::runtime::Handle::current(),
2026 workspace_root,
2027 transcript,
2028 );
2029 let observed = crate::update_check::UpdateCheckObserver::new(
2030 Arc::new(declared),
2031 declared_bodies,
2032 update_status,
2033 );
2034 if dev_enabled {
2035 let registry = ActivityMockRegistry::new();
2036 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
2037 (Arc::new(decorated), Some(registry))
2038 } else {
2039 (Arc::new(observed), None)
2040 }
2041}
2042
2043fn required_cluster_broadcast_capacity(
2048 runtime: &RuntimeConfig,
2049) -> Result<std::num::NonZeroUsize, ServerError> {
2050 runtime
2051 .websocket
2052 .cluster_broadcast_capacity
2053 .and_then(std::num::NonZeroUsize::new)
2054 .ok_or_else(|| ServerError::Config {
2055 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
2056 })
2057}
2058
2059fn build_real_time_publishers(
2072 runtime: &RuntimeConfig,
2073 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2074) -> Result<
2075 (
2076 crate::cluster_publisher::ClusterEventPublisher,
2077 crate::activity_publisher::ActivityEventPublisher,
2078 ),
2079 ServerError,
2080> {
2081 let capacity = required_cluster_broadcast_capacity(runtime)?;
2082 let batch = required_transcript_batch_policy(runtime)?;
2083 Ok((
2084 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
2085 build_transcript_publisher(
2086 observability_store,
2087 capacity,
2088 transcript_bounds(runtime),
2089 batch,
2090 ),
2091 ))
2092}
2093
2094pub(crate) fn required_node_cache_budget(
2113 config: &StoreConfig,
2114) -> Result<haematite::NodeCacheBudget, ServerError> {
2115 config.node_cache_budget.ok_or_else(|| ServerError::Config {
2116 message: crate::config::STORE_NODE_CACHE_BUDGET_REQUIRED.to_owned(),
2117 })
2118}
2119
2120fn warn_retired_lock_acquisition_keys(config: &StoreConfig) {
2127 if config.lock_acquisition_patience_ms.is_some()
2128 || config.lock_acquisition_retry_cadence_ms.is_some()
2129 {
2130 tracing::warn!(
2131 "store.lock_acquisition_patience_ms and store.lock_acquisition_retry_cadence_ms \
2132 are retired and ignored: the server waits indefinitely for the data-directory \
2133 writer lock (a kernel-released flock whose holder is either live mid-handover \
2134 or already gone) and reports while it waits. Remove the keys"
2135 );
2136 }
2137}
2138
2139pub(crate) fn required_transcript_batch_policy(
2159 runtime: &RuntimeConfig,
2160) -> Result<crate::activity_publisher::TranscriptBatchPolicy, ServerError> {
2161 let max_batch_events = runtime
2162 .observability
2163 .max_batch_events
2164 .and_then(std::num::NonZeroUsize::new)
2165 .ok_or_else(|| ServerError::Config {
2166 message: crate::config::OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED.to_owned(),
2167 })?;
2168 let max_batch_hold_ms =
2171 runtime
2172 .observability
2173 .max_batch_hold_ms
2174 .ok_or_else(|| ServerError::Config {
2175 message: crate::config::OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED.to_owned(),
2176 })?;
2177 Ok(crate::activity_publisher::TranscriptBatchPolicy {
2178 max_batch_events,
2179 max_hold: std::time::Duration::from_millis(max_batch_hold_ms),
2180 })
2181}
2182
2183fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
2185 crate::activity_bounds::TranscriptBounds {
2186 max_event_bytes: runtime.observability.max_event_bytes,
2187 max_stream_events: runtime.observability.max_stream_events,
2188 }
2189}
2190
2191fn build_transcript_publisher(
2202 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2203 capacity: std::num::NonZeroUsize,
2204 bounds: crate::activity_bounds::TranscriptBounds,
2205 batch: crate::activity_publisher::TranscriptBatchPolicy,
2206) -> crate::activity_publisher::ActivityEventPublisher {
2207 let store = observability_store
2208 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
2209 crate::activity_publisher::ActivityEventPublisher::new(store, capacity, batch)
2210 .with_bounds(bounds)
2211}
2212
2213struct RoutingState {
2215 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
2216 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
2217 mint_routing: Option<crate::namespace::NamespaceRouting>,
2222}
2223
2224fn build_routing_state(
2228 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
2229 directory_peers: Vec<crate::routing::DirectoryPeer>,
2230 self_node_id: Option<String>,
2231) -> RoutingState {
2232 let Some(store) = cluster_store else {
2233 return RoutingState {
2234 shard_directory: None,
2235 request_forwarder: None,
2236 mint_routing: None,
2237 };
2238 };
2239 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
2240 Arc::clone(store),
2241 directory_peers,
2242 self_node_id,
2243 ));
2244 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
2245 Arc::new(crate::routing::GrpcRequestForwarder::new());
2246 let mint_routing = build_namespace_routing(
2247 Some(store),
2248 Some(&shard_directory),
2249 Some(&request_forwarder),
2250 );
2251 RoutingState {
2252 shard_directory: Some(shard_directory),
2253 request_forwarder: Some(request_forwarder),
2254 mint_routing,
2255 }
2256}
2257
2258fn build_namespace_routing(
2268 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
2269 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
2270 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
2271) -> Option<crate::namespace::NamespaceRouting> {
2272 use crate::namespace::{
2273 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
2274 };
2275 let store = Arc::clone(cluster_store?);
2276 let directory = Arc::clone(shard_directory?);
2277 let shards: Arc<dyn NamespaceShardResolver> = store;
2278 let owners: Arc<dyn MintShardOwners> = directory;
2279 let forwarder: Arc<dyn MintForwarder> =
2280 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
2281 Some(NamespaceRouting::new(shards, owners, forwarder))
2282}
2283
2284struct ConnectedStore {
2294 event_store: Arc<dyn EventStore>,
2295 outbox_store: Option<Arc<dyn OutboxStore>>,
2296 namespace_store: Arc<dyn NamespaceStore>,
2303 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2305 workloop_store: Arc<dyn aion_store::workloop::WorkloopStore>,
2315 visibility_store: Arc<dyn aion_store::visibility::VisibilityStore>,
2320 assistant_store: Arc<dyn aion_store::AssistantSessionStore>,
2327 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2335 bootstrap_coordinator: bool,
2336 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
2337 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
2341 watched_peers: Vec<crate::cluster::WatchedPeer>,
2344 directory_peers: Vec<crate::routing::DirectoryPeer>,
2348 self_node_id: Option<String>,
2352}
2353
2354impl ConnectedStore {
2355 fn local(
2362 event_store: Arc<dyn EventStore>,
2363 outbox_store: Option<Arc<dyn OutboxStore>>,
2364 namespace_store: Arc<dyn NamespaceStore>,
2365 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2366 workloop_store: Arc<dyn aion_store::workloop::WorkloopStore>,
2367 visibility_store: Arc<dyn aion_store::visibility::VisibilityStore>,
2368 assistant_store: Arc<dyn aion_store::AssistantSessionStore>,
2369 ) -> Self {
2370 Self {
2371 event_store,
2372 outbox_store,
2373 namespace_store,
2374 worker_deployment_store,
2375 workloop_store,
2376 visibility_store,
2377 assistant_store,
2378 observability_store: None,
2384 bootstrap_coordinator: true,
2385 cluster_responder: None,
2386 cluster_store: None,
2387 watched_peers: Vec::new(),
2388 directory_peers: Vec::new(),
2389 self_node_id: None,
2390 }
2391 }
2392
2393 fn cluster_self_node(&self) -> Option<String> {
2396 self.self_node_id.clone()
2397 }
2398}
2399
2400async fn connect_store(
2403 config: StoreConfig,
2404 stage: &crate::control::StageReporter,
2405) -> Result<ConnectedStore, ServerError> {
2406 match config.backend {
2407 StoreBackend::Memory => {
2408 let leaf = Arc::new(aion_store::InMemoryStore::default());
2411 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2412 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2413 let workloop_store: Arc<dyn aion_store::workloop::WorkloopStore> = leaf.clone();
2414 let visibility_store: Arc<dyn aion_store::visibility::VisibilityStore> = leaf.clone();
2415 let assistant_store: Arc<dyn aion_store::AssistantSessionStore> = leaf.clone();
2416 Ok(ConnectedStore::local(
2417 leaf,
2418 None,
2419 namespace_store,
2420 worker_deployment_store,
2421 workloop_store,
2422 visibility_store,
2423 assistant_store,
2424 ))
2425 }
2426 StoreBackend::Haematite => connect_haematite_store(config, stage).await,
2427 }
2428}
2429
2430async fn connect_haematite_store(
2450 config: StoreConfig,
2451 stage: &crate::control::StageReporter,
2452) -> Result<ConnectedStore, ServerError> {
2453 let node_cache_budget = required_node_cache_budget(&config)?;
2459 warn_retired_lock_acquisition_keys(&config);
2460 let Some(data_dir) = config.data_dir else {
2461 return Err(ServerError::Config {
2462 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
2463 });
2464 };
2465 let shard_count = config.shard_count;
2466 let owned_shards = config.owned_shards.clone();
2467 let cluster = config.cluster.clone();
2468 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
2473 .as_ref()
2474 .map(|cluster| {
2475 cluster
2476 .peers
2477 .iter()
2478 .map(|peer| crate::cluster::WatchedPeer {
2479 name: peer.name.clone(),
2480 owned_shards: peer.owned_shards.clone(),
2481 })
2482 .collect()
2483 })
2484 .unwrap_or_default();
2485 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
2488 .as_ref()
2489 .map(|cluster| {
2490 cluster
2491 .peers
2492 .iter()
2493 .map(|peer| crate::routing::DirectoryPeer {
2494 name: peer.name.clone(),
2495 owned_shards: peer.owned_shards.clone(),
2496 grpc_addr: peer.grpc_address,
2497 })
2498 .collect()
2499 })
2500 .unwrap_or_default();
2501 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2504 stage.report(
2508 crate::control::stage::STAGE_STORE_OPEN,
2509 format!("opening the haematite store at {data_dir}"),
2510 );
2511 let build_stage = stage.clone();
2516 let (store, responder) = tokio::task::spawn_blocking(move || {
2517 build_haematite_store(
2518 &data_dir,
2519 shard_count,
2520 cluster,
2521 node_cache_budget,
2522 &build_stage,
2523 )
2524 })
2525 .await
2526 .map_err(|error| ServerError::Config {
2527 message: format!("haematite store initialization task failed: {error}"),
2528 })??;
2529
2530 let bootstrap_coordinator = if owned_shards.is_empty() {
2534 true
2535 } else {
2536 store.set_owned_shards(owned_shards.iter().copied());
2537 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2538 };
2539
2540 let leaf = Arc::new(store);
2541 let event_store: Arc<dyn EventStore> = leaf.clone();
2542 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2543 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2547 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2548 let workloop_store: Arc<dyn aion_store::workloop::WorkloopStore> = leaf.clone();
2549 let visibility_store: Arc<dyn aion_store::visibility::VisibilityStore> = leaf.clone();
2552 let assistant_store: Arc<dyn aion_store::AssistantSessionStore> = leaf.clone();
2553 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2558 let cluster_store = responder.as_ref().map(|_| leaf);
2562 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2563 (watched_peers, directory_peers, self_node_id)
2564 } else {
2565 (Vec::new(), Vec::new(), None)
2566 };
2567 Ok(ConnectedStore {
2568 event_store,
2569 outbox_store: Some(outbox_store),
2570 namespace_store,
2571 worker_deployment_store,
2572 workloop_store,
2573 visibility_store,
2574 assistant_store,
2575 observability_store: Some(observability_store),
2576 bootstrap_coordinator,
2577 cluster_responder: responder,
2578 cluster_store,
2579 watched_peers,
2580 directory_peers,
2581 self_node_id,
2582 })
2583}
2584
2585fn build_haematite_store(
2600 data_dir: &str,
2601 shard_count: usize,
2602 cluster: Option<crate::config::ClusterConfig>,
2603 node_cache_budget: haematite::NodeCacheBudget,
2604 stage: &crate::control::StageReporter,
2605) -> Result<
2606 (
2607 aion_store_haematite::HaematiteStore,
2608 Option<aion_store_haematite::ClusterResponder>,
2609 ),
2610 ServerError,
2611> {
2612 build_haematite_store_with_hook(
2613 data_dir,
2614 shard_count,
2615 cluster,
2616 node_cache_budget,
2617 stage,
2618 || Ok(()),
2619 )
2620}
2621
2622fn build_haematite_store_with_hook(
2623 data_dir: &str,
2624 shard_count: usize,
2625 cluster: Option<crate::config::ClusterConfig>,
2626 node_cache_budget: haematite::NodeCacheBudget,
2627 stage: &crate::control::StageReporter,
2628 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2629) -> Result<
2630 (
2631 aion_store_haematite::HaematiteStore,
2632 Option<aion_store_haematite::ClusterResponder>,
2633 ),
2634 ServerError,
2635> {
2636 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2637
2638 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2646 data_dir,
2647 ))
2648 .map_err(|error| ServerError::Config {
2649 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2650 })?;
2651
2652 stage.report(
2661 crate::control::stage::STAGE_STORE_OPEN,
2662 format!("checking the data root and {shard_count} shard directories under {data_dir}"),
2663 );
2664 for shard in 0..shard_count {
2665 let relative = std::path::PathBuf::from(format!("shard-{shard}"));
2666 private_root
2667 .create_dir_all(&relative)
2668 .map_err(|error| ServerError::Config {
2669 message: format!(
2670 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2671 ),
2672 })?;
2673 private_root
2674 .ensure_child_dir_private(&relative)
2675 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2676 }
2677
2678 before_backend_touch().map_err(|error| ServerError::Config {
2681 message: format!("store.data_dir pre-open hook failed: {error}"),
2682 })?;
2683
2684 #[cfg(unix)]
2685 let backend_path = private_root
2686 .backend_path()
2687 .map_err(|error| ServerError::Config {
2688 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2689 })?;
2690 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2691 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2692 let (component, reason) = error.into_parts();
2693 ServerError::UnsafeDataRootAncestor {
2694 data_root: backend_path.clone(),
2695 component,
2696 reason,
2697 }
2698 })?;
2699 #[cfg(not(unix))]
2700 let backend_path = std::path::PathBuf::from(data_dir);
2701
2702 let Some(cluster) = cluster else {
2703 let store = if backend_path.join("config.json").exists() {
2704 HaematiteStore::open(
2709 &backend_path,
2710 node_cache_budget,
2711 writer_lock_wait_reporter(stage),
2712 )
2713 .map_err(ServerError::from)?
2714 } else {
2715 HaematiteStore::create_with_shard_count(&backend_path, shard_count, node_cache_budget)
2716 .map_err(ServerError::from)?
2717 };
2718 materialize_narrated(&store, shard_count, stage)?;
2719 let store = store.retain_data_root_capability(private_root);
2720 return Ok((store, None));
2721 };
2722
2723 let boot = ClusterBootstrap {
2724 node_id: cluster.node_id,
2725 bind_address: cluster.bind_address,
2726 members: cluster.members,
2727 peers: cluster
2728 .peers
2729 .into_iter()
2730 .map(|peer| (peer.name, peer.address))
2731 .collect(),
2732 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2733 };
2734 let (store, responder) = HaematiteStore::open_or_create_distributed(
2735 &backend_path,
2736 shard_count,
2737 boot,
2738 node_cache_budget,
2739 )
2740 .map_err(ServerError::from)?;
2741 materialize_narrated(&store, shard_count, stage)?;
2742 let store = store.retain_data_root_capability(private_root);
2743 Ok((store, Some(responder)))
2744}
2745
2746fn writer_lock_wait_reporter(
2754 stage: &crate::control::StageReporter,
2755) -> impl FnMut(&std::path::Path, std::time::Duration) + use<'_> {
2756 move |lock_path, waited| {
2757 stage.report(
2758 crate::control::stage::STAGE_WRITER_LOCK_WAIT,
2759 format!(
2760 "waiting {}s on the store writer lock at {} — another live process \
2761 holds it (a draining predecessor, or another server on this data \
2762 directory); this boot proceeds the moment it is released",
2763 waited.as_secs(),
2764 lock_path.display()
2765 ),
2766 );
2767 }
2768}
2769
2770fn materialize_narrated(
2781 store: &aion_store_haematite::HaematiteStore,
2782 shard_count: usize,
2783 stage: &crate::control::StageReporter,
2784) -> Result<(), ServerError> {
2785 stage.report(
2786 crate::control::stage::STAGE_STORE_OPEN,
2787 format!("haematite store opened; materializing {shard_count} shards"),
2788 );
2789 store
2790 .materialize_all_shards(|shard, materialized, total| {
2791 stage.report(
2792 crate::control::stage::STAGE_WAL_RECOVERY,
2793 format!("materializing shard {shard} ({materialized} of {total})"),
2794 );
2795 })
2796 .map_err(ServerError::from)?;
2797 stage.report(
2798 crate::control::stage::STAGE_WAL_RECOVERY,
2799 format!("all {shard_count} shards materialized"),
2800 );
2801 Ok(())
2802}
2803
2804fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2805 ServerError::Config {
2806 message: format!(
2807 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2808 ),
2809 }
2810}
2811
2812const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2814
2815#[cfg(not(feature = "liminal-transport"))]
2824#[derive(Clone, Debug)]
2825struct NullInterventionTransport;
2826
2827#[cfg(not(feature = "liminal-transport"))]
2828#[async_trait::async_trait]
2829impl crate::worker::InterventionTransport for NullInterventionTransport {
2830 async fn push(
2831 &self,
2832 _worker: &crate::worker::WorkerHandle,
2833 _command: aion_core::InterventionCommand,
2834 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2835 Err(ServerError::worker_connection_lost(
2836 "intervention",
2837 "no intervention push transport is compiled in".to_owned(),
2838 ))
2839 }
2840}
2841
2842#[cfg(test)]
2843mod tests {
2844 use std::{net::SocketAddr, time::Duration};
2845
2846 use aion_store::InMemoryStore;
2847
2848 use super::ServerState;
2849 use crate::config::{
2850 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2851 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2852 RuntimeConfig, WebSocketConfig, WorkerConfig,
2853 };
2854
2855 fn runtime_config() -> RuntimeConfig {
2856 RuntimeConfig {
2857 listen: ListenConfig {
2858 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2859 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2860 },
2861 tls: None,
2862 auth: AuthConfig {
2863 enabled: false,
2864 jwks_url: None,
2865 jwks_refresh_seconds: 300,
2866 },
2867 ops_console: OpsConsoleConfig {
2868 source: OpsConsoleAssetSource::Embedded,
2869 },
2870 namespace: NamespaceConfig {
2871 mode: NamespaceMode::SharedEngine,
2872 },
2873 worker: WorkerConfig {
2874 heartbeat_window: Duration::from_secs(30),
2875 ..WorkerConfig::default()
2876 },
2877 websocket: WebSocketConfig {
2878 outbound_buffer_bound: 32,
2879 event_broadcast_capacity: Some(64),
2880 cluster_broadcast_capacity: Some(64),
2881 },
2882 workflow_packages: Vec::new(),
2883 deploy: DeployConfig::default(),
2884 authoring: AuthoringConfig::default(),
2885 dev: DevConfig::default(),
2886 outbox: OutboxConfig::default(),
2887 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
2888 mcp: crate::config::ResolvedMcpConfig::default(),
2889 assistant: crate::config::ResolvedAssistantConfig::default(),
2890 scheduler_threads: 1,
2891 stop_drain_timeout: Some(std::time::Duration::from_secs(5)),
2892 jit_threshold: None,
2893 query_timeout: Some(Duration::from_secs(10)),
2894 workloop_sweep_interval: Some(Duration::from_millis(50)),
2895 default_namespace: "default".to_owned(),
2896 auto_create: crate::config::AutoCreate::Open,
2897 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2898 drain_timeout: Duration::from_secs(30),
2899 metrics: MetricsConfig { enabled: true },
2900 owned_shards: Vec::new(),
2901 cors_allowed_origins: Vec::new(),
2902 }
2903 }
2904
2905 #[test]
2910 fn an_unruled_flush_policy_refuses_to_build_the_publisher() {
2911 for (mutate, expected_key) in [
2912 (
2913 Box::new(|runtime: &mut RuntimeConfig| {
2914 runtime.observability.max_batch_events = None;
2915 }) as Box<dyn Fn(&mut RuntimeConfig)>,
2916 "observability.max_batch_events",
2917 ),
2918 (
2919 Box::new(|runtime: &mut RuntimeConfig| {
2920 runtime.observability.max_batch_events = Some(0);
2921 }),
2922 "observability.max_batch_events",
2923 ),
2924 (
2925 Box::new(|runtime: &mut RuntimeConfig| {
2926 runtime.observability.max_batch_hold_ms = None;
2927 }),
2928 "observability.max_batch_hold_ms",
2929 ),
2930 ] {
2931 let mut runtime = runtime_config();
2932 mutate(&mut runtime);
2933 let message = super::required_transcript_batch_policy(&runtime)
2934 .err()
2935 .map_or_else(String::new, |error| error.to_string());
2936 assert!(
2937 message.contains(expected_key),
2938 "the refusal must name {expected_key}: {message}"
2939 );
2940 assert!(
2941 message.contains("no default"),
2942 "and must say the key has no default: {message}"
2943 );
2944 }
2945 }
2946
2947 #[test]
2950 fn a_stated_flush_policy_is_carried_through_verbatim() -> Result<(), Box<dyn std::error::Error>>
2951 {
2952 let mut runtime = runtime_config();
2953 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(32, 0);
2954 let policy = super::required_transcript_batch_policy(&runtime)?;
2955 assert_eq!(policy.max_batch_events.get(), 32);
2956 assert_eq!(policy.max_hold, Duration::ZERO);
2957
2958 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(8, 250);
2959 let policy = super::required_transcript_batch_policy(&runtime)?;
2960 assert_eq!(policy.max_batch_events.get(), 8);
2961 assert_eq!(policy.max_hold, Duration::from_millis(250));
2962 Ok(())
2963 }
2964
2965 #[test]
2976 fn engine_schema_accepts_every_attribute_the_start_writer_records()
2977 -> Result<(), Box<dyn std::error::Error>> {
2978 let schema = super::server_search_attribute_schema()?;
2979 let recorded = crate::api::handlers::workflows::start_search_attributes(
2980 "tenant-a",
2981 Some("gpu"),
2982 Some("Nightly settlement"),
2983 );
2984
2985 assert!(
2986 recorded.contains_key(crate::namespace::DISPLAY_NAME_ATTRIBUTE),
2987 "the fixture must exercise the display-name attribute, or this test \
2988 cannot see its registration go missing"
2989 );
2990 for (name, value) in &recorded {
2991 schema.validate(name, value).map_err(|error| {
2992 format!(
2993 "the start writer records {name}, but the engine's schema refuses it: {error}"
2994 )
2995 })?;
2996 }
2997 Ok(())
2998 }
2999
3000 #[tokio::test]
3001 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
3002 let state = crate::test_support::StateUnderTest::new(
3003 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?,
3004 );
3005
3006 std::hint::black_box(state.namespace_guard());
3007 std::hint::black_box(state.worker_registry());
3008
3009 Ok(())
3010 }
3011
3012 #[tokio::test]
3020 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
3021 -> Result<(), Box<dyn std::error::Error>> {
3022 use aion::{ActivityDispatch, ActivityDispatcher as _};
3023 use aion_core::{ActivityId, RunId, WorkflowId};
3024 use std::sync::Arc;
3025
3026 let state = crate::test_support::StateUnderTest::new(
3027 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?,
3028 );
3029 assert!(
3030 state.unserved_queues()?.is_empty(),
3031 "a calm boot has no unserved queues"
3032 );
3033 assert!(state.queue_declarations().is_installed());
3037 assert_eq!(
3038 state
3039 .queue_declarations()
3040 .declaration_for("nobody-serves-this"),
3041 crate::worker::QueueDeclaration::Unknown
3042 );
3043
3044 let dispatcher = Arc::new(
3045 crate::worker::WorkerActivityDispatcher::new(
3046 state.worker_registry().clone(),
3047 "default",
3048 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
3049 )
3050 .with_queue_state(state.queue_service_state().clone())
3051 .with_queue_declarations(state.queue_declarations().clone()),
3052 );
3053 let workflow_id = WorkflowId::new_v4();
3054 let request = ActivityDispatch {
3055 namespace: "default".to_owned(),
3056 task_queue: "nobody-serves-this".to_owned(),
3057 node: None,
3058 workflow_id: workflow_id.clone(),
3059 run_id: RunId::new_v4(),
3060 activity_id: ActivityId::from_sequence_position(0),
3061 name: "greet".to_owned(),
3062 input: "{}".to_owned(),
3063 config: "{}".to_owned(),
3064 attempt: 1,
3065 advisory: false,
3066 labels: std::collections::BTreeMap::new(),
3067 };
3068 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
3069
3070 let mut unserved = Vec::new();
3071 for _ in 0..30 {
3072 unserved = state.unserved_queues()?;
3073 if !unserved.is_empty() {
3074 break;
3075 }
3076 tokio::time::sleep(Duration::from_millis(100)).await;
3077 }
3078 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
3079 assert_eq!(
3080 unserved[0].reason,
3081 crate::worker::QueueServiceReason::NoLivePollers,
3082 "an empty catalog must not be read as a structural refusal"
3083 );
3084 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
3085 assert_eq!(unserved[0].waiting.len(), 1);
3086 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
3087
3088 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
3090 drop(worker_rx);
3091 let registration = state.worker_registry().register_namespaces(
3092 [String::from("default")],
3093 "nobody-serves-this",
3094 None,
3095 [String::from("greet")].iter(),
3096 worker_tx,
3097 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3098 )?;
3099 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
3100 assert!(outcome.is_err(), "the released dispatch must resolve");
3101 assert!(
3102 state.unserved_queues()?.is_empty(),
3103 "a resolved dispatch must leave the unserved state"
3104 );
3105 registration.deregister()?;
3106 Ok(())
3107 }
3108
3109 #[tokio::test]
3110 async fn namespace_store_is_reachable_and_functional_after_default_boot()
3111 -> Result<(), Box<dyn std::error::Error>> {
3112 use aion_store::{MintOutcome, NamespaceOrigin};
3113
3114 let state = crate::test_support::StateUnderTest::new(
3118 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?,
3119 );
3120
3121 let store = state.namespace_store();
3122
3123 let outcome = store
3125 .register_namespace("orders", NamespaceOrigin::WorkerMint)
3126 .await?;
3127 assert_eq!(
3128 outcome,
3129 MintOutcome::Created,
3130 "the first reference to a namespace mints it"
3131 );
3132
3133 let again = store
3135 .register_namespace("orders", NamespaceOrigin::WorkerMint)
3136 .await?;
3137 assert_eq!(
3138 again,
3139 MintOutcome::AlreadyExisted,
3140 "a second reference touches the existing record rather than re-creating it"
3141 );
3142
3143 let fetched = store.get_namespace("orders").await?;
3145 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
3146 assert_eq!(record.name, "orders");
3147 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
3148
3149 let listed = store.list_namespaces().await?;
3151 assert!(
3152 listed.iter().any(|record| record.name == "orders"),
3153 "list_namespaces returns the minted namespace"
3154 );
3155
3156 Ok(())
3157 }
3158
3159 #[tokio::test(flavor = "multi_thread")]
3160 async fn connect_store_haematite_round_trips_through_event_store()
3161 -> Result<(), Box<dyn std::error::Error>> {
3162 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3163 use aion_store::WriteToken;
3164 use chrono::Utc;
3165
3166 use crate::config::{StoreBackend, StoreConfig};
3167
3168 let data_dir = crate::test_support::private_tempdir()?;
3169 let connected = super::connect_store(
3173 StoreConfig {
3174 backend: StoreBackend::Haematite,
3175 owned_shards: Vec::new(),
3176 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
3177 shard_count: 1,
3178 cluster: None,
3179 node_cache_budget: Some(test_node_cache_budget()?),
3180 ..StoreConfig::default()
3181 },
3182 &crate::control::StageReporter::detached(),
3183 )
3184 .await?;
3185 let event_store = connected.event_store;
3186 assert!(
3187 connected.outbox_store.is_some(),
3188 "the haematite backend shares its leaf store as the dispatcher's outbox store"
3189 );
3190 assert!(
3191 connected.bootstrap_coordinator,
3192 "a single-node haematite boot owns all shards and bootstraps the coordinator"
3193 );
3194 assert!(
3195 connected.cluster_responder.is_none(),
3196 "a single-node (no [cluster]) haematite boot has no distributed responder"
3197 );
3198
3199 let workflow_id = WorkflowId::new_v4();
3200 let event = aion_core::Event::WorkflowStarted {
3201 envelope: EventEnvelope {
3202 seq: 1,
3203 recorded_at: Utc::now(),
3204 workflow_id: workflow_id.clone(),
3205 },
3206 workflow_type: String::from("checkout"),
3207 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3208 run_id: RunId::new_v4(),
3209 parent_run_id: None,
3210 parent_workflow_id: None,
3211 package_version: PackageVersion::new("a".repeat(64)),
3212 };
3213 event_store
3214 .append(
3215 WriteToken::recorder(),
3216 &workflow_id,
3217 std::slice::from_ref(&event),
3218 0,
3219 )
3220 .await?;
3221 let history = event_store.read_history(&workflow_id).await?;
3222 assert_eq!(
3223 history.len(),
3224 1,
3225 "an event appended through the server's dyn EventStore reads back"
3226 );
3227 Ok(())
3228 }
3229
3230 fn test_node_cache_budget() -> Result<haematite::NodeCacheBudget, Box<dyn std::error::Error>> {
3236 Ok(haematite::NodeCacheBudget::bytes(1 << 30)?)
3237 }
3238
3239 const BOOT_COST_WORKFLOWS: usize = 800;
3244
3245 const BOOT_OPENS_PER_DIRECTORY: u64 = 8;
3251
3252 #[cfg(unix)]
3269 #[tokio::test]
3270 async fn booting_over_a_store_of_many_object_files_opens_per_shard_not_per_file()
3271 -> Result<(), Box<dyn std::error::Error>> {
3272 let sandbox = crate::test_support::private_tempdir()?;
3273 let data_root = sandbox.path().join("data");
3274 let data_dir = data_root
3275 .to_str()
3276 .ok_or("temporary data path was not UTF-8")?
3277 .to_owned();
3278 let shard_count = 4;
3279
3280 populate_boot_cost_store(&data_root, shard_count).await?;
3281 let object_files = count_regular_files(&data_root)?;
3282 let directories = u64::try_from(shard_count)? + 1;
3283 let budget = BOOT_OPENS_PER_DIRECTORY * directories;
3284 assert!(
3285 object_files > budget * 4,
3286 "the specimen must carry far more object files ({object_files}) than the open \
3287 budget ({budget}), or a walk would fit inside the budget and this pin proves \
3288 nothing"
3289 );
3290
3291 crate::filesystem::reset_capability_opens();
3292 haematite::store::reset_file_opens();
3293 let (store, responder) = tokio::task::spawn_blocking(move || {
3294 super::build_haematite_store_with_hook(
3295 &data_dir,
3296 shard_count,
3297 None,
3298 haematite::NodeCacheBudget::bytes(1 << 30).map_err(|error| error.to_string())?,
3299 &crate::control::StageReporter::detached(),
3300 || Ok(()),
3301 )
3302 .map_err(|error| error.to_string())
3303 })
3304 .await??;
3305 let opens = crate::filesystem::capability_opens();
3306 let haematite_opens = haematite::store::file_opens();
3307 assert!(responder.is_none());
3308 assert_post_boot_node_is_private(&store, &data_root).await?;
3309 drop(store);
3310 println!(
3311 "boot-cost pin: {object_files} object files in {shard_count} shards; {opens} opens \
3312 through the server's capability layer and {haematite_opens} inside haematite during \
3313 the open (budget {budget} each)"
3314 );
3315
3316 assert!(
3317 opens <= budget,
3318 "booting over {object_files} object files in {shard_count} shards issued {opens} \
3319 opens through the server's capability layer; the budget is {budget} \
3320 ({BOOT_OPENS_PER_DIRECTORY} per directory) — the boot is walking the tree again"
3321 );
3322 assert!(
3323 haematite_opens <= budget,
3324 "opening the store over {object_files} object files issued {haematite_opens} opens \
3325 inside haematite; the budget is {budget} — the shard open is doing per-object work"
3326 );
3327 Ok(())
3328 }
3329
3330 #[cfg(unix)]
3334 #[test]
3335 fn a_permissive_shard_directory_we_own_is_tightened_at_boot()
3336 -> Result<(), Box<dyn std::error::Error>> {
3337 use std::os::unix::fs::PermissionsExt as _;
3338
3339 let sandbox = crate::test_support::private_tempdir()?;
3340 let data_root = sandbox.path().join("data");
3341 let loose = data_root.join("shard-2");
3342 std::fs::create_dir_all(&loose)?;
3343 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
3344 std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o755))?;
3345 let data_dir = data_root
3346 .to_str()
3347 .ok_or("temporary data path was not UTF-8")?;
3348
3349 let (captured, built) = crate::test_support::CapturedLogs::capture(|| {
3350 super::build_haematite_store_with_hook(
3351 data_dir,
3352 4,
3353 None,
3354 haematite::NodeCacheBudget::bytes(1 << 30).map_err(|error| error.to_string())?,
3355 &crate::control::StageReporter::detached(),
3356 || Ok(()),
3357 )
3358 .map_err(|error| error.to_string())
3359 });
3360 let (store, _) = built?;
3361 drop(store);
3362
3363 assert_eq!(
3364 std::fs::metadata(&loose)?.permissions().mode() & 0o777,
3365 0o700,
3366 "the loose shard directory is tightened by the boot"
3367 );
3368 let text = captured.text()?;
3369 assert!(
3370 text.contains("tightened a sensitive root") && text.contains("shard-2"),
3371 "the repair is logged and names the directory: {text}"
3372 );
3373 Ok(())
3374 }
3375
3376 #[test]
3386 fn the_store_build_narrates_each_shard_by_id_before_opening_it()
3387 -> Result<(), Box<dyn std::error::Error>> {
3388 const SHARDS: usize = 6;
3389 let sandbox = crate::test_support::private_tempdir()?;
3390 let data_root = sandbox.path().join("data");
3391 let data_dir = data_root
3392 .to_str()
3393 .ok_or("temporary data path was not UTF-8")?;
3394
3395 let (captured, built) = crate::test_support::CapturedLogs::capture(|| {
3396 super::build_haematite_store_with_hook(
3397 data_dir,
3398 SHARDS,
3399 None,
3400 haematite::NodeCacheBudget::bytes(1 << 30).map_err(|error| error.to_string())?,
3401 &crate::control::StageReporter::detached(),
3402 || Ok(()),
3403 )
3404 .map_err(|error| error.to_string())
3405 });
3406 let (store, _) = built?;
3407 drop(store);
3408
3409 let text = captured.text()?;
3410 let position = |needle: &str| -> Result<usize, String> {
3411 text.find(needle)
3412 .ok_or_else(|| format!("missing stage line `{needle}` in:\n{text}"))
3413 };
3414 let checking = position(&format!(
3415 "checking the data root and {SHARDS} shard directories"
3416 ))?;
3417 let opened = position(&format!(
3418 "haematite store opened; materializing {SHARDS} shards"
3419 ))?;
3420 let done = position(&format!("all {SHARDS} shards materialized"))?;
3421 assert!(
3422 checking < opened && opened < done,
3423 "stage lines out of order:\n{text}"
3424 );
3425 for shard in 0..SHARDS {
3426 let line = position(&format!("materializing shard {shard} ("))?;
3427 assert!(
3428 opened < line && line < done,
3429 "shard {shard}'s start line must sit between open and done:\n{text}"
3430 );
3431 }
3432 assert!(
3433 !text.contains(&format!("materializing shard {SHARDS} (")),
3434 "a line names a shard id; ids run 0..{SHARDS}, never the count"
3435 );
3436 Ok(())
3437 }
3438
3439 async fn populate_boot_cost_store(
3442 data_root: &std::path::Path,
3443 shard_count: usize,
3444 ) -> Result<(), Box<dyn std::error::Error>> {
3445 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3446 use aion_store::{WritableEventStore as _, WriteToken};
3447 use chrono::Utc;
3448
3449 let store = aion_store_haematite::HaematiteStore::create_with_shard_count(
3450 data_root,
3451 shard_count,
3452 test_node_cache_budget()?,
3453 )?;
3454 for index in 0..BOOT_COST_WORKFLOWS {
3455 let workflow_id = WorkflowId::new_v4();
3456 let event = aion_core::Event::WorkflowStarted {
3457 envelope: EventEnvelope {
3458 seq: 1,
3459 recorded_at: Utc::now(),
3460 workflow_id: workflow_id.clone(),
3461 },
3462 workflow_type: format!("boot-cost-{index}"),
3463 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3464 run_id: RunId::new_v4(),
3465 parent_run_id: None,
3466 parent_workflow_id: None,
3467 package_version: PackageVersion::new("a".repeat(64)),
3468 };
3469 store
3470 .append(
3471 WriteToken::recorder(),
3472 &workflow_id,
3473 std::slice::from_ref(&event),
3474 0,
3475 )
3476 .await?;
3477 }
3478 Ok(())
3479 }
3480
3481 async fn assert_post_boot_node_is_private(
3489 store: &aion_store_haematite::HaematiteStore,
3490 data_root: &std::path::Path,
3491 ) -> Result<(), Box<dyn std::error::Error>> {
3492 use std::os::unix::fs::PermissionsExt as _;
3493
3494 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3495 use aion_store::{WritableEventStore as _, WriteToken};
3496 use chrono::Utc;
3497
3498 let workflow_id = WorkflowId::new_v4();
3499 let event = aion_core::Event::WorkflowStarted {
3500 envelope: EventEnvelope {
3501 seq: 1,
3502 recorded_at: Utc::now(),
3503 workflow_id: workflow_id.clone(),
3504 },
3505 workflow_type: String::from("boot-cost-after-boot"),
3506 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3507 run_id: RunId::new_v4(),
3508 parent_run_id: None,
3509 parent_workflow_id: None,
3510 package_version: PackageVersion::new("a".repeat(64)),
3511 };
3512 store
3513 .append(
3514 WriteToken::recorder(),
3515 &workflow_id,
3516 std::slice::from_ref(&event),
3517 0,
3518 )
3519 .await?;
3520 let (newest, parent) = newest_regular_file(data_root)?
3521 .ok_or("the post-boot append must have written a node file")?;
3522 assert_eq!(
3523 std::fs::metadata(&newest)?.permissions().mode() & 0o777,
3524 0o600,
3525 "a node written after boot is private by creation: {}",
3526 newest.display()
3527 );
3528 assert_eq!(
3529 std::fs::metadata(&parent)?.permissions().mode() & 0o777,
3530 0o700,
3531 "its prefix directory is private by creation: {}",
3532 parent.display()
3533 );
3534 Ok(())
3535 }
3536
3537 fn newest_regular_file(
3539 root: &std::path::Path,
3540 ) -> std::io::Result<Option<(std::path::PathBuf, std::path::PathBuf)>> {
3541 let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
3542 let mut pending = vec![root.to_path_buf()];
3543 while let Some(dir) = pending.pop() {
3544 for entry in std::fs::read_dir(&dir)? {
3545 let entry = entry?;
3546 let file_type = entry.file_type()?;
3547 if file_type.is_dir() {
3548 pending.push(entry.path());
3549 } else if file_type.is_file() {
3550 let modified = entry.metadata()?.modified()?;
3551 if newest.as_ref().is_none_or(|(when, _)| modified > *when) {
3552 newest = Some((modified, entry.path()));
3553 }
3554 }
3555 }
3556 }
3557 Ok(newest.and_then(|(_, path)| {
3558 let parent = path.parent()?.to_path_buf();
3559 Some((path, parent))
3560 }))
3561 }
3562
3563 fn count_regular_files(root: &std::path::Path) -> std::io::Result<u64> {
3564 let mut total = 0;
3565 for entry in std::fs::read_dir(root)? {
3566 let entry = entry?;
3567 let file_type = entry.file_type()?;
3568 if file_type.is_dir() {
3569 total += count_regular_files(&entry.path())?;
3570 } else if file_type.is_file() {
3571 total += 1;
3572 }
3573 }
3574 Ok(total)
3575 }
3576
3577 #[cfg(unix)]
3578 #[test]
3579 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
3580 -> Result<(), Box<dyn std::error::Error>> {
3581 use std::os::unix::fs::symlink;
3582
3583 let sandbox = crate::test_support::private_tempdir()?;
3584 let configured_root = sandbox.path().join("data");
3585 let held_root = sandbox.path().join("held-data");
3586 let outside = sandbox.path().join("outside");
3587 std::fs::create_dir(&outside)?;
3588 let configured = configured_root
3589 .to_str()
3590 .ok_or("temporary data path was not UTF-8")?;
3591
3592 let (store, responder) = super::build_haematite_store_with_hook(
3593 configured,
3594 4,
3595 None,
3596 test_node_cache_budget()?,
3597 &crate::control::StageReporter::detached(),
3598 || {
3599 std::fs::rename(&configured_root, &held_root)?;
3604 symlink(&outside, &configured_root)?;
3605 Ok(())
3606 },
3607 )?;
3608 assert!(responder.is_none());
3609
3610 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
3611 assert!(
3612 outside_entries.is_empty(),
3613 "Haematite followed the replaced ambient root and wrote outside"
3614 );
3615 assert!(held_root.join("config.json").is_file());
3616 for shard in 0..4 {
3617 let shard_path = held_root.join(format!("shard-{shard}"));
3618 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
3619 assert!(
3620 std::fs::read_dir(&shard_path)?
3621 .next()
3622 .transpose()?
3623 .is_some(),
3624 "shard {shard} did not run Haematite's materialization path"
3625 );
3626 }
3627
3628 drop(store);
3629 Ok(())
3630 }
3631
3632 #[cfg(any(target_os = "linux", target_os = "android"))]
3633 #[tokio::test]
3634 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
3635 -> Result<(), Box<dyn std::error::Error>> {
3636 use std::os::unix::fs::symlink;
3637
3638 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
3639 use aion_store::{WritableEventStore as _, WriteToken};
3640 use chrono::Utc;
3641
3642 let sandbox = crate::test_support::private_tempdir()?;
3643 let configured_root = sandbox.path().join("data");
3644 let held_root = sandbox.path().join("held-data");
3645 let capture = sandbox.path().join("capture");
3646 std::fs::create_dir(&capture)?;
3647 let configured = configured_root
3648 .to_str()
3649 .ok_or("temporary data path was not UTF-8")?;
3650
3651 let (store, responder) = super::build_haematite_store(
3652 configured,
3653 4,
3654 None,
3655 test_node_cache_budget()?,
3656 &crate::control::StageReporter::detached(),
3657 )?;
3658 assert!(responder.is_none());
3659 std::fs::rename(&configured_root, &held_root)?;
3660 symlink(&capture, &configured_root)?;
3661
3662 let workflow_id = WorkflowId::new_v4();
3663 let event = aion_core::Event::WorkflowStarted {
3664 envelope: EventEnvelope {
3665 seq: 1,
3666 recorded_at: Utc::now(),
3667 workflow_id: workflow_id.clone(),
3668 },
3669 workflow_type: String::from("post-startup-root-swap"),
3670 input: Payload::new(ContentType::Json, b"{}".to_vec()),
3671 run_id: RunId::new_v4(),
3672 parent_run_id: None,
3673 parent_workflow_id: None,
3674 package_version: PackageVersion::new("a".repeat(64)),
3675 };
3676 store
3677 .append(
3678 WriteToken::recorder(),
3679 &workflow_id,
3680 std::slice::from_ref(&event),
3681 0,
3682 )
3683 .await?;
3684
3685 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
3686 assert!(
3687 captured.is_empty(),
3688 "post-startup append followed the replacement symlink into capture"
3689 );
3690 assert!(held_root.join("config.json").is_file());
3691 drop(store);
3692 Ok(())
3693 }
3694
3695 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
3696 #[test]
3697 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
3698 -> Result<(), Box<dyn std::error::Error>> {
3699 use std::os::unix::fs::PermissionsExt as _;
3700
3701 let sandbox = crate::test_support::private_tempdir()?;
3702 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3703
3704 for mode in [0o770, 0o1777] {
3705 let shared = sandbox.path().join(format!("shared-{mode:o}"));
3706 let data_root = shared.join("data");
3707 std::fs::create_dir(&shared)?;
3708 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
3709 std::fs::create_dir(&data_root)?;
3710 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
3711 let configured = data_root
3712 .to_str()
3713 .ok_or("temporary data path was not UTF-8")?;
3714
3715 let Err(error) = super::build_haematite_store(
3716 configured,
3717 4,
3718 None,
3719 test_node_cache_budget()?,
3720 &crate::control::StageReporter::detached(),
3721 ) else {
3722 return Err(format!("mode {mode:04o} ancestor was accepted").into());
3723 };
3724 let message = error.to_string();
3725 let crate::ServerError::UnsafeDataRootAncestor {
3726 data_root: resolved_root,
3727 component,
3728 reason,
3729 } = error
3730 else {
3731 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3732 };
3733 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
3734 assert_eq!(component, std::fs::canonicalize(&shared)?);
3735 assert!(
3736 reason.contains(&format!("mode {mode:04o}")),
3737 "unexpected reason: {reason}"
3738 );
3739 if mode & 0o1000 != 0 {
3740 assert!(reason.contains("sticky bit is not accepted"));
3741 }
3742 assert!(message.contains("private Aion home"));
3743 assert!(
3744 !data_root.join("config.json").exists(),
3745 "Haematite touched its ambient path before the refusal"
3746 );
3747 }
3748 Ok(())
3749 }
3750
3751 #[cfg(target_os = "macos")]
3752 #[test]
3753 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
3754 -> Result<(), Box<dyn std::error::Error>> {
3755 use std::os::unix::fs::PermissionsExt as _;
3756
3757 let sandbox = crate::test_support::private_tempdir()?;
3758 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3759 let shared = sandbox.path().join("acl-shared");
3760 let data_root = shared.join("data");
3761 std::fs::create_dir(&shared)?;
3762 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
3763 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
3764 let status = std::process::Command::new("chmod")
3765 .arg("+a")
3766 .arg(acl)
3767 .arg(&shared)
3768 .status()?;
3769 assert!(status.success(), "failed to install Darwin regression ACL");
3770 let configured = data_root
3771 .to_str()
3772 .ok_or("temporary data path was not UTF-8")?;
3773
3774 let result = super::build_haematite_store(
3775 configured,
3776 4,
3777 None,
3778 test_node_cache_budget()?,
3779 &crate::control::StageReporter::detached(),
3780 );
3781 let cleanup = std::process::Command::new("chmod")
3782 .arg("-RN")
3783 .arg(&shared)
3784 .status()?;
3785 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
3786
3787 let Err(error) = result else {
3788 return Err("mutating non-euid allow ACL ancestor was accepted".into());
3789 };
3790 let message = error.to_string();
3791 let crate::ServerError::UnsafeDataRootAncestor {
3792 component, reason, ..
3793 } = error
3794 else {
3795 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3796 };
3797 assert_eq!(component, std::fs::canonicalize(&shared)?);
3798 assert!(
3799 reason.contains("allow"),
3800 "reason did not name the ACE: {reason}"
3801 );
3802 assert!(
3803 reason.contains("everyone"),
3804 "reason did not name the ACE principal: {reason}"
3805 );
3806 assert!(
3807 !data_root.join("config.json").exists(),
3808 "Haematite touched its ambient path before the ACL refusal"
3809 );
3810 Ok(())
3811 }
3812
3813 #[cfg(target_os = "macos")]
3814 #[test]
3815 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
3816 -> Result<(), Box<dyn std::error::Error>> {
3817 use std::os::unix::fs::PermissionsExt as _;
3818
3819 use exacl::{AclEntry, AclOption, Perm};
3820
3821 let sandbox = crate::test_support::private_tempdir()?;
3822 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3823 let private_parent = sandbox.path().join("euid-uuid-allow");
3824 let data_root = private_parent.join("data");
3825 std::fs::create_dir(&private_parent)?;
3826 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3827
3828 let server_uid = rustix::process::geteuid().as_raw();
3829 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
3830 let entry = AclEntry::allow_user(
3831 &ace_qualifier.to_string(),
3832 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3833 None,
3834 );
3835 exacl::setfacl(
3836 &[private_parent.as_path()],
3837 &[entry],
3838 AclOption::SYMLINK_ACL,
3839 )?;
3840 let configured = data_root
3841 .to_str()
3842 .ok_or("temporary data path was not UTF-8")?;
3843
3844 let result = super::build_haematite_store(
3845 configured,
3846 4,
3847 None,
3848 test_node_cache_budget()?,
3849 &crate::control::StageReporter::detached(),
3850 );
3851 let cleanup = std::process::Command::new("chmod")
3852 .arg("-RN")
3853 .arg(&private_parent)
3854 .status()?;
3855 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
3856
3857 let (store, responder) = result?;
3858 assert!(responder.is_none());
3859 assert!(data_root.join("config.json").is_file());
3860 drop(store);
3861 Ok(())
3862 }
3863
3864 #[cfg(target_os = "macos")]
3865 #[test]
3866 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
3867 -> Result<(), Box<dyn std::error::Error>> {
3868 use std::os::unix::fs::PermissionsExt as _;
3869
3870 use exacl::{AclEntry, AclOption, Perm};
3871
3872 let sandbox = crate::test_support::private_tempdir()?;
3873 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3874 let shared = sandbox.path().join("non-euid-uuid-allow");
3875 let data_root = shared.join("data");
3876 std::fs::create_dir(&shared)?;
3877 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
3878
3879 let server_uid = rustix::process::geteuid().as_raw();
3880 let foreign_uid = u32::from(server_uid == 0);
3881 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
3882 let entry = AclEntry::allow_user(
3883 &foreign_qualifier.to_string(),
3884 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3885 None,
3886 );
3887 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
3888 let configured = data_root
3889 .to_str()
3890 .ok_or("temporary data path was not UTF-8")?;
3891
3892 let result = super::build_haematite_store(
3893 configured,
3894 4,
3895 None,
3896 test_node_cache_budget()?,
3897 &crate::control::StageReporter::detached(),
3898 );
3899 let cleanup = std::process::Command::new("chmod")
3900 .arg("-RN")
3901 .arg(&shared)
3902 .status()?;
3903 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
3904
3905 let Err(error) = result else {
3906 return Err("mutating non-euid user UUID allow ACE was accepted".into());
3907 };
3908 let message = error.to_string();
3909 let crate::ServerError::UnsafeDataRootAncestor {
3910 component, reason, ..
3911 } = error
3912 else {
3913 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3914 };
3915 assert_eq!(component, std::fs::canonicalize(&shared)?);
3916 assert!(
3917 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
3918 "reason did not name the rejected ACE: {reason}"
3919 );
3920 assert!(
3921 !data_root.join("config.json").exists(),
3922 "Haematite touched its ambient path before the UUID ACL refusal"
3923 );
3924 Ok(())
3925 }
3926
3927 #[cfg(target_os = "macos")]
3928 #[test]
3929 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
3930 -> Result<(), Box<dyn std::error::Error>> {
3931 use std::os::unix::fs::PermissionsExt as _;
3932
3933 let sandbox = crate::test_support::private_tempdir()?;
3934 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3935 let private_parent = sandbox.path().join("deny-only");
3936 let data_root = private_parent.join("data");
3937 std::fs::create_dir(&private_parent)?;
3938 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3939 let status = std::process::Command::new("chmod")
3940 .arg("+a")
3941 .arg("everyone deny delete")
3942 .arg(&private_parent)
3943 .status()?;
3944 assert!(status.success(), "failed to install Darwin deny-only ACL");
3945 let configured = data_root
3946 .to_str()
3947 .ok_or("temporary data path was not UTF-8")?;
3948
3949 let result = super::build_haematite_store(
3950 configured,
3951 4,
3952 None,
3953 test_node_cache_budget()?,
3954 &crate::control::StageReporter::detached(),
3955 );
3956 let cleanup = std::process::Command::new("chmod")
3957 .arg("-RN")
3958 .arg(&private_parent)
3959 .status()?;
3960 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
3961
3962 let (store, responder) = result?;
3963 assert!(responder.is_none());
3964 assert!(data_root.join("config.json").is_file());
3965 drop(store);
3966 Ok(())
3967 }
3968
3969 #[cfg(target_os = "macos")]
3970 #[test]
3971 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
3972 -> Result<(), Box<dyn std::error::Error>> {
3973 use std::os::unix::fs::PermissionsExt as _;
3974 use users::os::unix::UserExt as _;
3975
3976 let effective_uid = rustix::process::geteuid().as_raw();
3977 let effective_user = users::get_user_by_uid(effective_uid)
3978 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
3979 let sandbox = tempfile::Builder::new()
3980 .prefix(".aion-acl-home-proof-")
3981 .tempdir_in(effective_user.home_dir())?;
3982 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3983 let data_root = sandbox.path().join("data");
3984 let configured = data_root
3985 .to_str()
3986 .ok_or("temporary data path was not UTF-8")?;
3987
3988 let (store, responder) = super::build_haematite_store(
3989 configured,
3990 4,
3991 None,
3992 test_node_cache_budget()?,
3993 &crate::control::StageReporter::detached(),
3994 )?;
3995 assert!(responder.is_none());
3996 assert!(data_root.join("config.json").is_file());
3997 drop(store);
3998 Ok(())
3999 }
4000
4001 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
4002 #[test]
4003 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
4004 -> Result<(), Box<dyn std::error::Error>> {
4005 use std::os::unix::fs::PermissionsExt as _;
4006
4007 let sandbox = crate::test_support::private_tempdir()?;
4008 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
4009 let private_parent = sandbox.path().join("private");
4010 let data_root = private_parent.join("data");
4011 std::fs::create_dir(&private_parent)?;
4012 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
4013 let configured = data_root
4014 .to_str()
4015 .ok_or("temporary data path was not UTF-8")?;
4016
4017 let (store, responder) = super::build_haematite_store(
4018 configured,
4019 4,
4020 None,
4021 test_node_cache_budget()?,
4022 &crate::control::StageReporter::detached(),
4023 )?;
4024 assert!(responder.is_none());
4025 assert!(data_root.join("config.json").is_file());
4026 for shard in 0..4 {
4027 assert!(data_root.join(format!("shard-{shard}")).is_dir());
4028 }
4029 drop(store);
4030 Ok(())
4031 }
4032
4033 #[tokio::test]
4034 async fn connect_store_memory_backend_exposes_no_outbox_store()
4035 -> Result<(), Box<dyn std::error::Error>> {
4036 use crate::config::{StoreBackend, StoreConfig};
4037
4038 let connected = super::connect_store(
4041 StoreConfig {
4042 backend: StoreBackend::Memory,
4043 owned_shards: Vec::new(),
4044 data_dir: None,
4045 shard_count: 1,
4046 cluster: None,
4047 node_cache_budget: None,
4048 ..StoreConfig::default()
4049 },
4050 &crate::control::StageReporter::detached(),
4051 )
4052 .await?;
4053 assert!(
4054 connected.outbox_store.is_none(),
4055 "the in-memory backend exposes no outbox store"
4056 );
4057 Ok(())
4058 }
4059
4060 #[tokio::test]
4066 async fn haematite_boot_refuses_without_a_node_cache_budget()
4067 -> Result<(), Box<dyn std::error::Error>> {
4068 use crate::ServerError;
4069 use crate::config::{StoreBackend, StoreConfig};
4070
4071 let sandbox = crate::test_support::private_tempdir()?;
4072 let data_dir = sandbox.path().join("data");
4073 let error = super::connect_haematite_store(
4074 StoreConfig {
4075 backend: StoreBackend::Haematite,
4076 data_dir: Some(
4077 data_dir
4078 .to_str()
4079 .ok_or("temporary data path was not UTF-8")?
4080 .to_owned(),
4081 ),
4082 shard_count: 4,
4083 ..StoreConfig::default()
4084 },
4085 &crate::control::StageReporter::detached(),
4086 )
4087 .await
4088 .err()
4089 .ok_or("the haematite boot path must refuse a store config with no node_cache_budget")?;
4090 let ServerError::Config { message } = error else {
4091 return Err(format!("expected a config refusal, got {error:?}").into());
4092 };
4093 assert!(
4094 message.contains("store.node_cache_budget"),
4095 "the refusal must name the missing key, got: {message}"
4096 );
4097 assert!(
4098 message.contains("AION_STORE_NODE_CACHE_BUDGET"),
4099 "the refusal must name the environment override, got: {message}"
4100 );
4101 Ok(())
4102 }
4103
4104 #[tokio::test]
4109 async fn configured_node_cache_budget_reaches_the_created_database()
4110 -> Result<(), Box<dyn std::error::Error>> {
4111 use crate::config::{StoreBackend, StoreConfig};
4112
4113 const ONE_GIB: usize = 1 << 30;
4114
4115 let sandbox = crate::test_support::private_tempdir()?;
4116 let data_dir = sandbox.path().join("data");
4117 let connected = super::connect_haematite_store(
4118 StoreConfig {
4119 backend: StoreBackend::Haematite,
4120 data_dir: Some(
4121 data_dir
4122 .to_str()
4123 .ok_or("temporary data path was not UTF-8")?
4124 .to_owned(),
4125 ),
4126 shard_count: 4,
4127 node_cache_budget: Some(haematite::NodeCacheBudget::bytes(ONE_GIB)?),
4128 ..StoreConfig::default()
4129 },
4130 &crate::control::StageReporter::detached(),
4131 )
4132 .await?;
4133 drop(connected);
4134
4135 let recorded: serde_json::Value =
4136 serde_json::from_slice(&std::fs::read(data_dir.join("config.json"))?)?;
4137 assert_eq!(
4138 recorded.get("node_cache_budget"),
4139 Some(&serde_json::json!({ "bytes": ONE_GIB })),
4140 "the operator's budget must be the one haematite created the database with"
4141 );
4142 Ok(())
4143 }
4144
4145 #[tokio::test]
4146 async fn state_build_fails_without_event_broadcast_capacity()
4147 -> Result<(), Box<dyn std::error::Error>> {
4148 let mut runtime = runtime_config();
4149 runtime.websocket.event_broadcast_capacity = None;
4150
4151 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
4152 .await
4153 .err()
4154 .ok_or("state build must fail when event streaming is unsized")?;
4155
4156 assert!(error.is_config(), "expected a config error, got {error}");
4157 assert!(
4158 error
4159 .to_string()
4160 .contains("websocket.event_broadcast_capacity"),
4161 "error must name the missing key: {error}"
4162 );
4163 Ok(())
4164 }
4165
4166 #[tokio::test]
4167 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
4168 let mut runtime = runtime_config();
4169 runtime.query_timeout = None;
4170
4171 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
4172 .await
4173 .err()
4174 .ok_or("state build must fail when the query reply deadline is unset")?;
4175
4176 assert!(error.is_config(), "expected a config error, got {error}");
4177 assert!(
4178 error.to_string().contains("runtime.query_timeout_ms"),
4179 "error must name the missing key: {error}"
4180 );
4181 assert!(
4182 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
4183 "error must name the environment override: {error}"
4184 );
4185 Ok(())
4186 }
4187
4188 #[tokio::test]
4189 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
4190 let mut runtime = runtime_config();
4191 runtime.query_timeout = Some(Duration::ZERO);
4192
4193 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
4194 .await
4195 .err()
4196 .ok_or("state build must fail when the query reply deadline is zero")?;
4197
4198 assert!(error.is_config(), "expected a config error, got {error}");
4199 assert!(
4200 error.to_string().contains("runtime.query_timeout_ms"),
4201 "error must name the zero-valued key: {error}"
4202 );
4203 Ok(())
4204 }
4205
4206 #[tokio::test(flavor = "multi_thread")]
4229 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
4230 -> Result<(), Box<dyn std::error::Error>> {
4231 use std::collections::{BTreeMap, VecDeque};
4232 use std::sync::{Arc, Mutex};
4233
4234 use aion::ActivityDispatch;
4235 use aion_core::{ActivityId, RunId, WorkflowId};
4236 use aion_package::ActionBodyContract;
4237
4238 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
4239 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
4240
4241 struct SequencedBodies {
4244 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
4245 }
4246
4247 impl DeclaredBodies for SequencedBodies {
4248 fn body_for(
4249 &self,
4250 _task_queue: &str,
4251 _action: &str,
4252 _run: DispatchingRun<'_>,
4253 ) -> DeclaredBodyLookup {
4254 let mut replies = match self.replies.lock() {
4255 Ok(replies) => replies,
4256 Err(poisoned) => poisoned.into_inner(),
4257 };
4258 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
4259 }
4260 }
4261
4262 let runtime = runtime_config();
4263 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
4264 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
4265 );
4266 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
4267 Arc::new(InMemoryStore::default());
4268 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
4269 Arc::new(InMemoryStore::default());
4270 let metrics = crate::observability::Metrics::new()?;
4271 let seams = super::build_worker_seams(
4272 &runtime,
4273 &cluster_publisher,
4274 &metrics,
4275 &namespace_store,
4276 &worker_deployment_store,
4277 None,
4278 &Arc::new(tokio::sync::Notify::new()),
4279 );
4280
4281 let fixture = concat!(
4284 env!("CARGO_MANIFEST_DIR"),
4285 "/src/update_check/fixtures/aion-cli-index.jsonl"
4286 );
4287 seams.declared_bodies.install(Arc::new(SequencedBodies {
4288 replies: Mutex::new(VecDeque::from([
4289 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
4290 command: FETCH_COMMAND.to_owned(),
4291 }),
4292 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
4293 command: format!("cat {fixture}"),
4294 }),
4295 ])),
4296 }));
4297
4298 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
4299 Arc::new(aion_store::InMemoryObservabilityStore::default()),
4300 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
4301 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
4302 );
4303 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
4304 super::build_decorated_dispatcher(&runtime, &seams, transcript);
4305
4306 assert_eq!(
4307 update_status.last(),
4308 None,
4309 "the returned slot must start honestly empty"
4310 );
4311
4312 let dispatch = ActivityDispatch {
4313 namespace: "default".to_owned(),
4314 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
4315 node: None,
4316 workflow_id: WorkflowId::new_v4(),
4317 run_id: RunId::new_v4(),
4318 activity_id: ActivityId::from_sequence_position(1),
4319 name: FETCH_ACTION.to_owned(),
4320 input: "{}".to_owned(),
4321 config: "{}".to_owned(),
4322 attempt: 1,
4323 labels: BTreeMap::new(),
4324 advisory: false,
4325 };
4326 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
4327 let encoded = handle
4328 .await?
4329 .map_err(|error| format!("the check dispatch failed: {error}"))?;
4330 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
4331 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
4332
4333 let recorded = update_status.last().ok_or(
4334 "the completed check must land in the RETURNED slot — the one the boot path \
4335 stores and /update-status serves; an empty slot here is the disconnected-\
4336 producer mis-wire the r1 review proved unmeasured",
4337 )?;
4338 assert_eq!(recorded.latest_known, "0.13.7");
4339 Ok(())
4340 }
4341}