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::{Path, PathBuf};
8use std::time::Duration;
9
10use anyhow::{Context, Result};
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13
14use crate::config::{Update, UpdateMode};
15
16/// Env kill-switch. Any non-empty value other than `0` / `false` disables the
17/// background check, and it is read before the config so a broken `magi.toml`
18/// cannot force a network call.
19pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
20
21/// Default interval between checks.
22pub fn default_interval() -> Duration {
23    kaishin::default_interval()
24}
25
26/// Floor under [`effective_interval`], matching GitHub's unauthenticated rate
27/// limit for the releases API (60 requests/hour/address).
28///
29/// A one-off CLI invocation honouring a shorter configured interval could
30/// only ever make one call per process, so it was never at risk of tripping
31/// that limit on its own. The background recheck loop in `web.rs` is
32/// different: it polls for as long as `magi web` stays up, so an interval
33/// configured well below an hour would have it repeat the same call for as
34/// long as the deck runs - a shared floor here is what keeps it, and every
35/// other caller of [`Checker::new`], inside the budget regardless.
36const MIN_INTERVAL: Duration = Duration::from_secs(60);
37
38/// The interval `cfg` configures, floored at [`MIN_INTERVAL`], or
39/// [`default_interval`] when unset or unparsable.
40///
41/// Shared by [`Checker::new`], which throttles the network call itself
42/// against it, and the background recheck loop in `web.rs`, which uses it to
43/// decide how often to even ask - it cannot track an interval it never sees.
44pub fn effective_interval(cfg: &Update) -> Duration {
45    let interval = cfg
46        .interval
47        .as_deref()
48        .and_then(|s| kaishin::parse_interval(s).ok())
49        .unwrap_or_else(default_interval);
50    interval.max(MIN_INTERVAL)
51}
52
53/// Is the background check switched off by the environment?
54pub fn disabled_by_env() -> bool {
55    match std::env::var(NO_AUTOUPDATE_ENV) {
56        Ok(v) => {
57            let v = v.trim();
58            !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
59        }
60        Err(_) => false,
61    }
62}
63
64/// GitHub owner.
65const OWNER: &str = "yukimemi";
66/// GitHub repository — *not* `CARGO_PKG_NAME`, which is the published package.
67const REPO: &str = "magi";
68/// Binary inside the release asset.
69const BIN: &str = "magi";
70/// Published package name, for kaishin's `cargo install` fallback.
71const CRATE: &str = "magi-cli";
72
73/// kaishin options.
74///
75/// All four names are spelled out because three of them differ from
76/// `CARGO_PKG_NAME`: the package is `magi-cli` (the short name is a squatted
77/// placeholder on crates.io) while the repo, the binary and the library are
78/// `magi`. Deriving any of these from `CARGO_PKG_NAME` would send the updater
79/// looking for a `yukimemi/magi-cli` repository that does not exist.
80fn options() -> kaishin::KaishinOptions {
81    kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
82}
83
84/// Throttle bookkeeping is transient, so it belongs in the cache dir rather
85/// than beside the run history in the data dir.
86fn state_path() -> Option<PathBuf> {
87    dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
88}
89
90/// `magi self-update`.
91pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
92    let opts = kaishin::UpdateOptions::new()
93        .yes(yes)
94        .check_only(check_only)
95        .non_interactive(non_interactive);
96    kaishin::run_self_update(&options(), opts).await
97}
98
99/// A background update check, resolved at shutdown.
100pub enum Pending {
101    /// A previous run already found a newer release; just print the banner.
102    Cached {
103        /// For [`Checker::format_banner`].
104        checker: Checker,
105        /// The release found earlier.
106        latest: kaishin::LatestRelease,
107    },
108    /// A notify-mode check is in flight.
109    Notify {
110        /// For [`Checker::format_banner`].
111        checker: Checker,
112        /// The spawned task.
113        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
114    },
115    /// An install-mode update is in flight.
116    Install {
117        /// The spawned task.
118        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
119    },
120}
121
122/// Throttled release checker.
123#[derive(Clone)]
124pub struct Checker {
125    inner: kaishin::Checker,
126}
127
128impl Checker {
129    /// Build a checker honouring `cfg`, or `None` when checking is off.
130    ///
131    /// The `Option` had no `None` arm: every caller that asked for a checker
132    /// got one, so `[update] mode = "off"` was honoured by the *notify* path
133    /// alone (see [`cached_update`], which matches on the mode itself) and
134    /// ignored everywhere else. `POST /api/upgrade` therefore called the
135    /// GitHub releases API on a deck configured never to check - and so did
136    /// every unit test that reached that route, unauthenticated, against
137    /// GitHub's 60-per-hour-per-address limit.
138    ///
139    /// An operator who writes `mode = "off"` means it. The button is still
140    /// theirs to press; what it may not do is go to the network behind a
141    /// configuration that says not to.
142    pub fn new(cfg: &Update) -> Option<Self> {
143        if cfg.mode == UpdateMode::Off {
144            return None;
145        }
146        let mut inner = kaishin::Checker::new(BIN, options());
147        if let Some(path) = state_path() {
148            inner = inner.state_path(path);
149        }
150        Some(Self {
151            inner: inner.interval(effective_interval(cfg)),
152        })
153    }
154
155    /// Is a check due?
156    pub fn should_check(&self) -> bool {
157        self.inner.should_check()
158    }
159
160    /// Ask the forge now: is there a release newer than this build?
161    ///
162    /// Unlike [`Checker::cached_update`] this method does not consult
163    /// [`Checker::should_check`] itself - it has two callers, and they throttle
164    /// differently. `POST /api/upgrade` calls it unconditionally, because the
165    /// caller there is an operator who just pressed a button and is owed an
166    /// answer about the state of the world rather than about the last time
167    /// magi looked. `magi web`'s background recheck (`web::run_update_recheck`)
168    /// calls [`Checker::should_check`] itself first and only reaches here when
169    /// it says yes, which is what keeps that task's network use to at most
170    /// once per `[update] interval` no matter how often it polls.
171    pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
172        self.inner.check_and_save().await
173    }
174
175    /// A newer release already known from a previous run.
176    pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
177        self.inner.cached_update()
178    }
179
180    /// One-line "a newer version exists" banner.
181    pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
182        self.inner.format_banner(latest)
183    }
184
185    /// A checker over an explicit state path and interval, for a test that
186    /// must control throttle timing without touching the operator's real
187    /// cache directory - see [`state_path`] for why sharing it would be
188    /// unsafe.
189    #[cfg(test)]
190    pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
191        let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
192        Self {
193            inner: kaishin::Checker::new(BIN, opts)
194                .state_path(state_path)
195                .interval(interval),
196        }
197    }
198}
199
200/// How far a self-upgrade this deck set in motion has gotten.
201///
202/// `POST /api/upgrade` answers `202` and returns immediately - see its own
203/// doc for why - so [`Progress`] is the only way a phone that asked for an
204/// upgrade learns anything about it afterwards.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum Stage {
208    /// Downloading the release asset and replacing the binary. This bundles
209    /// what would otherwise be two stages: kaishin re-confirms the release
210    /// and downloads it inside one `await` with no hook to split, so from
211    /// here a phone cannot tell "still checking" from "still downloading" -
212    /// only that nothing has been swapped in yet. The confirmation that ran
213    /// *before* this stage started is already known to the phone: it is what
214    /// the `to` version in the `202` answered with.
215    Downloading,
216    /// The new binary is in place and [`crate::web`]'s handover has been
217    /// signalled, but has not acted yet.
218    Replaced,
219    /// The handover is waiting for the run in flight, if any, to reach its
220    /// next node boundary. The deck answers throughout this - it is not the
221    /// unreachable gap, see `web::hand_over`.
222    Parking,
223    /// The listener has been released and the successor is starting. This is
224    /// the one genuinely unreachable moment, and it is meant to be
225    /// sub-second - see `web::bind_waiting`.
226    Restarting,
227    /// A successor came up and confirmed it is running the release this
228    /// upgrade asked for.
229    Done,
230    /// The upgrade did not reach [`Stage::Done`]. `detail` on [`Progress`]
231    /// says why.
232    Failed,
233}
234
235impl Stage {
236    /// Finished, one way or the other - nothing is still moving.
237    #[must_use]
238    pub fn terminal(self) -> bool {
239        matches!(self, Self::Done | Self::Failed)
240    }
241}
242
243/// One upgrade's progress, persisted at [`progress_path`].
244///
245/// Kept on disk rather than in memory because the process that finishes an
246/// upgrade is never the one that started it: the successor is a fresh binary
247/// (see `web::spawn_successor`), and the only thing the two share is the
248/// disk. Beside the run history rather than under the cache dir alongside
249/// [`state_path`]: this is not throttle bookkeeping, it is the record of one
250/// upgrade the operator asked for, and - like a parked run - it is meant to
251/// outlive the process that wrote it.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct Progress {
254    /// Where this upgrade has gotten to.
255    pub stage: Stage,
256    /// Version this upgrade started from.
257    pub from: String,
258    /// Version it is replacing itself with.
259    pub to: Option<String>,
260    /// The run [`Stage::Parking`] is waiting on, when one was in flight.
261    #[serde(default)]
262    pub parked_run: Option<String>,
263    /// When this upgrade was asked for.
264    pub started_at: Timestamp,
265    /// Last time `stage` changed.
266    pub updated_at: Timestamp,
267    /// Why [`Stage::Failed`] happened; `None` for every other stage.
268    #[serde(default)]
269    pub detail: Option<String>,
270}
271
272impl Progress {
273    /// A fresh record for an upgrade that is about to replace the binary.
274    #[must_use]
275    pub fn new(from: String, to: String) -> Self {
276        let now = Timestamp::now();
277        Self {
278            stage: Stage::Downloading,
279            from,
280            to: Some(to),
281            parked_run: None,
282            started_at: now,
283            updated_at: now,
284            detail: None,
285        }
286    }
287
288    /// Move to `stage`, stamping when it changed.
289    pub fn advance(&mut self, stage: Stage) {
290        self.stage = stage;
291        self.updated_at = Timestamp::now();
292    }
293
294    /// Stop at [`Stage::Failed`], with a reason a human can read.
295    pub fn fail(&mut self, detail: impl Into<String>) {
296        self.stage = Stage::Failed;
297        self.updated_at = Timestamp::now();
298        self.detail = Some(detail.into());
299    }
300}
301
302/// Where [`Progress`] is recorded: beside `daemon.json`, not under the cache
303/// dir - see [`Progress`]'s own doc for why the two are not the same place.
304#[must_use]
305pub fn progress_path(home: &Path) -> PathBuf {
306    home.join("upgrade.json")
307}
308
309/// Persist `progress`, atomically.
310///
311/// Written to a sibling `.tmp` and renamed, the same reason
312/// `daemon::write_status_to` does it: `/api/health` reads this file on every
313/// poll and must never see a half-written one.
314pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
315    let path = progress_path(home);
316    if let Some(parent) = path.parent() {
317        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
318    }
319    let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
320    let tmp = path.with_extension("json.tmp");
321    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
322    std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
323    Ok(())
324}
325
326/// The last upgrade this deck recorded, if it has ever started one.
327#[must_use]
328pub fn read_progress(home: &Path) -> Option<Progress> {
329    let body = std::fs::read_to_string(progress_path(home)).ok()?;
330    serde_json::from_str(&body).ok()
331}
332
333/// Reconcile a leftover progress record on startup, before the server starts
334/// answering requests.
335///
336/// A non-terminal record on disk when a process starts can only mean one of
337/// two things: this *is* the successor `spawn_successor` started, or the
338/// predecessor died before finishing the handover (a crash, a reboot, an
339/// operator killing it by hand). Either way waiting longer will not resolve
340/// it - this process is already up - so it is settled immediately:
341/// [`Stage::Done`] when the running version matches what was asked for,
342/// [`Stage::Failed`] otherwise, so the operator is told rather than left
343/// watching a stage that will never move again.
344pub fn reconcile_after_restart(home: &Path) {
345    let Some(mut progress) = read_progress(home) else {
346        return;
347    };
348    if progress.stage.terminal() {
349        return;
350    }
351    let running = env!("CARGO_PKG_VERSION");
352    // `progress.to` is `latest.tag_name` from the forge, which - like every
353    // tag in this repository - carries a `v` prefix `CARGO_PKG_VERSION` does
354    // not. kaishin's own `is_update_available` strips it before comparing;
355    // an exact-string match here would call a successful upgrade `Failed`
356    // every time, because "v0.5.2" is never equal to "0.5.2".
357    if progress
358        .to
359        .as_deref()
360        .is_some_and(|to| to.trim_start_matches('v') == running)
361    {
362        progress.advance(Stage::Done);
363    } else {
364        let to = progress
365            .to
366            .clone()
367            .unwrap_or_else(|| "the expected release".to_owned());
368        progress.fail(format!(
369            "this process came up on {running}, not {to} - the upgrade may \
370             not have replaced the binary"
371        ));
372    }
373    let _ = write_progress(home, &progress);
374}
375
376/// Spawn the background check for `cfg`, unless it is switched off.
377pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
378    if disabled_by_env() || cfg.mode == UpdateMode::Off {
379        return None;
380    }
381    let checker = Checker::new(cfg)?;
382    match cfg.mode {
383        UpdateMode::Off => None,
384        UpdateMode::Notify => {
385            if !checker.should_check() {
386                let latest = checker.cached_update()?;
387                return Some(Pending::Cached { checker, latest });
388            }
389            let inner = checker.inner.clone();
390            let handle = rt.spawn(async move { inner.check_and_save().await });
391            Some(Pending::Notify { checker, handle })
392        }
393        UpdateMode::Install => {
394            let inner = checker.inner.clone();
395            let handle = rt.spawn(async move { inner.auto_update().await });
396            Some(Pending::Install { handle })
397        }
398    }
399}
400
401/// Drain a pending check and print at most one line.
402///
403/// Bounded on purpose: a slow network must never hold up the exit of a command
404/// that already did its work.
405pub async fn finalize(pending: Option<Pending>, budget: Duration) {
406    let Some(pending) = pending else {
407        return;
408    };
409    match pending {
410        Pending::Cached { checker, latest } => {
411            eprintln!("{}", checker.format_banner(&latest));
412        }
413        Pending::Notify { checker, handle } => {
414            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
415                eprintln!("{}", checker.format_banner(&latest));
416            }
417        }
418        Pending::Install { handle } => {
419            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
420                eprintln!("magi updated itself to {}", latest.tag_name);
421            }
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    /// An interval configured below GitHub's unauthenticated 60 req/hour/IP
431    /// limit must be floored, or `web`'s background recheck loop - which,
432    /// unlike a one-off CLI invocation, keeps polling for as long as `magi
433    /// web` stays up - would repeat the same call far past that limit.
434    #[test]
435    fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
436        let cfg = Update {
437            mode: UpdateMode::Notify,
438            interval: Some("1s".to_owned()),
439        };
440        assert_eq!(
441            effective_interval(&cfg),
442            MIN_INTERVAL,
443            "an interval that would exceed GitHub's rate limit under continuous \
444             polling must be floored rather than honoured verbatim"
445        );
446
447        let sane = Update {
448            mode: UpdateMode::Notify,
449            interval: Some("2h".to_owned()),
450        };
451        assert_eq!(
452            effective_interval(&sane),
453            Duration::from_secs(2 * 60 * 60),
454            "an interval already above the floor must pass through unchanged"
455        );
456    }
457
458    #[test]
459    fn env_kill_switch_semantics() {
460        // SAFETY: single-threaded test, no other thread reads the variable.
461        unsafe {
462            std::env::remove_var(NO_AUTOUPDATE_ENV);
463        }
464        assert!(!disabled_by_env());
465        for (value, disabled) in [
466            ("1", true),
467            ("true", true),
468            ("yes", true),
469            ("0", false),
470            ("false", false),
471            ("FALSE", false),
472            ("", false),
473            ("  ", false),
474        ] {
475            unsafe {
476                std::env::set_var(NO_AUTOUPDATE_ENV, value);
477            }
478            assert_eq!(
479                disabled_by_env(),
480                disabled,
481                "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
482                if disabled { "" } else { "not" }
483            );
484        }
485        unsafe {
486            std::env::remove_var(NO_AUTOUPDATE_ENV);
487        }
488    }
489
490    #[test]
491    fn off_mode_never_spawns() {
492        let rt = tokio::runtime::Builder::new_current_thread()
493            .enable_all()
494            .build()
495            .unwrap();
496        let cfg = Update {
497            mode: UpdateMode::Off,
498            interval: None,
499        };
500        assert!(spawn(&cfg, rt.handle()).is_none());
501    }
502
503    #[test]
504    fn state_path_lives_under_the_cache_dir() {
505        let path = state_path().expect("a cache dir on every supported platform");
506        assert!(path.ends_with("magi/last_update_check.json"));
507        let data = dirs::data_local_dir().unwrap_or_default();
508        assert!(
509            !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
510            "throttle state must not sit in the run history directory"
511        );
512    }
513
514    #[tokio::test]
515    async fn finalize_of_nothing_is_a_no_op() {
516        finalize(None, Duration::from_millis(1)).await;
517    }
518
519    /// `mode = "off"` means no checker, for every caller.
520    ///
521    /// It used to mean it only for the notify path: `Checker::new` returned
522    /// `Some` unconditionally, so `POST /api/upgrade` went to the GitHub
523    /// releases API on a deck configured never to check. A test that reached
524    /// that route made a live, unauthenticated request, and GitHub's
525    /// 60-per-hour-per-address limit then turned the suite red on one runner
526    /// at a time - for as long as anyone kept re-running it, since each
527    /// attempt spent another request.
528    #[test]
529    fn checking_is_off_for_every_caller_when_the_config_says_off() {
530        assert!(
531            Checker::new(&Update {
532                mode: UpdateMode::Off,
533                interval: None,
534            })
535            .is_none(),
536            "an operator who writes mode = \"off\" means it"
537        );
538        for mode in [UpdateMode::Notify, UpdateMode::Install] {
539            assert!(
540                Checker::new(&Update {
541                    mode,
542                    interval: None,
543                })
544                .is_some(),
545                "{mode:?} still asks the forge"
546            );
547        }
548    }
549
550    /// `cached_update` never touches the network: a state file written the
551    /// way `check_and_save` writes one is enough to answer, and no file at
552    /// all answers "unknown" rather than blocking or erroring.
553    ///
554    /// Built from `kaishin::Checker` directly, with an explicit state path,
555    /// rather than through [`Checker::new`]: that constructor always points
556    /// at the real cache directory, which is right for production - every
557    /// `magi` invocation on the machine shares one throttle file - but wrong
558    /// for a test, which must never read or write the operator's actual
559    /// state.
560    #[test]
561    fn cached_update_answers_from_disk_with_no_network_call() {
562        let dir = tempfile::tempdir().expect("temp dir");
563        let path = dir.path().join("state.json");
564        let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
565        let checker = Checker {
566            inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
567        };
568
569        assert!(
570            checker.cached_update().is_none(),
571            "no state file yet must read as \"unknown\", not an error"
572        );
573
574        let state = kaishin::UpdateCheckState {
575            last_checked_unix: 0,
576            last_known_latest: Some("v9.9.9".to_owned()),
577            last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
578        };
579        kaishin::save_check_state(&path, &state).expect("seed the state file");
580
581        let latest = checker.cached_update().expect("a newer release was cached");
582        assert_eq!(latest.tag_name, "v9.9.9");
583    }
584
585    #[test]
586    fn reconcile_after_restart_confirms_a_matching_version() {
587        // `to` is `latest.tag_name` as the forge and this repository's own
588        // tags spell it - with a `v` - which `CARGO_PKG_VERSION` never
589        // carries. A test that leaves the `v` off would not have caught the
590        // exact-string-equality bug this function used to have.
591        let home = tempfile::tempdir().expect("temp home");
592        let mut progress = Progress::new(
593            "0.1.0".to_owned(),
594            format!("v{}", env!("CARGO_PKG_VERSION")),
595        );
596        progress.advance(Stage::Restarting);
597        write_progress(home.path(), &progress).expect("seed progress");
598
599        reconcile_after_restart(home.path());
600
601        let after = read_progress(home.path()).expect("progress on disk");
602        assert_eq!(
603            after.stage,
604            Stage::Done,
605            "the successor is running exactly the release that was asked for, \
606             `v` prefix and all"
607        );
608    }
609
610    #[test]
611    fn reconcile_after_restart_flags_a_mismatched_version() {
612        let home = tempfile::tempdir().expect("temp home");
613        let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
614        progress.advance(Stage::Restarting);
615        write_progress(home.path(), &progress).expect("seed progress");
616
617        reconcile_after_restart(home.path());
618
619        let after = read_progress(home.path()).expect("progress on disk");
620        assert_eq!(after.stage, Stage::Failed);
621        assert!(
622            after.detail.is_some_and(|d| d.contains("9.9.9")),
623            "the operator needs to know which release it did not come back on"
624        );
625    }
626
627    #[test]
628    fn reconcile_after_restart_leaves_a_settled_record_alone() {
629        let home = tempfile::tempdir().expect("temp home");
630        let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
631        progress.advance(Stage::Done);
632        write_progress(home.path(), &progress).expect("seed progress");
633
634        reconcile_after_restart(home.path());
635
636        let after = read_progress(home.path()).expect("progress on disk");
637        assert_eq!(
638            after.stage,
639            Stage::Done,
640            "an already-settled record must not be rewritten by a later, unrelated start"
641        );
642    }
643
644    #[test]
645    fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
646        let home = tempfile::tempdir().expect("temp home");
647        reconcile_after_restart(home.path());
648        assert!(read_progress(home.path()).is_none());
649    }
650}