Skip to main content

systemprompt_loader/
services_state.rs

1//! The owned services-config state.
2//!
3//! [`ServicesState`] is built once by the boot path and handed to every
4//! consumer: the merged, validated [`ServicesConfig`] — agents, MCP servers,
5//! resolved gateway — behind a [`ConfigWatch`] the live-reload driver replaces,
6//! beside the one [`ResolvedCatalog`] every consumer reads models, deployments
7//! and credentials from. A load failure is a boot failure: nothing downstream
8//! may run against a catalog that did not parse or resolve. The catalog's
9//! `{install}` placeholders expand to the active profile's `site.name`
10//! ([`catalog_context`]).
11//!
12//! Model discovery ([`ServicesState::load_with_discovery`]) is **boot-time
13//! only**: the augmentation runs in the one window where the config is still
14//! owned — between `ConfigLoader::load` and resolution — and the config is
15//! re-validated afterwards so a discovered model can never bypass the gateway
16//! pricing gate. A model that appears upstream mid process is served only after
17//! the next restart, as is a catalog change. The report from that pass is kept
18//! behind [`ServicesState::discovery_report`].
19//!
20//! Copyright (c) systemprompt.io — Business Source License 1.1.
21//! See <https://systemprompt.io> for licensing details.
22
23use std::future::Future;
24use std::path::Path;
25use std::pin::Pin;
26use std::sync::Arc;
27
28use systemprompt_manifest::services::catalog::{CatalogContext, CatalogDocument, ResolvedCatalog};
29use systemprompt_manifest::services::{DiscoveryReport, ServicesConfig, ServicesView};
30use systemprompt_traits::ConfigWatch;
31
32use systemprompt_manifest::profile::Profile;
33
34use crate::boot::BootContext;
35use crate::config_loader::ConfigLoader;
36use crate::error::ConfigLoadResult;
37
38pub type DiscoveryFuture<'a> = Pin<Box<dyn Future<Output = DiscoveryReport> + Send + 'a>>;
39
40#[derive(Debug, Clone)]
41pub struct ServicesState {
42    watch: ConfigWatch<ServicesConfig>,
43    catalog: Arc<ResolvedCatalog>,
44    discovery: Option<Arc<DiscoveryReport>>,
45}
46
47impl ServicesState {
48    pub fn load(boot: &BootContext) -> ConfigLoadResult<Self> {
49        Self::from_config(boot.profile(), boot.load_services_config()?)
50    }
51
52    pub fn load_from_path(path: &Path, profile: &Profile) -> ConfigLoadResult<Self> {
53        Self::from_config(profile, ConfigLoader::load_from_path(path, Some(profile))?)
54    }
55
56    // Why: the augmenter borrows the catalog across an await, so it is a
57    // boxed future tied to that borrow — a plain `FnOnce(&mut _) -> Fut` cannot
58    // name the lifetime and every real async fn fails the higher-ranked bound.
59    pub async fn load_with_discovery<F>(boot: &BootContext, augment: F) -> ConfigLoadResult<Self>
60    where
61        F: for<'a> FnOnce(&'a mut CatalogDocument) -> DiscoveryFuture<'a>,
62    {
63        let mut services = boot.load_services_config()?;
64        let mut document = take_catalog(&mut services);
65        let report = augment(&mut document).await;
66        restore_catalog(&mut services, document);
67        services.validate()?;
68        let mut state = Self::from_config(boot.profile(), services)?;
69        state.discovery = Some(Arc::new(report));
70        Ok(state)
71    }
72
73    pub fn from_config(profile: &Profile, services: ServicesConfig) -> ConfigLoadResult<Self> {
74        let catalog = services.resolve_catalog(&catalog_context(profile))?;
75        Ok(Self {
76            watch: ConfigWatch::new(services),
77            catalog: Arc::new(catalog),
78            discovery: None,
79        })
80    }
81
82    #[must_use]
83    pub fn config(&self) -> Arc<ServicesConfig> {
84        self.watch.get()
85    }
86
87    #[must_use]
88    pub const fn watch(&self) -> &ConfigWatch<ServicesConfig> {
89        &self.watch
90    }
91
92    #[must_use]
93    pub fn view(&self) -> ServicesView {
94        ServicesView {
95            config: self.watch.clone(),
96            catalog: Arc::clone(&self.catalog),
97        }
98    }
99
100    #[must_use]
101    pub const fn catalog(&self) -> &Arc<ResolvedCatalog> {
102        &self.catalog
103    }
104
105    #[must_use]
106    pub fn discovery_report(&self) -> Option<&DiscoveryReport> {
107        self.discovery.as_deref()
108    }
109}
110
111#[must_use]
112pub fn catalog_context(profile: &Profile) -> CatalogContext {
113    CatalogContext::for_install(profile.site.name.clone())
114}
115
116fn take_catalog(services: &mut ServicesConfig) -> CatalogDocument {
117    CatalogDocument {
118        credentials: std::mem::take(&mut services.credentials),
119        providers: std::mem::take(&mut services.providers),
120        models: std::mem::take(&mut services.models),
121        deployments: std::mem::take(&mut services.deployments),
122        gateway: None,
123    }
124}
125
126fn restore_catalog(services: &mut ServicesConfig, document: CatalogDocument) {
127    services.credentials = document.credentials;
128    services.providers = document.providers;
129    services.models = document.models;
130    services.deployments = document.deployments;
131}