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`, or `None` when checking is off.
101    ///
102    /// The `Option` had no `None` arm: every caller that asked for a checker
103    /// got one, so `[update] mode = "off"` was honoured by the *notify* path
104    /// alone (see [`cached_update`], which matches on the mode itself) and
105    /// ignored everywhere else. `POST /api/upgrade` therefore called the
106    /// GitHub releases API on a deck configured never to check - and so did
107    /// every unit test that reached that route, unauthenticated, against
108    /// GitHub's 60-per-hour-per-address limit.
109    ///
110    /// An operator who writes `mode = "off"` means it. The button is still
111    /// theirs to press; what it may not do is go to the network behind a
112    /// configuration that says not to.
113    pub fn new(cfg: &Update) -> Option<Self> {
114        if cfg.mode == UpdateMode::Off {
115            return None;
116        }
117        let mut inner = kaishin::Checker::new(BIN, options());
118        if let Some(path) = state_path() {
119            inner = inner.state_path(path);
120        }
121        let interval = cfg
122            .interval
123            .as_deref()
124            .and_then(|s| kaishin::parse_interval(s).ok())
125            .unwrap_or_else(default_interval);
126        Some(Self {
127            inner: inner.interval(interval),
128        })
129    }
130
131    /// Is a check due?
132    pub fn should_check(&self) -> bool {
133        self.inner.should_check()
134    }
135
136    /// Ask the forge now: is there a release newer than this build?
137    ///
138    /// Unlike [`Checker::cached_update`] this is not throttled, because the
139    /// caller is an operator who just pressed a button and is owed an answer
140    /// about the state of the world rather than about the last time magi
141    /// looked.
142    pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
143        self.inner.check_and_save().await
144    }
145
146    /// A newer release already known from a previous run.
147    pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
148        self.inner.cached_update()
149    }
150
151    /// One-line "a newer version exists" banner.
152    pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
153        self.inner.format_banner(latest)
154    }
155}
156
157/// Spawn the background check for `cfg`, unless it is switched off.
158pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
159    if disabled_by_env() || cfg.mode == UpdateMode::Off {
160        return None;
161    }
162    let checker = Checker::new(cfg)?;
163    match cfg.mode {
164        UpdateMode::Off => None,
165        UpdateMode::Notify => {
166            if !checker.should_check() {
167                let latest = checker.cached_update()?;
168                return Some(Pending::Cached { checker, latest });
169            }
170            let inner = checker.inner.clone();
171            let handle = rt.spawn(async move { inner.check_and_save().await });
172            Some(Pending::Notify { checker, handle })
173        }
174        UpdateMode::Install => {
175            let inner = checker.inner.clone();
176            let handle = rt.spawn(async move { inner.auto_update().await });
177            Some(Pending::Install { handle })
178        }
179    }
180}
181
182/// Drain a pending check and print at most one line.
183///
184/// Bounded on purpose: a slow network must never hold up the exit of a command
185/// that already did its work.
186pub async fn finalize(pending: Option<Pending>, budget: Duration) {
187    let Some(pending) = pending else {
188        return;
189    };
190    match pending {
191        Pending::Cached { checker, latest } => {
192            eprintln!("{}", checker.format_banner(&latest));
193        }
194        Pending::Notify { checker, handle } => {
195            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
196                eprintln!("{}", checker.format_banner(&latest));
197            }
198        }
199        Pending::Install { handle } => {
200            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
201                eprintln!("magi updated itself to {}", latest.tag_name);
202            }
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn env_kill_switch_semantics() {
213        // SAFETY: single-threaded test, no other thread reads the variable.
214        unsafe {
215            std::env::remove_var(NO_AUTOUPDATE_ENV);
216        }
217        assert!(!disabled_by_env());
218        for (value, disabled) in [
219            ("1", true),
220            ("true", true),
221            ("yes", true),
222            ("0", false),
223            ("false", false),
224            ("FALSE", false),
225            ("", false),
226            ("  ", false),
227        ] {
228            unsafe {
229                std::env::set_var(NO_AUTOUPDATE_ENV, value);
230            }
231            assert_eq!(
232                disabled_by_env(),
233                disabled,
234                "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
235                if disabled { "" } else { "not" }
236            );
237        }
238        unsafe {
239            std::env::remove_var(NO_AUTOUPDATE_ENV);
240        }
241    }
242
243    #[test]
244    fn off_mode_never_spawns() {
245        let rt = tokio::runtime::Builder::new_current_thread()
246            .enable_all()
247            .build()
248            .unwrap();
249        let cfg = Update {
250            mode: UpdateMode::Off,
251            interval: None,
252        };
253        assert!(spawn(&cfg, rt.handle()).is_none());
254    }
255
256    #[test]
257    fn state_path_lives_under_the_cache_dir() {
258        let path = state_path().expect("a cache dir on every supported platform");
259        assert!(path.ends_with("magi/last_update_check.json"));
260        let data = dirs::data_local_dir().unwrap_or_default();
261        assert!(
262            !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
263            "throttle state must not sit in the run history directory"
264        );
265    }
266
267    #[tokio::test]
268    async fn finalize_of_nothing_is_a_no_op() {
269        finalize(None, Duration::from_millis(1)).await;
270    }
271
272    /// `mode = "off"` means no checker, for every caller.
273    ///
274    /// It used to mean it only for the notify path: `Checker::new` returned
275    /// `Some` unconditionally, so `POST /api/upgrade` went to the GitHub
276    /// releases API on a deck configured never to check. A test that reached
277    /// that route made a live, unauthenticated request, and GitHub's
278    /// 60-per-hour-per-address limit then turned the suite red on one runner
279    /// at a time - for as long as anyone kept re-running it, since each
280    /// attempt spent another request.
281    #[test]
282    fn checking_is_off_for_every_caller_when_the_config_says_off() {
283        assert!(
284            Checker::new(&Update {
285                mode: UpdateMode::Off,
286                interval: None,
287            })
288            .is_none(),
289            "an operator who writes mode = \"off\" means it"
290        );
291        for mode in [UpdateMode::Notify, UpdateMode::Install] {
292            assert!(
293                Checker::new(&Update {
294                    mode,
295                    interval: None,
296                })
297                .is_some(),
298                "{mode:?} still asks the forge"
299            );
300        }
301    }
302}