1use 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, HomeSource,
32 ListenConfig, McpConfig, MetricsConfig, NamespaceConfig, NamespaceMode, NamespacesConfig,
33 OUTBOX_BACKOFF_BASE_REQUIRED, OUTBOX_BACKOFF_MAX_REQUIRED, OUTBOX_BACKOFF_MULTIPLIER_REQUIRED,
34 OUTBOX_BATCH_SIZE_REQUIRED, 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, WorkerSupervisionConfig, aion_home, config_error, env, file, home,
39 resolution::fill_home_defaults, sections::RetiredStoreInput,
40};
41
42#[derive(Clone, Debug, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45#[derive(Default)]
46pub struct ServerConfig {
47 pub server: ServerSection,
49 pub store: StoreConfig,
51 pub runtime: RuntimeSection,
53 pub drain: DrainConfig,
55 pub auth: AuthConfig,
57 pub metrics: MetricsConfig,
59 pub namespaces: NamespacesConfig,
61 pub tls: Option<TlsConfig>,
63 #[serde(alias = "dashboard")]
65 pub ops_console: OpsConsoleConfig,
66 pub namespace: NamespaceConfig,
68 pub worker: WorkerConfig,
70 pub websocket: WebSocketConfig,
72 pub workflow_packages: Vec<PathBuf>,
74 pub deploy: DeployConfig,
76 pub authoring: AuthoringConfig,
78 pub dev: DevConfig,
80 pub outbox: OutboxConfig,
82 pub observability: ObservabilityConfig,
84 pub mcp: McpConfig,
86 pub worker_supervision: WorkerSupervisionConfig,
89}
90
91pub(crate) struct LoadedConfig {
92 pub(crate) config: ServerConfig,
93 pub(crate) resolution: ConfigResolution,
94}
95
96fn expand_operator_path_tildes(config: &mut ServerConfig) -> Result<(), ServerError> {
106 if let Some(data_dir) = config.store.data_dir.as_deref() {
107 let expanded = home::expand_tilde(Path::new(data_dir))?;
108 let expanded = expanded.to_str().ok_or_else(|| ServerError::Config {
109 message: format!(
110 "store.data_dir `{data_dir}` expands to a path that is not valid UTF-8"
111 ),
112 })?;
113 config.store.data_dir = Some(expanded.to_owned());
114 }
115 if let Some(workspace_dir) = config.authoring.workspace_dir.as_deref() {
116 config.authoring.workspace_dir = Some(home::expand_tilde(workspace_dir)?);
117 }
118 Ok(())
119}
120
121impl ServerConfig {
122 pub fn load(cli: &CliOverrides) -> Result<Self, ServerError> {
129 Ok(Self::load_resolved(cli)?.config)
130 }
131
132 pub(crate) fn load_resolved(cli: &CliOverrides) -> Result<LoadedConfig, ServerError> {
133 let home = aion_home()?;
134 let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
135 message: format!(
136 "failed to resolve the current directory for config discovery: {source}"
137 ),
138 })?;
139 Self::load_in(cli, &home.path, home.source, &working_dir, true)
140 }
141
142 fn load_in(
143 cli: &CliOverrides,
144 home: &Path,
145 home_source: HomeSource,
146 working_dir: &Path,
147 overlay_environment: bool,
148 ) -> Result<LoadedConfig, ServerError> {
149 let explicit = cli
153 .config_path
154 .as_deref()
155 .map(super::home::expand_tilde)
156 .transpose()?;
157 let discovered = file::discover(explicit.as_deref(), home, working_dir)?;
158 let mut config = match discovered.bytes {
159 Some(bytes) => Self::parse_unresolved(&bytes).map_err(|error| ServerError::Config {
160 message: format!("failed to parse {}: {error}", discovered.source),
161 })?,
162 None => Self::default(),
163 };
164 if overlay_environment {
165 env::overlay(&mut config)?;
166 }
167 config.apply_cli_overrides(cli);
168 expand_operator_path_tildes(&mut config)?;
169 #[cfg(not(unix))]
170 let home_explicit = !overlay_environment || std::env::var_os("AION_HOME").is_some();
171 #[cfg(not(unix))]
172 let data_dir_explicit = config.store.data_dir.is_some();
173 #[cfg(not(unix))]
174 let data_root_required = matches!(config.store.backend, StoreBackend::Haematite);
175 #[cfg(not(unix))]
176 let authoring_workspace_explicit = config.authoring.workspace_dir.is_some();
177 config.load_discovered_workflow_packages(cli, working_dir)?;
178 let legacy_notices = fill_home_defaults(&mut config, home, home_source, working_dir)?;
179 config.fill_operational_defaults();
180 config.validate()?;
181 let resolution = ConfigResolution {
182 home: home.to_owned(),
183 source: discovered.source,
184 data_dir: config.store.data_dir.clone(),
185 authoring_workspace: config.authoring.workspace_dir.clone(),
186 legacy_notices,
187 #[cfg(not(unix))]
188 home_explicit,
189 #[cfg(not(unix))]
190 data_dir_explicit,
191 #[cfg(not(unix))]
192 data_root_required,
193 #[cfg(not(unix))]
194 authoring_workspace_explicit,
195 };
196 Ok(LoadedConfig { config, resolution })
197 }
198
199 #[cfg(test)]
208 pub(super) fn load_for_test(
209 cli: &CliOverrides,
210 home: &Path,
211 home_source: HomeSource,
212 working_dir: &Path,
213 ) -> Result<LoadedConfig, ServerError> {
214 Self::load_in(cli, home, home_source, working_dir, false)
215 }
216
217 fn fill_operational_defaults(&mut self) {
223 self.runtime
224 .query_timeout_ms
225 .get_or_insert(DEFAULT_QUERY_TIMEOUT_MS);
226 self.websocket
227 .event_broadcast_capacity
228 .get_or_insert(DEFAULT_EVENT_BROADCAST_CAPACITY);
229 self.websocket
230 .cluster_broadcast_capacity
231 .get_or_insert(DEFAULT_CLUSTER_BROADCAST_CAPACITY);
232 self.fill_outbox_defaults();
233 self.fill_deploy_defaults();
234 }
235
236 fn fill_outbox_defaults(&mut self) {
245 if !self.outbox.enabled {
246 return;
247 }
248 self.outbox
249 .poll_interval_ms
250 .get_or_insert(DEFAULT_OUTBOX_POLL_INTERVAL_MS);
251 self.outbox
252 .batch_size
253 .get_or_insert(DEFAULT_OUTBOX_BATCH_SIZE);
254 self.outbox
255 .max_attempts
256 .get_or_insert(DEFAULT_OUTBOX_MAX_ATTEMPTS);
257 self.outbox
258 .backoff_base_ms
259 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_BASE_MS);
260 self.outbox
261 .backoff_multiplier
262 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER);
263 self.outbox
264 .backoff_max_ms
265 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MAX_MS);
266 }
267
268 fn fill_deploy_defaults(&mut self) {
276 if !self.deploy.enabled {
277 return;
278 }
279 self.deploy
280 .max_archive_bytes
281 .get_or_insert(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES);
282 self.deploy
283 .max_inflated_bytes
284 .get_or_insert(DEFAULT_DEPLOY_MAX_INFLATED_BYTES);
285 }
286
287 fn load_discovered_workflow_packages(
288 &mut self,
289 cli: &CliOverrides,
290 directory: &Path,
291 ) -> Result<(), ServerError> {
292 let discovered_packages = discover_workflow_packages(directory)?;
293 merge_workflow_packages(
294 &mut self.workflow_packages,
295 discovered_packages,
296 &cli.workflow_packages,
297 );
298 Ok(())
299 }
300
301 pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
307 let home = aion_home()?;
308 Self::from_slice_with_home(bytes, &home.path)
309 }
310
311 pub fn from_slice_with_home(bytes: &[u8], home: &Path) -> Result<Self, ServerError> {
326 let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
327 message: format!(
328 "failed to resolve the current directory for config defaults: {source}"
329 ),
330 })?;
331 let mut config = Self::parse_unresolved(bytes)?;
332 fill_home_defaults(&mut config, home, HomeSource::Explicit, &working_dir)?;
336 config.fill_operational_defaults();
337 config.validate()?;
338 Ok(config)
339 }
340
341 fn parse_unresolved(bytes: &[u8]) -> Result<Self, ServerError> {
342 toml::from_slice(bytes).map_err(|source| ServerError::Config {
343 message: format!("invalid server config: {source}"),
344 })
345 }
346
347 pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
353 file::load_required(&path.into())
354 }
355
356 #[must_use]
358 pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
359 let runtime = RuntimeConfig {
360 listen: ListenConfig {
361 grpc: self.server.grpc_address,
362 http: self.server.listen_address,
363 },
364 tls: self.tls,
365 auth: self.auth,
366 ops_console: self.ops_console,
367 namespace: self.namespace,
368 worker: self.worker,
369 websocket: self.websocket,
370 workflow_packages: self.workflow_packages,
371 deploy: self.deploy,
372 authoring: self.authoring,
373 dev: self.dev,
374 outbox: self.outbox,
375 observability: self.observability,
376 mcp: self.mcp.resolved(),
377 scheduler_threads: self.runtime.scheduler_threads,
378 jit_threshold: self.runtime.jit_threshold,
379 query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
380 default_namespace: self.namespaces.default,
381 auto_create: self.namespaces.auto_create,
382 max_in_flight_activities: self.namespaces.max_in_flight_activities,
383 drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
384 metrics: self.metrics,
385 owned_shards: self.store.owned_shards.clone(),
386 cors_allowed_origins: self.server.cors_allowed_origins.clone(),
387 };
388 (self.store, runtime)
389 }
390
391 fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
392 if let Some(address) = cli.listen_address {
393 self.server.listen_address = address;
394 }
395 if let Some(address) = cli.grpc_address {
396 self.server.grpc_address = address;
397 }
398 if cli.store_url.is_some() {
399 self.store.retired_input = Some(RetiredStoreInput::Flag);
400 }
401 if let Some(threads) = cli.scheduler_threads {
402 self.runtime.scheduler_threads = threads;
403 }
404 if let Some(threshold) = cli.jit_threshold {
405 self.runtime.jit_threshold = Some(threshold);
406 }
407 if let Some(timeout) = cli.drain_timeout_seconds {
408 self.drain.timeout_seconds = timeout;
409 }
410 if let Some(gleam_path) = &cli.gleam_path {
411 self.authoring.gleam_path = Some(gleam_path.clone());
412 }
413 if let Some(project_root) = &cli.authoring_project_root {
414 self.authoring.project_root = Some(project_root.clone());
415 }
416 }
417
418 fn validate(&self) -> Result<(), ServerError> {
419 if let Some(input) = self.store.retired_input {
420 let found = match input {
421 RetiredStoreInput::Backend => "backend = \"libsql\"",
422 RetiredStoreInput::BackendEnvironment => "AION_STORE_BACKEND=libsql",
423 RetiredStoreInput::Url => "store.url",
424 RetiredStoreInput::Environment => "AION_STORE_URL",
425 RetiredStoreInput::Flag => "--store-url",
426 };
427 return config_error(format!(
428 "found retired {found}; use backend = \"haematite\" with store.data_dir and store.node_cache_budget (all three are required — haematite refuses to start without the budget); existing libsql database files are not read by this build"
429 ));
430 }
431 if self.server.listen_address.port() == 0 {
432 return config_error("server.listen_address must use an explicit non-zero port");
433 }
434 if self.server.grpc_address.port() == 0 {
435 return config_error("server.grpc_address must use an explicit non-zero port");
436 }
437 validate_cors_origins(&self.server.cors_allowed_origins)?;
438 if self.runtime.scheduler_threads == 0 {
439 return config_error("runtime.scheduler_threads must be greater than zero");
440 }
441 if self.drain.timeout_seconds == 0 {
442 return config_error("drain.timeout_seconds must be greater than zero");
443 }
444 if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
445 return config_error("auth.jwks_url must not be empty when auth.enabled is true");
446 }
447 if self.auth.jwks_refresh_seconds == 0 {
448 return config_error("auth.jwks_refresh_seconds must be greater than zero");
449 }
450 if self.namespaces.default.is_empty() {
451 return config_error("namespaces.default must not be empty");
452 }
453 if matches!(self.store.backend, StoreBackend::Haematite) {
454 if self.store.data_dir.as_deref().is_none_or(str::is_empty) {
455 return config_error(
456 "store.data_dir must not be empty when store.backend is haematite",
457 );
458 }
459 if self.store.shard_count == 0 {
460 return config_error("store.shard_count must be greater than zero");
461 }
462 if let Some(cluster) = &self.store.cluster {
463 validate_cluster(cluster)?;
464 }
465 } else if self.store.cluster.is_some() {
466 return config_error("store.cluster is only valid when store.backend is haematite");
467 }
468 if let OpsConsoleAssetSource::FileSystem { asset_path } = &self.ops_console.source {
469 if asset_path.as_os_str().is_empty() {
470 return config_error("ops_console.source.FileSystem.asset_path must not be empty");
471 }
472 }
473 if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
474 if namespace.is_empty() {
475 return config_error("namespace.mode.SingleTenant.namespace must not be empty");
476 }
477 }
478 if self.worker.heartbeat_window.is_zero() {
479 return config_error("worker.heartbeat_window must be greater than zero");
480 }
481 self.websocket.validate()?;
482 self.observability.validate()?;
483 self.mcp.validate()?;
484 match self.runtime.query_timeout_ms {
485 None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
486 Some(_) => {}
487 }
488 if self.deploy.enabled {
489 let max_archive_bytes = match self.deploy.max_archive_bytes {
490 None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
491 Some(value) => value,
492 };
493 let max_inflated_bytes = match self.deploy.max_inflated_bytes {
494 None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
495 Some(value) => value,
496 };
497 ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
500 ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
501 if max_inflated_bytes < max_archive_bytes {
502 return config_error(format!(
503 "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"
504 ));
505 }
506 }
507 if let Some(gleam_path) = &self.authoring.gleam_path {
508 if gleam_path.as_os_str().is_empty() {
511 return config_error(AUTHORING_GLEAM_PATH_EMPTY);
512 }
513 match &self.authoring.project_root {
516 Some(root) if !root.as_os_str().is_empty() => {}
517 _ => return config_error(AUTHORING_PROJECT_ROOT_REQUIRED),
518 }
519 }
520 self.validate_outbox()?;
521 self.worker_supervision.resolve()?;
525 Ok(())
526 }
527
528 fn validate_outbox(&self) -> Result<(), ServerError> {
536 if !self.outbox.enabled {
537 return Ok(());
538 }
539 match self.outbox.poll_interval_ms {
540 None | Some(0) => return config_error(OUTBOX_POLL_INTERVAL_REQUIRED),
541 Some(_) => {}
542 }
543 match self.outbox.batch_size {
544 None | Some(0) => return config_error(OUTBOX_BATCH_SIZE_REQUIRED),
545 Some(_) => {}
546 }
547 match self.outbox.max_attempts {
548 None | Some(0) => return config_error(OUTBOX_MAX_ATTEMPTS_REQUIRED),
549 Some(_) => {}
550 }
551 let backoff_base_ms = match self.outbox.backoff_base_ms {
552 None | Some(0) => return config_error(OUTBOX_BACKOFF_BASE_REQUIRED),
553 Some(value) => value,
554 };
555 match self.outbox.backoff_multiplier {
556 None | Some(0) => return config_error(OUTBOX_BACKOFF_MULTIPLIER_REQUIRED),
557 Some(_) => {}
558 }
559 match self.outbox.backoff_max_ms {
560 Some(max) if max >= backoff_base_ms => {}
561 _ => return config_error(OUTBOX_BACKOFF_MAX_REQUIRED),
562 }
563 match (
564 self.outbox.reconcile_interval_ms,
565 self.outbox.reconcile_stale_after_ms,
566 ) {
567 (None, None) => {}
568 (None | Some(0), _) => return config_error(OUTBOX_RECONCILE_INTERVAL_REQUIRED),
569 (_, None | Some(0)) => return config_error(OUTBOX_RECONCILE_STALE_AFTER_REQUIRED),
570 (Some(_), Some(_)) => {}
571 }
572 Ok(())
573 }
574}
575
576fn validate_cluster(cluster: &ClusterConfig) -> Result<(), ServerError> {
580 if cluster.node_id.is_empty() {
581 return config_error("store.cluster.node_id must not be empty");
582 }
583 if cluster.members.iter().any(String::is_empty) {
584 return config_error("store.cluster.members entries must not be empty");
585 }
586 if cluster.peers.iter().any(|peer| peer.name.is_empty()) {
587 return config_error("store.cluster.peers entries must name a non-empty node");
588 }
589 if matches!(cluster.failover_poll_interval_ms, Some(0)) {
590 return config_error(
591 "store.cluster.failover_poll_interval_ms must be greater than zero when set",
592 );
593 }
594 if matches!(cluster.failover_confirmations, Some(0)) {
595 return config_error("store.cluster.failover_confirmations must be at least one when set");
596 }
597 Ok(())
598}
599
600fn validate_cors_origins(origins: &[String]) -> Result<(), ServerError> {
602 for origin in origins {
603 validate_cors_origin(origin)?;
604 }
605 Ok(())
606}
607
608fn validate_cors_origin(origin: &str) -> Result<(), ServerError> {
613 if origin.is_empty() {
614 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
615 }
616 let scheme_split = origin.split_once("://");
620 let Some((scheme, authority)) = scheme_split else {
621 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
622 };
623 if scheme.is_empty() || authority.is_empty() || authority.contains('/') {
624 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
625 }
626 if origin.parse::<axum::http::HeaderValue>().is_err() {
628 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
629 }
630 Ok(())
631}
632
633fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
635 if usize::try_from(value).is_err() {
636 return config_error(format!(
637 "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
638 usize::MAX
639 ));
640 }
641 Ok(())
642}
643
644fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
645 let mut packages = Vec::new();
646 let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
647 message: format!(
648 "failed to scan workflow packages in `{}`: {source}",
649 directory.display()
650 ),
651 })?;
652
653 for entry in entries {
654 let entry = entry.map_err(|source| ServerError::Config {
655 message: format!(
656 "failed to read workflow package entry in `{}`: {source}",
657 directory.display()
658 ),
659 })?;
660 let path = entry.path();
661 let has_aion_extension = path
662 .extension()
663 .is_some_and(|extension| extension == "aion");
664 if path.is_file() && has_aion_extension {
665 packages.push(path);
666 }
667 }
668
669 packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
670 Ok(packages)
671}
672
673fn merge_workflow_packages(
674 workflow_packages: &mut Vec<PathBuf>,
675 discovered_packages: Vec<PathBuf>,
676 cli_packages: &[PathBuf],
677) {
678 let mut seen: HashSet<PathBuf> = workflow_packages
679 .iter()
680 .map(|package| deduplicated_package_key(package))
681 .collect();
682 for package in discovered_packages
683 .into_iter()
684 .chain(cli_packages.iter().cloned())
685 {
686 if seen.insert(deduplicated_package_key(&package)) {
687 workflow_packages.push(package);
688 }
689 }
690}
691
692fn deduplicated_package_key(path: &Path) -> PathBuf {
693 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
694}
695
696#[cfg(test)]
697#[path = "load_home_tests.rs"]
698mod home_tests;
699
700#[cfg(test)]
701mod tests {
702 use crate::config::{
703 AutoCreate, DEFAULT_AUTHORING_WORKSPACE_DIR, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES, HomeSource,
704 OpsConsoleAssetSource,
705 };
706
707 use super::{
708 CliOverrides, DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
709 DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
710 DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
711 DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
712 DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, ServerConfig, StoreBackend,
713 discover_workflow_packages, merge_workflow_packages,
714 };
715
716 #[test]
717 fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
718 let config = ServerConfig::from_slice(
719 br#"
720 [server]
721 listen_address = "127.0.0.1:18080"
722 grpc_address = "127.0.0.1:15051"
723
724 [store]
725 backend = "haematite"
726 data_dir = "aion-data"
727
728 [runtime]
729 scheduler_threads = 2
730 query_timeout_ms = 10000
731
732 [drain]
733 timeout_seconds = 45
734
735 [auth]
736 enabled = true
737 jwks_url = "https://issuer.example.com/.well-known/jwks.json"
738 jwks_refresh_seconds = 60
739
740 [metrics]
741 enabled = true
742
743 [namespaces]
744 default = "production"
745
746 [websocket]
747 outbound_buffer_bound = 16
748 event_broadcast_capacity = 1024
749 cluster_broadcast_capacity = 1024
750 "#,
751 )?;
752
753 assert_eq!(config.store.backend, StoreBackend::Haematite);
754 assert_eq!(config.store.data_dir.as_deref(), Some("aion-data"));
755 assert_eq!(config.runtime.scheduler_threads, 2);
756 assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
757 assert_eq!(config.namespaces.default, "production");
758 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
760 assert_eq!(
763 config.namespaces.max_in_flight_activities,
764 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
765 );
766 assert_eq!(config.websocket.outbound_buffer_bound, 16);
767 assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
768 Ok(())
769 }
770
771 #[test]
772 fn namespaces_auto_create_closed_parses() -> Result<(), Box<dyn std::error::Error>> {
773 let config = ServerConfig::from_slice(
774 br#"
775 [namespaces]
776 default = "production"
777 auto_create = "closed"
778 "#,
779 )?;
780 assert_eq!(config.namespaces.default, "production");
781 assert_eq!(config.namespaces.auto_create, AutoCreate::Closed);
782 Ok(())
783 }
784
785 #[test]
786 fn namespaces_auto_create_open_parses() -> Result<(), Box<dyn std::error::Error>> {
787 let config = ServerConfig::from_slice(
788 br#"
789 [namespaces]
790 auto_create = "open"
791 "#,
792 )?;
793 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
794 Ok(())
795 }
796
797 #[test]
798 fn namespaces_max_in_flight_activities_override_parses()
799 -> Result<(), Box<dyn std::error::Error>> {
800 let config = ServerConfig::from_slice(
801 br#"
802 [namespaces]
803 default = "production"
804 max_in_flight_activities = 32
805 "#,
806 )?;
807 assert_eq!(config.namespaces.max_in_flight_activities, 32);
808 let (_store, runtime) = config.into_parts();
810 assert_eq!(runtime.max_in_flight_activities, 32);
811 Ok(())
812 }
813
814 #[test]
815 fn namespaces_max_in_flight_activities_defaults_when_omitted()
816 -> Result<(), Box<dyn std::error::Error>> {
817 let config = ServerConfig::from_slice(
820 br#"
821 [namespaces]
822 default = "production"
823 "#,
824 )?;
825 assert_eq!(
826 config.namespaces.max_in_flight_activities,
827 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
828 );
829 Ok(())
830 }
831
832 #[test]
833 fn namespaces_auto_create_rejects_unknown_variant() {
834 let result = ServerConfig::from_slice(
835 br#"
836 [namespaces]
837 auto_create = "sometimes"
838 "#,
839 );
840 assert!(
841 result.is_err(),
842 "an unknown auto_create variant must fail to parse"
843 );
844 }
845
846 #[test]
847 fn missing_event_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
848 let config = ServerConfig::from_slice(
852 br"
853 [runtime]
854 query_timeout_ms = 10000
855
856 [websocket]
857 cluster_broadcast_capacity = 64
858 ",
859 )?;
860 assert_eq!(
861 config.websocket.event_broadcast_capacity,
862 Some(DEFAULT_EVENT_BROADCAST_CAPACITY),
863 "omitted event_broadcast_capacity must resolve to the default"
864 );
865 Ok(())
866 }
867
868 #[test]
869 fn zero_event_broadcast_capacity_fails_startup_validation() {
870 let result = ServerConfig::from_slice(
871 br"
872 [websocket]
873 event_broadcast_capacity = 0
874 ",
875 );
876
877 let message = result
878 .err()
879 .map_or_else(String::new, |error| error.to_string());
880 assert!(
881 message.contains("websocket.event_broadcast_capacity"),
882 "validation message must name the zero-valued key: {message}"
883 );
884 }
885
886 #[test]
887 fn missing_cluster_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
888 let config = ServerConfig::from_slice(
892 br"
893 [runtime]
894 scheduler_threads = 1
895 query_timeout_ms = 10000
896
897 [websocket]
898 event_broadcast_capacity = 64
899 ",
900 )?;
901 assert_eq!(
902 config.websocket.cluster_broadcast_capacity,
903 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY),
904 "omitted cluster_broadcast_capacity must resolve to the default"
905 );
906 Ok(())
907 }
908
909 #[test]
910 fn zero_cluster_broadcast_capacity_fails_startup_validation() {
911 let result = ServerConfig::from_slice(
912 br"
913 [runtime]
914 query_timeout_ms = 10000
915
916 [websocket]
917 event_broadcast_capacity = 64
918 cluster_broadcast_capacity = 0
919 ",
920 );
921
922 let message = result
923 .err()
924 .map_or_else(String::new, |error| error.to_string());
925 assert!(
926 message.contains("websocket.cluster_broadcast_capacity"),
927 "validation message must name the zero-valued cluster key: {message}"
928 );
929 }
930
931 #[test]
935 fn missing_observability_section_uses_defaults() -> Result<(), Box<dyn std::error::Error>> {
936 let config = ServerConfig::from_slice(
937 br"
938 [runtime]
939 query_timeout_ms = 10000
940
941 [websocket]
942 event_broadcast_capacity = 64
943 cluster_broadcast_capacity = 64
944 ",
945 )?;
946 assert_eq!(
947 config.observability.max_event_bytes,
948 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
949 );
950 assert_eq!(
951 config.observability.max_stream_events,
952 crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS
953 );
954 let (_store, runtime) = config.into_parts();
956 assert_eq!(
957 runtime.observability.max_event_bytes,
958 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
959 );
960 Ok(())
961 }
962
963 #[test]
966 fn observability_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
967 let config = ServerConfig::from_slice(
968 br"
969 [runtime]
970 query_timeout_ms = 10000
971
972 [websocket]
973 event_broadcast_capacity = 64
974 cluster_broadcast_capacity = 64
975
976 [observability]
977 max_event_bytes = 512
978 max_stream_events = 3
979 ",
980 )?;
981 assert_eq!(config.observability.max_event_bytes, 512);
982 assert_eq!(config.observability.max_stream_events, 3);
983 let (_store, runtime) = config.into_parts();
984 assert_eq!(runtime.observability.max_event_bytes, 512);
985 assert_eq!(runtime.observability.max_stream_events, 3);
986 Ok(())
987 }
988
989 #[test]
993 fn missing_mcp_section_leaves_the_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
994 let config = ServerConfig::from_slice(
995 br"
996 [runtime]
997 query_timeout_ms = 10000
998
999 [websocket]
1000 event_broadcast_capacity = 64
1001 cluster_broadcast_capacity = 64
1002 ",
1003 )?;
1004 let (_store, runtime) = config.into_parts();
1005 assert!(!runtime.mcp.enabled);
1006 assert!(runtime.mcp.allowed_origins.is_empty());
1007 assert_eq!(
1008 runtime.mcp.discover_ttl_ms,
1009 crate::config::DEFAULT_MCP_DISCOVER_TTL_MS
1010 );
1011 assert_eq!(
1012 runtime.mcp.task_poll_interval_ms,
1013 crate::config::DEFAULT_MCP_TASK_POLL_INTERVAL_MS
1014 );
1015 Ok(())
1016 }
1017
1018 #[test]
1020 fn mcp_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1021 let config = ServerConfig::from_slice(
1022 br#"
1023 [runtime]
1024 query_timeout_ms = 10000
1025
1026 [websocket]
1027 event_broadcast_capacity = 64
1028 cluster_broadcast_capacity = 64
1029
1030 [mcp]
1031 enabled = true
1032 allowed_origins = ["http://localhost:5173"]
1033 discover_ttl_ms = 1000
1034 tools_list_ttl_ms = 2000
1035 task_poll_interval_ms = 250
1036 "#,
1037 )?;
1038 let (_store, runtime) = config.into_parts();
1039 assert!(runtime.mcp.enabled);
1040 assert_eq!(runtime.mcp.allowed_origins, vec!["http://localhost:5173"]);
1041 assert_eq!(runtime.mcp.discover_ttl_ms, 1_000);
1042 assert_eq!(runtime.mcp.tools_list_ttl_ms, 2_000);
1043 assert_eq!(runtime.mcp.task_poll_interval_ms, 250);
1044 Ok(())
1045 }
1046
1047 #[test]
1056 fn the_retired_mcp_task_knobs_are_refused_by_name() {
1057 for key in ["task_ttl_ms", "await_poll_interval_ms"] {
1058 let source = format!(
1059 "
1060 [runtime]
1061 query_timeout_ms = 10000
1062
1063 [websocket]
1064 event_broadcast_capacity = 64
1065 cluster_broadcast_capacity = 64
1066
1067 [mcp]
1068 enabled = true
1069 {key} = 1000
1070 "
1071 );
1072 let message = match ServerConfig::from_slice(source.as_bytes()) {
1073 Err(crate::ServerError::Config { message }) => message,
1074 _ => String::new(),
1075 };
1076 assert!(
1077 message.contains(key),
1078 "{key} must be refused by name: {message}"
1079 );
1080 }
1081 }
1082
1083 #[test]
1086 fn a_zero_mcp_poll_interval_fails_startup_validation() {
1087 let result = ServerConfig::from_slice(
1088 br"
1089 [runtime]
1090 query_timeout_ms = 10000
1091
1092 [websocket]
1093 event_broadcast_capacity = 64
1094 cluster_broadcast_capacity = 64
1095
1096 [mcp]
1097 enabled = true
1098 task_poll_interval_ms = 0
1099 ",
1100 );
1101 let message = match result {
1102 Err(crate::ServerError::Config { message }) => message,
1103 _ => String::new(),
1104 };
1105 assert!(
1106 message.contains("task_poll_interval_ms"),
1107 "a zero poll interval must be refused: {message}"
1108 );
1109 }
1110
1111 #[test]
1114 fn a_dark_mcp_surface_does_not_validate_its_unused_knobs()
1115 -> Result<(), Box<dyn std::error::Error>> {
1116 let config = ServerConfig::from_slice(
1117 br"
1118 [runtime]
1119 query_timeout_ms = 10000
1120
1121 [websocket]
1122 event_broadcast_capacity = 64
1123 cluster_broadcast_capacity = 64
1124
1125 [mcp]
1126 task_poll_interval_ms = 0
1127 ",
1128 )?;
1129 let (_store, runtime) = config.into_parts();
1130 assert!(!runtime.mcp.enabled);
1131 assert_eq!(
1132 runtime.mcp.task_poll_interval_ms,
1133 crate::config::DEFAULT_MCP_TASK_POLL_INTERVAL_MS,
1134 "an unused zero resolves to the default rather than to a busy loop"
1135 );
1136 Ok(())
1137 }
1138
1139 #[test]
1144 fn observability_flush_policy_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>>
1145 {
1146 let config = ServerConfig::from_slice(
1147 br"
1148 [runtime]
1149 query_timeout_ms = 10000
1150
1151 [websocket]
1152 event_broadcast_capacity = 64
1153 cluster_broadcast_capacity = 64
1154
1155 [observability]
1156 max_batch_events = 32
1157 max_batch_hold_ms = 0
1158 ",
1159 )?;
1160 assert_eq!(config.observability.max_batch_events, Some(32));
1161 assert_eq!(config.observability.max_batch_hold_ms, Some(0));
1162 let (_store, runtime) = config.into_parts();
1163 assert_eq!(runtime.observability.max_batch_events, Some(32));
1164 assert_eq!(
1165 runtime.observability.max_batch_hold_ms,
1166 Some(0),
1167 "a stated zero hold survives as a stated zero, never collapsing to absent"
1168 );
1169 Ok(())
1170 }
1171
1172 #[test]
1176 fn node_cache_budget_parses_both_spellings() -> Result<(), Box<dyn std::error::Error>> {
1177 let bounded = ServerConfig::from_slice(
1178 br"
1179 [runtime]
1180 query_timeout_ms = 10000
1181
1182 [store]
1183 node_cache_budget = { bytes = 1073741824 }
1184 ",
1185 )?;
1186 assert_eq!(
1187 bounded.store.node_cache_budget,
1188 Some(haematite::NodeCacheBudget::bytes(1 << 30)?),
1189 "a 1 GiB ceiling arrives as a 1 GiB ceiling"
1190 );
1191
1192 let unlimited = ServerConfig::from_slice(
1193 br#"
1194 [runtime]
1195 query_timeout_ms = 10000
1196
1197 [store]
1198 node_cache_budget = "unlimited"
1199lock_acquisition_patience_ms = 250
1200lock_acquisition_retry_cadence_ms = 5
1201 "#,
1202 )?;
1203 assert_eq!(
1204 unlimited.store.node_cache_budget,
1205 Some(haematite::NodeCacheBudget::Unlimited),
1206 "`unlimited` is a stated choice, never collapsed to absent"
1207 );
1208 Ok(())
1209 }
1210
1211 #[test]
1216 fn an_omitted_node_cache_budget_stays_absent() -> Result<(), Box<dyn std::error::Error>> {
1217 let config = ServerConfig::from_slice(
1218 br"
1219 [runtime]
1220 query_timeout_ms = 10000
1221
1222 [store]
1223 shard_count = 8
1224 ",
1225 )?;
1226 assert_eq!(config.store.node_cache_budget, None);
1227 Ok(())
1228 }
1229
1230 #[test]
1235 fn an_omitted_flush_policy_stays_absent() -> Result<(), Box<dyn std::error::Error>> {
1236 let config = ServerConfig::from_slice(
1237 br"
1238 [runtime]
1239 query_timeout_ms = 10000
1240
1241 [observability]
1242 max_event_bytes = 512
1243 ",
1244 )?;
1245 assert_eq!(config.observability.max_batch_events, None);
1246 assert_eq!(config.observability.max_batch_hold_ms, None);
1247 Ok(())
1248 }
1249
1250 #[test]
1251 fn zero_observability_max_event_bytes_fails_startup_validation() {
1252 let result = ServerConfig::from_slice(
1253 br"
1254 [runtime]
1255 query_timeout_ms = 10000
1256
1257 [websocket]
1258 event_broadcast_capacity = 64
1259 cluster_broadcast_capacity = 64
1260
1261 [observability]
1262 max_event_bytes = 0
1263 ",
1264 );
1265 let message = result
1266 .err()
1267 .map_or_else(String::new, |error| error.to_string());
1268 assert!(
1269 message.contains("observability.max_event_bytes"),
1270 "validation message must name the zero-valued key: {message}"
1271 );
1272 }
1273
1274 #[test]
1275 fn zero_observability_max_stream_events_fails_startup_validation() {
1276 let result = ServerConfig::from_slice(
1277 br"
1278 [runtime]
1279 query_timeout_ms = 10000
1280
1281 [websocket]
1282 event_broadcast_capacity = 64
1283 cluster_broadcast_capacity = 64
1284
1285 [observability]
1286 max_stream_events = 0
1287 ",
1288 );
1289 let message = result
1290 .err()
1291 .map_or_else(String::new, |error| error.to_string());
1292 assert!(
1293 message.contains("observability.max_stream_events"),
1294 "validation message must name the zero-valued key: {message}"
1295 );
1296 }
1297
1298 #[test]
1299 fn missing_query_timeout_uses_default() -> Result<(), Box<dyn std::error::Error>> {
1300 let config = ServerConfig::from_slice(
1304 br"
1305 [runtime]
1306 scheduler_threads = 1
1307
1308 [websocket]
1309 event_broadcast_capacity = 64
1310 cluster_broadcast_capacity = 64
1311 ",
1312 )?;
1313 assert_eq!(
1314 config.runtime.query_timeout_ms,
1315 Some(DEFAULT_QUERY_TIMEOUT_MS),
1316 "omitted query_timeout_ms must resolve to the default"
1317 );
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn empty_config_boots_on_operational_defaults() -> Result<(), Box<dyn std::error::Error>> {
1323 let config = ServerConfig::from_slice(b"")?;
1328 assert_eq!(config.store.backend, StoreBackend::Haematite);
1329 assert_eq!(
1330 config.runtime.query_timeout_ms,
1331 Some(DEFAULT_QUERY_TIMEOUT_MS)
1332 );
1333 assert_eq!(
1334 config.websocket.event_broadcast_capacity,
1335 Some(DEFAULT_EVENT_BROADCAST_CAPACITY)
1336 );
1337 assert_eq!(
1338 config.websocket.cluster_broadcast_capacity,
1339 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY)
1340 );
1341 Ok(())
1342 }
1343
1344 #[test]
1345 fn zero_query_timeout_fails_startup_validation() {
1346 let result = ServerConfig::from_slice(
1347 br"
1348 [runtime]
1349 query_timeout_ms = 0
1350
1351 [websocket]
1352 event_broadcast_capacity = 64
1353 cluster_broadcast_capacity = 64
1354 ",
1355 );
1356
1357 let message = result
1358 .err()
1359 .map_or_else(String::new, |error| error.to_string());
1360 assert!(
1361 message.contains("runtime.query_timeout_ms"),
1362 "validation message must name the zero-valued key: {message}"
1363 );
1364 }
1365
1366 #[test]
1371 fn deploy_enabled_defaults_max_archive_bytes() -> Result<(), Box<dyn std::error::Error>> {
1372 let config = ServerConfig::from_slice(
1373 br"
1374 [runtime]
1375 query_timeout_ms = 10000
1376
1377 [websocket]
1378 event_broadcast_capacity = 64
1379 cluster_broadcast_capacity = 64
1380
1381 [deploy]
1382 enabled = true
1383 ",
1384 )?;
1385
1386 assert_eq!(
1387 config.deploy.max_archive_bytes,
1388 Some(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES),
1389 "omitted max_archive_bytes must resolve to the conservative default"
1390 );
1391 assert_eq!(
1392 config.deploy.max_inflated_bytes,
1393 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1394 "omitted max_inflated_bytes must resolve to the conservative default"
1395 );
1396 Ok(())
1397 }
1398
1399 #[test]
1400 fn deploy_zero_max_archive_bytes_fails_startup_validation() {
1401 let result = ServerConfig::from_slice(
1402 br"
1403 [runtime]
1404 query_timeout_ms = 10000
1405
1406 [websocket]
1407 event_broadcast_capacity = 64
1408 cluster_broadcast_capacity = 64
1409
1410 [deploy]
1411 enabled = true
1412 max_archive_bytes = 0
1413 ",
1414 );
1415
1416 let message = result
1417 .err()
1418 .map_or_else(String::new, |error| error.to_string());
1419 assert!(
1420 message.contains("deploy.max_archive_bytes"),
1421 "validation message must name the zero-valued key: {message}"
1422 );
1423 }
1424
1425 #[test]
1430 fn deploy_enabled_defaults_max_inflated_bytes() -> Result<(), Box<dyn std::error::Error>> {
1431 let config = ServerConfig::from_slice(
1432 br"
1433 [runtime]
1434 query_timeout_ms = 10000
1435
1436 [websocket]
1437 event_broadcast_capacity = 64
1438 cluster_broadcast_capacity = 64
1439
1440 [deploy]
1441 enabled = true
1442 max_archive_bytes = 16777216
1443 ",
1444 )?;
1445
1446 assert_eq!(
1447 config.deploy.max_archive_bytes,
1448 Some(16_777_216),
1449 "explicit max_archive_bytes must be left untouched"
1450 );
1451 assert_eq!(
1452 config.deploy.max_inflated_bytes,
1453 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1454 "omitted max_inflated_bytes must resolve to the conservative default"
1455 );
1456 Ok(())
1457 }
1458
1459 #[test]
1460 fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
1461 let result = ServerConfig::from_slice(
1462 br"
1463 [runtime]
1464 query_timeout_ms = 10000
1465
1466 [websocket]
1467 event_broadcast_capacity = 64
1468 cluster_broadcast_capacity = 64
1469
1470 [deploy]
1471 enabled = true
1472 max_archive_bytes = 16777216
1473 max_inflated_bytes = 0
1474 ",
1475 );
1476
1477 let message = result
1478 .err()
1479 .map_or_else(String::new, |error| error.to_string());
1480 assert!(
1481 message.contains("deploy.max_inflated_bytes"),
1482 "validation message must name the zero-valued key: {message}"
1483 );
1484 }
1485
1486 #[test]
1489 fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
1490 let result = ServerConfig::from_slice(
1491 br"
1492 [runtime]
1493 query_timeout_ms = 10000
1494
1495 [websocket]
1496 event_broadcast_capacity = 64
1497 cluster_broadcast_capacity = 64
1498
1499 [deploy]
1500 enabled = true
1501 max_archive_bytes = 16777216
1502 max_inflated_bytes = 16777215
1503 ",
1504 );
1505
1506 let message = result
1507 .err()
1508 .map_or_else(String::new, |error| error.to_string());
1509 assert!(
1510 message.contains("deploy.max_inflated_bytes")
1511 && message.contains("deploy.max_archive_bytes"),
1512 "validation message must name both ceilings: {message}"
1513 );
1514 }
1515
1516 #[test]
1519 fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
1520 let config = ServerConfig::from_slice(
1521 br"
1522 [runtime]
1523 query_timeout_ms = 10000
1524
1525 [websocket]
1526 event_broadcast_capacity = 64
1527 cluster_broadcast_capacity = 64
1528 ",
1529 )?;
1530
1531 assert!(!config.deploy.enabled);
1532 assert_eq!(config.deploy.max_archive_bytes, None);
1533 assert_eq!(config.deploy.max_inflated_bytes, None);
1534 Ok(())
1535 }
1536
1537 #[test]
1538 fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
1539 let config = ServerConfig::from_slice(
1540 br"
1541 [runtime]
1542 query_timeout_ms = 10000
1543
1544 [websocket]
1545 event_broadcast_capacity = 64
1546 cluster_broadcast_capacity = 64
1547
1548 [deploy]
1549 enabled = true
1550 max_archive_bytes = 16777216
1551 max_inflated_bytes = 67108864
1552 ",
1553 )?;
1554
1555 assert!(config.deploy.enabled);
1556 assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
1557 assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
1558 Ok(())
1559 }
1560
1561 #[test]
1565 fn cors_allowed_origins_default_empty() -> Result<(), Box<dyn std::error::Error>> {
1566 let config = ServerConfig::from_slice(
1567 br"
1568 [runtime]
1569 query_timeout_ms = 10000
1570
1571 [websocket]
1572 event_broadcast_capacity = 64
1573 cluster_broadcast_capacity = 64
1574 ",
1575 )?;
1576
1577 assert!(config.server.cors_allowed_origins.is_empty());
1578 let (_, runtime) = config.into_parts();
1579 assert!(runtime.cors_allowed_origins.is_empty());
1580 Ok(())
1581 }
1582
1583 #[test]
1586 fn cors_allowed_origins_parse_and_round_trip() -> Result<(), Box<dyn std::error::Error>> {
1587 let config = ServerConfig::from_slice(
1588 br#"
1589 [server]
1590 cors_allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
1591
1592 [runtime]
1593 query_timeout_ms = 10000
1594
1595 [websocket]
1596 event_broadcast_capacity = 64
1597 cluster_broadcast_capacity = 64
1598 "#,
1599 )?;
1600
1601 assert_eq!(
1602 config.server.cors_allowed_origins,
1603 vec![
1604 "http://localhost:5173".to_owned(),
1605 "http://127.0.0.1:5173".to_owned()
1606 ]
1607 );
1608 let (_, runtime) = config.into_parts();
1609 assert_eq!(
1610 runtime.cors_allowed_origins,
1611 vec![
1612 "http://localhost:5173".to_owned(),
1613 "http://127.0.0.1:5173".to_owned()
1614 ]
1615 );
1616 Ok(())
1617 }
1618
1619 #[test]
1623 fn cors_allowed_origins_reject_malformed() {
1624 for bad in ["", "localhost:5173", "http://localhost:5173/"] {
1625 let toml = format!(
1626 "[server]\ncors_allowed_origins = [\"{bad}\"]\n\n[runtime]\nquery_timeout_ms = 10000\n\n[websocket]\nevent_broadcast_capacity = 64\n"
1627 );
1628 let result = ServerConfig::from_slice(toml.as_bytes());
1629 let message = result
1630 .err()
1631 .map_or_else(String::new, |error| error.to_string());
1632 assert!(
1633 message.contains("cors_allowed_origins"),
1634 "malformed origin `{bad}` must be rejected naming the key: {message}"
1635 );
1636 }
1637 }
1638
1639 #[test]
1641 fn dev_absent_leaves_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
1642 let config = ServerConfig::from_slice(
1643 br"
1644 [runtime]
1645 query_timeout_ms = 10000
1646
1647 [websocket]
1648 event_broadcast_capacity = 64
1649 cluster_broadcast_capacity = 64
1650 ",
1651 )?;
1652
1653 assert!(!config.dev.enabled);
1654 Ok(())
1655 }
1656
1657 #[test]
1660 fn dev_section_parses_enabled() -> Result<(), Box<dyn std::error::Error>> {
1661 let config = ServerConfig::from_slice(
1662 br"
1663 [runtime]
1664 query_timeout_ms = 10000
1665
1666 [websocket]
1667 event_broadcast_capacity = 64
1668 cluster_broadcast_capacity = 64
1669
1670 [dev]
1671 enabled = true
1672 ",
1673 )?;
1674
1675 assert!(config.dev.enabled);
1676 Ok(())
1677 }
1678
1679 #[test]
1682 fn authoring_absent_defaults_awl_workspace_but_keeps_gleam_dark()
1683 -> Result<(), Box<dyn std::error::Error>> {
1684 let home = crate::test_support::private_tempdir()?;
1685 let config = ServerConfig::from_slice_with_home(b"", home.path())?;
1686
1687 assert_eq!(config.authoring.gleam_path, None);
1688 assert_eq!(config.authoring.project_root, None);
1689 assert_eq!(
1690 config.authoring.workspace_dir.as_deref(),
1691 Some(home.path().join(DEFAULT_AUTHORING_WORKSPACE_DIR).as_path())
1692 );
1693 Ok(())
1694 }
1695
1696 #[test]
1698 fn authoring_explicit_workspace_is_honored() -> Result<(), Box<dyn std::error::Error>> {
1699 let config = ServerConfig::from_slice(
1700 br#"
1701 [authoring]
1702 workspace_dir = "/srv/aion/studio"
1703 "#,
1704 )?;
1705
1706 assert_eq!(
1707 config.authoring.workspace_dir.as_deref(),
1708 Some(std::path::Path::new("/srv/aion/studio"))
1709 );
1710 Ok(())
1711 }
1712
1713 #[test]
1716 fn authoring_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1717 let config = ServerConfig::from_slice(
1718 br#"
1719 [runtime]
1720 query_timeout_ms = 10000
1721
1722 [websocket]
1723 event_broadcast_capacity = 64
1724 cluster_broadcast_capacity = 64
1725
1726 [authoring]
1727 gleam_path = "/usr/local/bin/gleam"
1728 project_root = "/srv/aion/authoring"
1729 "#,
1730 )?;
1731
1732 assert_eq!(
1733 config.authoring.gleam_path.as_deref(),
1734 Some(std::path::Path::new("/usr/local/bin/gleam"))
1735 );
1736 let (_, runtime) = config.into_parts();
1737 assert_eq!(
1738 runtime.authoring.gleam_path.as_deref(),
1739 Some(std::path::Path::new("/usr/local/bin/gleam"))
1740 );
1741 assert_eq!(
1742 runtime.authoring.project_root.as_deref(),
1743 Some(std::path::Path::new("/srv/aion/authoring"))
1744 );
1745 Ok(())
1746 }
1747
1748 #[test]
1752 fn authoring_gleam_path_without_project_root_fails_naming_key_and_env() {
1753 let result = ServerConfig::from_slice(
1754 br#"
1755 [runtime]
1756 query_timeout_ms = 10000
1757
1758 [websocket]
1759 event_broadcast_capacity = 64
1760 cluster_broadcast_capacity = 64
1761
1762 [authoring]
1763 gleam_path = "/usr/local/bin/gleam"
1764 "#,
1765 );
1766
1767 let message = result
1768 .err()
1769 .map_or_else(String::new, |error| error.to_string());
1770 assert!(
1771 message.contains("authoring.project_root"),
1772 "validation message must name the missing key: {message}"
1773 );
1774 assert!(
1775 message.contains("AION_AUTHORING_PROJECT_ROOT"),
1776 "validation message must name the environment override: {message}"
1777 );
1778 }
1779
1780 #[test]
1783 fn authoring_empty_gleam_path_fails_naming_key_and_env() {
1784 let result = ServerConfig::from_slice(
1785 br#"
1786 [runtime]
1787 query_timeout_ms = 10000
1788
1789 [websocket]
1790 event_broadcast_capacity = 64
1791 cluster_broadcast_capacity = 64
1792
1793 [authoring]
1794 gleam_path = ""
1795 "#,
1796 );
1797
1798 let message = result
1799 .err()
1800 .map_or_else(String::new, |error| error.to_string());
1801 assert!(
1802 message.contains("authoring.gleam_path"),
1803 "validation message must name the empty key: {message}"
1804 );
1805 assert!(
1806 message.contains("AION_AUTHORING_GLEAM_PATH"),
1807 "validation message must name the environment override: {message}"
1808 );
1809 }
1810
1811 #[test]
1813 fn cli_overrides_set_authoring_paths() -> Result<(), Box<dyn std::error::Error>> {
1814 let mut config = ServerConfig::from_slice(
1815 br"
1816 [runtime]
1817 query_timeout_ms = 10000
1818
1819 [websocket]
1820 event_broadcast_capacity = 64
1821 cluster_broadcast_capacity = 64
1822 ",
1823 )?;
1824 let cli = CliOverrides {
1825 gleam_path: Some(std::path::PathBuf::from("/opt/gleam")),
1826 authoring_project_root: Some(std::path::PathBuf::from("/opt/project")),
1827 ..CliOverrides::default()
1828 };
1829
1830 config.apply_cli_overrides(&cli);
1831 config.validate()?;
1832
1833 assert_eq!(
1834 config.authoring.gleam_path.as_deref(),
1835 Some(std::path::Path::new("/opt/gleam"))
1836 );
1837 assert_eq!(
1838 config.authoring.project_root.as_deref(),
1839 Some(std::path::Path::new("/opt/project"))
1840 );
1841 Ok(())
1842 }
1843
1844 #[test]
1849 fn queue_service_settings_are_read_from_the_worker_section()
1850 -> Result<(), Box<dyn std::error::Error>> {
1851 use crate::worker::QueueServicePolicy;
1852 use std::time::Duration;
1853
1854 let bare = ServerConfig::from_slice(
1855 br"
1856 [websocket]
1857 event_broadcast_capacity = 64
1858 cluster_broadcast_capacity = 64
1859 ",
1860 )?;
1861 assert_eq!(
1862 bare.worker.queue_service.default_policy,
1863 QueueServicePolicy::Strict,
1864 "strict is the default with nothing written"
1865 );
1866 assert_eq!(
1867 bare.worker.queue_service.service_availability_deadline,
1868 None
1869 );
1870 assert_eq!(bare.worker.queue_service.schedule_to_start_timeout, None);
1871
1872 let written = ServerConfig::from_slice(
1873 br#"
1874 [websocket]
1875 event_broadcast_capacity = 64
1876 cluster_broadcast_capacity = 64
1877
1878 [worker.queue_service]
1879 service_availability_deadline = 45000
1880 schedule_to_start_timeout = 5000
1881
1882 [[worker.queue_service.overrides]]
1883 task_queue = "general"
1884 policy = "durable_pending"
1885 "#,
1886 )?;
1887 assert_eq!(
1888 written.worker.queue_service.service_availability_deadline,
1889 Some(Duration::from_secs(45))
1890 );
1891 assert_eq!(
1892 written.worker.queue_service.schedule_to_start_timeout,
1893 Some(Duration::from_secs(5))
1894 );
1895 assert_eq!(
1896 written
1897 .worker
1898 .queue_service
1899 .policy_for("default", "general"),
1900 QueueServicePolicy::DurablePending,
1901 "the written opt-in must reach the dispatch seam"
1902 );
1903 assert_eq!(
1904 written
1905 .worker
1906 .queue_service
1907 .policy_for("default", "billing"),
1908 QueueServicePolicy::Strict,
1909 "an override must not leak onto other queues"
1910 );
1911
1912 let (_store, runtime) = written.into_parts();
1914 assert_eq!(
1915 runtime
1916 .worker
1917 .queue_service
1918 .policy_for("default", "general"),
1919 QueueServicePolicy::DurablePending
1920 );
1921 Ok(())
1922 }
1923
1924 #[test]
1925 fn legacy_dashboard_section_alias_still_parses() -> Result<(), Box<dyn std::error::Error>> {
1926 let config = ServerConfig::from_slice(
1927 br#"
1928 [runtime]
1929 query_timeout_ms = 10000
1930
1931 [websocket]
1932 event_broadcast_capacity = 64
1933 cluster_broadcast_capacity = 64
1934
1935 [dashboard]
1936 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1937 "#,
1938 )?;
1939 match &config.ops_console.source {
1940 OpsConsoleAssetSource::FileSystem { asset_path } => {
1941 assert_eq!(asset_path.as_os_str(), "/srv/aion/ui");
1942 }
1943 OpsConsoleAssetSource::Embedded => {
1944 return Err("legacy [dashboard] section must map to ops_console".into());
1945 }
1946 }
1947 Ok(())
1948 }
1949
1950 #[test]
1952 fn ops_console_section_parses() -> Result<(), Box<dyn std::error::Error>> {
1953 let config = ServerConfig::from_slice(
1954 br#"
1955 [runtime]
1956 query_timeout_ms = 10000
1957
1958 [websocket]
1959 event_broadcast_capacity = 64
1960 cluster_broadcast_capacity = 64
1961
1962 [ops_console]
1963 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1964 "#,
1965 )?;
1966 assert!(matches!(
1967 config.ops_console.source,
1968 OpsConsoleAssetSource::FileSystem { .. }
1969 ));
1970 Ok(())
1971 }
1972
1973 #[test]
1974 fn invalid_values_name_problematic_field() {
1975 let result = ServerConfig::from_slice(
1976 br"
1977 [runtime]
1978 scheduler_threads = 0
1979 ",
1980 );
1981
1982 let message = result
1983 .err()
1984 .map_or_else(String::new, |error| error.to_string());
1985 assert!(message.contains("runtime.scheduler_threads"));
1986 }
1987
1988 const RETIRED_STORE_REMEDY: &str = "backend = \"haematite\" with store.data_dir and store.node_cache_budget (all three are required — haematite refuses to start without the budget); existing libsql database files are not read by this build";
1989
1990 fn assert_retired_store_refusal(
1991 result: Result<ServerConfig, crate::error::ServerError>,
1992 found: &str,
1993 ) {
1994 assert!(result.is_err(), "retired libsql input must be refused");
1995 let message = result
1996 .err()
1997 .map_or_else(String::new, |error| error.to_string());
1998 assert!(
1999 message.contains(found),
2000 "refusal did not name `{found}`: {message}"
2001 );
2002 assert!(
2003 message.contains(RETIRED_STORE_REMEDY),
2004 "refusal omitted the operator remedy: {message}"
2005 );
2006 }
2007
2008 #[test]
2009 fn retired_libsql_backend_is_refused_with_remedy() {
2010 assert_retired_store_refusal(
2011 ServerConfig::from_slice(
2012 br#"
2013 [store]
2014 backend = "libsql"
2015 "#,
2016 ),
2017 "backend = \"libsql\"",
2018 );
2019 }
2020
2021 #[test]
2022 fn retired_store_url_key_is_refused_with_remedy() {
2023 assert_retired_store_refusal(
2024 ServerConfig::from_slice(
2025 br#"
2026 [store]
2027 backend = "haematite"
2028 url = "old.db"
2029 "#,
2030 ),
2031 "store.url",
2032 );
2033 }
2034
2035 #[test]
2040 fn retired_libsql_backend_environment_is_refused_with_remedy() {
2041 let mut config = ServerConfig::default();
2042 let result = super::env::overlay_vars(
2043 &mut config,
2044 [("AION_STORE_BACKEND".to_owned(), "libsql".to_owned())],
2045 )
2046 .and_then(|()| config.validate().map(|()| config));
2047 assert_retired_store_refusal(result, "AION_STORE_BACKEND=libsql");
2048 }
2049
2050 #[test]
2051 fn retired_store_url_environment_is_refused_with_remedy() {
2052 let mut config = ServerConfig::default();
2053 let result = super::env::overlay_vars(
2054 &mut config,
2055 [("AION_STORE_URL".to_owned(), "old.db".to_owned())],
2056 )
2057 .and_then(|()| config.validate().map(|()| config));
2058 assert_retired_store_refusal(result, "AION_STORE_URL");
2059 }
2060
2061 #[test]
2062 fn retired_store_url_flag_is_refused_with_remedy() {
2063 let mut config = ServerConfig::default();
2064 config.apply_cli_overrides(&CliOverrides {
2065 store_url: Some("old.db".to_owned()),
2066 ..CliOverrides::default()
2067 });
2068 let result = config.validate().map(|()| config);
2069 assert_retired_store_refusal(result, "--store-url");
2070 }
2071
2072 #[test]
2076 fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
2077 let mut config = ServerConfig::from_slice(
2078 br#"
2079 [store]
2080 backend = "haematite"
2081 data_dir = "from-the-file"
2082
2083 [runtime]
2084 scheduler_threads = 1
2085 query_timeout_ms = 10000
2086
2087 [websocket]
2088 event_broadcast_capacity = 64
2089 cluster_broadcast_capacity = 64
2090
2091 [observability]
2092 max_batch_events = 64
2093 max_batch_hold_ms = 0
2094 "#,
2095 )?;
2096 assert_eq!(
2097 config.runtime.scheduler_threads, 1,
2098 "the file's value must be what the flag then beats"
2099 );
2100 let cli = CliOverrides {
2101 scheduler_threads: Some(
2102 std::num::NonZeroUsize::new(3)
2103 .ok_or("3 is not zero")?
2104 .into(),
2105 ),
2106 ..CliOverrides::default()
2107 };
2108
2109 config.apply_cli_overrides(&cli);
2110 config.validate()?;
2111
2112 assert_eq!(config.runtime.scheduler_threads, 3);
2113 assert_eq!(
2114 config.store.data_dir.as_deref(),
2115 Some("from-the-file"),
2116 "a value the CLI did not override keeps the file's value"
2117 );
2118 Ok(())
2119 }
2120
2121 #[test]
2122 fn haematite_store_config_remains_accepted() -> Result<(), Box<dyn std::error::Error>> {
2123 let config = ServerConfig::from_slice(
2124 br#"
2125 [store]
2126 backend = "haematite"
2127 data_dir = "aion-data"
2128 "#,
2129 )?;
2130 assert_eq!(config.store.backend, StoreBackend::Haematite);
2131 assert_eq!(config.store.data_dir.as_deref(), Some("aion-data"));
2132 Ok(())
2133 }
2134
2135 #[test]
2136 fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
2137 let mut config = ServerConfig::default();
2138
2139 assert_eq!(config.store.backend, StoreBackend::Haematite);
2142 assert_eq!(config.store.data_dir, None);
2143 assert_eq!(config.store.shard_count, 64);
2147 assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
2148 assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
2149 assert_eq!(config.namespaces.default, "default");
2150 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
2154 assert_eq!(
2157 config.namespaces.max_in_flight_activities,
2158 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
2159 );
2160 assert_eq!(config.namespaces.max_in_flight_activities, 1024);
2161 assert!(!config.auth.enabled);
2162 assert!(config.metrics.enabled);
2163 assert_eq!(config.websocket.event_broadcast_capacity, None);
2167 assert_eq!(config.websocket.cluster_broadcast_capacity, None);
2168 assert_eq!(config.runtime.query_timeout_ms, None);
2169 config.websocket.event_broadcast_capacity = Some(64);
2170 config.websocket.cluster_broadcast_capacity = Some(64);
2171 config.runtime.query_timeout_ms = Some(10_000);
2172 let home = crate::test_support::private_tempdir()?;
2173 let working_dir = crate::test_support::private_tempdir()?;
2174 super::fill_home_defaults(
2175 &mut config,
2176 home.path(),
2177 HomeSource::Derived,
2178 working_dir.path(),
2179 )?;
2180 assert_eq!(
2181 config.store.data_dir.as_deref(),
2182 home.path().join("data").to_str()
2183 );
2184 config.validate()?;
2185 Ok(())
2186 }
2187
2188 #[test]
2189 fn outbox_is_disabled_by_default_and_needs_no_knobs() -> Result<(), Box<dyn std::error::Error>>
2190 {
2191 let mut config = ServerConfig::default();
2192 config.store.data_dir = Some("test-data".to_owned());
2193 config.websocket.event_broadcast_capacity = Some(64);
2194 config.websocket.cluster_broadcast_capacity = Some(64);
2195 config.runtime.query_timeout_ms = Some(10_000);
2196
2197 assert!(!config.outbox.enabled);
2201 assert_eq!(config.outbox.poll_interval_ms, None);
2202 assert_eq!(config.outbox.batch_size, None);
2203 assert_eq!(config.outbox.max_attempts, None);
2204 assert_eq!(config.outbox.backoff_base_ms, None);
2205 assert_eq!(config.outbox.backoff_multiplier, None);
2206 assert_eq!(config.outbox.backoff_max_ms, None);
2207 assert_eq!(config.outbox.reconcile_interval_ms, None);
2208 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
2209 config.validate()?;
2210 Ok(())
2211 }
2212
2213 fn outbox_enabled_base() -> ServerConfig {
2214 let mut config = ServerConfig::default();
2215 config.store.data_dir = Some("test-data".to_owned());
2216 config.websocket.event_broadcast_capacity = Some(64);
2217 config.websocket.cluster_broadcast_capacity = Some(64);
2218 config.runtime.query_timeout_ms = Some(10_000);
2219 config.outbox.enabled = true;
2220 config.outbox.poll_interval_ms = Some(250);
2221 config.outbox.batch_size = Some(64);
2222 config.outbox.max_attempts = Some(5);
2223 config.outbox.backoff_base_ms = Some(100);
2224 config.outbox.backoff_multiplier = Some(2);
2225 config.outbox.backoff_max_ms = Some(30_000);
2226 config.outbox.reconcile_interval_ms = Some(1_000);
2227 config.outbox.reconcile_stale_after_ms = Some(60_000);
2228 config
2229 }
2230
2231 #[test]
2232 fn outbox_enabled_with_all_knobs_validates() -> Result<(), Box<dyn std::error::Error>> {
2233 outbox_enabled_base().validate()?;
2234 Ok(())
2235 }
2236
2237 #[test]
2238 fn outbox_enabled_defaults_poll_interval() -> Result<(), Box<dyn std::error::Error>> {
2239 let config = ServerConfig::from_slice(
2243 br"
2244 [runtime]
2245 query_timeout_ms = 10000
2246
2247 [websocket]
2248 event_broadcast_capacity = 64
2249 cluster_broadcast_capacity = 64
2250
2251 [outbox]
2252 enabled = true
2253 ",
2254 )?;
2255 assert_eq!(
2256 config.outbox.poll_interval_ms,
2257 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS),
2258 "omitted poll_interval_ms must resolve to the default"
2259 );
2260 Ok(())
2261 }
2262
2263 #[test]
2264 fn outbox_enabled_defaults_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
2265 let config = ServerConfig::from_slice(
2268 br"
2269 [runtime]
2270 query_timeout_ms = 10000
2271
2272 [websocket]
2273 event_broadcast_capacity = 64
2274 cluster_broadcast_capacity = 64
2275
2276 [outbox]
2277 enabled = true
2278 poll_interval_ms = 250
2279 ",
2280 )?;
2281 assert_eq!(
2282 config.outbox.poll_interval_ms,
2283 Some(250),
2284 "explicit poll_interval_ms must be left untouched"
2285 );
2286 assert_eq!(
2287 config.outbox.max_attempts,
2288 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS),
2289 "omitted max_attempts must resolve to the default"
2290 );
2291 Ok(())
2292 }
2293
2294 #[test]
2295 fn outbox_enabled_with_only_enabled_flag_uses_all_defaults()
2296 -> Result<(), Box<dyn std::error::Error>> {
2297 let config = ServerConfig::from_slice(
2301 br"
2302 [runtime]
2303 query_timeout_ms = 10000
2304
2305 [websocket]
2306 event_broadcast_capacity = 64
2307 cluster_broadcast_capacity = 64
2308
2309 [outbox]
2310 enabled = true
2311 ",
2312 )?;
2313 assert!(config.outbox.enabled);
2314 assert_eq!(
2315 config.outbox.poll_interval_ms,
2316 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS)
2317 );
2318 assert_eq!(config.outbox.batch_size, Some(DEFAULT_OUTBOX_BATCH_SIZE));
2319 assert_eq!(
2320 config.outbox.max_attempts,
2321 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS)
2322 );
2323 assert_eq!(
2324 config.outbox.backoff_base_ms,
2325 Some(DEFAULT_OUTBOX_BACKOFF_BASE_MS)
2326 );
2327 assert_eq!(
2328 config.outbox.backoff_multiplier,
2329 Some(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER)
2330 );
2331 assert_eq!(
2332 config.outbox.backoff_max_ms,
2333 Some(DEFAULT_OUTBOX_BACKOFF_MAX_MS)
2334 );
2335 assert_eq!(config.outbox.reconcile_interval_ms, None);
2338 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
2339 Ok(())
2340 }
2341
2342 #[test]
2343 fn outbox_enabled_zero_poll_interval_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2344 let mut config = outbox_enabled_base();
2347 config.outbox.poll_interval_ms = Some(0);
2348 let error = config
2349 .validate()
2350 .err()
2351 .ok_or("enabled outbox with zero poll interval must fail")?;
2352 assert!(
2353 error.to_string().contains("outbox.poll_interval_ms"),
2354 "error must name the zero-valued key: {error}"
2355 );
2356 Ok(())
2357 }
2358
2359 #[test]
2360 fn outbox_enabled_zero_max_attempts_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2361 let mut config = outbox_enabled_base();
2362 config.outbox.max_attempts = Some(0);
2363 let error = config
2364 .validate()
2365 .err()
2366 .ok_or("enabled outbox with zero max attempts must fail")?;
2367 assert!(
2368 error.to_string().contains("outbox.max_attempts"),
2369 "error must name the zero-valued key: {error}"
2370 );
2371 Ok(())
2372 }
2373
2374 #[test]
2375 fn outbox_backoff_max_below_base_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2376 let mut config = outbox_enabled_base();
2377 config.outbox.backoff_base_ms = Some(1_000);
2378 config.outbox.backoff_max_ms = Some(500);
2379 let error = config
2380 .validate()
2381 .err()
2382 .ok_or("backoff_max below backoff_base must fail")?;
2383 assert!(
2384 error.to_string().contains("outbox.backoff_max_ms"),
2385 "error must name the offending key: {error}"
2386 );
2387 Ok(())
2388 }
2389
2390 #[test]
2391 fn outbox_enabled_can_leave_reconciliation_dark() -> Result<(), Box<dyn std::error::Error>> {
2392 let mut config = outbox_enabled_base();
2393 config.outbox.reconcile_interval_ms = None;
2394 config.outbox.reconcile_stale_after_ms = None;
2395 config.validate()?;
2396 Ok(())
2397 }
2398
2399 #[test]
2400 fn outbox_reconciliation_requires_interval_when_partially_enabled()
2401 -> Result<(), Box<dyn std::error::Error>> {
2402 let mut config = outbox_enabled_base();
2403 config.outbox.reconcile_interval_ms = None;
2404 let error = config
2405 .validate()
2406 .err()
2407 .ok_or("reconciliation without interval must fail")?;
2408 assert!(error.to_string().contains("outbox.reconcile_interval_ms"));
2409 Ok(())
2410 }
2411
2412 #[test]
2413 fn outbox_reconciliation_requires_stale_threshold_when_partially_enabled()
2414 -> Result<(), Box<dyn std::error::Error>> {
2415 let mut config = outbox_enabled_base();
2416 config.outbox.reconcile_stale_after_ms = None;
2417 let error = config
2418 .validate()
2419 .err()
2420 .ok_or("reconciliation without stale threshold must fail")?;
2421 assert!(
2422 error
2423 .to_string()
2424 .contains("outbox.reconcile_stale_after_ms")
2425 );
2426 Ok(())
2427 }
2428
2429 #[test]
2430 fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
2431 let temp_dir = crate::test_support::private_tempdir()?;
2432 std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
2433 std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
2434 std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
2435 std::fs::create_dir(temp_dir.path().join("nested"))?;
2436 std::fs::write(
2437 temp_dir.path().join("nested").join("nested.aion"),
2438 b"package",
2439 )?;
2440
2441 let packages = discover_workflow_packages(temp_dir.path())?;
2442
2443 assert_eq!(
2444 packages,
2445 vec![
2446 temp_dir.path().join("alpha.aion"),
2447 temp_dir.path().join("zeta.aion"),
2448 ]
2449 );
2450 Ok(())
2451 }
2452
2453 #[test]
2454 fn workflow_package_merge_is_additive_and_deduplicated() {
2455 let mut packages = vec!["config.aion".into(), "shared.aion".into()];
2456 let discovered = vec!["auto.aion".into(), "shared.aion".into()];
2457 let cli = vec!["cli.aion".into(), "auto.aion".into()];
2458
2459 merge_workflow_packages(&mut packages, discovered, &cli);
2460
2461 assert_eq!(
2462 packages,
2463 vec![
2464 std::path::PathBuf::from("config.aion"),
2465 std::path::PathBuf::from("shared.aion"),
2466 std::path::PathBuf::from("auto.aion"),
2467 std::path::PathBuf::from("cli.aion"),
2468 ]
2469 );
2470 }
2471
2472 #[test]
2473 fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
2474 let temp_dir = crate::test_support::private_tempdir()?;
2475 let package = temp_dir.path().join("hello.aion");
2476 std::fs::write(&package, b"package")?;
2477 let mut packages = vec![package.clone()];
2478 let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
2479
2480 merge_workflow_packages(&mut packages, discovered, &[]);
2481
2482 assert_eq!(packages, vec![package]);
2483 Ok(())
2484 }
2485
2486 #[test]
2487 fn zero_config_cli_workflow_package_uses_in_memory_defaults()
2488 -> Result<(), Box<dyn std::error::Error>> {
2489 let temp_dir = crate::test_support::private_tempdir()?;
2490
2491 let cli = CliOverrides {
2492 workflow_packages: vec!["hello-world.aion".into()],
2493 ..CliOverrides::default()
2494 };
2495 let mut config = ServerConfig::default();
2496 config.store.backend = StoreBackend::Memory;
2500 config.store.data_dir = None;
2501 config.websocket.event_broadcast_capacity = Some(64);
2506 config.websocket.cluster_broadcast_capacity = Some(64);
2507 config.runtime.query_timeout_ms = Some(10_000);
2508 config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
2509
2510 config.validate()?;
2511
2512 assert_eq!(config.store.backend, StoreBackend::Memory);
2513 assert_eq!(
2514 config.workflow_packages,
2515 vec![std::path::PathBuf::from("hello-world.aion")]
2516 );
2517 Ok(())
2518 }
2519
2520 #[test]
2521 fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
2522 let mut config = ServerConfig::from_slice(
2523 br#"
2524 workflow_packages = ["config.aion"]
2525
2526 [runtime]
2527 query_timeout_ms = 10000
2528
2529 [websocket]
2530 event_broadcast_capacity = 64
2531 cluster_broadcast_capacity = 64
2532 "#,
2533 )?;
2534 let cli = CliOverrides {
2535 workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
2536 ..CliOverrides::default()
2537 };
2538
2539 merge_workflow_packages(
2540 &mut config.workflow_packages,
2541 Vec::new(),
2542 &cli.workflow_packages,
2543 );
2544
2545 assert_eq!(
2546 config.workflow_packages,
2547 vec![
2548 std::path::PathBuf::from("config.aion"),
2549 std::path::PathBuf::from("cli-one.aion"),
2550 std::path::PathBuf::from("cli-two.aion"),
2551 ]
2552 );
2553 Ok(())
2554 }
2555}