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