Skip to main content

systemprompt_runtime/builder/
mod.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//! Subsystem resolution helpers live in [`assembly`].
7
8mod assembly;
9mod core_layer;
10
11use std::sync::{Arc, OnceLock};
12
13use systemprompt_analytics::{AnalyticsService, FingerprintRepository};
14use systemprompt_database::MigrationConfig;
15use systemprompt_extension::ExtensionRegistry;
16use systemprompt_marketplace::MarketplaceFilter;
17use systemprompt_mcp::services::registry::RegistryService;
18use systemprompt_security::authz::{AuthzDecisionHook, SharedAuthzHook};
19use systemprompt_users::UserService;
20
21use crate::context::{AppContext, ConfigPlane, DataPlane, Plugins, Subsystems};
22use crate::error::RuntimeResult;
23use crate::registry::ModuleApiRegistry;
24use core_layer::{CoreLayer, init_core, init_extensions};
25
26/// Assembles an [`AppContext`], owning the bootstrap order described on the
27/// module.
28///
29/// All fields default to a no-op build: extensions are discovered via
30/// inventory, schema installation is off, and the marketplace filter falls
31/// back to the inventory-registered implementation (or an allow-all filter).
32/// Override these with the `with_*` methods before calling
33/// [`build`](Self::build).
34#[derive(Default)]
35pub struct AppContextBuilder {
36    extension_registry: Option<ExtensionRegistry>,
37    show_startup_warnings: bool,
38    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
39    authz_hook: Option<SharedAuthzHook>,
40    install_schemas: bool,
41    migration_config: MigrationConfig,
42}
43
44impl std::fmt::Debug for AppContextBuilder {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("AppContextBuilder")
47            .field("extension_registry", &self.extension_registry.is_some())
48            .field("show_startup_warnings", &self.show_startup_warnings)
49            .field("marketplace_filter", &self.marketplace_filter.is_some())
50            .field("authz_hook", &self.authz_hook.is_some())
51            .field("install_schemas", &self.install_schemas)
52            .field("migration_config", &self.migration_config)
53            .finish()
54    }
55}
56
57impl AppContextBuilder {
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Supplies an explicit extension registry. When unset, `build()`
64    /// discovers extensions via inventory ([`ExtensionRegistry::discover`]).
65    #[must_use]
66    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
67        self.extension_registry = Some(registry);
68        self
69    }
70
71    #[must_use]
72    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
73        self.show_startup_warnings = show;
74        self
75    }
76
77    /// Supplies an explicit marketplace filter. When unset, `build()` selects
78    /// the highest-priority inventory-registered filter, falling back to an
79    /// allow-all filter when none succeeds.
80    #[must_use]
81    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
82        self.marketplace_filter = Some(filter);
83        self
84    }
85
86    /// Install / migrate extension schemas as part of `build()`. Off by
87    /// default so admin tools (`db doctor`, repair scripts) can open a
88    /// connection without mutating the schema. `serve` turns this on.
89    #[must_use]
90    pub const fn with_migrations(mut self, install: bool) -> Self {
91        self.install_schemas = install;
92        self
93    }
94
95    /// Supplies an extension-built authz decision hook. The hook is wired
96    /// only when `profile.governance.authz.hook.mode = extension`; pairing
97    /// this call with any other mode is a bootstrap error.
98    #[must_use]
99    pub fn with_authz_hook<H>(mut self, hook: H) -> Self
100    where
101        H: AuthzDecisionHook + 'static,
102    {
103        self.authz_hook = Some(Arc::new(hook));
104        self
105    }
106
107    /// Variant of [`Self::with_authz_hook`] for callers that already hold an
108    /// `Arc<dyn AuthzDecisionHook>` (e.g. a pre-built [`CompositeAuthzHook`]
109    /// shared across consumers).
110    ///
111    /// [`CompositeAuthzHook`]: systemprompt_security::authz::CompositeAuthzHook
112    #[must_use]
113    pub fn with_shared_authz_hook(mut self, hook: SharedAuthzHook) -> Self {
114        self.authz_hook = Some(hook);
115        self
116    }
117
118    #[must_use]
119    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
120        self.migration_config = config;
121        self
122    }
123
124    pub async fn build(self) -> RuntimeResult<AppContext> {
125        let CoreLayer {
126            config,
127            app_paths,
128            database,
129            authz_hook,
130        } = init_core(self.authz_hook).await?;
131
132        let api_registry = Arc::new(ModuleApiRegistry::new());
133        let extension_registry = init_extensions(
134            self.extension_registry,
135            self.install_schemas,
136            self.migration_config,
137            &database,
138        )
139        .await?;
140
141        let geoip_reader = AppContext::load_geoip_database(&config, self.show_startup_warnings);
142        let content_config = AppContext::load_content_config(&config, &app_paths);
143        let content_routing = assembly::content_routing_from(content_config.as_ref());
144        let route_classifier = Arc::new(systemprompt_models::RouteClassifier::new(
145            content_routing.clone(),
146        ));
147        let analytics_service = Arc::new(AnalyticsService::new(
148            &database,
149            geoip_reader.clone(),
150            content_routing,
151        )?);
152
153        let fingerprint_repo = match FingerprintRepository::new(&database) {
154            Ok(repo) => Some(Arc::new(repo)),
155            Err(e) => {
156                tracing::warn!(error = %e, "Failed to initialize fingerprint repository");
157                None
158            },
159        };
160
161        // UserService is a mandatory dependency: the system admin cannot be
162        // resolved without it, so a construction failure is fatal here rather
163        // than a warning that re-surfaces as a less specific error downstream.
164        let user_service = Arc::new(UserService::new(&database)?);
165
166        let system_admin =
167            assembly::resolve_and_install_system_admin(&config, &user_service).await?;
168        let mcp_registry = RegistryService::new(system_admin.id().clone());
169
170        let marketplace_filter = self
171            .marketplace_filter
172            .unwrap_or_else(|| assembly::build_marketplace_filter(&database));
173
174        let event_bridge = Arc::new(OnceLock::new());
175
176        Ok(AppContext::from_parts(
177            DataPlane {
178                database,
179                analytics_service,
180                fingerprint_repo,
181                user_service: Some(user_service),
182            },
183            ConfigPlane {
184                config,
185                app_paths,
186                content_config,
187                route_classifier,
188            },
189            Plugins {
190                extension_registry,
191                api_registry,
192                mcp_registry,
193                marketplace_filter,
194            },
195            Subsystems {
196                system_admin,
197                authz_hook,
198                event_bridge,
199                geoip_reader,
200            },
201        ))
202    }
203}