Skip to main content

systemprompt_cli/commands/core/services/
refresh.rs

1//! `services refresh` — re-resolve the profile's bundle sources.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use clap::Args;
8use serde::Serialize;
9use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
10use systemprompt_loader::ServicesSourceBootstrap;
11use systemprompt_loader::bundle::source::{AnyFetcher, BundleFetcher};
12use systemprompt_loader::bundle::{BundleCache, cache_root};
13use systemprompt_models::Profile;
14use systemprompt_models::services::bundle::ServicesBundleState;
15
16use super::reconcile::{ReconcileRow, reconcile_after_swap};
17use crate::context::CommandContext;
18use crate::shared::{CommandOutput, render_result};
19
20pub const EXIT_CHANGED: i32 = 3;
21
22#[derive(Debug, Clone, Copy, Args)]
23pub struct RefreshArgs {
24    #[arg(
25        long,
26        help = "Only compare remote digests; fetch nothing and swap nothing"
27    )]
28    pub check: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RefreshOutcome {
33    Unchanged,
34    Changed,
35}
36
37#[must_use]
38pub const fn exit_code_for(outcome: RefreshOutcome) -> i32 {
39    match outcome {
40        RefreshOutcome::Unchanged => 0,
41        RefreshOutcome::Changed => EXIT_CHANGED,
42    }
43}
44
45#[derive(Debug, Serialize)]
46pub struct SourceRow {
47    pub name: String,
48    pub previous_digest: String,
49    pub new_digest: String,
50    pub version: String,
51    pub changed: bool,
52}
53
54pub async fn execute(args: &RefreshArgs, ctx: &CommandContext) -> Result<()> {
55    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
56    let cache = BundleCache::new(cache_root(profile));
57    let before = cache.read_state();
58
59    let (rows, outcome, reconciled) = if args.check {
60        let (rows, outcome) = check_sources(profile, &before).await?;
61        (rows, outcome, Vec::new())
62    } else {
63        swap_sources(profile, &cache, &before, ctx).await?
64    };
65
66    let title = if args.check {
67        "Services Sources (check)"
68    } else {
69        "Services Sources"
70    };
71    render_result(
72        &CommandOutput::table_of(
73            vec![
74                "name",
75                "previous_digest",
76                "new_digest",
77                "version",
78                "changed",
79            ],
80            &rows,
81        )
82        .with_title(title),
83        &ctx.cli,
84    );
85
86    if !reconciled.is_empty() {
87        render_result(
88            &CommandOutput::table_of(
89                vec![
90                    "bundle",
91                    "inserted",
92                    "updated",
93                    "deleted",
94                    "protected",
95                    "inert_role_rules",
96                ],
97                &reconciled,
98            )
99            .with_title("Access Rules Reconciled"),
100            &ctx.cli,
101        );
102    }
103
104    // Why: a supervisor script distinguishes "nothing to do" from "restart me"
105    // by exit status, and every error path the binary has collapses to 1.
106    let code = exit_code_for(outcome);
107    if code != 0 {
108        #[expect(
109            clippy::exit,
110            reason = "the documented exit code is this command's contract with a supervisor \
111                      script; anyhow can only produce 1"
112        )]
113        std::process::exit(code);
114    }
115    Ok(())
116}
117
118async fn check_sources(
119    profile: &Profile,
120    before: &ServicesBundleState,
121) -> Result<(Vec<SourceRow>, RefreshOutcome)> {
122    let client = reqwest::Client::builder()
123        .redirect(reqwest::redirect::Policy::none())
124        .build()
125        .context("Failed to build an HTTP client")?;
126
127    let mut rows = Vec::new();
128    for source in &profile.services.sources {
129        let auth = source.auth_secret().and_then(lookup_secret);
130        let fetcher = AnyFetcher::from_source(source, auth, &client)
131            .with_context(|| format!("Source {} is not usable", source.name))?;
132        let remote = fetcher
133            .head()
134            .await
135            .with_context(|| format!("Source {} could not be reached", source.name))?;
136        let known = before.sources.get(&source.name);
137        let previous = known.map_or_else(String::new, |s| s.digest.clone());
138        rows.push(SourceRow {
139            name: source.name.clone(),
140            changed: remote.is_unknown() || previous != remote.digest,
141            new_digest: remote.digest,
142            previous_digest: previous,
143            version: known.map_or_else(String::new, |s| s.version.clone()),
144        });
145    }
146    let outcome = outcome_for(&rows);
147    Ok((rows, outcome))
148}
149
150#[must_use]
151pub fn outcome_for(rows: &[SourceRow]) -> RefreshOutcome {
152    if rows.iter().any(|row| row.changed) {
153        RefreshOutcome::Changed
154    } else {
155        RefreshOutcome::Unchanged
156    }
157}
158
159async fn swap_sources(
160    profile: &Profile,
161    cache: &BundleCache,
162    before: &ServicesBundleState,
163    ctx: &CommandContext,
164) -> Result<(Vec<SourceRow>, RefreshOutcome, Vec<ReconcileRow>)> {
165    let root = ServicesSourceBootstrap::resolve(profile, lookup_secret, env!("CARGO_PKG_VERSION"))
166        .await
167        .context("Failed to resolve the services bundle sources")?;
168
169    let after = cache.read_state();
170    let rows = diff_states(before, &after);
171    let outcome = outcome_for(&rows);
172
173    let reconciled = if outcome == RefreshOutcome::Changed {
174        reconcile_after_swap(profile, &root, cache, ctx).await?
175    } else {
176        Vec::new()
177    };
178    Ok((rows, outcome, reconciled))
179}
180
181#[must_use]
182pub fn diff_states(before: &ServicesBundleState, after: &ServicesBundleState) -> Vec<SourceRow> {
183    after
184        .sources
185        .iter()
186        .map(|(name, state)| {
187            let previous = before.sources.get(name);
188            SourceRow {
189                name: name.clone(),
190                previous_digest: previous.map_or_else(String::new, |s| s.digest.clone()),
191                changed: previous.is_none_or(|s| s.digest != state.digest),
192                new_digest: state.digest.clone(),
193                version: state.version.clone(),
194            }
195        })
196        .collect()
197}
198
199fn lookup_secret(name: &str) -> Option<String> {
200    SecretsBootstrap::get()
201        .ok()
202        .and_then(|secrets| secrets.get(name).cloned())
203}