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