1use std::{
2 error::Error,
3 fmt,
4 path::{Component, PathBuf},
5 time::Duration,
6};
7
8use crate::{
9 api::{AgentAccessPolicy, AgentPolicyError},
10 domain::{RerankMode, RerankModeError, WorkerKind},
11 env::{
12 EnvError, EnvironmentConfig, PlatformKind, RELAY_KNOWLEDGE_EMBEDDING_API_KEY,
13 RELAY_KNOWLEDGE_EMBEDDING_BASE_URL, RELAY_KNOWLEDGE_EMBEDDING_DIMENSION,
14 RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL, RELAY_KNOWLEDGE_RERANK_MODEL,
15 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL, RetrievalEnvOverrides,
16 },
17 net::{NetworkConfig, NetworkConfigError, NetworkRuntime, NetworkRuntimeError},
18 observability::{ObservabilityRuntime, TelemetryConfig},
19 paths::{PathError, RuntimePaths, default_user_document_roots},
20 retrieval::{
21 DEFAULT_EMBEDDING_BATCH_SIZE, DEFAULT_EMBEDDING_MAX_CONCURRENCY, DEFAULT_EMBEDDING_TIMEOUT,
22 DEFAULT_RERANK_CANDIDATE_MULTIPLIER, DEFAULT_RERANK_MAX_CANDIDATES, DEFAULT_RERANK_TIMEOUT,
23 EmbeddingProviderKind, EmbeddingProviderKindError, LOCAL_RERANK_MODEL,
24 LOCAL_SEMANTIC_MODEL, LOCAL_VECTOR_DIMENSION, LOCAL_VECTOR_MODEL, ReadModelBackendConfig,
25 ReadModelBackendMode, ReadModelBackendModeError, ReadModelMetadata, RemoteEmbeddingConfig,
26 RerankConfig,
27 },
28};
29
30#[derive(Debug, Clone)]
32pub struct RuntimeConfiguration {
33 pub paths: RuntimePaths,
34 pub network: NetworkRuntime,
35 pub observability: ObservabilityRuntime,
36 pub agent: AgentRuntimeConfig,
37 pub retrieval: ReadModelBackendConfig,
38 pub workers: WorkerRuntimeConfig,
39 pub file_index: FileIndexRuntimeConfig,
40}
41
42impl RuntimeConfiguration {
43 pub async fn from_process_environment() -> Result<Self, RuntimeConfigurationError> {
45 let environment =
46 EnvironmentConfig::from_process().map_err(RuntimeConfigurationError::Environment)?;
47
48 Self::from_environment(&environment).await
49 }
50
51 pub async fn from_environment(
53 environment: &EnvironmentConfig,
54 ) -> Result<Self, RuntimeConfigurationError> {
55 let network = NetworkConfig::from_overrides(&environment.network)
56 .map_err(RuntimeConfigurationError::Network)?;
57 let observability =
58 ObservabilityRuntime::new(TelemetryConfig::from_environment(&environment.telemetry));
59 let agent = AgentRuntimeConfig::from_environment(environment, network.http.request_timeout)
60 .map_err(RuntimeConfigurationError::Agent)?;
61 let retrieval = retrieval_config_from_environment(&environment.retrieval)
62 .map_err(RuntimeConfigurationError::Retrieval)?;
63 let workers = WorkerRuntimeConfig::from_environment(environment)
64 .map_err(RuntimeConfigurationError::Workers)?;
65 let file_index = FileIndexRuntimeConfig::from_environment(environment)
66 .map_err(RuntimeConfigurationError::FileIndex)?;
67
68 Ok(Self {
69 paths: RuntimePaths::resolve(&environment.platform, &environment.paths)
70 .map_err(RuntimeConfigurationError::Paths)?,
71 network: NetworkRuntime::from_config(network),
72 observability,
73 agent,
74 retrieval,
75 workers,
76 file_index,
77 })
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct FileIndexRuntimeConfig {
84 pub enabled: bool,
85 pub roots: Vec<FileIndexRootConfig>,
86 pub excludes: Vec<String>,
87 pub max_depth: usize,
88 pub max_file_bytes: u64,
89 pub scan_interval: Duration,
90 pub scan_timeout: Duration,
91 pub max_files_per_root: usize,
92 pub query_timeout: Duration,
93}
94
95impl FileIndexRuntimeConfig {
96 pub const DEFAULT_MAX_DEPTH: usize = 32;
97 pub const DEFAULT_MAX_FILE_BYTES: u64 = 512 * 1024 * 1024;
98 pub const DEFAULT_SCAN_INTERVAL: Duration = Duration::from_secs(900);
99 pub const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_secs(300);
100 pub const DEFAULT_MAX_FILES_PER_ROOT: usize = 50_000;
101 pub const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_millis(750);
102
103 pub fn from_environment(
104 environment: &EnvironmentConfig,
105 ) -> Result<Self, FileIndexRuntimeConfigError> {
106 let mut roots = default_user_document_roots(&environment.platform)
107 .map_err(FileIndexRuntimeConfigError::Paths)?
108 .into_iter()
109 .map(|path| FileIndexRootConfig::new("user-documents", path))
110 .collect::<Vec<_>>();
111 for root in split_semicolon(environment.file_index.roots.as_deref())? {
112 roots.push(file_index_root_from_environment(
113 "local-files",
114 root,
115 environment.platform.platform,
116 )?);
117 }
118 roots.sort_by(|left, right| {
119 left.scope_id
120 .cmp(&right.scope_id)
121 .then(left.root_id.cmp(&right.root_id))
122 });
123 roots.dedup_by(|left, right| {
124 left.scope_id == right.scope_id && left.root_id == right.root_id
125 });
126
127 Ok(Self {
128 enabled: environment.file_index.enabled.unwrap_or(false),
129 roots,
130 excludes: split_semicolon(environment.file_index.excludes.as_deref())?,
131 max_depth: environment
132 .file_index
133 .max_depth
134 .unwrap_or(Self::DEFAULT_MAX_DEPTH),
135 max_file_bytes: environment
136 .file_index
137 .max_file_bytes
138 .unwrap_or(Self::DEFAULT_MAX_FILE_BYTES),
139 scan_interval: Duration::from_millis(
140 environment
141 .file_index
142 .scan_interval_ms
143 .unwrap_or(duration_millis(Self::DEFAULT_SCAN_INTERVAL)),
144 ),
145 scan_timeout: Duration::from_millis(
146 environment
147 .file_index
148 .scan_timeout_ms
149 .unwrap_or(duration_millis(Self::DEFAULT_SCAN_TIMEOUT)),
150 ),
151 max_files_per_root: environment
152 .file_index
153 .max_files_per_root
154 .unwrap_or(Self::DEFAULT_MAX_FILES_PER_ROOT),
155 query_timeout: Duration::from_millis(
156 environment
157 .file_index
158 .query_timeout_ms
159 .unwrap_or(duration_millis(Self::DEFAULT_QUERY_TIMEOUT)),
160 ),
161 })
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct FileIndexRootConfig {
168 pub scope_id: String,
169 pub root_id: String,
170 pub root_path: PathBuf,
171}
172
173impl FileIndexRootConfig {
174 pub fn new(scope_id: impl Into<String>, root_path: PathBuf) -> Self {
175 let root_path = normalize_file_index_root_path(root_path);
176 let root_id = format!(
177 "root-{:016x}",
178 stable_hash64(root_path.to_string_lossy().as_bytes())
179 );
180
181 Self {
182 scope_id: scope_id.into(),
183 root_id,
184 root_path,
185 }
186 }
187}
188
189fn normalize_file_index_root_path(root_path: PathBuf) -> PathBuf {
190 if let Ok(canonical) = std::fs::canonicalize(&root_path) {
191 return canonical;
192 }
193
194 let mut normalized = PathBuf::new();
195 for component in root_path.components() {
196 match component {
197 Component::CurDir => {}
198 Component::ParentDir => normalized.push(".."),
199 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
200 Component::RootDir => normalized.push(component.as_os_str()),
201 Component::Normal(value) => normalized.push(value),
202 }
203 }
204
205 if normalized.as_os_str().is_empty() {
206 root_path
207 } else {
208 normalized
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum FileIndexRuntimeConfigError {
215 EmptyListValue,
216 RelativeRoot(String),
217 Paths(PathError),
218}
219
220impl fmt::Display for FileIndexRuntimeConfigError {
221 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222 match self {
223 Self::EmptyListValue => {
224 write!(formatter, "file index lists must not contain empty values")
225 }
226 Self::RelativeRoot(path) => {
227 write!(
228 formatter,
229 "file index root '{path}' must be an absolute path"
230 )
231 }
232 Self::Paths(error) => write!(formatter, "{error}"),
233 }
234 }
235}
236
237impl Error for FileIndexRuntimeConfigError {}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct WorkerRuntimeConfig {
242 pub embedding_endpoint: Option<String>,
243 pub ocr_endpoint: Option<String>,
244 pub vision_endpoint: Option<String>,
245 pub extractor_endpoint: Option<String>,
246 pub max_in_flight: usize,
247 pub silent_updates_enabled: bool,
248}
249
250impl WorkerRuntimeConfig {
251 pub const DEFAULT_MAX_IN_FLIGHT: usize = 2;
252
253 pub fn from_environment(
255 environment: &EnvironmentConfig,
256 ) -> Result<Self, WorkerRuntimeConfigError> {
257 Ok(Self {
258 embedding_endpoint: validate_worker_endpoint(
259 environment.workers.embedding_endpoint.clone(),
260 )?,
261 ocr_endpoint: validate_worker_endpoint(environment.workers.ocr_endpoint.clone())?,
262 vision_endpoint: validate_worker_endpoint(environment.workers.vision_endpoint.clone())?,
263 extractor_endpoint: validate_worker_endpoint(
264 environment.workers.extractor_endpoint.clone(),
265 )?,
266 max_in_flight: environment
267 .workers
268 .max_in_flight
269 .unwrap_or(Self::DEFAULT_MAX_IN_FLIGHT),
270 silent_updates_enabled: environment.workers.silent_updates_enabled.unwrap_or(false),
271 })
272 }
273
274 pub fn endpoint_for(&self, kind: WorkerKind) -> Option<&str> {
276 match kind {
277 WorkerKind::Embedding => self.embedding_endpoint.as_deref(),
278 WorkerKind::Ocr => self.ocr_endpoint.as_deref(),
279 WorkerKind::Vision => self.vision_endpoint.as_deref(),
280 WorkerKind::Extractor => self.extractor_endpoint.as_deref(),
281 }
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum WorkerRuntimeConfigError {
288 InvalidEndpoint(String),
289}
290
291impl fmt::Display for WorkerRuntimeConfigError {
292 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293 match self {
294 Self::InvalidEndpoint(value) => write!(
295 formatter,
296 "worker endpoint '{value}' must use http:// and include a host"
297 ),
298 }
299 }
300}
301
302impl Error for WorkerRuntimeConfigError {}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct AgentRuntimeConfig {
307 pub mcp_streamable_http_enabled: bool,
308 pub mcp_endpoint: String,
309 pub mcp_allowed_origins: Vec<String>,
310 pub access_policy: AgentAccessPolicy,
311 pub audit_sink_enabled: bool,
312 pub audit_queue_depth: usize,
313}
314
315impl AgentRuntimeConfig {
316 pub const DEFAULT_AUDIT_QUEUE_DEPTH: usize = 1024;
317
318 pub fn from_environment(
320 environment: &EnvironmentConfig,
321 request_timeout: Duration,
322 ) -> Result<Self, AgentRuntimeConfigError> {
323 let max_runtime_ms = agent_runtime_budget_ms(request_timeout);
324 let access_policy = AgentAccessPolicy::new(
325 split_csv(environment.agent.mcp_allowed_scopes.as_deref())?,
326 environment
327 .agent
328 .mcp_allow_unspecified_scope
329 .unwrap_or(false),
330 environment
331 .agent
332 .mcp_max_limit
333 .unwrap_or(AgentAccessPolicy::DEFAULT_MAX_LIMIT),
334 environment
335 .agent
336 .mcp_max_context_bytes
337 .unwrap_or(AgentAccessPolicy::DEFAULT_MAX_CONTEXT_BYTES),
338 max_runtime_ms,
339 environment.agent.mcp_allow_remote_clients.unwrap_or(false),
340 )
341 .map_err(AgentRuntimeConfigError::Policy)?;
342
343 Ok(Self {
344 mcp_streamable_http_enabled: environment
345 .agent
346 .mcp_streamable_http_enabled
347 .unwrap_or(false),
348 mcp_endpoint: validate_endpoint(
349 environment.agent.mcp_endpoint.as_deref().unwrap_or("/mcp"),
350 )?,
351 mcp_allowed_origins: split_csv(environment.agent.mcp_allowed_origins.as_deref())?,
352 access_policy,
353 audit_sink_enabled: environment.agent.audit_sink_enabled.unwrap_or(false),
354 audit_queue_depth: environment
355 .agent
356 .audit_queue_depth
357 .unwrap_or(Self::DEFAULT_AUDIT_QUEUE_DEPTH),
358 })
359 }
360
361 pub fn with_streamable_http_enabled(mut self) -> Self {
363 self.mcp_streamable_http_enabled = true;
364 self
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
370pub enum AgentRuntimeConfigError {
371 InvalidEndpoint(String),
372 EmptyListValue,
373 Policy(AgentPolicyError),
374}
375
376impl fmt::Display for AgentRuntimeConfigError {
377 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
378 match self {
379 Self::InvalidEndpoint(value) => {
380 write!(
381 formatter,
382 "MCP endpoint '{value}' must be an absolute HTTP path"
383 )
384 }
385 Self::EmptyListValue => {
386 write!(formatter, "MCP comma-separated values must not be empty")
387 }
388 Self::Policy(error) => write!(formatter, "{error}"),
389 }
390 }
391}
392
393impl Error for AgentRuntimeConfigError {}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
397pub enum RuntimeConfigurationError {
398 Environment(EnvError),
399 Paths(PathError),
400 Network(NetworkConfigError),
401 NetworkRuntime(NetworkRuntimeError),
402 Agent(AgentRuntimeConfigError),
403 Retrieval(RetrievalRuntimeConfigError),
404 Workers(WorkerRuntimeConfigError),
405 FileIndex(FileIndexRuntimeConfigError),
406}
407
408impl fmt::Display for RuntimeConfigurationError {
409 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410 match self {
411 Self::Environment(error) => write!(formatter, "{error}"),
412 Self::Paths(error) => write!(formatter, "{error}"),
413 Self::Network(error) => write!(formatter, "{error}"),
414 Self::NetworkRuntime(error) => write!(formatter, "{error}"),
415 Self::Agent(error) => write!(formatter, "{error}"),
416 Self::Retrieval(error) => write!(formatter, "{error}"),
417 Self::Workers(error) => write!(formatter, "{error}"),
418 Self::FileIndex(error) => write!(formatter, "{error}"),
419 }
420 }
421}
422
423impl Error for RuntimeConfigurationError {}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum RetrievalRuntimeConfigError {
428 InvalidBackend(ReadModelBackendModeError),
429 InvalidRerankBackend(RerankModeError),
430 InvalidProvider(EmbeddingProviderKindError),
431 EmptyModelName(&'static str),
432 MissingRemoteValue(&'static str),
433 InvalidRemoteBaseUrl(String),
434 DimensionTooLarge(usize),
435}
436
437impl fmt::Display for RetrievalRuntimeConfigError {
438 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
439 match self {
440 Self::InvalidBackend(error) => write!(formatter, "{error}"),
441 Self::InvalidRerankBackend(error) => write!(formatter, "{error}"),
442 Self::InvalidProvider(error) => write!(formatter, "{error}"),
443 Self::EmptyModelName(variable) => {
444 write!(formatter, "{variable} must not be blank")
445 }
446 Self::MissingRemoteValue(variable) => {
447 write!(
448 formatter,
449 "{variable} is required when a read model backend is external"
450 )
451 }
452 Self::InvalidRemoteBaseUrl(value) => {
453 write!(
454 formatter,
455 "embedding base URL '{value}' must use http:// or https://"
456 )
457 }
458 Self::DimensionTooLarge(value) => {
459 write!(formatter, "embedding dimension {value} does not fit in u32")
460 }
461 }
462 }
463}
464
465impl Error for RetrievalRuntimeConfigError {}
466
467fn retrieval_config_from_environment(
468 overrides: &RetrievalEnvOverrides,
469) -> Result<ReadModelBackendConfig, RetrievalRuntimeConfigError> {
470 let semantic_mode = parse_backend_mode(overrides.semantic_backend.as_deref())?;
471 let vector_mode = parse_backend_mode(overrides.vector_backend.as_deref())?;
472 let remote_required = semantic_mode == ReadModelBackendMode::External
473 || vector_mode == ReadModelBackendMode::External;
474 require_remote_model_metadata(overrides, remote_required)?;
475 let dimension = match overrides.embedding_dimension {
476 Some(value) => u32::try_from(value)
477 .map_err(|_| RetrievalRuntimeConfigError::DimensionTooLarge(value))?,
478 None => LOCAL_VECTOR_DIMENSION,
479 };
480 let text_model = model_name_override(
481 overrides.text_embedding_model.as_deref(),
482 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL,
483 LOCAL_VECTOR_MODEL,
484 )?;
485 let semantic_model = model_name_override(
486 overrides.text_embedding_model.as_deref(),
487 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL,
488 LOCAL_SEMANTIC_MODEL,
489 )?;
490 let image_model = model_name_override(
491 overrides.image_embedding_model.as_deref(),
492 RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL,
493 "relay-local-image-hash-v1",
494 )?;
495
496 let remote_embedding = remote_embedding_config_from_environment(overrides, remote_required)?;
497 let rerank = rerank_config_from_environment(overrides)?;
498
499 Ok(ReadModelBackendConfig {
500 semantic_mode,
501 vector_mode,
502 semantic_model: ReadModelMetadata {
503 name: semantic_model,
504 dimension,
505 },
506 vector_model: ReadModelMetadata {
507 name: text_model,
508 dimension,
509 },
510 image_model: ReadModelMetadata {
511 name: image_model,
512 dimension,
513 },
514 remote_embedding,
515 rerank,
516 })
517}
518
519fn rerank_config_from_environment(
520 overrides: &RetrievalEnvOverrides,
521) -> Result<RerankConfig, RetrievalRuntimeConfigError> {
522 let mode = overrides
523 .rerank_backend
524 .as_deref()
525 .map(RerankMode::parse)
526 .transpose()
527 .map_err(RetrievalRuntimeConfigError::InvalidRerankBackend)?
528 .unwrap_or(RerankMode::Local);
529 let model = match mode {
530 RerankMode::Disabled => None,
531 RerankMode::Local => Some(model_name_override(
532 overrides.rerank_model.as_deref(),
533 RELAY_KNOWLEDGE_RERANK_MODEL,
534 LOCAL_RERANK_MODEL,
535 )?),
536 RerankMode::External => overrides
537 .rerank_model
538 .as_deref()
539 .map(|model| model_name_override(Some(model), RELAY_KNOWLEDGE_RERANK_MODEL, ""))
540 .transpose()?,
541 };
542 let timeout = overrides
543 .rerank_timeout_ms
544 .map(Duration::from_millis)
545 .unwrap_or(DEFAULT_RERANK_TIMEOUT);
546
547 Ok(RerankConfig {
548 mode,
549 model,
550 timeout,
551 candidate_multiplier: overrides
552 .rerank_candidate_multiplier
553 .unwrap_or(DEFAULT_RERANK_CANDIDATE_MULTIPLIER),
554 max_candidates: overrides
555 .rerank_max_candidates
556 .unwrap_or(DEFAULT_RERANK_MAX_CANDIDATES),
557 })
558}
559
560fn require_remote_model_metadata(
561 overrides: &RetrievalEnvOverrides,
562 required: bool,
563) -> Result<(), RetrievalRuntimeConfigError> {
564 if !required {
565 return Ok(());
566 }
567 if overrides.text_embedding_model.is_none() {
568 return Err(RetrievalRuntimeConfigError::MissingRemoteValue(
569 RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL,
570 ));
571 }
572 if overrides.embedding_dimension.is_none() {
573 return Err(RetrievalRuntimeConfigError::MissingRemoteValue(
574 RELAY_KNOWLEDGE_EMBEDDING_DIMENSION,
575 ));
576 }
577
578 Ok(())
579}
580
581fn remote_embedding_config_from_environment(
582 overrides: &RetrievalEnvOverrides,
583 required: bool,
584) -> Result<Option<RemoteEmbeddingConfig>, RetrievalRuntimeConfigError> {
585 if !required {
586 return Ok(None);
587 }
588 let provider = overrides
589 .llm_provider
590 .as_deref()
591 .map(EmbeddingProviderKind::parse)
592 .transpose()
593 .map_err(RetrievalRuntimeConfigError::InvalidProvider)?
594 .unwrap_or(EmbeddingProviderKind::OpenAiCompatible);
595 let base_url = required_remote_value(
596 overrides.embedding_base_url.as_deref(),
597 RELAY_KNOWLEDGE_EMBEDDING_BASE_URL,
598 )?;
599 if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
600 return Err(RetrievalRuntimeConfigError::InvalidRemoteBaseUrl(base_url));
601 }
602 let api_key = required_remote_value(
603 overrides.embedding_api_key.as_deref(),
604 RELAY_KNOWLEDGE_EMBEDDING_API_KEY,
605 )?;
606 let batch_size = overrides
607 .embedding_batch_size
608 .unwrap_or(DEFAULT_EMBEDDING_BATCH_SIZE);
609 let timeout = overrides
610 .embedding_timeout_ms
611 .map(Duration::from_millis)
612 .unwrap_or(DEFAULT_EMBEDDING_TIMEOUT);
613 let max_concurrency = overrides
614 .embedding_max_concurrency
615 .unwrap_or(DEFAULT_EMBEDDING_MAX_CONCURRENCY);
616
617 Ok(Some(RemoteEmbeddingConfig {
618 provider,
619 base_url,
620 api_key,
621 batch_size,
622 timeout,
623 max_concurrency,
624 }))
625}
626
627fn required_remote_value(
628 value: Option<&str>,
629 variable: &'static str,
630) -> Result<String, RetrievalRuntimeConfigError> {
631 match value.map(str::trim) {
632 Some(trimmed) if !trimmed.is_empty() => Ok(trimmed.to_owned()),
633 _ => Err(RetrievalRuntimeConfigError::MissingRemoteValue(variable)),
634 }
635}
636
637fn model_name_override(
638 value: Option<&str>,
639 variable: &'static str,
640 default: &'static str,
641) -> Result<String, RetrievalRuntimeConfigError> {
642 match value {
643 Some(raw) => {
644 let trimmed = raw.trim();
645 if trimmed.is_empty() {
646 Err(RetrievalRuntimeConfigError::EmptyModelName(variable))
647 } else {
648 Ok(trimmed.to_owned())
649 }
650 }
651 None => Ok(default.to_owned()),
652 }
653}
654
655fn parse_backend_mode(
656 value: Option<&str>,
657) -> Result<ReadModelBackendMode, RetrievalRuntimeConfigError> {
658 value
659 .map(ReadModelBackendMode::parse)
660 .transpose()
661 .map_err(RetrievalRuntimeConfigError::InvalidBackend)
662 .map(|mode| mode.unwrap_or(ReadModelBackendMode::Local))
663}
664
665fn validate_endpoint(value: &str) -> Result<String, AgentRuntimeConfigError> {
666 let trimmed = value.trim();
667 if !trimmed.starts_with('/')
668 || trimmed.contains(char::is_whitespace)
669 || trimmed.contains('?')
670 || trimmed.contains('#')
671 {
672 return Err(AgentRuntimeConfigError::InvalidEndpoint(value.to_owned()));
673 }
674
675 Ok(trimmed.to_owned())
676}
677
678fn validate_worker_endpoint(
679 value: Option<String>,
680) -> Result<Option<String>, WorkerRuntimeConfigError> {
681 value
682 .map(|endpoint| {
683 let trimmed = endpoint.trim();
684 if is_valid_worker_http_endpoint(trimmed) {
685 Ok(trimmed.to_owned())
686 } else {
687 Err(WorkerRuntimeConfigError::InvalidEndpoint(endpoint))
688 }
689 })
690 .transpose()
691}
692
693fn is_valid_worker_http_endpoint(value: &str) -> bool {
694 let Some(remainder) = value.strip_prefix("http://") else {
695 return false;
696 };
697 let authority = remainder
698 .split_once('/')
699 .map_or(remainder, |(authority, _)| authority);
700 if authority.is_empty() || authority.contains(char::is_whitespace) {
701 return false;
702 }
703 if let Some((host, port)) = authority.rsplit_once(':') {
704 return !host.is_empty() && port.parse::<u16>().is_ok_and(|port| port > 0);
705 }
706
707 !authority.is_empty()
708}
709
710fn split_csv(value: Option<&str>) -> Result<Vec<String>, AgentRuntimeConfigError> {
711 value
712 .map(|items| {
713 items
714 .split(',')
715 .map(str::trim)
716 .map(|item| {
717 if item.is_empty() {
718 Err(AgentRuntimeConfigError::EmptyListValue)
719 } else {
720 Ok(item.to_owned())
721 }
722 })
723 .collect()
724 })
725 .unwrap_or_else(|| Ok(Vec::new()))
726}
727
728fn split_semicolon(value: Option<&str>) -> Result<Vec<String>, FileIndexRuntimeConfigError> {
729 value
730 .map(|items| {
731 items
732 .split(';')
733 .map(str::trim)
734 .map(|item| {
735 if item.is_empty() {
736 Err(FileIndexRuntimeConfigError::EmptyListValue)
737 } else {
738 Ok(item.to_owned())
739 }
740 })
741 .collect()
742 })
743 .unwrap_or_else(|| Ok(Vec::new()))
744}
745
746fn file_index_root_from_environment(
747 scope_id: &'static str,
748 root: String,
749 platform: PlatformKind,
750) -> Result<FileIndexRootConfig, FileIndexRuntimeConfigError> {
751 if !is_absolute_file_index_root(&root, platform) {
752 return Err(FileIndexRuntimeConfigError::RelativeRoot(root));
753 }
754
755 Ok(FileIndexRootConfig::new(scope_id, PathBuf::from(root)))
756}
757
758fn is_absolute_file_index_root(root: &str, platform: PlatformKind) -> bool {
759 match platform {
760 PlatformKind::Windows => is_absolute_windows_path(root),
761 _ => PathBuf::from(root).is_absolute(),
762 }
763}
764
765fn is_absolute_windows_path(root: &str) -> bool {
766 let bytes = root.as_bytes();
767 let drive_rooted = bytes.len() >= 3
768 && bytes[0].is_ascii_alphabetic()
769 && bytes[1] == b':'
770 && matches!(bytes[2], b'\\' | b'/');
771 if drive_rooted {
772 return true;
773 }
774
775 if !(root.starts_with("\\\\") || root.starts_with("//")) {
776 return false;
777 }
778 root[2..]
779 .split(['\\', '/'])
780 .filter(|component| !component.is_empty())
781 .take(2)
782 .count()
783 == 2
784}
785
786fn stable_hash64(bytes: &[u8]) -> u64 {
787 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
788 const FNV_PRIME: u64 = 0x100000001b3;
789
790 let mut hash = FNV_OFFSET_BASIS;
791 for byte in bytes {
792 hash ^= u64::from(*byte);
793 hash = hash.wrapping_mul(FNV_PRIME);
794 }
795
796 hash
797}
798
799fn duration_millis(duration: Duration) -> u64 {
800 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
801}
802
803fn agent_runtime_budget_ms(request_timeout: Duration) -> u64 {
804 let budget = request_timeout.saturating_sub(Duration::from_millis(1));
805 duration_millis(budget).max(1)
806}
807
808#[cfg(test)]
809#[path = "runtime_tests.rs"]
810mod tests;