aion_server/state.rs
1//! Shared server state constructed once at startup.
2
3use std::{path::PathBuf, sync::Arc};
4
5use aion::{
6 ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
7};
8use aion_store::{EventStore, NamespaceStore, OutboxStore};
9#[cfg(feature = "libsql-backend")]
10use aion_store_libsql::LibSqlStore;
11
12use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
13
14#[cfg(feature = "auth")]
15use crate::auth::JwksCache;
16use crate::{
17 config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
18 error::ServerError,
19 namespace::{NamespaceGuard, NamespaceMinter, resolver::NamespaceResolver},
20 observability::{
21 Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
22 metrics::MetricsError,
23 },
24 shutdown::DrainState,
25 worker::{
26 ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
27 },
28};
29
30/// Cloneable shared state passed to all server transports.
31#[derive(Clone)]
32pub struct ServerState {
33 inner: Arc<ServerStateInner>,
34}
35
36struct ServerStateInner {
37 namespace_guard: NamespaceGuard,
38 runtime: RuntimeConfig,
39 worker_registry: ConnectedWorkerRegistry,
40 pending_activities: PendingActivities,
41 heartbeat_tracker: HeartbeatTracker,
42 drain_state: DrainState,
43 metrics: Option<Metrics>,
44 health: Option<HealthState>,
45 /// Shared per-run activity-mock registry. Present only when the dev surface
46 /// is commissioned; the engine's dispatcher consults this exact instance.
47 activity_mock_registry: Option<ActivityMockRegistry>,
48 /// The leaf libSQL store cast as an [`OutboxStore`], shared with the engine's
49 /// `EventStore` so the outbox dispatcher writes through the same single
50 /// `libsql::Connection`. `None` for the in-memory backend (no outbox table).
51 outbox_store: Option<Arc<dyn OutboxStore>>,
52 /// The durable namespace registry, captured from the SAME concrete leaf
53 /// backend as the engine's `EventStore` BEFORE that leaf is wrapped in the
54 /// decorator chain (`PublishingEventStore` → `InstrumentedEventStore`),
55 /// which do not implement [`NamespaceStore`]. The haematite backend supplies
56 /// the quorum-replicated implementation; the libSQL and in-memory backends
57 /// supply a local-only one. Always present so the control-plane mint
58 /// (Phase 1 S5) and `GET /namespaces` (S7) can reach a real store on every
59 /// boot. Mirrors the `cluster_store` retention pattern.
60 namespace_store: Arc<dyn NamespaceStore>,
61 /// Advisory outbox wake (LSUB-2): the in-process `Notify` shared by the
62 /// engine's stage seam (the `InstrumentedEventStore`'s `append_with_outbox`)
63 /// and the [`OutboxDispatcher`](crate::worker::OutboxDispatcher) run loop, so
64 /// a committed fan-out row wakes the dispatcher in ~RTT instead of waiting up
65 /// to one poll interval. Always present (cheap, no `Option`): the handle is
66 /// harmless when the outbox is not commissioned, since nothing pulses it.
67 outbox_wake: Arc<tokio::sync::Notify>,
68 /// WS3 cluster topology/ownership publisher. Always present: the ops console's
69 /// cluster channel is served on every boot (calm state with no peers on a
70 /// single-node server). Sized from `websocket.cluster_broadcast_capacity`.
71 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
72 /// NOI-5b agent-observability transcript sequencer + live fan-out. Always
73 /// present: the transcript channel is served on every boot. The backing
74 /// [`ObservabilityStore`](aion_store::ObservabilityStore) is the durable
75 /// `O`-keyspace impl on a haematite boot and an in-memory impl on every other
76 /// backend (libSQL / in-memory have no `O` keyspace), so the transcript path
77 /// is uniform across backends while only haematite persists across restart.
78 /// Sized from `websocket.cluster_broadcast_capacity` (the same deployment-wide
79 /// real-time channel capacity the cluster tail uses).
80 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
81 /// NOI-6 server-side intervention routing: the `attempt -> owning-worker`
82 /// back-index the intervention router resolves a command's target through.
83 /// Always present (cheap, no `Option`): the agent-dispatch path binds an owner
84 /// when it dispatches an agent attempt and releases it on completion, so the
85 /// router resolves the CURRENT owner. Empty until an agent attempt is
86 /// dispatched — a command to an unbound attempt is the attempt-scoped no-op.
87 attempt_owners: crate::worker::AttemptOwnerIndex,
88 /// This node's distribution name for the WS3 cluster snapshot self-identity.
89 /// `Some` on a distributed haematite boot (the configured `store.cluster.node_id`),
90 /// `None` on a single-node boot — the snapshot then reports the standalone
91 /// self-label so the ops console still has a node to render.
92 cluster_self_node: Option<String>,
93 /// Owns the distributed haematite inbound-write responder thread, kept alive
94 /// for the server's lifetime so a cluster node keeps answering peers'
95 /// replication/election traffic. `None` for non-distributed boots. Dropping
96 /// the state stops the responder.
97 #[cfg(feature = "haematite-backend")]
98 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
99 /// The concrete distributed haematite store the SS-5b supervisor polls for
100 /// peer liveness. `None` for every non-distributed boot.
101 #[cfg(feature = "haematite-backend")]
102 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
103 /// The peers the SS-5b supervisor watches (each with the shards this node
104 /// adopts on its death). Empty for non-distributed boots.
105 #[cfg(feature = "haematite-backend")]
106 watched_peers: Vec<crate::cluster::WatchedPeer>,
107 /// The request-routing shard directory (R-2), built over the cluster store +
108 /// static peer config. `None` for every non-distributed boot, so the routing
109 /// edge falls back to the bare R-1 ownership check (and the default path is a
110 /// no-op).
111 #[cfg(feature = "haematite-backend")]
112 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
113 /// The request forwarder (R-3): relays a non-local signal/query/cancel to the
114 /// shard owner's gRPC address. `None` for non-distributed boots. The trait
115 /// object makes the liminal forwarder a one-line swap when 13-L0/L1 land (R-6).
116 #[cfg(feature = "haematite-backend")]
117 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
118 #[cfg(feature = "auth")]
119 jwks_cache: Option<JwksCache>,
120}
121
122impl ServerState {
123 /// Fallback cluster broadcast capacity for the `from_parts*` embedder/test
124 /// constructors, which bypass config validation. The config-driven
125 /// [`Self::build`] path always sizes the publisher from the validated
126 /// `websocket.cluster_broadcast_capacity` instead.
127 ///
128 /// `NonZeroUsize::new(64)` is statically non-`None`, so the
129 /// [`Option::unwrap`]-free `match` keeps the value `const` without tripping
130 /// the workspace `unwrap_used`/`expect_used` deny lints.
131 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
132 match std::num::NonZeroUsize::new(64) {
133 Some(value) => value,
134 None => std::num::NonZeroUsize::MIN,
135 };
136
137 /// Build shared state from operator configuration.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`ServerError`] if the store cannot connect or the engine cannot
142 /// be constructed.
143 pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
144 let (store_config, runtime) = config.into_parts();
145 let connected = connect_store(store_config).await?;
146 Self::build_with_connected_store(connected, runtime).await
147 }
148
149 /// Build shared state from an already-constructed store.
150 ///
151 /// # Errors
152 ///
153 /// Returns [`ServerError::EngineCall`] if the engine cannot be constructed.
154 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
155 where
156 S: EventStore + NamespaceStore,
157 {
158 // Capture the concrete leaf as BOTH the event store and the namespace
159 // registry before it is wrapped in the (NamespaceStore-unaware) decorator
160 // chain — the same leaf, two trait objects.
161 let leaf = Arc::new(store);
162 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
163 Self::build_with_connected_store(
164 ConnectedStore::local(leaf, None, namespace_store),
165 runtime,
166 )
167 .await
168 }
169
170 async fn build_with_connected_store(
171 connected: ConnectedStore,
172 runtime: RuntimeConfig,
173 ) -> Result<Self, ServerError> {
174 let outbox_store = connected.outbox_store;
175 let bootstrap_coordinator = connected.bootstrap_coordinator;
176 #[cfg(feature = "haematite-backend")]
177 let cluster_responder = connected.cluster_responder;
178 #[cfg(feature = "haematite-backend")]
179 let cluster_store = connected.cluster_store;
180 #[cfg(feature = "haematite-backend")]
181 let watched_peers = connected.watched_peers;
182 // Capture this node's self-identity for the WS3 cluster snapshot before
183 // `self_node_id` is moved into the routing-state builder below.
184 #[cfg(feature = "haematite-backend")]
185 let cluster_self_node = connected.self_node_id.clone();
186 #[cfg(not(feature = "haematite-backend"))]
187 let cluster_self_node: Option<String> = None;
188 // Build the R-2 directory + R-3 forwarder over the (live, failover-aware)
189 // cluster store and static peer config. Both present only for a
190 // distributed boot; `None` otherwise leaves the routing edge a no-op.
191 #[cfg(feature = "haematite-backend")]
192 let RoutingState {
193 shard_directory,
194 request_forwarder,
195 } = build_routing_state(
196 cluster_store.as_ref(),
197 connected.directory_peers,
198 connected.self_node_id,
199 );
200 let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
201 let (cluster_publisher, transcript_publisher) =
202 build_real_time_publishers(&runtime, connected.observability_store)?;
203 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
204 // LSUB-2 advisory wake: one process-wide `Notify` shared by the engine's
205 // stage seam and the outbox dispatcher. A single handle is correct here
206 // because there is exactly one in-process dispatcher that sweeps all owned
207 // shards per tick — a wake just means "something was staged; sweep".
208 let outbox_wake = Arc::new(tokio::sync::Notify::new());
209 let instrumented_store = Arc::new(
210 InstrumentedEventStore::new(
211 connected.event_store,
212 metrics.clone(),
213 runtime.default_namespace.clone(),
214 )
215 .with_outbox_wake(Arc::clone(&outbox_wake)),
216 );
217 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
218 // WS3 topology deltas + Control-Plane Phase 1 mint hook (`with_namespace_minting`).
219 let worker_registry = ConnectedWorkerRegistry::default()
220 .with_cluster_publisher(cluster_publisher.clone())
221 .with_namespace_minting(connected.namespace_store.clone(), runtime.auto_create);
222 let pending_activities = PendingActivities::default();
223 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
224 let drain_state = DrainState::default();
225 let (dispatcher, attempt_owners) = build_bridge_dispatcher(
226 &runtime,
227 &worker_registry,
228 &pending_activities,
229 &heartbeat_tracker,
230 &drain_state,
231 );
232 let (activity_dispatcher, activity_mock_registry) =
233 decorate_activity_dispatcher(dispatcher, runtime.dev.enabled);
234
235 let engine = build_engine(EngineAssembly {
236 instrumented_store: &instrumented_store,
237 event_broadcast_capacity,
238 query_timeout,
239 activity_dispatcher,
240 active_registry: Arc::new(aion::Registry::default()),
241 bootstrap_coordinator,
242 runtime: &runtime,
243 })
244 .await?;
245 let engine = Arc::new(engine);
246 install_outbox_delivery(&pending_activities, &engine, runtime.outbox.enabled);
247 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
248 #[cfg(feature = "auth")]
249 let jwks_cache = build_jwks_cache(&runtime).await?;
250 Ok(Self {
251 inner: Arc::new(ServerStateInner {
252 namespace_guard: NamespaceGuard::new(resolver),
253 runtime,
254 worker_registry,
255 pending_activities,
256 heartbeat_tracker,
257 drain_state,
258 metrics: exported_metrics,
259 health: Some(HealthState::new(instrumented_store, true)),
260 activity_mock_registry,
261 outbox_store,
262 namespace_store: connected.namespace_store,
263 outbox_wake,
264 cluster_publisher,
265 transcript_publisher,
266 attempt_owners,
267 cluster_self_node,
268 #[cfg(feature = "haematite-backend")]
269 cluster_responder,
270 #[cfg(feature = "haematite-backend")]
271 cluster_store,
272 #[cfg(feature = "haematite-backend")]
273 watched_peers,
274 #[cfg(feature = "haematite-backend")]
275 shard_directory,
276 #[cfg(feature = "haematite-backend")]
277 request_forwarder,
278 #[cfg(feature = "auth")]
279 jwks_cache,
280 }),
281 })
282 }
283
284 /// Build shared state from explicit parts with a default worker registry.
285 #[must_use]
286 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
287 // No durable store was supplied (this constructor builds state from a
288 // resolver only), so the registry is a local-only in-memory store —
289 // present so `namespace_store()` is always reachable, never mutating any
290 // durable backend.
291 Self::from_parts_with_namespace_store(
292 namespace_resolver,
293 runtime,
294 Arc::new(aion_store::InMemoryStore::default()),
295 )
296 }
297
298 /// Build shared state from explicit parts with a caller-supplied durable
299 /// namespace registry.
300 ///
301 /// Identical to [`Self::from_parts`] except the namespace registry is the
302 /// supplied store rather than a fresh in-memory one, so a caller can seed the
303 /// durable set the control-plane read/create paths (`GET`/`POST
304 /// /namespaces`) observe.
305 #[must_use]
306 pub fn from_parts_with_namespace_store(
307 namespace_resolver: NamespaceResolver,
308 runtime: RuntimeConfig,
309 namespace_store: Arc<dyn NamespaceStore>,
310 ) -> Self {
311 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
312 // Computed before `runtime` moves into the state: the retention bounds
313 // flow from `[observability]` config on the embedder path too, so a
314 // from-parts server enforces the same truncation/cap as a full boot.
315 let bounds = transcript_bounds(&runtime);
316 Self {
317 inner: Arc::new(ServerStateInner {
318 namespace_guard: NamespaceGuard::new(namespace_resolver),
319 runtime,
320 worker_registry: ConnectedWorkerRegistry::default(),
321 pending_activities: PendingActivities::default(),
322 heartbeat_tracker,
323 drain_state: DrainState::default(),
324 metrics: None,
325 health: None,
326 activity_mock_registry: None,
327 outbox_store: None,
328 namespace_store,
329 outbox_wake: Arc::new(tokio::sync::Notify::new()),
330 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
331 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
332 ),
333 // NOI-5b: a from-parts / embedder state has no durable store, so
334 // the transcript sequencer runs over an in-memory `O`-keyspace
335 // impl — the transcript channel is served on every boot.
336 transcript_publisher: build_transcript_publisher(
337 None,
338 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
339 bounds,
340 ),
341 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
342 cluster_self_node: None,
343 #[cfg(feature = "haematite-backend")]
344 cluster_responder: None,
345 #[cfg(feature = "haematite-backend")]
346 cluster_store: None,
347 #[cfg(feature = "haematite-backend")]
348 watched_peers: Vec::new(),
349 #[cfg(feature = "haematite-backend")]
350 shard_directory: None,
351 #[cfg(feature = "haematite-backend")]
352 request_forwarder: None,
353 #[cfg(feature = "auth")]
354 jwks_cache: None,
355 }),
356 }
357 }
358
359 /// Build shared state from explicit parts with BOTH a caller-supplied
360 /// durable namespace registry AND a caller-supplied JWKS cache.
361 ///
362 /// The combined seam of [`Self::from_parts_with_namespace_store`] (seed the
363 /// durable registry the control-plane read/create paths observe) and
364 /// [`Self::from_parts_with_jwks`] (validate bearer tokens against an injected
365 /// issuer): an enumerated caller can exercise the real JWT authorization path
366 /// against a seeded registry without a full [`Self::build`] boot.
367 #[cfg(feature = "auth")]
368 #[must_use]
369 pub fn from_parts_with_namespace_store_and_jwks(
370 namespace_resolver: NamespaceResolver,
371 runtime: RuntimeConfig,
372 namespace_store: Arc<dyn NamespaceStore>,
373 jwks_cache: JwksCache,
374 ) -> Self {
375 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
376 // Computed before `runtime` moves into the state: the retention bounds
377 // flow from `[observability]` config on the embedder path too, so a
378 // from-parts server enforces the same truncation/cap as a full boot.
379 let bounds = transcript_bounds(&runtime);
380 Self {
381 inner: Arc::new(ServerStateInner {
382 namespace_guard: NamespaceGuard::new(namespace_resolver),
383 runtime,
384 worker_registry: ConnectedWorkerRegistry::default(),
385 pending_activities: PendingActivities::default(),
386 heartbeat_tracker,
387 drain_state: DrainState::default(),
388 metrics: None,
389 health: None,
390 activity_mock_registry: None,
391 outbox_store: None,
392 namespace_store,
393 outbox_wake: Arc::new(tokio::sync::Notify::new()),
394 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
395 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
396 ),
397 // NOI-5b: a from-parts / embedder state has no durable store, so
398 // the transcript sequencer runs over an in-memory `O`-keyspace
399 // impl — the transcript channel is served on every boot.
400 transcript_publisher: build_transcript_publisher(
401 None,
402 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
403 bounds,
404 ),
405 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
406 cluster_self_node: None,
407 #[cfg(feature = "haematite-backend")]
408 cluster_responder: None,
409 #[cfg(feature = "haematite-backend")]
410 cluster_store: None,
411 #[cfg(feature = "haematite-backend")]
412 watched_peers: Vec::new(),
413 #[cfg(feature = "haematite-backend")]
414 shard_directory: None,
415 #[cfg(feature = "haematite-backend")]
416 request_forwarder: None,
417 jwks_cache: Some(jwks_cache),
418 }),
419 }
420 }
421
422 /// Build shared state from explicit parts with a caller-supplied JWKS cache.
423 ///
424 /// Embedders that construct their own [`JwksCache`] (for example against a
425 /// private issuer) can install it here; transports then validate bearer
426 /// tokens against it exactly as with a [`Self::build`]-constructed state.
427 #[cfg(feature = "auth")]
428 #[must_use]
429 pub fn from_parts_with_jwks(
430 namespace_resolver: NamespaceResolver,
431 runtime: RuntimeConfig,
432 jwks_cache: JwksCache,
433 ) -> Self {
434 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
435 // Computed before `runtime` moves into the state: the retention bounds
436 // flow from `[observability]` config on the embedder path too, so a
437 // from-parts server enforces the same truncation/cap as a full boot.
438 let bounds = transcript_bounds(&runtime);
439 Self {
440 inner: Arc::new(ServerStateInner {
441 namespace_guard: NamespaceGuard::new(namespace_resolver),
442 runtime,
443 worker_registry: ConnectedWorkerRegistry::default(),
444 pending_activities: PendingActivities::default(),
445 heartbeat_tracker,
446 drain_state: DrainState::default(),
447 metrics: None,
448 health: None,
449 activity_mock_registry: None,
450 outbox_store: None,
451 // No durable store was supplied (these constructors build state
452 // from a resolver only), so the registry is a local-only
453 // in-memory store — present so `namespace_store()` is always
454 // reachable, never mutating any durable backend.
455 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
456 outbox_wake: Arc::new(tokio::sync::Notify::new()),
457 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
458 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
459 ),
460 // NOI-5b: a from-parts / embedder state has no durable store, so
461 // the transcript sequencer runs over an in-memory `O`-keyspace
462 // impl — the transcript channel is served on every boot.
463 transcript_publisher: build_transcript_publisher(
464 None,
465 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
466 bounds,
467 ),
468 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
469 cluster_self_node: None,
470 #[cfg(feature = "haematite-backend")]
471 cluster_responder: None,
472 #[cfg(feature = "haematite-backend")]
473 cluster_store: None,
474 #[cfg(feature = "haematite-backend")]
475 watched_peers: Vec::new(),
476 #[cfg(feature = "haematite-backend")]
477 shard_directory: None,
478 #[cfg(feature = "haematite-backend")]
479 request_forwarder: None,
480 jwks_cache: Some(jwks_cache),
481 }),
482 }
483 }
484
485 /// Build shared state from explicit parts with a caller-supplied registry.
486 #[must_use]
487 pub fn from_parts_with_registry(
488 namespace_resolver: NamespaceResolver,
489 runtime: RuntimeConfig,
490 worker_registry: ConnectedWorkerRegistry,
491 ) -> Self {
492 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
493 // Computed before `runtime` moves into the state: the retention bounds
494 // flow from `[observability]` config on the embedder path too, so a
495 // from-parts server enforces the same truncation/cap as a full boot.
496 let bounds = transcript_bounds(&runtime);
497 Self {
498 inner: Arc::new(ServerStateInner {
499 namespace_guard: NamespaceGuard::new(namespace_resolver),
500 runtime,
501 worker_registry,
502 pending_activities: PendingActivities::default(),
503 heartbeat_tracker,
504 drain_state: DrainState::default(),
505 metrics: None,
506 health: None,
507 activity_mock_registry: None,
508 outbox_store: None,
509 // No durable store was supplied (these constructors build state
510 // from a resolver only), so the registry is a local-only
511 // in-memory store — present so `namespace_store()` is always
512 // reachable, never mutating any durable backend.
513 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
514 outbox_wake: Arc::new(tokio::sync::Notify::new()),
515 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
516 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
517 ),
518 // NOI-5b: a from-parts / embedder state has no durable store, so
519 // the transcript sequencer runs over an in-memory `O`-keyspace
520 // impl — the transcript channel is served on every boot.
521 transcript_publisher: build_transcript_publisher(
522 None,
523 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
524 bounds,
525 ),
526 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
527 cluster_self_node: None,
528 #[cfg(feature = "haematite-backend")]
529 cluster_responder: None,
530 #[cfg(feature = "haematite-backend")]
531 cluster_store: None,
532 #[cfg(feature = "haematite-backend")]
533 watched_peers: Vec::new(),
534 #[cfg(feature = "haematite-backend")]
535 shard_directory: None,
536 #[cfg(feature = "haematite-backend")]
537 request_forwarder: None,
538 #[cfg(feature = "auth")]
539 jwks_cache: None,
540 }),
541 }
542 }
543
544 /// Borrow the namespace guard shared by all transports.
545 #[must_use]
546 pub fn namespace_guard(&self) -> &NamespaceGuard {
547 &self.inner.namespace_guard
548 }
549
550 /// Build the deploy authorization guard over the shared resolver.
551 #[must_use]
552 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
553 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
554 }
555
556 /// Borrow non-secret runtime settings needed by transports.
557 #[must_use]
558 pub fn runtime_config(&self) -> &RuntimeConfig {
559 &self.inner.runtime
560 }
561
562 /// Borrow the connected-worker registry shared by worker transports and dispatch.
563 #[must_use]
564 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
565 &self.inner.worker_registry
566 }
567
568 /// Borrow the WS3 cluster-event publisher shared by the cluster state-change
569 /// sites (supervisor, worker registry) and the cluster subscription endpoint.
570 /// Always present, on every boot.
571 #[must_use]
572 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
573 &self.inner.cluster_publisher
574 }
575
576 /// Borrow the NOI-5b transcript sequencer shared by the worker->server
577 /// ingestion seam (which publishes a running activity's `ActivityEvent`s) and
578 /// the transcript subscription endpoint (which tails + resumes them). Always
579 /// present, on every boot.
580 #[must_use]
581 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
582 &self.inner.transcript_publisher
583 }
584
585 /// Borrow the NOI-6 `attempt -> owning-worker` back-index. The agent-dispatch
586 /// path binds an owner when it dispatches an agent attempt and releases it on
587 /// completion, so the intervention router always resolves the CURRENT owner.
588 #[must_use]
589 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
590 &self.inner.attempt_owners
591 }
592
593 /// Build the NOI-6 intervention router over the connected-worker registry, the
594 /// attempt-owner back-index, and the active intervention transport.
595 ///
596 /// The transport is the liminal server-push
597 /// ([`LiminalInterventionTransport`](crate::worker::LiminalInterventionTransport))
598 /// when the `liminal-transport` feature is compiled in — the production path
599 /// that pushes a routed command out on the owning worker's connection — and a
600 /// null transport otherwise, which reports the target unreachable so every
601 /// command NACKs the attempt-scoped no-op rather than silently vanishing. The
602 /// router is cheap to build (it clones cloneable handles), so it is constructed
603 /// per request at the endpoint rather than stored.
604 #[must_use]
605 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
606 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
607 #[cfg(feature = "liminal-transport")]
608 {
609 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
610 }
611 #[cfg(not(feature = "liminal-transport"))]
612 {
613 std::sync::Arc::new(NullInterventionTransport)
614 }
615 };
616 crate::worker::InterventionRouter::new(
617 self.inner.worker_registry.clone(),
618 self.inner.attempt_owners.clone(),
619 transport,
620 )
621 // Lane #229: an APPLIED InjectMessage is teed into the durable
622 // transcript, so the retained record holds the operator's words.
623 .with_transcript_publisher(self.inner.transcript_publisher.clone())
624 }
625
626 /// This node's configured cluster distribution name for the WS3 snapshot
627 /// self-identity, or `None` on a single-node boot (the snapshot then reports
628 /// the standalone self-label).
629 #[must_use]
630 pub fn cluster_self_node(&self) -> Option<&str> {
631 self.inner.cluster_self_node.as_deref()
632 }
633
634 /// Clone the live engine handle the completion path records terminals through.
635 ///
636 /// This is the SAME `Arc<Engine>` the gRPC completion callback is built over
637 /// (state.rs installs `ServerOutboxDeliveryCallback::new(engine)` on the
638 /// pending tracker when `outbox.enabled`), so the liminal completion path
639 /// re-enters worker results through the identical `record_fan_out_completion`
640 /// seam rather than inventing a second one.
641 ///
642 /// # Errors
643 ///
644 /// Returns [`ServerError`] when the namespace resolver has no engine handle
645 /// (a state built from parts without an engine).
646 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
647 self.inner
648 .namespace_guard
649 .resolver()
650 .engine()
651 .map(Arc::clone)
652 }
653
654 /// Borrow the pending-activities tracker shared by the NIF bridge and worker stream handler.
655 #[must_use]
656 pub fn pending_activities(&self) -> &PendingActivities {
657 &self.inner.pending_activities
658 }
659
660 /// Borrow the heartbeat/liveness tracker shared by dispatch and worker streams.
661 #[must_use]
662 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
663 &self.inner.heartbeat_tracker
664 }
665
666 /// Borrow the drain gate shared by transports and worker dispatch.
667 #[must_use]
668 pub fn drain_state(&self) -> &DrainState {
669 &self.inner.drain_state
670 }
671
672 /// Borrow the prometheus metrics handle when this state was built with a store.
673 #[must_use]
674 pub fn metrics(&self) -> Option<&Metrics> {
675 self.inner.metrics.as_ref()
676 }
677
678 /// Borrow health probe state when this state was built with a store.
679 #[must_use]
680 pub fn health(&self) -> Option<&HealthState> {
681 self.inner.health.as_ref()
682 }
683
684 /// Borrow the shared per-run activity-mock registry when the dev surface is
685 /// commissioned. Returns [`None`] on a server with the dev surface dark, so
686 /// the dev handlers refuse cleanly rather than mocking on a production
687 /// server.
688 #[must_use]
689 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
690 self.inner.activity_mock_registry.as_ref()
691 }
692
693 /// Borrow the outbox store the dispatcher claims rows from, when the durable
694 /// (libSQL) backend is in use. This is the SAME leaf `Arc<LibSqlStore>` the
695 /// engine writes through, so the dispatcher shares its single
696 /// `libsql::Connection` rather than opening a second contending one. Returns
697 /// [`None`] for the in-memory backend, which has no outbox table.
698 #[must_use]
699 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
700 self.inner.outbox_store.clone()
701 }
702
703 /// Borrow the durable namespace registry shared by the control plane.
704 ///
705 /// This is the SAME concrete leaf backend the engine writes events through
706 /// (haematite quorum-replicated, or libSQL / in-memory local-only),
707 /// captured as a [`NamespaceStore`] before the decorator chain wrapped it.
708 /// Always present on every boot, so the mint-on-register path (Phase 1 S5)
709 /// and `GET /namespaces` (S7) can reach a real registry regardless of
710 /// backend.
711 #[must_use]
712 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
713 &self.inner.namespace_store
714 }
715
716 /// Build the shared minted-on-use hook over the durable namespace store and
717 /// the configured [`AutoCreate`](crate::config::AutoCreate) policy.
718 ///
719 /// This is the SAME policy logic the worker-registration seam applies (S5);
720 /// the workflow-start safety net (S6) calls it after authorization so a
721 /// client that starts a workflow before any worker registers still gets a
722 /// durable namespace record. Cheap to build (clones an `Arc` + a `Copy`
723 /// policy), so transports construct it per request rather than holding it.
724 #[must_use]
725 pub fn namespace_minter(&self) -> NamespaceMinter {
726 NamespaceMinter::new(
727 Arc::clone(&self.inner.namespace_store),
728 self.inner.runtime.auto_create,
729 )
730 // Thread the deployment-global cluster channel so the start-time safety
731 // net (S6) and the explicit `POST /namespaces` path (S7) emit the same
732 // live "namespace created" delta the worker-mint seam (S5) does — all
733 // three mint choke-points surface on the one ops-console push channel.
734 .with_cluster_publisher(self.inner.cluster_publisher.clone())
735 }
736
737 /// Clone the advisory outbox wake (LSUB-2) shared with the engine's stage
738 /// seam. The outbox dispatcher installs this handle so a committed fan-out row
739 /// wakes its run loop in ~RTT rather than waiting for the next poll tick. The
740 /// handle is always present; it is simply never pulsed when the outbox is not
741 /// commissioned, so wiring it is free and behaviour is unchanged.
742 #[must_use]
743 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
744 Arc::clone(&self.inner.outbox_wake)
745 }
746
747 /// Whether this server is a node in a distributed haematite cluster.
748 ///
749 /// `true` when boot constructed the distributed backend (a `[store.cluster]`
750 /// section was present) and is holding its inbound-write responder alive;
751 /// `false` for every single-node / non-haematite boot.
752 #[cfg(feature = "haematite-backend")]
753 #[must_use]
754 pub fn is_clustered(&self) -> bool {
755 self.inner.cluster_responder.is_some()
756 }
757
758 /// The concrete distributed haematite store the request-routing edge consults
759 /// for shard ownership (`shard_for_workflow` / `owns_workflow_shard`) and
760 /// unsteered-start remint. `None` for every single-node / non-clustered boot,
761 /// so the routing pre-step is a no-op and the default path is unchanged.
762 #[cfg(feature = "haematite-backend")]
763 #[must_use]
764 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
765 self.inner.cluster_store.as_ref()
766 }
767
768 /// The request-routing shard directory (R-2) the edge consults to resolve a
769 /// non-owned shard's owner. `None` for single-node / non-clustered boots, so
770 /// the edge falls back to the bare R-1 ownership check.
771 #[cfg(feature = "haematite-backend")]
772 #[must_use]
773 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
774 self.inner.shard_directory.as_ref()
775 }
776
777 /// The R-3 request forwarder used to relay a non-local signal/query/cancel to
778 /// the shard owner. `None` for single-node / non-clustered boots.
779 #[cfg(feature = "haematite-backend")]
780 #[must_use]
781 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
782 self.inner.request_forwarder.as_ref()
783 }
784
785 /// Spawn the worker heartbeat expiry sweeper (#176): the production driver
786 /// of [`HeartbeatTracker::fail_expired_workers`], failing every worker with
787 /// an in-flight task beyond the operator's `worker.heartbeat_window` and
788 /// deregistering it with the provable
789 /// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout).
790 ///
791 /// Always spawned on the server boot path — dead-worker detection is a
792 /// liveness correctness property, not an opt-in feature. The cadence is
793 /// derived from the heartbeat window
794 /// ([`sweep_interval`](crate::worker::sweep_interval): a quarter of the
795 /// window clamped to `[1s, window]`, so the default 30s window sweeps every
796 /// 7.5s); there is deliberately no separate config knob. The task exits
797 /// when `shutdown` flips to `true`, exactly like the transports; the
798 /// returned handle may be dropped to detach it (dropping a tokio
799 /// `JoinHandle` never cancels the task) and is returned so tests can await
800 /// clean shutdown.
801 #[must_use]
802 pub fn spawn_heartbeat_sweeper(
803 &self,
804 shutdown: tokio::sync::watch::Receiver<bool>,
805 ) -> tokio::task::JoinHandle<()> {
806 let sweeper = crate::worker::HeartbeatSweeper::new(
807 self.inner.heartbeat_tracker.clone(),
808 self.inner.worker_registry.clone(),
809 self.inner.pending_activities.clone(),
810 self.inner.drain_state.clone(),
811 self.inner.runtime.worker.heartbeat_window,
812 );
813 tokio::spawn(sweeper.run(shutdown))
814 }
815
816 /// Spawn the SS-5b cluster supervisor: a background task that watches every
817 /// declared peer's replication liveness and, on a confirmed peer death,
818 /// calls `adopt_shards` for that peer's shards on THIS node's live engine —
819 /// automatic failover with no manual trigger.
820 ///
821 /// Does nothing (returns `Ok(())` without spawning) unless this is a
822 /// distributed boot whose cluster config declared at least one peer with
823 /// `owned_shards`. A single-node / non-clustered server therefore never runs
824 /// a supervisor, so default behaviour is unchanged.
825 ///
826 /// The spawned task drains on `shutdown` exactly like the transports.
827 ///
828 /// # Errors
829 ///
830 /// Returns [`ServerError`] when the engine handle cannot be resolved.
831 #[cfg(feature = "haematite-backend")]
832 pub fn spawn_cluster_supervisor(
833 &self,
834 config: crate::cluster::SupervisorConfig,
835 shutdown: tokio::sync::watch::Receiver<bool>,
836 ) -> Result<bool, ServerError> {
837 let Some(cluster_store) = self.inner.cluster_store.clone() else {
838 return Ok(false);
839 };
840 if self.inner.watched_peers.is_empty() {
841 return Ok(false);
842 }
843 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
844 // WS3: feed cluster topology deltas from the supervisor's existing
845 // decision points into the ops console channel. `self_node` is the
846 // configured distribution name (already captured for the snapshot).
847 let publisher = Arc::new(self.inner.cluster_publisher.clone());
848 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
849 // #253: adoption re-runs the terminal-workflow outbox settlement sweep
850 // over the widened owned-shard scope, so a dead peer's stranded row for
851 // a terminal workflow is settled — never re-armed — by its adopter.
852 // With no outbox commissioned there is nothing to settle and the
853 // adopter delegates straight to the engine.
854 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
855 engine,
856 self.inner.outbox_store.clone(),
857 ));
858 let supervisor = crate::cluster::ClusterSupervisor::new(
859 cluster_store,
860 adopter,
861 self.inner.watched_peers.clone(),
862 config,
863 )
864 .with_publisher(publisher, self_node);
865 if !supervisor.watches_any() {
866 return Ok(false);
867 }
868 tokio::spawn(supervisor.run(shutdown));
869 Ok(true)
870 }
871
872 /// Borrow the shared JWKS cache when authentication is enabled.
873 #[cfg(feature = "auth")]
874 #[must_use]
875 pub fn jwks_cache(&self) -> Option<&JwksCache> {
876 self.inner.jwks_cache.as_ref()
877 }
878
879 /// Shut down the embedded engine so in-flight durable appends can finish.
880 ///
881 /// # Errors
882 ///
883 /// Returns [`ServerError`] if the namespace resolver has no engine handle or the engine rejects
884 /// shutdown.
885 pub fn shutdown(&self) -> Result<(), ServerError> {
886 self.inner.namespace_guard.resolver().shutdown_engine()
887 }
888}
889
890#[cfg(feature = "auth")]
891async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
892 if !runtime.auth.enabled {
893 return Ok(None);
894 }
895 let Some(url) = runtime.auth.jwks_url.clone() else {
896 return Err(ServerError::Config {
897 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
898 });
899 };
900 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
901 let cache = JwksCache::new(url, interval)
902 .await
903 .map_err(|error| ServerError::Config {
904 message: format!("auth jwks initial fetch failed: {error}"),
905 })?;
906 Ok(Some(cache))
907}
908
909fn metrics_config_error(error: &MetricsError) -> ServerError {
910 ServerError::Config {
911 message: error.to_string(),
912 }
913}
914
915/// Borrowed inputs assembled into the embedded engine by [`build_engine`].
916struct EngineAssembly<'a> {
917 /// The metrics-instrumented store the engine writes through.
918 instrumented_store: &'a Arc<InstrumentedEventStore>,
919 /// Explicitly-sized broadcast channel capacity for `/events/stream`.
920 event_broadcast_capacity: std::num::NonZeroUsize,
921 /// Explicit workflow-query reply deadline for `/workflows/query`.
922 query_timeout: std::time::Duration,
923 /// The activity dispatcher (optionally dev-mock-decorated) the engine uses.
924 activity_dispatcher: Arc<dyn ActivityDispatcher>,
925 /// The shared active-workflow registry server dispatchers correlate against.
926 active_registry: Arc<aion::Registry>,
927 /// Whether THIS node seeds the schedule coordinator (SS-2 ownership gate).
928 bootstrap_coordinator: bool,
929 /// Non-secret runtime settings driving scheduler/outbox/package/shard knobs.
930 runtime: &'a RuntimeConfig,
931}
932
933/// Assemble the embedded engine from the server's runtime configuration.
934///
935/// Factored out of [`ServerState::build_with_connected_store`] to keep that
936/// method within length bounds; it carries the SS-2 wiring — the coordinator
937/// bootstrap gate fed from real ownership and the `owned_shards` hook that drives
938/// both scoping and the per-shard election before recovery.
939async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
940 let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
941 search_attribute_schema
942 .register(
943 crate::namespace::NAMESPACE_ATTRIBUTE,
944 aion_core::SearchAttributeType::String,
945 )
946 .map_err(|error| ServerError::Config {
947 message: format!("failed to register namespace search attribute: {error}"),
948 })?;
949 search_attribute_schema
950 .register(
951 crate::namespace::TASK_QUEUE_ATTRIBUTE,
952 aion_core::SearchAttributeType::String,
953 )
954 .map_err(|error| ServerError::Config {
955 message: format!("failed to register task_queue search attribute: {error}"),
956 })?;
957 let runtime = assembly.runtime;
958 let builder = EngineBuilder::new()
959 .store_arc(assembly.instrumented_store.clone())
960 .event_streaming(assembly.event_broadcast_capacity)
961 .in_memory_visibility()
962 .search_attribute_schema(search_attribute_schema)
963 .scheduler_threads(runtime.scheduler_threads)
964 .outbox_enabled(runtime.outbox.enabled)
965 .activity_dispatcher(assembly.activity_dispatcher)
966 .active_registry(assembly.active_registry)
967 .production_recovery_seam()
968 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
969 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
970 })
971 .query_timeout(assembly.query_timeout)
972 // SS-2: only the node owning the schedule-coordinator's shard seeds and
973 // serves it. `true` for every non-distributed boot (owns all shards); a
974 // distributed non-owner passes `false` so it does not fence the
975 // coordinator stream (AA-4-4). Default `true`, so a single-node boot is
976 // byte-identical to today.
977 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
978 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
979 // Owned-shard assignment: when the operator pins this node to a shard subset,
980 // scope the engine to it AND (SS-2) elect those shards before recovery — the
981 // builder's `owned_shards` hook drives both. Empty (the default) leaves the
982 // builder untouched, so single-node boot owns ALL shards, elects nothing, and
983 // is byte-identical to today.
984 let builder = if runtime.owned_shards.is_empty() {
985 builder
986 } else {
987 builder.owned_shards(runtime.owned_shards.iter().copied())
988 };
989 builder.build().await.map_err(ServerError::from)
990}
991
992/// Validate the two engine seams the server unconditionally mounts: the event
993/// broadcast channel capacity (`/events/stream`) and the query reply deadline
994/// (`/workflows/query`). Both are explicit-no-default — a mounted-but-
995/// unconfigured surface is never acceptable.
996fn required_engine_seams(
997 runtime: &RuntimeConfig,
998) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
999 let event_broadcast_capacity = runtime
1000 .websocket
1001 .event_broadcast_capacity
1002 .and_then(std::num::NonZeroUsize::new)
1003 .ok_or_else(|| ServerError::Config {
1004 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1005 })?;
1006 let query_timeout = runtime
1007 .query_timeout
1008 .filter(|timeout| !timeout.is_zero())
1009 .ok_or_else(|| ServerError::Config {
1010 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1011 })?;
1012 Ok((event_broadcast_capacity, query_timeout))
1013}
1014
1015/// Install the outbox delivery callback when the durable outbox is commissioned.
1016///
1017/// Routes unmatched worker completions arriving at the sink into the live
1018/// workflow's mailbox. Flag-off, no callback is installed and the sink's
1019/// unmatched branch stays a silent drop. The dispatcher is not rebuilt — it
1020/// shares this exact pending tracker.
1021fn install_outbox_delivery(
1022 pending_activities: &PendingActivities,
1023 engine: &Arc<aion::Engine>,
1024 outbox_enabled: bool,
1025) {
1026 if outbox_enabled {
1027 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1028 Arc::clone(engine),
1029 ));
1030 pending_activities.set_outbox_delivery(callback);
1031 }
1032}
1033
1034/// Decorate the worker activity dispatcher with the per-run activity-mock layer
1035/// when the dev surface is commissioned, returning the dispatcher and the shared
1036/// mock registry (if any).
1037///
1038/// Dark by default: with the dev surface off the engine gets the bare production
1039/// dispatcher and there is no mocking path at all (CN4).
1040/// Compose the engine-seam bridge dispatcher over the state's shared parts.
1041///
1042/// Also mints the NOI-6 attempt→owner index and returns it alongside: the
1043/// bridge binds each liminal-delivered attempt into it for the dispatch's
1044/// lifetime, and the state stores the SAME instance for the intervention
1045/// router to read, so the ops console can enumerate and target live attempts.
1046fn build_bridge_dispatcher(
1047 runtime: &RuntimeConfig,
1048 worker_registry: &ConnectedWorkerRegistry,
1049 pending_activities: &PendingActivities,
1050 heartbeat_tracker: &HeartbeatTracker,
1051 drain_state: &DrainState,
1052) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1053 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1054 let dispatcher = WorkerActivityDispatcher::new(
1055 worker_registry.clone(),
1056 runtime.default_namespace.clone(),
1057 heartbeat_tracker.clone(),
1058 )
1059 .with_pending(pending_activities.clone())
1060 .with_drain_state(drain_state.clone())
1061 .with_tokio_handle(tokio::runtime::Handle::current())
1062 .with_attempt_owners(attempt_owners.clone());
1063 (dispatcher, attempt_owners)
1064}
1065
1066fn decorate_activity_dispatcher(
1067 dispatcher: WorkerActivityDispatcher,
1068 dev_enabled: bool,
1069) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1070 if dev_enabled {
1071 let registry = ActivityMockRegistry::new();
1072 let decorated = DevMockingDispatcher::new(Arc::new(dispatcher), registry.clone());
1073 (Arc::new(decorated), Some(registry))
1074 } else {
1075 (Arc::new(dispatcher), None)
1076 }
1077}
1078
1079/// Validate the WS3 cluster broadcast capacity the server unconditionally mounts
1080/// (the `cluster` subscription on `/events/stream`). Explicit-no-default with the
1081/// same non-zero startup guard as the workflow event channel: the lag contract
1082/// has no buffer to lag against unless sized.
1083fn required_cluster_broadcast_capacity(
1084 runtime: &RuntimeConfig,
1085) -> Result<std::num::NonZeroUsize, ServerError> {
1086 runtime
1087 .websocket
1088 .cluster_broadcast_capacity
1089 .and_then(std::num::NonZeroUsize::new)
1090 .ok_or_else(|| ServerError::Config {
1091 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1092 })
1093}
1094
1095/// Build the deployment-wide real-time publishers the server mounts on every
1096/// boot — the WS3 cluster topology channel and the NOI-5b agent-observability
1097/// transcript channel — from the validated `websocket.cluster_broadcast_capacity`.
1098///
1099/// The transcript sequencer runs over `observability_store` (the durable
1100/// `O`-keyspace impl on a haematite boot) or an in-memory impl when the backend
1101/// has none — see [`build_transcript_publisher`].
1102///
1103/// # Errors
1104///
1105/// Returns [`ServerError`] when `websocket.cluster_broadcast_capacity` is unset
1106/// or zero (the same explicit-no-default guard the cluster channel already had).
1107fn build_real_time_publishers(
1108 runtime: &RuntimeConfig,
1109 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1110) -> Result<
1111 (
1112 crate::cluster_publisher::ClusterEventPublisher,
1113 crate::activity_publisher::ActivityEventPublisher,
1114 ),
1115 ServerError,
1116> {
1117 let capacity = required_cluster_broadcast_capacity(runtime)?;
1118 Ok((
1119 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1120 build_transcript_publisher(observability_store, capacity, transcript_bounds(runtime)),
1121 ))
1122}
1123
1124/// The operator-configured transcript retention bounds from `[observability]`.
1125fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1126 crate::activity_bounds::TranscriptBounds {
1127 max_event_bytes: runtime.observability.max_event_bytes,
1128 max_stream_events: runtime.observability.max_stream_events,
1129 }
1130}
1131
1132/// Build the NOI-5b transcript sequencer over `observability_store` (the durable
1133/// `O`-keyspace impl when the backend has one, an in-memory impl otherwise) with
1134/// a live-tail buffer of `capacity` and the `[observability]` retention bounds.
1135///
1136/// The publisher is ALWAYS constructed (the transcript channel is served on every
1137/// boot); only the durability of the backing store varies by backend. A backend
1138/// with no `O` keyspace (libSQL / in-memory) gets the in-memory
1139/// [`InMemoryObservabilityStore`](aion_store::InMemoryObservabilityStore), so the
1140/// live-tail + resume path behaves identically and only cross-restart durability
1141/// differs — exactly the "keep the no-observability path uniform" contract.
1142fn build_transcript_publisher(
1143 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1144 capacity: std::num::NonZeroUsize,
1145 bounds: crate::activity_bounds::TranscriptBounds,
1146) -> crate::activity_publisher::ActivityEventPublisher {
1147 let store = observability_store
1148 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1149 crate::activity_publisher::ActivityEventPublisher::new(store, capacity).with_bounds(bounds)
1150}
1151
1152/// The request-routing pieces built from the cluster store + peer config.
1153#[cfg(feature = "haematite-backend")]
1154struct RoutingState {
1155 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1156 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1157}
1158
1159/// Build the R-2 shard directory and R-3 request forwarder over the cluster
1160/// store and static peer config, or all-`None` when this is not a distributed
1161/// boot (no cluster store) so the routing edge is a no-op (default path).
1162#[cfg(feature = "haematite-backend")]
1163fn build_routing_state(
1164 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1165 directory_peers: Vec<crate::routing::DirectoryPeer>,
1166 self_node_id: Option<String>,
1167) -> RoutingState {
1168 let Some(store) = cluster_store else {
1169 return RoutingState {
1170 shard_directory: None,
1171 request_forwarder: None,
1172 };
1173 };
1174 RoutingState {
1175 shard_directory: Some(Arc::new(crate::routing::StaticShardDirectory::new(
1176 Arc::clone(store),
1177 directory_peers,
1178 self_node_id,
1179 ))),
1180 request_forwarder: Some(Arc::new(crate::routing::GrpcRequestForwarder::new())),
1181 }
1182}
1183
1184/// A connected durable store plus the lifecycle pieces the boot path needs.
1185///
1186/// `outbox_store` is the SAME leaf store cast as an [`OutboxStore`] for backends
1187/// with a durable outbox table (libSQL, haematite); the in-memory backend yields
1188/// `None`. `bootstrap_coordinator` gates the schedule-coordinator seed on real
1189/// ownership (SS-2 / AA-4-4): `true` for every non-distributed boot (single-node
1190/// owns the coordinator's shard), and for a distributed node only when it owns
1191/// that shard. `cluster_responder` owns the distributed inbound-write responder
1192/// thread, kept alive for the server's lifetime; `None` for non-distributed boots.
1193struct ConnectedStore {
1194 event_store: Arc<dyn EventStore>,
1195 outbox_store: Option<Arc<dyn OutboxStore>>,
1196 /// The SAME concrete leaf store as `event_store`, captured as a
1197 /// [`NamespaceStore`] before the decorator chain wraps it (the decorators
1198 /// are `NamespaceStore`-unaware). The control plane mints and lists through
1199 /// this handle. Every backend populates it: haematite supplies the
1200 /// quorum-replicated implementation, libSQL and in-memory the local-only
1201 /// one.
1202 namespace_store: Arc<dyn NamespaceStore>,
1203 /// NOI-5b: the SAME concrete leaf store captured as an
1204 /// [`ObservabilityStore`](aion_store::ObservabilityStore) when the backend
1205 /// implements the durable `O` keyspace (haematite). `None` for backends with
1206 /// no `O` keyspace (libSQL / in-memory), where the transcript sequencer runs
1207 /// over an in-memory impl instead. Captured before the leaf is wrapped in the
1208 /// (`ObservabilityStore`-unaware) decorator chain, exactly like
1209 /// `namespace_store`.
1210 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1211 bootstrap_coordinator: bool,
1212 #[cfg(feature = "haematite-backend")]
1213 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1214 /// The concrete distributed haematite store (the SAME leaf as `event_store`),
1215 /// retained for the SS-5b cluster supervisor's peer-liveness polling. `None`
1216 /// for every non-distributed boot.
1217 #[cfg(feature = "haematite-backend")]
1218 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1219 /// The peers the SS-5b supervisor watches, each with the shards this node
1220 /// adopts on its death. Empty for non-distributed boots.
1221 #[cfg(feature = "haematite-backend")]
1222 watched_peers: Vec<crate::cluster::WatchedPeer>,
1223 /// The static shard-directory peer entries (name + declared shards + gRPC
1224 /// forward address) used to build the request-routing directory (R-2). Empty
1225 /// for non-distributed boots.
1226 #[cfg(feature = "haematite-backend")]
1227 directory_peers: Vec<crate::routing::DirectoryPeer>,
1228 /// This node's own distribution name (cluster `node_id`), so the SS-3
1229 /// directory can resolve a shard-owner record naming THIS node to `Local`.
1230 /// `None` for non-distributed boots.
1231 #[cfg(feature = "haematite-backend")]
1232 self_node_id: Option<String>,
1233}
1234
1235impl ConnectedStore {
1236 /// A non-distributed connected store: owns the coordinator's shard (so it
1237 /// bootstraps the coordinator) and has no cluster responder.
1238 ///
1239 /// `namespace_store` is the SAME concrete leaf as `event_store`, captured as
1240 /// a [`NamespaceStore`] by the caller (where the concrete type is still
1241 /// known) before the decorator chain wraps the event store.
1242 fn local(
1243 event_store: Arc<dyn EventStore>,
1244 outbox_store: Option<Arc<dyn OutboxStore>>,
1245 namespace_store: Arc<dyn NamespaceStore>,
1246 ) -> Self {
1247 Self {
1248 event_store,
1249 outbox_store,
1250 namespace_store,
1251 // A `local` connected store is the memory / libSQL / embedder path,
1252 // none of which implement the durable `O` keyspace: the transcript
1253 // sequencer falls back to an in-memory impl (NOI-5b).
1254 observability_store: None,
1255 bootstrap_coordinator: true,
1256 #[cfg(feature = "haematite-backend")]
1257 cluster_responder: None,
1258 #[cfg(feature = "haematite-backend")]
1259 cluster_store: None,
1260 #[cfg(feature = "haematite-backend")]
1261 watched_peers: Vec::new(),
1262 #[cfg(feature = "haematite-backend")]
1263 directory_peers: Vec::new(),
1264 #[cfg(feature = "haematite-backend")]
1265 self_node_id: None,
1266 }
1267 }
1268}
1269
1270/// Connect the durable store, yielding the engine's [`EventStore`] handle and,
1271/// for the libSQL backend, the SAME leaf store cast as an [`OutboxStore`].
1272///
1273/// Both handles are clones of one `Arc<LibSqlStore>`, which holds a single
1274/// `libsql::Connection`. Sharing that connection with the outbox dispatcher
1275/// serializes the engine's `append_with_outbox` and the dispatcher's
1276/// `claim_outbox_rows` writes, so the two never contend across separate
1277/// connections and never raise `SQLITE_BUSY`. The in-memory backend has no
1278/// outbox table, so it yields `None`.
1279async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1280 match config.backend {
1281 StoreBackend::Memory => {
1282 // One leaf store, captured as both the engine's event store and the
1283 // namespace registry (in-memory backends have no outbox table).
1284 let leaf = Arc::new(aion_store::InMemoryStore::default());
1285 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1286 Ok(ConnectedStore::local(leaf, None, namespace_store))
1287 }
1288 StoreBackend::LibSql => {
1289 #[cfg(feature = "libsql-backend")]
1290 {
1291 connect_libsql_store(config).await
1292 }
1293 #[cfg(not(feature = "libsql-backend"))]
1294 {
1295 let _ = config;
1296 connect_libsql_store_unavailable()
1297 }
1298 }
1299 StoreBackend::Haematite => {
1300 #[cfg(feature = "haematite-backend")]
1301 {
1302 connect_haematite_store(config).await
1303 }
1304 #[cfg(not(feature = "haematite-backend"))]
1305 {
1306 let _ = config;
1307 connect_haematite_store_unavailable()
1308 }
1309 }
1310 }
1311}
1312
1313/// Connect the libSQL backend, opening the embedded database at `store.url` and
1314/// sharing the SAME leaf `Arc<LibSqlStore>` (one `libsql::Connection`) as both the
1315/// engine's [`EventStore`] and the dispatcher's [`OutboxStore`].
1316#[cfg(feature = "libsql-backend")]
1317async fn connect_libsql_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1318 let Some(url) = config.url else {
1319 return Err(ServerError::Config {
1320 message: "store.url must not be empty when store.backend is libsql".to_owned(),
1321 });
1322 };
1323 let store = LibSqlStore::open(url.clone())
1324 .await
1325 .map_err(ServerError::from)?;
1326 store
1327 .validate_event_compatibility()
1328 .await
1329 .map_err(|error| match error {
1330 aion_store::StoreError::Serialization(_) => ServerError::Config {
1331 message: format!(
1332 "Database schema mismatch — delete {url} and restart, or run migrations."
1333 ),
1334 },
1335 other => ServerError::from(other),
1336 })?;
1337 let leaf = Arc::new(store);
1338 let event_store: Arc<dyn EventStore> = leaf.clone();
1339 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1340 let outbox_store: Arc<dyn OutboxStore> = leaf;
1341 Ok(ConnectedStore::local(
1342 event_store,
1343 Some(outbox_store),
1344 namespace_store,
1345 ))
1346}
1347
1348/// Reject `backend = libsql` cleanly when the optional `libsql-backend` feature
1349/// is not compiled in, so a default (ablative-stack) build gives a precise
1350/// operator error instead of a silent fallthrough.
1351#[cfg(not(feature = "libsql-backend"))]
1352fn connect_libsql_store_unavailable() -> Result<ConnectedStore, ServerError> {
1353 Err(ServerError::Config {
1354 message: "store.backend = libsql requires the aion-server `libsql-backend` feature"
1355 .to_owned(),
1356 })
1357}
1358
1359/// Connect the haematite backend, opening the on-disk database if `store.data_dir`
1360/// already holds one and otherwise creating it with `store.shard_count` shards.
1361///
1362/// Without a `[store.cluster]` section this is the SINGLE-NODE path
1363/// ([`HaematiteStore::open`] / [`create_with_shard_count`]), byte-identical to
1364/// before: no endpoint, no election, owns everything, bootstraps the coordinator.
1365/// With a cluster section this is the DISTRIBUTED path
1366/// ([`HaematiteStore::open_or_create_distributed`]): it binds the replication
1367/// endpoint, builds the quorum membership, dials peers, starts the responder, and
1368/// computes whether THIS node owns the schedule-coordinator's shard so the engine
1369/// boot path seeds the coordinator on exactly one owner cluster-wide (SS-2).
1370///
1371/// The SAME leaf `Arc<HaematiteStore>` is shared as both the engine's
1372/// [`EventStore`] and the dispatcher's [`OutboxStore`] (one inner haematite
1373/// database), mirroring the libSQL backend.
1374///
1375/// [`HaematiteStore::open`]: aion_store_haematite::HaematiteStore::open
1376/// [`create_with_shard_count`]: aion_store_haematite::HaematiteStore::create_with_shard_count
1377/// [`HaematiteStore::open_or_create_distributed`]: aion_store_haematite::HaematiteStore::open_or_create_distributed
1378#[cfg(feature = "haematite-backend")]
1379async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1380 let Some(data_dir) = config.data_dir else {
1381 return Err(ServerError::Config {
1382 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
1383 });
1384 };
1385 let shard_count = config.shard_count;
1386 let owned_shards = config.owned_shards.clone();
1387 let cluster = config.cluster.clone();
1388 // The peers the SS-5b supervisor watches, captured before `cluster` is moved
1389 // into the blocking build. A peer with declared `owned_shards` becomes a
1390 // watch target; peers without are kept out of the watch set (the supervisor
1391 // would have nothing to adopt for them).
1392 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
1393 .as_ref()
1394 .map(|cluster| {
1395 cluster
1396 .peers
1397 .iter()
1398 .map(|peer| crate::cluster::WatchedPeer {
1399 name: peer.name.clone(),
1400 owned_shards: peer.owned_shards.clone(),
1401 })
1402 .collect()
1403 })
1404 .unwrap_or_default();
1405 // The static shard-directory entries (R-2): each peer's declared shards plus
1406 // its gRPC forward address. Built from the same config the supervisor uses.
1407 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
1408 .as_ref()
1409 .map(|cluster| {
1410 cluster
1411 .peers
1412 .iter()
1413 .map(|peer| crate::routing::DirectoryPeer {
1414 name: peer.name.clone(),
1415 owned_shards: peer.owned_shards.clone(),
1416 grpc_addr: peer.grpc_address,
1417 })
1418 .collect()
1419 })
1420 .unwrap_or_default();
1421 // This node's own distribution name, so the SS-3 directory resolves a
1422 // shard-owner record naming THIS node to `Local`.
1423 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
1424 // Construction (and, for the distributed path, the off-runtime endpoint bind)
1425 // must not stall the async runtime, so run it on the blocking pool. The
1426 // distributed constructor itself steps onto a bare thread for the bind.
1427 let (store, responder) =
1428 tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
1429 .await
1430 .map_err(|error| ServerError::Config {
1431 message: format!("haematite store initialization task failed: {error}"),
1432 })??;
1433
1434 // Gate the coordinator bootstrap on real ownership: a distributed node that
1435 // does NOT own the coordinator's shard must not seed/fence it (AA-4-4). A
1436 // single-node boot owns all shards, so it always bootstraps.
1437 let bootstrap_coordinator = if owned_shards.is_empty() {
1438 true
1439 } else {
1440 store.set_owned_shards(owned_shards.iter().copied());
1441 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
1442 };
1443
1444 let leaf = Arc::new(store);
1445 let event_store: Arc<dyn EventStore> = leaf.clone();
1446 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
1447 // The namespace registry is the SAME concrete `HaematiteStore` leaf (the
1448 // quorum-replicated implementation), captured before the leaf is moved into
1449 // the cluster-store retention below.
1450 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1451 // NOI-5b: the SAME concrete leaf captured as the durable `O`-keyspace
1452 // observability store, so the transcript sequencer persists to haematite and
1453 // survives restart/failover. Captured here (before the decorator chain wraps
1454 // the event store) exactly like the namespace registry.
1455 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
1456 // Retain the concrete store ONLY for a distributed boot (responder present),
1457 // where the SS-5b supervisor will poll it for peer liveness. A single-node
1458 // boot has no peers, so it carries no cluster store and never supervises.
1459 let cluster_store = responder.as_ref().map(|_| leaf);
1460 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
1461 (watched_peers, directory_peers, self_node_id)
1462 } else {
1463 (Vec::new(), Vec::new(), None)
1464 };
1465 Ok(ConnectedStore {
1466 event_store,
1467 outbox_store: Some(outbox_store),
1468 namespace_store,
1469 observability_store: Some(observability_store),
1470 bootstrap_coordinator,
1471 cluster_responder: responder,
1472 cluster_store,
1473 watched_peers,
1474 directory_peers,
1475 self_node_id,
1476 })
1477}
1478
1479/// Build the haematite store: the distributed path when a cluster section is
1480/// present, otherwise the single-node path. Returns the store and (for the
1481/// distributed path) its inbound-write responder. Restart-safe: an existing
1482/// on-disk database is reused (its shard count wins) rather than re-created.
1483#[cfg(feature = "haematite-backend")]
1484fn build_haematite_store(
1485 data_dir: &str,
1486 shard_count: usize,
1487 cluster: Option<crate::config::ClusterConfig>,
1488) -> Result<
1489 (
1490 aion_store_haematite::HaematiteStore,
1491 Option<aion_store_haematite::ClusterResponder>,
1492 ),
1493 ServerError,
1494> {
1495 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
1496
1497 let Some(cluster) = cluster else {
1498 // Single-node path: byte-identical to before.
1499 let path = std::path::Path::new(data_dir);
1500 let store = if path.join("config.json").exists() {
1501 HaematiteStore::open(path).map_err(ServerError::from)?
1502 } else {
1503 HaematiteStore::create_with_shard_count(path, shard_count).map_err(ServerError::from)?
1504 };
1505 return Ok((store, None));
1506 };
1507
1508 let boot = ClusterBootstrap {
1509 node_id: cluster.node_id,
1510 bind_address: cluster.bind_address,
1511 members: cluster.members,
1512 peers: cluster
1513 .peers
1514 .into_iter()
1515 .map(|peer| (peer.name, peer.address))
1516 .collect(),
1517 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
1518 };
1519 let (store, responder) =
1520 HaematiteStore::open_or_create_distributed(data_dir, shard_count, boot)
1521 .map_err(ServerError::from)?;
1522 Ok((store, Some(responder)))
1523}
1524
1525/// Per-operation quorum/election timeout for the distributed haematite backend.
1526#[cfg(feature = "haematite-backend")]
1527const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1528
1529/// Reject `backend = haematite` cleanly when the optional `haematite-backend`
1530/// feature is not compiled in, so a default build gives a precise operator
1531/// error instead of a silent fallthrough.
1532#[cfg(not(feature = "haematite-backend"))]
1533fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
1534 Err(ServerError::Config {
1535 message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
1536 .to_owned(),
1537 })
1538}
1539
1540/// The NOI-6 intervention transport used when no push transport is compiled in.
1541///
1542/// Without the `liminal-transport` feature there is no way to reach a worker's
1543/// out-of-band connection, so every routed command reports the owning worker
1544/// unreachable — which the router maps onto the attempt-scoped stale-target no-op.
1545/// This keeps the intervention endpoint honest on a transport-less build (an
1546/// operator gets a NACK, never a false "applied") without gating the endpoint on a
1547/// feature.
1548#[cfg(not(feature = "liminal-transport"))]
1549#[derive(Clone, Debug)]
1550struct NullInterventionTransport;
1551
1552#[cfg(not(feature = "liminal-transport"))]
1553#[async_trait::async_trait]
1554impl crate::worker::InterventionTransport for NullInterventionTransport {
1555 async fn push(
1556 &self,
1557 _worker: &crate::worker::WorkerHandle,
1558 _command: aion_core::InterventionCommand,
1559 ) -> Result<aion_core::InterventionOutcome, ServerError> {
1560 Err(ServerError::worker_connection_lost(
1561 "intervention",
1562 "no intervention push transport is compiled in".to_owned(),
1563 ))
1564 }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569 use std::{net::SocketAddr, time::Duration};
1570
1571 use aion_store::InMemoryStore;
1572
1573 use super::ServerState;
1574 use crate::config::{
1575 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
1576 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
1577 RuntimeConfig, WebSocketConfig, WorkerConfig,
1578 };
1579
1580 fn runtime_config() -> RuntimeConfig {
1581 RuntimeConfig {
1582 listen: ListenConfig {
1583 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
1584 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
1585 },
1586 tls: None,
1587 auth: AuthConfig {
1588 enabled: false,
1589 jwks_url: None,
1590 jwks_refresh_seconds: 300,
1591 },
1592 ops_console: OpsConsoleConfig {
1593 source: OpsConsoleAssetSource::Embedded,
1594 },
1595 namespace: NamespaceConfig {
1596 mode: NamespaceMode::SharedEngine,
1597 },
1598 worker: WorkerConfig {
1599 heartbeat_window: Duration::from_millis(30_000),
1600 },
1601 websocket: WebSocketConfig {
1602 outbound_buffer_bound: 32,
1603 event_broadcast_capacity: Some(64),
1604 cluster_broadcast_capacity: Some(64),
1605 },
1606 workflow_packages: Vec::new(),
1607 deploy: DeployConfig::default(),
1608 authoring: AuthoringConfig::default(),
1609 dev: DevConfig::default(),
1610 outbox: OutboxConfig::default(),
1611 observability: crate::config::ObservabilityConfig::default(),
1612 scheduler_threads: 1,
1613 query_timeout: Some(Duration::from_millis(10_000)),
1614 default_namespace: "default".to_owned(),
1615 auto_create: crate::config::AutoCreate::Open,
1616 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1617 drain_timeout: Duration::from_secs(30),
1618 metrics: MetricsConfig { enabled: true },
1619 owned_shards: Vec::new(),
1620 cors_allowed_origins: Vec::new(),
1621 }
1622 }
1623
1624 #[tokio::test]
1625 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
1626 let state =
1627 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
1628
1629 std::hint::black_box(state.namespace_guard());
1630 std::hint::black_box(state.worker_registry());
1631
1632 Ok(())
1633 }
1634
1635 #[tokio::test]
1636 async fn namespace_store_is_reachable_and_functional_after_default_boot()
1637 -> Result<(), Box<dyn std::error::Error>> {
1638 use aion_store::{MintOutcome, NamespaceOrigin};
1639
1640 // A default single-node (in-memory) boot must expose a real, functional
1641 // namespace registry through `state.namespace_store()` — the control
1642 // plane's mint (S5) and `GET /namespaces` (S7) reach the store this way.
1643 let state =
1644 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
1645
1646 let store = state.namespace_store();
1647
1648 // Mint a fresh namespace: the first reference creates it.
1649 let outcome = store
1650 .register_namespace("orders", NamespaceOrigin::WorkerMint)
1651 .await?;
1652 assert_eq!(
1653 outcome,
1654 MintOutcome::Created,
1655 "the first reference to a namespace mints it"
1656 );
1657
1658 // Re-referencing is idempotent: the record already exists.
1659 let again = store
1660 .register_namespace("orders", NamespaceOrigin::WorkerMint)
1661 .await?;
1662 assert_eq!(
1663 again,
1664 MintOutcome::AlreadyExisted,
1665 "a second reference touches the existing record rather than re-creating it"
1666 );
1667
1668 // Single lookup returns the durable record.
1669 let fetched = store.get_namespace("orders").await?;
1670 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
1671 assert_eq!(record.name, "orders");
1672 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
1673
1674 // The live set lists the namespace.
1675 let listed = store.list_namespaces().await?;
1676 assert!(
1677 listed.iter().any(|record| record.name == "orders"),
1678 "list_namespaces returns the minted namespace"
1679 );
1680
1681 Ok(())
1682 }
1683
1684 #[cfg(feature = "haematite-backend")]
1685 #[tokio::test(flavor = "multi_thread")]
1686 async fn connect_store_haematite_round_trips_through_event_store()
1687 -> Result<(), Box<dyn std::error::Error>> {
1688 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
1689 use aion_store::WriteToken;
1690 use chrono::Utc;
1691
1692 use crate::config::{StoreBackend, StoreConfig};
1693
1694 let data_dir = tempfile::tempdir()?;
1695 // Single shard, a fresh temp data_dir: the production connect path opens
1696 // an existing haematite database or creates one, then shares the leaf as
1697 // both the engine EventStore and the dispatcher OutboxStore.
1698 let connected = super::connect_store(StoreConfig {
1699 backend: StoreBackend::Haematite,
1700 url: None,
1701 owned_shards: Vec::new(),
1702 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
1703 shard_count: 1,
1704 cluster: None,
1705 })
1706 .await?;
1707 let event_store = connected.event_store;
1708 assert!(
1709 connected.outbox_store.is_some(),
1710 "the haematite backend shares its leaf store as the dispatcher's outbox store"
1711 );
1712 assert!(
1713 connected.bootstrap_coordinator,
1714 "a single-node haematite boot owns all shards and bootstraps the coordinator"
1715 );
1716 assert!(
1717 connected.cluster_responder.is_none(),
1718 "a single-node (no [cluster]) haematite boot has no distributed responder"
1719 );
1720
1721 let workflow_id = WorkflowId::new_v4();
1722 let event = aion_core::Event::WorkflowStarted {
1723 envelope: EventEnvelope {
1724 seq: 1,
1725 recorded_at: Utc::now(),
1726 workflow_id: workflow_id.clone(),
1727 },
1728 workflow_type: String::from("checkout"),
1729 input: Payload::new(ContentType::Json, b"{}".to_vec()),
1730 run_id: RunId::new_v4(),
1731 parent_run_id: None,
1732 package_version: PackageVersion::new("a".repeat(64)),
1733 };
1734 event_store
1735 .append(
1736 WriteToken::recorder(),
1737 &workflow_id,
1738 std::slice::from_ref(&event),
1739 0,
1740 )
1741 .await?;
1742 let history = event_store.read_history(&workflow_id).await?;
1743 assert_eq!(
1744 history.len(),
1745 1,
1746 "an event appended through the server's dyn EventStore reads back"
1747 );
1748 Ok(())
1749 }
1750
1751 #[tokio::test]
1752 async fn connect_store_memory_backend_exposes_no_outbox_store()
1753 -> Result<(), Box<dyn std::error::Error>> {
1754 use crate::config::{StoreBackend, StoreConfig};
1755
1756 // Memory backend: no durable outbox table, so no outbox store handle —
1757 // and `outbox.enabled` over memory is rejected at dispatcher commission.
1758 let connected = super::connect_store(StoreConfig {
1759 backend: StoreBackend::Memory,
1760 url: None,
1761 owned_shards: Vec::new(),
1762 data_dir: None,
1763 shard_count: 1,
1764 cluster: None,
1765 })
1766 .await?;
1767 assert!(
1768 connected.outbox_store.is_none(),
1769 "the in-memory backend exposes no outbox store"
1770 );
1771 Ok(())
1772 }
1773
1774 // The libSQL connect path is now an opt-in backend (`libsql-backend`), so this
1775 // libSQL-specific outbox-sharing assertion compiles and runs only under that
1776 // feature. The memory case is covered above, unconditionally.
1777 #[cfg(feature = "libsql-backend")]
1778 #[tokio::test]
1779 async fn connect_store_shares_outbox_store_only_for_libsql()
1780 -> Result<(), Box<dyn std::error::Error>> {
1781 use crate::config::{StoreBackend, StoreConfig};
1782
1783 // LibSql backend: the leaf Arc<LibSqlStore> is shared as BOTH the engine's
1784 // EventStore and the dispatcher's OutboxStore (one libsql::Connection), so
1785 // the dispatcher reuses the engine's connection rather than opening a
1786 // second contending one (the inc-8 contention fix).
1787 let path = std::env::temp_dir().join(format!(
1788 "aion-connect-store-{}-{}.db",
1789 std::process::id(),
1790 std::time::SystemTime::now()
1791 .duration_since(std::time::UNIX_EPOCH)
1792 .map(|elapsed| elapsed.as_nanos())
1793 .unwrap_or_default()
1794 ));
1795 let connected = super::connect_store(StoreConfig {
1796 backend: StoreBackend::LibSql,
1797 url: Some(path.to_string_lossy().into_owned()),
1798 owned_shards: Vec::new(),
1799 data_dir: None,
1800 shard_count: 1,
1801 cluster: None,
1802 })
1803 .await?;
1804 assert!(
1805 connected.outbox_store.is_some(),
1806 "the libSQL backend shares its leaf store as the dispatcher's outbox store"
1807 );
1808 Ok(())
1809 }
1810
1811 #[tokio::test]
1812 async fn state_build_fails_without_event_broadcast_capacity()
1813 -> Result<(), Box<dyn std::error::Error>> {
1814 let mut runtime = runtime_config();
1815 runtime.websocket.event_broadcast_capacity = None;
1816
1817 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
1818 .await
1819 .err()
1820 .ok_or("state build must fail when event streaming is unsized")?;
1821
1822 assert!(error.is_config(), "expected a config error, got {error}");
1823 assert!(
1824 error
1825 .to_string()
1826 .contains("websocket.event_broadcast_capacity"),
1827 "error must name the missing key: {error}"
1828 );
1829 Ok(())
1830 }
1831
1832 #[tokio::test]
1833 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
1834 let mut runtime = runtime_config();
1835 runtime.query_timeout = None;
1836
1837 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
1838 .await
1839 .err()
1840 .ok_or("state build must fail when the query reply deadline is unset")?;
1841
1842 assert!(error.is_config(), "expected a config error, got {error}");
1843 assert!(
1844 error.to_string().contains("runtime.query_timeout_ms"),
1845 "error must name the missing key: {error}"
1846 );
1847 assert!(
1848 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
1849 "error must name the environment override: {error}"
1850 );
1851 Ok(())
1852 }
1853
1854 #[tokio::test]
1855 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
1856 let mut runtime = runtime_config();
1857 runtime.query_timeout = Some(Duration::ZERO);
1858
1859 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
1860 .await
1861 .err()
1862 .ok_or("state build must fail when the query reply deadline is zero")?;
1863
1864 assert!(error.is_config(), "expected a config error, got {error}");
1865 assert!(
1866 error.to_string().contains("runtime.query_timeout_ms"),
1867 "error must name the zero-valued key: {error}"
1868 );
1869 Ok(())
1870 }
1871}