Skip to main content

systemprompt_api/routes/admin/services/
mod.rs

1//! Admin endpoints reporting and refreshing the services bundle composition.
2//!
3//! `GET /status` answers "what tree is this instance actually serving, and
4//! why" — including the failure text when the instance fell back to a cached
5//! or baked tree, so an instance running yesterday's bundle does not look
6//! healthy. `POST /refresh` re-runs the boot-time resolution against the
7//! configured sources and reports whether the composition changed; the
8//! running process keeps its old root either way, so `restart=true` asks the
9//! supervisor to bring the process back on the new composition.
10//!
11//! Two refreshes must not fetch at once, so the router owns a single-flight
12//! lock and the second caller is refused rather than queued behind a
13//! multi-megabyte download.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18mod refresh;
19mod status;
20
21use std::sync::Arc;
22
23use axum::routing::{get, post};
24use axum::{Extension, Router};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use systemprompt_loader::services_root::{ActiveServicesRoot, ServicesProvenance};
28use systemprompt_models::services::bundle::ServicesBundleState;
29use systemprompt_runtime::AppContext;
30
31pub use refresh::{RefreshQuery, refresh};
32pub use status::build_status;
33
34pub(super) fn router() -> Router<AppContext> {
35    Router::new()
36        .route("/status", get(status::status))
37        .route("/refresh", post(refresh))
38        .layer(Extension(RefreshLock::default()))
39}
40
41/// Single-flight guard around the fetch-verify-compose pipeline.
42///
43/// Held for the whole refresh, so a caller that cannot take it is told the
44/// refresh is already running instead of waiting on the lock and timing the
45/// request out.
46#[derive(Debug, Clone, Default)]
47pub struct RefreshLock(Arc<tokio::sync::Mutex<()>>);
48
49impl RefreshLock {
50    pub fn try_acquire(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
51        Arc::clone(&self.0).try_lock_owned().ok()
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct SourceView {
57    pub name: String,
58    pub digest: String,
59    pub version: String,
60    pub content_hash: String,
61    pub fetched_at: DateTime<Utc>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ProvenanceView {
66    pub kind: String,
67
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub composed_hash: Option<String>,
70
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub error: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ServicesStatusResponse {
77    pub active_root: String,
78    pub provenance: ProvenanceView,
79    pub sources: Vec<SourceView>,
80
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub last_reconciled_hash: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ServicesRefreshResponse {
87    pub changed: bool,
88
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub composed_hash: Option<String>,
91
92    pub sources: Vec<SourceView>,
93    pub restarting: bool,
94}
95
96pub fn source_views(state: &ServicesBundleState) -> Vec<SourceView> {
97    state
98        .sources
99        .iter()
100        .map(|(name, s)| SourceView {
101            name: name.clone(),
102            digest: s.digest.clone(),
103            version: s.version.clone(),
104            content_hash: s.content_hash.clone(),
105            fetched_at: s.fetched_at,
106        })
107        .collect()
108}
109
110pub fn provenance_view(provenance: &ServicesProvenance) -> ProvenanceView {
111    match provenance {
112        ServicesProvenance::Bundled => ProvenanceView {
113            kind: "bundled".to_owned(),
114            composed_hash: None,
115            error: None,
116        },
117        ServicesProvenance::Fetched { composed_hash, .. } => ProvenanceView {
118            kind: "fetched".to_owned(),
119            composed_hash: Some(composed_hash.clone()),
120            error: None,
121        },
122        ServicesProvenance::LastGood {
123            composed_hash,
124            error,
125        } => ProvenanceView {
126            kind: "last_good".to_owned(),
127            composed_hash: Some(composed_hash.clone()),
128            error: Some(error.clone()),
129        },
130        ServicesProvenance::BundledFallback { error } => ProvenanceView {
131            kind: "bundled".to_owned(),
132            composed_hash: None,
133            error: Some(error.clone()),
134        },
135    }
136}
137
138pub const fn composed_hash_of(active: &ActiveServicesRoot) -> Option<&str> {
139    match &active.provenance {
140        ServicesProvenance::Fetched { composed_hash, .. }
141        | ServicesProvenance::LastGood { composed_hash, .. } => Some(composed_hash.as_str()),
142        ServicesProvenance::Bundled | ServicesProvenance::BundledFallback { .. } => None,
143    }
144}