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 status::build_status;
32
33pub(super) fn router() -> Router<AppContext> {
34    Router::new()
35        .route("/status", get(status::status))
36        .route("/refresh", post(refresh::refresh))
37        .layer(Extension(RefreshLock::default()))
38}
39
40/// Single-flight guard around the fetch-verify-compose pipeline.
41///
42/// Held for the whole refresh, so a caller that cannot take it is told the
43/// refresh is already running instead of waiting on the lock and timing the
44/// request out.
45#[derive(Debug, Clone, Default)]
46pub struct RefreshLock(Arc<tokio::sync::Mutex<()>>);
47
48impl RefreshLock {
49    pub fn try_acquire(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
50        Arc::clone(&self.0).try_lock_owned().ok()
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct SourceView {
56    pub name: String,
57    pub digest: String,
58    pub version: String,
59    pub content_hash: String,
60    pub fetched_at: DateTime<Utc>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ProvenanceView {
65    pub kind: String,
66
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub composed_hash: Option<String>,
69
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub error: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ServicesStatusResponse {
76    pub active_root: String,
77    pub provenance: ProvenanceView,
78    pub sources: Vec<SourceView>,
79
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub last_reconciled_hash: Option<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct ServicesRefreshResponse {
86    pub changed: bool,
87
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub composed_hash: Option<String>,
90
91    pub sources: Vec<SourceView>,
92    pub restarting: bool,
93}
94
95pub fn source_views(state: &ServicesBundleState) -> Vec<SourceView> {
96    state
97        .sources
98        .iter()
99        .map(|(name, s)| SourceView {
100            name: name.clone(),
101            digest: s.digest.clone(),
102            version: s.version.clone(),
103            content_hash: s.content_hash.clone(),
104            fetched_at: s.fetched_at,
105        })
106        .collect()
107}
108
109pub fn provenance_view(provenance: &ServicesProvenance) -> ProvenanceView {
110    match provenance {
111        ServicesProvenance::Bundled => ProvenanceView {
112            kind: "bundled".to_owned(),
113            composed_hash: None,
114            error: None,
115        },
116        ServicesProvenance::Fetched { composed_hash, .. } => ProvenanceView {
117            kind: "fetched".to_owned(),
118            composed_hash: Some(composed_hash.clone()),
119            error: None,
120        },
121        ServicesProvenance::LastGood {
122            composed_hash,
123            error,
124        } => ProvenanceView {
125            kind: "last_good".to_owned(),
126            composed_hash: Some(composed_hash.clone()),
127            error: Some(error.clone()),
128        },
129        ServicesProvenance::BundledFallback { error } => ProvenanceView {
130            kind: "bundled".to_owned(),
131            composed_hash: None,
132            error: Some(error.clone()),
133        },
134    }
135}
136
137pub const fn composed_hash_of(active: &ActiveServicesRoot) -> Option<&str> {
138    match &active.provenance {
139        ServicesProvenance::Fetched { composed_hash, .. }
140        | ServicesProvenance::LastGood { composed_hash, .. } => Some(composed_hash.as_str()),
141        ServicesProvenance::Bundled | ServicesProvenance::BundledFallback { .. } => None,
142    }
143}