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