Skip to main content

magi/
updater.rs

1//! Self-update, via `kaishin`.
2//!
3//! A magi run takes minutes of agent latency, so a background release check
4//! costs nothing measurable: it is spawned on the same tokio runtime as the
5//! command, overlaps it, and is drained with a bounded wait at shutdown. It
6//! never delays the graph.
7use std::path::PathBuf;
8use std::time::Duration;
9
10use anyhow::Result;
11
12use crate::config::{Update, UpdateMode};
13
14/// Env kill-switch. Any non-empty value other than `0` / `false` disables the
15/// background check, and it is read before the config so a broken `magi.toml`
16/// cannot force a network call.
17pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
18
19/// Default interval between checks.
20pub fn default_interval() -> Duration {
21    kaishin::default_interval()
22}
23
24/// Is the background check switched off by the environment?
25pub fn disabled_by_env() -> bool {
26    match std::env::var(NO_AUTOUPDATE_ENV) {
27        Ok(v) => {
28            let v = v.trim();
29            !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
30        }
31        Err(_) => false,
32    }
33}
34
35/// GitHub owner.
36const OWNER: &str = "yukimemi";
37/// GitHub repository — *not* `CARGO_PKG_NAME`, which is the published package.
38const REPO: &str = "magi";
39/// Binary inside the release asset.
40const BIN: &str = "magi";
41/// Published package name, for kaishin's `cargo install` fallback.
42const CRATE: &str = "magi-cli";
43
44/// kaishin options.
45///
46/// All four names are spelled out because three of them differ from
47/// `CARGO_PKG_NAME`: the package is `magi-cli` (the short name is a squatted
48/// placeholder on crates.io) while the repo, the binary and the library are
49/// `magi`. Deriving any of these from `CARGO_PKG_NAME` would send the updater
50/// looking for a `yukimemi/magi-cli` repository that does not exist.
51fn options() -> kaishin::KaishinOptions {
52    kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
53}
54
55/// Throttle bookkeeping is transient, so it belongs in the cache dir rather
56/// than beside the run history in the data dir.
57fn state_path() -> Option<PathBuf> {
58    dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
59}
60
61/// `magi self-update`.
62pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
63    let opts = kaishin::UpdateOptions::new()
64        .yes(yes)
65        .check_only(check_only)
66        .non_interactive(non_interactive);
67    kaishin::run_self_update(&options(), opts).await
68}
69
70/// A background update check, resolved at shutdown.
71pub enum Pending {
72    /// A previous run already found a newer release; just print the banner.
73    Cached {
74        /// For [`Checker::format_banner`].
75        checker: Checker,
76        /// The release found earlier.
77        latest: kaishin::LatestRelease,
78    },
79    /// A notify-mode check is in flight.
80    Notify {
81        /// For [`Checker::format_banner`].
82        checker: Checker,
83        /// The spawned task.
84        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
85    },
86    /// An install-mode update is in flight.
87    Install {
88        /// The spawned task.
89        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
90    },
91}
92
93/// Throttled release checker.
94#[derive(Clone)]
95pub struct Checker {
96    inner: kaishin::Checker,
97}
98
99impl Checker {
100    /// Build a checker honouring `cfg`.
101    pub fn new(cfg: &Update) -> Option<Self> {
102        let mut inner = kaishin::Checker::new(BIN, options());
103        if let Some(path) = state_path() {
104            inner = inner.state_path(path);
105        }
106        let interval = cfg
107            .interval
108            .as_deref()
109            .and_then(|s| kaishin::parse_interval(s).ok())
110            .unwrap_or_else(default_interval);
111        Some(Self {
112            inner: inner.interval(interval),
113        })
114    }
115
116    /// Is a check due?
117    pub fn should_check(&self) -> bool {
118        self.inner.should_check()
119    }
120
121    /// Ask the forge now: is there a release newer than this build?
122    ///
123    /// Unlike [`Checker::cached_update`] this is not throttled, because the
124    /// caller is an operator who just pressed a button and is owed an answer
125    /// about the state of the world rather than about the last time magi
126    /// looked.
127    pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
128        self.inner.check_and_save().await
129    }
130
131    /// A newer release already known from a previous run.
132    pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
133        self.inner.cached_update()
134    }
135
136    /// One-line "a newer version exists" banner.
137    pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
138        self.inner.format_banner(latest)
139    }
140}
141
142/// Spawn the background check for `cfg`, unless it is switched off.
143pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
144    if disabled_by_env() || cfg.mode == UpdateMode::Off {
145        return None;
146    }
147    let checker = Checker::new(cfg)?;
148    match cfg.mode {
149        UpdateMode::Off => None,
150        UpdateMode::Notify => {
151            if !checker.should_check() {
152                let latest = checker.cached_update()?;
153                return Some(Pending::Cached { checker, latest });
154            }
155            let inner = checker.inner.clone();
156            let handle = rt.spawn(async move { inner.check_and_save().await });
157            Some(Pending::Notify { checker, handle })
158        }
159        UpdateMode::Install => {
160            let inner = checker.inner.clone();
161            let handle = rt.spawn(async move { inner.auto_update().await });
162            Some(Pending::Install { handle })
163        }
164    }
165}
166
167/// Drain a pending check and print at most one line.
168///
169/// Bounded on purpose: a slow network must never hold up the exit of a command
170/// that already did its work.
171pub async fn finalize(pending: Option<Pending>, budget: Duration) {
172    let Some(pending) = pending else {
173        return;
174    };
175    match pending {
176        Pending::Cached { checker, latest } => {
177            eprintln!("{}", checker.format_banner(&latest));
178        }
179        Pending::Notify { checker, handle } => {
180            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
181                eprintln!("{}", checker.format_banner(&latest));
182            }
183        }
184        Pending::Install { handle } => {
185            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
186                eprintln!("magi updated itself to {}", latest.tag_name);
187            }
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn env_kill_switch_semantics() {
198        // SAFETY: single-threaded test, no other thread reads the variable.
199        unsafe {
200            std::env::remove_var(NO_AUTOUPDATE_ENV);
201        }
202        assert!(!disabled_by_env());
203        for (value, disabled) in [
204            ("1", true),
205            ("true", true),
206            ("yes", true),
207            ("0", false),
208            ("false", false),
209            ("FALSE", false),
210            ("", false),
211            ("  ", false),
212        ] {
213            unsafe {
214                std::env::set_var(NO_AUTOUPDATE_ENV, value);
215            }
216            assert_eq!(
217                disabled_by_env(),
218                disabled,
219                "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
220                if disabled { "" } else { "not" }
221            );
222        }
223        unsafe {
224            std::env::remove_var(NO_AUTOUPDATE_ENV);
225        }
226    }
227
228    #[test]
229    fn off_mode_never_spawns() {
230        let rt = tokio::runtime::Builder::new_current_thread()
231            .enable_all()
232            .build()
233            .unwrap();
234        let cfg = Update {
235            mode: UpdateMode::Off,
236            interval: None,
237        };
238        assert!(spawn(&cfg, rt.handle()).is_none());
239    }
240
241    #[test]
242    fn state_path_lives_under_the_cache_dir() {
243        let path = state_path().expect("a cache dir on every supported platform");
244        assert!(path.ends_with("magi/last_update_check.json"));
245        let data = dirs::data_local_dir().unwrap_or_default();
246        assert!(
247            !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
248            "throttle state must not sit in the run history directory"
249        );
250    }
251
252    #[tokio::test]
253    async fn finalize_of_nothing_is_a_no_op() {
254        finalize(None, Duration::from_millis(1)).await;
255    }
256}