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