Skip to main content

aion_server/
run.rs

1//! Run loop for the Aion workflow server: tracing initialization,
2//! configuration load, transport startup, and signal-driven graceful
3//! shutdown.
4//!
5//! This is the library entry point behind the `aion server` command. It
6//! preserves the operational contract of the former standalone
7//! `aion-server` binary: exit code 2 for configuration errors, the drain
8//! outcome's exit code on shutdown, and 130 when a second termination
9//! signal forces immediate exit.
10
11use std::{net::SocketAddr, process::ExitCode};
12
13use tokio::net::TcpListener;
14use tonic::transport::Server as TonicServer;
15use tracing::{error, info, warn};
16
17use std::sync::Arc;
18
19use crate::{
20    ServerConfig, ServerError, ServerState, api,
21    config::{CliOverrides, NamespaceMode, OutboxConfig, OutboxTransport, StoreBackend},
22    observability,
23    shutdown::{self, ShutdownOutcome},
24    worker::{
25        ActivityDispatcher, OutboxDispatcher, OutboxDispatcherConfig, OutboxReconciler,
26        OutboxReconcilerConfig, OutboxRowDispatch, WorkerOutboxDispatch,
27    },
28};
29
30/// Owns the liminal worker listener for the server's lifetime when the outbox is
31/// commissioned over the liminal transport.
32///
33/// The aion-server HOSTS the liminal listener that remote workers connect IN to;
34/// its inner [`ServerListener`](liminal_server::server::listener::ServerListener)
35/// owns the accept worker. Held as a local in [`run_server`] across the whole
36/// serve `select!`, so it is dropped exactly at server shutdown — and the
37/// listener's own `Drop` stops the accept worker cleanly (no leaked thread, no
38/// orphaned listener). Every non-liminal boot (the default) carries the `None`
39/// guard, which holds nothing and drops to a no-op, so behaviour is unchanged.
40#[derive(Debug, Default)]
41struct OutboxWorkerListener {
42    /// Held purely for its `Drop` side-effect (stopping the accept worker on
43    /// server shutdown); never read after construction, hence the leading
44    /// underscore.
45    #[cfg(feature = "liminal-transport")]
46    _inner: Option<liminal_server::server::listener::ServerListener>,
47}
48
49/// Run the Aion workflow server until it shuts down, returning the process
50/// exit code.
51///
52/// Initializes the JSON tracing subscriber, loads and validates the merged
53/// configuration (file, environment, then `overrides`), serves the gRPC and
54/// HTTP transports, and drains gracefully after the first termination
55/// signal. Every failure is logged through tracing and mapped to the exit
56/// code contract above; the caller only has to exit with the returned code.
57pub async fn run(overrides: CliOverrides) -> ExitCode {
58    match run_server(overrides).await {
59        Ok(code) => code,
60        Err(error) => {
61            error!(%error, "aion-server failed");
62            if error.is_config() {
63                ExitCode::from(2)
64            } else {
65                ExitCode::FAILURE
66            }
67        }
68    }
69}
70
71async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
72    observability::tracing::init()?;
73
74    let config = ServerConfig::load(&cli)?;
75    reject_auth_without_feature(&config)?;
76    let store_backend = config.store.backend;
77    // Static shard assignment (SS-1): read the operator's pinned shard set from
78    // `[store] owned_shards`. Empty means own ALL shards (single-node default).
79    // The set is carried into `RuntimeConfig` by `into_parts` and applied to the
80    // `EngineBuilder` during state construction; surface it here so the boot
81    // banner records which shards this node serves. No election is performed.
82    let owned_shards = config.store.owned_shards.clone();
83    // Capture the outbox settings before `build` consumes `config`, so the
84    // (default-off) outbox dispatcher can be wired after state is up. The
85    // dispatcher shares the engine's already-opened libSQL store (one
86    // connection) via `state.outbox_store()`, so no store settings are needed.
87    let outbox_config = config.outbox.clone();
88    // Capture the SS-5b failover supervisor knobs before `build` consumes config.
89    // Only a distributed haematite boot carries a `[store.cluster]` section; this
90    // is `None` for every single-node boot, so no supervisor is ever spawned.
91    #[cfg(feature = "haematite-backend")]
92    let cluster_config = config.store.cluster.clone();
93    let state = ServerState::build(config).await?;
94    reject_tls_until_supported(&state)?;
95
96    let runtime = state.runtime_config();
97    let grpc_address = runtime.listen.grpc;
98    let http_address = runtime.listen.http;
99    let workflow_packages: Vec<String> = runtime
100        .workflow_packages
101        .iter()
102        .map(|path| path.display().to_string())
103        .collect();
104    info!(
105        version = env!("CARGO_PKG_VERSION"),
106        grpc_address = %grpc_address,
107        http_address = %http_address,
108        default_namespace = %runtime.default_namespace,
109        namespace_mode = namespace_mode_label(&runtime.namespace.mode),
110        store_backend = store_backend_label(store_backend),
111        auth_enabled = runtime.auth.enabled,
112        deploy_enabled = runtime.deploy.enabled,
113        metrics_enabled = runtime.metrics.enabled,
114        workflow_package_count = workflow_packages.len(),
115        workflow_packages = ?workflow_packages,
116        owned_shards = ?owned_shards,
117        owns_all_shards = owned_shards.is_empty(),
118        "aion-server startup banner"
119    );
120    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
121    // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
122    // The single outbox dispatcher task is spawned in BOTH modes; the difference
123    // is only how ownership is enforced. Single-node (`None`) owns all shards by
124    // construction (`owned_shard_scope() == None`), so its claim sweeps see every
125    // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
126    // filter — already seeded by `set_owned_shards` during `ServerState::build`,
127    // which runs before this point — so each node only ever claims rows on the
128    // shards it owns. Compute the flag here where the (feature-gated) cluster
129    // section is in scope; pass it to the gate so the boot banner records the mode.
130    #[cfg(feature = "haematite-backend")]
131    let outbox_clustered = cluster_config.is_some();
132    #[cfg(not(feature = "haematite-backend"))]
133    let outbox_clustered = false;
134    // Dormant by default: only when `outbox.enabled` is set does the
135    // non-replayed outbox dispatcher task start. With the flag off (the
136    // default) nothing here runs and server behaviour is unchanged.
137    // Hold the liminal worker listener (if any) for the server's lifetime: it is
138    // dropped at the end of `run_server`, after the serve `select!` completes, so
139    // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
140    let _outbox_worker_listener =
141        maybe_spawn_outbox_dispatcher(&state, &outbox_config, outbox_clustered, &shutdown_rx)?;
142    // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
143    // supervisor — automatic failover detection. A single-node boot spawns
144    // nothing here (the method returns `false`), so default behaviour is
145    // unchanged.
146    #[cfg(feature = "haematite-backend")]
147    maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
148    let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
149    let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
150
151    let outcome = tokio::select! {
152        result = &mut grpc => {
153            transport_result("gRPC", result)?;
154            state.shutdown()?;
155            ShutdownOutcome::Clean
156        },
157        result = &mut http => {
158            transport_result("HTTP", result)?;
159            state.shutdown()?;
160            ShutdownOutcome::Clean
161        },
162        result = shutdown_signal() => {
163            result?;
164            let _receiver_count = shutdown_tx.send(true);
165            let outcome = shutdown::drain_after_first_signal(state.clone(), async {
166                let _ = shutdown_signal().await;
167            }).await?;
168            if !matches!(outcome, ShutdownOutcome::Forced) {
169                transport_result("gRPC", grpc.await)?;
170                transport_result("HTTP", http.await)?;
171            }
172            outcome
173        },
174    };
175
176    Ok(outcome.exit_code())
177}
178
179fn transport_result(
180    transport: &'static str,
181    result: Result<Result<(), ServerError>, tokio::task::JoinError>,
182) -> Result<(), ServerError> {
183    match result {
184        Ok(transport_outcome) => transport_outcome,
185        Err(join_error) => Err(ServerError::Transport {
186            transport,
187            message: join_error.to_string(),
188        }),
189    }
190}
191
192async fn serve_grpc(
193    state: ServerState,
194    address: SocketAddr,
195    shutdown: tokio::sync::watch::Receiver<bool>,
196) -> Result<(), ServerError> {
197    let workflow = api::grpc::workflow_service(state.clone());
198    let worker = api::worker_grpc::worker_service(state.clone());
199    let mut router = TonicServer::builder()
200        .add_service(workflow)
201        .add_service(worker);
202    // Dark by default: the deploy service joins the listener only when the
203    // operator commissioned it; otherwise the surface answers Unimplemented.
204    if state.runtime_config().deploy.enabled {
205        router = router.add_service(api::deploy_grpc::deploy_service(state)?);
206    }
207    router
208        .serve_with_shutdown(address, shutdown_requested(shutdown))
209        .await
210        .map_err(|source| transport_bind("grpc", address, source))?;
211    Ok(())
212}
213
214async fn serve_http(
215    state: ServerState,
216    address: SocketAddr,
217    shutdown: tokio::sync::watch::Receiver<bool>,
218) -> Result<(), ServerError> {
219    let listener = TcpListener::bind(address)
220        .await
221        .map_err(|source| transport_bind("http", address, source))?;
222    axum::serve(listener, api::http::http_router(state)?)
223        .with_graceful_shutdown(shutdown_requested(shutdown))
224        .await
225        .map_err(|source| transport_bind("http", address, source))?;
226    Ok(())
227}
228
229async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
230    while !*shutdown.borrow_and_update() {
231        if shutdown.changed().await.is_err() {
232            break;
233        }
234    }
235}
236
237async fn shutdown_signal() -> Result<(), ServerError> {
238    #[cfg(unix)]
239    {
240        use tokio::signal::unix::{SignalKind, signal};
241
242        let mut terminate = signal(SignalKind::terminate())
243            .map_err(|source| signal_listener("SIGTERM", &source))?;
244        let mut interrupt =
245            signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
246        tokio::select! {
247            _ = terminate.recv() => Ok(()),
248            _ = interrupt.recv() => Ok(()),
249        }
250    }
251
252    #[cfg(not(unix))]
253    {
254        tokio::signal::ctrl_c()
255            .await
256            .map_err(|source| signal_listener("shutdown signal", &source))
257    }
258}
259
260fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
261    ServerError::SignalListener {
262        listener,
263        message: source.to_string(),
264    }
265}
266
267fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
268    if cfg!(not(feature = "auth")) && config.auth.enabled {
269        return Err(ServerError::Config {
270            message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
271        });
272    }
273    Ok(())
274}
275
276/// Spawn the durable-outbox fan-out dispatcher when, and only when, the
277/// operator commissioned it (`outbox.enabled = true`).
278///
279/// This is the single gate that keeps Phase 2 dormant: with the flag off (the
280/// default) the function returns immediately without spawning a task, so
281/// default server behaviour — and the live workflow dispatch path — is entirely
282/// unchanged. When commissioned, the dispatcher claims rows through the engine's
283/// own shared `Arc<LibSqlStore>` (one `libsql::Connection`), so its writes
284/// serialize with the engine's rather than contending across a second
285/// connection. The dispatcher shares the server's shutdown watch, so it drains
286/// on the same signal as the transports.
287///
288/// NOTE (Phase boundary): the spawned dispatcher dispatches claimed rows and
289/// records each row's terminal outbox state (done / retry / failed). Routing the
290/// worker completion back into workflow history through the Recorder is Phase 3
291/// and is not wired here.
292fn maybe_spawn_outbox_dispatcher(
293    state: &ServerState,
294    outbox_config: &OutboxConfig,
295    clustered: bool,
296    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
297) -> Result<OutboxWorkerListener, ServerError> {
298    if !outbox_config.enabled {
299        return Ok(OutboxWorkerListener::default());
300    }
301    let dispatcher_config = resolve_outbox_config(outbox_config)?;
302    // Share the engine's already-opened store: one backing connection. The
303    // dispatcher's `claim_outbox_rows` writes then serialize against the engine's
304    // `append_with_outbox` on that single connection instead of contending across
305    // a second one. Both the libSQL and the haematite backends provide an
306    // `OutboxStore` (the haematite leaf is wired as the outbox store at boot); the
307    // in-memory backend has no outbox table, so `outbox_store()` is `None` and
308    // commissioning the dispatcher against it is a configuration error (LSUB-4-2).
309    let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
310        message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
311                  the durable outbox dispatcher claims rows from the store's outbox table, which \
312                  the in-memory store does not provide"
313            .to_owned(),
314    })?;
315    let (row_dispatch, worker_listener) = select_outbox_row_dispatch(state, outbox_config)?;
316    // LSUB-2: share the engine's advisory wake so the stage seam pulses this
317    // dispatcher the instant a fan-out row commits, dispatching in ~RTT instead of
318    // up to one poll interval. The wake is always-on and free; the interval poll is
319    // untouched, so it remains the correctness backstop for any lost wake.
320    let dispatcher =
321        OutboxDispatcher::new(Arc::clone(&outbox_store), row_dispatch, dispatcher_config)
322            .with_wake(state.outbox_wake());
323    tokio::spawn(dispatcher.run(shutdown_rx.clone()));
324    // LSUB-4-1: the single dispatcher task is spawned in both modes. In a
325    // single-node boot it owns all shards by construction; in an active-active
326    // clustered boot it claims ONLY the shards this node owns, enforced by
327    // `claim_outbox_rows`' owned-shard scope (already seeded before this point).
328    info!(
329        clustered,
330        "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
331         when clustered; single-node owns all shards)"
332    );
333    // LSUB-4-4: the stale-claim reconciler is the in-flight recovery backstop. It
334    // is only configured when BOTH reconcile knobs are set, so on a clustered boot
335    // that left them unset, owner-kill in-flight recovery latency is bounded only
336    // by re-residency replay (a survivor adopting the shard re-residents from
337    // history and re-arms via `rearm_outbox_pending`), NOT by `stale_after`. Warn
338    // so the operator knows the backstop is absent.
339    if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
340        let reconciler = OutboxReconciler::new(outbox_store, reconciler_config);
341        tokio::spawn(reconciler.run(shutdown_rx.clone()));
342        info!("outbox reconciler commissioned");
343    } else if clustered {
344        warn!(
345            "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
346             and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
347             owner is killed is then bounded only by re-residency replay on the adopting node, \
348             not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
349        );
350    }
351    Ok(worker_listener)
352}
353
354/// Spawn the SS-5b cluster supervisor when, and only when, this is a distributed
355/// haematite boot whose `[store.cluster]` declared peers with owned shards.
356///
357/// Reads the failover cadence + debounce from the cluster config (or the
358/// documented defaults), then asks the state to spawn the supervisor over its
359/// retained concrete store and live engine. With no `[store.cluster]` section —
360/// or with no peer declaring `owned_shards` — nothing is spawned and behaviour
361/// is unchanged.
362#[cfg(feature = "haematite-backend")]
363fn maybe_spawn_cluster_supervisor(
364    state: &ServerState,
365    cluster_config: Option<&crate::config::ClusterConfig>,
366    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
367) -> Result<(), ServerError> {
368    let Some(cluster) = cluster_config else {
369        return Ok(());
370    };
371    let poll_interval = std::time::Duration::from_millis(
372        cluster
373            .failover_poll_interval_ms
374            .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
375    );
376    let confirmations = cluster
377        .failover_confirmations
378        .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
379    let supervisor_config = crate::cluster::SupervisorConfig {
380        poll_interval,
381        confirmations,
382    };
383    let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
384    if spawned {
385        info!(
386            poll_interval_ms = %poll_interval.as_millis(),
387            confirmations,
388            "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
389        );
390    }
391    Ok(())
392}
393
394/// Select the outbox row-dispatch sink by the configured `outbox.transport`,
395/// returning the sink plus the worker listener whose lifetime the caller must
396/// hold.
397///
398/// `grpc` (the default) builds the unchanged [`WorkerOutboxDispatch`] over the
399/// connected-worker registry and carries the empty [`OutboxWorkerListener`], so a
400/// default server is byte-identical. `liminal` builds the cross-node
401/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) AND stands
402/// up the liminal worker listener the aion-server hosts (returned in the guard);
403/// it is only reachable when the `liminal-transport` feature is compiled in, and
404/// selecting it without that feature is a configuration error rather than a
405/// silent fall-through to gRPC.
406fn select_outbox_row_dispatch(
407    state: &ServerState,
408    outbox_config: &OutboxConfig,
409) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
410    match outbox_config.transport {
411        OutboxTransport::Grpc => {
412            let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
413                .with_drain_state(state.drain_state().clone());
414            let dispatch: Arc<dyn OutboxRowDispatch> =
415                Arc::new(WorkerOutboxDispatch::new(push_dispatcher));
416            Ok((dispatch, OutboxWorkerListener::default()))
417        }
418        OutboxTransport::Liminal => build_liminal_row_dispatch(state, outbox_config),
419    }
420}
421
422/// Build the production liminal row-dispatch sink and host the worker listener, or
423/// fail with the missing-feature error.
424///
425/// This lifts the tested cross-node wiring (the `lsub1`/`lsub5` e2e blueprint)
426/// into the production boot. The aion-server HOSTS the liminal listener that
427/// remote workers connect IN to, so its
428/// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
429/// owns each worker's connection and can push a dispatch out on it. The
430/// constructor cycle resolves the notifier <-> supervisor dependency:
431///
432/// 1. Reuse the registry already in [`ServerState`] — gRPC and liminal workers
433///    share ONE registry and the same `select_worker`, so routing is identical.
434/// 2. Build the [`LiminalConnectionNotifier`] over that registry (no supervisor
435///    yet).
436/// 3. Build the [`LiminalConnectionServices`] from the liminal listen config.
437/// 4. Build the [`ConnectionSupervisor`] WITH the services + notifier.
438/// 5. Bind the supervisor back into the notifier (must succeed).
439/// 6. Bind the [`ServerListener`] on the configured listen address — workers
440///    connect IN here.
441/// 7. Reuse the SAME completion callback the gRPC completion path installs
442///    ([`ServerOutboxDeliveryCallback`] over the live engine), so a liminal
443///    completion re-enters aion through the identical terminal-recording seam.
444/// 8. Build the [`RegistryLiminalDispatch`] over the registry + callback (it
445///    constructs the [`LiminalCompletionSource`] internally).
446///
447/// The returned listener is held by the caller for the server's lifetime; its
448/// `Drop` stops the accept worker on shutdown.
449///
450/// [`LiminalConnectionServices`]: liminal_server::server::connection::LiminalConnectionServices
451/// [`ServerListener`]: liminal_server::server::listener::ServerListener
452/// [`ServerOutboxDeliveryCallback`]: crate::worker::ServerOutboxDeliveryCallback
453/// [`LiminalCompletionSource`]: crate::worker::LiminalCompletionSource
454/// [`LiminalConnectionNotifier`]: crate::worker::LiminalConnectionNotifier
455#[cfg(feature = "liminal-transport")]
456fn build_liminal_row_dispatch(
457    state: &ServerState,
458    outbox_config: &OutboxConfig,
459) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
460    use liminal_server::config::ServerConfig as LiminalServerConfig;
461    use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
462    use liminal_server::server::listener::ServerListener;
463
464    use crate::worker::{
465        LiminalConnectionNotifier, RegistryLiminalDispatch, ServerOutboxDeliveryCallback,
466    };
467
468    let listen_address = outbox_config
469        .liminal_listen_address
470        .as_ref()
471        .ok_or_else(|| ServerError::Config {
472            message: "outbox.transport=liminal requires outbox.liminal_listen_address \
473                      (host:port the aion-server listens on for inbound liminal worker \
474                      connections)"
475                .to_owned(),
476        })?;
477    let listen_address: SocketAddr =
478        listen_address
479            .parse()
480            .map_err(|error| ServerError::Config {
481                message: format!(
482                    "outbox.liminal_listen_address must be a host:port socket address: {error}"
483                ),
484            })?;
485
486    // The liminal listener is the worker-connection front door only: it binds the
487    // wire listen address and serves the connection supervisor. `from_config` and
488    // `ServerListener::bind` read neither `health_listen_address` nor `channels`
489    // (the health probe is bound only by the standalone liminal server's full
490    // boot, not this embedded path), so no separate health port is bound here;
491    // it is set structurally to the listen address and never used.
492    let liminal_config = LiminalServerConfig {
493        listen_address,
494        health_listen_address: listen_address,
495        drain_timeout_ms: 30_000,
496        channels: Vec::new(),
497        routing_rules: Vec::new(),
498        persistence_path: None,
499        cluster: None,
500    };
501
502    // (1) Reuse the registry already in ServerState: gRPC + liminal workers share
503    // ONE registry and the same `select_worker`.
504    let registry = state.worker_registry().clone();
505    // (2) Notifier over that registry (supervisor bound after it is built).
506    let notifier = Arc::new(LiminalConnectionNotifier::new(registry.clone()));
507    // (3) Connection services from the liminal listen config.
508    let services = Arc::new(
509        LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
510            ServerError::Config {
511                message: format!("liminal connection services build failed: {error}"),
512            }
513        })?,
514    );
515    // (4) Supervisor WITH the services + notifier (the cycle's forward edge).
516    let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
517        .map_err(|error| ServerError::Config {
518        message: format!("liminal connection supervisor build failed: {error}"),
519    })?;
520    // (5) Bind the supervisor back into the notifier (the cycle's back edge); a
521    // failure here is a wiring bug, surfaced rather than silently ignored.
522    if !notifier.bind_supervisor(supervisor.clone()) {
523        return Err(ServerError::Config {
524            message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
525        });
526    }
527    // (6) Bind the listener on the configured address — workers connect IN here.
528    let listener =
529        ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
530            message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
531        })?;
532    // (7) Reuse the SAME completion callback the gRPC completion path uses, over
533    // the live engine, so a liminal completion re-enters aion through the
534    // identical terminal-recording seam (`record_fan_out_completion`).
535    let engine = state.engine()?;
536    let callback: Arc<dyn crate::worker::OutboxDeliveryCallback> =
537        Arc::new(ServerOutboxDeliveryCallback::new(engine));
538    // (8) The registry-backed dispatch builds its LiminalCompletionSource from the
539    // shared callback internally.
540    let dispatch: Arc<dyn OutboxRowDispatch> =
541        Arc::new(RegistryLiminalDispatch::new(registry, callback));
542
543    info!(
544        listen_address = %listen_address,
545        "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
546    );
547    Ok((
548        dispatch,
549        OutboxWorkerListener {
550            _inner: Some(listener),
551        },
552    ))
553}
554
555/// Feature-off stub: selecting the liminal transport without the
556/// `liminal-transport` feature is a configuration error, never a silent
557/// fall-through to gRPC.
558#[cfg(not(feature = "liminal-transport"))]
559fn build_liminal_row_dispatch(
560    _state: &ServerState,
561    _outbox_config: &OutboxConfig,
562) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
563    Err(ServerError::Config {
564        message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
565                  Cargo feature, which is not enabled in this build"
566            .to_owned(),
567    })
568}
569
570/// Resolve the validated, all-present outbox knobs into the dispatcher's
571/// non-optional config. Validation already guaranteed each value is set and in
572/// range when `outbox.enabled` is true, so an absent value here is a defensive
573/// configuration error, not a default to invent.
574fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
575    let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
576        message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
577    })?;
578    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
579        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
580    })?;
581    let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
582        message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
583    })?;
584    let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
585        message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
586    })?;
587    let backoff_multiplier = outbox
588        .backoff_multiplier
589        .ok_or_else(|| ServerError::Config {
590            message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
591        })?;
592    let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
593        message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
594    })?;
595    Ok(OutboxDispatcherConfig {
596        poll_interval: std::time::Duration::from_millis(poll_interval_ms),
597        batch_size,
598        max_attempts,
599        backoff_base: std::time::Duration::from_millis(backoff_base_ms),
600        backoff_multiplier,
601        backoff_max: std::time::Duration::from_millis(backoff_max_ms),
602    })
603}
604
605fn resolve_outbox_reconciler_config(
606    outbox: &OutboxConfig,
607) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
608    let (Some(interval_ms), Some(stale_after_ms)) = (
609        outbox.reconcile_interval_ms,
610        outbox.reconcile_stale_after_ms,
611    ) else {
612        return Ok(None);
613    };
614    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
615        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
616    })?;
617    Ok(Some(OutboxReconcilerConfig {
618        interval: std::time::Duration::from_millis(interval_ms),
619        stale_after: std::time::Duration::from_millis(stale_after_ms),
620        batch_size,
621    }))
622}
623
624fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
625    if state.runtime_config().tls.is_some() {
626        return Err(ServerError::Config {
627            message: "configured TLS material cannot be served until transport TLS is wired"
628                .to_owned(),
629        });
630    }
631    Ok(())
632}
633
634fn store_backend_label(backend: StoreBackend) -> &'static str {
635    match backend {
636        StoreBackend::Memory => "memory",
637        StoreBackend::LibSql => "libsql",
638        StoreBackend::Haematite => "haematite",
639    }
640}
641
642fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
643    match mode {
644        NamespaceMode::SharedEngine => "SharedEngine",
645        NamespaceMode::SingleTenant { .. } => "SingleTenant",
646    }
647}
648
649fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
650where
651    E: std::error::Error,
652{
653    ServerError::TransportBind {
654        transport,
655        address,
656        message: source.to_string(),
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    #![allow(clippy::expect_used)]
663
664    use super::{
665        OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
666        resolve_outbox_reconciler_config,
667    };
668    use crate::ServerState;
669    use crate::config::RuntimeConfig;
670    use aion_store::InMemoryStore;
671    use std::net::SocketAddr;
672    use std::time::Duration;
673
674    /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
675    /// tests (mirrors `state.rs`'s test `runtime_config`).
676    fn runtime_config() -> RuntimeConfig {
677        use crate::config::{
678            AuthConfig, AuthoringConfig, DashboardAssetSource, DashboardConfig, DeployConfig,
679            DevConfig, ListenConfig, MetricsConfig, NamespaceConfig, NamespaceMode,
680            WebSocketConfig, WorkerConfig,
681        };
682        RuntimeConfig {
683            listen: ListenConfig {
684                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
685                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
686            },
687            tls: None,
688            auth: AuthConfig {
689                enabled: false,
690                jwks_url: None,
691                jwks_refresh_seconds: 300,
692            },
693            dashboard: DashboardConfig {
694                source: DashboardAssetSource::Embedded,
695            },
696            namespace: NamespaceConfig {
697                mode: NamespaceMode::SharedEngine,
698            },
699            worker: WorkerConfig {
700                heartbeat_window: Duration::from_millis(30_000),
701            },
702            websocket: WebSocketConfig {
703                outbound_buffer_bound: 32,
704                event_broadcast_capacity: Some(64),
705            },
706            workflow_packages: Vec::new(),
707            deploy: DeployConfig::default(),
708            authoring: AuthoringConfig::default(),
709            dev: DevConfig::default(),
710            outbox: OutboxConfig::default(),
711            scheduler_threads: 1,
712            query_timeout: Some(Duration::from_millis(10_000)),
713            default_namespace: "default".to_owned(),
714            drain_timeout: Duration::from_secs(30),
715            metrics: MetricsConfig { enabled: true },
716            owned_shards: Vec::new(),
717            cors_allowed_origins: Vec::new(),
718        }
719    }
720
721    /// An `OutboxConfig` with `enabled = true` and every required knob present, so
722    /// the only remaining gate is the store-backend / outbox-table availability.
723    fn enabled_outbox_config() -> OutboxConfig {
724        OutboxConfig {
725            enabled: true,
726            poll_interval_ms: Some(250),
727            batch_size: Some(64),
728            max_attempts: Some(5),
729            backoff_base_ms: Some(100),
730            backoff_multiplier: Some(2),
731            backoff_max_ms: Some(30_000),
732            reconcile_interval_ms: None,
733            reconcile_stale_after_ms: None,
734            transport: OutboxTransport::Grpc,
735            liminal_listen_address: None,
736        }
737    }
738
739    /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
740    /// dispatcher against the in-memory backend (which has no outbox table, so
741    /// `outbox_store()` is `None`) is a configuration error, and the message names
742    /// BOTH supported backends (libsql / haematite), not just libsql.
743    #[tokio::test]
744    async fn outbox_enabled_on_memory_backend_is_a_config_error() {
745        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
746            .await
747            .expect("build in-memory state");
748        let (_tx, rx) = tokio::sync::watch::channel(false);
749        let error = maybe_spawn_outbox_dispatcher(&state, &enabled_outbox_config(), false, &rx)
750            .expect_err("outbox.enabled on the memory backend must be a config error");
751        assert!(
752            error.is_config(),
753            "memory-backend outbox error must be Config"
754        );
755        let message = error.to_string();
756        assert!(
757            message.contains("libsql") && message.contains("haematite"),
758            "corrected message must name both supported backends, got: {message}"
759        );
760    }
761
762    /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
763    /// gate is a no-op even on a memory backend — nothing is spawned and no error
764    /// is produced, so a default single-node boot is unchanged.
765    #[tokio::test]
766    async fn disabled_outbox_is_a_noop_on_any_backend() {
767        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
768            .await
769            .expect("build in-memory state");
770        let (_tx, rx) = tokio::sync::watch::channel(false);
771        maybe_spawn_outbox_dispatcher(&state, &OutboxConfig::default(), false, &rx)
772            .expect("disabled outbox gate must be an infallible no-op");
773    }
774
775    /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
776    /// set — the condition under which the clustered-boot WARN fires.
777    #[test]
778    fn reconciler_config_absent_unless_both_knobs_set() {
779        let mut config = enabled_outbox_config();
780        // Neither knob: absent.
781        assert!(
782            resolve_outbox_reconciler_config(&config)
783                .expect("resolve")
784                .is_none()
785        );
786        // Only interval: still absent (the silent-backstop-absent default).
787        config.reconcile_interval_ms = Some(1_000);
788        assert!(
789            resolve_outbox_reconciler_config(&config)
790                .expect("resolve")
791                .is_none()
792        );
793        // Both set: present.
794        config.reconcile_stale_after_ms = Some(60_000);
795        assert!(
796            resolve_outbox_reconciler_config(&config)
797                .expect("resolve")
798                .is_some()
799        );
800    }
801
802    /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
803    /// Commissioning the dispatcher with `transport = liminal` but no listen
804    /// address is a configuration error naming the missing knob, rather than a
805    /// panic or a silent fall-through to gRPC. Built over the libSQL backend (so
806    /// the outbox-store gate passes and the missing-address check is actually
807    /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
808    /// only exists with `liminal-transport` on; in a feature-off build the same
809    /// selection is the missing-feature error instead, covered by the type system
810    /// rather than this test.)
811    #[cfg(feature = "liminal-transport")]
812    #[tokio::test]
813    async fn liminal_transport_requires_listen_address() {
814        use crate::config::{
815            RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
816        };
817
818        let db_path = std::env::temp_dir().join(format!(
819            "aion-lsub-prod-listen-guard-{}-{}.db",
820            std::process::id(),
821            std::time::SystemTime::now()
822                .duration_since(std::time::UNIX_EPOCH)
823                .map(|elapsed| elapsed.as_nanos())
824                .unwrap_or_default()
825        ));
826        let mut outbox = enabled_outbox_config();
827        outbox.transport = OutboxTransport::Liminal;
828        outbox.liminal_listen_address = None;
829        let config = ServerConfig {
830            store: StoreConfig {
831                backend: StoreBackend::LibSql,
832                url: Some(db_path.to_string_lossy().into_owned()),
833                ..StoreConfig::default()
834            },
835            runtime: RuntimeSection {
836                scheduler_threads: 1,
837                query_timeout_ms: Some(10_000),
838            },
839            websocket: WebSocketConfig {
840                outbound_buffer_bound: 32,
841                event_broadcast_capacity: Some(64),
842            },
843            outbox: outbox.clone(),
844            ..ServerConfig::default()
845        };
846        let state = ServerState::build(config)
847            .await
848            .expect("build libsql state");
849        let (_tx, rx) = tokio::sync::watch::channel(false);
850
851        let error = maybe_spawn_outbox_dispatcher(&state, &outbox, false, &rx)
852            .expect_err("liminal transport without a listen address must be a config error");
853        assert!(
854            error.is_config(),
855            "missing-listen-address error must be Config"
856        );
857        assert!(
858            error.to_string().contains("liminal_listen_address"),
859            "error must name the missing knob, got: {error}"
860        );
861    }
862}
863
864/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
865///
866/// This is the proof that the production boot now does the full round-trip the
867/// retired stub could not. It drives the EXACT production commissioning function
868/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
869/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
870/// `liminal_listen_address`. That function lifts the full push wiring
871/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
872/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
873/// SAME registry the gRPC path uses and the SAME
874/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
875/// (over the live engine), and spawns the real [`OutboxDispatcher`].
876///
877/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
878/// connects IN to the listener and self-registers in-band. A `collect_four`
879/// fan-out is started over the REAL HTTP transport, which stages four pending
880/// outbox rows; the production-wired dispatcher claims and pushes each to the
881/// worker, the worker executes it, and its completion re-enters aion through the
882/// production engine callback — `record_fan_out_completion` — driving the
883/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
884/// executed the activities, AND the terminals were recorded in history (four
885/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
886/// publish-and-mark-done path never achieved.
887#[cfg(all(test, feature = "liminal-transport"))]
888mod lsub_prod_xnode_e2e {
889    #![allow(clippy::expect_used)]
890
891    use std::net::SocketAddr;
892    use std::path::PathBuf;
893    use std::sync::Arc;
894    use std::sync::atomic::{AtomicUsize, Ordering};
895    use std::time::{Duration, Instant};
896
897    use aion_core::Event;
898    use aion_package::{
899        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
900        PackageBuilder,
901    };
902    use aion_store::ReadableEventStore;
903    use aion_store_libsql::LibSqlStore;
904    use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
905    use axum::body;
906    use axum::http::{Request, StatusCode};
907    use serde_json::json;
908    use tower::ServiceExt;
909
910    use super::maybe_spawn_outbox_dispatcher;
911    use crate::ServerState;
912    use crate::api::http::http_router;
913    use crate::config::{
914        OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
915        WebSocketConfig,
916    };
917
918    type TestError = Box<dyn std::error::Error + Send + Sync>;
919
920    /// The `collect_four` fixture passes each member the JSON string `"in"` as
921    /// activity input, so the worker handler decodes a [`String`], not a struct.
922    type FanInput = String;
923
924    const NAMESPACE: &str = "default";
925    const TASK_QUEUE: &str = "default";
926    const OUTBOX_MODULE: &str = "aion_outbox_fixture";
927    const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
928    const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
929    const FAN_OUT: usize = 4;
930    const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
931    const POLL_DEADLINE: Duration = Duration::from_secs(20);
932
933    fn test_error(message: impl std::fmt::Display) -> TestError {
934        message.to_string().into()
935    }
936
937    /// Reserve a loopback port and return it: the liminal listener binds this exact
938    /// address (the production path binds the configured `liminal_listen_address`,
939    /// so the test must commit to a concrete port the worker can also dial).
940    fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
941        let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
942        let address = listener.local_addr().map_err(test_error)?;
943        drop(listener);
944        Ok(address)
945    }
946
947    /// Build the `collect_four` package on disk so the production state-build path
948    /// loads it exactly as it loads operator-supplied `workflow_packages`.
949    fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
950        let beams =
951            BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
952        let manifest = Manifest {
953            entry_module: OUTBOX_MODULE.to_owned(),
954            entry_function: "collect_four".to_owned(),
955            input_schema: json!({ "type": "object" }),
956            output_schema: json!({}),
957            timeout: Duration::from_secs(30),
958            activities: vec![DeclaredActivity {
959                activity_type: "fixture_activity".to_owned(),
960            }],
961            version: ManifestVersion::new("stamped-by-builder"),
962            format_version: CURRENT_FORMAT_VERSION,
963        };
964        let archive =
965            PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
966                .write_to_bytes()
967                .map_err(test_error)?;
968        let path = dir.join("collect_four.aion");
969        std::fs::write(&path, archive).map_err(test_error)?;
970        Ok(path)
971    }
972
973    /// A production-shaped `ServerConfig`: the libSQL backend (so the boot store
974    /// path shares the leaf as the dispatcher's outbox store, exactly as
975    /// `ServerState::build` does in production), `outbox.enabled`,
976    /// `transport = liminal`, the reserved `liminal_listen_address`, and the
977    /// `collect_four` package. Built through `ServerState::build` (not
978    /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
979    fn server_config(
980        db_path: &std::path::Path,
981        package_path: PathBuf,
982        listen_address: SocketAddr,
983    ) -> ServerConfig {
984        ServerConfig {
985            store: StoreConfig {
986                backend: StoreBackend::LibSql,
987                url: Some(db_path.to_string_lossy().into_owned()),
988                ..StoreConfig::default()
989            },
990            runtime: RuntimeSection {
991                scheduler_threads: 1,
992                query_timeout_ms: Some(10_000),
993            },
994            websocket: WebSocketConfig {
995                outbound_buffer_bound: 32,
996                event_broadcast_capacity: Some(64),
997            },
998            workflow_packages: vec![package_path],
999            outbox: OutboxConfig {
1000                enabled: true,
1001                poll_interval_ms: Some(20),
1002                batch_size: Some(16),
1003                max_attempts: Some(5),
1004                backoff_base_ms: Some(50),
1005                backoff_multiplier: Some(2),
1006                backoff_max_ms: Some(1_000),
1007                reconcile_interval_ms: None,
1008                reconcile_stale_after_ms: None,
1009                transport: OutboxTransport::Liminal,
1010                liminal_listen_address: Some(listen_address.to_string()),
1011            },
1012            ..ServerConfig::default()
1013        }
1014    }
1015
1016    /// The remote worker self-describes for the fixture's pool `(default, default)`
1017    /// and registers a handler for every `fan:N` activity type, counting executions
1018    /// so the test proves it genuinely ran the pushed dispatches.
1019    fn worker_config() -> Result<WorkerConfig, TestError> {
1020        WorkerConfig::builder()
1021            .endpoint("unused-direct-address")
1022            .namespace(NAMESPACE)
1023            .task_queue(TASK_QUEUE)
1024            .identity("lsub-prod-worker")
1025            .max_concurrency(4)
1026            .reconnect_initial_backoff(Duration::from_millis(5))
1027            .reconnect_max_backoff(Duration::from_millis(20))
1028            .reconnect_max_attempts(3)
1029            .build()
1030            .map_err(test_error)
1031    }
1032
1033    fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1034        let mut registry = ActivityRegistry::new();
1035        for activity_type in FAN_ACTIVITY_TYPES {
1036            let executions = Arc::clone(executions);
1037            registry = registry
1038                .register_activity(activity_type, move |_input: FanInput, _context| {
1039                    let executions = Arc::clone(&executions);
1040                    Box::pin(async move {
1041                        executions.fetch_add(1, Ordering::SeqCst);
1042                        Ok(activity_type.to_owned())
1043                    })
1044                })
1045                .map_err(test_error)?;
1046        }
1047        Ok(Arc::new(registry))
1048    }
1049
1050    /// Spawns the remote worker on its own OS thread with a current-thread runtime
1051    /// (the push receive is blocking), connecting IN to the production listener.
1052    struct WorkerThread {
1053        stop: Arc<std::sync::atomic::AtomicBool>,
1054        handle: Option<std::thread::JoinHandle<()>>,
1055    }
1056
1057    impl WorkerThread {
1058        fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1059            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1060            let thread_stop = Arc::clone(&stop);
1061            let handle = std::thread::spawn(move || {
1062                let runtime = match tokio::runtime::Builder::new_current_thread()
1063                    .enable_all()
1064                    .build()
1065                {
1066                    Ok(runtime) => runtime,
1067                    Err(error) => {
1068                        eprintln!("worker runtime build failed: {error}");
1069                        return;
1070                    }
1071                };
1072                runtime.block_on(async move {
1073                    let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1074                        Ok(worker) => worker,
1075                        Err(error) => {
1076                            eprintln!("worker connect failed: {error}");
1077                            return;
1078                        }
1079                    };
1080                    if let Err(error) = worker
1081                        .serve_until(|| thread_stop.load(Ordering::SeqCst))
1082                        .await
1083                    {
1084                        eprintln!("worker serve loop ended with error: {error}");
1085                    }
1086                });
1087            });
1088            Self {
1089                stop,
1090                handle: Some(handle),
1091            }
1092        }
1093
1094        fn stop(mut self) {
1095            self.stop.store(true, Ordering::SeqCst);
1096            if let Some(handle) = self.handle.take() {
1097                handle.join().ok();
1098            }
1099        }
1100    }
1101
1102    fn count_completed(history: &[Event]) -> usize {
1103        history
1104            .iter()
1105            .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1106            .count()
1107    }
1108
1109    fn count_workflow_completed(history: &[Event]) -> usize {
1110        history
1111            .iter()
1112            .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1113            .count()
1114    }
1115
1116    async fn wait_for_history<F>(
1117        store: &LibSqlStore,
1118        workflow_id: &aion_core::WorkflowId,
1119        description: &str,
1120        predicate: F,
1121    ) -> Result<Vec<Event>, TestError>
1122    where
1123        F: Fn(&[Event]) -> bool,
1124    {
1125        let deadline = Instant::now() + POLL_DEADLINE;
1126        loop {
1127            let history = store.read_history(workflow_id).await.map_err(test_error)?;
1128            if predicate(&history) {
1129                return Ok(history);
1130            }
1131            if Instant::now() > deadline {
1132                return Err(test_error(format!(
1133                    "timed out waiting for {description}: {history:#?}"
1134                )));
1135            }
1136            tokio::time::sleep(Duration::from_millis(25)).await;
1137        }
1138    }
1139
1140    /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1141    async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1142        let build_request = || -> Result<Request<body::Body>, TestError> {
1143            Request::builder()
1144                .uri("/workflows/start")
1145                .method("POST")
1146                .header("content-type", "application/json")
1147                .header("x-aion-subject", "ci")
1148                .header("x-aion-namespaces", NAMESPACE)
1149                .body(body::Body::from(
1150                    serde_json::to_vec(&json!({
1151                        "namespace": NAMESPACE,
1152                        "workflow_type": OUTBOX_MODULE,
1153                        "input": { "fixture": "input" },
1154                    }))
1155                    .map_err(test_error)?,
1156                ))
1157                .map_err(test_error)
1158        };
1159        let response = router
1160            .clone()
1161            .oneshot(build_request()?)
1162            .await
1163            .map_err(test_error)?;
1164        let status = response.status();
1165        let bytes = body::to_bytes(response.into_body(), usize::MAX)
1166            .await
1167            .map_err(test_error)?
1168            .to_vec();
1169        if status != StatusCode::OK {
1170            return Err(test_error(format!(
1171                "workflow start over HTTP must succeed, got {status}: {}",
1172                String::from_utf8_lossy(&bytes)
1173            )));
1174        }
1175        let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1176        let workflow_id = body["workflow_id"]["uuid"]
1177            .as_str()
1178            .ok_or_else(|| test_error("start response missing workflow id"))?
1179            .parse::<uuid::Uuid>()
1180            .map_err(test_error)?;
1181        Ok(aion_core::WorkflowId::new(workflow_id))
1182    }
1183
1184    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1185    async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1186    {
1187        let dir = tempfile::tempdir().map_err(test_error)?;
1188        let db_path = dir.path().join("aion.db");
1189        let package_path = write_package_archive(dir.path())?;
1190        // The production path binds the CONFIGURED listen address, so commit to a
1191        // concrete reserved loopback port the worker can also dial.
1192        let listen_address = reserve_loopback_port()?;
1193
1194        // (A) Build a real ServerState through the production boot path
1195        // (ServerState::build over a libSQL ServerConfig): outbox enabled,
1196        // transport = liminal, the listen address set, collect_four loaded. This
1197        // shares the libSQL leaf as the dispatcher's outbox store (the real boot
1198        // store seam) and installs the production ServerOutboxDeliveryCallback over
1199        // the live engine (gated on outbox.enabled).
1200        let config = server_config(&db_path, package_path, listen_address);
1201        let outbox_config = config.outbox.clone();
1202        let state = ServerState::build(config).await.map_err(test_error)?;
1203
1204        // (B) Drive the EXACT production commissioning function run_server calls:
1205        // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1206        // shared registry + engine callback, and spawns the real OutboxDispatcher.
1207        // Hold the returned listener guard for the test's lifetime, exactly as
1208        // run_server holds it.
1209        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1210        let listener_guard =
1211            maybe_spawn_outbox_dispatcher(&state, &outbox_config, false, &shutdown_rx)
1212                .map_err(test_error)?;
1213
1214        // (C) A REAL remote worker connects IN to the production listener and
1215        // self-registers in-band for the fixture's pool.
1216        let executions = Arc::new(AtomicUsize::new(0));
1217        let worker = WorkerThread::spawn(
1218            listen_address.to_string(),
1219            worker_config()?,
1220            worker_registry(&executions)?,
1221        );
1222
1223        // Wait until the in-band registration landed in the SAME registry the
1224        // dispatch path selects from (every fan-out activity type is eligible).
1225        let registry = state.worker_registry().clone();
1226        let deadline = Instant::now() + Duration::from_secs(5);
1227        loop {
1228            let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
1229                registry
1230                    .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1231                    .ok()
1232                    .flatten()
1233                    .is_some()
1234            });
1235            if ready {
1236                break;
1237            }
1238            if Instant::now() > deadline {
1239                worker.stop();
1240                return Err(test_error("worker never registered in-band for the pool"));
1241            }
1242            tokio::time::sleep(Duration::from_millis(10)).await;
1243        }
1244
1245        // (D) Start collect_four over the REAL HTTP transport: the engine stages
1246        // four pending outbox rows; the production-wired dispatcher claims and
1247        // pushes each to the worker.
1248        let router = http_router(state.clone()).map_err(test_error)?;
1249        let workflow_id = start_over_http(&router).await?;
1250
1251        // (E) THE PROOF: the worker executed all four activities AND every terminal
1252        // was recorded through the production engine callback (record_fan_out_completion)
1253        // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1254        // is the full round-trip the retired stub never achieved.
1255        let reader = LibSqlStore::open(db_path.clone())
1256            .await
1257            .map_err(test_error)?;
1258        let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
1259            count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1260        })
1261        .await?;
1262        assert_eq!(
1263            count_completed(&settled),
1264            FAN_OUT,
1265            "every fan-out member must record a terminal through the production callback"
1266        );
1267        assert_eq!(
1268            count_workflow_completed(&settled),
1269            1,
1270            "the workflow must complete exactly once"
1271        );
1272        assert_eq!(
1273            executions.load(Ordering::SeqCst),
1274            FAN_OUT,
1275            "the remote worker must have executed every pushed dispatch exactly once"
1276        );
1277
1278        // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1279        // stops the accept worker), shut the engine down so durable appends finish.
1280        shutdown_tx.send(true).ok();
1281        worker.stop();
1282        drop(listener_guard);
1283        state.shutdown().map_err(test_error)?;
1284        Ok(())
1285    }
1286}