Skip to main content

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}