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/// Short TTL for the dispatcher's per-namespace placement cache (Control-Plane
31/// Phase 2, P2-P3). Kept small so an operator's `PUT /namespaces/{name}/placement`
32/// takes effect on the hot claim loop within a couple of seconds, while still
33/// collapsing a per-sweep quorum `get_namespace` into a cheap in-process lookup.
34/// A stale entry under `Prefer` only mis-prefers a worker for at most one window
35/// and self-corrects — it never affects correctness or replay.
36const PLACEMENT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
37
38/// Short TTL for the dispatcher's per-namespace quota cache (Control-Plane Phase 2,
39/// P2-Q2). Kept small so an operator raising/lowering a tenant's
40/// `max_in_flight_activities` takes effect on the hot claim loop within a couple of
41/// seconds, while still collapsing a per-sweep quorum `get_namespace` into a cheap
42/// in-process lookup. A stale entry only over- or under-admits slightly for one
43/// window and self-corrects — backpressure never drops a row, so it cannot affect
44/// correctness or replay.
45const QUOTA_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
46
47/// Cadence of the ops-console quota-state broadcaster (Control-Plane Phase 2,
48/// P2-Q3). Each tick samples every registry namespace's durable Claimed-row count
49/// and cluster-wide ceiling, then pushes one `NamespaceQuotaState` per namespace
50/// onto the cluster channel, so the console badge tracks live load. Kept at 1s:
51/// brisk enough that the badge visibly ticks as work flows, throttled enough that
52/// it is never a per-row firehose (in-flight changes on every claim/settle). It is
53/// a server-side push on a timer, NOT a client poll — the dashboard rule bans the
54/// latter, not a throttled server snapshot of REAL durable state.
55const QUOTA_BROADCAST_CADENCE: std::time::Duration = std::time::Duration::from_secs(1);
56
57/// Resolved keyed-backpressure inputs for the outbox dispatcher (Control-Plane
58/// Phase 2, P2-Q2): the generous platform-default ceiling and this node's
59/// owned-shard fraction of the cluster shard space.
60#[derive(Clone, Copy, Debug)]
61struct BackpressureSettings {
62    /// The `[namespaces] max_in_flight_activities` platform default, applied to any
63    /// namespace carrying no explicit per-tenant override.
64    platform_default: u32,
65    /// This node's owned-shard fraction of the cluster's virtual shard space,
66    /// derived from `[store] owned_shards` and `[store] shard_count`.
67    fraction: crate::worker::OwnedShardFraction,
68}
69
70impl BackpressureSettings {
71    /// Derive the backpressure inputs from the merged server config.
72    ///
73    /// An empty `[store] owned_shards` means own-all (the single-node default), so
74    /// the fraction is 1 and per-node ceilings equal the cluster-wide quota. A
75    /// declared owned set enforces the proportional per-node slice
76    /// `|owned| / shard_count` (CP-Phase-2 §3.6).
77    fn from_config(config: &ServerConfig) -> Self {
78        let total = u32::try_from(config.store.shard_count).unwrap_or(u32::MAX);
79        let fraction = if config.store.owned_shards.is_empty() {
80            crate::worker::OwnedShardFraction::own_all()
81        } else {
82            let owned = u32::try_from(config.store.owned_shards.len()).unwrap_or(u32::MAX);
83            crate::worker::OwnedShardFraction::new(owned, total)
84        };
85        Self {
86            platform_default: config.namespaces.max_in_flight_activities,
87            fraction,
88        }
89    }
90}
91
92/// Owns the liminal worker listener for the server's lifetime when the outbox is
93/// commissioned over the liminal transport.
94///
95/// The aion-server HOSTS the liminal listener that remote workers connect IN to;
96/// its inner [`ServerListener`](liminal_server::server::listener::ServerListener)
97/// owns the accept worker. Held as a local in [`run_server`] across the whole
98/// serve `select!`, so it is dropped exactly at server shutdown — and the
99/// listener's own `Drop` stops the accept worker cleanly (no leaked thread, no
100/// orphaned listener). Every non-liminal boot (the default) carries the `None`
101/// guard, which holds nothing and drops to a no-op, so behaviour is unchanged.
102#[derive(Debug, Default)]
103struct OutboxWorkerListener {
104    /// Held purely for its `Drop` side-effect (stopping the accept worker on
105    /// server shutdown); never read after construction, hence the leading
106    /// underscore.
107    #[cfg(feature = "liminal-transport")]
108    _inner: Option<liminal_server::server::listener::ServerListener>,
109}
110
111/// Run the Aion workflow server until it shuts down, returning the process
112/// exit code.
113///
114/// Initializes the JSON tracing subscriber, loads and validates the merged
115/// configuration (file, environment, then `overrides`), serves the gRPC and
116/// HTTP transports, and drains gracefully after the first termination
117/// signal. Every failure is logged through tracing and mapped to the exit
118/// code contract above; the caller only has to exit with the returned code.
119pub async fn run(overrides: CliOverrides) -> ExitCode {
120    match run_server(overrides).await {
121        Ok(code) => code,
122        Err(error) => {
123            error!(%error, "aion-server failed");
124            if error.is_config() {
125                ExitCode::from(2)
126            } else {
127                ExitCode::FAILURE
128            }
129        }
130    }
131}
132
133async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
134    observability::tracing::init()?;
135
136    let loaded = ServerConfig::load_resolved(&cli)?;
137    loaded.resolution.validate_private_home()?;
138    loaded.resolution.log_startup();
139    let config = loaded.config;
140    reject_auth_without_feature(&config)?;
141    let store_backend = config.store.backend;
142    // Static shard assignment (SS-1): read the operator's pinned shard set from
143    // `[store] owned_shards`. Empty means own ALL shards (single-node default).
144    // The set is carried into `RuntimeConfig` by `into_parts` and applied to the
145    // `EngineBuilder` during state construction; surface it here so the boot
146    // banner records which shards this node serves. No election is performed.
147    let owned_shards = config.store.owned_shards.clone();
148    // Capture the outbox settings before `build` consumes `config`, so the
149    // (default-off) outbox dispatcher can be wired after state is up. The
150    // dispatcher shares the engine's already-opened libSQL store (one
151    // connection) via `state.outbox_store()`, so no store settings are needed.
152    let outbox_config = config.outbox.clone();
153    // Control-Plane Phase 2 (P2-Q2): capture the keyed-backpressure inputs — the
154    // generous platform-default ceiling and this node's owned-shard fraction —
155    // before `build` consumes `config`. On a single-node / own-all boot the fraction
156    // is 1, so per-node ceilings equal the cluster-wide quota and, with the generous
157    // default and no tenant override, the ceiling never engages (byte-identical claim).
158    let backpressure_settings = BackpressureSettings::from_config(&config);
159    // Capture the SS-5b failover supervisor knobs before `build` consumes config.
160    // Only a distributed haematite boot carries a `[store.cluster]` section; this
161    // is `None` for every single-node boot, so no supervisor is ever spawned.
162    #[cfg(feature = "haematite-backend")]
163    let cluster_config = config.store.cluster.clone();
164    let state = ServerState::build(config).await?;
165    reject_tls_until_supported(&state)?;
166
167    let runtime = state.runtime_config();
168    let grpc_address = runtime.listen.grpc;
169    let http_address = runtime.listen.http;
170    let workflow_packages: Vec<String> = runtime
171        .workflow_packages
172        .iter()
173        .map(|path| path.display().to_string())
174        .collect();
175    info!(
176        version = env!("CARGO_PKG_VERSION"),
177        grpc_address = %grpc_address,
178        http_address = %http_address,
179        default_namespace = %runtime.default_namespace,
180        namespace_mode = namespace_mode_label(&runtime.namespace.mode),
181        store_backend = store_backend_label(store_backend),
182        auth_enabled = runtime.auth.enabled,
183        deploy_enabled = runtime.deploy.enabled,
184        metrics_enabled = runtime.metrics.enabled,
185        workflow_package_count = workflow_packages.len(),
186        workflow_packages = ?workflow_packages,
187        owned_shards = ?owned_shards,
188        owns_all_shards = owned_shards.is_empty(),
189        "aion-server startup banner"
190    );
191    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
192    // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
193    // The single outbox dispatcher task is spawned in BOTH modes; the difference
194    // is only how ownership is enforced. Single-node (`None`) owns all shards by
195    // construction (`owned_shard_scope() == None`), so its claim sweeps see every
196    // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
197    // filter — already seeded by `set_owned_shards` during `ServerState::build`,
198    // which runs before this point — so each node only ever claims rows on the
199    // shards it owns. Compute the flag here where the (feature-gated) cluster
200    // section is in scope; pass it to the gate so the boot banner records the mode.
201    #[cfg(feature = "haematite-backend")]
202    let outbox_clustered = cluster_config.is_some();
203    #[cfg(not(feature = "haematite-backend"))]
204    let outbox_clustered = false;
205    // Dormant by default: only when `outbox.enabled` is set does the
206    // non-replayed outbox dispatcher task start. With the flag off (the
207    // default) nothing here runs and server behaviour is unchanged.
208    // Hold the liminal worker listener (if any) for the server's lifetime: it is
209    // dropped at the end of `run_server`, after the serve `select!` completes, so
210    // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
211    // #204/#253: rebuild the pause dispatch-hold and settle terminal
212    // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
213    rebuild_outbox_boot_state(&state, &outbox_config).await;
214    let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
215        &state,
216        &outbox_config,
217        outbox_clustered,
218        backpressure_settings,
219        &shutdown_rx,
220    )?;
221    // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
222    // supervisor — automatic failover detection. A single-node boot spawns
223    // nothing here (the method returns `false`), so default behaviour is
224    // unchanged.
225    #[cfg(feature = "haematite-backend")]
226    maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
227    // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
228    // dead-worker detection is a liveness correctness property, not an opt-in
229    // feature. It is the production caller of `fail_expired_workers`: a worker
230    // whose stream stays open while its process wedges (stops heartbeating
231    // without disconnecting) is expired, deregistered with the provable Timeout
232    // reason, and its in-flight tasks surface as retryable lost-worker failures.
233    // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
234    // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
235    // separate config knob. It drains on the same shutdown watch as the
236    // transports; dropping the JoinHandle only detaches the task.
237    drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
238    let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
239    let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
240
241    let outcome = tokio::select! {
242        result = &mut grpc => {
243            transport_result("gRPC", result)?;
244            state.shutdown()?;
245            ShutdownOutcome::Clean
246        },
247        result = &mut http => {
248            transport_result("HTTP", result)?;
249            state.shutdown()?;
250            ShutdownOutcome::Clean
251        },
252        result = shutdown_signal() => {
253            result?;
254            let _receiver_count = shutdown_tx.send(true);
255            let outcome = shutdown::drain_after_first_signal(state.clone(), async {
256                let _ = shutdown_signal().await;
257            }).await?;
258            if !matches!(outcome, ShutdownOutcome::Forced) {
259                transport_result("gRPC", grpc.await)?;
260                transport_result("HTTP", http.await)?;
261            }
262            outcome
263        },
264    };
265
266    Ok(outcome.exit_code())
267}
268
269fn transport_result(
270    transport: &'static str,
271    result: Result<Result<(), ServerError>, tokio::task::JoinError>,
272) -> Result<(), ServerError> {
273    match result {
274        Ok(transport_outcome) => transport_outcome,
275        Err(join_error) => Err(ServerError::Transport {
276            transport,
277            message: join_error.to_string(),
278        }),
279    }
280}
281
282async fn serve_grpc(
283    state: ServerState,
284    address: SocketAddr,
285    shutdown: tokio::sync::watch::Receiver<bool>,
286) -> Result<(), ServerError> {
287    let workflow = api::grpc::workflow_service(state.clone());
288    let worker = api::worker_grpc::worker_service(state.clone());
289    let mut router = TonicServer::builder()
290        .add_service(workflow)
291        .add_service(worker);
292    // Dark by default: the deploy service joins the listener only when the
293    // operator commissioned it; otherwise the surface answers Unimplemented.
294    if state.runtime_config().deploy.enabled {
295        router = router.add_service(api::deploy_grpc::deploy_service(state)?);
296    }
297    router
298        .serve_with_shutdown(address, shutdown_requested(shutdown))
299        .await
300        .map_err(|source| transport_bind("grpc", address, source))?;
301    Ok(())
302}
303
304async fn serve_http(
305    state: ServerState,
306    address: SocketAddr,
307    shutdown: tokio::sync::watch::Receiver<bool>,
308) -> Result<(), ServerError> {
309    let listener = TcpListener::bind(address)
310        .await
311        .map_err(|source| transport_bind("http", address, source))?;
312    axum::serve(listener, api::http::http_router(state)?)
313        .with_graceful_shutdown(shutdown_requested(shutdown))
314        .await
315        .map_err(|source| transport_bind("http", address, source))?;
316    Ok(())
317}
318
319async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
320    while !*shutdown.borrow_and_update() {
321        if shutdown.changed().await.is_err() {
322            break;
323        }
324    }
325}
326
327async fn shutdown_signal() -> Result<(), ServerError> {
328    #[cfg(unix)]
329    {
330        use tokio::signal::unix::{SignalKind, signal};
331
332        let mut terminate = signal(SignalKind::terminate())
333            .map_err(|source| signal_listener("SIGTERM", &source))?;
334        let mut interrupt =
335            signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
336        tokio::select! {
337            _ = terminate.recv() => Ok(()),
338            _ = interrupt.recv() => Ok(()),
339        }
340    }
341
342    #[cfg(not(unix))]
343    {
344        tokio::signal::ctrl_c()
345            .await
346            .map_err(|source| signal_listener("shutdown signal", &source))
347    }
348}
349
350fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
351    ServerError::SignalListener {
352        listener,
353        message: source.to_string(),
354    }
355}
356
357fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
358    if cfg!(not(feature = "auth")) && config.auth.enabled {
359        return Err(ServerError::Config {
360            message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
361        });
362    }
363    Ok(())
364}
365
366/// Rebuild the outbox-related boot state BEFORE the dispatcher's first claim,
367/// when (and only when) the outbox is commissioned:
368///
369/// - #204: repopulate the durable pause dispatch-hold from `list_paused`, so a
370///   run paused before a restart keeps its outbox rows held (never claimed)
371///   after recovery. A run projecting `Paused` is excluded from `list_active`
372///   respawn for free; this repopulates the hold that would otherwise be empty
373///   in memory after a crash.
374/// - #253: settle terminal workflows' stranded outbox rows. A workflow that
375///   reached a durable terminal without its rows being settled (a settle-hook
376///   failure, or a crash between the terminal append and the settle) must not
377///   have those rows re-armed and redelivered after restart — that is the
378///   zombie-round incident. A sweep error is loud but non-fatal: the
379///   settle-at-terminal hook and the reconciler's liveness gate remain as
380///   repair paths, and the residual window is one bounded dispatch whose
381///   completion drops unmatched, never a re-arm loop.
382async fn rebuild_outbox_boot_state(state: &ServerState, outbox_config: &OutboxConfig) {
383    if !outbox_config.enabled {
384        return;
385    }
386    let Ok(engine) = state.engine() else {
387        return;
388    };
389    if let Err(error) = engine.rebuild_paused_runs().await {
390        warn!(%error, "failed to rebuild paused-runs dispatch hold at startup");
391    }
392    let Some(outbox_store) = state.outbox_store() else {
393        return;
394    };
395    match crate::worker::settle_terminal_outbox_rows(engine.store().as_ref(), outbox_store.as_ref())
396        .await
397    {
398        Ok(settled) if settled.is_empty() => {}
399        Ok(settled) => {
400            info!(
401                settled = settled.len(),
402                "boot sweep settled stranded outbox rows for terminal workflows"
403            );
404        }
405        Err(error) => {
406            error!(
407                %error,
408                "boot sweep failed to settle terminal workflows' outbox rows; \
409                 the reconciler liveness gate remains the backstop"
410            );
411        }
412    }
413}
414
415/// Spawn the durable-outbox fan-out dispatcher when, and only when, the
416/// operator commissioned it (`outbox.enabled = true`).
417///
418/// This is the single gate that keeps Phase 2 dormant: with the flag off (the
419/// default) the function returns immediately without spawning a task, so
420/// default server behaviour — and the live workflow dispatch path — is entirely
421/// unchanged. When commissioned, the dispatcher claims rows through the engine's
422/// own shared `Arc<LibSqlStore>` (one `libsql::Connection`), so its writes
423/// serialize with the engine's rather than contending across a second
424/// connection. The dispatcher shares the server's shutdown watch, so it drains
425/// on the same signal as the transports.
426///
427/// NOTE (Phase boundary): the spawned dispatcher dispatches claimed rows and
428/// records each row's terminal outbox state (done / retry / failed). Routing the
429/// worker completion back into workflow history through the Recorder is Phase 3
430/// and is not wired here.
431fn maybe_spawn_outbox_dispatcher(
432    state: &ServerState,
433    outbox_config: &OutboxConfig,
434    clustered: bool,
435    backpressure_settings: BackpressureSettings,
436    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
437) -> Result<OutboxWorkerListener, ServerError> {
438    if !outbox_config.enabled {
439        return Ok(OutboxWorkerListener::default());
440    }
441    let dispatcher_config = resolve_outbox_config(outbox_config)?;
442    // Share the engine's already-opened store: one backing connection. The
443    // dispatcher's `claim_outbox_rows` writes then serialize against the engine's
444    // `append_with_outbox` on that single connection instead of contending across
445    // a second one. Both the libSQL and the haematite backends provide an
446    // `OutboxStore` (the haematite leaf is wired as the outbox store at boot); the
447    // in-memory backend has no outbox table, so `outbox_store()` is `None` and
448    // commissioning the dispatcher against it is a configuration error (LSUB-4-2).
449    let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
450        message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
451                  the durable outbox dispatcher claims rows from the store's outbox table, which \
452                  the in-memory store does not provide"
453            .to_owned(),
454    })?;
455    let (row_dispatch, worker_listener) = select_outbox_row_dispatch(state, outbox_config)?;
456    // LSUB-2: share the engine's advisory wake so the stage seam pulses this
457    // dispatcher the instant a fan-out row commits, dispatching in ~RTT instead of
458    // up to one poll interval. The wake is always-on and free; the interval poll is
459    // untouched, so it remains the correctness backstop for any lost wake.
460    // Control-Plane Phase 2 (P2-Q2): attach per-tenant keyed backpressure so each
461    // sweep claims per-namespace, round-robin, capped at each tenant's CLAIMED-only
462    // headroom (`per_node_ceiling − claimed`). The quota cache front-runs a per-sweep
463    // quorum `get_namespace`. With the generous platform default and no tenant
464    // override the ceiling never engages, so a default deployment's claim behaviour is
465    // byte-identical to the pre-Phase-2 single unscoped claim.
466    let quota_cache = crate::worker::QuotaCache::new(
467        Arc::clone(state.namespace_store()),
468        backpressure_settings.platform_default,
469        QUOTA_CACHE_TTL,
470    );
471    let backpressure =
472        crate::worker::Backpressure::new(quota_cache.clone(), backpressure_settings.fraction);
473    let mut dispatcher =
474        OutboxDispatcher::new(Arc::clone(&outbox_store), row_dispatch, dispatcher_config)
475            .with_wake(state.outbox_wake())
476            .with_backpressure(backpressure);
477    // #204: attach the engine's durable pause dispatch-hold so a held (paused)
478    // run's rows are never claimed. The hold set is rebuilt from `list_paused`
479    // BEFORE this spawn (see `run_server`), so the dispatcher's first claim
480    // already excludes pre-pause rows after a restart.
481    if let Ok(engine) = state.engine() {
482        dispatcher = dispatcher.with_paused_runs(engine.paused_runs());
483    }
484    tokio::spawn(dispatcher.run(shutdown_rx.clone()));
485    // Control-Plane Phase 2 (P2-Q3): commission the ops-console quota-state
486    // broadcaster on the SAME durable stores + quota cache the dispatcher enforces
487    // against, so the console badge is a faithful window onto the live per-tenant
488    // in-flight/ceiling the backpressure caps. It shares the shutdown watch, so it
489    // drains with the dispatcher. Only spawned alongside the (default-off)
490    // dispatcher: quota state is meaningless without the outbox fan-out path, and
491    // `in_flight` is the durable Claimed outbox count that path produces.
492    let quota_broadcaster = crate::worker::QuotaBroadcaster::new(
493        Arc::clone(state.namespace_store()),
494        Arc::clone(&outbox_store),
495        quota_cache,
496        state.cluster_publisher().clone(),
497        QUOTA_BROADCAST_CADENCE,
498    );
499    tokio::spawn(quota_broadcaster.run(shutdown_rx.clone()));
500    // LSUB-4-1: the single dispatcher task is spawned in both modes. In a
501    // single-node boot it owns all shards by construction; in an active-active
502    // clustered boot it claims ONLY the shards this node owns, enforced by
503    // `claim_outbox_rows`' owned-shard scope (already seeded before this point).
504    info!(
505        clustered,
506        "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
507         when clustered; single-node owns all shards)"
508    );
509    // LSUB-4-4: the stale-claim reconciler is the in-flight recovery backstop. It
510    // is only configured when BOTH reconcile knobs are set, so on a clustered boot
511    // that left them unset, owner-kill in-flight recovery latency is bounded only
512    // by re-residency replay (a survivor adopting the shard re-residents from
513    // history and re-arms via `rearm_outbox_pending`), NOT by `stale_after`. Warn
514    // so the operator knows the backstop is absent.
515    if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
516        // #253: the reconciler's liveness gate projects each stale candidate's
517        // workflow status from the engine's event store before any re-arm, so
518        // a terminal workflow's stranded row settles instead of redelivering.
519        let event_store = state.engine()?.store();
520        let reconciler = OutboxReconciler::new(outbox_store, event_store, reconciler_config);
521        tokio::spawn(reconciler.run(shutdown_rx.clone()));
522        info!("outbox reconciler commissioned (terminal-workflow liveness gate active)");
523    } else if clustered {
524        warn!(
525            "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
526             and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
527             owner is killed is then bounded only by re-residency replay on the adopting node, \
528             not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
529        );
530    }
531    Ok(worker_listener)
532}
533
534/// Spawn the SS-5b cluster supervisor when, and only when, this is a distributed
535/// haematite boot whose `[store.cluster]` declared peers with owned shards.
536///
537/// Reads the failover cadence + debounce from the cluster config (or the
538/// documented defaults), then asks the state to spawn the supervisor over its
539/// retained concrete store and live engine. With no `[store.cluster]` section —
540/// or with no peer declaring `owned_shards` — nothing is spawned and behaviour
541/// is unchanged.
542#[cfg(feature = "haematite-backend")]
543fn maybe_spawn_cluster_supervisor(
544    state: &ServerState,
545    cluster_config: Option<&crate::config::ClusterConfig>,
546    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
547) -> Result<(), ServerError> {
548    let Some(cluster) = cluster_config else {
549        return Ok(());
550    };
551    let poll_interval = std::time::Duration::from_millis(
552        cluster
553            .failover_poll_interval_ms
554            .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
555    );
556    let confirmations = cluster
557        .failover_confirmations
558        .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
559    let supervisor_config = crate::cluster::SupervisorConfig {
560        poll_interval,
561        confirmations,
562    };
563    let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
564    if spawned {
565        info!(
566            poll_interval_ms = %poll_interval.as_millis(),
567            confirmations,
568            "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
569        );
570    }
571    Ok(())
572}
573
574/// Select the outbox row-dispatch sink by the configured `outbox.transport`,
575/// returning the sink plus the worker listener whose lifetime the caller must
576/// hold.
577///
578/// `grpc` (the default) builds the unchanged [`WorkerOutboxDispatch`] over the
579/// connected-worker registry and carries the empty [`OutboxWorkerListener`], so a
580/// default server is byte-identical. `liminal` builds the cross-node
581/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) AND stands
582/// up the liminal worker listener the aion-server hosts (returned in the guard);
583/// it is only reachable when the `liminal-transport` feature is compiled in, and
584/// selecting it without that feature is a configuration error rather than a
585/// silent fall-through to gRPC.
586fn select_outbox_row_dispatch(
587    state: &ServerState,
588    outbox_config: &OutboxConfig,
589) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
590    match outbox_config.transport {
591        OutboxTransport::Grpc => {
592            let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
593                .with_drain_state(state.drain_state().clone());
594            // Control-Plane Phase 2 (P2-P3): attach the short-TTL placement cache
595            // so an unpinned row in a `Prefer{L}` namespace prefers an L-labelled
596            // worker (spilling to any live worker). The cache front-runs a per-row
597            // quorum `get_namespace` on the hot claim loop; a default-`Unplaced`
598            // deployment is byte-identical (every row falls through to any-worker).
599            let placement_cache = crate::worker::PlacementCache::new(
600                Arc::clone(state.namespace_store()),
601                PLACEMENT_CACHE_TTL,
602            );
603            let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
604                WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache),
605            );
606            Ok((dispatch, OutboxWorkerListener::default()))
607        }
608        OutboxTransport::Liminal => build_liminal_row_dispatch(state, outbox_config),
609    }
610}
611
612/// Build the production liminal row-dispatch sink and host the worker listener, or
613/// fail with the missing-feature error.
614///
615/// This lifts the tested cross-node wiring (the `lsub1`/`lsub5` e2e blueprint)
616/// into the production boot. The aion-server HOSTS the liminal listener that
617/// remote workers connect IN to, so its
618/// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
619/// owns each worker's connection and can push a dispatch out on it. The
620/// constructor cycle resolves the notifier <-> supervisor dependency:
621///
622/// 1. Reuse the registry already in [`ServerState`] — gRPC and liminal workers
623///    share ONE registry and the same `select_worker`, so routing is identical.
624/// 2. Build the [`LiminalConnectionNotifier`] over that registry (no supervisor
625///    yet).
626/// 3. Build the [`LiminalConnectionServices`] from the liminal listen config.
627/// 4. Build the [`ConnectionSupervisor`] WITH the services + notifier.
628/// 5. Bind the supervisor back into the notifier (must succeed).
629/// 6. Bind the [`ServerListener`] on the configured listen address — workers
630///    connect IN here.
631/// 7. Reuse the SAME completion callback the gRPC completion path installs
632///    ([`ServerOutboxDeliveryCallback`] over the live engine), so a liminal
633///    completion re-enters aion through the identical terminal-recording seam.
634/// 8. Build the [`RegistryLiminalDispatch`] over the registry + callback (it
635///    constructs the [`LiminalCompletionSource`] internally).
636///
637/// The returned listener is held by the caller for the server's lifetime; its
638/// `Drop` stops the accept worker on shutdown.
639///
640/// [`LiminalConnectionServices`]: liminal_server::server::connection::LiminalConnectionServices
641/// [`ServerListener`]: liminal_server::server::listener::ServerListener
642/// [`ServerOutboxDeliveryCallback`]: crate::worker::ServerOutboxDeliveryCallback
643/// [`LiminalCompletionSource`]: crate::worker::LiminalCompletionSource
644/// [`LiminalConnectionNotifier`]: crate::worker::LiminalConnectionNotifier
645#[cfg(feature = "liminal-transport")]
646fn build_liminal_row_dispatch(
647    state: &ServerState,
648    outbox_config: &OutboxConfig,
649) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
650    use liminal_server::config::ServerConfig as LiminalServerConfig;
651    use liminal_server::config::{LimitsConfig, ServicesConfig};
652    use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
653    use liminal_server::server::listener::ServerListener;
654
655    use crate::worker::{
656        LiminalConnectionNotifier, RegistryLiminalDispatch, ServerOutboxDeliveryCallback,
657    };
658
659    let listen_address = outbox_config
660        .liminal_listen_address
661        .as_ref()
662        .ok_or_else(|| ServerError::Config {
663            message: "outbox.transport=liminal requires outbox.liminal_listen_address \
664                      (host:port the aion-server listens on for inbound liminal worker \
665                      connections)"
666                .to_owned(),
667        })?;
668    let listen_address: SocketAddr =
669        listen_address
670            .parse()
671            .map_err(|error| ServerError::Config {
672                message: format!(
673                    "outbox.liminal_listen_address must be a host:port socket address: {error}"
674                ),
675            })?;
676
677    // The liminal listener is the worker-connection front door only: it binds the
678    // wire listen address and serves the connection supervisor. `from_config` and
679    // `ServerListener::bind` read neither `health_listen_address` nor `channels`
680    // (the health probe is bound only by the standalone liminal server's full
681    // boot, not this embedded path), so no separate health port is bound here;
682    // it is set structurally to the listen address and never used.
683    let liminal_config = LiminalServerConfig {
684        listen_address,
685        health_listen_address: listen_address,
686        drain_timeout_ms: 30_000,
687        channels: Vec::new(),
688        routing_rules: Vec::new(),
689        persistence_path: None,
690        cluster: None,
691        // liminal 0.2.3 (H4) added an optional shared-token Connect gate. `None`
692        // keeps this embedded worker front door open at the liminal layer —
693        // identical to the pre-0.2.3 wire behavior; worker identity/authorization
694        // stays aion's job (x-aion-* registration metadata). Threading an
695        // operator-configured token through aion's outbox config is a separate
696        // feature decision, not part of the dependency alignment.
697        auth: None,
698        // liminal 0.2.4 (D2/§5): service profile + operational bounds. Defaults =
699        // full profile + the certifying-pair-signed caps — byte-equivalent to the
700        // 0.2.3 behaviour this embedded front door always had. A worker-front-door
701        // profile election here is a future feature decision, not this migration.
702        services: ServicesConfig::default(),
703        limits: LimitsConfig::default(),
704        // liminal 0.3.0 (LP-WS-TRANSPORT R1 / LP Part B): optional WebSocket
705        // acceptor and participant lifecycle activation. `None` for both starts
706        // no WebSocket listener and leaves the participant capability disabled —
707        // documented as byte-identical to the pre-0.3.0 build. Electing either
708        // for this embedded worker front door is a feature decision, not part of
709        // the dependency alignment.
710        websocket: None,
711        participant: None,
712    };
713
714    // (1) Reuse the registry already in ServerState: gRPC + liminal workers share
715    // ONE registry and the same `select_worker`.
716    let registry = state.worker_registry().clone();
717    // (2) Notifier over that registry (supervisor bound after it is built), with the
718    // NOI-5b transcript tap: a worker's observability publishes on the reserved
719    // channel drain into the SAME transcript sequencer the transcript socket serves,
720    // so a live agent's transcript is persisted + fanned out. (Captures the current
721    // runtime handle to bridge the sync connection callback onto the async append.)
722    let notifier = Arc::new(
723        LiminalConnectionNotifier::new(registry.clone())
724            .with_transcript_publisher(state.transcript_publisher().clone())
725            // The SAME per-task liveness tracker the engine-seam bridge tracks
726            // into: a liminal worker's automatic liveness beats refresh it, so
727            // the #176 expiry sweeper never falsely expires a healthy liminal
728            // worker running an activity longer than the heartbeat window.
729            .with_heartbeat_tracker(state.heartbeat_tracker().clone()),
730    );
731    // (3) Connection services from the liminal listen config.
732    let services = Arc::new(
733        LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
734            ServerError::Config {
735                message: format!("liminal connection services build failed: {error}"),
736            }
737        })?,
738    );
739    // (4) Supervisor WITH the services + notifier (the cycle's forward edge).
740    let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
741        .map_err(|error| ServerError::Config {
742        message: format!("liminal connection supervisor build failed: {error}"),
743    })?;
744    // (5) Bind the supervisor back into the notifier (the cycle's back edge); a
745    // failure here is a wiring bug, surfaced rather than silently ignored.
746    if !notifier.bind_supervisor(supervisor.clone()) {
747        return Err(ServerError::Config {
748            message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
749        });
750    }
751    // (6) Bind the listener on the configured address — workers connect IN here.
752    let listener =
753        ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
754            message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
755        })?;
756    // (7) Reuse the SAME completion callback the gRPC completion path uses, over
757    // the live engine, so a liminal completion re-enters aion through the
758    // identical terminal-recording seam (`record_fan_out_completion`).
759    let engine = state.engine()?;
760    let callback: Arc<dyn crate::worker::OutboxDeliveryCallback> =
761        Arc::new(ServerOutboxDeliveryCallback::new(engine));
762    // (8) The registry-backed dispatch builds its LiminalCompletionSource from the
763    // shared callback internally. Attach the SAME short-TTL placement cache the
764    // gRPC arm installs (Control-Plane Phase 2, P2-P3), so an unpinned row in a
765    // `Prefer{L}` namespace prefers an L-labelled worker (spilling to any live
766    // worker) on the cross-node liminal transport too — the cluster-failover
767    // demo behaviour. A default-`Unplaced` deployment is byte-identical.
768    let placement_cache = crate::worker::PlacementCache::new(
769        Arc::clone(state.namespace_store()),
770        PLACEMENT_CACHE_TTL,
771    );
772    // NOI-6: install the SAME attempt-owner back-index the server's intervention
773    // router resolves through, so each dispatched agent attempt binds its owning
774    // worker and a pushed command reaches the worker this dispatcher sent it to.
775    let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
776        RegistryLiminalDispatch::new(registry, callback)
777            .with_placement_cache(placement_cache)
778            .with_attempt_owners(state.attempt_owners().clone()),
779    );
780
781    info!(
782        listen_address = %listen_address,
783        "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
784    );
785    Ok((
786        dispatch,
787        OutboxWorkerListener {
788            _inner: Some(listener),
789        },
790    ))
791}
792
793/// Feature-off stub: selecting the liminal transport without the
794/// `liminal-transport` feature is a configuration error, never a silent
795/// fall-through to gRPC.
796#[cfg(not(feature = "liminal-transport"))]
797fn build_liminal_row_dispatch(
798    _state: &ServerState,
799    _outbox_config: &OutboxConfig,
800) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
801    Err(ServerError::Config {
802        message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
803                  Cargo feature, which is not enabled in this build"
804            .to_owned(),
805    })
806}
807
808/// Resolve the validated, all-present outbox knobs into the dispatcher's
809/// non-optional config. Validation already guaranteed each value is set and in
810/// range when `outbox.enabled` is true, so an absent value here is a defensive
811/// configuration error, not a default to invent.
812fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
813    let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
814        message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
815    })?;
816    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
817        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
818    })?;
819    let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
820        message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
821    })?;
822    let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
823        message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
824    })?;
825    let backoff_multiplier = outbox
826        .backoff_multiplier
827        .ok_or_else(|| ServerError::Config {
828            message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
829        })?;
830    let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
831        message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
832    })?;
833    Ok(OutboxDispatcherConfig {
834        poll_interval: std::time::Duration::from_millis(poll_interval_ms),
835        batch_size,
836        max_attempts,
837        backoff_base: std::time::Duration::from_millis(backoff_base_ms),
838        backoff_multiplier,
839        backoff_max: std::time::Duration::from_millis(backoff_max_ms),
840    })
841}
842
843fn resolve_outbox_reconciler_config(
844    outbox: &OutboxConfig,
845) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
846    let (Some(interval_ms), Some(stale_after_ms)) = (
847        outbox.reconcile_interval_ms,
848        outbox.reconcile_stale_after_ms,
849    ) else {
850        return Ok(None);
851    };
852    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
853        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
854    })?;
855    Ok(Some(OutboxReconcilerConfig {
856        interval: std::time::Duration::from_millis(interval_ms),
857        stale_after: std::time::Duration::from_millis(stale_after_ms),
858        batch_size,
859    }))
860}
861
862fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
863    if state.runtime_config().tls.is_some() {
864        return Err(ServerError::Config {
865            message: "configured TLS material cannot be served until transport TLS is wired"
866                .to_owned(),
867        });
868    }
869    Ok(())
870}
871
872fn store_backend_label(backend: StoreBackend) -> &'static str {
873    match backend {
874        StoreBackend::Memory => "memory",
875        StoreBackend::LibSql => "libsql",
876        StoreBackend::Haematite => "haematite",
877    }
878}
879
880fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
881    match mode {
882        NamespaceMode::SharedEngine => "SharedEngine",
883        NamespaceMode::SingleTenant { .. } => "SingleTenant",
884    }
885}
886
887fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
888where
889    E: std::error::Error,
890{
891    ServerError::TransportBind {
892        transport,
893        address,
894        message: source.to_string(),
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    #![allow(clippy::expect_used)]
901
902    use super::{
903        BackpressureSettings, OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
904        resolve_outbox_reconciler_config,
905    };
906    use crate::ServerState;
907    use crate::config::RuntimeConfig;
908    use aion_store::InMemoryStore;
909    use std::net::SocketAddr;
910    use std::time::Duration;
911
912    /// Own-all, generous-default backpressure settings for the gate tests (the
913    /// single-node default: fraction 1, so the ceiling never engages).
914    fn test_backpressure_settings() -> BackpressureSettings {
915        BackpressureSettings {
916            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
917            fraction: crate::worker::OwnedShardFraction::own_all(),
918        }
919    }
920
921    /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
922    /// tests (mirrors `state.rs`'s test `runtime_config`).
923    fn runtime_config() -> RuntimeConfig {
924        use crate::config::{
925            AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
926            NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
927            WebSocketConfig, WorkerConfig,
928        };
929        RuntimeConfig {
930            listen: ListenConfig {
931                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
932                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
933            },
934            tls: None,
935            auth: AuthConfig {
936                enabled: false,
937                jwks_url: None,
938                jwks_refresh_seconds: 300,
939            },
940            ops_console: OpsConsoleConfig {
941                source: OpsConsoleAssetSource::Embedded,
942            },
943            namespace: NamespaceConfig {
944                mode: NamespaceMode::SharedEngine,
945            },
946            worker: WorkerConfig {
947                heartbeat_window: Duration::from_millis(30_000),
948            },
949            websocket: WebSocketConfig {
950                outbound_buffer_bound: 32,
951                event_broadcast_capacity: Some(64),
952                cluster_broadcast_capacity: Some(64),
953            },
954            workflow_packages: Vec::new(),
955            deploy: DeployConfig::default(),
956            authoring: AuthoringConfig::default(),
957            dev: DevConfig::default(),
958            outbox: OutboxConfig::default(),
959            observability: crate::config::ObservabilityConfig::default(),
960            scheduler_threads: 1,
961            query_timeout: Some(Duration::from_millis(10_000)),
962            default_namespace: "default".to_owned(),
963            auto_create: crate::config::AutoCreate::Open,
964            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
965            drain_timeout: Duration::from_secs(30),
966            metrics: MetricsConfig { enabled: true },
967            owned_shards: Vec::new(),
968            cors_allowed_origins: Vec::new(),
969        }
970    }
971
972    /// An `OutboxConfig` with `enabled = true` and every required knob present, so
973    /// the only remaining gate is the store-backend / outbox-table availability.
974    fn enabled_outbox_config() -> OutboxConfig {
975        OutboxConfig {
976            enabled: true,
977            poll_interval_ms: Some(250),
978            batch_size: Some(64),
979            max_attempts: Some(5),
980            backoff_base_ms: Some(100),
981            backoff_multiplier: Some(2),
982            backoff_max_ms: Some(30_000),
983            reconcile_interval_ms: None,
984            reconcile_stale_after_ms: None,
985            transport: OutboxTransport::Grpc,
986            liminal_listen_address: None,
987        }
988    }
989
990    /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
991    /// dispatcher against the in-memory backend (which has no outbox table, so
992    /// `outbox_store()` is `None`) is a configuration error, and the message names
993    /// BOTH supported backends (libsql / haematite), not just libsql.
994    #[tokio::test]
995    async fn outbox_enabled_on_memory_backend_is_a_config_error() {
996        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
997            .await
998            .expect("build in-memory state");
999        let (_tx, rx) = tokio::sync::watch::channel(false);
1000        let error = maybe_spawn_outbox_dispatcher(
1001            &state,
1002            &enabled_outbox_config(),
1003            false,
1004            test_backpressure_settings(),
1005            &rx,
1006        )
1007        .expect_err("outbox.enabled on the memory backend must be a config error");
1008        assert!(
1009            error.is_config(),
1010            "memory-backend outbox error must be Config"
1011        );
1012        let message = error.to_string();
1013        assert!(
1014            message.contains("libsql") && message.contains("haematite"),
1015            "corrected message must name both supported backends, got: {message}"
1016        );
1017    }
1018
1019    /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
1020    /// gate is a no-op even on a memory backend — nothing is spawned and no error
1021    /// is produced, so a default single-node boot is unchanged.
1022    #[tokio::test]
1023    async fn disabled_outbox_is_a_noop_on_any_backend() {
1024        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1025            .await
1026            .expect("build in-memory state");
1027        let (_tx, rx) = tokio::sync::watch::channel(false);
1028        maybe_spawn_outbox_dispatcher(
1029            &state,
1030            &OutboxConfig::default(),
1031            false,
1032            test_backpressure_settings(),
1033            &rx,
1034        )
1035        .expect("disabled outbox gate must be an infallible no-op");
1036    }
1037
1038    /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
1039    /// set — the condition under which the clustered-boot WARN fires.
1040    #[test]
1041    fn reconciler_config_absent_unless_both_knobs_set() {
1042        let mut config = enabled_outbox_config();
1043        // Neither knob: absent.
1044        assert!(
1045            resolve_outbox_reconciler_config(&config)
1046                .expect("resolve")
1047                .is_none()
1048        );
1049        // Only interval: still absent (the silent-backstop-absent default).
1050        config.reconcile_interval_ms = Some(1_000);
1051        assert!(
1052            resolve_outbox_reconciler_config(&config)
1053                .expect("resolve")
1054                .is_none()
1055        );
1056        // Both set: present.
1057        config.reconcile_stale_after_ms = Some(60_000);
1058        assert!(
1059            resolve_outbox_reconciler_config(&config)
1060                .expect("resolve")
1061                .is_some()
1062        );
1063    }
1064
1065    /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
1066    /// Commissioning the dispatcher with `transport = liminal` but no listen
1067    /// address is a configuration error naming the missing knob, rather than a
1068    /// panic or a silent fall-through to gRPC. Built over the libSQL backend (so
1069    /// the outbox-store gate passes and the missing-address check is actually
1070    /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
1071    /// only exists with `liminal-transport` on; in a feature-off build the same
1072    /// selection is the missing-feature error instead, covered by the type system
1073    /// rather than this test.)
1074    // Also gated on `libsql-backend`: it boots a real libSQL-backed `ServerState`
1075    // to obtain an outbox-bearing store, and the libSQL connect path is now an
1076    // opt-in feature. The listen-address guard itself is backend-agnostic.
1077    #[cfg(all(feature = "liminal-transport", feature = "libsql-backend"))]
1078    #[tokio::test]
1079    async fn liminal_transport_requires_listen_address() {
1080        use crate::config::{
1081            RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
1082        };
1083
1084        let db_path = std::env::temp_dir().join(format!(
1085            "aion-lsub-prod-listen-guard-{}-{}.db",
1086            std::process::id(),
1087            std::time::SystemTime::now()
1088                .duration_since(std::time::UNIX_EPOCH)
1089                .map(|elapsed| elapsed.as_nanos())
1090                .unwrap_or_default()
1091        ));
1092        let mut outbox = enabled_outbox_config();
1093        outbox.transport = OutboxTransport::Liminal;
1094        outbox.liminal_listen_address = None;
1095        let config = ServerConfig {
1096            store: StoreConfig {
1097                backend: StoreBackend::LibSql,
1098                url: Some(db_path.to_string_lossy().into_owned()),
1099                ..StoreConfig::default()
1100            },
1101            runtime: RuntimeSection {
1102                scheduler_threads: 1,
1103                query_timeout_ms: Some(10_000),
1104            },
1105            websocket: WebSocketConfig {
1106                outbound_buffer_bound: 32,
1107                event_broadcast_capacity: Some(64),
1108                cluster_broadcast_capacity: Some(64),
1109            },
1110            outbox: outbox.clone(),
1111            ..ServerConfig::default()
1112        };
1113        let state = ServerState::build(config)
1114            .await
1115            .expect("build libsql state");
1116        let (_tx, rx) = tokio::sync::watch::channel(false);
1117
1118        let error = maybe_spawn_outbox_dispatcher(
1119            &state,
1120            &outbox,
1121            false,
1122            test_backpressure_settings(),
1123            &rx,
1124        )
1125        .expect_err("liminal transport without a listen address must be a config error");
1126        assert!(
1127            error.is_config(),
1128            "missing-listen-address error must be Config"
1129        );
1130        assert!(
1131            error.to_string().contains("liminal_listen_address"),
1132            "error must name the missing knob, got: {error}"
1133        );
1134    }
1135}
1136
1137/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
1138///
1139/// This is the proof that the production boot now does the full round-trip the
1140/// retired stub could not. It drives the EXACT production commissioning function
1141/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
1142/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
1143/// `liminal_listen_address`. That function lifts the full push wiring
1144/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
1145/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
1146/// SAME registry the gRPC path uses and the SAME
1147/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
1148/// (over the live engine), and spawns the real [`OutboxDispatcher`].
1149///
1150/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
1151/// connects IN to the listener and self-registers in-band. A `collect_four`
1152/// fan-out is started over the REAL HTTP transport, which stages four pending
1153/// outbox rows; the production-wired dispatcher claims and pushes each to the
1154/// worker, the worker executes it, and its completion re-enters aion through the
1155/// production engine callback — `record_fan_out_completion` — driving the
1156/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
1157/// executed the activities, AND the terminals were recorded in history (four
1158/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
1159/// publish-and-mark-done path never achieved.
1160// Also gated on `libsql-backend`: this production-boot round-trip stands up a
1161// real libSQL-backed server (the durable outbox path it exercises), and the
1162// libSQL connect path is now an opt-in feature.
1163#[cfg(all(test, feature = "liminal-transport", feature = "libsql-backend"))]
1164mod lsub_prod_xnode_e2e {
1165    #![allow(clippy::expect_used)]
1166
1167    use std::net::SocketAddr;
1168    use std::path::PathBuf;
1169    use std::sync::Arc;
1170    use std::sync::atomic::{AtomicUsize, Ordering};
1171    use std::time::{Duration, Instant};
1172
1173    use aion_core::Event;
1174    use aion_package::{
1175        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
1176        PackageBuilder,
1177    };
1178    use aion_store::ReadableEventStore;
1179    use aion_store_libsql::LibSqlStore;
1180    use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
1181    use axum::body;
1182    use axum::http::{Request, StatusCode};
1183    use serde_json::json;
1184    use tower::ServiceExt;
1185
1186    use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
1187    use crate::ServerState;
1188    use crate::api::http::http_router;
1189    use crate::config::{
1190        OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
1191        WebSocketConfig,
1192    };
1193
1194    type TestError = Box<dyn std::error::Error + Send + Sync>;
1195
1196    /// The `collect_four` fixture passes each member the JSON string `"in"` as
1197    /// activity input, so the worker handler decodes a [`String`], not a struct.
1198    type FanInput = String;
1199
1200    const NAMESPACE: &str = "default";
1201    const TASK_QUEUE: &str = "default";
1202    const OUTBOX_MODULE: &str = "aion_outbox_fixture";
1203    const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
1204    const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
1205    const FAN_OUT: usize = 4;
1206    const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
1207    const POLL_DEADLINE: Duration = Duration::from_secs(20);
1208
1209    fn test_error(message: impl std::fmt::Display) -> TestError {
1210        message.to_string().into()
1211    }
1212
1213    /// Reserve a loopback port and return it: the liminal listener binds this exact
1214    /// address (the production path binds the configured `liminal_listen_address`,
1215    /// so the test must commit to a concrete port the worker can also dial).
1216    fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
1217        let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1218        let address = listener.local_addr().map_err(test_error)?;
1219        drop(listener);
1220        Ok(address)
1221    }
1222
1223    /// Build the `collect_four` package on disk so the production state-build path
1224    /// loads it exactly as it loads operator-supplied `workflow_packages`.
1225    fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
1226        let beams =
1227            BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
1228        let manifest = Manifest {
1229            entry_module: OUTBOX_MODULE.to_owned(),
1230            entry_function: "collect_four".to_owned(),
1231            input_schema: json!({ "type": "object" }),
1232            output_schema: json!({}),
1233            timeout: Duration::from_secs(30),
1234            activities: vec![DeclaredActivity {
1235                activity_type: "fixture_activity".to_owned(),
1236            }],
1237            version: ManifestVersion::new("stamped-by-builder"),
1238            format_version: CURRENT_FORMAT_VERSION,
1239            additional_workflows: Vec::new(),
1240        };
1241        let archive =
1242            PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
1243                .write_to_bytes()
1244                .map_err(test_error)?;
1245        let path = dir.join("collect_four.aion");
1246        std::fs::write(&path, archive).map_err(test_error)?;
1247        Ok(path)
1248    }
1249
1250    /// A production-shaped `ServerConfig`: the libSQL backend (so the boot store
1251    /// path shares the leaf as the dispatcher's outbox store, exactly as
1252    /// `ServerState::build` does in production), `outbox.enabled`,
1253    /// `transport = liminal`, the reserved `liminal_listen_address`, and the
1254    /// `collect_four` package. Built through `ServerState::build` (not
1255    /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
1256    fn server_config(
1257        db_path: &std::path::Path,
1258        package_path: PathBuf,
1259        listen_address: SocketAddr,
1260    ) -> ServerConfig {
1261        ServerConfig {
1262            store: StoreConfig {
1263                backend: StoreBackend::LibSql,
1264                url: Some(db_path.to_string_lossy().into_owned()),
1265                ..StoreConfig::default()
1266            },
1267            runtime: RuntimeSection {
1268                scheduler_threads: 1,
1269                query_timeout_ms: Some(10_000),
1270            },
1271            websocket: WebSocketConfig {
1272                outbound_buffer_bound: 32,
1273                event_broadcast_capacity: Some(64),
1274                cluster_broadcast_capacity: Some(64),
1275            },
1276            workflow_packages: vec![package_path],
1277            outbox: OutboxConfig {
1278                enabled: true,
1279                poll_interval_ms: Some(20),
1280                batch_size: Some(16),
1281                max_attempts: Some(5),
1282                backoff_base_ms: Some(50),
1283                backoff_multiplier: Some(2),
1284                backoff_max_ms: Some(1_000),
1285                reconcile_interval_ms: None,
1286                reconcile_stale_after_ms: None,
1287                transport: OutboxTransport::Liminal,
1288                liminal_listen_address: Some(listen_address.to_string()),
1289            },
1290            ..ServerConfig::default()
1291        }
1292    }
1293
1294    /// The remote worker self-describes for the fixture's pool `(default, default)`
1295    /// and registers a handler for every `fan:N` activity type, counting executions
1296    /// so the test proves it genuinely ran the pushed dispatches.
1297    fn worker_config() -> Result<WorkerConfig, TestError> {
1298        WorkerConfig::builder()
1299            .endpoint("unused-direct-address")
1300            .namespace(NAMESPACE)
1301            .task_queue(TASK_QUEUE)
1302            .identity("lsub-prod-worker")
1303            .max_concurrency(4)
1304            .reconnect_initial_backoff(Duration::from_millis(5))
1305            .reconnect_max_backoff(Duration::from_millis(20))
1306            .reconnect_max_attempts(3)
1307            .build()
1308            .map_err(test_error)
1309    }
1310
1311    fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1312        let mut registry = ActivityRegistry::new();
1313        for activity_type in FAN_ACTIVITY_TYPES {
1314            let executions = Arc::clone(executions);
1315            registry = registry
1316                .register_activity(activity_type, move |_input: FanInput, _context| {
1317                    let executions = Arc::clone(&executions);
1318                    Box::pin(async move {
1319                        executions.fetch_add(1, Ordering::SeqCst);
1320                        Ok(activity_type.to_owned())
1321                    })
1322                })
1323                .map_err(test_error)?;
1324        }
1325        Ok(Arc::new(registry))
1326    }
1327
1328    /// Spawns the remote worker on its own OS thread with a current-thread runtime
1329    /// (the push receive is blocking), connecting IN to the production listener.
1330    struct WorkerThread {
1331        stop: Arc<std::sync::atomic::AtomicBool>,
1332        handle: Option<std::thread::JoinHandle<()>>,
1333    }
1334
1335    impl WorkerThread {
1336        fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1337            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1338            let thread_stop = Arc::clone(&stop);
1339            let handle = std::thread::spawn(move || {
1340                let runtime = match tokio::runtime::Builder::new_current_thread()
1341                    .enable_all()
1342                    .build()
1343                {
1344                    Ok(runtime) => runtime,
1345                    Err(error) => {
1346                        eprintln!("worker runtime build failed: {error}");
1347                        return;
1348                    }
1349                };
1350                runtime.block_on(async move {
1351                    let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1352                        Ok(worker) => worker,
1353                        Err(error) => {
1354                            eprintln!("worker connect failed: {error}");
1355                            return;
1356                        }
1357                    };
1358                    if let Err(error) = worker
1359                        .serve_until(|| thread_stop.load(Ordering::SeqCst))
1360                        .await
1361                    {
1362                        eprintln!("worker serve loop ended with error: {error}");
1363                    }
1364                });
1365            });
1366            Self {
1367                stop,
1368                handle: Some(handle),
1369            }
1370        }
1371
1372        fn stop(mut self) {
1373            self.stop.store(true, Ordering::SeqCst);
1374            if let Some(handle) = self.handle.take() {
1375                handle.join().ok();
1376            }
1377        }
1378    }
1379
1380    fn count_completed(history: &[Event]) -> usize {
1381        history
1382            .iter()
1383            .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1384            .count()
1385    }
1386
1387    fn count_workflow_completed(history: &[Event]) -> usize {
1388        history
1389            .iter()
1390            .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1391            .count()
1392    }
1393
1394    async fn wait_for_history<F>(
1395        store: &LibSqlStore,
1396        workflow_id: &aion_core::WorkflowId,
1397        description: &str,
1398        predicate: F,
1399    ) -> Result<Vec<Event>, TestError>
1400    where
1401        F: Fn(&[Event]) -> bool,
1402    {
1403        let deadline = Instant::now() + POLL_DEADLINE;
1404        loop {
1405            let history = store.read_history(workflow_id).await.map_err(test_error)?;
1406            if predicate(&history) {
1407                return Ok(history);
1408            }
1409            if Instant::now() > deadline {
1410                return Err(test_error(format!(
1411                    "timed out waiting for {description}: {history:#?}"
1412                )));
1413            }
1414            tokio::time::sleep(Duration::from_millis(25)).await;
1415        }
1416    }
1417
1418    /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1419    async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1420        let build_request = || -> Result<Request<body::Body>, TestError> {
1421            Request::builder()
1422                .uri("/workflows/start")
1423                .method("POST")
1424                .header("content-type", "application/json")
1425                .header("x-aion-subject", "ci")
1426                .header("x-aion-namespaces", NAMESPACE)
1427                .body(body::Body::from(
1428                    serde_json::to_vec(&json!({
1429                        "namespace": NAMESPACE,
1430                        "workflow_type": OUTBOX_MODULE,
1431                        "input": { "fixture": "input" },
1432                    }))
1433                    .map_err(test_error)?,
1434                ))
1435                .map_err(test_error)
1436        };
1437        let response = router
1438            .clone()
1439            .oneshot(build_request()?)
1440            .await
1441            .map_err(test_error)?;
1442        let status = response.status();
1443        let bytes = body::to_bytes(response.into_body(), usize::MAX)
1444            .await
1445            .map_err(test_error)?
1446            .to_vec();
1447        if status != StatusCode::OK {
1448            return Err(test_error(format!(
1449                "workflow start over HTTP must succeed, got {status}: {}",
1450                String::from_utf8_lossy(&bytes)
1451            )));
1452        }
1453        let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1454        // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1455        // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1456        let workflow_id = body["workflow_id"]
1457            .as_str()
1458            .ok_or_else(|| test_error("start response missing workflow id"))?
1459            .parse::<uuid::Uuid>()
1460            .map_err(test_error)?;
1461        Ok(aion_core::WorkflowId::new(workflow_id))
1462    }
1463
1464    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1465    async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1466    {
1467        let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1468        let db_path = dir.path().join("aion.db");
1469        let package_path = write_package_archive(dir.path())?;
1470        // The production path binds the CONFIGURED listen address, so commit to a
1471        // concrete reserved loopback port the worker can also dial.
1472        let listen_address = reserve_loopback_port()?;
1473
1474        // (A) Build a real ServerState through the production boot path
1475        // (ServerState::build over a libSQL ServerConfig): outbox enabled,
1476        // transport = liminal, the listen address set, collect_four loaded. This
1477        // shares the libSQL leaf as the dispatcher's outbox store (the real boot
1478        // store seam) and installs the production ServerOutboxDeliveryCallback over
1479        // the live engine (gated on outbox.enabled).
1480        let config = server_config(&db_path, package_path, listen_address);
1481        let outbox_config = config.outbox.clone();
1482        let state = ServerState::build(config).await.map_err(test_error)?;
1483
1484        // (B) Drive the EXACT production commissioning function run_server calls:
1485        // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1486        // shared registry + engine callback, and spawns the real OutboxDispatcher.
1487        // Hold the returned listener guard for the test's lifetime, exactly as
1488        // run_server holds it.
1489        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1490        // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1491        // the platform default, so the ceiling never engages — the claim behaves
1492        // exactly as before, proving the production path is byte-identical on default.
1493        let backpressure_settings = BackpressureSettings {
1494            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1495            fraction: crate::worker::OwnedShardFraction::own_all(),
1496        };
1497        let listener_guard = maybe_spawn_outbox_dispatcher(
1498            &state,
1499            &outbox_config,
1500            false,
1501            backpressure_settings,
1502            &shutdown_rx,
1503        )
1504        .map_err(test_error)?;
1505
1506        // (C) A REAL remote worker connects IN to the production listener and
1507        // self-registers in-band for the fixture's pool.
1508        let executions = Arc::new(AtomicUsize::new(0));
1509        let worker = WorkerThread::spawn(
1510            listen_address.to_string(),
1511            worker_config()?,
1512            worker_registry(&executions)?,
1513        );
1514
1515        // Wait until the in-band registration landed in the SAME registry the
1516        // dispatch path selects from (every fan-out activity type is eligible).
1517        let registry = state.worker_registry().clone();
1518        let deadline = Instant::now() + Duration::from_secs(5);
1519        loop {
1520            let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
1521                registry
1522                    .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1523                    .ok()
1524                    .flatten()
1525                    .is_some()
1526            });
1527            if ready {
1528                break;
1529            }
1530            if Instant::now() > deadline {
1531                worker.stop();
1532                return Err(test_error("worker never registered in-band for the pool"));
1533            }
1534            tokio::time::sleep(Duration::from_millis(10)).await;
1535        }
1536
1537        // (D) Start collect_four over the REAL HTTP transport: the engine stages
1538        // four pending outbox rows; the production-wired dispatcher claims and
1539        // pushes each to the worker.
1540        let router = http_router(state.clone()).map_err(test_error)?;
1541        let workflow_id = start_over_http(&router).await?;
1542
1543        // (E) THE PROOF: the worker executed all four activities AND every terminal
1544        // was recorded through the production engine callback (record_fan_out_completion)
1545        // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1546        // is the full round-trip the retired stub never achieved.
1547        let reader = LibSqlStore::open(db_path.clone())
1548            .await
1549            .map_err(test_error)?;
1550        let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
1551            count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1552        })
1553        .await?;
1554        assert_eq!(
1555            count_completed(&settled),
1556            FAN_OUT,
1557            "every fan-out member must record a terminal through the production callback"
1558        );
1559        assert_eq!(
1560            count_workflow_completed(&settled),
1561            1,
1562            "the workflow must complete exactly once"
1563        );
1564        assert_eq!(
1565            executions.load(Ordering::SeqCst),
1566            FAN_OUT,
1567            "the remote worker must have executed every pushed dispatch exactly once"
1568        );
1569
1570        // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1571        // stops the accept worker), shut the engine down so durable appends finish.
1572        shutdown_tx.send(true).ok();
1573        worker.stop();
1574        drop(listener_guard);
1575        state.shutdown().map_err(test_error)?;
1576        Ok(())
1577    }
1578}