systemprompt_api/routes/admin/services/
refresh.rs1use 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}