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;
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_models::{AppPaths, Config, ContentConfigRaw, ContentRouting};
15use systemprompt_users::UserService;
16
17use crate::context::{AppContext, AppContextParts};
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::registry::ModuleApiRegistry;
20
21#[derive(Default)]
22pub struct AppContextBuilder {
23    extension_registry: Option<ExtensionRegistry>,
24    show_startup_warnings: bool,
25    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
26    install_schemas: bool,
27    migration_config: MigrationConfig,
28}
29
30impl std::fmt::Debug for AppContextBuilder {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("AppContextBuilder")
33            .field("extension_registry", &self.extension_registry.is_some())
34            .field("show_startup_warnings", &self.show_startup_warnings)
35            .field("marketplace_filter", &self.marketplace_filter.is_some())
36            .field("install_schemas", &self.install_schemas)
37            .field("migration_config", &self.migration_config)
38            .finish()
39    }
40}
41
42impl AppContextBuilder {
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    #[must_use]
49    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
50        self.extension_registry = Some(registry);
51        self
52    }
53
54    #[must_use]
55    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
56        self.show_startup_warnings = show;
57        self
58    }
59
60    #[must_use]
61    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
62        self.marketplace_filter = Some(filter);
63        self
64    }
65
66    /// Install / migrate extension schemas as part of `build()`. Off by
67    /// default so admin tools (`db doctor`, repair scripts) can open a
68    /// connection without mutating the schema. `serve` turns this on.
69    #[must_use]
70    pub const fn with_migrations(mut self, install: bool) -> Self {
71        self.install_schemas = install;
72        self
73    }
74
75    #[must_use]
76    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
77        self.migration_config = config;
78        self
79    }
80
81    pub async fn build(self) -> RuntimeResult<AppContext> {
82        let profile = ProfileBootstrap::get()?;
83        let app_paths = Arc::new(AppPaths::from_profile(&profile.paths)?);
84        systemprompt_files::FilesConfig::init(&app_paths)?;
85        let config = Arc::new(Config::get()?.clone());
86
87        let database = Arc::new(
88            Database::from_config_with_write(
89                &config.database_type,
90                &config.database_url,
91                config.database_write_url.as_deref(),
92            )
93            .await?,
94        );
95
96        let authz_audit_pool = database.write_pool_arc().ok();
97        systemprompt_security::authz::install_from_governance_config(
98            profile.governance.as_ref(),
99            authz_audit_pool,
100        )
101        .map_err(|err| RuntimeError::Internal(format!("authz bootstrap: {err}")))?;
102
103        systemprompt_logging::init_logging(Arc::clone(&database));
104
105        if config.database_write_url.is_some() {
106            tracing::info!(
107                "Database read/write separation enabled: reads from replica, writes to primary"
108            );
109        }
110
111        let api_registry = Arc::new(ModuleApiRegistry::new());
112
113        let registry = match self.extension_registry {
114            Some(registry) => registry,
115            None => ExtensionRegistry::discover()?,
116        };
117        registry.validate()?;
118
119        if self.install_schemas {
120            install_extension_schemas_full(
121                &registry,
122                database.write_provider(),
123                &[],
124                self.migration_config,
125            )
126            .await?;
127        }
128
129        let extension_registry = Arc::new(registry);
130
131        let geoip_reader = AppContext::load_geoip_database(&config, self.show_startup_warnings);
132        let content_config = AppContext::load_content_config(&config, &app_paths);
133        let content_routing = content_routing_from(content_config.as_ref());
134        let route_classifier = Arc::new(systemprompt_models::RouteClassifier::new(
135            content_routing.clone(),
136        ));
137        let analytics_service = Arc::new(AnalyticsService::new(
138            &database,
139            geoip_reader.clone(),
140            content_routing,
141        )?);
142
143        let fingerprint_repo = match FingerprintRepository::new(&database) {
144            Ok(repo) => Some(Arc::new(repo)),
145            Err(e) => {
146                tracing::warn!(error = %e, "Failed to initialize fingerprint repository");
147                None
148            },
149        };
150
151        let user_service = match UserService::new(&database) {
152            Ok(svc) => Some(Arc::new(svc)),
153            Err(e) => {
154                tracing::warn!(error = %e, "Failed to initialize user service");
155                None
156            },
157        };
158
159        let marketplace_filter = self
160            .marketplace_filter
161            .unwrap_or_else(|| build_marketplace_filter(&database));
162
163        Ok(AppContext::from_parts(AppContextParts {
164            config,
165            database,
166            api_registry,
167            extension_registry,
168            geoip_reader,
169            content_config,
170            route_classifier,
171            analytics_service,
172            fingerprint_repo,
173            user_service,
174            app_paths,
175            marketplace_filter,
176        }))
177    }
178}
179
180fn build_marketplace_filter(
181    database: &systemprompt_database::DbPool,
182) -> Arc<dyn MarketplaceFilter> {
183    for reg in discover_filters() {
184        match (reg.factory)(database) {
185            Ok(filter) => {
186                tracing::info!(
187                    priority = reg.priority,
188                    "marketplace filter registered via inventory; using highest-priority impl",
189                );
190                return filter;
191            },
192            Err(err) => {
193                tracing::error!(
194                    priority = reg.priority,
195                    error = %err,
196                    "marketplace filter factory failed; trying next candidate",
197                );
198            },
199        }
200    }
201    let fallback: Arc<dyn MarketplaceFilter> = Arc::new(AllowAllFilter);
202    fallback
203}
204
205fn content_routing_from(
206    content_config: Option<&Arc<ContentConfigRaw>>,
207) -> Option<Arc<dyn ContentRouting>> {
208    let concrete = Arc::clone(content_config?);
209    let routing: Arc<dyn ContentRouting> = concrete;
210    Some(routing)
211}