Skip to main content

systemprompt_runtime/
builder.rs

1//! Builder that assembles an [`AppContext`] from profile + config state.
2//!
3//! The builder owns the bootstrap order: profile -> paths -> files ->
4//! database -> logging -> extensions -> ancillary services. Failures at
5//! any step propagate as [`RuntimeError`](crate::error::RuntimeError).
6
7use std::sync::{Arc, OnceLock};
8
9use systemprompt_analytics::{AnalyticsService, FingerprintRepository};
10use systemprompt_config::ProfileBootstrap;
11use systemprompt_database::{Database, MigrationConfig, install_extension_schemas_full};
12use systemprompt_extension::ExtensionRegistry;
13use systemprompt_marketplace::{AllowAllFilter, MarketplaceFilter, discover_filters};
14use systemprompt_mcp::services::registry::RegistryService;
15use systemprompt_models::services::{SystemAdmin, SystemAdminConfig};
16use systemprompt_models::{AppPaths, Config, ContentConfigRaw, ContentRouting};
17use systemprompt_users::UserService;
18
19use crate::context::{AppContext, AppContextParts};
20use crate::context_loaders;
21use crate::error::{RuntimeError, RuntimeResult};
22use crate::registry::ModuleApiRegistry;
23
24/// Assembles an [`AppContext`], owning the bootstrap order described on the
25/// module.
26///
27/// All fields default to a no-op build: extensions are discovered via
28/// inventory, schema installation is off, and the marketplace filter falls
29/// back to the inventory-registered implementation (or an allow-all filter).
30/// Override these with the `with_*` methods before calling
31/// [`build`](Self::build).
32#[derive(Default)]
33pub struct AppContextBuilder {
34    extension_registry: Option<ExtensionRegistry>,
35    show_startup_warnings: bool,
36    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
37    install_schemas: bool,
38    migration_config: MigrationConfig,
39}
40
41impl std::fmt::Debug for AppContextBuilder {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("AppContextBuilder")
44            .field("extension_registry", &self.extension_registry.is_some())
45            .field("show_startup_warnings", &self.show_startup_warnings)
46            .field("marketplace_filter", &self.marketplace_filter.is_some())
47            .field("install_schemas", &self.install_schemas)
48            .field("migration_config", &self.migration_config)
49            .finish()
50    }
51}
52
53impl AppContextBuilder {
54    #[must_use]
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Supplies an explicit extension registry. When unset, `build()`
60    /// discovers extensions via inventory ([`ExtensionRegistry::discover`]).
61    #[must_use]
62    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
63        self.extension_registry = Some(registry);
64        self
65    }
66
67    #[must_use]
68    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
69        self.show_startup_warnings = show;
70        self
71    }
72
73    /// Supplies an explicit marketplace filter. When unset, `build()` selects
74    /// the highest-priority inventory-registered filter, falling back to an
75    /// allow-all filter when none succeeds.
76    #[must_use]
77    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
78        self.marketplace_filter = Some(filter);
79        self
80    }
81
82    /// Install / migrate extension schemas as part of `build()`. Off by
83    /// default so admin tools (`db doctor`, repair scripts) can open a
84    /// connection without mutating the schema. `serve` turns this on.
85    #[must_use]
86    pub const fn with_migrations(mut self, install: bool) -> Self {
87        self.install_schemas = install;
88        self
89    }
90
91    #[must_use]
92    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
93        self.migration_config = config;
94        self
95    }
96
97    pub async fn build(self) -> RuntimeResult<AppContext> {
98        let profile = ProfileBootstrap::get()?;
99        let app_paths = Arc::new(AppPaths::from_profile(&profile.paths)?);
100        systemprompt_files::FilesConfig::init(&app_paths)?;
101        let config = Arc::new(Config::get()?.clone());
102
103        let database = Arc::new(
104            Database::from_config_with_write(
105                &config.database_type,
106                &config.database_url,
107                config.database_write_url.as_deref(),
108            )
109            .await?,
110        );
111
112        let authz_audit_pool = database.write_pool_arc().ok();
113        let authz_hook = systemprompt_security::authz::build_authz_hook(
114            profile.governance.as_ref(),
115            authz_audit_pool,
116        )
117        .map_err(|err| RuntimeError::Internal(format!("authz bootstrap: {err}")))?;
118
119        systemprompt_logging::init_logging(Arc::clone(&database));
120
121        if config.database_write_url.is_some() {
122            tracing::debug!(
123                "Database read/write separation enabled: reads from replica, writes to primary"
124            );
125        }
126
127        let api_registry = Arc::new(ModuleApiRegistry::new());
128
129        let registry = match self.extension_registry {
130            Some(registry) => registry,
131            None => ExtensionRegistry::discover()?,
132        };
133        registry.validate()?;
134
135        if self.install_schemas {
136            install_extension_schemas_full(
137                &registry,
138                database.write_provider(),
139                &[],
140                self.migration_config,
141            )
142            .await?;
143        }
144
145        let extension_registry = Arc::new(registry);
146
147        let geoip_reader = AppContext::load_geoip_database(&config, self.show_startup_warnings);
148        let content_config = AppContext::load_content_config(&config, &app_paths);
149        let content_routing = content_routing_from(content_config.as_ref());
150        let route_classifier = Arc::new(systemprompt_models::RouteClassifier::new(
151            content_routing.clone(),
152        ));
153        let analytics_service = Arc::new(AnalyticsService::new(
154            &database,
155            geoip_reader.clone(),
156            content_routing,
157        )?);
158
159        let fingerprint_repo = match FingerprintRepository::new(&database) {
160            Ok(repo) => Some(Arc::new(repo)),
161            Err(e) => {
162                tracing::warn!(error = %e, "Failed to initialize fingerprint repository");
163                None
164            },
165        };
166
167        let user_service = match UserService::new(&database) {
168            Ok(svc) => Some(Arc::new(svc)),
169            Err(e) => {
170                tracing::warn!(error = %e, "Failed to initialize user service");
171                None
172            },
173        };
174
175        let system_admin = resolve_and_install_system_admin(&config, user_service.as_ref()).await?;
176        let mcp_registry = RegistryService::new(system_admin.id().clone());
177
178        let marketplace_filter = self
179            .marketplace_filter
180            .unwrap_or_else(|| build_marketplace_filter(&database));
181
182        let event_bridge = Arc::new(OnceLock::new());
183
184        Ok(AppContext::from_parts(AppContextParts {
185            config,
186            database,
187            api_registry,
188            extension_registry,
189            geoip_reader,
190            content_config,
191            route_classifier,
192            analytics_service,
193            fingerprint_repo,
194            user_service,
195            app_paths,
196            marketplace_filter,
197            event_bridge,
198            system_admin,
199            mcp_registry,
200            authz_hook,
201        }))
202    }
203}
204
205async fn resolve_and_install_system_admin(
206    config: &Config,
207    user_service: Option<&Arc<UserService>>,
208) -> RuntimeResult<Arc<SystemAdmin>> {
209    let users = user_service.ok_or(RuntimeError::SystemAdminUserServiceUnavailable)?;
210    let cfg = SystemAdminConfig {
211        username: config.system_admin_username.clone(),
212    };
213    let resolved = context_loaders::resolve_system_admin(&cfg, users.as_ref()).await?;
214    systemprompt_logging::install_log_attribution(resolved.clone());
215    Ok(Arc::new(resolved))
216}
217
218fn build_marketplace_filter(
219    database: &systemprompt_database::DbPool,
220) -> Arc<dyn MarketplaceFilter> {
221    for reg in discover_filters() {
222        match (reg.factory)(database) {
223            Ok(filter) => {
224                tracing::debug!(
225                    priority = reg.priority,
226                    "marketplace filter registered via inventory; using highest-priority impl",
227                );
228                return filter;
229            },
230            Err(err) => {
231                tracing::error!(
232                    priority = reg.priority,
233                    error = %err,
234                    "marketplace filter factory failed; trying next candidate",
235                );
236            },
237        }
238    }
239    let fallback: Arc<dyn MarketplaceFilter> = Arc::new(AllowAllFilter);
240    fallback
241}
242
243fn content_routing_from(
244    content_config: Option<&Arc<ContentConfigRaw>>,
245) -> Option<Arc<dyn ContentRouting>> {
246    let concrete = Arc::clone(content_config?);
247    let routing: Arc<dyn ContentRouting> = concrete;
248    Some(routing)
249}