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//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11mod assembly;
12mod core_layer;
13
14use std::sync::{Arc, OnceLock};
15
16use systemprompt_database::MigrationConfig;
17use systemprompt_extension::ExtensionRegistry;
18use systemprompt_marketplace::MarketplaceFilter;
19use systemprompt_mcp::services::registry::RegistryService;
20use systemprompt_security::authz::{AuthzDecisionHook, SharedAuthzHook};
21use systemprompt_security::policy::GovernanceEngine;
22use systemprompt_users::UserService;
23
24use crate::context::{AppContext, ConfigPlane, DataPlane, Plugins, Subsystems};
25use crate::error::RuntimeResult;
26use crate::registry::ModuleApiRegistry;
27use core_layer::{CoreLayer, init_core, init_extensions};
28
29/// Assembles an [`AppContext`], owning the bootstrap order described on the
30/// module.
31///
32/// All fields default to a no-op build: extensions are discovered via
33/// inventory, schema installation is off, and the marketplace filter falls
34/// back to the inventory-registered implementation (or an allow-all filter).
35/// Override these with the `with_*` methods before calling
36/// [`build`](Self::build).
37#[derive(Default)]
38pub struct AppContextBuilder {
39    extension_registry: Option<ExtensionRegistry>,
40    show_startup_warnings: bool,
41    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
42    authz_hook: Option<SharedAuthzHook>,
43    install_schemas: bool,
44    migration_config: MigrationConfig,
45}
46
47impl std::fmt::Debug for AppContextBuilder {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("AppContextBuilder")
50            .field("extension_registry", &self.extension_registry.is_some())
51            .field("show_startup_warnings", &self.show_startup_warnings)
52            .field("marketplace_filter", &self.marketplace_filter.is_some())
53            .field("authz_hook", &self.authz_hook.is_some())
54            .field("install_schemas", &self.install_schemas)
55            .field("migration_config", &self.migration_config)
56            .finish()
57    }
58}
59
60impl AppContextBuilder {
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    #[must_use]
67    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
68        self.extension_registry = Some(registry);
69        self
70    }
71
72    #[must_use]
73    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
74        self.show_startup_warnings = show;
75        self
76    }
77
78    #[must_use]
79    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
80        self.marketplace_filter = Some(filter);
81        self
82    }
83
84    #[must_use]
85    pub const fn with_migrations(mut self, install: bool) -> Self {
86        self.install_schemas = install;
87        self
88    }
89
90    #[must_use]
91    pub fn with_authz_hook<H>(mut self, hook: H) -> Self
92    where
93        H: AuthzDecisionHook + 'static,
94    {
95        self.authz_hook = Some(Arc::new(hook));
96        self
97    }
98
99    #[must_use]
100    pub fn with_shared_authz_hook(mut self, hook: SharedAuthzHook) -> Self {
101        self.authz_hook = Some(hook);
102        self
103    }
104
105    #[must_use]
106    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
107        self.migration_config = config;
108        self
109    }
110
111    pub async fn build(self) -> RuntimeResult<AppContext> {
112        let CoreLayer {
113            config,
114            app_paths,
115            database,
116            authz_hook,
117            file_storage,
118        } = init_core(self.authz_hook).await?;
119
120        let api_registry = Arc::new(ModuleApiRegistry::new());
121        let extension_registry = init_extensions(
122            self.extension_registry,
123            self.install_schemas,
124            self.migration_config,
125            &database,
126        )
127        .await?;
128
129        GovernanceEngine::global()?;
130
131        let assembly::ContentAnalytics {
132            geoip_reader,
133            content_config,
134            route_classifier,
135            analytics_service,
136            analytics_repositories,
137            fingerprint_repo,
138        } = assembly::assemble_content_analytics(
139            &config,
140            &app_paths,
141            &database,
142            self.show_startup_warnings,
143        )?;
144
145        let instance_id = systemprompt_identifiers::InstanceId::new(&config.instance_id);
146        let repositories = build_repositories(&database, analytics_repositories, instance_id)?;
147
148        let user_service = Arc::new(UserService::new(Arc::clone(&repositories.users)));
149
150        let system_admin =
151            assembly::resolve_and_install_system_admin(&config, &user_service).await?;
152        let mcp_registry = RegistryService::new(system_admin.id().clone());
153
154        ensure_legacy_context(&repositories, &system_admin).await?;
155
156        let marketplace_filter = self
157            .marketplace_filter
158            .unwrap_or_else(|| assembly::build_marketplace_filter(&database));
159
160        Ok(AppContext::from_parts(
161            DataPlane {
162                database,
163                analytics_service,
164                fingerprint_repo,
165                user_service: Some(user_service),
166                a2a_repositories: repositories.a2a,
167                content_repositories: repositories.content,
168                oauth_repositories: repositories.oauth,
169                user_repository: repositories.users,
170                service_repository: repositories.services,
171                ai_repositories: repositories.ai,
172                analytics_repositories: repositories.analytics,
173                file_repository: repositories.files,
174                mcp_session_repository: repositories.mcp_sessions,
175            },
176            ConfigPlane {
177                config,
178                app_paths,
179                content_config,
180                route_classifier,
181            },
182            Plugins {
183                extension_registry,
184                api_registry,
185                mcp_registry,
186                marketplace_filter,
187            },
188            build_subsystems(system_admin, authz_hook, geoip_reader, file_storage),
189        ))
190    }
191}
192
193fn build_subsystems(
194    system_admin: Arc<systemprompt_models::services::SystemAdmin>,
195    authz_hook: SharedAuthzHook,
196    geoip_reader: Option<systemprompt_analytics::GeoIpReader>,
197    file_storage: Arc<dyn systemprompt_traits::FileStorage>,
198) -> Subsystems {
199    Subsystems {
200        system_admin,
201        authz_hook,
202        event_bridge: Arc::new(OnceLock::new()),
203        geoip_reader,
204        file_storage,
205    }
206}
207
208async fn ensure_legacy_context(
209    repositories: &RepositoryBundles,
210    system_admin: &systemprompt_models::services::SystemAdmin,
211) -> RuntimeResult<()> {
212    repositories
213        .a2a
214        .contexts
215        .ensure_context(
216            &systemprompt_traits::EnsureContextParams {
217                context_id: &systemprompt_identifiers::ContextId::legacy(),
218                user_id: system_admin.id(),
219                session_id: None,
220                name: "Legacy (pre-context)",
221                kind: systemprompt_models::ContextKind::Legacy.as_str(),
222            },
223            systemprompt_models::ContextKind::Legacy,
224        )
225        .await
226        .map_err(|e| crate::error::RuntimeError::Internal(e.to_string()))
227}
228
229struct RepositoryBundles {
230    a2a: Arc<systemprompt_agent::repository::A2ARepositories>,
231    content: Arc<systemprompt_content::repository::ContentRepositories>,
232    oauth: Arc<systemprompt_oauth::repository::OAuthRepositories>,
233    users: Arc<systemprompt_users::UserRepository>,
234    services: Arc<systemprompt_database::ServiceRepository>,
235    ai: Arc<systemprompt_ai::repository::AiRepositories>,
236    analytics: Arc<systemprompt_analytics::repository::AnalyticsRepositories>,
237    files: Arc<systemprompt_files::FileRepository>,
238    mcp_sessions: Arc<systemprompt_mcp::repository::McpSessionRepository>,
239}
240
241fn build_repositories(
242    database: &systemprompt_database::DbPool,
243    analytics: Arc<systemprompt_analytics::repository::AnalyticsRepositories>,
244    instance_id: systemprompt_identifiers::InstanceId,
245) -> RuntimeResult<RepositoryBundles> {
246    let session_usage: systemprompt_traits::DynSessionUsageCounters =
247        Arc::new(analytics.sessions.clone());
248    Ok(RepositoryBundles {
249        a2a: Arc::new(systemprompt_agent::repository::A2ARepositories::new(
250            database,
251            session_usage,
252            instance_id.clone(),
253        )?),
254        content: Arc::new(systemprompt_content::repository::ContentRepositories::new(
255            database,
256        )?),
257        oauth: Arc::new(systemprompt_oauth::repository::OAuthRepositories::new(
258            database,
259        )?),
260        users: Arc::new(systemprompt_users::UserRepository::new(database)?),
261        services: Arc::new(systemprompt_database::ServiceRepository::new(
262            database,
263            instance_id,
264        )?),
265        ai: Arc::new(systemprompt_ai::repository::AiRepositories::new(database)?),
266        analytics,
267        files: Arc::new(systemprompt_files::FileRepository::new(database)?),
268        mcp_sessions: Arc::new(systemprompt_mcp::repository::McpSessionRepository::new(
269            database,
270        )?),
271    })
272}