aion_server/state.rs
1//! Shared server state constructed once at startup.
2
3use std::{path::PathBuf, sync::Arc};
4
5use aion::{
6 ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
7};
8use aion_store::{EventStore, NamespaceStore, OutboxStore, WorkerDeploymentStore};
9
10use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
11
12#[cfg(feature = "auth")]
13use crate::auth::JwksCache;
14use crate::{
15 config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
16 error::ServerError,
17 namespace::{NamespaceGuard, NamespaceMinter, resolver::NamespaceResolver},
18 observability::{
19 Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
20 metrics::MetricsError,
21 },
22 shutdown::DrainState,
23 worker::{
24 ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
25 supervisor::WorkerSupervisor,
26 },
27};
28
29/// Build an uncommissioned supervisor over one durable deployment store and
30/// the state's cluster channel.
31///
32/// Every state constructor routes through this, so no construction path can
33/// accidentally hand the supervisor a DIFFERENT store from the one the
34/// management API writes through — desired state written in one place and read
35/// in another is exactly the drift this single call site removes. The same
36/// argument holds for the publisher: it is the state's own cluster channel,
37/// so a desired-state write made by the supervisor reaches the same live feed
38/// as one made by the worker-deployment endpoints.
39fn new_supervisor(
40 store: &Arc<dyn WorkerDeploymentStore>,
41 publisher: &crate::cluster_publisher::ClusterEventPublisher,
42) -> Arc<WorkerSupervisor> {
43 Arc::new(WorkerSupervisor::new(Arc::clone(store), publisher.clone()))
44}
45
46/// Cloneable shared state passed to all server transports.
47#[derive(Clone)]
48pub struct ServerState {
49 inner: Arc<ServerStateInner>,
50}
51
52struct ServerStateInner {
53 namespace_guard: NamespaceGuard,
54 runtime: RuntimeConfig,
55 worker_registry: ConnectedWorkerRegistry,
56 pending_activities: PendingActivities,
57 heartbeat_tracker: HeartbeatTracker,
58 /// Correlation registry joining the liveness probe's pushed gRPC pings to
59 /// the answers that come back up each worker's inbound stream (#197).
60 ///
61 /// Held on the state because the two halves live in tasks that cannot see
62 /// each other: the probe owns a timer loop, and each answer arrives inside
63 /// the tonic stream handler for one worker.
64 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters,
65 drain_state: DrainState,
66 metrics: Option<Metrics>,
67 health: Option<HealthState>,
68 /// Shared per-run activity-mock registry. Present only when the dev surface
69 /// is commissioned; the engine's dispatcher consults this exact instance.
70 activity_mock_registry: Option<ActivityMockRegistry>,
71 /// The durable store cast as an [`OutboxStore`]. `None` for the in-memory
72 /// backend, which intentionally has no outbox.
73 outbox_store: Option<Arc<dyn OutboxStore>>,
74 /// The durable namespace registry, captured from the SAME concrete leaf
75 /// backend as the engine's `EventStore` BEFORE that leaf is wrapped in the
76 /// decorator chain (`PublishingEventStore` → `InstrumentedEventStore`),
77 /// which do not implement [`NamespaceStore`]. The haematite backend supplies
78 /// the quorum-replicated implementation; the in-memory backend supplies a
79 /// local-only one. Always present so the control-plane mint
80 /// (Phase 1 S5) and `GET /namespaces` (S7) can reach a real store on every
81 /// boot. Mirrors the `cluster_store` retention pattern.
82 namespace_store: Arc<dyn NamespaceStore>,
83 /// Durable worker-deployment records captured from the same leaf backend.
84 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
85 /// Managed-worker supervision (W-1..W-4). Always present and always
86 /// UNCOMMISSIONED at construction: it supervises nothing until
87 /// [`crate::run`] installs the operator's `[worker_supervision]` policy, so
88 /// state construction can never start a process by itself.
89 worker_supervisor: Arc<WorkerSupervisor>,
90 /// Advisory outbox wake (LSUB-2): the in-process `Notify` shared by the
91 /// engine's stage seam (the `InstrumentedEventStore`'s `append_with_outbox`)
92 /// and the [`OutboxDispatcher`](crate::worker::OutboxDispatcher) run loop, so
93 /// a committed fan-out row wakes the dispatcher in ~RTT instead of waiting up
94 /// to one poll interval. Always present (cheap, no `Option`): the handle is
95 /// harmless when the outbox is not commissioned, since nothing pulses it.
96 outbox_wake: Arc<tokio::sync::Notify>,
97 /// WS3 cluster topology/ownership publisher. Always present: the ops console's
98 /// cluster channel is served on every boot (calm state with no peers on a
99 /// single-node server). Sized from `websocket.cluster_broadcast_capacity`.
100 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
101 /// NOI-5b agent-observability transcript sequencer + live fan-out. Always
102 /// present: the transcript channel is served on every boot. The backing
103 /// [`ObservabilityStore`](aion_store::ObservabilityStore) is the durable
104 /// `O`-keyspace impl on a haematite boot and an in-memory impl on the memory
105 /// backend (which has no `O` keyspace), so the transcript path is uniform
106 /// across backends while only haematite persists across restart.
107 /// Sized from `websocket.cluster_broadcast_capacity` (the same deployment-wide
108 /// real-time channel capacity the cluster tail uses).
109 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
110 /// NOI-6 server-side intervention routing: the `attempt -> owning-worker`
111 /// back-index the intervention router resolves a command's target through.
112 /// Always present (cheap, no `Option`): the agent-dispatch path binds an owner
113 /// when it dispatches an agent attempt and releases it on completion, so the
114 /// router resolves the CURRENT owner. Empty until an agent attempt is
115 /// dispatched — a command to an unbound attempt is the attempt-scoped no-op.
116 attempt_owners: crate::worker::AttemptOwnerIndex,
117 /// R1 live unserved-queue state. Always present (cheap, no `Option`): the
118 /// bridge dispatcher publishes every parked dispatch into THIS instance, so
119 /// `unserved_queues()` answers "which addresses are unserved, why, and which
120 /// runs are waiting on them" without reading logs. Empty whenever nothing is
121 /// parked.
122 queue_service_state: crate::worker::QueueServiceState,
123 /// R1 queue-declaration source, filled in with the engine-backed reader once
124 /// the engine exists. Held so surfaces (and the bridge) share ONE reader
125 /// rather than each building their own view of the deployed contracts.
126 queue_declarations: crate::worker::QueueDeclarationSource,
127 /// The declared bodies THIS server is executing right now, shared with the
128 /// [`crate::worker::DeclaredCommandDispatcher`] that registers each attempt
129 /// for the life of its command. Always present (cheap, no `Option`): the
130 /// cancel path signals through it on every boot. Empty whenever no
131 /// server-run command is in flight — which, on a `from_parts*` state that
132 /// builds no declared-body dispatcher, is always.
133 declared_attempts: crate::worker::DeclaredCommandAttempts,
134 /// The last completed update check (#189 slice one), shared with the
135 /// [`crate::update_check::UpdateCheckObserver`] decorator that writes it
136 /// on the full boot path. Always present and always EMPTY at
137 /// construction: a server that has never checked says so, and nothing
138 /// here ever fetches anything — every check is an explicit operator act.
139 update_status: crate::update_check::UpdateStatusState,
140 /// The server-resolved workspace root for declared action bodies (#139):
141 /// the aion home's `clones/` directory, resolved ONCE at state
142 /// construction. The declared-body dispatcher expands `{workspace_root}`
143 /// with THIS value and the startup banner reports it, so composition
144 /// points read the value instead of re-deriving it. A resolution failure
145 /// is held here — boot proceeds, and the failure surfaces as a terminal
146 /// refusal when a placeholder-bearing body dispatches.
147 workspace_root: crate::worker::WorkspaceRoot,
148 /// This node's distribution name for the WS3 cluster snapshot self-identity.
149 /// `Some` on a distributed haematite boot (the configured `store.cluster.node_id`),
150 /// `None` on a single-node boot — the snapshot then reports the standalone
151 /// self-label so the ops console still has a node to render.
152 cluster_self_node: Option<String>,
153 /// Owns the distributed haematite inbound-write responder thread, kept alive
154 /// for the server's lifetime so a cluster node keeps answering peers'
155 /// replication/election traffic. `None` for non-distributed boots. Dropping
156 /// the state stops the responder.
157 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
158 /// The concrete distributed haematite store the SS-5b supervisor polls for
159 /// peer liveness. `None` for every non-distributed boot.
160 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
161 /// The peers the SS-5b supervisor watches (each with the shards this node
162 /// adopts on its death). Empty for non-distributed boots.
163 watched_peers: Vec<crate::cluster::WatchedPeer>,
164 /// The request-routing shard directory (R-2), built over the cluster store +
165 /// static peer config. `None` for every non-distributed boot, so the routing
166 /// edge falls back to the bare R-1 ownership check (and the default path is a
167 /// no-op).
168 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
169 /// The request forwarder (R-3): relays a non-local signal/query/cancel to the
170 /// shard owner's gRPC address. `None` for non-distributed boots. The trait
171 /// object makes the liminal forwarder a one-line swap when 13-L0/L1 land (R-6).
172 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
173 #[cfg(feature = "auth")]
174 jwks_cache: Option<JwksCache>,
175}
176
177impl ServerState {
178 /// Fallback cluster broadcast capacity for the `from_parts*` embedder/test
179 /// constructors, which bypass config validation. The config-driven
180 /// [`Self::build`] path always sizes the publisher from the validated
181 /// `websocket.cluster_broadcast_capacity` instead.
182 ///
183 /// `NonZeroUsize::new(64)` is statically non-`None`, so the
184 /// [`Option::unwrap`]-free `match` keeps the value `const` without tripping
185 /// the workspace `unwrap_used`/`expect_used` deny lints.
186 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
187 match std::num::NonZeroUsize::new(64) {
188 Some(value) => value,
189 None => std::num::NonZeroUsize::MIN,
190 };
191
192 /// Build shared state from operator configuration.
193 ///
194 /// # Errors
195 ///
196 /// Returns [`ServerError`] if the store cannot connect or the engine cannot
197 /// be constructed.
198 pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
199 let (store_config, runtime) = config.into_parts();
200 let connected = connect_store(store_config).await?;
201 Self::build_with_connected_store(connected, runtime).await
202 }
203
204 /// Build shared state from an already-constructed store.
205 ///
206 /// # Errors
207 ///
208 /// Returns [`ServerError::EngineCall`] if the engine cannot be constructed.
209 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
210 where
211 S: EventStore + NamespaceStore + WorkerDeploymentStore,
212 {
213 // Capture the concrete leaf as BOTH the event store and the namespace
214 // registry before it is wrapped in the (NamespaceStore-unaware) decorator
215 // chain — the same leaf, two trait objects.
216 let leaf = Arc::new(store);
217 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
218 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
219 Self::build_with_connected_store(
220 ConnectedStore::local(leaf, None, namespace_store, worker_deployment_store),
221 runtime,
222 )
223 .await
224 }
225
226 async fn build_with_connected_store(
227 connected: ConnectedStore,
228 runtime: RuntimeConfig,
229 ) -> Result<Self, ServerError> {
230 let cluster_self_node = connected.cluster_self_node();
231 let outbox_store = connected.outbox_store;
232 let bootstrap_coordinator = connected.bootstrap_coordinator;
233 let cluster_responder = connected.cluster_responder;
234 let cluster_store = connected.cluster_store;
235 let watched_peers = connected.watched_peers;
236 // Build the R-2 directory + R-3 forwarder over the (live, failover-aware)
237 // cluster store and static peer config. Both present only for a
238 // distributed boot; `None` otherwise leaves the routing edge a no-op.
239 let RoutingState {
240 shard_directory,
241 request_forwarder,
242 mint_routing,
243 } = build_routing_state(
244 cluster_store.as_ref(),
245 connected.directory_peers,
246 connected.self_node_id,
247 );
248 let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
249 let (cluster_publisher, transcript_publisher) =
250 build_real_time_publishers(&runtime, connected.observability_store)?;
251 let (metrics, outbox_wake, instrumented_store) =
252 build_instrumented_store(&runtime, connected.event_store)?;
253 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
254 let seams = build_worker_seams(
255 &runtime,
256 &cluster_publisher,
257 &connected.namespace_store,
258 &connected.worker_deployment_store,
259 mint_routing,
260 );
261 let (
262 activity_dispatcher,
263 activity_mock_registry,
264 attempt_owners,
265 workspace_root,
266 update_status,
267 ) = build_decorated_dispatcher(&runtime, &seams, transcript_publisher.clone());
268
269 let engine = boot_engine(EngineAssembly {
270 seams: &seams,
271 instrumented_store: &instrumented_store,
272 event_broadcast_capacity,
273 query_timeout,
274 activity_dispatcher,
275 active_registry: Arc::new(aion::Registry::default()),
276 bootstrap_coordinator,
277 runtime: &runtime,
278 })
279 .await?;
280 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
281 let worker_supervisor =
282 new_supervisor(&connected.worker_deployment_store, &cluster_publisher);
283 #[cfg(feature = "auth")]
284 let jwks_cache = build_jwks_cache(&runtime).await?;
285 Ok(Self {
286 inner: Arc::new(ServerStateInner {
287 namespace_guard: NamespaceGuard::new(resolver),
288 runtime,
289 metrics: exported_metrics,
290 worker_registry: seams.worker_registry,
291 pending_activities: seams.pending_activities,
292 heartbeat_tracker: seams.heartbeat_tracker,
293 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
294 drain_state: seams.drain_state,
295 health: Some(HealthState::new(instrumented_store, true)),
296 activity_mock_registry,
297 outbox_store,
298 namespace_store: connected.namespace_store,
299 worker_supervisor,
300 worker_deployment_store: connected.worker_deployment_store,
301 outbox_wake,
302 cluster_publisher,
303 transcript_publisher,
304 attempt_owners,
305 queue_service_state: seams.queue_service_state,
306 queue_declarations: seams.queue_declarations,
307 declared_attempts: seams.declared_attempts,
308 update_status,
309 workspace_root,
310 cluster_self_node,
311 cluster_responder,
312 cluster_store,
313 watched_peers,
314 shard_directory,
315 request_forwarder,
316 #[cfg(feature = "auth")]
317 jwks_cache,
318 }),
319 })
320 }
321
322 /// Build shared state from explicit parts with a default worker registry.
323 #[must_use]
324 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
325 // No durable store was supplied (this constructor builds state from a
326 // resolver only), so the registry is a local-only in-memory store —
327 // present so `namespace_store()` is always reachable, never mutating any
328 // durable backend.
329 Self::from_parts_with_namespace_store(
330 namespace_resolver,
331 runtime,
332 Arc::new(aion_store::InMemoryStore::default()),
333 )
334 }
335
336 /// Build shared state from explicit parts with one caller-supplied durable
337 /// namespace and worker-deployment leaf.
338 ///
339 /// Identical to [`Self::from_parts`] except both control-plane dimensions are
340 /// derived from `store`: namespace registry reads/writes and durable worker
341 /// deployments use the same supplied leaf rather than fresh in-memory state.
342 #[must_use]
343 pub fn from_parts_with_namespace_store<S>(
344 namespace_resolver: NamespaceResolver,
345 runtime: RuntimeConfig,
346 store: Arc<S>,
347 ) -> Self
348 where
349 S: NamespaceStore + WorkerDeploymentStore,
350 {
351 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
352 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
353 Self::from_parts_with_control_stores(
354 namespace_resolver,
355 runtime,
356 namespace_store,
357 worker_deployment_store,
358 )
359 }
360
361 /// Build shared state with caller-supplied namespace and worker-deployment stores.
362 ///
363 /// This is the explicit embedder/test seam for retaining both control-plane
364 /// contracts from one durable leaf without constructing the full engine boot.
365 #[must_use]
366 pub fn from_parts_with_control_stores(
367 namespace_resolver: NamespaceResolver,
368 runtime: RuntimeConfig,
369 namespace_store: Arc<dyn NamespaceStore>,
370 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
371 ) -> Self {
372 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
373 // Bound here, beside the tracker, for the same reason: `runtime` moves
374 // into the state below, and the transport-loss budget must be read from
375 // the operator's heartbeat window BEFORE it does. The window is a
376 // constructor parameter, so an embedder-booted server cannot reach the
377 // state carrying a budget the operator never declared.
378 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
379 // Computed before `runtime` moves into the state: the retention bounds
380 // flow from `[observability]` config on the embedder path too, so a
381 // from-parts server enforces the same truncation/cap as a full boot.
382 let bounds = transcript_bounds(&runtime);
383 // These constructors bypass config validation and cannot fail, so an
384 // unruled flush policy falls back to the identity (unbatched) one rather
385 // than inventing a tuning value; see `TranscriptBatchPolicy::UNBATCHED`.
386 let batch = required_transcript_batch_policy(&runtime)
387 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
388 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
389 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
390 );
391 Self {
392 inner: Arc::new(ServerStateInner {
393 namespace_guard: NamespaceGuard::new(namespace_resolver),
394 runtime,
395 worker_registry: ConnectedWorkerRegistry::default()
396 .with_worker_deployment_store(worker_deployment_store.clone())
397 .with_cluster_publisher(cluster_publisher.clone()),
398 pending_activities,
399 heartbeat_tracker,
400 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
401 drain_state: DrainState::default(),
402 metrics: None,
403 health: None,
404 activity_mock_registry: None,
405 outbox_store: None,
406 namespace_store,
407 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
408 worker_deployment_store,
409 outbox_wake: Arc::new(tokio::sync::Notify::new()),
410 cluster_publisher,
411 // NOI-5b: a from-parts / embedder state has no durable store, so
412 // the transcript sequencer runs over an in-memory `O`-keyspace
413 // impl — the transcript channel is served on every boot.
414 transcript_publisher: build_transcript_publisher(
415 None,
416 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
417 bounds,
418 batch,
419 ),
420 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
421 queue_service_state: crate::worker::QueueServiceState::default(),
422 queue_declarations: crate::worker::QueueDeclarationSource::default(),
423 // No declared-body dispatcher is built on this path, so nothing
424 // ever registers here; present so the cancel path reads one
425 // registry on every construction rather than an `Option`.
426 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
427 // Empty and STAYING empty: this constructor builds no
428 // dispatcher, so no observer ever writes it — the honest
429 // answer for an embedder/test state is "never checked".
430 update_status: crate::update_check::UpdateStatusState::default(),
431 // Inert here: this constructor builds no declared-body
432 // dispatcher, so nothing expands `{workspace_root}` with this
433 // value — it exists so `workspace_root()` is always readable.
434 workspace_root: crate::worker::WorkspaceRoot::resolve(),
435 cluster_self_node: None,
436 cluster_responder: None,
437 cluster_store: None,
438 watched_peers: Vec::new(),
439 shard_directory: None,
440 request_forwarder: None,
441 #[cfg(feature = "auth")]
442 jwks_cache: None,
443 }),
444 }
445 }
446
447 /// Build shared state from explicit parts with one caller-supplied durable
448 /// namespace/worker-deployment leaf and a caller-supplied JWKS cache.
449 ///
450 /// The combined seam of [`Self::from_parts_with_namespace_store`] (seed the
451 /// durable registry the control-plane read/create paths observe) and
452 /// [`Self::from_parts_with_jwks`] (validate bearer tokens against an injected
453 /// issuer): an enumerated caller can exercise the real JWT authorization path
454 /// against a seeded registry without a full [`Self::build`] boot.
455 #[cfg(feature = "auth")]
456 #[must_use]
457 pub fn from_parts_with_namespace_store_and_jwks<S>(
458 namespace_resolver: NamespaceResolver,
459 runtime: RuntimeConfig,
460 store: Arc<S>,
461 jwks_cache: JwksCache,
462 ) -> Self
463 where
464 S: NamespaceStore + WorkerDeploymentStore,
465 {
466 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
467 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
468 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
469 // Bound here, beside the tracker, for the same reason: `runtime` moves
470 // into the state below, and the transport-loss budget must be read from
471 // the operator's heartbeat window BEFORE it does. The window is a
472 // constructor parameter, so an embedder-booted server cannot reach the
473 // state carrying a budget the operator never declared.
474 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
475 // Computed before `runtime` moves into the state: the retention bounds
476 // flow from `[observability]` config on the embedder path too, so a
477 // from-parts server enforces the same truncation/cap as a full boot.
478 let bounds = transcript_bounds(&runtime);
479 // These constructors bypass config validation and cannot fail, so an
480 // unruled flush policy falls back to the identity (unbatched) one rather
481 // than inventing a tuning value; see `TranscriptBatchPolicy::UNBATCHED`.
482 let batch = required_transcript_batch_policy(&runtime)
483 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
484 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
485 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
486 );
487 Self {
488 inner: Arc::new(ServerStateInner {
489 namespace_guard: NamespaceGuard::new(namespace_resolver),
490 runtime,
491 worker_registry: ConnectedWorkerRegistry::default()
492 .with_worker_deployment_store(worker_deployment_store.clone())
493 .with_cluster_publisher(cluster_publisher.clone()),
494 pending_activities,
495 heartbeat_tracker,
496 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
497 drain_state: DrainState::default(),
498 metrics: None,
499 health: None,
500 activity_mock_registry: None,
501 outbox_store: None,
502 namespace_store,
503 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
504 worker_deployment_store,
505 outbox_wake: Arc::new(tokio::sync::Notify::new()),
506 cluster_publisher,
507 // NOI-5b: a from-parts / embedder state has no durable store, so
508 // the transcript sequencer runs over an in-memory `O`-keyspace
509 // impl — the transcript channel is served on every boot.
510 transcript_publisher: build_transcript_publisher(
511 None,
512 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
513 bounds,
514 batch,
515 ),
516 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
517 queue_service_state: crate::worker::QueueServiceState::default(),
518 queue_declarations: crate::worker::QueueDeclarationSource::default(),
519 // No declared-body dispatcher is built on this path, so nothing
520 // ever registers here; present so the cancel path reads one
521 // registry on every construction rather than an `Option`.
522 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
523 // Empty and STAYING empty: this constructor builds no
524 // dispatcher, so no observer ever writes it — the honest
525 // answer for an embedder/test state is "never checked".
526 update_status: crate::update_check::UpdateStatusState::default(),
527 // Inert here: this constructor builds no declared-body
528 // dispatcher, so nothing expands `{workspace_root}` with this
529 // value — it exists so `workspace_root()` is always readable.
530 workspace_root: crate::worker::WorkspaceRoot::resolve(),
531 cluster_self_node: None,
532 cluster_responder: None,
533 cluster_store: None,
534 watched_peers: Vec::new(),
535 shard_directory: None,
536 request_forwarder: None,
537 jwks_cache: Some(jwks_cache),
538 }),
539 }
540 }
541
542 /// Build shared state from explicit parts with a caller-supplied JWKS cache.
543 ///
544 /// Embedders that construct their own [`JwksCache`] (for example against a
545 /// private issuer) can install it here; transports then validate bearer
546 /// tokens against it exactly as with a [`Self::build`]-constructed state.
547 #[cfg(feature = "auth")]
548 #[must_use]
549 pub fn from_parts_with_jwks(
550 namespace_resolver: NamespaceResolver,
551 runtime: RuntimeConfig,
552 jwks_cache: JwksCache,
553 ) -> Self {
554 // No durable store was supplied, so the deployment records and the
555 // supervisor built over them share ONE in-memory leaf: two leaves
556 // would let the supervisor read a record the API never wrote.
557 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
558 Arc::new(aion_store::InMemoryStore::default());
559 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
560 // Bound here, beside the tracker, for the same reason: `runtime` moves
561 // into the state below, and the transport-loss budget must be read from
562 // the operator's heartbeat window BEFORE it does. The window is a
563 // constructor parameter, so an embedder-booted server cannot reach the
564 // state carrying a budget the operator never declared.
565 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
566 // Computed before `runtime` moves into the state: the retention bounds
567 // flow from `[observability]` config on the embedder path too, so a
568 // from-parts server enforces the same truncation/cap as a full boot.
569 let bounds = transcript_bounds(&runtime);
570 // These constructors bypass config validation and cannot fail, so an
571 // unruled flush policy falls back to the identity (unbatched) one rather
572 // than inventing a tuning value; see `TranscriptBatchPolicy::UNBATCHED`.
573 let batch = required_transcript_batch_policy(&runtime)
574 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
575 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
576 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
577 );
578 Self {
579 inner: Arc::new(ServerStateInner {
580 namespace_guard: NamespaceGuard::new(namespace_resolver),
581 runtime,
582 worker_registry: ConnectedWorkerRegistry::default(),
583 pending_activities,
584 heartbeat_tracker,
585 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
586 drain_state: DrainState::default(),
587 metrics: None,
588 health: None,
589 activity_mock_registry: None,
590 outbox_store: None,
591 // No durable store was supplied (these constructors build state
592 // from a resolver only), so the registry is a local-only
593 // in-memory store — present so `namespace_store()` is always
594 // reachable, never mutating any durable backend.
595 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
596 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
597 worker_deployment_store: fallback_deployment_store,
598 outbox_wake: Arc::new(tokio::sync::Notify::new()),
599 cluster_publisher,
600 // NOI-5b: a from-parts / embedder state has no durable store, so
601 // the transcript sequencer runs over an in-memory `O`-keyspace
602 // impl — the transcript channel is served on every boot.
603 transcript_publisher: build_transcript_publisher(
604 None,
605 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
606 bounds,
607 batch,
608 ),
609 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
610 queue_service_state: crate::worker::QueueServiceState::default(),
611 queue_declarations: crate::worker::QueueDeclarationSource::default(),
612 // No declared-body dispatcher is built on this path, so nothing
613 // ever registers here; present so the cancel path reads one
614 // registry on every construction rather than an `Option`.
615 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
616 // Empty and STAYING empty: this constructor builds no
617 // dispatcher, so no observer ever writes it — the honest
618 // answer for an embedder/test state is "never checked".
619 update_status: crate::update_check::UpdateStatusState::default(),
620 // Inert here: this constructor builds no declared-body
621 // dispatcher, so nothing expands `{workspace_root}` with this
622 // value — it exists so `workspace_root()` is always readable.
623 workspace_root: crate::worker::WorkspaceRoot::resolve(),
624 cluster_self_node: None,
625 cluster_responder: None,
626 cluster_store: None,
627 watched_peers: Vec::new(),
628 shard_directory: None,
629 request_forwarder: None,
630 jwks_cache: Some(jwks_cache),
631 }),
632 }
633 }
634
635 /// Build shared state from explicit parts with a caller-supplied registry.
636 #[must_use]
637 pub fn from_parts_with_registry(
638 namespace_resolver: NamespaceResolver,
639 runtime: RuntimeConfig,
640 worker_registry: ConnectedWorkerRegistry,
641 ) -> Self {
642 // No durable store was supplied, so the deployment records and the
643 // supervisor built over them share ONE in-memory leaf: two leaves
644 // would let the supervisor read a record the API never wrote.
645 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
646 Arc::new(aion_store::InMemoryStore::default());
647 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
648 // Bound here, beside the tracker, for the same reason: `runtime` moves
649 // into the state below, and the transport-loss budget must be read from
650 // the operator's heartbeat window BEFORE it does. The window is a
651 // constructor parameter, so an embedder-booted server cannot reach the
652 // state carrying a budget the operator never declared.
653 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
654 // Computed before `runtime` moves into the state: the retention bounds
655 // flow from `[observability]` config on the embedder path too, so a
656 // from-parts server enforces the same truncation/cap as a full boot.
657 let bounds = transcript_bounds(&runtime);
658 // These constructors bypass config validation and cannot fail, so an
659 // unruled flush policy falls back to the identity (unbatched) one rather
660 // than inventing a tuning value; see `TranscriptBatchPolicy::UNBATCHED`.
661 let batch = required_transcript_batch_policy(&runtime)
662 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
663 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
664 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
665 );
666 Self {
667 inner: Arc::new(ServerStateInner {
668 namespace_guard: NamespaceGuard::new(namespace_resolver),
669 runtime,
670 worker_registry,
671 pending_activities,
672 heartbeat_tracker,
673 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
674 drain_state: DrainState::default(),
675 metrics: None,
676 health: None,
677 activity_mock_registry: None,
678 outbox_store: None,
679 // No durable store was supplied (these constructors build state
680 // from a resolver only), so the registry is a local-only
681 // in-memory store — present so `namespace_store()` is always
682 // reachable, never mutating any durable backend.
683 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
684 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
685 worker_deployment_store: fallback_deployment_store,
686 outbox_wake: Arc::new(tokio::sync::Notify::new()),
687 cluster_publisher,
688 // NOI-5b: a from-parts / embedder state has no durable store, so
689 // the transcript sequencer runs over an in-memory `O`-keyspace
690 // impl — the transcript channel is served on every boot.
691 transcript_publisher: build_transcript_publisher(
692 None,
693 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
694 bounds,
695 batch,
696 ),
697 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
698 queue_service_state: crate::worker::QueueServiceState::default(),
699 queue_declarations: crate::worker::QueueDeclarationSource::default(),
700 // No declared-body dispatcher is built on this path, so nothing
701 // ever registers here; present so the cancel path reads one
702 // registry on every construction rather than an `Option`.
703 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
704 // Empty and STAYING empty: this constructor builds no
705 // dispatcher, so no observer ever writes it — the honest
706 // answer for an embedder/test state is "never checked".
707 update_status: crate::update_check::UpdateStatusState::default(),
708 // Inert here: this constructor builds no declared-body
709 // dispatcher, so nothing expands `{workspace_root}` with this
710 // value — it exists so `workspace_root()` is always readable.
711 workspace_root: crate::worker::WorkspaceRoot::resolve(),
712 cluster_self_node: None,
713 cluster_responder: None,
714 cluster_store: None,
715 watched_peers: Vec::new(),
716 shard_directory: None,
717 request_forwarder: None,
718 #[cfg(feature = "auth")]
719 jwks_cache: None,
720 }),
721 }
722 }
723
724 /// Borrow the namespace guard shared by all transports.
725 #[must_use]
726 pub fn namespace_guard(&self) -> &NamespaceGuard {
727 &self.inner.namespace_guard
728 }
729
730 /// Build the deploy authorization guard over the shared resolver.
731 #[must_use]
732 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
733 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
734 }
735
736 /// Borrow non-secret runtime settings needed by transports.
737 #[must_use]
738 pub fn runtime_config(&self) -> &RuntimeConfig {
739 &self.inner.runtime
740 }
741
742 /// Borrow the last-completed-update-check slot (#189 slice one).
743 ///
744 /// Written only by the [`crate::update_check::UpdateCheckObserver`] on the
745 /// full boot path; read by `GET /update-status`. Empty until a check has
746 /// genuinely completed — a fresh server has honestly never checked.
747 /// Crate-visible only: the slot's type is implementation detail behind
748 /// the route, not `aion-server` public API.
749 #[must_use]
750 pub(crate) fn update_status(&self) -> &crate::update_check::UpdateStatusState {
751 &self.inner.update_status
752 }
753
754 /// Borrow the server-resolved workspace root declared bodies expand
755 /// `{workspace_root}` with (#139).
756 ///
757 /// The startup banner reads this so composition points learn the value
758 /// from the server that will use it, rather than re-deriving it. That
759 /// banner claim holds for [`Self::build`]-constructed states, where this
760 /// same value is threaded into the declared-body dispatcher; the
761 /// `from_parts*` constructors build no such dispatcher, so their copy is
762 /// inert — readable, but expanded by nothing.
763 #[must_use]
764 pub fn workspace_root(&self) -> &crate::worker::WorkspaceRoot {
765 &self.inner.workspace_root
766 }
767
768 /// Borrow the connected-worker registry shared by worker transports and dispatch.
769 #[must_use]
770 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
771 &self.inner.worker_registry
772 }
773
774 /// Stop every in-flight activity of `workflow_id`, by whichever path is
775 /// executing it (#233).
776 ///
777 /// Joins the three pieces of live state this node holds — the heartbeat
778 /// tracker (which worker holds which activity), the connected-worker
779 /// registry (how to reach that worker), and the declared-attempt registry
780 /// (which commands this server is running itself) — so the cancel handler
781 /// needs only a `ServerState` and never learns the routing itself.
782 ///
783 /// Returns one record per tracked in-flight activity, INCLUDING the ones
784 /// that could not be asked, plus the server-executed declared bodies that
785 /// were signalled. A worker cancel is a request, not a guarantee; a
786 /// declared body's signal reaches its process group.
787 ///
788 /// # Errors
789 ///
790 /// Returns [`ServerError::LockPoisoned`] when the tracker's, the registry's,
791 /// or the declared-attempt registry's state cannot be read.
792 pub fn cancel_in_flight_activities(
793 &self,
794 workflow_id: &aion_core::WorkflowId,
795 ) -> Result<crate::worker::InFlightCancellation, ServerError> {
796 crate::worker::cancel_in_flight_activities(
797 self.heartbeat_tracker(),
798 self.worker_registry(),
799 self.declared_attempts(),
800 workflow_id,
801 )
802 }
803
804 /// Borrow the registry of declared bodies this server is executing.
805 ///
806 /// The declared-body dispatcher registers each attempt here for the life of
807 /// its command; [`Self::cancel_in_flight_activities`] signals through it.
808 #[must_use]
809 pub fn declared_attempts(&self) -> &crate::worker::DeclaredCommandAttempts {
810 &self.inner.declared_attempts
811 }
812
813 /// Borrow the WS3 cluster-event publisher shared by the cluster state-change
814 /// sites (supervisor, worker registry) and the cluster subscription endpoint.
815 /// Always present, on every boot.
816 #[must_use]
817 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
818 &self.inner.cluster_publisher
819 }
820
821 /// Borrow the NOI-5b transcript sequencer shared by the worker->server
822 /// ingestion seam (which publishes a running activity's `ActivityEvent`s) and
823 /// the transcript subscription endpoint (which tails + resumes them). Always
824 /// present, on every boot.
825 #[must_use]
826 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
827 &self.inner.transcript_publisher
828 }
829
830 /// Borrow the NOI-6 `attempt -> owning-worker` back-index. The agent-dispatch
831 /// path binds an owner when it dispatches an agent attempt and releases it on
832 /// completion, so the intervention router always resolves the CURRENT owner.
833 #[must_use]
834 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
835 &self.inner.attempt_owners
836 }
837
838 /// Borrow the R1 live unserved-queue state — the same instance the bridge
839 /// dispatcher publishes every parked dispatch into.
840 #[must_use]
841 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
842 &self.inner.queue_service_state
843 }
844
845 /// Borrow the R1 queue-declaration source — the engine-backed reader the
846 /// bridge classifies against. Answers `Unknown` on a state built without an
847 /// engine, which never refuses anything.
848 #[must_use]
849 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
850 &self.inner.queue_declarations
851 }
852
853 /// Every queue address currently unserved, with its taxonomy reason, the
854 /// policy it is held under, the live poller census behind the verdict, and
855 /// the runs parked on it.
856 ///
857 /// This is the server-side answer to "is anything stuck, and on what" — the
858 /// question the pre-R1 seam could only be asked by reading logs.
859 ///
860 /// # Errors
861 ///
862 /// Returns [`ServerError::LockPoisoned`] if the state lock is poisoned.
863 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
864 self.inner.queue_service_state.unserved()
865 }
866
867 /// Every run this engine process could not make resident, with the reason it
868 /// could not and when the failure was observed (#117).
869 ///
870 /// This is the fleet half of the degraded-residency question. `POST
871 /// /workflows/describe` answers it for a run an operator can already name;
872 /// this answers it for the operator who cannot, which is the case that made
873 /// the original defect unrecoverable in practice — the id was only ever
874 /// printed in a boot log line that had scrolled away.
875 ///
876 /// The set is per-process and self-clearing: an entry disappears the moment
877 /// the engine observes that run resident, so an EMPTY list is the healthy
878 /// answer and never a stale one.
879 ///
880 /// # Errors
881 ///
882 /// Returns [`ServerError`] when the state carries no engine handle, or when
883 /// the registry lock is poisoned.
884 pub fn unrecoverable_runs(
885 &self,
886 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
887 self.engine()?
888 .registry()
889 .unrecoverable()
890 .list()
891 .map_err(ServerError::from)
892 }
893
894 /// Build the NOI-6 intervention router over the connected-worker registry, the
895 /// attempt-owner back-index, and the active intervention transport.
896 ///
897 /// The transport is the liminal server-push
898 /// ([`LiminalInterventionTransport`](crate::worker::LiminalInterventionTransport))
899 /// when the `liminal-transport` feature is compiled in — the production path
900 /// that pushes a routed command out on the owning worker's connection — and a
901 /// null transport otherwise, which reports the target unreachable so every
902 /// command NACKs the attempt-scoped no-op rather than silently vanishing. The
903 /// router is cheap to build (it clones cloneable handles), so it is constructed
904 /// per request at the endpoint rather than stored.
905 #[must_use]
906 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
907 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
908 #[cfg(feature = "liminal-transport")]
909 {
910 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
911 }
912 #[cfg(not(feature = "liminal-transport"))]
913 {
914 std::sync::Arc::new(NullInterventionTransport)
915 }
916 };
917 crate::worker::InterventionRouter::new(
918 self.inner.worker_registry.clone(),
919 self.inner.attempt_owners.clone(),
920 transport,
921 )
922 // Lane #229: an APPLIED InjectMessage is teed into the durable
923 // transcript, so the retained record holds the operator's words.
924 .with_transcript_publisher(self.inner.transcript_publisher.clone())
925 }
926
927 /// This node's configured cluster distribution name for the WS3 snapshot
928 /// self-identity, or `None` on a single-node boot (the snapshot then reports
929 /// the standalone self-label).
930 #[must_use]
931 pub fn cluster_self_node(&self) -> Option<&str> {
932 self.inner.cluster_self_node.as_deref()
933 }
934
935 /// Clone the live engine handle the completion path records terminals through.
936 ///
937 /// This is the SAME `Arc<Engine>` the gRPC completion callback is built over
938 /// (state.rs installs `ServerOutboxDeliveryCallback::new(engine)` on the
939 /// pending tracker when `outbox.enabled`), so the liminal completion path
940 /// re-enters worker results through the identical `record_fan_out_completion`
941 /// seam rather than inventing a second one.
942 ///
943 /// # Errors
944 ///
945 /// Returns [`ServerError`] when the namespace resolver has no engine handle
946 /// (a state built from parts without an engine).
947 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
948 self.inner
949 .namespace_guard
950 .resolver()
951 .engine()
952 .map(Arc::clone)
953 }
954
955 /// Borrow the pending-activities tracker shared by the NIF bridge and worker stream handler.
956 #[must_use]
957 pub fn pending_activities(&self) -> &PendingActivities {
958 &self.inner.pending_activities
959 }
960
961 /// Borrow the heartbeat/liveness tracker shared by dispatch and worker streams.
962 #[must_use]
963 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
964 &self.inner.heartbeat_tracker
965 }
966
967 /// Borrow the gRPC liveness answer-correlation registry (#197).
968 ///
969 /// The worker stream handler delivers each `LivenessAnswer` into it; the
970 /// liveness probe arms and awaits through the SAME handle. There is exactly
971 /// one per server, for the same reason there is exactly one probe.
972 #[must_use]
973 pub fn grpc_liveness_waiters(&self) -> &crate::worker::GrpcLivenessWaiters {
974 &self.inner.grpc_liveness_waiters
975 }
976
977 /// Borrow the drain gate shared by transports and worker dispatch.
978 #[must_use]
979 pub fn drain_state(&self) -> &DrainState {
980 &self.inner.drain_state
981 }
982
983 /// Borrow the prometheus metrics handle when this state was built with a store.
984 #[must_use]
985 pub fn metrics(&self) -> Option<&Metrics> {
986 self.inner.metrics.as_ref()
987 }
988
989 /// Borrow health probe state when this state was built with a store.
990 #[must_use]
991 pub fn health(&self) -> Option<&HealthState> {
992 self.inner.health.as_ref()
993 }
994
995 /// Borrow the shared per-run activity-mock registry when the dev surface is
996 /// commissioned. Returns [`None`] on a server with the dev surface dark, so
997 /// the dev handlers refuse cleanly rather than mocking on a production
998 /// server.
999 #[must_use]
1000 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
1001 self.inner.activity_mock_registry.as_ref()
1002 }
1003
1004 /// Borrow the outbox store the dispatcher claims rows from, when the durable
1005 /// (haematite) backend is in use. This is the SAME leaf `Arc<HaematiteStore>` the
1006 /// engine writes through, so the dispatcher shares its single
1007 /// `haematite::Connection` rather than opening a second contending one. Returns
1008 /// [`None`] for the in-memory backend, which has no outbox table.
1009 #[must_use]
1010 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
1011 self.inner.outbox_store.clone()
1012 }
1013
1014 /// Borrow the durable namespace registry shared by the control plane.
1015 ///
1016 /// This is the SAME concrete leaf backend the engine writes events through
1017 /// (haematite quorum-replicated, or in-memory local-only),
1018 /// captured as a [`NamespaceStore`] before the decorator chain wrapped it.
1019 /// Always present on every boot, so the mint-on-register path (Phase 1 S5)
1020 /// and `GET /namespaces` (S7) can reach a real registry regardless of
1021 /// backend.
1022 #[must_use]
1023 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
1024 &self.inner.namespace_store
1025 }
1026
1027 /// Borrow the durable worker-deployment store shared by the control plane.
1028 #[must_use]
1029 pub fn worker_deployment_store(&self) -> &Arc<dyn WorkerDeploymentStore> {
1030 &self.inner.worker_deployment_store
1031 }
1032
1033 /// Borrow the managed-worker supervisor.
1034 ///
1035 /// Built over the SAME durable deployment store as
1036 /// [`Self::worker_deployment_store`], so desired state written through the
1037 /// API is the desired state the supervisor converges on. Uncommissioned
1038 /// until [`crate::run`] installs an operator policy.
1039 #[must_use]
1040 pub fn worker_supervisor(&self) -> &Arc<WorkerSupervisor> {
1041 &self.inner.worker_supervisor
1042 }
1043
1044 /// Build the shared minted-on-use hook over the durable namespace store and
1045 /// the configured [`AutoCreate`](crate::config::AutoCreate) policy.
1046 ///
1047 /// This is the SAME policy logic the worker-registration seam applies (S5);
1048 /// the workflow-start safety net (S6) calls it after authorization so a
1049 /// client that starts a workflow before any worker registers still gets a
1050 /// durable namespace record. Cheap to build (clones an `Arc` + a `Copy`
1051 /// policy), so transports construct it per request rather than holding it.
1052 #[must_use]
1053 pub fn namespace_minter(&self) -> NamespaceMinter {
1054 let minter = NamespaceMinter::new(
1055 Arc::clone(&self.inner.namespace_store),
1056 self.inner.runtime.auto_create,
1057 )
1058 // Thread the deployment-global cluster channel so the start-time safety
1059 // net (S6) and the explicit `POST /namespaces` path (S7) emit the same
1060 // live "namespace created" delta the worker-mint seam (S5) does — all
1061 // three mint choke-points surface on the one ops-console push channel.
1062 .with_cluster_publisher(self.inner.cluster_publisher.clone());
1063 // And the namespace-mint routing context on a clustered boot, so a
1064 // namespace whose registry shard this node does not own is minted by the
1065 // node that does rather than being fenced forever. `None` off-cluster,
1066 // where the minter is byte-identical to before routing existed.
1067 match self.namespace_routing() {
1068 Some(routing) => minter.with_routing(routing),
1069 None => minter,
1070 }
1071 }
1072
1073 /// The namespace-mint routing context for this boot, or `None` when there is
1074 /// nothing to route to.
1075 ///
1076 /// Present only when ALL THREE handles exist: the distributed store (which
1077 /// hashes a namespace to its registry shard), the R-2 shard directory (which
1078 /// resolves that shard's current owner), and the R-3 request forwarder (which
1079 /// dials it). Those three are populated together by `build_routing_state` on
1080 /// a `[store.cluster]` boot and are all `None` otherwise, so a partial
1081 /// context can never arise — but each is checked rather than assumed.
1082 #[must_use]
1083 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1084 build_namespace_routing(
1085 self.cluster_store(),
1086 self.shard_directory(),
1087 self.request_forwarder(),
1088 )
1089 }
1090
1091 /// Clone the advisory outbox wake (LSUB-2) shared with the engine's stage
1092 /// seam. The outbox dispatcher installs this handle so a committed fan-out row
1093 /// wakes its run loop in ~RTT rather than waiting for the next poll tick. The
1094 /// handle is always present; it is simply never pulsed when the outbox is not
1095 /// commissioned, so wiring it is free and behaviour is unchanged.
1096 #[must_use]
1097 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
1098 Arc::clone(&self.inner.outbox_wake)
1099 }
1100
1101 /// Whether this server is a node in a distributed haematite cluster.
1102 ///
1103 /// `true` when boot constructed the distributed backend (a `[store.cluster]`
1104 /// section was present) and is holding its inbound-write responder alive;
1105 /// `false` for every single-node / non-haematite boot.
1106 #[must_use]
1107 pub fn is_clustered(&self) -> bool {
1108 self.inner.cluster_responder.is_some()
1109 }
1110
1111 /// The concrete distributed haematite store the request-routing edge consults
1112 /// for shard ownership (`shard_for_workflow` / `owns_workflow_shard`) and
1113 /// unsteered-start remint. `None` for every single-node / non-clustered boot,
1114 /// so the routing pre-step is a no-op and the default path is unchanged.
1115 #[must_use]
1116 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
1117 self.inner.cluster_store.as_ref()
1118 }
1119
1120 /// The request-routing shard directory (R-2) the edge consults to resolve a
1121 /// non-owned shard's owner. `None` for single-node / non-clustered boots, so
1122 /// the edge falls back to the bare R-1 ownership check.
1123 #[must_use]
1124 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
1125 self.inner.shard_directory.as_ref()
1126 }
1127
1128 /// The R-3 request forwarder used to relay a non-local signal/query/cancel to
1129 /// the shard owner. `None` for single-node / non-clustered boots.
1130 #[must_use]
1131 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
1132 self.inner.request_forwarder.as_ref()
1133 }
1134
1135 /// Spawn the worker heartbeat expiry sweeper (#176): the production driver
1136 /// of [`HeartbeatTracker::fail_expired_workers`], failing every worker with
1137 /// an in-flight task beyond the operator's `worker.heartbeat_window` and
1138 /// deregistering it with the provable
1139 /// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout).
1140 ///
1141 /// Always spawned on the server boot path — dead-worker detection is a
1142 /// liveness correctness property, not an opt-in feature. The cadence is
1143 /// derived from the heartbeat window
1144 /// ([`sweep_interval`](crate::worker::sweep_interval): a quarter of the
1145 /// window clamped to `[1s, window]`, so the default 30s window sweeps every
1146 /// 7.5s); there is deliberately no separate config knob. The task exits
1147 /// when `shutdown` flips to `true`, exactly like the transports; the
1148 /// returned handle may be dropped to detach it (dropping a tokio
1149 /// `JoinHandle` never cancels the task) and is returned so tests can await
1150 /// clean shutdown.
1151 #[must_use]
1152 pub fn spawn_heartbeat_sweeper(
1153 &self,
1154 shutdown: tokio::sync::watch::Receiver<bool>,
1155 ) -> tokio::task::JoinHandle<()> {
1156 let sweeper = crate::worker::HeartbeatSweeper::new(
1157 self.inner.heartbeat_tracker.clone(),
1158 self.inner.worker_registry.clone(),
1159 self.inner.pending_activities.clone(),
1160 self.inner.drain_state.clone(),
1161 self.inner.runtime.worker.heartbeat_window,
1162 )
1163 // The SAME queue-service state the bridge parks dispatches into, so a
1164 // deregistration names how many dispatches are already stranded on the
1165 // queue the dead worker was serving.
1166 .with_queue_state(self.inner.queue_service_state.clone());
1167 tokio::spawn(sweeper.run(shutdown))
1168 }
1169
1170 /// Spawn the startup catch-up legs — owed timer fires, schedule-
1171 /// coordinator catch-up, schedule recovery — behind already-open doors.
1172 ///
1173 /// [`boot_engine`] runs only the workflow-residency recovery leg before
1174 /// the transports bind, because the catch-up backlog has no upper bound
1175 /// (an estate watcher down for hours owes thousands of fires) and a boot
1176 /// that blocks on it keeps every door shut for the whole sweep. Every
1177 /// catch-up fire is the same idempotent record-once delivery the live
1178 /// timer wheel performs against a serving engine, so running it
1179 /// concurrently with the transports is the steady-state contract.
1180 ///
1181 /// The task stops when `shutdown` flips: catch-up left unfinished at
1182 /// shutdown is exactly a boot interrupted mid-sweep, and the next boot's
1183 /// sweep re-derives the remaining owed fires from durable state. A
1184 /// catch-up error is reported with its remedy and does NOT kill the
1185 /// serving process — the doors stay open, and a restart re-runs the
1186 /// sweep from durable state.
1187 ///
1188 /// # Errors
1189 ///
1190 /// Returns [`ServerError`] when the state holds no engine handle (a state
1191 /// built from parts without an engine) — such a state also never deferred
1192 /// any recovery, so there is nothing to catch up.
1193 pub fn spawn_startup_catchup(
1194 &self,
1195 mut shutdown: tokio::sync::watch::Receiver<bool>,
1196 ) -> Result<tokio::task::JoinHandle<()>, ServerError> {
1197 let engine = self.engine()?;
1198 Ok(tokio::spawn(async move {
1199 tracing::info!(
1200 "startup catch-up running behind open doors: owed timer fires, \
1201 schedule catch-up"
1202 );
1203 let catchup = engine.run_startup_catchup();
1204 tokio::pin!(catchup);
1205 tokio::select! {
1206 result = &mut catchup => match result {
1207 Ok(()) => {
1208 tracing::info!("startup catch-up complete");
1209 }
1210 Err(error) => {
1211 tracing::error!(
1212 %error,
1213 "startup catch-up failed; owed timer fires and schedule \
1214 catch-up remain undelivered — restart the server to re-run \
1215 the sweep from durable state"
1216 );
1217 }
1218 },
1219 _ = shutdown.changed() => {
1220 tracing::info!(
1221 "startup catch-up interrupted by shutdown; the next boot's \
1222 sweep resumes from durable state"
1223 );
1224 }
1225 }
1226 }))
1227 }
1228
1229 /// Spawn the liminal connection dead-man switch (the liveness probe) over
1230 /// `notifier`.
1231 ///
1232 /// Always spawned on a boot that hosts the liminal worker listener:
1233 /// connection liveness is a correctness property of the transport, not an
1234 /// opt-in feature. Both timings derive from the operator's
1235 /// `worker.heartbeat_window` — see
1236 /// [`LivenessProbe`](crate::worker::LivenessProbe) — so there is no separate
1237 /// knob. The task exits when `shutdown` flips to `true`, exactly like the
1238 /// heartbeat sweeper and the transports.
1239 #[cfg(feature = "liminal-transport")]
1240 #[must_use]
1241 pub fn spawn_liminal_liveness_probe(
1242 &self,
1243 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1244 shutdown: tokio::sync::watch::Receiver<bool>,
1245 ) -> tokio::task::JoinHandle<()> {
1246 let probe = crate::worker::LivenessProbe::across_transports(
1247 Some(notifier),
1248 // #197: the SAME probe covers gRPC-delivered workers. One probe,
1249 // one probation, one eligibility set — two probes would each
1250 // publish a whole verdict over the other's, because publication is
1251 // a replacement rather than a merge.
1252 Some(self.inner.grpc_liveness_waiters.clone()),
1253 self.inner.heartbeat_tracker.clone(),
1254 self.inner.worker_registry.clone(),
1255 self.inner.runtime.worker.heartbeat_window,
1256 );
1257 tokio::spawn(probe.run(shutdown))
1258 }
1259
1260 /// Spawn the SS-5b cluster supervisor: a background task that watches every
1261 /// declared peer's replication liveness and, on a confirmed peer death,
1262 /// calls `adopt_shards` for that peer's shards on THIS node's live engine —
1263 /// automatic failover with no manual trigger.
1264 ///
1265 /// Does nothing (returns `Ok(())` without spawning) unless this is a
1266 /// distributed boot whose cluster config declared at least one peer with
1267 /// `owned_shards`. A single-node / non-clustered server therefore never runs
1268 /// a supervisor, so default behaviour is unchanged.
1269 ///
1270 /// The spawned task drains on `shutdown` exactly like the transports.
1271 ///
1272 /// # Errors
1273 ///
1274 /// Returns [`ServerError`] when the engine handle cannot be resolved.
1275 pub fn spawn_cluster_supervisor(
1276 &self,
1277 config: crate::cluster::SupervisorConfig,
1278 shutdown: tokio::sync::watch::Receiver<bool>,
1279 ) -> Result<bool, ServerError> {
1280 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1281 return Ok(false);
1282 };
1283 if self.inner.watched_peers.is_empty() {
1284 return Ok(false);
1285 }
1286 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1287 // WS3: feed cluster topology deltas from the supervisor's existing
1288 // decision points into the ops console channel. `self_node` is the
1289 // configured distribution name (already captured for the snapshot).
1290 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1291 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1292 // #253: adoption re-runs the terminal-workflow outbox settlement sweep
1293 // over the widened owned-shard scope, so a dead peer's stranded row for
1294 // a terminal workflow is settled — never re-armed — by its adopter.
1295 // With no outbox commissioned there is nothing to settle and the
1296 // adopter delegates straight to the engine.
1297 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1298 engine,
1299 self.inner.outbox_store.clone(),
1300 ));
1301 let supervisor = crate::cluster::ClusterSupervisor::new(
1302 cluster_store,
1303 adopter,
1304 self.inner.watched_peers.clone(),
1305 config,
1306 )
1307 .with_publisher(publisher, self_node);
1308 if !supervisor.watches_any() {
1309 return Ok(false);
1310 }
1311 tokio::spawn(supervisor.run(shutdown));
1312 Ok(true)
1313 }
1314
1315 /// Borrow the shared JWKS cache when authentication is enabled.
1316 #[cfg(feature = "auth")]
1317 #[must_use]
1318 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1319 self.inner.jwks_cache.as_ref()
1320 }
1321
1322 /// Shut down the embedded engine so in-flight durable appends can finish.
1323 ///
1324 /// # Errors
1325 ///
1326 /// Returns [`ServerError`] if the namespace resolver has no engine handle or the engine rejects
1327 /// shutdown.
1328 pub fn shutdown(&self) -> Result<(), ServerError> {
1329 self.inner.namespace_guard.resolver().shutdown_engine()
1330 }
1331}
1332
1333#[cfg(feature = "auth")]
1334async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1335 if !runtime.auth.enabled {
1336 return Ok(None);
1337 }
1338 let Some(url) = runtime.auth.jwks_url.clone() else {
1339 return Err(ServerError::Config {
1340 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1341 });
1342 };
1343 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1344 let cache = JwksCache::new(url, interval)
1345 .await
1346 .map_err(|error| ServerError::Config {
1347 message: format!("auth jwks initial fetch failed: {error}"),
1348 })?;
1349 Ok(Some(cache))
1350}
1351
1352fn metrics_config_error(error: &MetricsError) -> ServerError {
1353 ServerError::Config {
1354 message: error.to_string(),
1355 }
1356}
1357
1358/// Borrowed inputs assembled into the embedded engine by [`boot_engine`].
1359struct EngineAssembly<'a> {
1360 /// The worker seams installed onto the engine before startup recovery runs.
1361 seams: &'a WorkerSeams,
1362 /// The metrics-instrumented store the engine writes through.
1363 instrumented_store: &'a Arc<InstrumentedEventStore>,
1364 /// Explicitly-sized broadcast channel capacity for `/events/stream`.
1365 event_broadcast_capacity: std::num::NonZeroUsize,
1366 /// Explicit workflow-query reply deadline for `/workflows/query`.
1367 query_timeout: std::time::Duration,
1368 /// The activity dispatcher (optionally dev-mock-decorated) the engine uses.
1369 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1370 /// The shared active-workflow registry server dispatchers correlate against.
1371 active_registry: Arc<aion::Registry>,
1372 /// Whether THIS node seeds the schedule coordinator (SS-2 ownership gate).
1373 bootstrap_coordinator: bool,
1374 /// Non-secret runtime settings driving scheduler/outbox/package/shard knobs.
1375 runtime: &'a RuntimeConfig,
1376}
1377
1378/// The search-attribute schema every server-embedded engine runs with.
1379///
1380/// The engine REFUSES an append carrying an unregistered attribute, and this is
1381/// the only place the server registers any. So every attribute name a server
1382/// writer records must appear here or the write it rides on fails outright — a
1383/// missing `display_name` registration does not lose the label, it fails every
1384/// named start (#211). Kept as its own function so that coupling is testable
1385/// against the actual writers rather than only observable at boot.
1386fn server_search_attribute_schema() -> Result<aion_core::SearchAttributeSchema, ServerError> {
1387 let mut schema = aion_core::SearchAttributeSchema::new();
1388 for (name, label) in [
1389 (crate::namespace::NAMESPACE_ATTRIBUTE, "namespace"),
1390 (crate::namespace::TASK_QUEUE_ATTRIBUTE, "task_queue"),
1391 (crate::namespace::DISPLAY_NAME_ATTRIBUTE, "display_name"),
1392 ] {
1393 schema
1394 .register(name, aion_core::SearchAttributeType::String)
1395 .map_err(|error| ServerError::Config {
1396 message: format!("failed to register {label} search attribute: {error}"),
1397 })?;
1398 }
1399 Ok(schema)
1400}
1401
1402/// Assemble the embedded engine from the server's runtime configuration and
1403/// bring it to serving state: build, install every engine-backed seam, then run
1404/// startup recovery — in that order, enforced here by construction rather than
1405/// by call-site discipline.
1406///
1407/// Factored out of [`ServerState::build_with_connected_store`]; it carries the
1408/// SS-2 wiring — the coordinator bootstrap gate fed from real ownership and the
1409/// `owned_shards` hook that drives both scoping and the per-shard election
1410/// before recovery.
1411async fn boot_engine(assembly: EngineAssembly<'_>) -> Result<Arc<aion::Engine>, ServerError> {
1412 let search_attribute_schema = server_search_attribute_schema()?;
1413 let runtime = assembly.runtime;
1414 let builder = EngineBuilder::new()
1415 .store_arc(assembly.instrumented_store.clone())
1416 .event_streaming(assembly.event_broadcast_capacity)
1417 .in_memory_visibility()
1418 .search_attribute_schema(search_attribute_schema)
1419 .scheduler_threads(runtime.scheduler_threads)
1420 .outbox_enabled(runtime.outbox.enabled)
1421 .activity_dispatcher(assembly.activity_dispatcher)
1422 .active_registry(assembly.active_registry)
1423 .production_recovery_seam()
1424 // #266: recovery replay re-dispatches every in-flight activity through
1425 // the dispatcher decorated above, and that dispatcher consults seams
1426 // (the declared-body source foremost) that are only installed once the
1427 // engine exists. Defer recovery out of `build()`;
1428 // `build_with_connected_store` runs it via `run_startup_recovery`
1429 // immediately after `install_engine_backed_seams`.
1430 .defer_startup_recovery()
1431 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1432 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1433 })
1434 .query_timeout(assembly.query_timeout)
1435 // SS-2: only the node owning the schedule-coordinator's shard seeds and
1436 // serves it. `true` for every non-distributed boot (owns all shards); a
1437 // distributed non-owner passes `false` so it does not fence the
1438 // coordinator stream (AA-4-4). Default `true`, so a single-node boot is
1439 // byte-identical to today.
1440 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1441 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1442 // Owned-shard assignment: when the operator pins this node to a shard subset,
1443 // scope the engine to it AND (SS-2) elect those shards before recovery — the
1444 // builder's `owned_shards` hook drives both. Empty (the default) leaves the
1445 // builder untouched, so single-node boot owns ALL shards, elects nothing, and
1446 // is byte-identical to today.
1447 let builder = if runtime.owned_shards.is_empty() {
1448 builder
1449 } else {
1450 builder.owned_shards(runtime.owned_shards.iter().copied())
1451 };
1452 // JIT compilation threshold: applied ONLY when the operator named one, so
1453 // absent stays absent all the way down to beamr rather than becoming a
1454 // value the server picked. Same shape as `owned_shards` above — an unset
1455 // knob leaves the builder untouched and the boot is byte-identical to a
1456 // tree without this field.
1457 let builder = match runtime.jit_threshold {
1458 Some(threshold) => builder.scheduler_jit_threshold(threshold),
1459 None => builder,
1460 };
1461 let engine = Arc::new(builder.build().await.map_err(ServerError::from)?);
1462 install_engine_backed_seams(assembly.seams, &engine, runtime.outbox.enabled);
1463 // #266: workflow recovery runs HERE — after every seam the decorated
1464 // activity dispatcher consults is installed, never before. The build
1465 // above deferred it; running it earlier re-dispatched adopted
1466 // in-flight declared-body activities into an empty body source, and
1467 // they parked forever on their own queue (the fleet e2e pins this).
1468 //
1469 // Instant doors: ONLY the workflow-residency leg blocks the boot. The
1470 // catch-up legs (owed timer fires, schedule catch-up) have no upper
1471 // bound — an estate watcher can owe thousands of fires — and every fire
1472 // goes through the same idempotent record-once path the live wheel
1473 // uses, so the run loop starts them behind already-open doors via
1474 // [`ServerState::spawn_startup_catchup`].
1475 engine
1476 .recover_workflows_on_startup()
1477 .await
1478 .map_err(ServerError::from)?;
1479 Ok(engine)
1480}
1481
1482/// Validate the two engine seams the server unconditionally mounts: the event
1483/// broadcast channel capacity (`/events/stream`) and the query reply deadline
1484/// (`/workflows/query`). Both are explicit-no-default — a mounted-but-
1485/// unconfigured surface is never acceptable.
1486fn required_engine_seams(
1487 runtime: &RuntimeConfig,
1488) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1489 let event_broadcast_capacity = runtime
1490 .websocket
1491 .event_broadcast_capacity
1492 .and_then(std::num::NonZeroUsize::new)
1493 .ok_or_else(|| ServerError::Config {
1494 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1495 })?;
1496 let query_timeout = runtime
1497 .query_timeout
1498 .filter(|timeout| !timeout.is_zero())
1499 .ok_or_else(|| ServerError::Config {
1500 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1501 })?;
1502 Ok((event_broadcast_capacity, query_timeout))
1503}
1504
1505/// Install the outbox delivery callback when the durable outbox is commissioned.
1506///
1507/// Routes unmatched worker completions arriving at the sink into the live
1508/// workflow's mailbox. Flag-off, no callback is installed and the sink's
1509/// unmatched branch stays a silent drop. The dispatcher is not rebuilt — it
1510/// shares this exact pending tracker.
1511fn install_outbox_delivery(
1512 pending_activities: &PendingActivities,
1513 engine: &Arc<aion::Engine>,
1514 outbox_enabled: bool,
1515) {
1516 if outbox_enabled {
1517 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1518 Arc::clone(engine),
1519 ));
1520 pending_activities.set_outbox_delivery(callback);
1521 }
1522}
1523
1524/// The per-boot worker-side seams every dispatch path shares.
1525struct WorkerSeams {
1526 worker_registry: ConnectedWorkerRegistry,
1527 pending_activities: PendingActivities,
1528 heartbeat_tracker: HeartbeatTracker,
1529 drain_state: DrainState,
1530 queue_declarations: crate::worker::QueueDeclarationSource,
1531 queue_service_state: crate::worker::QueueServiceState,
1532 declared_bodies: crate::worker::DeclaredBodySource,
1533 /// The declared bodies this server is EXECUTING, as opposed to the ones it
1534 /// can look up. The declared-body dispatcher registers each attempt here for
1535 /// the life of its command and the cancel path signals through it, so a
1536 /// cancelled run's server-run command is stopped rather than left holding
1537 /// the machine.
1538 declared_attempts: crate::worker::DeclaredCommandAttempts,
1539 /// The boot's one cluster-event publisher, kept here so the dispatchers
1540 /// built over these seams announce an unbounded park on the operator's
1541 /// real-time channel (#266 T4).
1542 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
1543}
1544
1545/// Build the worker-side seams: the connected-worker registry (WS3 topology
1546/// deltas + the Control-Plane Phase 1 mint hook), the shared completion tracker,
1547/// worker liveness, the drain gate, and the two R1 queue-service handles the
1548/// bridge publishes into and the state reads from.
1549fn build_worker_seams(
1550 runtime: &RuntimeConfig,
1551 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1552 namespace_store: &Arc<dyn NamespaceStore>,
1553 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1554 mint_routing: Option<crate::namespace::NamespaceRouting>,
1555) -> WorkerSeams {
1556 let worker_registry = ConnectedWorkerRegistry::default()
1557 .with_cluster_publisher(cluster_publisher.clone())
1558 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1559 .with_worker_deployment_store(worker_deployment_store.clone());
1560 // The worker-registration mint is the OTHER `mint_or_gate` choke-point, so
1561 // it gets the same routing the start seams get: a worker registering for a
1562 // namespace whose registry shard this node does not own must not be refused
1563 // `NotOwner` forever either. `None` off-cluster leaves the registry
1564 // byte-identical.
1565 let worker_registry = match mint_routing {
1566 Some(routing) => worker_registry.with_namespace_routing(routing),
1567 None => worker_registry,
1568 };
1569 WorkerSeams {
1570 worker_registry,
1571 // The transport-loss budget derives from the operator's heartbeat
1572 // window (the one value declaring what silence means), so a worker that
1573 // keeps dying under an activity is re-dispatched attempt-neutrally for
1574 // a bounded span and then terminates naming the TRANSPORT.
1575 pending_activities: PendingActivities::new(runtime.worker.heartbeat_window),
1576 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1577 drain_state: DrainState::default(),
1578 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1579 queue_service_state: crate::worker::QueueServiceState::default(),
1580 declared_bodies: crate::worker::DeclaredBodySource::default(),
1581 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
1582 cluster_publisher: cluster_publisher.clone(),
1583 }
1584}
1585
1586/// Point the bridge's R1 classifier at the engine's live workflow catalog.
1587///
1588/// Installed only after the engine exists (the dispatcher is built before it),
1589/// exactly like the outbox delivery callback: the dispatcher is not rebuilt, it
1590/// shares this handle. Until this runs — and on any state built without an
1591/// engine — the classifier answers `Unknown`, which never refuses anything.
1592fn install_queue_declarations(
1593 queue_declarations: &crate::worker::QueueDeclarationSource,
1594 engine: &Arc<aion::Engine>,
1595) {
1596 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1597 Arc::clone(engine),
1598 )));
1599}
1600
1601/// Point the declared-body executor at the engine's live workflow catalog.
1602///
1603/// Installed only after the engine exists, exactly like the queue-declaration
1604/// reader above: the dispatcher already holds a clone of this handle. Until
1605/// this runs, every lookup answers `None` and every dispatch takes the worker
1606/// path — and that window is why the boot defers startup recovery (#266):
1607/// deploys are durable, so a restarted server DOES have declared bodies its
1608/// recovered runs depend on before this install, and recovery replay
1609/// re-dispatching an adopted in-flight activity inside `EngineBuilder::build()`
1610/// fell through the uninstalled source to a queue with no pollers and parked
1611/// forever. [`boot_engine`] therefore runs `Engine::run_startup_recovery()`
1612/// only AFTER `install_engine_backed_seams`, enforced by construction.
1613/// The one dispatch source that legitimately precedes this install is a
1614/// fresh start arriving over the API — and the API is not serving yet.
1615fn install_declared_bodies(
1616 declared_bodies: &crate::worker::DeclaredBodySource,
1617 engine: &Arc<aion::Engine>,
1618) {
1619 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1620 Arc::clone(engine),
1621 )));
1622}
1623
1624/// Hand every engine-backed seam its reader once the engine exists.
1625///
1626/// One boot-path step for the three handles built before the engine and
1627/// filled in after it: outbox completion delivery, the R1 queue-declaration
1628/// classifier, and the declared-body executor.
1629fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1630 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1631 install_queue_declarations(&seams.queue_declarations, engine);
1632 install_declared_bodies(&seams.declared_bodies, engine);
1633}
1634
1635/// Decorate the worker activity dispatcher with the per-run activity-mock layer
1636/// when the dev surface is commissioned, returning the dispatcher and the shared
1637/// mock registry (if any).
1638///
1639/// Dark by default: with the dev surface off the engine gets the bare production
1640/// dispatcher and there is no mocking path at all (CN4).
1641/// Compose the engine-seam bridge dispatcher over the state's shared parts.
1642///
1643/// Also mints the NOI-6 attempt→owner index and returns it alongside: the
1644/// bridge binds each liminal-delivered attempt into it for the dispatch's
1645/// lifetime, and the state stores the SAME instance for the intervention
1646/// router to read, so the ops console can enumerate and target live attempts.
1647fn build_bridge_dispatcher(
1648 runtime: &RuntimeConfig,
1649 seams: &WorkerSeams,
1650) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1651 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1652 let dispatcher = WorkerActivityDispatcher::new(
1653 seams.worker_registry.clone(),
1654 runtime.default_namespace.clone(),
1655 seams.heartbeat_tracker.clone(),
1656 )
1657 .with_pending(seams.pending_activities.clone())
1658 .with_drain_state(seams.drain_state.clone())
1659 .with_tokio_handle(tokio::runtime::Handle::current())
1660 .with_attempt_owners(attempt_owners.clone())
1661 .with_queue_service(runtime.worker.queue_service.clone())
1662 .with_queue_declarations(seams.queue_declarations.clone())
1663 .with_queue_state(seams.queue_service_state.clone())
1664 .with_cluster_publisher(seams.cluster_publisher.clone());
1665 (dispatcher, attempt_owners)
1666}
1667
1668/// Build the process metrics, the LSUB-2 advisory outbox wake, and the
1669/// instrumented event store wired to pulse it — the storage-side trio the
1670/// full boot path assembles before the engine exists.
1671///
1672/// The wake is one process-wide `Notify` shared by the engine's stage seam
1673/// and the outbox dispatcher. A single handle is correct here because there
1674/// is exactly one in-process dispatcher that sweeps all owned shards per tick
1675/// — a wake just means "something was staged; sweep".
1676///
1677/// # Errors
1678///
1679/// Returns [`ServerError`] when the metrics registry cannot be constructed.
1680fn build_instrumented_store(
1681 runtime: &RuntimeConfig,
1682 event_store: Arc<dyn EventStore>,
1683) -> Result<
1684 (
1685 Metrics,
1686 Arc<tokio::sync::Notify>,
1687 Arc<InstrumentedEventStore>,
1688 ),
1689 ServerError,
1690> {
1691 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1692 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1693 let instrumented_store = Arc::new(
1694 InstrumentedEventStore::new(
1695 event_store,
1696 metrics.clone(),
1697 runtime.default_namespace.clone(),
1698 )
1699 .with_outbox_wake(Arc::clone(&outbox_wake)),
1700 );
1701 Ok((metrics, outbox_wake, instrumented_store))
1702}
1703
1704/// Build the production bridge dispatcher and its decoration stack in one
1705/// step: declared-body execution always, the update-check observer above it,
1706/// the dev mock when commissioned. Also mints the update-status slot the
1707/// observer writes, returned so the state serves the same slot.
1708fn build_decorated_dispatcher(
1709 runtime: &RuntimeConfig,
1710 seams: &WorkerSeams,
1711 transcript: crate::activity_publisher::ActivityEventPublisher,
1712) -> (
1713 Arc<dyn ActivityDispatcher>,
1714 Option<ActivityMockRegistry>,
1715 crate::worker::AttemptOwnerIndex,
1716 crate::worker::WorkspaceRoot,
1717 crate::update_check::UpdateStatusState,
1718) {
1719 // #139: THE one resolution of the declared-body workspace root. The
1720 // dispatcher expands `{workspace_root}` with it and the returned value is
1721 // what the state exposes for the startup banner; nothing else derives it.
1722 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1723 // #189 slice one: the update-status slot is created beside the observer
1724 // that writes it and returned so the state serves the SAME slot.
1725 let update_status = crate::update_check::UpdateStatusState::default();
1726 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1727 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1728 dispatcher,
1729 seams.declared_bodies.clone(),
1730 seams.declared_attempts.clone(),
1731 workspace_root.clone(),
1732 transcript,
1733 update_status.clone(),
1734 runtime.dev.enabled,
1735 );
1736 (
1737 activity_dispatcher,
1738 activity_mock_registry,
1739 attempt_owners,
1740 workspace_root,
1741 update_status,
1742 )
1743}
1744
1745fn decorate_activity_dispatcher(
1746 dispatcher: WorkerActivityDispatcher,
1747 declared_bodies: crate::worker::DeclaredBodySource,
1748 declared_attempts: crate::worker::DeclaredCommandAttempts,
1749 workspace_root: crate::worker::WorkspaceRoot,
1750 transcript: crate::activity_publisher::ActivityEventPublisher,
1751 update_status: crate::update_check::UpdateStatusState,
1752 dev_enabled: bool,
1753) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1754 // The declared-body layer wraps the production dispatcher UNCONDITIONALLY:
1755 // an action whose deployed contract declares a body executes at the
1756 // server, everything else falls through to the worker path untouched. The
1757 // update-check observer wraps THAT layer so it sees each completed
1758 // declared execution's result (it records completed checks and touches
1759 // nothing else). The dev mock (when commissioned) stays outermost so a
1760 // mocked activity short-circuits before either real execution path.
1761 let declared = crate::worker::DeclaredCommandDispatcher::new(
1762 Arc::new(dispatcher),
1763 declared_bodies.clone(),
1764 declared_attempts,
1765 tokio::runtime::Handle::current(),
1766 workspace_root,
1767 transcript,
1768 );
1769 let observed = crate::update_check::UpdateCheckObserver::new(
1770 Arc::new(declared),
1771 declared_bodies,
1772 update_status,
1773 );
1774 if dev_enabled {
1775 let registry = ActivityMockRegistry::new();
1776 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
1777 (Arc::new(decorated), Some(registry))
1778 } else {
1779 (Arc::new(observed), None)
1780 }
1781}
1782
1783/// Validate the WS3 cluster broadcast capacity the server unconditionally mounts
1784/// (the `cluster` subscription on `/events/stream`). Explicit-no-default with the
1785/// same non-zero startup guard as the workflow event channel: the lag contract
1786/// has no buffer to lag against unless sized.
1787fn required_cluster_broadcast_capacity(
1788 runtime: &RuntimeConfig,
1789) -> Result<std::num::NonZeroUsize, ServerError> {
1790 runtime
1791 .websocket
1792 .cluster_broadcast_capacity
1793 .and_then(std::num::NonZeroUsize::new)
1794 .ok_or_else(|| ServerError::Config {
1795 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1796 })
1797}
1798
1799/// Build the deployment-wide real-time publishers the server mounts on every
1800/// boot — the WS3 cluster topology channel and the NOI-5b agent-observability
1801/// transcript channel — from the validated `websocket.cluster_broadcast_capacity`.
1802///
1803/// The transcript sequencer runs over `observability_store` (the durable
1804/// `O`-keyspace impl on a haematite boot) or an in-memory impl when the backend
1805/// has none — see [`build_transcript_publisher`].
1806///
1807/// # Errors
1808///
1809/// Returns [`ServerError`] when `websocket.cluster_broadcast_capacity` is unset
1810/// or zero (the same explicit-no-default guard the cluster channel already had).
1811fn build_real_time_publishers(
1812 runtime: &RuntimeConfig,
1813 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1814) -> Result<
1815 (
1816 crate::cluster_publisher::ClusterEventPublisher,
1817 crate::activity_publisher::ActivityEventPublisher,
1818 ),
1819 ServerError,
1820> {
1821 let capacity = required_cluster_broadcast_capacity(runtime)?;
1822 let batch = required_transcript_batch_policy(runtime)?;
1823 Ok((
1824 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1825 build_transcript_publisher(
1826 observability_store,
1827 capacity,
1828 transcript_bounds(runtime),
1829 batch,
1830 ),
1831 ))
1832}
1833
1834/// The operator's transcript flush policy, or a startup refusal naming the key
1835/// they have not ruled on.
1836///
1837/// `observability.max_batch_events` and `observability.max_batch_hold_ms` have
1838/// NO shipped default (see [`crate::config::ObservabilityConfig`]): the trade
1839/// between store cost and how much not-yet-durable transcript a crash can lose
1840/// belongs to the deployment, and a guessed value would make it silently. This
1841/// is the same explicit-no-default guard [`required_cluster_broadcast_capacity`]
1842/// applies one line above, for the same reason.
1843///
1844/// # Errors
1845///
1846/// Returns [`ServerError`] when either key is unset, when `max_batch_events` is
1847/// zero (a batch of no events would commit nothing, forever), or when the hold
1848/// in milliseconds does not fit a [`Duration`](std::time::Duration).
1849/// The operator's node-cache byte budget, or a refusal that names the key.
1850///
1851/// There is no default to fall back to — not here, and not in haematite, which
1852/// refuses a `DatabaseConfig` that does not carry one. A node is not a
1853/// fixed-size thing, so a cache bounded only by its entry count is unbounded in
1854/// BYTES, and how much resident memory this process may spend on cached nodes is
1855/// a deployment decision. `"unlimited"` is available as an explicit choice, so
1856/// the pre-budget behaviour is still reachable — by NAME, visible in a diff.
1857///
1858/// # Errors
1859///
1860/// Returns [`ServerError::Config`] carrying
1861/// [`STORE_NODE_CACHE_BUDGET_REQUIRED`](crate::config::STORE_NODE_CACHE_BUDGET_REQUIRED)
1862/// when `store.node_cache_budget` is unset.
1863fn required_node_cache_budget(
1864 config: &StoreConfig,
1865) -> Result<haematite::NodeCacheBudget, ServerError> {
1866 config.node_cache_budget.ok_or_else(|| ServerError::Config {
1867 message: crate::config::STORE_NODE_CACHE_BUDGET_REQUIRED.to_owned(),
1868 })
1869}
1870
1871/// The lock-acquisition keys are RETIRED (ruled 2026-08-24): the writer lock
1872/// is a kernel-released flock, so its holder is either live mid-handover
1873/// (boot now waits indefinitely, reporting while it waits) or dead (the
1874/// kernel already released it). A configuration still carrying the keys
1875/// stays legal — warned and ignored, never refused — so existing estates
1876/// boot unchanged.
1877fn warn_retired_lock_acquisition_keys(config: &StoreConfig) {
1878 if config.lock_acquisition_patience_ms.is_some()
1879 || config.lock_acquisition_retry_cadence_ms.is_some()
1880 {
1881 tracing::warn!(
1882 "store.lock_acquisition_patience_ms and store.lock_acquisition_retry_cadence_ms \
1883 are retired and ignored: the server waits indefinitely for the data-directory \
1884 writer lock (a kernel-released flock whose holder is either live mid-handover \
1885 or already gone) and reports while it waits. Remove the keys"
1886 );
1887 }
1888}
1889
1890fn required_transcript_batch_policy(
1891 runtime: &RuntimeConfig,
1892) -> Result<crate::activity_publisher::TranscriptBatchPolicy, ServerError> {
1893 let max_batch_events = runtime
1894 .observability
1895 .max_batch_events
1896 .and_then(std::num::NonZeroUsize::new)
1897 .ok_or_else(|| ServerError::Config {
1898 message: crate::config::OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED.to_owned(),
1899 })?;
1900 // Zero is a MEANINGFUL setting here (never hold a partial batch open), so
1901 // absence — not zero — is what fails.
1902 let max_batch_hold_ms =
1903 runtime
1904 .observability
1905 .max_batch_hold_ms
1906 .ok_or_else(|| ServerError::Config {
1907 message: crate::config::OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED.to_owned(),
1908 })?;
1909 Ok(crate::activity_publisher::TranscriptBatchPolicy {
1910 max_batch_events,
1911 max_hold: std::time::Duration::from_millis(max_batch_hold_ms),
1912 })
1913}
1914
1915/// The operator-configured transcript retention bounds from `[observability]`.
1916fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1917 crate::activity_bounds::TranscriptBounds {
1918 max_event_bytes: runtime.observability.max_event_bytes,
1919 max_stream_events: runtime.observability.max_stream_events,
1920 }
1921}
1922
1923/// Build the NOI-5b transcript sequencer over `observability_store` (the durable
1924/// `O`-keyspace impl when the backend has one, an in-memory impl otherwise) with
1925/// a live-tail buffer of `capacity` and the `[observability]` retention bounds.
1926///
1927/// The publisher is ALWAYS constructed (the transcript channel is served on every
1928/// boot); only the durability of the backing store varies by backend. The memory
1929/// backend, which has no `O` keyspace, gets the in-memory
1930/// [`InMemoryObservabilityStore`](aion_store::InMemoryObservabilityStore), so the
1931/// live-tail + resume path behaves identically and only cross-restart durability
1932/// differs — exactly the "keep the no-observability path uniform" contract.
1933fn build_transcript_publisher(
1934 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1935 capacity: std::num::NonZeroUsize,
1936 bounds: crate::activity_bounds::TranscriptBounds,
1937 batch: crate::activity_publisher::TranscriptBatchPolicy,
1938) -> crate::activity_publisher::ActivityEventPublisher {
1939 let store = observability_store
1940 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1941 crate::activity_publisher::ActivityEventPublisher::new(store, capacity, batch)
1942 .with_bounds(bounds)
1943}
1944
1945/// The request-routing pieces built from the cluster store + peer config.
1946struct RoutingState {
1947 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1948 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1949 /// The namespace-mint routing context assembled from the two handles above
1950 /// plus the cluster store, so the boot path can thread it into the worker
1951 /// registry's minter (the second of the two minter construction sites)
1952 /// without re-deriving it.
1953 mint_routing: Option<crate::namespace::NamespaceRouting>,
1954}
1955
1956/// Build the R-2 shard directory and R-3 request forwarder over the cluster
1957/// store and static peer config, or all-`None` when this is not a distributed
1958/// boot (no cluster store) so the routing edge is a no-op (default path).
1959fn build_routing_state(
1960 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1961 directory_peers: Vec<crate::routing::DirectoryPeer>,
1962 self_node_id: Option<String>,
1963) -> RoutingState {
1964 let Some(store) = cluster_store else {
1965 return RoutingState {
1966 shard_directory: None,
1967 request_forwarder: None,
1968 mint_routing: None,
1969 };
1970 };
1971 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1972 Arc::clone(store),
1973 directory_peers,
1974 self_node_id,
1975 ));
1976 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1977 Arc::new(crate::routing::GrpcRequestForwarder::new());
1978 let mint_routing = build_namespace_routing(
1979 Some(store),
1980 Some(&shard_directory),
1981 Some(&request_forwarder),
1982 );
1983 RoutingState {
1984 shard_directory: Some(shard_directory),
1985 request_forwarder: Some(request_forwarder),
1986 mint_routing,
1987 }
1988}
1989
1990/// Assemble the namespace-mint routing context from the three handles a
1991/// distributed boot produces, or `None` when any is absent.
1992///
1993/// The single place the context is built, shared by the boot path (which wires
1994/// it into the worker registry's minter before the state exists) and
1995/// [`ServerState::namespace_routing`] (which serves the per-request minters).
1996/// Each handle is checked rather than assumed present: `build_routing_state`
1997/// populates them together, but a partial context would silently route mints to
1998/// nowhere.
1999fn build_namespace_routing(
2000 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
2001 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
2002 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
2003) -> Option<crate::namespace::NamespaceRouting> {
2004 use crate::namespace::{
2005 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
2006 };
2007 let store = Arc::clone(cluster_store?);
2008 let directory = Arc::clone(shard_directory?);
2009 let shards: Arc<dyn NamespaceShardResolver> = store;
2010 let owners: Arc<dyn MintShardOwners> = directory;
2011 let forwarder: Arc<dyn MintForwarder> =
2012 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
2013 Some(NamespaceRouting::new(shards, owners, forwarder))
2014}
2015
2016/// A connected durable store plus the lifecycle pieces the boot path needs.
2017///
2018/// `outbox_store` is the SAME leaf store cast as an [`OutboxStore`] on the
2019/// haematite backend, which is the one that has a durable outbox; the in-memory
2020/// backend yields `None`. `bootstrap_coordinator` gates the schedule-coordinator seed on real
2021/// ownership (SS-2 / AA-4-4): `true` for every non-distributed boot (single-node
2022/// owns the coordinator's shard), and for a distributed node only when it owns
2023/// that shard. `cluster_responder` owns the distributed inbound-write responder
2024/// thread, kept alive for the server's lifetime; `None` for non-distributed boots.
2025struct ConnectedStore {
2026 event_store: Arc<dyn EventStore>,
2027 outbox_store: Option<Arc<dyn OutboxStore>>,
2028 /// The SAME concrete leaf store as `event_store`, captured as a
2029 /// [`NamespaceStore`] before the decorator chain wraps it (the decorators
2030 /// are `NamespaceStore`-unaware). The control plane mints and lists through
2031 /// this handle. Every backend populates it: distributed haematite supplies
2032 /// the quorum-replicated implementation, single-node haematite and the
2033 /// in-memory store the local-only one.
2034 namespace_store: Arc<dyn NamespaceStore>,
2035 /// Same concrete leaf captured as the deployment-store contract.
2036 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2037 /// NOI-5b: the SAME concrete leaf store captured as an
2038 /// [`ObservabilityStore`](aion_store::ObservabilityStore) when the backend
2039 /// implements the durable `O` keyspace (haematite). `None` for backends with
2040 /// no `O` keyspace (haematite / in-memory), where the transcript sequencer runs
2041 /// over an in-memory impl instead. Captured before the leaf is wrapped in the
2042 /// (`ObservabilityStore`-unaware) decorator chain, exactly like
2043 /// `namespace_store`.
2044 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
2045 bootstrap_coordinator: bool,
2046 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
2047 /// The concrete distributed haematite store (the SAME leaf as `event_store`),
2048 /// retained for the SS-5b cluster supervisor's peer-liveness polling. `None`
2049 /// for every non-distributed boot.
2050 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
2051 /// The peers the SS-5b supervisor watches, each with the shards this node
2052 /// adopts on its death. Empty for non-distributed boots.
2053 watched_peers: Vec<crate::cluster::WatchedPeer>,
2054 /// The static shard-directory peer entries (name + declared shards + gRPC
2055 /// forward address) used to build the request-routing directory (R-2). Empty
2056 /// for non-distributed boots.
2057 directory_peers: Vec<crate::routing::DirectoryPeer>,
2058 /// This node's own distribution name (cluster `node_id`), so the SS-3
2059 /// directory can resolve a shard-owner record naming THIS node to `Local`.
2060 /// `None` for non-distributed boots.
2061 self_node_id: Option<String>,
2062}
2063
2064impl ConnectedStore {
2065 /// A non-distributed connected store: owns the coordinator's shard (so it
2066 /// bootstraps the coordinator) and has no cluster responder.
2067 ///
2068 /// `namespace_store` is the SAME concrete leaf as `event_store`, captured as
2069 /// a [`NamespaceStore`] by the caller (where the concrete type is still
2070 /// known) before the decorator chain wraps the event store.
2071 fn local(
2072 event_store: Arc<dyn EventStore>,
2073 outbox_store: Option<Arc<dyn OutboxStore>>,
2074 namespace_store: Arc<dyn NamespaceStore>,
2075 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2076 ) -> Self {
2077 Self {
2078 event_store,
2079 outbox_store,
2080 namespace_store,
2081 worker_deployment_store,
2082 // `local` is the memory backend and the embedder's own-store path.
2083 // A haematite boot never arrives here — `connect_store` routes it to
2084 // `connect_haematite_store`, which sets `observability_store`. So a
2085 // `local` store has no durable `O` keyspace and the transcript
2086 // sequencer falls back to an in-memory impl (NOI-5b).
2087 observability_store: None,
2088 bootstrap_coordinator: true,
2089 cluster_responder: None,
2090 cluster_store: None,
2091 watched_peers: Vec::new(),
2092 directory_peers: Vec::new(),
2093 self_node_id: None,
2094 }
2095 }
2096
2097 /// Capture this node's self-identity for the cluster snapshot before the
2098 /// distributed boot moves it into routing state.
2099 fn cluster_self_node(&self) -> Option<String> {
2100 self.self_node_id.clone()
2101 }
2102}
2103
2104/// Connect the selected store. Haematite supplies both durable events and the
2105/// durable outbox; the in-memory development backend has no outbox.
2106async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
2107 match config.backend {
2108 StoreBackend::Memory => {
2109 // One leaf store, captured as both the engine's event store and the
2110 // namespace registry (in-memory backends have no outbox table).
2111 let leaf = Arc::new(aion_store::InMemoryStore::default());
2112 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2113 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2114 Ok(ConnectedStore::local(
2115 leaf,
2116 None,
2117 namespace_store,
2118 worker_deployment_store,
2119 ))
2120 }
2121 StoreBackend::Haematite => connect_haematite_store(config).await,
2122 }
2123}
2124
2125/// Connect the haematite backend, opening the on-disk database if `store.data_dir`
2126/// already holds one and otherwise creating it with `store.shard_count` shards.
2127///
2128/// Without a `[store.cluster]` section this is the SINGLE-NODE path
2129/// ([`HaematiteStore::open`] / [`create_with_shard_count`]), byte-identical to
2130/// before: no endpoint, no election, owns everything, bootstraps the coordinator.
2131/// With a cluster section this is the DISTRIBUTED path
2132/// ([`HaematiteStore::open_or_create_distributed`]): it binds the replication
2133/// endpoint, builds the quorum membership, dials peers, starts the responder, and
2134/// computes whether THIS node owns the schedule-coordinator's shard so the engine
2135/// boot path seeds the coordinator on exactly one owner cluster-wide (SS-2).
2136///
2137/// The SAME leaf `Arc<HaematiteStore>` is shared as both the engine's
2138/// [`EventStore`] and the dispatcher's [`OutboxStore`] (one inner haematite
2139/// database), mirroring the haematite backend.
2140///
2141/// [`HaematiteStore::open`]: aion_store_haematite::HaematiteStore::open
2142/// [`create_with_shard_count`]: aion_store_haematite::HaematiteStore::create_with_shard_count
2143/// [`HaematiteStore::open_or_create_distributed`]: aion_store_haematite::HaematiteStore::open_or_create_distributed
2144async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
2145 // Required HERE, at the only seam that consumes it: haematite is the only
2146 // backend with a node cache, so a `memory` deployment is never asked to
2147 // rule on a cache it does not have. Same explicit-no-default guard
2148 // `observability.max_batch_events` uses, at the same kind of seam. Read
2149 // before `config` is partially moved below.
2150 let node_cache_budget = required_node_cache_budget(&config)?;
2151 warn_retired_lock_acquisition_keys(&config);
2152 let Some(data_dir) = config.data_dir else {
2153 return Err(ServerError::Config {
2154 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
2155 });
2156 };
2157 let shard_count = config.shard_count;
2158 let owned_shards = config.owned_shards.clone();
2159 let cluster = config.cluster.clone();
2160 // The peers the SS-5b supervisor watches, captured before `cluster` is moved
2161 // into the blocking build. A peer with declared `owned_shards` becomes a
2162 // watch target; peers without are kept out of the watch set (the supervisor
2163 // would have nothing to adopt for them).
2164 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
2165 .as_ref()
2166 .map(|cluster| {
2167 cluster
2168 .peers
2169 .iter()
2170 .map(|peer| crate::cluster::WatchedPeer {
2171 name: peer.name.clone(),
2172 owned_shards: peer.owned_shards.clone(),
2173 })
2174 .collect()
2175 })
2176 .unwrap_or_default();
2177 // The static shard-directory entries (R-2): each peer's declared shards plus
2178 // its gRPC forward address. Built from the same config the supervisor uses.
2179 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
2180 .as_ref()
2181 .map(|cluster| {
2182 cluster
2183 .peers
2184 .iter()
2185 .map(|peer| crate::routing::DirectoryPeer {
2186 name: peer.name.clone(),
2187 owned_shards: peer.owned_shards.clone(),
2188 grpc_addr: peer.grpc_address,
2189 })
2190 .collect()
2191 })
2192 .unwrap_or_default();
2193 // This node's own distribution name, so the SS-3 directory resolves a
2194 // shard-owner record naming THIS node to `Local`.
2195 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2196 // Construction (and, for the distributed path, the off-runtime endpoint bind)
2197 // must not stall the async runtime, so run it on the blocking pool. The
2198 // distributed constructor itself steps onto a bare thread for the bind.
2199 let (store, responder) = tokio::task::spawn_blocking(move || {
2200 build_haematite_store(&data_dir, shard_count, cluster, node_cache_budget)
2201 })
2202 .await
2203 .map_err(|error| ServerError::Config {
2204 message: format!("haematite store initialization task failed: {error}"),
2205 })??;
2206
2207 // Gate the coordinator bootstrap on real ownership: a distributed node that
2208 // does NOT own the coordinator's shard must not seed/fence it (AA-4-4). A
2209 // single-node boot owns all shards, so it always bootstraps.
2210 let bootstrap_coordinator = if owned_shards.is_empty() {
2211 true
2212 } else {
2213 store.set_owned_shards(owned_shards.iter().copied());
2214 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2215 };
2216
2217 let leaf = Arc::new(store);
2218 let event_store: Arc<dyn EventStore> = leaf.clone();
2219 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2220 // The namespace registry is the SAME concrete `HaematiteStore` leaf (the
2221 // quorum-replicated implementation), captured before the leaf is moved into
2222 // the cluster-store retention below.
2223 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2224 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2225 // NOI-5b: the SAME concrete leaf captured as the durable `O`-keyspace
2226 // observability store, so the transcript sequencer persists to haematite and
2227 // survives restart/failover. Captured here (before the decorator chain wraps
2228 // the event store) exactly like the namespace registry.
2229 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2230 // Retain the concrete store ONLY for a distributed boot (responder present),
2231 // where the SS-5b supervisor will poll it for peer liveness. A single-node
2232 // boot has no peers, so it carries no cluster store and never supervises.
2233 let cluster_store = responder.as_ref().map(|_| leaf);
2234 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2235 (watched_peers, directory_peers, self_node_id)
2236 } else {
2237 (Vec::new(), Vec::new(), None)
2238 };
2239 Ok(ConnectedStore {
2240 event_store,
2241 outbox_store: Some(outbox_store),
2242 namespace_store,
2243 worker_deployment_store,
2244 observability_store: Some(observability_store),
2245 bootstrap_coordinator,
2246 cluster_responder: responder,
2247 cluster_store,
2248 watched_peers,
2249 directory_peers,
2250 self_node_id,
2251 })
2252}
2253
2254/// Build the haematite store: the distributed path when a cluster section is
2255/// present, otherwise the single-node path. Returns the store and (for the
2256/// distributed path) its inbound-write responder. Restart-safe: an existing
2257/// on-disk database is reused (its shard count wins) rather than re-created.
2258///
2259/// Linux/Android give Haematite a descriptor-authoritative `/proc/self/fd` path.
2260/// On path-ambient Unix targets such as macOS, startup instead resolves the held
2261/// descriptor's current path and refuses any ancestor owned by an unprivileged
2262/// principal other than the server euid or writable by group/world. That policy
2263/// prevents a second principal from renaming a parent after startup and replacing
2264/// the old name with a symlink that redirects Haematite's normal reads/commits.
2265/// Every shard is still eagerly materialized and the capability retained, but on
2266/// those targets neither action confines later pathname I/O. A descriptor-relative
2267/// Haematite constructor and backend I/O remain the long-term fix.
2268fn build_haematite_store(
2269 data_dir: &str,
2270 shard_count: usize,
2271 cluster: Option<crate::config::ClusterConfig>,
2272 node_cache_budget: haematite::NodeCacheBudget,
2273) -> Result<
2274 (
2275 aion_store_haematite::HaematiteStore,
2276 Option<aion_store_haematite::ClusterResponder>,
2277 ),
2278 ServerError,
2279> {
2280 build_haematite_store_with_hook(data_dir, shard_count, cluster, node_cache_budget, || Ok(()))
2281}
2282
2283fn build_haematite_store_with_hook(
2284 data_dir: &str,
2285 shard_count: usize,
2286 cluster: Option<crate::config::ClusterConfig>,
2287 node_cache_budget: haematite::NodeCacheBudget,
2288 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2289) -> Result<
2290 (
2291 aion_store_haematite::HaematiteStore,
2292 Option<aion_store_haematite::ClusterResponder>,
2293 ),
2294 ServerError,
2295> {
2296 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2297
2298 // Acquire the data root through the same no-follow component walk used by
2299 // authoring. New components are created 0700 on Unix, and an existing root
2300 // that the server's own user owns is tightened to 0700 rather than refused —
2301 // provisioning our own directory is Aion's job, not the operator's. Only a
2302 // root Aion cannot make safe (foreign owner, a filesystem without Unix
2303 // modes) is a loud startup failure here; an unsafe ANCESTOR is caught
2304 // separately below and is never repaired.
2305 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2306 data_dir,
2307 ))
2308 .map_err(|error| ServerError::Config {
2309 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2310 })?;
2311
2312 // Haematite 0.5 creates shard directories lazily. Pre-create every configured
2313 // directory descriptor-relatively, then force the backend's actual shard
2314 // spawn/recovery path below while this checked-and-hardened window is held.
2315 for shard in 0..shard_count {
2316 private_root
2317 .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
2318 .map_err(|error| ServerError::Config {
2319 message: format!(
2320 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2321 ),
2322 })?;
2323 }
2324 private_root
2325 .harden_tree()
2326 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2327
2328 // Deterministic regression seam: the capability and shard directories exist,
2329 // but Haematite has not touched any path yet.
2330 before_backend_touch().map_err(|error| ServerError::Config {
2331 message: format!("store.data_dir pre-open hook failed: {error}"),
2332 })?;
2333
2334 #[cfg(unix)]
2335 let backend_path = private_root
2336 .backend_path()
2337 .map_err(|error| ServerError::Config {
2338 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2339 })?;
2340 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2341 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2342 let (component, reason) = error.into_parts();
2343 ServerError::UnsafeDataRootAncestor {
2344 data_root: backend_path.clone(),
2345 component,
2346 reason,
2347 }
2348 })?;
2349 #[cfg(not(unix))]
2350 let backend_path = std::path::PathBuf::from(data_dir);
2351
2352 let Some(cluster) = cluster else {
2353 let store = if backend_path.join("config.json").exists() {
2354 // The configured budget rides along as the migration ruling for a
2355 // store that predates the field (aion#75: an upgrade must never
2356 // present as data loss); a store that already rules keeps its
2357 // recorded ruling and this value is ignored.
2358 HaematiteStore::open(&backend_path, node_cache_budget).map_err(ServerError::from)?
2359 } else {
2360 HaematiteStore::create_with_shard_count(&backend_path, shard_count, node_cache_budget)
2361 .map_err(ServerError::from)?
2362 };
2363 store.materialize_all_shards().map_err(ServerError::from)?;
2364 private_root
2365 .harden_tree()
2366 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2367 let store = store.retain_data_root_capability(private_root);
2368 return Ok((store, None));
2369 };
2370
2371 let boot = ClusterBootstrap {
2372 node_id: cluster.node_id,
2373 bind_address: cluster.bind_address,
2374 members: cluster.members,
2375 peers: cluster
2376 .peers
2377 .into_iter()
2378 .map(|peer| (peer.name, peer.address))
2379 .collect(),
2380 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2381 };
2382 let (store, responder) = HaematiteStore::open_or_create_distributed(
2383 &backend_path,
2384 shard_count,
2385 boot,
2386 node_cache_budget,
2387 )
2388 .map_err(ServerError::from)?;
2389 store.materialize_all_shards().map_err(ServerError::from)?;
2390 private_root
2391 .harden_tree()
2392 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2393 let store = store.retain_data_root_capability(private_root);
2394 Ok((store, Some(responder)))
2395}
2396
2397fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2398 ServerError::Config {
2399 message: format!(
2400 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2401 ),
2402 }
2403}
2404
2405/// Per-operation quorum/election timeout for the distributed haematite backend.
2406const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2407
2408/// The NOI-6 intervention transport used when no push transport is compiled in.
2409///
2410/// Without the `liminal-transport` feature there is no way to reach a worker's
2411/// out-of-band connection, so every routed command reports the owning worker
2412/// unreachable — which the router maps onto the attempt-scoped stale-target no-op.
2413/// This keeps the intervention endpoint honest on a transport-less build (an
2414/// operator gets a NACK, never a false "applied") without gating the endpoint on a
2415/// feature.
2416#[cfg(not(feature = "liminal-transport"))]
2417#[derive(Clone, Debug)]
2418struct NullInterventionTransport;
2419
2420#[cfg(not(feature = "liminal-transport"))]
2421#[async_trait::async_trait]
2422impl crate::worker::InterventionTransport for NullInterventionTransport {
2423 async fn push(
2424 &self,
2425 _worker: &crate::worker::WorkerHandle,
2426 _command: aion_core::InterventionCommand,
2427 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2428 Err(ServerError::worker_connection_lost(
2429 "intervention",
2430 "no intervention push transport is compiled in".to_owned(),
2431 ))
2432 }
2433}
2434
2435#[cfg(test)]
2436mod tests {
2437 use std::{net::SocketAddr, time::Duration};
2438
2439 use aion_store::InMemoryStore;
2440
2441 use super::ServerState;
2442 use crate::config::{
2443 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2444 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2445 RuntimeConfig, WebSocketConfig, WorkerConfig,
2446 };
2447
2448 fn runtime_config() -> RuntimeConfig {
2449 RuntimeConfig {
2450 listen: ListenConfig {
2451 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2452 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2453 },
2454 tls: None,
2455 auth: AuthConfig {
2456 enabled: false,
2457 jwks_url: None,
2458 jwks_refresh_seconds: 300,
2459 },
2460 ops_console: OpsConsoleConfig {
2461 source: OpsConsoleAssetSource::Embedded,
2462 },
2463 namespace: NamespaceConfig {
2464 mode: NamespaceMode::SharedEngine,
2465 },
2466 worker: WorkerConfig {
2467 heartbeat_window: Duration::from_secs(30),
2468 ..WorkerConfig::default()
2469 },
2470 websocket: WebSocketConfig {
2471 outbound_buffer_bound: 32,
2472 event_broadcast_capacity: Some(64),
2473 cluster_broadcast_capacity: Some(64),
2474 },
2475 workflow_packages: Vec::new(),
2476 deploy: DeployConfig::default(),
2477 authoring: AuthoringConfig::default(),
2478 dev: DevConfig::default(),
2479 outbox: OutboxConfig::default(),
2480 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
2481 mcp: crate::config::ResolvedMcpConfig::default(),
2482 scheduler_threads: 1,
2483 jit_threshold: None,
2484 query_timeout: Some(Duration::from_secs(10)),
2485 default_namespace: "default".to_owned(),
2486 auto_create: crate::config::AutoCreate::Open,
2487 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2488 drain_timeout: Duration::from_secs(30),
2489 metrics: MetricsConfig { enabled: true },
2490 owned_shards: Vec::new(),
2491 cors_allowed_origins: Vec::new(),
2492 }
2493 }
2494
2495 /// The flush policy is REQUIRED and has no default: a runtime that has not
2496 /// ruled on it refuses to build the transcript publisher, and the refusal
2497 /// names the key the operator must set. This is the loud-at-startup half of
2498 /// "no invented tuning values".
2499 #[test]
2500 fn an_unruled_flush_policy_refuses_to_build_the_publisher() {
2501 for (mutate, expected_key) in [
2502 (
2503 Box::new(|runtime: &mut RuntimeConfig| {
2504 runtime.observability.max_batch_events = None;
2505 }) as Box<dyn Fn(&mut RuntimeConfig)>,
2506 "observability.max_batch_events",
2507 ),
2508 (
2509 Box::new(|runtime: &mut RuntimeConfig| {
2510 runtime.observability.max_batch_events = Some(0);
2511 }),
2512 "observability.max_batch_events",
2513 ),
2514 (
2515 Box::new(|runtime: &mut RuntimeConfig| {
2516 runtime.observability.max_batch_hold_ms = None;
2517 }),
2518 "observability.max_batch_hold_ms",
2519 ),
2520 ] {
2521 let mut runtime = runtime_config();
2522 mutate(&mut runtime);
2523 let message = super::required_transcript_batch_policy(&runtime)
2524 .err()
2525 .map_or_else(String::new, |error| error.to_string());
2526 assert!(
2527 message.contains(expected_key),
2528 "the refusal must name {expected_key}: {message}"
2529 );
2530 assert!(
2531 message.contains("no default"),
2532 "and must say the key has no default: {message}"
2533 );
2534 }
2535 }
2536
2537 /// A stated policy is carried through verbatim — including a hold of ZERO,
2538 /// which is a real ruling ("never wait"), not a missing one.
2539 #[test]
2540 fn a_stated_flush_policy_is_carried_through_verbatim() -> Result<(), Box<dyn std::error::Error>>
2541 {
2542 let mut runtime = runtime_config();
2543 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(32, 0);
2544 let policy = super::required_transcript_batch_policy(&runtime)?;
2545 assert_eq!(policy.max_batch_events.get(), 32);
2546 assert_eq!(policy.max_hold, Duration::ZERO);
2547
2548 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(8, 250);
2549 let policy = super::required_transcript_batch_policy(&runtime)?;
2550 assert_eq!(policy.max_batch_events.get(), 8);
2551 assert_eq!(policy.max_hold, Duration::from_millis(250));
2552 Ok(())
2553 }
2554
2555 /// The engine's schema must accept EVERY attribute the server's start
2556 /// writer actually records — the invariant, not an enumeration of names.
2557 ///
2558 /// The two sides are genuinely coupled at runtime: the recorder validates
2559 /// each attribute against this schema before appending, so an attribute the
2560 /// writer produces and the schema does not register fails the START, not
2561 /// just the label (#211). The map is taken from the production
2562 /// `start_search_attributes` with every optional field populated, so any
2563 /// future attribute the writer learns to record is covered here without
2564 /// this test being edited.
2565 #[test]
2566 fn engine_schema_accepts_every_attribute_the_start_writer_records()
2567 -> Result<(), Box<dyn std::error::Error>> {
2568 let schema = super::server_search_attribute_schema()?;
2569 let recorded = crate::api::handlers::workflows::start_search_attributes(
2570 "tenant-a",
2571 Some("gpu"),
2572 Some("Nightly settlement"),
2573 );
2574
2575 assert!(
2576 recorded.contains_key(crate::namespace::DISPLAY_NAME_ATTRIBUTE),
2577 "the fixture must exercise the display-name attribute, or this test \
2578 cannot see its registration go missing"
2579 );
2580 for (name, value) in &recorded {
2581 schema.validate(name, value).map_err(|error| {
2582 format!(
2583 "the start writer records {name}, but the engine's schema refuses it: {error}"
2584 )
2585 })?;
2586 }
2587 Ok(())
2588 }
2589
2590 #[tokio::test]
2591 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2592 let state =
2593 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2594
2595 std::hint::black_box(state.namespace_guard());
2596 std::hint::black_box(state.worker_registry());
2597
2598 Ok(())
2599 }
2600
2601 /// R1 surfacing: a real boot exposes the unserved-queue state, the bridge
2602 /// publishes parked dispatches into THAT instance, and the address leaves
2603 /// the state the moment the dispatch resolves.
2604 ///
2605 /// The dispatch is driven through a dispatcher built over the state's OWN
2606 /// registry, queue state, and engine-backed declaration source — the same
2607 /// three handles `build_bridge_dispatcher` hands the production bridge.
2608 #[tokio::test]
2609 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2610 -> Result<(), Box<dyn std::error::Error>> {
2611 use aion::{ActivityDispatch, ActivityDispatcher as _};
2612 use aion_core::{ActivityId, RunId, WorkflowId};
2613 use std::sync::Arc;
2614
2615 let state =
2616 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2617 assert!(
2618 state.unserved_queues()?.is_empty(),
2619 "a calm boot has no unserved queues"
2620 );
2621 // The engine-backed reader IS installed on a real boot; with no
2622 // queue-declaring package deployed it can contradict nothing, so it must
2623 // answer Unknown rather than manufacture a structural refusal.
2624 assert!(state.queue_declarations().is_installed());
2625 assert_eq!(
2626 state
2627 .queue_declarations()
2628 .declaration_for("nobody-serves-this"),
2629 crate::worker::QueueDeclaration::Unknown
2630 );
2631
2632 let dispatcher = Arc::new(
2633 crate::worker::WorkerActivityDispatcher::new(
2634 state.worker_registry().clone(),
2635 "default",
2636 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2637 )
2638 .with_queue_state(state.queue_service_state().clone())
2639 .with_queue_declarations(state.queue_declarations().clone()),
2640 );
2641 let workflow_id = WorkflowId::new_v4();
2642 let request = ActivityDispatch {
2643 namespace: "default".to_owned(),
2644 task_queue: "nobody-serves-this".to_owned(),
2645 node: None,
2646 workflow_id: workflow_id.clone(),
2647 run_id: RunId::new_v4(),
2648 activity_id: ActivityId::from_sequence_position(0),
2649 name: "greet".to_owned(),
2650 input: "{}".to_owned(),
2651 config: "{}".to_owned(),
2652 attempt: 1,
2653 advisory: false,
2654 labels: std::collections::BTreeMap::new(),
2655 };
2656 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2657
2658 let mut unserved = Vec::new();
2659 for _ in 0..30 {
2660 unserved = state.unserved_queues()?;
2661 if !unserved.is_empty() {
2662 break;
2663 }
2664 tokio::time::sleep(Duration::from_millis(100)).await;
2665 }
2666 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2667 assert_eq!(
2668 unserved[0].reason,
2669 crate::worker::QueueServiceReason::NoLivePollers,
2670 "an empty catalog must not be read as a structural refusal"
2671 );
2672 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2673 assert_eq!(unserved[0].waiting.len(), 1);
2674 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2675
2676 // Release the dispatch: a worker arrives whose receiver is already gone.
2677 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2678 drop(worker_rx);
2679 let registration = state.worker_registry().register_namespaces(
2680 [String::from("default")],
2681 "nobody-serves-this",
2682 None,
2683 [String::from("greet")].iter(),
2684 worker_tx,
2685 )?;
2686 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2687 assert!(outcome.is_err(), "the released dispatch must resolve");
2688 assert!(
2689 state.unserved_queues()?.is_empty(),
2690 "a resolved dispatch must leave the unserved state"
2691 );
2692 registration.deregister()?;
2693 Ok(())
2694 }
2695
2696 #[tokio::test]
2697 async fn namespace_store_is_reachable_and_functional_after_default_boot()
2698 -> Result<(), Box<dyn std::error::Error>> {
2699 use aion_store::{MintOutcome, NamespaceOrigin};
2700
2701 // A default single-node (in-memory) boot must expose a real, functional
2702 // namespace registry through `state.namespace_store()` — the control
2703 // plane's mint (S5) and `GET /namespaces` (S7) reach the store this way.
2704 let state =
2705 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2706
2707 let store = state.namespace_store();
2708
2709 // Mint a fresh namespace: the first reference creates it.
2710 let outcome = store
2711 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2712 .await?;
2713 assert_eq!(
2714 outcome,
2715 MintOutcome::Created,
2716 "the first reference to a namespace mints it"
2717 );
2718
2719 // Re-referencing is idempotent: the record already exists.
2720 let again = store
2721 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2722 .await?;
2723 assert_eq!(
2724 again,
2725 MintOutcome::AlreadyExisted,
2726 "a second reference touches the existing record rather than re-creating it"
2727 );
2728
2729 // Single lookup returns the durable record.
2730 let fetched = store.get_namespace("orders").await?;
2731 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2732 assert_eq!(record.name, "orders");
2733 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2734
2735 // The live set lists the namespace.
2736 let listed = store.list_namespaces().await?;
2737 assert!(
2738 listed.iter().any(|record| record.name == "orders"),
2739 "list_namespaces returns the minted namespace"
2740 );
2741
2742 Ok(())
2743 }
2744
2745 #[tokio::test(flavor = "multi_thread")]
2746 async fn connect_store_haematite_round_trips_through_event_store()
2747 -> Result<(), Box<dyn std::error::Error>> {
2748 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2749 use aion_store::WriteToken;
2750 use chrono::Utc;
2751
2752 use crate::config::{StoreBackend, StoreConfig};
2753
2754 let data_dir = crate::test_support::private_tempdir()?;
2755 // Single shard, a fresh temp data_dir: the production connect path opens
2756 // an existing haematite database or creates one, then shares the leaf as
2757 // both the engine EventStore and the dispatcher OutboxStore.
2758 let connected = super::connect_store(StoreConfig {
2759 backend: StoreBackend::Haematite,
2760 owned_shards: Vec::new(),
2761 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2762 shard_count: 1,
2763 cluster: None,
2764 node_cache_budget: Some(test_node_cache_budget()?),
2765 ..StoreConfig::default()
2766 })
2767 .await?;
2768 let event_store = connected.event_store;
2769 assert!(
2770 connected.outbox_store.is_some(),
2771 "the haematite backend shares its leaf store as the dispatcher's outbox store"
2772 );
2773 assert!(
2774 connected.bootstrap_coordinator,
2775 "a single-node haematite boot owns all shards and bootstraps the coordinator"
2776 );
2777 assert!(
2778 connected.cluster_responder.is_none(),
2779 "a single-node (no [cluster]) haematite boot has no distributed responder"
2780 );
2781
2782 let workflow_id = WorkflowId::new_v4();
2783 let event = aion_core::Event::WorkflowStarted {
2784 envelope: EventEnvelope {
2785 seq: 1,
2786 recorded_at: Utc::now(),
2787 workflow_id: workflow_id.clone(),
2788 },
2789 workflow_type: String::from("checkout"),
2790 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2791 run_id: RunId::new_v4(),
2792 parent_run_id: None,
2793 parent_workflow_id: None,
2794 package_version: PackageVersion::new("a".repeat(64)),
2795 };
2796 event_store
2797 .append(
2798 WriteToken::recorder(),
2799 &workflow_id,
2800 std::slice::from_ref(&event),
2801 0,
2802 )
2803 .await?;
2804 let history = event_store.read_history(&workflow_id).await?;
2805 assert_eq!(
2806 history.len(),
2807 1,
2808 "an event appended through the server's dyn EventStore reads back"
2809 );
2810 Ok(())
2811 }
2812
2813 /// A generous node-cache byte budget (1 GiB) for the haematite fixtures.
2814 ///
2815 /// Roomy enough that no fixture here can reach it, so these stay boot-path
2816 /// and data-root tests rather than accidental cache-eviction tests. It is a
2817 /// TEST value, not a default: production reads the operator's ruling.
2818 fn test_node_cache_budget() -> Result<haematite::NodeCacheBudget, Box<dyn std::error::Error>> {
2819 Ok(haematite::NodeCacheBudget::bytes(1 << 30)?)
2820 }
2821
2822 #[cfg(unix)]
2823 #[test]
2824 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2825 -> Result<(), Box<dyn std::error::Error>> {
2826 use std::os::unix::fs::symlink;
2827
2828 let sandbox = crate::test_support::private_tempdir()?;
2829 let configured_root = sandbox.path().join("data");
2830 let held_root = sandbox.path().join("held-data");
2831 let outside = sandbox.path().join("outside");
2832 std::fs::create_dir(&outside)?;
2833 let configured = configured_root
2834 .to_str()
2835 .ok_or("temporary data path was not UTF-8")?;
2836
2837 let (store, responder) = super::build_haematite_store_with_hook(
2838 configured,
2839 4,
2840 None,
2841 test_node_cache_budget()?,
2842 || {
2843 // The server has acquired and hardened `configured_root`, but
2844 // Haematite has not opened or created anything. Replace the
2845 // ambient name with an attacker-controlled symlink at exactly
2846 // the old check/use boundary.
2847 std::fs::rename(&configured_root, &held_root)?;
2848 symlink(&outside, &configured_root)?;
2849 Ok(())
2850 },
2851 )?;
2852 assert!(responder.is_none());
2853
2854 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2855 assert!(
2856 outside_entries.is_empty(),
2857 "Haematite followed the replaced ambient root and wrote outside"
2858 );
2859 assert!(held_root.join("config.json").is_file());
2860 for shard in 0..4 {
2861 let shard_path = held_root.join(format!("shard-{shard}"));
2862 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2863 assert!(
2864 std::fs::read_dir(&shard_path)?
2865 .next()
2866 .transpose()?
2867 .is_some(),
2868 "shard {shard} did not run Haematite's materialization path"
2869 );
2870 }
2871
2872 drop(store);
2873 Ok(())
2874 }
2875
2876 #[cfg(any(target_os = "linux", target_os = "android"))]
2877 #[tokio::test]
2878 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2879 -> Result<(), Box<dyn std::error::Error>> {
2880 use std::os::unix::fs::symlink;
2881
2882 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2883 use aion_store::{WritableEventStore as _, WriteToken};
2884 use chrono::Utc;
2885
2886 let sandbox = crate::test_support::private_tempdir()?;
2887 let configured_root = sandbox.path().join("data");
2888 let held_root = sandbox.path().join("held-data");
2889 let capture = sandbox.path().join("capture");
2890 std::fs::create_dir(&capture)?;
2891 let configured = configured_root
2892 .to_str()
2893 .ok_or("temporary data path was not UTF-8")?;
2894
2895 let (store, responder) =
2896 super::build_haematite_store(configured, 4, None, test_node_cache_budget()?)?;
2897 assert!(responder.is_none());
2898 std::fs::rename(&configured_root, &held_root)?;
2899 symlink(&capture, &configured_root)?;
2900
2901 let workflow_id = WorkflowId::new_v4();
2902 let event = aion_core::Event::WorkflowStarted {
2903 envelope: EventEnvelope {
2904 seq: 1,
2905 recorded_at: Utc::now(),
2906 workflow_id: workflow_id.clone(),
2907 },
2908 workflow_type: String::from("post-startup-root-swap"),
2909 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2910 run_id: RunId::new_v4(),
2911 parent_run_id: None,
2912 parent_workflow_id: None,
2913 package_version: PackageVersion::new("a".repeat(64)),
2914 };
2915 store
2916 .append(
2917 WriteToken::recorder(),
2918 &workflow_id,
2919 std::slice::from_ref(&event),
2920 0,
2921 )
2922 .await?;
2923
2924 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2925 assert!(
2926 captured.is_empty(),
2927 "post-startup append followed the replacement symlink into capture"
2928 );
2929 assert!(held_root.join("config.json").is_file());
2930 drop(store);
2931 Ok(())
2932 }
2933
2934 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2935 #[test]
2936 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2937 -> Result<(), Box<dyn std::error::Error>> {
2938 use std::os::unix::fs::PermissionsExt as _;
2939
2940 let sandbox = crate::test_support::private_tempdir()?;
2941 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2942
2943 for mode in [0o770, 0o1777] {
2944 let shared = sandbox.path().join(format!("shared-{mode:o}"));
2945 let data_root = shared.join("data");
2946 std::fs::create_dir(&shared)?;
2947 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2948 std::fs::create_dir(&data_root)?;
2949 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2950 let configured = data_root
2951 .to_str()
2952 .ok_or("temporary data path was not UTF-8")?;
2953
2954 let Err(error) =
2955 super::build_haematite_store(configured, 4, None, test_node_cache_budget()?)
2956 else {
2957 return Err(format!("mode {mode:04o} ancestor was accepted").into());
2958 };
2959 let message = error.to_string();
2960 let crate::ServerError::UnsafeDataRootAncestor {
2961 data_root: resolved_root,
2962 component,
2963 reason,
2964 } = error
2965 else {
2966 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2967 };
2968 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2969 assert_eq!(component, std::fs::canonicalize(&shared)?);
2970 assert!(
2971 reason.contains(&format!("mode {mode:04o}")),
2972 "unexpected reason: {reason}"
2973 );
2974 if mode & 0o1000 != 0 {
2975 assert!(reason.contains("sticky bit is not accepted"));
2976 }
2977 assert!(message.contains("private Aion home"));
2978 assert!(
2979 !data_root.join("config.json").exists(),
2980 "Haematite touched its ambient path before the refusal"
2981 );
2982 }
2983 Ok(())
2984 }
2985
2986 #[cfg(target_os = "macos")]
2987 #[test]
2988 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2989 -> Result<(), Box<dyn std::error::Error>> {
2990 use std::os::unix::fs::PermissionsExt as _;
2991
2992 let sandbox = crate::test_support::private_tempdir()?;
2993 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2994 let shared = sandbox.path().join("acl-shared");
2995 let data_root = shared.join("data");
2996 std::fs::create_dir(&shared)?;
2997 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2998 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2999 let status = std::process::Command::new("chmod")
3000 .arg("+a")
3001 .arg(acl)
3002 .arg(&shared)
3003 .status()?;
3004 assert!(status.success(), "failed to install Darwin regression ACL");
3005 let configured = data_root
3006 .to_str()
3007 .ok_or("temporary data path was not UTF-8")?;
3008
3009 let result = super::build_haematite_store(configured, 4, None, test_node_cache_budget()?);
3010 let cleanup = std::process::Command::new("chmod")
3011 .arg("-RN")
3012 .arg(&shared)
3013 .status()?;
3014 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
3015
3016 let Err(error) = result else {
3017 return Err("mutating non-euid allow ACL ancestor was accepted".into());
3018 };
3019 let message = error.to_string();
3020 let crate::ServerError::UnsafeDataRootAncestor {
3021 component, reason, ..
3022 } = error
3023 else {
3024 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3025 };
3026 assert_eq!(component, std::fs::canonicalize(&shared)?);
3027 assert!(
3028 reason.contains("allow"),
3029 "reason did not name the ACE: {reason}"
3030 );
3031 assert!(
3032 reason.contains("everyone"),
3033 "reason did not name the ACE principal: {reason}"
3034 );
3035 assert!(
3036 !data_root.join("config.json").exists(),
3037 "Haematite touched its ambient path before the ACL refusal"
3038 );
3039 Ok(())
3040 }
3041
3042 #[cfg(target_os = "macos")]
3043 #[test]
3044 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
3045 -> Result<(), Box<dyn std::error::Error>> {
3046 use std::os::unix::fs::PermissionsExt as _;
3047
3048 use exacl::{AclEntry, AclOption, Perm};
3049
3050 let sandbox = crate::test_support::private_tempdir()?;
3051 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3052 let private_parent = sandbox.path().join("euid-uuid-allow");
3053 let data_root = private_parent.join("data");
3054 std::fs::create_dir(&private_parent)?;
3055 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3056
3057 let server_uid = rustix::process::geteuid().as_raw();
3058 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
3059 let entry = AclEntry::allow_user(
3060 &ace_qualifier.to_string(),
3061 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3062 None,
3063 );
3064 exacl::setfacl(
3065 &[private_parent.as_path()],
3066 &[entry],
3067 AclOption::SYMLINK_ACL,
3068 )?;
3069 let configured = data_root
3070 .to_str()
3071 .ok_or("temporary data path was not UTF-8")?;
3072
3073 let result = super::build_haematite_store(configured, 4, None, test_node_cache_budget()?);
3074 let cleanup = std::process::Command::new("chmod")
3075 .arg("-RN")
3076 .arg(&private_parent)
3077 .status()?;
3078 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
3079
3080 let (store, responder) = result?;
3081 assert!(responder.is_none());
3082 assert!(data_root.join("config.json").is_file());
3083 drop(store);
3084 Ok(())
3085 }
3086
3087 #[cfg(target_os = "macos")]
3088 #[test]
3089 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
3090 -> Result<(), Box<dyn std::error::Error>> {
3091 use std::os::unix::fs::PermissionsExt as _;
3092
3093 use exacl::{AclEntry, AclOption, Perm};
3094
3095 let sandbox = crate::test_support::private_tempdir()?;
3096 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3097 let shared = sandbox.path().join("non-euid-uuid-allow");
3098 let data_root = shared.join("data");
3099 std::fs::create_dir(&shared)?;
3100 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
3101
3102 let server_uid = rustix::process::geteuid().as_raw();
3103 let foreign_uid = u32::from(server_uid == 0);
3104 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
3105 let entry = AclEntry::allow_user(
3106 &foreign_qualifier.to_string(),
3107 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3108 None,
3109 );
3110 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
3111 let configured = data_root
3112 .to_str()
3113 .ok_or("temporary data path was not UTF-8")?;
3114
3115 let result = super::build_haematite_store(configured, 4, None, test_node_cache_budget()?);
3116 let cleanup = std::process::Command::new("chmod")
3117 .arg("-RN")
3118 .arg(&shared)
3119 .status()?;
3120 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
3121
3122 let Err(error) = result else {
3123 return Err("mutating non-euid user UUID allow ACE was accepted".into());
3124 };
3125 let message = error.to_string();
3126 let crate::ServerError::UnsafeDataRootAncestor {
3127 component, reason, ..
3128 } = error
3129 else {
3130 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3131 };
3132 assert_eq!(component, std::fs::canonicalize(&shared)?);
3133 assert!(
3134 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
3135 "reason did not name the rejected ACE: {reason}"
3136 );
3137 assert!(
3138 !data_root.join("config.json").exists(),
3139 "Haematite touched its ambient path before the UUID ACL refusal"
3140 );
3141 Ok(())
3142 }
3143
3144 #[cfg(target_os = "macos")]
3145 #[test]
3146 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
3147 -> Result<(), Box<dyn std::error::Error>> {
3148 use std::os::unix::fs::PermissionsExt as _;
3149
3150 let sandbox = crate::test_support::private_tempdir()?;
3151 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3152 let private_parent = sandbox.path().join("deny-only");
3153 let data_root = private_parent.join("data");
3154 std::fs::create_dir(&private_parent)?;
3155 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3156 let status = std::process::Command::new("chmod")
3157 .arg("+a")
3158 .arg("everyone deny delete")
3159 .arg(&private_parent)
3160 .status()?;
3161 assert!(status.success(), "failed to install Darwin deny-only ACL");
3162 let configured = data_root
3163 .to_str()
3164 .ok_or("temporary data path was not UTF-8")?;
3165
3166 let result = super::build_haematite_store(configured, 4, None, test_node_cache_budget()?);
3167 let cleanup = std::process::Command::new("chmod")
3168 .arg("-RN")
3169 .arg(&private_parent)
3170 .status()?;
3171 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
3172
3173 let (store, responder) = result?;
3174 assert!(responder.is_none());
3175 assert!(data_root.join("config.json").is_file());
3176 drop(store);
3177 Ok(())
3178 }
3179
3180 #[cfg(target_os = "macos")]
3181 #[test]
3182 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
3183 -> Result<(), Box<dyn std::error::Error>> {
3184 use std::os::unix::fs::PermissionsExt as _;
3185 use users::os::unix::UserExt as _;
3186
3187 let effective_uid = rustix::process::geteuid().as_raw();
3188 let effective_user = users::get_user_by_uid(effective_uid)
3189 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
3190 let sandbox = tempfile::Builder::new()
3191 .prefix(".aion-acl-home-proof-")
3192 .tempdir_in(effective_user.home_dir())?;
3193 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3194 let data_root = sandbox.path().join("data");
3195 let configured = data_root
3196 .to_str()
3197 .ok_or("temporary data path was not UTF-8")?;
3198
3199 let (store, responder) =
3200 super::build_haematite_store(configured, 4, None, test_node_cache_budget()?)?;
3201 assert!(responder.is_none());
3202 assert!(data_root.join("config.json").is_file());
3203 drop(store);
3204 Ok(())
3205 }
3206
3207 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
3208 #[test]
3209 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
3210 -> Result<(), Box<dyn std::error::Error>> {
3211 use std::os::unix::fs::PermissionsExt as _;
3212
3213 let sandbox = crate::test_support::private_tempdir()?;
3214 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3215 let private_parent = sandbox.path().join("private");
3216 let data_root = private_parent.join("data");
3217 std::fs::create_dir(&private_parent)?;
3218 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3219 let configured = data_root
3220 .to_str()
3221 .ok_or("temporary data path was not UTF-8")?;
3222
3223 let (store, responder) =
3224 super::build_haematite_store(configured, 4, None, test_node_cache_budget()?)?;
3225 assert!(responder.is_none());
3226 assert!(data_root.join("config.json").is_file());
3227 for shard in 0..4 {
3228 assert!(data_root.join(format!("shard-{shard}")).is_dir());
3229 }
3230 drop(store);
3231 Ok(())
3232 }
3233
3234 #[tokio::test]
3235 async fn connect_store_memory_backend_exposes_no_outbox_store()
3236 -> Result<(), Box<dyn std::error::Error>> {
3237 use crate::config::{StoreBackend, StoreConfig};
3238
3239 // Memory backend: no durable outbox table, so no outbox store handle —
3240 // and `outbox.enabled` over memory is rejected at dispatcher commission.
3241 let connected = super::connect_store(StoreConfig {
3242 backend: StoreBackend::Memory,
3243 owned_shards: Vec::new(),
3244 data_dir: None,
3245 shard_count: 1,
3246 cluster: None,
3247 node_cache_budget: None,
3248 ..StoreConfig::default()
3249 })
3250 .await?;
3251 assert!(
3252 connected.outbox_store.is_none(),
3253 "the in-memory backend exposes no outbox store"
3254 );
3255 Ok(())
3256 }
3257
3258 /// The haematite boot path REFUSES a store config that does not rule on the
3259 /// node cache's byte budget, naming the missing key — the same
3260 /// explicit-no-default guard `observability.max_batch_events` uses, applied
3261 /// where the value is used (haematite is the only backend that has a node
3262 /// cache, so this is the seam that consumes the ruling).
3263 #[tokio::test]
3264 async fn haematite_boot_refuses_without_a_node_cache_budget()
3265 -> Result<(), Box<dyn std::error::Error>> {
3266 use crate::ServerError;
3267 use crate::config::{StoreBackend, StoreConfig};
3268
3269 let sandbox = crate::test_support::private_tempdir()?;
3270 let data_dir = sandbox.path().join("data");
3271 let error = super::connect_haematite_store(StoreConfig {
3272 backend: StoreBackend::Haematite,
3273 data_dir: Some(
3274 data_dir
3275 .to_str()
3276 .ok_or("temporary data path was not UTF-8")?
3277 .to_owned(),
3278 ),
3279 shard_count: 4,
3280 ..StoreConfig::default()
3281 })
3282 .await
3283 .err()
3284 .ok_or("the haematite boot path must refuse a store config with no node_cache_budget")?;
3285 let ServerError::Config { message } = error else {
3286 return Err(format!("expected a config refusal, got {error:?}").into());
3287 };
3288 assert!(
3289 message.contains("store.node_cache_budget"),
3290 "the refusal must name the missing key, got: {message}"
3291 );
3292 assert!(
3293 message.contains("AION_STORE_NODE_CACHE_BUDGET"),
3294 "the refusal must name the environment override, got: {message}"
3295 );
3296 Ok(())
3297 }
3298
3299 /// The operator's configured budget reaches the constructed
3300 /// [`haematite::DatabaseConfig`] — observed where haematite records it, in
3301 /// the created database's own `config.json`, so the assertion cannot pass by
3302 /// a value that stopped short of `Database::create`.
3303 #[tokio::test]
3304 async fn configured_node_cache_budget_reaches_the_created_database()
3305 -> Result<(), Box<dyn std::error::Error>> {
3306 use crate::config::{StoreBackend, StoreConfig};
3307
3308 const ONE_GIB: usize = 1 << 30;
3309
3310 let sandbox = crate::test_support::private_tempdir()?;
3311 let data_dir = sandbox.path().join("data");
3312 let connected = super::connect_haematite_store(StoreConfig {
3313 backend: StoreBackend::Haematite,
3314 data_dir: Some(
3315 data_dir
3316 .to_str()
3317 .ok_or("temporary data path was not UTF-8")?
3318 .to_owned(),
3319 ),
3320 shard_count: 4,
3321 node_cache_budget: Some(haematite::NodeCacheBudget::bytes(ONE_GIB)?),
3322 ..StoreConfig::default()
3323 })
3324 .await?;
3325 drop(connected);
3326
3327 let recorded: serde_json::Value =
3328 serde_json::from_slice(&std::fs::read(data_dir.join("config.json"))?)?;
3329 assert_eq!(
3330 recorded.get("node_cache_budget"),
3331 Some(&serde_json::json!({ "bytes": ONE_GIB })),
3332 "the operator's budget must be the one haematite created the database with"
3333 );
3334 Ok(())
3335 }
3336
3337 #[tokio::test]
3338 async fn state_build_fails_without_event_broadcast_capacity()
3339 -> Result<(), Box<dyn std::error::Error>> {
3340 let mut runtime = runtime_config();
3341 runtime.websocket.event_broadcast_capacity = None;
3342
3343 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3344 .await
3345 .err()
3346 .ok_or("state build must fail when event streaming is unsized")?;
3347
3348 assert!(error.is_config(), "expected a config error, got {error}");
3349 assert!(
3350 error
3351 .to_string()
3352 .contains("websocket.event_broadcast_capacity"),
3353 "error must name the missing key: {error}"
3354 );
3355 Ok(())
3356 }
3357
3358 #[tokio::test]
3359 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3360 let mut runtime = runtime_config();
3361 runtime.query_timeout = None;
3362
3363 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3364 .await
3365 .err()
3366 .ok_or("state build must fail when the query reply deadline is unset")?;
3367
3368 assert!(error.is_config(), "expected a config error, got {error}");
3369 assert!(
3370 error.to_string().contains("runtime.query_timeout_ms"),
3371 "error must name the missing key: {error}"
3372 );
3373 assert!(
3374 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
3375 "error must name the environment override: {error}"
3376 );
3377 Ok(())
3378 }
3379
3380 #[tokio::test]
3381 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3382 let mut runtime = runtime_config();
3383 runtime.query_timeout = Some(Duration::ZERO);
3384
3385 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3386 .await
3387 .err()
3388 .ok_or("state build must fail when the query reply deadline is zero")?;
3389
3390 assert!(error.is_config(), "expected a config error, got {error}");
3391 assert!(
3392 error.to_string().contains("runtime.query_timeout_ms"),
3393 "error must name the zero-valued key: {error}"
3394 );
3395 Ok(())
3396 }
3397
3398 /// THE #189 WIRING PIN (r1 Blocker B1): a completed update check driven
3399 /// through the dispatcher stack `build_decorated_dispatcher` actually
3400 /// builds lands in the update-status slot that same call RETURNS — the
3401 /// slot the boot path stores and `GET /update-status` serves.
3402 ///
3403 /// Everything between the dispatch and the slot is the production object:
3404 /// the real `DeclaredCommandDispatcher` executes a real server-run command
3405 /// through the real `ShellAction` (transcript pump and all), the real
3406 /// `UpdateCheckObserver` sits in its real position, and the assertion
3407 /// reads the slot off the function's own return value. The one test
3408 /// double is the `DeclaredBodies` source — the seam production code
3409 /// installs after the engine exists — and it is SEQUENCED because the
3410 /// gates run offline: the observer's verification (first resolution)
3411 /// sees the genuine `FETCH_COMMAND`, and the executor (second
3412 /// resolution) is handed a local `cat` of the captured real index body,
3413 /// standing in for the network transfer the genuine curl would perform.
3414 ///
3415 /// The r1 review proved the absence of this pin by mutation: returning a
3416 /// FRESH slot instead of the observer's left all 1268 tests green while
3417 /// `/update-status` would answer null forever. Under this test that
3418 /// exact mutation goes red: the returned slot stays empty and the
3419 /// assertion below names it.
3420 #[tokio::test(flavor = "multi_thread")]
3421 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
3422 -> Result<(), Box<dyn std::error::Error>> {
3423 use std::collections::{BTreeMap, VecDeque};
3424 use std::sync::{Arc, Mutex};
3425
3426 use aion::ActivityDispatch;
3427 use aion_core::{ActivityId, RunId, WorkflowId};
3428 use aion_package::ActionBodyContract;
3429
3430 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
3431 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
3432
3433 /// Hands out one scripted resolution per call, in order. Documented
3434 /// above: first the observer's verification, then the executor's.
3435 struct SequencedBodies {
3436 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
3437 }
3438
3439 impl DeclaredBodies for SequencedBodies {
3440 fn body_for(
3441 &self,
3442 _task_queue: &str,
3443 _action: &str,
3444 _run: DispatchingRun<'_>,
3445 ) -> DeclaredBodyLookup {
3446 let mut replies = match self.replies.lock() {
3447 Ok(replies) => replies,
3448 Err(poisoned) => poisoned.into_inner(),
3449 };
3450 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
3451 }
3452 }
3453
3454 let runtime = runtime_config();
3455 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
3456 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3457 );
3458 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
3459 Arc::new(InMemoryStore::default());
3460 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
3461 Arc::new(InMemoryStore::default());
3462 let seams = super::build_worker_seams(
3463 &runtime,
3464 &cluster_publisher,
3465 &namespace_store,
3466 &worker_deployment_store,
3467 None,
3468 );
3469
3470 // The captured REAL index body, served to the executor by a local
3471 // command instead of the network (gates run offline).
3472 let fixture = concat!(
3473 env!("CARGO_MANIFEST_DIR"),
3474 "/src/update_check/fixtures/aion-cli-index.jsonl"
3475 );
3476 seams.declared_bodies.install(Arc::new(SequencedBodies {
3477 replies: Mutex::new(VecDeque::from([
3478 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3479 command: FETCH_COMMAND.to_owned(),
3480 }),
3481 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3482 command: format!("cat {fixture}"),
3483 }),
3484 ])),
3485 }));
3486
3487 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
3488 Arc::new(aion_store::InMemoryObservabilityStore::default()),
3489 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3490 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
3491 );
3492 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
3493 super::build_decorated_dispatcher(&runtime, &seams, transcript);
3494
3495 assert_eq!(
3496 update_status.last(),
3497 None,
3498 "the returned slot must start honestly empty"
3499 );
3500
3501 let dispatch = ActivityDispatch {
3502 namespace: "default".to_owned(),
3503 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
3504 node: None,
3505 workflow_id: WorkflowId::new_v4(),
3506 run_id: RunId::new_v4(),
3507 activity_id: ActivityId::from_sequence_position(1),
3508 name: FETCH_ACTION.to_owned(),
3509 input: "{}".to_owned(),
3510 config: "{}".to_owned(),
3511 attempt: 1,
3512 labels: BTreeMap::new(),
3513 advisory: false,
3514 };
3515 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
3516 let encoded = handle
3517 .await?
3518 .map_err(|error| format!("the check dispatch failed: {error}"))?;
3519 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
3520 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
3521
3522 let recorded = update_status.last().ok_or(
3523 "the completed check must land in the RETURNED slot — the one the boot path \
3524 stores and /update-status serves; an empty slot here is the disconnected-\
3525 producer mis-wire the r1 review proved unmeasured",
3526 )?;
3527 assert_eq!(recorded.latest_known, "0.13.7");
3528 Ok(())
3529 }
3530}