Skip to main content

systemprompt_api/routes/admin/services/
refresh.rs

1//! `POST /admin/services/refresh`.
2//!
3//! Re-resolves the configured sources, recomposes the tree and — when the
4//! composition changed — projects the new composition into the authz tables
5//! and refreshes the skill inventory, all in-process. The routes that hand
6//! marketplaces, plugins and skills to clients reload the services tree per
7//! request through the `current` link the recompose just swapped, so a
8//! marketplace-only kit is live the moment this returns. `restart=true`
9//! remains an explicit opt-in for the one thing a running process cannot
10//! re-read: the static services config behind governance hooks.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use std::time::Duration;
16
17use axum::Json;
18use axum::extract::{Extension, Query, State};
19use serde::Deserialize;
20use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
21use systemprompt_loader::bundle::bootstrap::baked::BASE_SOURCE_NAME;
22use systemprompt_loader::bundle::{BundleCache, cache_root};
23use systemprompt_loader::services_root::ServicesRootBootstrap;
24use systemprompt_loader::{ConfigLoader, ServicesSourceBootstrap};
25use systemprompt_models::RequestContext;
26use systemprompt_models::api::ApiError;
27use systemprompt_models::services::bundle::ServicesBundleState;
28use systemprompt_runtime::AppContext;
29use systemprompt_runtime::managed::inventory::publish_latest;
30use systemprompt_runtime::services_reconcile::{ReconcileOutcome, reconcile_fetched_services};
31
32use super::{
33    RefreshLock, ServicesRefreshResponse, composed_hash_of, provenance_view, source_views,
34};
35use crate::error::ApiHttpError;
36
37const RESTART_DELAY: Duration = Duration::from_millis(250);
38const RESTART_REASON: &str = "admin services refresh";
39
40#[derive(Debug, Clone, Copy, Default, Deserialize)]
41pub struct RefreshQuery {
42    #[serde(default)]
43    pub restart: bool,
44}
45
46pub async fn refresh(
47    State(ctx): State<AppContext>,
48    Extension(lock): Extension<RefreshLock>,
49    Extension(req_ctx): Extension<RequestContext>,
50    Query(query): Query<RefreshQuery>,
51) -> Result<Json<ServicesRefreshResponse>, ApiHttpError> {
52    let Some(_guard) = lock.try_acquire() else {
53        return Err(ApiError::conflict("a services refresh is already running").into());
54    };
55
56    let profile = ProfileBootstrap::get()
57        .map_err(|e| ApiHttpError::internal_error(format!("profile not ready: {e}")))?;
58    let secrets = SecretsBootstrap::get()
59        .map_err(|e| ApiHttpError::internal_error(format!("secrets not ready: {e}")))?;
60
61    // Why: the boot-time root is a static; after an in-place import the cache
62    // state names the composition actually being served, so "changed" is
63    // measured against that and a repeat import is a no-op.
64    let cache = BundleCache::new(cache_root(profile));
65    let previous = cache.read_state();
66    let served_hash = (!previous.composed_hash.is_empty())
67        .then_some(previous.composed_hash.clone())
68        .or_else(|| {
69            ServicesRootBootstrap::get()
70                .and_then(composed_hash_of)
71                .map(str::to_owned)
72        });
73
74    let resolved = ServicesSourceBootstrap::resolve(
75        profile,
76        |name| secrets.get(name).cloned(),
77        env!("CARGO_PKG_VERSION"),
78    )
79    .await?;
80
81    let new_hash = composed_hash_of(&resolved).map(str::to_owned);
82    let changed = new_hash != served_hash;
83    // Why: a composition that was swapped in but never projected (a failed
84    // earlier reconcile) is finished by the next import even though nothing
85    // else changed.
86    let unreconciled = new_hash.is_some() && previous.last_reconciled_hash != new_hash;
87    let mut reconciled = false;
88    if changed || unreconciled {
89        // Why: the boot-time root is a static that still names the previous
90        // tree; the recomposed tree is the one whose config is projected.
91        let services =
92            ConfigLoader::reload_from_path(&resolved.path.join("config").join("config.yaml"))
93                .map_err(|e| {
94                    ApiHttpError::internal_error(format!("recomposed services config: {e}"))
95                })?;
96        let outcome = reconcile_fetched_services(profile, &resolved, &services, ctx.db_pool())
97            .await
98            .map_err(|e| ApiHttpError::internal_error(format!("services reconcile: {e}")))?;
99        reconciled = outcome == ReconcileOutcome::Projected;
100
101        let system_admin = ctx.system_admin().id().clone();
102        if let Err(error) = publish_latest(&ctx, &system_admin, req_ctx.user_id()).await {
103            tracing::warn!(%error, "Inventory refresh after services import failed; the scheduled pass will retry");
104        }
105    }
106
107    let state = cache.read_state();
108    let restart_recommended = changed && owns_static_config(&cache, &state);
109    let restarting = changed && query.restart;
110
111    tracing::info!(
112        user_id = %req_ctx.user_id(),
113        changed,
114        reconciled,
115        restart_recommended,
116        composed_hash = new_hash.as_deref().unwrap_or("none"),
117        provenance = %provenance_view(&resolved.provenance).kind,
118        restarting,
119        "Admin services refresh"
120    );
121
122    if restarting {
123        let ctx = ctx.clone();
124        tokio::spawn(async move {
125            tokio::time::sleep(RESTART_DELAY).await;
126            ctx.request_restart(RESTART_REASON);
127        });
128    }
129
130    Ok(Json(ServicesRefreshResponse {
131        changed,
132        composed_hash: new_hash,
133        sources: source_views(&state),
134        reconciled,
135        restart_recommended,
136        restarting,
137    }))
138}
139
140// Why: governance hooks are read once at boot into the static services config;
141// a bundle that ships hooks is the one case an in-process import cannot fully
142// serve, so the caller is told a restart would complete it. A manifest that
143// cannot be read may own hooks, so it recommends the restart too.
144fn owns_static_config(cache: &BundleCache, state: &ServicesBundleState) -> bool {
145    state
146        .sources
147        .iter()
148        .filter(|(name, _)| name.as_str() != BASE_SOURCE_NAME)
149        .any(|(name, fetched)| match cache.read_manifest(name, &fetched.content_hash) {
150            Ok(signed) => !signed.manifest.owns.hooks.is_empty(),
151            Err(error) => {
152                tracing::warn!(source = %name, %error, "Cached bundle manifest unreadable; recommending a restart");
153                true
154            },
155        })
156}