Skip to main content

systemprompt_api/routes/admin/services/
refresh.rs

1//! `POST /admin/services/refresh`.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::time::Duration;
7
8use axum::Json;
9use axum::extract::{Extension, Query, State};
10use serde::Deserialize;
11use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
12use systemprompt_loader::ServicesSourceBootstrap;
13use systemprompt_loader::bundle::{BundleCache, cache_root};
14use systemprompt_loader::services_root::ServicesRootBootstrap;
15use systemprompt_models::RequestContext;
16use systemprompt_models::api::ApiError;
17use systemprompt_runtime::AppContext;
18
19use super::{
20    RefreshLock, ServicesRefreshResponse, composed_hash_of, provenance_view, source_views,
21};
22use crate::error::ApiHttpError;
23
24const RESTART_DELAY: Duration = Duration::from_millis(250);
25const RESTART_REASON: &str = "admin services refresh";
26
27#[derive(Debug, Clone, Copy, Default, Deserialize)]
28pub struct RefreshQuery {
29    #[serde(default)]
30    pub restart: bool,
31}
32
33pub async fn refresh(
34    State(ctx): State<AppContext>,
35    Extension(lock): Extension<RefreshLock>,
36    Extension(req_ctx): Extension<RequestContext>,
37    Query(query): Query<RefreshQuery>,
38) -> Result<Json<ServicesRefreshResponse>, ApiHttpError> {
39    let Some(_guard) = lock.try_acquire() else {
40        return Err(ApiError::conflict("a services refresh is already running").into());
41    };
42
43    let profile = ProfileBootstrap::get()
44        .map_err(|e| ApiHttpError::internal_error(format!("profile not ready: {e}")))?;
45    let secrets = SecretsBootstrap::get().ok();
46
47    let resolved = ServicesSourceBootstrap::resolve(
48        profile,
49        |name| secrets.and_then(|s| s.get(name).cloned()),
50        env!("CARGO_PKG_VERSION"),
51    )
52    .await?;
53
54    let active_hash = ServicesRootBootstrap::get().and_then(composed_hash_of);
55    let new_hash = composed_hash_of(&resolved);
56    let changed = new_hash != active_hash;
57
58    let state = BundleCache::new(cache_root(profile)).read_state();
59    let restarting = changed && query.restart;
60
61    tracing::info!(
62        user_id = %req_ctx.user_id(),
63        changed,
64        composed_hash = new_hash.unwrap_or("none"),
65        provenance = %provenance_view(&resolved.provenance).kind,
66        restarting,
67        "Admin services refresh"
68    );
69
70    if restarting {
71        let ctx = ctx.clone();
72        tokio::spawn(async move {
73            tokio::time::sleep(RESTART_DELAY).await;
74            ctx.request_restart(RESTART_REASON);
75        });
76    }
77
78    Ok(Json(ServicesRefreshResponse {
79        changed,
80        composed_hash: new_hash.map(str::to_owned),
81        sources: source_views(&state),
82        restarting,
83    }))
84}