Skip to main content

aion_server/config/
load.rs

1//! [`ServerConfig`]: the complete merged server configuration, its load /
2//! merge / validate pipeline, and the workflow-package discovery helpers.
3//!
4//! This is the assembly point of the config surface: [`ServerConfig`] holds one
5//! field per `[section]` (defined in [`super::sections`]), and its impls carry
6//! the load/merge/CLI-override/default-fill/validate pipeline plus
7//! [`ServerConfig::into_parts`], which splits the durable store config from the
8//! non-secret [`RuntimeConfig`] runtime view. It is re-exported from the
9//! `config` module so every existing `crate::config::X` path resolves
10//! identically.
11
12use std::{
13    collections::HashSet,
14    fs,
15    path::{Path, PathBuf},
16    time::Duration,
17};
18
19use serde::Deserialize;
20
21use crate::error::ServerError;
22
23use super::{
24    AUTHORING_GLEAM_PATH_EMPTY, AUTHORING_PROJECT_ROOT_REQUIRED, AuthConfig, AuthoringConfig,
25    CORS_ALLOWED_ORIGIN_INVALID, CliOverrides, ClusterConfig, ConfigResolution,
26    DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
27    DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
28    DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
29    DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
30    DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED,
31    DEPLOY_MAX_INFLATED_BYTES_REQUIRED, DeployConfig, DevConfig, DrainConfig, ListenConfig,
32    MetricsConfig, NamespaceConfig, NamespaceMode, NamespacesConfig, OUTBOX_BACKOFF_BASE_REQUIRED,
33    OUTBOX_BACKOFF_MAX_REQUIRED, OUTBOX_BACKOFF_MULTIPLIER_REQUIRED, OUTBOX_BATCH_SIZE_REQUIRED,
34    OUTBOX_MAX_ATTEMPTS_REQUIRED, OUTBOX_POLL_INTERVAL_REQUIRED,
35    OUTBOX_RECONCILE_INTERVAL_REQUIRED, OUTBOX_RECONCILE_STALE_AFTER_REQUIRED, ObservabilityConfig,
36    OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig, QUERY_TIMEOUT_REQUIRED, RuntimeConfig,
37    RuntimeSection, ServerSection, StoreBackend, StoreConfig, TlsConfig, WebSocketConfig,
38    WorkerConfig, aion_home, config_error, env, file, resolution::fill_home_defaults,
39};
40
41/// Complete merged server configuration.
42#[derive(Clone, Debug, Deserialize)]
43#[serde(default, deny_unknown_fields)]
44#[derive(Default)]
45pub struct ServerConfig {
46    /// Public listener and transport addresses.
47    pub server: ServerSection,
48    /// Event-store backend configuration.
49    pub store: StoreConfig,
50    /// Engine runtime settings.
51    pub runtime: RuntimeSection,
52    /// Shutdown drain settings.
53    pub drain: DrainConfig,
54    /// Authentication settings defined by the operations config surface.
55    pub auth: AuthConfig,
56    /// Metrics endpoint settings.
57    pub metrics: MetricsConfig,
58    /// Namespace defaults.
59    pub namespaces: NamespacesConfig,
60    /// Optional TLS material for transports that require it.
61    pub tls: Option<TlsConfig>,
62    /// Static ops-console asset bundle location.
63    #[serde(alias = "dashboard")]
64    pub ops_console: OpsConsoleConfig,
65    /// Namespace resolver construction mode retained for existing transports.
66    pub namespace: NamespaceConfig,
67    /// Remote-worker heartbeat policy.
68    pub worker: WorkerConfig,
69    /// WebSocket event streaming policy.
70    pub websocket: WebSocketConfig,
71    /// Workflow package archives loaded into the engine at startup.
72    pub workflow_packages: Vec<PathBuf>,
73    /// Operator deploy API settings.
74    pub deploy: DeployConfig,
75    /// AWL studio and optional server-side Gleam authoring settings.
76    pub authoring: AuthoringConfig,
77    /// Local dev-server surface settings.
78    pub dev: DevConfig,
79    /// Durable-outbox fan-out dispatcher settings.
80    pub outbox: OutboxConfig,
81    /// Agent-observability transcript retention bounds.
82    pub observability: ObservabilityConfig,
83}
84
85pub(crate) struct LoadedConfig {
86    pub(crate) config: ServerConfig,
87    pub(crate) resolution: ConfigResolution,
88}
89
90impl ServerConfig {
91    /// Load and merge config from defaults, optional TOML file, environment, and CLI overrides.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`ServerError::Config`] when file discovery, parsing, environment parsing, CLI
96    /// values, or validation fail.
97    pub fn load(cli: &CliOverrides) -> Result<Self, ServerError> {
98        Ok(Self::load_resolved(cli)?.config)
99    }
100
101    pub(crate) fn load_resolved(cli: &CliOverrides) -> Result<LoadedConfig, ServerError> {
102        let home = aion_home()?;
103        let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
104            message: format!(
105                "failed to resolve the current directory for config discovery: {source}"
106            ),
107        })?;
108        Self::load_in(cli, &home, &working_dir, true)
109    }
110
111    fn load_in(
112        cli: &CliOverrides,
113        home: &Path,
114        working_dir: &Path,
115        overlay_environment: bool,
116    ) -> Result<LoadedConfig, ServerError> {
117        let discovered = file::discover(cli.config_path.as_deref(), home, working_dir)?;
118        let mut config = match discovered.bytes {
119            Some(bytes) => Self::parse_unresolved(&bytes).map_err(|error| ServerError::Config {
120                message: format!("failed to parse {}: {error}", discovered.source),
121            })?,
122            None => Self::default(),
123        };
124        if overlay_environment {
125            env::overlay(&mut config)?;
126        }
127        config.apply_cli_overrides(cli);
128        #[cfg(not(unix))]
129        let home_explicit = !overlay_environment || std::env::var_os("AION_HOME").is_some();
130        #[cfg(not(unix))]
131        let data_dir_explicit = config.store.data_dir.is_some();
132        #[cfg(not(unix))]
133        let data_root_required = matches!(config.store.backend, StoreBackend::Haematite);
134        #[cfg(not(unix))]
135        let authoring_workspace_explicit = config.authoring.workspace_dir.is_some();
136        config.load_discovered_workflow_packages(cli, working_dir)?;
137        let migrations = fill_home_defaults(&mut config, home, working_dir)?;
138        config.fill_operational_defaults();
139        config.validate()?;
140        let resolution = ConfigResolution {
141            home: home.to_owned(),
142            source: discovered.source,
143            data_dir: config.store.data_dir.clone(),
144            authoring_workspace: config.authoring.workspace_dir.clone(),
145            migrations,
146            #[cfg(not(unix))]
147            home_explicit,
148            #[cfg(not(unix))]
149            data_dir_explicit,
150            #[cfg(not(unix))]
151            data_root_required,
152            #[cfg(not(unix))]
153            authoring_workspace_explicit,
154        };
155        Ok(LoadedConfig { config, resolution })
156    }
157
158    #[cfg(test)]
159    fn load_for_test(
160        cli: &CliOverrides,
161        home: &Path,
162        working_dir: &Path,
163    ) -> Result<LoadedConfig, ServerError> {
164        Self::load_in(cli, home, working_dir, false)
165    }
166
167    /// Fill operational tuning knobs that have a sane default when omitted, so a
168    /// minimal or empty config boots without forcing the operator to hand-author
169    /// values that are pure tuning. Uses `get_or_insert`, so an explicitly set
170    /// value (including a misconfigured `0`, which [`Self::validate`] still
171    /// rejects) is left untouched; only an absent (`None`) field is defaulted.
172    fn fill_operational_defaults(&mut self) {
173        self.runtime
174            .query_timeout_ms
175            .get_or_insert(DEFAULT_QUERY_TIMEOUT_MS);
176        self.websocket
177            .event_broadcast_capacity
178            .get_or_insert(DEFAULT_EVENT_BROADCAST_CAPACITY);
179        self.websocket
180            .cluster_broadcast_capacity
181            .get_or_insert(DEFAULT_CLUSTER_BROADCAST_CAPACITY);
182        self.fill_outbox_defaults();
183        self.fill_deploy_defaults();
184    }
185
186    /// Fill the durable-outbox tuning knobs with sane defaults when the
187    /// dispatcher is enabled but a knob was omitted, so turning the feature on
188    /// does not force hand-authoring pure tuning. Inert while `outbox.enabled`
189    /// is false (the knobs are never read behind the gate). The reconciliation
190    /// pair is intentionally NOT defaulted: when both are absent reconciliation
191    /// stays dark, so forcing a default would silently commission a sweep.
192    /// `get_or_insert` leaves any explicit value (including a misconfigured `0`,
193    /// which [`Self::validate_outbox`] still rejects) untouched.
194    fn fill_outbox_defaults(&mut self) {
195        if !self.outbox.enabled {
196            return;
197        }
198        self.outbox
199            .poll_interval_ms
200            .get_or_insert(DEFAULT_OUTBOX_POLL_INTERVAL_MS);
201        self.outbox
202            .batch_size
203            .get_or_insert(DEFAULT_OUTBOX_BATCH_SIZE);
204        self.outbox
205            .max_attempts
206            .get_or_insert(DEFAULT_OUTBOX_MAX_ATTEMPTS);
207        self.outbox
208            .backoff_base_ms
209            .get_or_insert(DEFAULT_OUTBOX_BACKOFF_BASE_MS);
210        self.outbox
211            .backoff_multiplier
212            .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER);
213        self.outbox
214            .backoff_max_ms
215            .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MAX_MS);
216    }
217
218    /// Fill the deploy decompression-bomb ceilings with conservative defaults
219    /// when the deploy surface is enabled but a ceiling was omitted, so turning
220    /// the feature on boots rather than refusing for want of a security knob.
221    /// Inert while `deploy.enabled` is false (the ceilings are never read with
222    /// the surface dark). `get_or_insert` leaves any explicit value (including a
223    /// misconfigured `0` or an inflate ceiling below the archive ceiling, both
224    /// still rejected by [`Self::validate`]) untouched.
225    fn fill_deploy_defaults(&mut self) {
226        if !self.deploy.enabled {
227            return;
228        }
229        self.deploy
230            .max_archive_bytes
231            .get_or_insert(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES);
232        self.deploy
233            .max_inflated_bytes
234            .get_or_insert(DEFAULT_DEPLOY_MAX_INFLATED_BYTES);
235    }
236
237    fn load_discovered_workflow_packages(
238        &mut self,
239        cli: &CliOverrides,
240        directory: &Path,
241    ) -> Result<(), ServerError> {
242        let discovered_packages = discover_workflow_packages(directory)?;
243        merge_workflow_packages(
244            &mut self.workflow_packages,
245            discovered_packages,
246            &cli.workflow_packages,
247        );
248        Ok(())
249    }
250
251    /// Parse server configuration from TOML bytes and validate it.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`ServerError::Config`] when parsing fails or values are invalid.
256    pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
257        let home = aion_home()?;
258        Self::from_slice_with_home(bytes, &home)
259    }
260
261    /// Parse server configuration using an explicitly supplied Aion home.
262    ///
263    /// This is useful for embedded callers and tests that must not consult or
264    /// touch the process user's real home. It applies the same dynamic defaults
265    /// and legacy-directory migration guard as [`Self::load`], but does not
266    /// apply environment or CLI overlays.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`ServerError::Config`] when parsing, current-directory
271    /// resolution, dynamic path defaulting, or validation fails.
272    pub fn from_slice_with_home(bytes: &[u8], home: &Path) -> Result<Self, ServerError> {
273        let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
274            message: format!(
275                "failed to resolve the current directory for config defaults: {source}"
276            ),
277        })?;
278        let mut config = Self::parse_unresolved(bytes)?;
279        fill_home_defaults(&mut config, home, &working_dir)?;
280        config.fill_operational_defaults();
281        config.validate()?;
282        Ok(config)
283    }
284
285    fn parse_unresolved(bytes: &[u8]) -> Result<Self, ServerError> {
286        toml::from_slice(bytes).map_err(|source| ServerError::Config {
287            message: format!("invalid server config: {source}"),
288        })
289    }
290
291    /// Load server configuration from an explicit TOML file path.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`ServerError::Config`] when the file is missing, unreadable, unparsable, or invalid.
296    pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
297        file::load_required(&path.into())
298    }
299
300    /// Split store configuration from non-secret runtime settings.
301    #[must_use]
302    pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
303        let runtime = RuntimeConfig {
304            listen: ListenConfig {
305                grpc: self.server.grpc_address,
306                http: self.server.listen_address,
307            },
308            tls: self.tls,
309            auth: self.auth,
310            ops_console: self.ops_console,
311            namespace: self.namespace,
312            worker: self.worker,
313            websocket: self.websocket,
314            workflow_packages: self.workflow_packages,
315            deploy: self.deploy,
316            authoring: self.authoring,
317            dev: self.dev,
318            outbox: self.outbox,
319            observability: self.observability,
320            scheduler_threads: self.runtime.scheduler_threads,
321            query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
322            default_namespace: self.namespaces.default,
323            auto_create: self.namespaces.auto_create,
324            max_in_flight_activities: self.namespaces.max_in_flight_activities,
325            drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
326            metrics: self.metrics,
327            owned_shards: self.store.owned_shards.clone(),
328            cors_allowed_origins: self.server.cors_allowed_origins.clone(),
329        };
330        (self.store, runtime)
331    }
332
333    fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
334        if let Some(address) = cli.listen_address {
335            self.server.listen_address = address;
336        }
337        if let Some(url) = &cli.store_url {
338            self.store.url = Some(url.clone());
339            // `--store-url` names an embedded libSQL database file, so it is an
340            // explicit libSQL selection: coerce the implicit durable defaults
341            // (memory, or the new haematite default) to libsql. An operator who
342            // explicitly set `backend = "libsql"` already lands here too. The
343            // haematite backend ignores `store.url`, so the only way `--store-url`
344            // is meaningful is as a libSQL choice.
345            if matches!(
346                self.store.backend,
347                StoreBackend::Memory | StoreBackend::Haematite
348            ) {
349                self.store.backend = StoreBackend::LibSql;
350            }
351        }
352        if let Some(threads) = cli.scheduler_threads {
353            self.runtime.scheduler_threads = threads;
354        }
355        if let Some(timeout) = cli.drain_timeout_seconds {
356            self.drain.timeout_seconds = timeout;
357        }
358        if let Some(gleam_path) = &cli.gleam_path {
359            self.authoring.gleam_path = Some(gleam_path.clone());
360        }
361        if let Some(project_root) = &cli.authoring_project_root {
362            self.authoring.project_root = Some(project_root.clone());
363        }
364    }
365
366    fn validate(&self) -> Result<(), ServerError> {
367        if self.server.listen_address.port() == 0 {
368            return config_error("server.listen_address must use an explicit non-zero port");
369        }
370        if self.server.grpc_address.port() == 0 {
371            return config_error("server.grpc_address must use an explicit non-zero port");
372        }
373        validate_cors_origins(&self.server.cors_allowed_origins)?;
374        if self.runtime.scheduler_threads == 0 {
375            return config_error("runtime.scheduler_threads must be greater than zero");
376        }
377        if self.drain.timeout_seconds == 0 {
378            return config_error("drain.timeout_seconds must be greater than zero");
379        }
380        if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
381            return config_error("auth.jwks_url must not be empty when auth.enabled is true");
382        }
383        if self.auth.jwks_refresh_seconds == 0 {
384            return config_error("auth.jwks_refresh_seconds must be greater than zero");
385        }
386        if self.namespaces.default.is_empty() {
387            return config_error("namespaces.default must not be empty");
388        }
389        if matches!(self.store.backend, StoreBackend::LibSql)
390            && self.store.url.as_deref().is_none_or(str::is_empty)
391        {
392            return config_error("store.url must not be empty when store.backend is libsql");
393        }
394        if let Some(url) = &self.store.url {
395            if url.is_empty() {
396                return config_error("store.url must not be empty");
397            }
398        }
399        if matches!(self.store.backend, StoreBackend::Haematite) {
400            if self.store.data_dir.as_deref().is_none_or(str::is_empty) {
401                return config_error(
402                    "store.data_dir must not be empty when store.backend is haematite",
403                );
404            }
405            if self.store.shard_count == 0 {
406                return config_error("store.shard_count must be greater than zero");
407            }
408            if let Some(cluster) = &self.store.cluster {
409                validate_cluster(cluster)?;
410            }
411        } else if self.store.cluster.is_some() {
412            return config_error("store.cluster is only valid when store.backend is haematite");
413        }
414        if let OpsConsoleAssetSource::FileSystem { asset_path } = &self.ops_console.source {
415            if asset_path.as_os_str().is_empty() {
416                return config_error("ops_console.source.FileSystem.asset_path must not be empty");
417            }
418        }
419        if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
420            if namespace.is_empty() {
421                return config_error("namespace.mode.SingleTenant.namespace must not be empty");
422            }
423        }
424        if self.worker.heartbeat_window.is_zero() {
425            return config_error("worker.heartbeat_window must be greater than zero");
426        }
427        self.websocket.validate()?;
428        self.observability.validate()?;
429        match self.runtime.query_timeout_ms {
430            None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
431            Some(_) => {}
432        }
433        if self.deploy.enabled {
434            let max_archive_bytes = match self.deploy.max_archive_bytes {
435                None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
436                Some(value) => value,
437            };
438            let max_inflated_bytes = match self.deploy.max_inflated_bytes {
439                None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
440                Some(value) => value,
441            };
442            // Both ceilings size in-memory buffers, so they must be
443            // addressable on this platform (32-bit targets).
444            ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
445            ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
446            if max_inflated_bytes < max_archive_bytes {
447                return config_error(format!(
448                    "deploy.max_inflated_bytes ({max_inflated_bytes}) must be at least deploy.max_archive_bytes ({max_archive_bytes}): an inflate ceiling below the upload ceiling would refuse archives the upload ceiling admits, even stored uncompressed"
449                ));
450            }
451        }
452        if let Some(gleam_path) = &self.authoring.gleam_path {
453            // The authoring surface is commissioned by a non-empty gleam_path;
454            // an empty value is a misconfiguration, not "dark".
455            if gleam_path.as_os_str().is_empty() {
456                return config_error(AUTHORING_GLEAM_PATH_EMPTY);
457            }
458            // Commissioning the loop requires a project root with no default
459            // (a Gleam project cannot be invented; the operator provisions it).
460            match &self.authoring.project_root {
461                Some(root) if !root.as_os_str().is_empty() => {}
462                _ => return config_error(AUTHORING_PROJECT_ROOT_REQUIRED),
463            }
464        }
465        self.validate_outbox()?;
466        Ok(())
467    }
468
469    /// Validate the durable-outbox dispatcher knobs.
470    ///
471    /// All knobs are inert while `outbox.enabled` is false (the dispatcher is
472    /// never spawned), so they are only required — and only checked — once the
473    /// operator commissions the dispatcher. This mirrors the dark-by-default
474    /// `deploy` surface: the on/off gate carries no defaults, and every
475    /// operational value behind it is an explicit operator decision.
476    fn validate_outbox(&self) -> Result<(), ServerError> {
477        if !self.outbox.enabled {
478            return Ok(());
479        }
480        match self.outbox.poll_interval_ms {
481            None | Some(0) => return config_error(OUTBOX_POLL_INTERVAL_REQUIRED),
482            Some(_) => {}
483        }
484        match self.outbox.batch_size {
485            None | Some(0) => return config_error(OUTBOX_BATCH_SIZE_REQUIRED),
486            Some(_) => {}
487        }
488        match self.outbox.max_attempts {
489            None | Some(0) => return config_error(OUTBOX_MAX_ATTEMPTS_REQUIRED),
490            Some(_) => {}
491        }
492        let backoff_base_ms = match self.outbox.backoff_base_ms {
493            None | Some(0) => return config_error(OUTBOX_BACKOFF_BASE_REQUIRED),
494            Some(value) => value,
495        };
496        match self.outbox.backoff_multiplier {
497            None | Some(0) => return config_error(OUTBOX_BACKOFF_MULTIPLIER_REQUIRED),
498            Some(_) => {}
499        }
500        match self.outbox.backoff_max_ms {
501            Some(max) if max >= backoff_base_ms => {}
502            _ => return config_error(OUTBOX_BACKOFF_MAX_REQUIRED),
503        }
504        match (
505            self.outbox.reconcile_interval_ms,
506            self.outbox.reconcile_stale_after_ms,
507        ) {
508            (None, None) => {}
509            (None | Some(0), _) => return config_error(OUTBOX_RECONCILE_INTERVAL_REQUIRED),
510            (_, None | Some(0)) => return config_error(OUTBOX_RECONCILE_STALE_AFTER_REQUIRED),
511            (Some(_), Some(_)) => {}
512        }
513        Ok(())
514    }
515}
516
517/// Validate a `[store.cluster]` section: a non-empty node id, and every member /
518/// peer name non-empty. A cluster of one (no peers, members empty or `[node_id]`)
519/// is valid.
520fn validate_cluster(cluster: &ClusterConfig) -> Result<(), ServerError> {
521    if cluster.node_id.is_empty() {
522        return config_error("store.cluster.node_id must not be empty");
523    }
524    if cluster.members.iter().any(String::is_empty) {
525        return config_error("store.cluster.members entries must not be empty");
526    }
527    if cluster.peers.iter().any(|peer| peer.name.is_empty()) {
528        return config_error("store.cluster.peers entries must name a non-empty node");
529    }
530    if matches!(cluster.failover_poll_interval_ms, Some(0)) {
531        return config_error(
532            "store.cluster.failover_poll_interval_ms must be greater than zero when set",
533        );
534    }
535    if matches!(cluster.failover_confirmations, Some(0)) {
536        return config_error("store.cluster.failover_confirmations must be at least one when set");
537    }
538    Ok(())
539}
540
541/// Validate every `[server] cors_allowed_origins` entry.
542fn validate_cors_origins(origins: &[String]) -> Result<(), ServerError> {
543    for origin in origins {
544        validate_cors_origin(origin)?;
545    }
546    Ok(())
547}
548
549/// Validate one `[server] cors_allowed_origins` entry: it must be a non-empty,
550/// parseable HTTP origin so the `CorsLayer` can match it against the browser's
551/// `Origin` header. A malformed origin can never match a real request, so it is
552/// a misconfiguration caught at startup rather than silently never matching.
553fn validate_cors_origin(origin: &str) -> Result<(), ServerError> {
554    if origin.is_empty() {
555        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
556    }
557    // An origin is scheme + host + optional port and carries no path: reject a
558    // trailing slash or any path segment, which would never equal a browser
559    // `Origin` header value.
560    let scheme_split = origin.split_once("://");
561    let Some((scheme, authority)) = scheme_split else {
562        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
563    };
564    if scheme.is_empty() || authority.is_empty() || authority.contains('/') {
565        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
566    }
567    // It must parse as an HTTP header value (the form the CorsLayer compares).
568    if origin.parse::<axum::http::HeaderValue>().is_err() {
569        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
570    }
571    Ok(())
572}
573
574/// Refuses byte-ceiling values that cannot index memory on this platform.
575fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
576    if usize::try_from(value).is_err() {
577        return config_error(format!(
578            "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
579            usize::MAX
580        ));
581    }
582    Ok(())
583}
584
585fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
586    let mut packages = Vec::new();
587    let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
588        message: format!(
589            "failed to scan workflow packages in `{}`: {source}",
590            directory.display()
591        ),
592    })?;
593
594    for entry in entries {
595        let entry = entry.map_err(|source| ServerError::Config {
596            message: format!(
597                "failed to read workflow package entry in `{}`: {source}",
598                directory.display()
599            ),
600        })?;
601        let path = entry.path();
602        let has_aion_extension = path
603            .extension()
604            .is_some_and(|extension| extension == "aion");
605        if path.is_file() && has_aion_extension {
606            packages.push(path);
607        }
608    }
609
610    packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
611    Ok(packages)
612}
613
614fn merge_workflow_packages(
615    workflow_packages: &mut Vec<PathBuf>,
616    discovered_packages: Vec<PathBuf>,
617    cli_packages: &[PathBuf],
618) {
619    let mut seen: HashSet<PathBuf> = workflow_packages
620        .iter()
621        .map(|package| deduplicated_package_key(package))
622        .collect();
623    for package in discovered_packages
624        .into_iter()
625        .chain(cli_packages.iter().cloned())
626    {
627        if seen.insert(deduplicated_package_key(&package)) {
628            workflow_packages.push(package);
629        }
630    }
631}
632
633fn deduplicated_package_key(path: &Path) -> PathBuf {
634    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
635}
636
637#[cfg(test)]
638#[path = "load_home_tests.rs"]
639mod home_tests;
640
641#[cfg(test)]
642mod tests {
643    use crate::config::{
644        AutoCreate, DEFAULT_AUTHORING_WORKSPACE_DIR, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
645        OpsConsoleAssetSource,
646    };
647
648    use super::{
649        CliOverrides, DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
650        DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
651        DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
652        DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
653        DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, ServerConfig, StoreBackend,
654        discover_workflow_packages, merge_workflow_packages,
655    };
656
657    #[test]
658    fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
659        let config = ServerConfig::from_slice(
660            br#"
661                [server]
662                listen_address = "127.0.0.1:18080"
663                grpc_address = "127.0.0.1:15051"
664
665                [store]
666                backend = "libsql"
667                url = "aion.db"
668
669                [runtime]
670                scheduler_threads = 2
671                query_timeout_ms = 10000
672
673                [drain]
674                timeout_seconds = 45
675
676                [auth]
677                enabled = true
678                jwks_url = "https://issuer.example.com/.well-known/jwks.json"
679                jwks_refresh_seconds = 60
680
681                [metrics]
682                enabled = true
683
684                [namespaces]
685                default = "production"
686
687                [websocket]
688                outbound_buffer_bound = 16
689                event_broadcast_capacity = 1024
690                cluster_broadcast_capacity = 1024
691            "#,
692        )?;
693
694        assert_eq!(config.store.backend, StoreBackend::LibSql);
695        assert_eq!(config.store.url.as_deref(), Some("aion.db"));
696        assert_eq!(config.runtime.scheduler_threads, 2);
697        assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
698        assert_eq!(config.namespaces.default, "production");
699        // `auto_create` is omitted above, so it resolves to the Open default.
700        assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
701        // `max_in_flight_activities` is omitted above, so it resolves to the
702        // generous platform default.
703        assert_eq!(
704            config.namespaces.max_in_flight_activities,
705            DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
706        );
707        assert_eq!(config.websocket.outbound_buffer_bound, 16);
708        assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
709        Ok(())
710    }
711
712    #[test]
713    fn namespaces_auto_create_closed_parses() -> Result<(), Box<dyn std::error::Error>> {
714        let config = ServerConfig::from_slice(
715            br#"
716                [namespaces]
717                default = "production"
718                auto_create = "closed"
719            "#,
720        )?;
721        assert_eq!(config.namespaces.default, "production");
722        assert_eq!(config.namespaces.auto_create, AutoCreate::Closed);
723        Ok(())
724    }
725
726    #[test]
727    fn namespaces_auto_create_open_parses() -> Result<(), Box<dyn std::error::Error>> {
728        let config = ServerConfig::from_slice(
729            br#"
730                [namespaces]
731                auto_create = "open"
732            "#,
733        )?;
734        assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
735        Ok(())
736    }
737
738    #[test]
739    fn namespaces_max_in_flight_activities_override_parses()
740    -> Result<(), Box<dyn std::error::Error>> {
741        let config = ServerConfig::from_slice(
742            br#"
743                [namespaces]
744                default = "production"
745                max_in_flight_activities = 32
746            "#,
747        )?;
748        assert_eq!(config.namespaces.max_in_flight_activities, 32);
749        // The override also propagates into the runtime view.
750        let (_store, runtime) = config.into_parts();
751        assert_eq!(runtime.max_in_flight_activities, 32);
752        Ok(())
753    }
754
755    #[test]
756    fn namespaces_max_in_flight_activities_defaults_when_omitted()
757    -> Result<(), Box<dyn std::error::Error>> {
758        // An old/minimal config that predates the field omits it entirely and
759        // resolves to the generous platform default (additive, not a migration).
760        let config = ServerConfig::from_slice(
761            br#"
762                [namespaces]
763                default = "production"
764            "#,
765        )?;
766        assert_eq!(
767            config.namespaces.max_in_flight_activities,
768            DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
769        );
770        Ok(())
771    }
772
773    #[test]
774    fn namespaces_auto_create_rejects_unknown_variant() {
775        let result = ServerConfig::from_slice(
776            br#"
777                [namespaces]
778                auto_create = "sometimes"
779            "#,
780        );
781        assert!(
782            result.is_err(),
783            "an unknown auto_create variant must fail to parse"
784        );
785    }
786
787    #[test]
788    fn missing_event_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
789        // The server unconditionally mounts /events/stream, but the channel
790        // capacity is a tuning knob: omitting it must resolve to the default and
791        // boot, not fail startup.
792        let config = ServerConfig::from_slice(
793            br"
794                [runtime]
795                query_timeout_ms = 10000
796
797                [websocket]
798                cluster_broadcast_capacity = 64
799            ",
800        )?;
801        assert_eq!(
802            config.websocket.event_broadcast_capacity,
803            Some(DEFAULT_EVENT_BROADCAST_CAPACITY),
804            "omitted event_broadcast_capacity must resolve to the default"
805        );
806        Ok(())
807    }
808
809    #[test]
810    fn zero_event_broadcast_capacity_fails_startup_validation() {
811        let result = ServerConfig::from_slice(
812            br"
813                [websocket]
814                event_broadcast_capacity = 0
815            ",
816        );
817
818        let message = result
819            .err()
820            .map_or_else(String::new, |error| error.to_string());
821        assert!(
822            message.contains("websocket.event_broadcast_capacity"),
823            "validation message must name the zero-valued key: {message}"
824        );
825    }
826
827    #[test]
828    fn missing_cluster_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
829        // A config that sizes the workflow channel but omits the low-rate cluster
830        // channel must resolve the cluster capacity to its default and boot, not
831        // fail loudly.
832        let config = ServerConfig::from_slice(
833            br"
834                [runtime]
835                scheduler_threads = 1
836                query_timeout_ms = 10000
837
838                [websocket]
839                event_broadcast_capacity = 64
840            ",
841        )?;
842        assert_eq!(
843            config.websocket.cluster_broadcast_capacity,
844            Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY),
845            "omitted cluster_broadcast_capacity must resolve to the default"
846        );
847        Ok(())
848    }
849
850    #[test]
851    fn zero_cluster_broadcast_capacity_fails_startup_validation() {
852        let result = ServerConfig::from_slice(
853            br"
854                [runtime]
855                query_timeout_ms = 10000
856
857                [websocket]
858                event_broadcast_capacity = 64
859                cluster_broadcast_capacity = 0
860            ",
861        );
862
863        let message = result
864            .err()
865            .map_or_else(String::new, |error| error.to_string());
866        assert!(
867            message.contains("websocket.cluster_broadcast_capacity"),
868            "validation message must name the zero-valued cluster key: {message}"
869        );
870    }
871
872    /// An omitted `[observability]` section resolves both retention bounds to
873    /// their defaults and boots — retention is on out of the box, never a
874    /// forced operator decision.
875    #[test]
876    fn missing_observability_section_uses_defaults() -> Result<(), Box<dyn std::error::Error>> {
877        let config = ServerConfig::from_slice(
878            br"
879                [runtime]
880                query_timeout_ms = 10000
881
882                [websocket]
883                event_broadcast_capacity = 64
884                cluster_broadcast_capacity = 64
885            ",
886        )?;
887        assert_eq!(
888            config.observability.max_event_bytes,
889            crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
890        );
891        assert_eq!(
892            config.observability.max_stream_events,
893            crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS
894        );
895        // The bounds also ride into the runtime view the server state reads.
896        let (_store, runtime) = config.into_parts();
897        assert_eq!(
898            runtime.observability.max_event_bytes,
899            crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
900        );
901        Ok(())
902    }
903
904    /// Explicit `[observability]` values parse and round-trip into the runtime
905    /// view.
906    #[test]
907    fn observability_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
908        let config = ServerConfig::from_slice(
909            br"
910                [runtime]
911                query_timeout_ms = 10000
912
913                [websocket]
914                event_broadcast_capacity = 64
915                cluster_broadcast_capacity = 64
916
917                [observability]
918                max_event_bytes = 512
919                max_stream_events = 3
920            ",
921        )?;
922        assert_eq!(config.observability.max_event_bytes, 512);
923        assert_eq!(config.observability.max_stream_events, 3);
924        let (_store, runtime) = config.into_parts();
925        assert_eq!(runtime.observability.max_event_bytes, 512);
926        assert_eq!(runtime.observability.max_stream_events, 3);
927        Ok(())
928    }
929
930    #[test]
931    fn zero_observability_max_event_bytes_fails_startup_validation() {
932        let result = ServerConfig::from_slice(
933            br"
934                [runtime]
935                query_timeout_ms = 10000
936
937                [websocket]
938                event_broadcast_capacity = 64
939                cluster_broadcast_capacity = 64
940
941                [observability]
942                max_event_bytes = 0
943            ",
944        );
945        let message = result
946            .err()
947            .map_or_else(String::new, |error| error.to_string());
948        assert!(
949            message.contains("observability.max_event_bytes"),
950            "validation message must name the zero-valued key: {message}"
951        );
952    }
953
954    #[test]
955    fn zero_observability_max_stream_events_fails_startup_validation() {
956        let result = ServerConfig::from_slice(
957            br"
958                [runtime]
959                query_timeout_ms = 10000
960
961                [websocket]
962                event_broadcast_capacity = 64
963                cluster_broadcast_capacity = 64
964
965                [observability]
966                max_stream_events = 0
967            ",
968        );
969        let message = result
970            .err()
971            .map_or_else(String::new, |error| error.to_string());
972        assert!(
973            message.contains("observability.max_stream_events"),
974            "validation message must name the zero-valued key: {message}"
975        );
976    }
977
978    #[test]
979    fn missing_query_timeout_uses_default() -> Result<(), Box<dyn std::error::Error>> {
980        // The server unconditionally mounts /workflows/query, but the reply
981        // deadline is a tuning knob: omitting it must resolve to the default and
982        // boot, not fail startup.
983        let config = ServerConfig::from_slice(
984            br"
985                [runtime]
986                scheduler_threads = 1
987
988                [websocket]
989                event_broadcast_capacity = 64
990                cluster_broadcast_capacity = 64
991            ",
992        )?;
993        assert_eq!(
994            config.runtime.query_timeout_ms,
995            Some(DEFAULT_QUERY_TIMEOUT_MS),
996            "omitted query_timeout_ms must resolve to the default"
997        );
998        Ok(())
999    }
1000
1001    #[test]
1002    fn empty_config_boots_on_operational_defaults() -> Result<(), Box<dyn std::error::Error>> {
1003        // The headline zero-config contract: an empty TOML must parse, fill every
1004        // operational tuning knob with its default, and validate — so `aion
1005        // server` runs with no hand-authored file. The durable default backend
1006        // (haematite under its default data_dir) carries the store side.
1007        let config = ServerConfig::from_slice(b"")?;
1008        assert_eq!(config.store.backend, StoreBackend::Haematite);
1009        assert_eq!(
1010            config.runtime.query_timeout_ms,
1011            Some(DEFAULT_QUERY_TIMEOUT_MS)
1012        );
1013        assert_eq!(
1014            config.websocket.event_broadcast_capacity,
1015            Some(DEFAULT_EVENT_BROADCAST_CAPACITY)
1016        );
1017        assert_eq!(
1018            config.websocket.cluster_broadcast_capacity,
1019            Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY)
1020        );
1021        Ok(())
1022    }
1023
1024    #[test]
1025    fn zero_query_timeout_fails_startup_validation() {
1026        let result = ServerConfig::from_slice(
1027            br"
1028                [runtime]
1029                query_timeout_ms = 0
1030
1031                [websocket]
1032                event_broadcast_capacity = 64
1033                cluster_broadcast_capacity = 64
1034            ",
1035        );
1036
1037        let message = result
1038            .err()
1039            .map_or_else(String::new, |error| error.to_string());
1040        assert!(
1041            message.contains("runtime.query_timeout_ms"),
1042            "validation message must name the zero-valued key: {message}"
1043        );
1044    }
1045
1046    /// The deploy surface is commissioned explicitly: enabling it without
1047    /// the archive ceiling is a conservative security default, not a forced
1048    /// operator decision: enabling deploy without it must resolve the ceiling
1049    /// to [`DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES`] and boot, not fail startup.
1050    #[test]
1051    fn deploy_enabled_defaults_max_archive_bytes() -> Result<(), Box<dyn std::error::Error>> {
1052        let config = ServerConfig::from_slice(
1053            br"
1054                [runtime]
1055                query_timeout_ms = 10000
1056
1057                [websocket]
1058                event_broadcast_capacity = 64
1059                cluster_broadcast_capacity = 64
1060
1061                [deploy]
1062                enabled = true
1063            ",
1064        )?;
1065
1066        assert_eq!(
1067            config.deploy.max_archive_bytes,
1068            Some(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES),
1069            "omitted max_archive_bytes must resolve to the conservative default"
1070        );
1071        assert_eq!(
1072            config.deploy.max_inflated_bytes,
1073            Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1074            "omitted max_inflated_bytes must resolve to the conservative default"
1075        );
1076        Ok(())
1077    }
1078
1079    #[test]
1080    fn deploy_zero_max_archive_bytes_fails_startup_validation() {
1081        let result = ServerConfig::from_slice(
1082            br"
1083                [runtime]
1084                query_timeout_ms = 10000
1085
1086                [websocket]
1087                event_broadcast_capacity = 64
1088                cluster_broadcast_capacity = 64
1089
1090                [deploy]
1091                enabled = true
1092                max_archive_bytes = 0
1093            ",
1094        );
1095
1096        let message = result
1097            .err()
1098            .map_or_else(String::new, |error| error.to_string());
1099        assert!(
1100            message.contains("deploy.max_archive_bytes"),
1101            "validation message must name the zero-valued key: {message}"
1102        );
1103    }
1104
1105    /// The inflate ceiling defaults independently of an explicit archive
1106    /// ceiling: setting only `max_archive_bytes` must resolve the inflate
1107    /// ceiling to [`DEFAULT_DEPLOY_MAX_INFLATED_BYTES`] (which exceeds a 16 MiB
1108    /// archive, so the invariant holds) and boot.
1109    #[test]
1110    fn deploy_enabled_defaults_max_inflated_bytes() -> Result<(), Box<dyn std::error::Error>> {
1111        let config = ServerConfig::from_slice(
1112            br"
1113                [runtime]
1114                query_timeout_ms = 10000
1115
1116                [websocket]
1117                event_broadcast_capacity = 64
1118                cluster_broadcast_capacity = 64
1119
1120                [deploy]
1121                enabled = true
1122                max_archive_bytes = 16777216
1123            ",
1124        )?;
1125
1126        assert_eq!(
1127            config.deploy.max_archive_bytes,
1128            Some(16_777_216),
1129            "explicit max_archive_bytes must be left untouched"
1130        );
1131        assert_eq!(
1132            config.deploy.max_inflated_bytes,
1133            Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1134            "omitted max_inflated_bytes must resolve to the conservative default"
1135        );
1136        Ok(())
1137    }
1138
1139    #[test]
1140    fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
1141        let result = ServerConfig::from_slice(
1142            br"
1143                [runtime]
1144                query_timeout_ms = 10000
1145
1146                [websocket]
1147                event_broadcast_capacity = 64
1148                cluster_broadcast_capacity = 64
1149
1150                [deploy]
1151                enabled = true
1152                max_archive_bytes = 16777216
1153                max_inflated_bytes = 0
1154            ",
1155        );
1156
1157        let message = result
1158            .err()
1159            .map_or_else(String::new, |error| error.to_string());
1160        assert!(
1161            message.contains("deploy.max_inflated_bytes"),
1162            "validation message must name the zero-valued key: {message}"
1163        );
1164    }
1165
1166    /// An inflate ceiling below the upload ceiling is incoherent: archives
1167    /// the upload ceiling admits would be refused even stored uncompressed.
1168    #[test]
1169    fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
1170        let result = ServerConfig::from_slice(
1171            br"
1172                [runtime]
1173                query_timeout_ms = 10000
1174
1175                [websocket]
1176                event_broadcast_capacity = 64
1177                cluster_broadcast_capacity = 64
1178
1179                [deploy]
1180                enabled = true
1181                max_archive_bytes = 16777216
1182                max_inflated_bytes = 16777215
1183            ",
1184        );
1185
1186        let message = result
1187            .err()
1188            .map_or_else(String::new, |error| error.to_string());
1189        assert!(
1190            message.contains("deploy.max_inflated_bytes")
1191                && message.contains("deploy.max_archive_bytes"),
1192            "validation message must name both ceilings: {message}"
1193        );
1194    }
1195
1196    /// An absent `[deploy]` section means the surface stays dark and the
1197    /// ceilings are not required.
1198    #[test]
1199    fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
1200        let config = ServerConfig::from_slice(
1201            br"
1202                [runtime]
1203                query_timeout_ms = 10000
1204
1205                [websocket]
1206                event_broadcast_capacity = 64
1207                cluster_broadcast_capacity = 64
1208            ",
1209        )?;
1210
1211        assert!(!config.deploy.enabled);
1212        assert_eq!(config.deploy.max_archive_bytes, None);
1213        assert_eq!(config.deploy.max_inflated_bytes, None);
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
1219        let config = ServerConfig::from_slice(
1220            br"
1221                [runtime]
1222                query_timeout_ms = 10000
1223
1224                [websocket]
1225                event_broadcast_capacity = 64
1226                cluster_broadcast_capacity = 64
1227
1228                [deploy]
1229                enabled = true
1230                max_archive_bytes = 16777216
1231                max_inflated_bytes = 67108864
1232            ",
1233        )?;
1234
1235        assert!(config.deploy.enabled);
1236        assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
1237        assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
1238        Ok(())
1239    }
1240
1241    /// With no `[server] cors_allowed_origins` the list is empty: the secure
1242    /// default, where no cross-origin request is permitted and no `CorsLayer`
1243    /// is installed.
1244    #[test]
1245    fn cors_allowed_origins_default_empty() -> Result<(), Box<dyn std::error::Error>> {
1246        let config = ServerConfig::from_slice(
1247            br"
1248                [runtime]
1249                query_timeout_ms = 10000
1250
1251                [websocket]
1252                event_broadcast_capacity = 64
1253                cluster_broadcast_capacity = 64
1254            ",
1255        )?;
1256
1257        assert!(config.server.cors_allowed_origins.is_empty());
1258        let (_, runtime) = config.into_parts();
1259        assert!(runtime.cors_allowed_origins.is_empty());
1260        Ok(())
1261    }
1262
1263    /// A configured `[server] cors_allowed_origins` list parses and round-trips
1264    /// into `RuntimeConfig` (the value the `CorsLayer` is built from).
1265    #[test]
1266    fn cors_allowed_origins_parse_and_round_trip() -> Result<(), Box<dyn std::error::Error>> {
1267        let config = ServerConfig::from_slice(
1268            br#"
1269                [server]
1270                cors_allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
1271
1272                [runtime]
1273                query_timeout_ms = 10000
1274
1275                [websocket]
1276                event_broadcast_capacity = 64
1277                cluster_broadcast_capacity = 64
1278            "#,
1279        )?;
1280
1281        assert_eq!(
1282            config.server.cors_allowed_origins,
1283            vec![
1284                "http://localhost:5173".to_owned(),
1285                "http://127.0.0.1:5173".to_owned()
1286            ]
1287        );
1288        let (_, runtime) = config.into_parts();
1289        assert_eq!(
1290            runtime.cors_allowed_origins,
1291            vec![
1292                "http://localhost:5173".to_owned(),
1293                "http://127.0.0.1:5173".to_owned()
1294            ]
1295        );
1296        Ok(())
1297    }
1298
1299    /// A malformed CORS origin (no scheme, or a trailing path) can never match a
1300    /// browser `Origin` header, so it fails startup validation rather than
1301    /// silently never matching.
1302    #[test]
1303    fn cors_allowed_origins_reject_malformed() {
1304        for bad in ["", "localhost:5173", "http://localhost:5173/"] {
1305            let toml = format!(
1306                "[server]\ncors_allowed_origins = [\"{bad}\"]\n\n[runtime]\nquery_timeout_ms = 10000\n\n[websocket]\nevent_broadcast_capacity = 64\n"
1307            );
1308            let result = ServerConfig::from_slice(toml.as_bytes());
1309            let message = result
1310                .err()
1311                .map_or_else(String::new, |error| error.to_string());
1312            assert!(
1313                message.contains("cors_allowed_origins"),
1314                "malformed origin `{bad}` must be rejected naming the key: {message}"
1315            );
1316        }
1317    }
1318
1319    /// An absent `[dev]` section leaves the dev surface dark.
1320    #[test]
1321    fn dev_absent_leaves_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
1322        let config = ServerConfig::from_slice(
1323            br"
1324                [runtime]
1325                query_timeout_ms = 10000
1326
1327                [websocket]
1328                event_broadcast_capacity = 64
1329                cluster_broadcast_capacity = 64
1330            ",
1331        )?;
1332
1333        assert!(!config.dev.enabled);
1334        Ok(())
1335    }
1336
1337    /// `[dev] enabled = true` commissions the dev surface; it adds no other
1338    /// knobs (ADR-001: the only setting is the on/off gate).
1339    #[test]
1340    fn dev_section_parses_enabled() -> Result<(), Box<dyn std::error::Error>> {
1341        let config = ServerConfig::from_slice(
1342            br"
1343                [runtime]
1344                query_timeout_ms = 10000
1345
1346                [websocket]
1347                event_broadcast_capacity = 64
1348                cluster_broadcast_capacity = 64
1349
1350                [dev]
1351                enabled = true
1352            ",
1353        )?;
1354
1355        assert!(config.dev.enabled);
1356        Ok(())
1357    }
1358
1359    /// An absent `[authoring]` section commissions the AWL workspace while the
1360    /// separate Gleam loop remains dark and requires neither of its paths.
1361    #[test]
1362    fn authoring_absent_defaults_awl_workspace_but_keeps_gleam_dark()
1363    -> Result<(), Box<dyn std::error::Error>> {
1364        let home = crate::test_support::private_tempdir()?;
1365        let config = ServerConfig::from_slice_with_home(b"", home.path())?;
1366
1367        assert_eq!(config.authoring.gleam_path, None);
1368        assert_eq!(config.authoring.project_root, None);
1369        assert_eq!(
1370            config.authoring.workspace_dir.as_deref(),
1371            Some(home.path().join(DEFAULT_AUTHORING_WORKSPACE_DIR).as_path())
1372        );
1373        Ok(())
1374    }
1375
1376    /// An explicit `workspace_dir` wins over the out-of-box relative default.
1377    #[test]
1378    fn authoring_explicit_workspace_is_honored() -> Result<(), Box<dyn std::error::Error>> {
1379        let config = ServerConfig::from_slice(
1380            br#"
1381                [authoring]
1382                workspace_dir = "/srv/aion/studio"
1383            "#,
1384        )?;
1385
1386        assert_eq!(
1387            config.authoring.workspace_dir.as_deref(),
1388            Some(std::path::Path::new("/srv/aion/studio"))
1389        );
1390        Ok(())
1391    }
1392
1393    /// A configured `[authoring]` section with both `gleam_path` and
1394    /// `project_root` parses and round-trips into `RuntimeConfig`.
1395    #[test]
1396    fn authoring_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1397        let config = ServerConfig::from_slice(
1398            br#"
1399                [runtime]
1400                query_timeout_ms = 10000
1401
1402                [websocket]
1403                event_broadcast_capacity = 64
1404                cluster_broadcast_capacity = 64
1405
1406                [authoring]
1407                gleam_path = "/usr/local/bin/gleam"
1408                project_root = "/srv/aion/authoring"
1409            "#,
1410        )?;
1411
1412        assert_eq!(
1413            config.authoring.gleam_path.as_deref(),
1414            Some(std::path::Path::new("/usr/local/bin/gleam"))
1415        );
1416        let (_, runtime) = config.into_parts();
1417        assert_eq!(
1418            runtime.authoring.gleam_path.as_deref(),
1419            Some(std::path::Path::new("/usr/local/bin/gleam"))
1420        );
1421        assert_eq!(
1422            runtime.authoring.project_root.as_deref(),
1423            Some(std::path::Path::new("/srv/aion/authoring"))
1424        );
1425        Ok(())
1426    }
1427
1428    /// Commissioning the authoring loop (a `gleam_path`) without a
1429    /// `project_root` must fail startup naming the key and the environment
1430    /// override (the deploy required-config pattern).
1431    #[test]
1432    fn authoring_gleam_path_without_project_root_fails_naming_key_and_env() {
1433        let result = ServerConfig::from_slice(
1434            br#"
1435                [runtime]
1436                query_timeout_ms = 10000
1437
1438                [websocket]
1439                event_broadcast_capacity = 64
1440                cluster_broadcast_capacity = 64
1441
1442                [authoring]
1443                gleam_path = "/usr/local/bin/gleam"
1444            "#,
1445        );
1446
1447        let message = result
1448            .err()
1449            .map_or_else(String::new, |error| error.to_string());
1450        assert!(
1451            message.contains("authoring.project_root"),
1452            "validation message must name the missing key: {message}"
1453        );
1454        assert!(
1455            message.contains("AION_AUTHORING_PROJECT_ROOT"),
1456            "validation message must name the environment override: {message}"
1457        );
1458    }
1459
1460    /// An empty `gleam_path` is a misconfiguration, not "dark": it must fail
1461    /// startup naming the key and the environment override.
1462    #[test]
1463    fn authoring_empty_gleam_path_fails_naming_key_and_env() {
1464        let result = ServerConfig::from_slice(
1465            br#"
1466                [runtime]
1467                query_timeout_ms = 10000
1468
1469                [websocket]
1470                event_broadcast_capacity = 64
1471                cluster_broadcast_capacity = 64
1472
1473                [authoring]
1474                gleam_path = ""
1475            "#,
1476        );
1477
1478        let message = result
1479            .err()
1480            .map_or_else(String::new, |error| error.to_string());
1481        assert!(
1482            message.contains("authoring.gleam_path"),
1483            "validation message must name the empty key: {message}"
1484        );
1485        assert!(
1486            message.contains("AION_AUTHORING_GLEAM_PATH"),
1487            "validation message must name the environment override: {message}"
1488        );
1489    }
1490
1491    /// CLI overrides commission the authoring loop after file/env merge.
1492    #[test]
1493    fn cli_overrides_set_authoring_paths() -> Result<(), Box<dyn std::error::Error>> {
1494        let mut config = ServerConfig::from_slice(
1495            br"
1496                [runtime]
1497                query_timeout_ms = 10000
1498
1499                [websocket]
1500                event_broadcast_capacity = 64
1501                cluster_broadcast_capacity = 64
1502            ",
1503        )?;
1504        let cli = CliOverrides {
1505            gleam_path: Some(std::path::PathBuf::from("/opt/gleam")),
1506            authoring_project_root: Some(std::path::PathBuf::from("/opt/project")),
1507            ..CliOverrides::default()
1508        };
1509
1510        config.apply_cli_overrides(&cli);
1511        config.validate()?;
1512
1513        assert_eq!(
1514            config.authoring.gleam_path.as_deref(),
1515            Some(std::path::Path::new("/opt/gleam"))
1516        );
1517        assert_eq!(
1518            config.authoring.project_root.as_deref(),
1519            Some(std::path::Path::new("/opt/project"))
1520        );
1521        Ok(())
1522    }
1523
1524    /// The config field renamed `dashboard` -> `ops_console` carries a serde
1525    /// alias so existing `[dashboard]` TOML still parses (non-breaking rename).
1526    #[test]
1527    fn legacy_dashboard_section_alias_still_parses() -> Result<(), Box<dyn std::error::Error>> {
1528        let config = ServerConfig::from_slice(
1529            br#"
1530                [runtime]
1531                query_timeout_ms = 10000
1532
1533                [websocket]
1534                event_broadcast_capacity = 64
1535                cluster_broadcast_capacity = 64
1536
1537                [dashboard]
1538                source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1539            "#,
1540        )?;
1541        match &config.ops_console.source {
1542            OpsConsoleAssetSource::FileSystem { asset_path } => {
1543                assert_eq!(asset_path.as_os_str(), "/srv/aion/ui");
1544            }
1545            OpsConsoleAssetSource::Embedded => {
1546                return Err("legacy [dashboard] section must map to ops_console".into());
1547            }
1548        }
1549        Ok(())
1550    }
1551
1552    /// The new `[ops_console]` section name also parses.
1553    #[test]
1554    fn ops_console_section_parses() -> Result<(), Box<dyn std::error::Error>> {
1555        let config = ServerConfig::from_slice(
1556            br#"
1557                [runtime]
1558                query_timeout_ms = 10000
1559
1560                [websocket]
1561                event_broadcast_capacity = 64
1562                cluster_broadcast_capacity = 64
1563
1564                [ops_console]
1565                source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1566            "#,
1567        )?;
1568        assert!(matches!(
1569            config.ops_console.source,
1570            OpsConsoleAssetSource::FileSystem { .. }
1571        ));
1572        Ok(())
1573    }
1574
1575    #[test]
1576    fn invalid_values_name_problematic_field() {
1577        let result = ServerConfig::from_slice(
1578            br"
1579                [runtime]
1580                scheduler_threads = 0
1581            ",
1582        );
1583
1584        let message = result
1585            .err()
1586            .map_or_else(String::new, |error| error.to_string());
1587        assert!(message.contains("runtime.scheduler_threads"));
1588    }
1589
1590    #[test]
1591    fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
1592        let mut config = ServerConfig::from_slice(
1593            br#"
1594                [store]
1595                backend = "libsql"
1596                url = "file.db"
1597
1598                [runtime]
1599                query_timeout_ms = 10000
1600
1601                [websocket]
1602                event_broadcast_capacity = 64
1603                cluster_broadcast_capacity = 64
1604            "#,
1605        )?;
1606        let cli = CliOverrides {
1607            store_url: Some("cli.db".to_owned()),
1608            scheduler_threads: Some(3),
1609            ..CliOverrides::default()
1610        };
1611
1612        config.apply_cli_overrides(&cli);
1613        config.validate()?;
1614
1615        assert_eq!(config.store.url.as_deref(), Some("cli.db"));
1616        assert_eq!(config.runtime.scheduler_threads, 3);
1617        Ok(())
1618    }
1619
1620    #[test]
1621    fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
1622        let mut config = ServerConfig::default();
1623
1624        // Raw serde defaults defer user-level paths until the merged loader has
1625        // resolved Aion home and applied every operator override.
1626        assert_eq!(config.store.backend, StoreBackend::Haematite);
1627        assert_eq!(config.store.data_dir, None);
1628        // 64 pending #187: 4096 defeated its own lazy-materialization premise
1629        // (boot scan_prefix materializes all shards; commit then fsyncs per
1630        // shard and blows the actor timeout). See StoreConfig::default().
1631        assert_eq!(config.store.shard_count, 64);
1632        assert_eq!(config.store.url, None);
1633        assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
1634        assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
1635        assert_eq!(config.namespaces.default, "default");
1636        // Minted-on-use is OPEN by default to preserve the zero-config,
1637        // no-pre-provision model: a namespace comes into being on first
1638        // worker reference.
1639        assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
1640        // The cluster-wide in-flight ceiling defaults to the generous platform
1641        // headroom value (P2-Q1); nothing enforces it yet.
1642        assert_eq!(
1643            config.namespaces.max_in_flight_activities,
1644            DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
1645        );
1646        assert_eq!(config.namespaces.max_in_flight_activities, 1024);
1647        assert!(!config.auth.enabled);
1648        assert!(config.metrics.enabled);
1649        // event_broadcast_capacity and query_timeout_ms are the deliberately
1650        // defaultless values: defaults validate only once the operator
1651        // supplies them.
1652        assert_eq!(config.websocket.event_broadcast_capacity, None);
1653        assert_eq!(config.websocket.cluster_broadcast_capacity, None);
1654        assert_eq!(config.runtime.query_timeout_ms, None);
1655        config.websocket.event_broadcast_capacity = Some(64);
1656        config.websocket.cluster_broadcast_capacity = Some(64);
1657        config.runtime.query_timeout_ms = Some(10_000);
1658        let home = crate::test_support::private_tempdir()?;
1659        let working_dir = crate::test_support::private_tempdir()?;
1660        super::fill_home_defaults(&mut config, home.path(), working_dir.path())?;
1661        assert_eq!(
1662            config.store.data_dir.as_deref(),
1663            home.path().join("data").to_str()
1664        );
1665        config.validate()?;
1666        Ok(())
1667    }
1668
1669    #[test]
1670    fn outbox_is_disabled_by_default_and_needs_no_knobs() -> Result<(), Box<dyn std::error::Error>>
1671    {
1672        let mut config = ServerConfig::default();
1673        config.store.data_dir = Some("test-data".to_owned());
1674        config.websocket.event_broadcast_capacity = Some(64);
1675        config.websocket.cluster_broadcast_capacity = Some(64);
1676        config.runtime.query_timeout_ms = Some(10_000);
1677
1678        // The dispatcher is dark by default and its operational knobs are all
1679        // absent — yet validation passes, because a disabled dispatcher never
1680        // reads them (no assumed defaults behind the gate).
1681        assert!(!config.outbox.enabled);
1682        assert_eq!(config.outbox.poll_interval_ms, None);
1683        assert_eq!(config.outbox.batch_size, None);
1684        assert_eq!(config.outbox.max_attempts, None);
1685        assert_eq!(config.outbox.backoff_base_ms, None);
1686        assert_eq!(config.outbox.backoff_multiplier, None);
1687        assert_eq!(config.outbox.backoff_max_ms, None);
1688        assert_eq!(config.outbox.reconcile_interval_ms, None);
1689        assert_eq!(config.outbox.reconcile_stale_after_ms, None);
1690        config.validate()?;
1691        Ok(())
1692    }
1693
1694    fn outbox_enabled_base() -> ServerConfig {
1695        let mut config = ServerConfig::default();
1696        config.store.data_dir = Some("test-data".to_owned());
1697        config.websocket.event_broadcast_capacity = Some(64);
1698        config.websocket.cluster_broadcast_capacity = Some(64);
1699        config.runtime.query_timeout_ms = Some(10_000);
1700        config.outbox.enabled = true;
1701        config.outbox.poll_interval_ms = Some(250);
1702        config.outbox.batch_size = Some(64);
1703        config.outbox.max_attempts = Some(5);
1704        config.outbox.backoff_base_ms = Some(100);
1705        config.outbox.backoff_multiplier = Some(2);
1706        config.outbox.backoff_max_ms = Some(30_000);
1707        config.outbox.reconcile_interval_ms = Some(1_000);
1708        config.outbox.reconcile_stale_after_ms = Some(60_000);
1709        config
1710    }
1711
1712    #[test]
1713    fn outbox_enabled_with_all_knobs_validates() -> Result<(), Box<dyn std::error::Error>> {
1714        outbox_enabled_base().validate()?;
1715        Ok(())
1716    }
1717
1718    #[test]
1719    fn outbox_enabled_defaults_poll_interval() -> Result<(), Box<dyn std::error::Error>> {
1720        // Enabling the dispatcher but omitting the poll cadence must resolve it
1721        // to the default and boot, not fail startup — the cadence is pure
1722        // tuning, not a forced operator decision.
1723        let config = ServerConfig::from_slice(
1724            br"
1725                [runtime]
1726                query_timeout_ms = 10000
1727
1728                [websocket]
1729                event_broadcast_capacity = 64
1730                cluster_broadcast_capacity = 64
1731
1732                [outbox]
1733                enabled = true
1734            ",
1735        )?;
1736        assert_eq!(
1737            config.outbox.poll_interval_ms,
1738            Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS),
1739            "omitted poll_interval_ms must resolve to the default"
1740        );
1741        Ok(())
1742    }
1743
1744    #[test]
1745    fn outbox_enabled_defaults_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
1746        // Setting a tuning knob explicitly but omitting the retry budget must
1747        // leave the explicit knob untouched and default only the omitted one.
1748        let config = ServerConfig::from_slice(
1749            br"
1750                [runtime]
1751                query_timeout_ms = 10000
1752
1753                [websocket]
1754                event_broadcast_capacity = 64
1755                cluster_broadcast_capacity = 64
1756
1757                [outbox]
1758                enabled = true
1759                poll_interval_ms = 250
1760            ",
1761        )?;
1762        assert_eq!(
1763            config.outbox.poll_interval_ms,
1764            Some(250),
1765            "explicit poll_interval_ms must be left untouched"
1766        );
1767        assert_eq!(
1768            config.outbox.max_attempts,
1769            Some(DEFAULT_OUTBOX_MAX_ATTEMPTS),
1770            "omitted max_attempts must resolve to the default"
1771        );
1772        Ok(())
1773    }
1774
1775    #[test]
1776    fn outbox_enabled_with_only_enabled_flag_uses_all_defaults()
1777    -> Result<(), Box<dyn std::error::Error>> {
1778        // Headline conditional-default contract: an outbox section with nothing
1779        // but `enabled = true` validates with every tuning knob resolved to its
1780        // default. The reconciliation pair stays dark (both absent), as before.
1781        let config = ServerConfig::from_slice(
1782            br"
1783                [runtime]
1784                query_timeout_ms = 10000
1785
1786                [websocket]
1787                event_broadcast_capacity = 64
1788                cluster_broadcast_capacity = 64
1789
1790                [outbox]
1791                enabled = true
1792            ",
1793        )?;
1794        assert!(config.outbox.enabled);
1795        assert_eq!(
1796            config.outbox.poll_interval_ms,
1797            Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS)
1798        );
1799        assert_eq!(config.outbox.batch_size, Some(DEFAULT_OUTBOX_BATCH_SIZE));
1800        assert_eq!(
1801            config.outbox.max_attempts,
1802            Some(DEFAULT_OUTBOX_MAX_ATTEMPTS)
1803        );
1804        assert_eq!(
1805            config.outbox.backoff_base_ms,
1806            Some(DEFAULT_OUTBOX_BACKOFF_BASE_MS)
1807        );
1808        assert_eq!(
1809            config.outbox.backoff_multiplier,
1810            Some(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER)
1811        );
1812        assert_eq!(
1813            config.outbox.backoff_max_ms,
1814            Some(DEFAULT_OUTBOX_BACKOFF_MAX_MS)
1815        );
1816        // Reconciliation is not force-defaulted: both knobs stay absent so the
1817        // live sweep remains dark.
1818        assert_eq!(config.outbox.reconcile_interval_ms, None);
1819        assert_eq!(config.outbox.reconcile_stale_after_ms, None);
1820        Ok(())
1821    }
1822
1823    #[test]
1824    fn outbox_enabled_zero_poll_interval_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1825        // An explicit zero is a misconfiguration the default never masks:
1826        // `get_or_insert` leaves `Some(0)` untouched and validate rejects it.
1827        let mut config = outbox_enabled_base();
1828        config.outbox.poll_interval_ms = Some(0);
1829        let error = config
1830            .validate()
1831            .err()
1832            .ok_or("enabled outbox with zero poll interval must fail")?;
1833        assert!(
1834            error.to_string().contains("outbox.poll_interval_ms"),
1835            "error must name the zero-valued key: {error}"
1836        );
1837        Ok(())
1838    }
1839
1840    #[test]
1841    fn outbox_enabled_zero_max_attempts_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1842        let mut config = outbox_enabled_base();
1843        config.outbox.max_attempts = Some(0);
1844        let error = config
1845            .validate()
1846            .err()
1847            .ok_or("enabled outbox with zero max attempts must fail")?;
1848        assert!(
1849            error.to_string().contains("outbox.max_attempts"),
1850            "error must name the zero-valued key: {error}"
1851        );
1852        Ok(())
1853    }
1854
1855    #[test]
1856    fn outbox_backoff_max_below_base_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1857        let mut config = outbox_enabled_base();
1858        config.outbox.backoff_base_ms = Some(1_000);
1859        config.outbox.backoff_max_ms = Some(500);
1860        let error = config
1861            .validate()
1862            .err()
1863            .ok_or("backoff_max below backoff_base must fail")?;
1864        assert!(
1865            error.to_string().contains("outbox.backoff_max_ms"),
1866            "error must name the offending key: {error}"
1867        );
1868        Ok(())
1869    }
1870
1871    #[test]
1872    fn outbox_enabled_can_leave_reconciliation_dark() -> Result<(), Box<dyn std::error::Error>> {
1873        let mut config = outbox_enabled_base();
1874        config.outbox.reconcile_interval_ms = None;
1875        config.outbox.reconcile_stale_after_ms = None;
1876        config.validate()?;
1877        Ok(())
1878    }
1879
1880    #[test]
1881    fn outbox_reconciliation_requires_interval_when_partially_enabled()
1882    -> Result<(), Box<dyn std::error::Error>> {
1883        let mut config = outbox_enabled_base();
1884        config.outbox.reconcile_interval_ms = None;
1885        let error = config
1886            .validate()
1887            .err()
1888            .ok_or("reconciliation without interval must fail")?;
1889        assert!(error.to_string().contains("outbox.reconcile_interval_ms"));
1890        Ok(())
1891    }
1892
1893    #[test]
1894    fn outbox_reconciliation_requires_stale_threshold_when_partially_enabled()
1895    -> Result<(), Box<dyn std::error::Error>> {
1896        let mut config = outbox_enabled_base();
1897        config.outbox.reconcile_stale_after_ms = None;
1898        let error = config
1899            .validate()
1900            .err()
1901            .ok_or("reconciliation without stale threshold must fail")?;
1902        assert!(
1903            error
1904                .to_string()
1905                .contains("outbox.reconcile_stale_after_ms")
1906        );
1907        Ok(())
1908    }
1909
1910    #[test]
1911    fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
1912        let temp_dir = crate::test_support::private_tempdir()?;
1913        std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
1914        std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
1915        std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
1916        std::fs::create_dir(temp_dir.path().join("nested"))?;
1917        std::fs::write(
1918            temp_dir.path().join("nested").join("nested.aion"),
1919            b"package",
1920        )?;
1921
1922        let packages = discover_workflow_packages(temp_dir.path())?;
1923
1924        assert_eq!(
1925            packages,
1926            vec![
1927                temp_dir.path().join("alpha.aion"),
1928                temp_dir.path().join("zeta.aion"),
1929            ]
1930        );
1931        Ok(())
1932    }
1933
1934    #[test]
1935    fn workflow_package_merge_is_additive_and_deduplicated() {
1936        let mut packages = vec!["config.aion".into(), "shared.aion".into()];
1937        let discovered = vec!["auto.aion".into(), "shared.aion".into()];
1938        let cli = vec!["cli.aion".into(), "auto.aion".into()];
1939
1940        merge_workflow_packages(&mut packages, discovered, &cli);
1941
1942        assert_eq!(
1943            packages,
1944            vec![
1945                std::path::PathBuf::from("config.aion"),
1946                std::path::PathBuf::from("shared.aion"),
1947                std::path::PathBuf::from("auto.aion"),
1948                std::path::PathBuf::from("cli.aion"),
1949            ]
1950        );
1951    }
1952
1953    #[test]
1954    fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
1955        let temp_dir = crate::test_support::private_tempdir()?;
1956        let package = temp_dir.path().join("hello.aion");
1957        std::fs::write(&package, b"package")?;
1958        let mut packages = vec![package.clone()];
1959        let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
1960
1961        merge_workflow_packages(&mut packages, discovered, &[]);
1962
1963        assert_eq!(packages, vec![package]);
1964        Ok(())
1965    }
1966
1967    #[test]
1968    fn zero_config_cli_workflow_package_uses_in_memory_defaults()
1969    -> Result<(), Box<dyn std::error::Error>> {
1970        let temp_dir = crate::test_support::private_tempdir()?;
1971
1972        let cli = CliOverrides {
1973            workflow_packages: vec!["hello-world.aion".into()],
1974            ..CliOverrides::default()
1975        };
1976        let mut config = ServerConfig::default();
1977        // This test exercises CLI workflow-package discovery against the ephemeral
1978        // in-memory store, so it opts OUT of the new durable haematite default
1979        // explicitly (the default would otherwise carry a haematite data_dir).
1980        config.store.backend = StoreBackend::Memory;
1981        config.store.data_dir = None;
1982        // Even zero-config development runs must size event streaming and the
1983        // query reply deadline explicitly (config keys or the
1984        // AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY /
1985        // AION_RUNTIME_QUERY_TIMEOUT_MS environment overrides).
1986        config.websocket.event_broadcast_capacity = Some(64);
1987        config.websocket.cluster_broadcast_capacity = Some(64);
1988        config.runtime.query_timeout_ms = Some(10_000);
1989        config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
1990
1991        config.validate()?;
1992
1993        assert_eq!(config.store.backend, StoreBackend::Memory);
1994        assert_eq!(config.store.url, None);
1995        assert_eq!(
1996            config.workflow_packages,
1997            vec![std::path::PathBuf::from("hello-world.aion")]
1998        );
1999        Ok(())
2000    }
2001
2002    #[test]
2003    fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
2004        let mut config = ServerConfig::from_slice(
2005            br#"
2006                workflow_packages = ["config.aion"]
2007
2008                [runtime]
2009                query_timeout_ms = 10000
2010
2011                [websocket]
2012                event_broadcast_capacity = 64
2013                cluster_broadcast_capacity = 64
2014            "#,
2015        )?;
2016        let cli = CliOverrides {
2017            workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
2018            ..CliOverrides::default()
2019        };
2020
2021        merge_workflow_packages(
2022            &mut config.workflow_packages,
2023            Vec::new(),
2024            &cli.workflow_packages,
2025        );
2026
2027        assert_eq!(
2028            config.workflow_packages,
2029            vec![
2030                std::path::PathBuf::from("config.aion"),
2031                std::path::PathBuf::from("cli-one.aion"),
2032                std::path::PathBuf::from("cli-two.aion"),
2033            ]
2034        );
2035        Ok(())
2036    }
2037}