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 query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
379 default_namespace: self.namespaces.default,
380 auto_create: self.namespaces.auto_create,
381 max_in_flight_activities: self.namespaces.max_in_flight_activities,
382 drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
383 metrics: self.metrics,
384 owned_shards: self.store.owned_shards.clone(),
385 cors_allowed_origins: self.server.cors_allowed_origins.clone(),
386 };
387 (self.store, runtime)
388 }
389
390 fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
391 if let Some(address) = cli.listen_address {
392 self.server.listen_address = address;
393 }
394 if let Some(address) = cli.grpc_address {
395 self.server.grpc_address = address;
396 }
397 if cli.store_url.is_some() {
398 self.store.retired_input = Some(RetiredStoreInput::Flag);
399 }
400 if let Some(threads) = cli.scheduler_threads {
401 self.runtime.scheduler_threads = threads;
402 }
403 if let Some(timeout) = cli.drain_timeout_seconds {
404 self.drain.timeout_seconds = timeout;
405 }
406 if let Some(gleam_path) = &cli.gleam_path {
407 self.authoring.gleam_path = Some(gleam_path.clone());
408 }
409 if let Some(project_root) = &cli.authoring_project_root {
410 self.authoring.project_root = Some(project_root.clone());
411 }
412 }
413
414 fn validate(&self) -> Result<(), ServerError> {
415 if let Some(input) = self.store.retired_input {
416 let found = match input {
417 RetiredStoreInput::Backend => "backend = \"libsql\"",
418 RetiredStoreInput::BackendEnvironment => "AION_STORE_BACKEND=libsql",
419 RetiredStoreInput::Url => "store.url",
420 RetiredStoreInput::Environment => "AION_STORE_URL",
421 RetiredStoreInput::Flag => "--store-url",
422 };
423 return config_error(format!(
424 "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"
425 ));
426 }
427 if self.server.listen_address.port() == 0 {
428 return config_error("server.listen_address must use an explicit non-zero port");
429 }
430 if self.server.grpc_address.port() == 0 {
431 return config_error("server.grpc_address must use an explicit non-zero port");
432 }
433 validate_cors_origins(&self.server.cors_allowed_origins)?;
434 if self.runtime.scheduler_threads == 0 {
435 return config_error("runtime.scheduler_threads must be greater than zero");
436 }
437 if self.drain.timeout_seconds == 0 {
438 return config_error("drain.timeout_seconds must be greater than zero");
439 }
440 if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
441 return config_error("auth.jwks_url must not be empty when auth.enabled is true");
442 }
443 if self.auth.jwks_refresh_seconds == 0 {
444 return config_error("auth.jwks_refresh_seconds must be greater than zero");
445 }
446 if self.namespaces.default.is_empty() {
447 return config_error("namespaces.default must not be empty");
448 }
449 if matches!(self.store.backend, StoreBackend::Haematite) {
450 if self.store.data_dir.as_deref().is_none_or(str::is_empty) {
451 return config_error(
452 "store.data_dir must not be empty when store.backend is haematite",
453 );
454 }
455 if self.store.shard_count == 0 {
456 return config_error("store.shard_count must be greater than zero");
457 }
458 if let Some(cluster) = &self.store.cluster {
459 validate_cluster(cluster)?;
460 }
461 } else if self.store.cluster.is_some() {
462 return config_error("store.cluster is only valid when store.backend is haematite");
463 }
464 if let OpsConsoleAssetSource::FileSystem { asset_path } = &self.ops_console.source {
465 if asset_path.as_os_str().is_empty() {
466 return config_error("ops_console.source.FileSystem.asset_path must not be empty");
467 }
468 }
469 if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
470 if namespace.is_empty() {
471 return config_error("namespace.mode.SingleTenant.namespace must not be empty");
472 }
473 }
474 if self.worker.heartbeat_window.is_zero() {
475 return config_error("worker.heartbeat_window must be greater than zero");
476 }
477 self.websocket.validate()?;
478 self.observability.validate()?;
479 self.mcp.validate()?;
480 match self.runtime.query_timeout_ms {
481 None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
482 Some(_) => {}
483 }
484 if self.deploy.enabled {
485 let max_archive_bytes = match self.deploy.max_archive_bytes {
486 None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
487 Some(value) => value,
488 };
489 let max_inflated_bytes = match self.deploy.max_inflated_bytes {
490 None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
491 Some(value) => value,
492 };
493 ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
496 ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
497 if max_inflated_bytes < max_archive_bytes {
498 return config_error(format!(
499 "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"
500 ));
501 }
502 }
503 if let Some(gleam_path) = &self.authoring.gleam_path {
504 if gleam_path.as_os_str().is_empty() {
507 return config_error(AUTHORING_GLEAM_PATH_EMPTY);
508 }
509 match &self.authoring.project_root {
512 Some(root) if !root.as_os_str().is_empty() => {}
513 _ => return config_error(AUTHORING_PROJECT_ROOT_REQUIRED),
514 }
515 }
516 self.validate_outbox()?;
517 self.worker_supervision.resolve()?;
521 Ok(())
522 }
523
524 fn validate_outbox(&self) -> Result<(), ServerError> {
532 if !self.outbox.enabled {
533 return Ok(());
534 }
535 match self.outbox.poll_interval_ms {
536 None | Some(0) => return config_error(OUTBOX_POLL_INTERVAL_REQUIRED),
537 Some(_) => {}
538 }
539 match self.outbox.batch_size {
540 None | Some(0) => return config_error(OUTBOX_BATCH_SIZE_REQUIRED),
541 Some(_) => {}
542 }
543 match self.outbox.max_attempts {
544 None | Some(0) => return config_error(OUTBOX_MAX_ATTEMPTS_REQUIRED),
545 Some(_) => {}
546 }
547 let backoff_base_ms = match self.outbox.backoff_base_ms {
548 None | Some(0) => return config_error(OUTBOX_BACKOFF_BASE_REQUIRED),
549 Some(value) => value,
550 };
551 match self.outbox.backoff_multiplier {
552 None | Some(0) => return config_error(OUTBOX_BACKOFF_MULTIPLIER_REQUIRED),
553 Some(_) => {}
554 }
555 match self.outbox.backoff_max_ms {
556 Some(max) if max >= backoff_base_ms => {}
557 _ => return config_error(OUTBOX_BACKOFF_MAX_REQUIRED),
558 }
559 match (
560 self.outbox.reconcile_interval_ms,
561 self.outbox.reconcile_stale_after_ms,
562 ) {
563 (None, None) => {}
564 (None | Some(0), _) => return config_error(OUTBOX_RECONCILE_INTERVAL_REQUIRED),
565 (_, None | Some(0)) => return config_error(OUTBOX_RECONCILE_STALE_AFTER_REQUIRED),
566 (Some(_), Some(_)) => {}
567 }
568 Ok(())
569 }
570}
571
572fn validate_cluster(cluster: &ClusterConfig) -> Result<(), ServerError> {
576 if cluster.node_id.is_empty() {
577 return config_error("store.cluster.node_id must not be empty");
578 }
579 if cluster.members.iter().any(String::is_empty) {
580 return config_error("store.cluster.members entries must not be empty");
581 }
582 if cluster.peers.iter().any(|peer| peer.name.is_empty()) {
583 return config_error("store.cluster.peers entries must name a non-empty node");
584 }
585 if matches!(cluster.failover_poll_interval_ms, Some(0)) {
586 return config_error(
587 "store.cluster.failover_poll_interval_ms must be greater than zero when set",
588 );
589 }
590 if matches!(cluster.failover_confirmations, Some(0)) {
591 return config_error("store.cluster.failover_confirmations must be at least one when set");
592 }
593 Ok(())
594}
595
596fn validate_cors_origins(origins: &[String]) -> Result<(), ServerError> {
598 for origin in origins {
599 validate_cors_origin(origin)?;
600 }
601 Ok(())
602}
603
604fn validate_cors_origin(origin: &str) -> Result<(), ServerError> {
609 if origin.is_empty() {
610 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
611 }
612 let scheme_split = origin.split_once("://");
616 let Some((scheme, authority)) = scheme_split else {
617 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
618 };
619 if scheme.is_empty() || authority.is_empty() || authority.contains('/') {
620 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
621 }
622 if origin.parse::<axum::http::HeaderValue>().is_err() {
624 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
625 }
626 Ok(())
627}
628
629fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
631 if usize::try_from(value).is_err() {
632 return config_error(format!(
633 "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
634 usize::MAX
635 ));
636 }
637 Ok(())
638}
639
640fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
641 let mut packages = Vec::new();
642 let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
643 message: format!(
644 "failed to scan workflow packages in `{}`: {source}",
645 directory.display()
646 ),
647 })?;
648
649 for entry in entries {
650 let entry = entry.map_err(|source| ServerError::Config {
651 message: format!(
652 "failed to read workflow package entry in `{}`: {source}",
653 directory.display()
654 ),
655 })?;
656 let path = entry.path();
657 let has_aion_extension = path
658 .extension()
659 .is_some_and(|extension| extension == "aion");
660 if path.is_file() && has_aion_extension {
661 packages.push(path);
662 }
663 }
664
665 packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
666 Ok(packages)
667}
668
669fn merge_workflow_packages(
670 workflow_packages: &mut Vec<PathBuf>,
671 discovered_packages: Vec<PathBuf>,
672 cli_packages: &[PathBuf],
673) {
674 let mut seen: HashSet<PathBuf> = workflow_packages
675 .iter()
676 .map(|package| deduplicated_package_key(package))
677 .collect();
678 for package in discovered_packages
679 .into_iter()
680 .chain(cli_packages.iter().cloned())
681 {
682 if seen.insert(deduplicated_package_key(&package)) {
683 workflow_packages.push(package);
684 }
685 }
686}
687
688fn deduplicated_package_key(path: &Path) -> PathBuf {
689 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
690}
691
692#[cfg(test)]
693#[path = "load_home_tests.rs"]
694mod home_tests;
695
696#[cfg(test)]
697mod tests {
698 use crate::config::{
699 AutoCreate, DEFAULT_AUTHORING_WORKSPACE_DIR, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES, HomeSource,
700 OpsConsoleAssetSource,
701 };
702
703 use super::{
704 CliOverrides, DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
705 DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
706 DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
707 DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
708 DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, ServerConfig, StoreBackend,
709 discover_workflow_packages, merge_workflow_packages,
710 };
711
712 #[test]
713 fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
714 let config = ServerConfig::from_slice(
715 br#"
716 [server]
717 listen_address = "127.0.0.1:18080"
718 grpc_address = "127.0.0.1:15051"
719
720 [store]
721 backend = "haematite"
722 data_dir = "aion-data"
723
724 [runtime]
725 scheduler_threads = 2
726 query_timeout_ms = 10000
727
728 [drain]
729 timeout_seconds = 45
730
731 [auth]
732 enabled = true
733 jwks_url = "https://issuer.example.com/.well-known/jwks.json"
734 jwks_refresh_seconds = 60
735
736 [metrics]
737 enabled = true
738
739 [namespaces]
740 default = "production"
741
742 [websocket]
743 outbound_buffer_bound = 16
744 event_broadcast_capacity = 1024
745 cluster_broadcast_capacity = 1024
746 "#,
747 )?;
748
749 assert_eq!(config.store.backend, StoreBackend::Haematite);
750 assert_eq!(config.store.data_dir.as_deref(), Some("aion-data"));
751 assert_eq!(config.runtime.scheduler_threads, 2);
752 assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
753 assert_eq!(config.namespaces.default, "production");
754 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
756 assert_eq!(
759 config.namespaces.max_in_flight_activities,
760 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
761 );
762 assert_eq!(config.websocket.outbound_buffer_bound, 16);
763 assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
764 Ok(())
765 }
766
767 #[test]
768 fn namespaces_auto_create_closed_parses() -> Result<(), Box<dyn std::error::Error>> {
769 let config = ServerConfig::from_slice(
770 br#"
771 [namespaces]
772 default = "production"
773 auto_create = "closed"
774 "#,
775 )?;
776 assert_eq!(config.namespaces.default, "production");
777 assert_eq!(config.namespaces.auto_create, AutoCreate::Closed);
778 Ok(())
779 }
780
781 #[test]
782 fn namespaces_auto_create_open_parses() -> Result<(), Box<dyn std::error::Error>> {
783 let config = ServerConfig::from_slice(
784 br#"
785 [namespaces]
786 auto_create = "open"
787 "#,
788 )?;
789 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
790 Ok(())
791 }
792
793 #[test]
794 fn namespaces_max_in_flight_activities_override_parses()
795 -> Result<(), Box<dyn std::error::Error>> {
796 let config = ServerConfig::from_slice(
797 br#"
798 [namespaces]
799 default = "production"
800 max_in_flight_activities = 32
801 "#,
802 )?;
803 assert_eq!(config.namespaces.max_in_flight_activities, 32);
804 let (_store, runtime) = config.into_parts();
806 assert_eq!(runtime.max_in_flight_activities, 32);
807 Ok(())
808 }
809
810 #[test]
811 fn namespaces_max_in_flight_activities_defaults_when_omitted()
812 -> Result<(), Box<dyn std::error::Error>> {
813 let config = ServerConfig::from_slice(
816 br#"
817 [namespaces]
818 default = "production"
819 "#,
820 )?;
821 assert_eq!(
822 config.namespaces.max_in_flight_activities,
823 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
824 );
825 Ok(())
826 }
827
828 #[test]
829 fn namespaces_auto_create_rejects_unknown_variant() {
830 let result = ServerConfig::from_slice(
831 br#"
832 [namespaces]
833 auto_create = "sometimes"
834 "#,
835 );
836 assert!(
837 result.is_err(),
838 "an unknown auto_create variant must fail to parse"
839 );
840 }
841
842 #[test]
843 fn missing_event_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
844 let config = ServerConfig::from_slice(
848 br"
849 [runtime]
850 query_timeout_ms = 10000
851
852 [websocket]
853 cluster_broadcast_capacity = 64
854 ",
855 )?;
856 assert_eq!(
857 config.websocket.event_broadcast_capacity,
858 Some(DEFAULT_EVENT_BROADCAST_CAPACITY),
859 "omitted event_broadcast_capacity must resolve to the default"
860 );
861 Ok(())
862 }
863
864 #[test]
865 fn zero_event_broadcast_capacity_fails_startup_validation() {
866 let result = ServerConfig::from_slice(
867 br"
868 [websocket]
869 event_broadcast_capacity = 0
870 ",
871 );
872
873 let message = result
874 .err()
875 .map_or_else(String::new, |error| error.to_string());
876 assert!(
877 message.contains("websocket.event_broadcast_capacity"),
878 "validation message must name the zero-valued key: {message}"
879 );
880 }
881
882 #[test]
883 fn missing_cluster_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
884 let config = ServerConfig::from_slice(
888 br"
889 [runtime]
890 scheduler_threads = 1
891 query_timeout_ms = 10000
892
893 [websocket]
894 event_broadcast_capacity = 64
895 ",
896 )?;
897 assert_eq!(
898 config.websocket.cluster_broadcast_capacity,
899 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY),
900 "omitted cluster_broadcast_capacity must resolve to the default"
901 );
902 Ok(())
903 }
904
905 #[test]
906 fn zero_cluster_broadcast_capacity_fails_startup_validation() {
907 let result = ServerConfig::from_slice(
908 br"
909 [runtime]
910 query_timeout_ms = 10000
911
912 [websocket]
913 event_broadcast_capacity = 64
914 cluster_broadcast_capacity = 0
915 ",
916 );
917
918 let message = result
919 .err()
920 .map_or_else(String::new, |error| error.to_string());
921 assert!(
922 message.contains("websocket.cluster_broadcast_capacity"),
923 "validation message must name the zero-valued cluster key: {message}"
924 );
925 }
926
927 #[test]
931 fn missing_observability_section_uses_defaults() -> Result<(), Box<dyn std::error::Error>> {
932 let config = 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 )?;
942 assert_eq!(
943 config.observability.max_event_bytes,
944 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
945 );
946 assert_eq!(
947 config.observability.max_stream_events,
948 crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS
949 );
950 let (_store, runtime) = config.into_parts();
952 assert_eq!(
953 runtime.observability.max_event_bytes,
954 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
955 );
956 Ok(())
957 }
958
959 #[test]
962 fn observability_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
963 let config = ServerConfig::from_slice(
964 br"
965 [runtime]
966 query_timeout_ms = 10000
967
968 [websocket]
969 event_broadcast_capacity = 64
970 cluster_broadcast_capacity = 64
971
972 [observability]
973 max_event_bytes = 512
974 max_stream_events = 3
975 ",
976 )?;
977 assert_eq!(config.observability.max_event_bytes, 512);
978 assert_eq!(config.observability.max_stream_events, 3);
979 let (_store, runtime) = config.into_parts();
980 assert_eq!(runtime.observability.max_event_bytes, 512);
981 assert_eq!(runtime.observability.max_stream_events, 3);
982 Ok(())
983 }
984
985 #[test]
989 fn missing_mcp_section_leaves_the_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
990 let config = ServerConfig::from_slice(
991 br"
992 [runtime]
993 query_timeout_ms = 10000
994
995 [websocket]
996 event_broadcast_capacity = 64
997 cluster_broadcast_capacity = 64
998 ",
999 )?;
1000 let (_store, runtime) = config.into_parts();
1001 assert!(!runtime.mcp.enabled);
1002 assert!(runtime.mcp.allowed_origins.is_empty());
1003 assert_eq!(
1004 runtime.mcp.discover_ttl_ms,
1005 crate::config::DEFAULT_MCP_DISCOVER_TTL_MS
1006 );
1007 assert_eq!(
1008 runtime.mcp.task_poll_interval_ms,
1009 crate::config::DEFAULT_MCP_TASK_POLL_INTERVAL_MS
1010 );
1011 assert_eq!(
1012 runtime.mcp.task_ttl_ms,
1013 Some(crate::config::DEFAULT_MCP_TASK_TTL_MS)
1014 );
1015 Ok(())
1016 }
1017
1018 #[test]
1021 fn mcp_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1022 let config = ServerConfig::from_slice(
1023 br#"
1024 [runtime]
1025 query_timeout_ms = 10000
1026
1027 [websocket]
1028 event_broadcast_capacity = 64
1029 cluster_broadcast_capacity = 64
1030
1031 [mcp]
1032 enabled = true
1033 allowed_origins = ["http://localhost:5173"]
1034 discover_ttl_ms = 1000
1035 tools_list_ttl_ms = 2000
1036 task_ttl_ms = 0
1037 task_poll_interval_ms = 250
1038 await_poll_interval_ms = 125
1039 "#,
1040 )?;
1041 let (_store, runtime) = config.into_parts();
1042 assert!(runtime.mcp.enabled);
1043 assert_eq!(runtime.mcp.allowed_origins, vec!["http://localhost:5173"]);
1044 assert_eq!(runtime.mcp.discover_ttl_ms, 1_000);
1045 assert_eq!(runtime.mcp.tools_list_ttl_ms, 2_000);
1046 assert_eq!(runtime.mcp.task_ttl_ms, None, "zero means unlimited");
1047 assert_eq!(runtime.mcp.task_poll_interval_ms, 250);
1048 assert_eq!(runtime.mcp.await_poll_interval_ms, 125);
1049 Ok(())
1050 }
1051
1052 #[test]
1055 fn zero_mcp_poll_intervals_fail_startup_validation() {
1056 for key in ["task_poll_interval_ms", "await_poll_interval_ms"] {
1057 let source = format!(
1058 "
1059 [runtime]
1060 query_timeout_ms = 10000
1061
1062 [websocket]
1063 event_broadcast_capacity = 64
1064 cluster_broadcast_capacity = 64
1065
1066 [mcp]
1067 enabled = true
1068 {key} = 0
1069 "
1070 );
1071 let result = ServerConfig::from_slice(source.as_bytes());
1072 let message = match result {
1073 Err(crate::ServerError::Config { message }) => message,
1074 _ => String::new(),
1075 };
1076 assert!(message.contains(key), "{key} must be refused: {message}");
1077 }
1078 }
1079
1080 #[test]
1083 fn a_dark_mcp_surface_does_not_validate_its_unused_knobs()
1084 -> Result<(), Box<dyn std::error::Error>> {
1085 let config = ServerConfig::from_slice(
1086 br"
1087 [runtime]
1088 query_timeout_ms = 10000
1089
1090 [websocket]
1091 event_broadcast_capacity = 64
1092 cluster_broadcast_capacity = 64
1093
1094 [mcp]
1095 task_poll_interval_ms = 0
1096 ",
1097 )?;
1098 let (_store, runtime) = config.into_parts();
1099 assert!(!runtime.mcp.enabled);
1100 assert_eq!(
1101 runtime.mcp.task_poll_interval_ms,
1102 crate::config::DEFAULT_MCP_TASK_POLL_INTERVAL_MS,
1103 "an unused zero resolves to the default rather than to a busy loop"
1104 );
1105 Ok(())
1106 }
1107
1108 #[test]
1113 fn observability_flush_policy_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>>
1114 {
1115 let config = ServerConfig::from_slice(
1116 br"
1117 [runtime]
1118 query_timeout_ms = 10000
1119
1120 [websocket]
1121 event_broadcast_capacity = 64
1122 cluster_broadcast_capacity = 64
1123
1124 [observability]
1125 max_batch_events = 32
1126 max_batch_hold_ms = 0
1127 ",
1128 )?;
1129 assert_eq!(config.observability.max_batch_events, Some(32));
1130 assert_eq!(config.observability.max_batch_hold_ms, Some(0));
1131 let (_store, runtime) = config.into_parts();
1132 assert_eq!(runtime.observability.max_batch_events, Some(32));
1133 assert_eq!(
1134 runtime.observability.max_batch_hold_ms,
1135 Some(0),
1136 "a stated zero hold survives as a stated zero, never collapsing to absent"
1137 );
1138 Ok(())
1139 }
1140
1141 #[test]
1145 fn node_cache_budget_parses_both_spellings() -> Result<(), Box<dyn std::error::Error>> {
1146 let bounded = ServerConfig::from_slice(
1147 br"
1148 [runtime]
1149 query_timeout_ms = 10000
1150
1151 [store]
1152 node_cache_budget = { bytes = 1073741824 }
1153 ",
1154 )?;
1155 assert_eq!(
1156 bounded.store.node_cache_budget,
1157 Some(haematite::NodeCacheBudget::bytes(1 << 30)?),
1158 "a 1 GiB ceiling arrives as a 1 GiB ceiling"
1159 );
1160
1161 let unlimited = ServerConfig::from_slice(
1162 br#"
1163 [runtime]
1164 query_timeout_ms = 10000
1165
1166 [store]
1167 node_cache_budget = "unlimited"
1168 "#,
1169 )?;
1170 assert_eq!(
1171 unlimited.store.node_cache_budget,
1172 Some(haematite::NodeCacheBudget::Unlimited),
1173 "`unlimited` is a stated choice, never collapsed to absent"
1174 );
1175 Ok(())
1176 }
1177
1178 #[test]
1183 fn an_omitted_node_cache_budget_stays_absent() -> Result<(), Box<dyn std::error::Error>> {
1184 let config = ServerConfig::from_slice(
1185 br"
1186 [runtime]
1187 query_timeout_ms = 10000
1188
1189 [store]
1190 shard_count = 8
1191 ",
1192 )?;
1193 assert_eq!(config.store.node_cache_budget, None);
1194 Ok(())
1195 }
1196
1197 #[test]
1202 fn an_omitted_flush_policy_stays_absent() -> Result<(), Box<dyn std::error::Error>> {
1203 let config = ServerConfig::from_slice(
1204 br"
1205 [runtime]
1206 query_timeout_ms = 10000
1207
1208 [observability]
1209 max_event_bytes = 512
1210 ",
1211 )?;
1212 assert_eq!(config.observability.max_batch_events, None);
1213 assert_eq!(config.observability.max_batch_hold_ms, None);
1214 Ok(())
1215 }
1216
1217 #[test]
1218 fn zero_observability_max_event_bytes_fails_startup_validation() {
1219 let result = 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 [observability]
1229 max_event_bytes = 0
1230 ",
1231 );
1232 let message = result
1233 .err()
1234 .map_or_else(String::new, |error| error.to_string());
1235 assert!(
1236 message.contains("observability.max_event_bytes"),
1237 "validation message must name the zero-valued key: {message}"
1238 );
1239 }
1240
1241 #[test]
1242 fn zero_observability_max_stream_events_fails_startup_validation() {
1243 let result = ServerConfig::from_slice(
1244 br"
1245 [runtime]
1246 query_timeout_ms = 10000
1247
1248 [websocket]
1249 event_broadcast_capacity = 64
1250 cluster_broadcast_capacity = 64
1251
1252 [observability]
1253 max_stream_events = 0
1254 ",
1255 );
1256 let message = result
1257 .err()
1258 .map_or_else(String::new, |error| error.to_string());
1259 assert!(
1260 message.contains("observability.max_stream_events"),
1261 "validation message must name the zero-valued key: {message}"
1262 );
1263 }
1264
1265 #[test]
1266 fn missing_query_timeout_uses_default() -> Result<(), Box<dyn std::error::Error>> {
1267 let config = ServerConfig::from_slice(
1271 br"
1272 [runtime]
1273 scheduler_threads = 1
1274
1275 [websocket]
1276 event_broadcast_capacity = 64
1277 cluster_broadcast_capacity = 64
1278 ",
1279 )?;
1280 assert_eq!(
1281 config.runtime.query_timeout_ms,
1282 Some(DEFAULT_QUERY_TIMEOUT_MS),
1283 "omitted query_timeout_ms must resolve to the default"
1284 );
1285 Ok(())
1286 }
1287
1288 #[test]
1289 fn empty_config_boots_on_operational_defaults() -> Result<(), Box<dyn std::error::Error>> {
1290 let config = ServerConfig::from_slice(b"")?;
1295 assert_eq!(config.store.backend, StoreBackend::Haematite);
1296 assert_eq!(
1297 config.runtime.query_timeout_ms,
1298 Some(DEFAULT_QUERY_TIMEOUT_MS)
1299 );
1300 assert_eq!(
1301 config.websocket.event_broadcast_capacity,
1302 Some(DEFAULT_EVENT_BROADCAST_CAPACITY)
1303 );
1304 assert_eq!(
1305 config.websocket.cluster_broadcast_capacity,
1306 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY)
1307 );
1308 Ok(())
1309 }
1310
1311 #[test]
1312 fn zero_query_timeout_fails_startup_validation() {
1313 let result = ServerConfig::from_slice(
1314 br"
1315 [runtime]
1316 query_timeout_ms = 0
1317
1318 [websocket]
1319 event_broadcast_capacity = 64
1320 cluster_broadcast_capacity = 64
1321 ",
1322 );
1323
1324 let message = result
1325 .err()
1326 .map_or_else(String::new, |error| error.to_string());
1327 assert!(
1328 message.contains("runtime.query_timeout_ms"),
1329 "validation message must name the zero-valued key: {message}"
1330 );
1331 }
1332
1333 #[test]
1338 fn deploy_enabled_defaults_max_archive_bytes() -> Result<(), Box<dyn std::error::Error>> {
1339 let config = ServerConfig::from_slice(
1340 br"
1341 [runtime]
1342 query_timeout_ms = 10000
1343
1344 [websocket]
1345 event_broadcast_capacity = 64
1346 cluster_broadcast_capacity = 64
1347
1348 [deploy]
1349 enabled = true
1350 ",
1351 )?;
1352
1353 assert_eq!(
1354 config.deploy.max_archive_bytes,
1355 Some(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES),
1356 "omitted max_archive_bytes must resolve to the conservative default"
1357 );
1358 assert_eq!(
1359 config.deploy.max_inflated_bytes,
1360 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1361 "omitted max_inflated_bytes must resolve to the conservative default"
1362 );
1363 Ok(())
1364 }
1365
1366 #[test]
1367 fn deploy_zero_max_archive_bytes_fails_startup_validation() {
1368 let result = ServerConfig::from_slice(
1369 br"
1370 [runtime]
1371 query_timeout_ms = 10000
1372
1373 [websocket]
1374 event_broadcast_capacity = 64
1375 cluster_broadcast_capacity = 64
1376
1377 [deploy]
1378 enabled = true
1379 max_archive_bytes = 0
1380 ",
1381 );
1382
1383 let message = result
1384 .err()
1385 .map_or_else(String::new, |error| error.to_string());
1386 assert!(
1387 message.contains("deploy.max_archive_bytes"),
1388 "validation message must name the zero-valued key: {message}"
1389 );
1390 }
1391
1392 #[test]
1397 fn deploy_enabled_defaults_max_inflated_bytes() -> Result<(), Box<dyn std::error::Error>> {
1398 let config = ServerConfig::from_slice(
1399 br"
1400 [runtime]
1401 query_timeout_ms = 10000
1402
1403 [websocket]
1404 event_broadcast_capacity = 64
1405 cluster_broadcast_capacity = 64
1406
1407 [deploy]
1408 enabled = true
1409 max_archive_bytes = 16777216
1410 ",
1411 )?;
1412
1413 assert_eq!(
1414 config.deploy.max_archive_bytes,
1415 Some(16_777_216),
1416 "explicit max_archive_bytes must be left untouched"
1417 );
1418 assert_eq!(
1419 config.deploy.max_inflated_bytes,
1420 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1421 "omitted max_inflated_bytes must resolve to the conservative default"
1422 );
1423 Ok(())
1424 }
1425
1426 #[test]
1427 fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
1428 let result = ServerConfig::from_slice(
1429 br"
1430 [runtime]
1431 query_timeout_ms = 10000
1432
1433 [websocket]
1434 event_broadcast_capacity = 64
1435 cluster_broadcast_capacity = 64
1436
1437 [deploy]
1438 enabled = true
1439 max_archive_bytes = 16777216
1440 max_inflated_bytes = 0
1441 ",
1442 );
1443
1444 let message = result
1445 .err()
1446 .map_or_else(String::new, |error| error.to_string());
1447 assert!(
1448 message.contains("deploy.max_inflated_bytes"),
1449 "validation message must name the zero-valued key: {message}"
1450 );
1451 }
1452
1453 #[test]
1456 fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
1457 let result = ServerConfig::from_slice(
1458 br"
1459 [runtime]
1460 query_timeout_ms = 10000
1461
1462 [websocket]
1463 event_broadcast_capacity = 64
1464 cluster_broadcast_capacity = 64
1465
1466 [deploy]
1467 enabled = true
1468 max_archive_bytes = 16777216
1469 max_inflated_bytes = 16777215
1470 ",
1471 );
1472
1473 let message = result
1474 .err()
1475 .map_or_else(String::new, |error| error.to_string());
1476 assert!(
1477 message.contains("deploy.max_inflated_bytes")
1478 && message.contains("deploy.max_archive_bytes"),
1479 "validation message must name both ceilings: {message}"
1480 );
1481 }
1482
1483 #[test]
1486 fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
1487 let config = ServerConfig::from_slice(
1488 br"
1489 [runtime]
1490 query_timeout_ms = 10000
1491
1492 [websocket]
1493 event_broadcast_capacity = 64
1494 cluster_broadcast_capacity = 64
1495 ",
1496 )?;
1497
1498 assert!(!config.deploy.enabled);
1499 assert_eq!(config.deploy.max_archive_bytes, None);
1500 assert_eq!(config.deploy.max_inflated_bytes, None);
1501 Ok(())
1502 }
1503
1504 #[test]
1505 fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
1506 let config = ServerConfig::from_slice(
1507 br"
1508 [runtime]
1509 query_timeout_ms = 10000
1510
1511 [websocket]
1512 event_broadcast_capacity = 64
1513 cluster_broadcast_capacity = 64
1514
1515 [deploy]
1516 enabled = true
1517 max_archive_bytes = 16777216
1518 max_inflated_bytes = 67108864
1519 ",
1520 )?;
1521
1522 assert!(config.deploy.enabled);
1523 assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
1524 assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
1525 Ok(())
1526 }
1527
1528 #[test]
1532 fn cors_allowed_origins_default_empty() -> Result<(), Box<dyn std::error::Error>> {
1533 let config = ServerConfig::from_slice(
1534 br"
1535 [runtime]
1536 query_timeout_ms = 10000
1537
1538 [websocket]
1539 event_broadcast_capacity = 64
1540 cluster_broadcast_capacity = 64
1541 ",
1542 )?;
1543
1544 assert!(config.server.cors_allowed_origins.is_empty());
1545 let (_, runtime) = config.into_parts();
1546 assert!(runtime.cors_allowed_origins.is_empty());
1547 Ok(())
1548 }
1549
1550 #[test]
1553 fn cors_allowed_origins_parse_and_round_trip() -> Result<(), Box<dyn std::error::Error>> {
1554 let config = ServerConfig::from_slice(
1555 br#"
1556 [server]
1557 cors_allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
1558
1559 [runtime]
1560 query_timeout_ms = 10000
1561
1562 [websocket]
1563 event_broadcast_capacity = 64
1564 cluster_broadcast_capacity = 64
1565 "#,
1566 )?;
1567
1568 assert_eq!(
1569 config.server.cors_allowed_origins,
1570 vec![
1571 "http://localhost:5173".to_owned(),
1572 "http://127.0.0.1:5173".to_owned()
1573 ]
1574 );
1575 let (_, runtime) = config.into_parts();
1576 assert_eq!(
1577 runtime.cors_allowed_origins,
1578 vec![
1579 "http://localhost:5173".to_owned(),
1580 "http://127.0.0.1:5173".to_owned()
1581 ]
1582 );
1583 Ok(())
1584 }
1585
1586 #[test]
1590 fn cors_allowed_origins_reject_malformed() {
1591 for bad in ["", "localhost:5173", "http://localhost:5173/"] {
1592 let toml = format!(
1593 "[server]\ncors_allowed_origins = [\"{bad}\"]\n\n[runtime]\nquery_timeout_ms = 10000\n\n[websocket]\nevent_broadcast_capacity = 64\n"
1594 );
1595 let result = ServerConfig::from_slice(toml.as_bytes());
1596 let message = result
1597 .err()
1598 .map_or_else(String::new, |error| error.to_string());
1599 assert!(
1600 message.contains("cors_allowed_origins"),
1601 "malformed origin `{bad}` must be rejected naming the key: {message}"
1602 );
1603 }
1604 }
1605
1606 #[test]
1608 fn dev_absent_leaves_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
1609 let config = ServerConfig::from_slice(
1610 br"
1611 [runtime]
1612 query_timeout_ms = 10000
1613
1614 [websocket]
1615 event_broadcast_capacity = 64
1616 cluster_broadcast_capacity = 64
1617 ",
1618 )?;
1619
1620 assert!(!config.dev.enabled);
1621 Ok(())
1622 }
1623
1624 #[test]
1627 fn dev_section_parses_enabled() -> Result<(), Box<dyn std::error::Error>> {
1628 let config = ServerConfig::from_slice(
1629 br"
1630 [runtime]
1631 query_timeout_ms = 10000
1632
1633 [websocket]
1634 event_broadcast_capacity = 64
1635 cluster_broadcast_capacity = 64
1636
1637 [dev]
1638 enabled = true
1639 ",
1640 )?;
1641
1642 assert!(config.dev.enabled);
1643 Ok(())
1644 }
1645
1646 #[test]
1649 fn authoring_absent_defaults_awl_workspace_but_keeps_gleam_dark()
1650 -> Result<(), Box<dyn std::error::Error>> {
1651 let home = crate::test_support::private_tempdir()?;
1652 let config = ServerConfig::from_slice_with_home(b"", home.path())?;
1653
1654 assert_eq!(config.authoring.gleam_path, None);
1655 assert_eq!(config.authoring.project_root, None);
1656 assert_eq!(
1657 config.authoring.workspace_dir.as_deref(),
1658 Some(home.path().join(DEFAULT_AUTHORING_WORKSPACE_DIR).as_path())
1659 );
1660 Ok(())
1661 }
1662
1663 #[test]
1665 fn authoring_explicit_workspace_is_honored() -> Result<(), Box<dyn std::error::Error>> {
1666 let config = ServerConfig::from_slice(
1667 br#"
1668 [authoring]
1669 workspace_dir = "/srv/aion/studio"
1670 "#,
1671 )?;
1672
1673 assert_eq!(
1674 config.authoring.workspace_dir.as_deref(),
1675 Some(std::path::Path::new("/srv/aion/studio"))
1676 );
1677 Ok(())
1678 }
1679
1680 #[test]
1683 fn authoring_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1684 let config = ServerConfig::from_slice(
1685 br#"
1686 [runtime]
1687 query_timeout_ms = 10000
1688
1689 [websocket]
1690 event_broadcast_capacity = 64
1691 cluster_broadcast_capacity = 64
1692
1693 [authoring]
1694 gleam_path = "/usr/local/bin/gleam"
1695 project_root = "/srv/aion/authoring"
1696 "#,
1697 )?;
1698
1699 assert_eq!(
1700 config.authoring.gleam_path.as_deref(),
1701 Some(std::path::Path::new("/usr/local/bin/gleam"))
1702 );
1703 let (_, runtime) = config.into_parts();
1704 assert_eq!(
1705 runtime.authoring.gleam_path.as_deref(),
1706 Some(std::path::Path::new("/usr/local/bin/gleam"))
1707 );
1708 assert_eq!(
1709 runtime.authoring.project_root.as_deref(),
1710 Some(std::path::Path::new("/srv/aion/authoring"))
1711 );
1712 Ok(())
1713 }
1714
1715 #[test]
1719 fn authoring_gleam_path_without_project_root_fails_naming_key_and_env() {
1720 let result = ServerConfig::from_slice(
1721 br#"
1722 [runtime]
1723 query_timeout_ms = 10000
1724
1725 [websocket]
1726 event_broadcast_capacity = 64
1727 cluster_broadcast_capacity = 64
1728
1729 [authoring]
1730 gleam_path = "/usr/local/bin/gleam"
1731 "#,
1732 );
1733
1734 let message = result
1735 .err()
1736 .map_or_else(String::new, |error| error.to_string());
1737 assert!(
1738 message.contains("authoring.project_root"),
1739 "validation message must name the missing key: {message}"
1740 );
1741 assert!(
1742 message.contains("AION_AUTHORING_PROJECT_ROOT"),
1743 "validation message must name the environment override: {message}"
1744 );
1745 }
1746
1747 #[test]
1750 fn authoring_empty_gleam_path_fails_naming_key_and_env() {
1751 let result = ServerConfig::from_slice(
1752 br#"
1753 [runtime]
1754 query_timeout_ms = 10000
1755
1756 [websocket]
1757 event_broadcast_capacity = 64
1758 cluster_broadcast_capacity = 64
1759
1760 [authoring]
1761 gleam_path = ""
1762 "#,
1763 );
1764
1765 let message = result
1766 .err()
1767 .map_or_else(String::new, |error| error.to_string());
1768 assert!(
1769 message.contains("authoring.gleam_path"),
1770 "validation message must name the empty key: {message}"
1771 );
1772 assert!(
1773 message.contains("AION_AUTHORING_GLEAM_PATH"),
1774 "validation message must name the environment override: {message}"
1775 );
1776 }
1777
1778 #[test]
1780 fn cli_overrides_set_authoring_paths() -> Result<(), Box<dyn std::error::Error>> {
1781 let mut 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 )?;
1791 let cli = CliOverrides {
1792 gleam_path: Some(std::path::PathBuf::from("/opt/gleam")),
1793 authoring_project_root: Some(std::path::PathBuf::from("/opt/project")),
1794 ..CliOverrides::default()
1795 };
1796
1797 config.apply_cli_overrides(&cli);
1798 config.validate()?;
1799
1800 assert_eq!(
1801 config.authoring.gleam_path.as_deref(),
1802 Some(std::path::Path::new("/opt/gleam"))
1803 );
1804 assert_eq!(
1805 config.authoring.project_root.as_deref(),
1806 Some(std::path::Path::new("/opt/project"))
1807 );
1808 Ok(())
1809 }
1810
1811 #[test]
1816 fn queue_service_settings_are_read_from_the_worker_section()
1817 -> Result<(), Box<dyn std::error::Error>> {
1818 use crate::worker::QueueServicePolicy;
1819 use std::time::Duration;
1820
1821 let bare = ServerConfig::from_slice(
1822 br"
1823 [websocket]
1824 event_broadcast_capacity = 64
1825 cluster_broadcast_capacity = 64
1826 ",
1827 )?;
1828 assert_eq!(
1829 bare.worker.queue_service.default_policy,
1830 QueueServicePolicy::Strict,
1831 "strict is the default with nothing written"
1832 );
1833 assert_eq!(
1834 bare.worker.queue_service.service_availability_deadline,
1835 None
1836 );
1837 assert_eq!(bare.worker.queue_service.schedule_to_start_timeout, None);
1838
1839 let written = ServerConfig::from_slice(
1840 br#"
1841 [websocket]
1842 event_broadcast_capacity = 64
1843 cluster_broadcast_capacity = 64
1844
1845 [worker.queue_service]
1846 service_availability_deadline = 45000
1847 schedule_to_start_timeout = 5000
1848
1849 [[worker.queue_service.overrides]]
1850 task_queue = "general"
1851 policy = "durable_pending"
1852 "#,
1853 )?;
1854 assert_eq!(
1855 written.worker.queue_service.service_availability_deadline,
1856 Some(Duration::from_secs(45))
1857 );
1858 assert_eq!(
1859 written.worker.queue_service.schedule_to_start_timeout,
1860 Some(Duration::from_secs(5))
1861 );
1862 assert_eq!(
1863 written
1864 .worker
1865 .queue_service
1866 .policy_for("default", "general"),
1867 QueueServicePolicy::DurablePending,
1868 "the written opt-in must reach the dispatch seam"
1869 );
1870 assert_eq!(
1871 written
1872 .worker
1873 .queue_service
1874 .policy_for("default", "billing"),
1875 QueueServicePolicy::Strict,
1876 "an override must not leak onto other queues"
1877 );
1878
1879 let (_store, runtime) = written.into_parts();
1881 assert_eq!(
1882 runtime
1883 .worker
1884 .queue_service
1885 .policy_for("default", "general"),
1886 QueueServicePolicy::DurablePending
1887 );
1888 Ok(())
1889 }
1890
1891 #[test]
1892 fn legacy_dashboard_section_alias_still_parses() -> Result<(), Box<dyn std::error::Error>> {
1893 let config = ServerConfig::from_slice(
1894 br#"
1895 [runtime]
1896 query_timeout_ms = 10000
1897
1898 [websocket]
1899 event_broadcast_capacity = 64
1900 cluster_broadcast_capacity = 64
1901
1902 [dashboard]
1903 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1904 "#,
1905 )?;
1906 match &config.ops_console.source {
1907 OpsConsoleAssetSource::FileSystem { asset_path } => {
1908 assert_eq!(asset_path.as_os_str(), "/srv/aion/ui");
1909 }
1910 OpsConsoleAssetSource::Embedded => {
1911 return Err("legacy [dashboard] section must map to ops_console".into());
1912 }
1913 }
1914 Ok(())
1915 }
1916
1917 #[test]
1919 fn ops_console_section_parses() -> Result<(), Box<dyn std::error::Error>> {
1920 let config = ServerConfig::from_slice(
1921 br#"
1922 [runtime]
1923 query_timeout_ms = 10000
1924
1925 [websocket]
1926 event_broadcast_capacity = 64
1927 cluster_broadcast_capacity = 64
1928
1929 [ops_console]
1930 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1931 "#,
1932 )?;
1933 assert!(matches!(
1934 config.ops_console.source,
1935 OpsConsoleAssetSource::FileSystem { .. }
1936 ));
1937 Ok(())
1938 }
1939
1940 #[test]
1941 fn invalid_values_name_problematic_field() {
1942 let result = ServerConfig::from_slice(
1943 br"
1944 [runtime]
1945 scheduler_threads = 0
1946 ",
1947 );
1948
1949 let message = result
1950 .err()
1951 .map_or_else(String::new, |error| error.to_string());
1952 assert!(message.contains("runtime.scheduler_threads"));
1953 }
1954
1955 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";
1956
1957 fn assert_retired_store_refusal(
1958 result: Result<ServerConfig, crate::error::ServerError>,
1959 found: &str,
1960 ) {
1961 assert!(result.is_err(), "retired libsql input must be refused");
1962 let message = result
1963 .err()
1964 .map_or_else(String::new, |error| error.to_string());
1965 assert!(
1966 message.contains(found),
1967 "refusal did not name `{found}`: {message}"
1968 );
1969 assert!(
1970 message.contains(RETIRED_STORE_REMEDY),
1971 "refusal omitted the operator remedy: {message}"
1972 );
1973 }
1974
1975 #[test]
1976 fn retired_libsql_backend_is_refused_with_remedy() {
1977 assert_retired_store_refusal(
1978 ServerConfig::from_slice(
1979 br#"
1980 [store]
1981 backend = "libsql"
1982 "#,
1983 ),
1984 "backend = \"libsql\"",
1985 );
1986 }
1987
1988 #[test]
1989 fn retired_store_url_key_is_refused_with_remedy() {
1990 assert_retired_store_refusal(
1991 ServerConfig::from_slice(
1992 br#"
1993 [store]
1994 backend = "haematite"
1995 url = "old.db"
1996 "#,
1997 ),
1998 "store.url",
1999 );
2000 }
2001
2002 #[test]
2007 fn retired_libsql_backend_environment_is_refused_with_remedy() {
2008 let mut config = ServerConfig::default();
2009 let result = super::env::overlay_vars(
2010 &mut config,
2011 [("AION_STORE_BACKEND".to_owned(), "libsql".to_owned())],
2012 )
2013 .and_then(|()| config.validate().map(|()| config));
2014 assert_retired_store_refusal(result, "AION_STORE_BACKEND=libsql");
2015 }
2016
2017 #[test]
2018 fn retired_store_url_environment_is_refused_with_remedy() {
2019 let mut config = ServerConfig::default();
2020 let result = super::env::overlay_vars(
2021 &mut config,
2022 [("AION_STORE_URL".to_owned(), "old.db".to_owned())],
2023 )
2024 .and_then(|()| config.validate().map(|()| config));
2025 assert_retired_store_refusal(result, "AION_STORE_URL");
2026 }
2027
2028 #[test]
2029 fn retired_store_url_flag_is_refused_with_remedy() {
2030 let mut config = ServerConfig::default();
2031 config.apply_cli_overrides(&CliOverrides {
2032 store_url: Some("old.db".to_owned()),
2033 ..CliOverrides::default()
2034 });
2035 let result = config.validate().map(|()| config);
2036 assert_retired_store_refusal(result, "--store-url");
2037 }
2038
2039 #[test]
2043 fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
2044 let mut config = ServerConfig::from_slice(
2045 br#"
2046 [store]
2047 backend = "haematite"
2048 data_dir = "from-the-file"
2049
2050 [runtime]
2051 scheduler_threads = 1
2052 query_timeout_ms = 10000
2053
2054 [websocket]
2055 event_broadcast_capacity = 64
2056 cluster_broadcast_capacity = 64
2057
2058 [observability]
2059 max_batch_events = 64
2060 max_batch_hold_ms = 0
2061 "#,
2062 )?;
2063 assert_eq!(
2064 config.runtime.scheduler_threads, 1,
2065 "the file's value must be what the flag then beats"
2066 );
2067 let cli = CliOverrides {
2068 scheduler_threads: Some(
2069 std::num::NonZeroUsize::new(3)
2070 .ok_or("3 is not zero")?
2071 .into(),
2072 ),
2073 ..CliOverrides::default()
2074 };
2075
2076 config.apply_cli_overrides(&cli);
2077 config.validate()?;
2078
2079 assert_eq!(config.runtime.scheduler_threads, 3);
2080 assert_eq!(
2081 config.store.data_dir.as_deref(),
2082 Some("from-the-file"),
2083 "a value the CLI did not override keeps the file's value"
2084 );
2085 Ok(())
2086 }
2087
2088 #[test]
2089 fn haematite_store_config_remains_accepted() -> Result<(), Box<dyn std::error::Error>> {
2090 let config = ServerConfig::from_slice(
2091 br#"
2092 [store]
2093 backend = "haematite"
2094 data_dir = "aion-data"
2095 "#,
2096 )?;
2097 assert_eq!(config.store.backend, StoreBackend::Haematite);
2098 assert_eq!(config.store.data_dir.as_deref(), Some("aion-data"));
2099 Ok(())
2100 }
2101
2102 #[test]
2103 fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
2104 let mut config = ServerConfig::default();
2105
2106 assert_eq!(config.store.backend, StoreBackend::Haematite);
2109 assert_eq!(config.store.data_dir, None);
2110 assert_eq!(config.store.shard_count, 64);
2114 assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
2115 assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
2116 assert_eq!(config.namespaces.default, "default");
2117 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
2121 assert_eq!(
2124 config.namespaces.max_in_flight_activities,
2125 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
2126 );
2127 assert_eq!(config.namespaces.max_in_flight_activities, 1024);
2128 assert!(!config.auth.enabled);
2129 assert!(config.metrics.enabled);
2130 assert_eq!(config.websocket.event_broadcast_capacity, None);
2134 assert_eq!(config.websocket.cluster_broadcast_capacity, None);
2135 assert_eq!(config.runtime.query_timeout_ms, None);
2136 config.websocket.event_broadcast_capacity = Some(64);
2137 config.websocket.cluster_broadcast_capacity = Some(64);
2138 config.runtime.query_timeout_ms = Some(10_000);
2139 let home = crate::test_support::private_tempdir()?;
2140 let working_dir = crate::test_support::private_tempdir()?;
2141 super::fill_home_defaults(
2142 &mut config,
2143 home.path(),
2144 HomeSource::Derived,
2145 working_dir.path(),
2146 )?;
2147 assert_eq!(
2148 config.store.data_dir.as_deref(),
2149 home.path().join("data").to_str()
2150 );
2151 config.validate()?;
2152 Ok(())
2153 }
2154
2155 #[test]
2156 fn outbox_is_disabled_by_default_and_needs_no_knobs() -> Result<(), Box<dyn std::error::Error>>
2157 {
2158 let mut config = ServerConfig::default();
2159 config.store.data_dir = Some("test-data".to_owned());
2160 config.websocket.event_broadcast_capacity = Some(64);
2161 config.websocket.cluster_broadcast_capacity = Some(64);
2162 config.runtime.query_timeout_ms = Some(10_000);
2163
2164 assert!(!config.outbox.enabled);
2168 assert_eq!(config.outbox.poll_interval_ms, None);
2169 assert_eq!(config.outbox.batch_size, None);
2170 assert_eq!(config.outbox.max_attempts, None);
2171 assert_eq!(config.outbox.backoff_base_ms, None);
2172 assert_eq!(config.outbox.backoff_multiplier, None);
2173 assert_eq!(config.outbox.backoff_max_ms, None);
2174 assert_eq!(config.outbox.reconcile_interval_ms, None);
2175 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
2176 config.validate()?;
2177 Ok(())
2178 }
2179
2180 fn outbox_enabled_base() -> ServerConfig {
2181 let mut config = ServerConfig::default();
2182 config.store.data_dir = Some("test-data".to_owned());
2183 config.websocket.event_broadcast_capacity = Some(64);
2184 config.websocket.cluster_broadcast_capacity = Some(64);
2185 config.runtime.query_timeout_ms = Some(10_000);
2186 config.outbox.enabled = true;
2187 config.outbox.poll_interval_ms = Some(250);
2188 config.outbox.batch_size = Some(64);
2189 config.outbox.max_attempts = Some(5);
2190 config.outbox.backoff_base_ms = Some(100);
2191 config.outbox.backoff_multiplier = Some(2);
2192 config.outbox.backoff_max_ms = Some(30_000);
2193 config.outbox.reconcile_interval_ms = Some(1_000);
2194 config.outbox.reconcile_stale_after_ms = Some(60_000);
2195 config
2196 }
2197
2198 #[test]
2199 fn outbox_enabled_with_all_knobs_validates() -> Result<(), Box<dyn std::error::Error>> {
2200 outbox_enabled_base().validate()?;
2201 Ok(())
2202 }
2203
2204 #[test]
2205 fn outbox_enabled_defaults_poll_interval() -> Result<(), Box<dyn std::error::Error>> {
2206 let config = ServerConfig::from_slice(
2210 br"
2211 [runtime]
2212 query_timeout_ms = 10000
2213
2214 [websocket]
2215 event_broadcast_capacity = 64
2216 cluster_broadcast_capacity = 64
2217
2218 [outbox]
2219 enabled = true
2220 ",
2221 )?;
2222 assert_eq!(
2223 config.outbox.poll_interval_ms,
2224 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS),
2225 "omitted poll_interval_ms must resolve to the default"
2226 );
2227 Ok(())
2228 }
2229
2230 #[test]
2231 fn outbox_enabled_defaults_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
2232 let config = ServerConfig::from_slice(
2235 br"
2236 [runtime]
2237 query_timeout_ms = 10000
2238
2239 [websocket]
2240 event_broadcast_capacity = 64
2241 cluster_broadcast_capacity = 64
2242
2243 [outbox]
2244 enabled = true
2245 poll_interval_ms = 250
2246 ",
2247 )?;
2248 assert_eq!(
2249 config.outbox.poll_interval_ms,
2250 Some(250),
2251 "explicit poll_interval_ms must be left untouched"
2252 );
2253 assert_eq!(
2254 config.outbox.max_attempts,
2255 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS),
2256 "omitted max_attempts must resolve to the default"
2257 );
2258 Ok(())
2259 }
2260
2261 #[test]
2262 fn outbox_enabled_with_only_enabled_flag_uses_all_defaults()
2263 -> Result<(), Box<dyn std::error::Error>> {
2264 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 ",
2279 )?;
2280 assert!(config.outbox.enabled);
2281 assert_eq!(
2282 config.outbox.poll_interval_ms,
2283 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS)
2284 );
2285 assert_eq!(config.outbox.batch_size, Some(DEFAULT_OUTBOX_BATCH_SIZE));
2286 assert_eq!(
2287 config.outbox.max_attempts,
2288 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS)
2289 );
2290 assert_eq!(
2291 config.outbox.backoff_base_ms,
2292 Some(DEFAULT_OUTBOX_BACKOFF_BASE_MS)
2293 );
2294 assert_eq!(
2295 config.outbox.backoff_multiplier,
2296 Some(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER)
2297 );
2298 assert_eq!(
2299 config.outbox.backoff_max_ms,
2300 Some(DEFAULT_OUTBOX_BACKOFF_MAX_MS)
2301 );
2302 assert_eq!(config.outbox.reconcile_interval_ms, None);
2305 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
2306 Ok(())
2307 }
2308
2309 #[test]
2310 fn outbox_enabled_zero_poll_interval_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2311 let mut config = outbox_enabled_base();
2314 config.outbox.poll_interval_ms = Some(0);
2315 let error = config
2316 .validate()
2317 .err()
2318 .ok_or("enabled outbox with zero poll interval must fail")?;
2319 assert!(
2320 error.to_string().contains("outbox.poll_interval_ms"),
2321 "error must name the zero-valued key: {error}"
2322 );
2323 Ok(())
2324 }
2325
2326 #[test]
2327 fn outbox_enabled_zero_max_attempts_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2328 let mut config = outbox_enabled_base();
2329 config.outbox.max_attempts = Some(0);
2330 let error = config
2331 .validate()
2332 .err()
2333 .ok_or("enabled outbox with zero max attempts must fail")?;
2334 assert!(
2335 error.to_string().contains("outbox.max_attempts"),
2336 "error must name the zero-valued key: {error}"
2337 );
2338 Ok(())
2339 }
2340
2341 #[test]
2342 fn outbox_backoff_max_below_base_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2343 let mut config = outbox_enabled_base();
2344 config.outbox.backoff_base_ms = Some(1_000);
2345 config.outbox.backoff_max_ms = Some(500);
2346 let error = config
2347 .validate()
2348 .err()
2349 .ok_or("backoff_max below backoff_base must fail")?;
2350 assert!(
2351 error.to_string().contains("outbox.backoff_max_ms"),
2352 "error must name the offending key: {error}"
2353 );
2354 Ok(())
2355 }
2356
2357 #[test]
2358 fn outbox_enabled_can_leave_reconciliation_dark() -> Result<(), Box<dyn std::error::Error>> {
2359 let mut config = outbox_enabled_base();
2360 config.outbox.reconcile_interval_ms = None;
2361 config.outbox.reconcile_stale_after_ms = None;
2362 config.validate()?;
2363 Ok(())
2364 }
2365
2366 #[test]
2367 fn outbox_reconciliation_requires_interval_when_partially_enabled()
2368 -> Result<(), Box<dyn std::error::Error>> {
2369 let mut config = outbox_enabled_base();
2370 config.outbox.reconcile_interval_ms = None;
2371 let error = config
2372 .validate()
2373 .err()
2374 .ok_or("reconciliation without interval must fail")?;
2375 assert!(error.to_string().contains("outbox.reconcile_interval_ms"));
2376 Ok(())
2377 }
2378
2379 #[test]
2380 fn outbox_reconciliation_requires_stale_threshold_when_partially_enabled()
2381 -> Result<(), Box<dyn std::error::Error>> {
2382 let mut config = outbox_enabled_base();
2383 config.outbox.reconcile_stale_after_ms = None;
2384 let error = config
2385 .validate()
2386 .err()
2387 .ok_or("reconciliation without stale threshold must fail")?;
2388 assert!(
2389 error
2390 .to_string()
2391 .contains("outbox.reconcile_stale_after_ms")
2392 );
2393 Ok(())
2394 }
2395
2396 #[test]
2397 fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
2398 let temp_dir = crate::test_support::private_tempdir()?;
2399 std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
2400 std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
2401 std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
2402 std::fs::create_dir(temp_dir.path().join("nested"))?;
2403 std::fs::write(
2404 temp_dir.path().join("nested").join("nested.aion"),
2405 b"package",
2406 )?;
2407
2408 let packages = discover_workflow_packages(temp_dir.path())?;
2409
2410 assert_eq!(
2411 packages,
2412 vec![
2413 temp_dir.path().join("alpha.aion"),
2414 temp_dir.path().join("zeta.aion"),
2415 ]
2416 );
2417 Ok(())
2418 }
2419
2420 #[test]
2421 fn workflow_package_merge_is_additive_and_deduplicated() {
2422 let mut packages = vec!["config.aion".into(), "shared.aion".into()];
2423 let discovered = vec!["auto.aion".into(), "shared.aion".into()];
2424 let cli = vec!["cli.aion".into(), "auto.aion".into()];
2425
2426 merge_workflow_packages(&mut packages, discovered, &cli);
2427
2428 assert_eq!(
2429 packages,
2430 vec![
2431 std::path::PathBuf::from("config.aion"),
2432 std::path::PathBuf::from("shared.aion"),
2433 std::path::PathBuf::from("auto.aion"),
2434 std::path::PathBuf::from("cli.aion"),
2435 ]
2436 );
2437 }
2438
2439 #[test]
2440 fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
2441 let temp_dir = crate::test_support::private_tempdir()?;
2442 let package = temp_dir.path().join("hello.aion");
2443 std::fs::write(&package, b"package")?;
2444 let mut packages = vec![package.clone()];
2445 let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
2446
2447 merge_workflow_packages(&mut packages, discovered, &[]);
2448
2449 assert_eq!(packages, vec![package]);
2450 Ok(())
2451 }
2452
2453 #[test]
2454 fn zero_config_cli_workflow_package_uses_in_memory_defaults()
2455 -> Result<(), Box<dyn std::error::Error>> {
2456 let temp_dir = crate::test_support::private_tempdir()?;
2457
2458 let cli = CliOverrides {
2459 workflow_packages: vec!["hello-world.aion".into()],
2460 ..CliOverrides::default()
2461 };
2462 let mut config = ServerConfig::default();
2463 config.store.backend = StoreBackend::Memory;
2467 config.store.data_dir = None;
2468 config.websocket.event_broadcast_capacity = Some(64);
2473 config.websocket.cluster_broadcast_capacity = Some(64);
2474 config.runtime.query_timeout_ms = Some(10_000);
2475 config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
2476
2477 config.validate()?;
2478
2479 assert_eq!(config.store.backend, StoreBackend::Memory);
2480 assert_eq!(
2481 config.workflow_packages,
2482 vec![std::path::PathBuf::from("hello-world.aion")]
2483 );
2484 Ok(())
2485 }
2486
2487 #[test]
2488 fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
2489 let mut config = ServerConfig::from_slice(
2490 br#"
2491 workflow_packages = ["config.aion"]
2492
2493 [runtime]
2494 query_timeout_ms = 10000
2495
2496 [websocket]
2497 event_broadcast_capacity = 64
2498 cluster_broadcast_capacity = 64
2499 "#,
2500 )?;
2501 let cli = CliOverrides {
2502 workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
2503 ..CliOverrides::default()
2504 };
2505
2506 merge_workflow_packages(
2507 &mut config.workflow_packages,
2508 Vec::new(),
2509 &cli.workflow_packages,
2510 );
2511
2512 assert_eq!(
2513 config.workflow_packages,
2514 vec![
2515 std::path::PathBuf::from("config.aion"),
2516 std::path::PathBuf::from("cli-one.aion"),
2517 std::path::PathBuf::from("cli-two.aion"),
2518 ]
2519 );
2520 Ok(())
2521 }
2522}