Files
omnigent-ai--omnigent/dev/omnidev/src/update_check.rs
T
2026-07-13 13:12:00 +08:00

229 lines
7.6 KiB
Rust

//! Daily update check for a git-installed omnigent.
//!
//! Fills a real gap: omnigent's own update notice only works for PyPI-wheel
//! installs and bails on VCS installs. The hot path (`check`) never blocks on
//! the network — it reads a cache and spawns a detached `refresh` when stale.
use std::io::{IsTerminal, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::install::{self, InstallConfig};
use crate::paths;
const STALE_SECS: u64 = 24 * 60 * 60;
const LS_REMOTE_TIMEOUT_SECS: u64 = 5;
/// Volatile update-check state cached between runs.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CheckCache {
#[serde(default)]
pub last_checked: u64,
#[serde(default)]
pub remote_sha: Option<String>,
#[serde(default)]
pub installed_sha: Option<String>,
/// The remote sha we already prompted about, so a declined update isn't
/// re-nagged until a newer commit lands.
#[serde(default)]
pub last_prompted_sha: Option<String>,
}
impl CheckCache {
pub fn load() -> CheckCache {
let Ok(path) = paths::check_cache_path() else {
return CheckCache::default();
};
std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
let path = paths::check_cache_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let text = serde_json::to_string_pretty(self).context("serializing check cache")?;
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
}
/// Whether the cache indicates an update the user hasn't already declined.
/// Pure so it can be unit-tested without touching disk or the network.
///
/// `installed` is the best-known installed commit (dist-info first, else the
/// cached `installed_sha`). An update is available when we have a remote sha
/// that differs from what's installed and that we haven't already prompted for.
pub fn update_available(cache: &CheckCache, installed: Option<&str>) -> bool {
let Some(remote) = cache.remote_sha.as_deref() else {
return false;
};
if Some(remote) == installed {
return false;
}
if cache.last_prompted_sha.as_deref() == Some(remote) {
return false;
}
true
}
/// Whether `last_checked` is older than the staleness window.
pub fn is_stale(cache: &CheckCache, now: u64) -> bool {
now.saturating_sub(cache.last_checked) > STALE_SECS
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The remote HEAD sha of `git_ref` in `repo`, via `git ls-remote` (targets the
/// remote, so no local checkout is needed). `None` on any failure/timeout.
pub fn remote_sha(repo: &str, git_ref: &str) -> Option<String> {
// `timeout` isn't portable (absent on macOS by default), so bound the call
// with git's own connect timeout and a wait guard instead.
let mut child = Command::new("git")
.args(["ls-remote", repo, git_ref])
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.ok()?;
let deadline = SystemTime::now() + Duration::from_secs(LS_REMOTE_TIMEOUT_SECS);
loop {
match child.try_wait().ok()? {
Some(_) => break,
None => {
if SystemTime::now() > deadline {
let _ = child.kill();
return None;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
// First whitespace-delimited token of the first line is the sha.
text.lines()
.next()
.and_then(|l| l.split_whitespace().next())
.map(str::to_string)
}
/// Record the installed sha into the cache (called after install/update).
pub fn set_installed_sha(sha: &str) -> Result<()> {
let mut cache = CheckCache::load();
cache.installed_sha = Some(sha.to_string());
cache.save()
}
/// `refresh` subcommand: hit the network, update `remote_sha` + `last_checked`.
/// Invoked detached by `check`, but also runnable directly.
pub fn refresh() -> Result<()> {
let config = InstallConfig::load()?.unwrap_or_default();
let mut cache = CheckCache::load();
cache.remote_sha = remote_sha(&config.repo, &config.git_ref);
cache.last_checked = now_epoch();
cache.save()
}
/// Best-known installed commit: the tool's dist-info first (authoritative),
/// else the sha we recorded at install time.
fn installed_commit(cache: &CheckCache) -> Option<String> {
install::installed_commit().or_else(|| cache.installed_sha.clone())
}
/// `check` subcommand: the fast hook primitive. Never blocks on the network.
///
/// - Stale cache ⇒ spawn a detached `refresh` and return.
/// - An available update ⇒ notice; on a TTY, prompt and update in the
/// foreground on yes, else record the decline.
/// - `quiet` suppresses the "up to date" path so shell startup stays silent.
pub fn check(quiet: bool) -> Result<()> {
let cache = CheckCache::load();
if is_stale(&cache, now_epoch()) {
spawn_detached_refresh();
// Still evaluate against whatever we already had cached.
}
let installed = installed_commit(&cache);
if !update_available(&cache, installed.as_deref()) {
if !quiet {
println!("omnigent is up to date.");
}
return Ok(());
}
let remote = cache.remote_sha.clone().unwrap_or_default();
let short = |s: &str| s.chars().take(8).collect::<String>();
let installed_desc = installed
.as_deref()
.map(short)
.unwrap_or_else(|| "unknown".to_string());
eprintln!(
"omnigent update available: {}{} (git)",
installed_desc,
short(&remote),
);
// Only prompt on an interactive terminal; scripts/CI just see the notice.
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
return Ok(());
}
if prompt_yes_no("Update omnigent now? [y/N] ") {
install::update()?;
} else {
// Don't re-nag for this same commit.
let mut cache = CheckCache::load();
cache.last_prompted_sha = Some(remote);
cache.save()?;
}
Ok(())
}
/// Prompt on the controlling terminal. Reads from `/dev/tty` so it works even
/// when the hook's stdin is redirected. Any read failure ⇒ treated as "no".
fn prompt_yes_no(prompt: &str) -> bool {
use std::io::BufRead;
let Ok(tty) = std::fs::OpenOptions::new().read(true).open("/dev/tty") else {
return false;
};
eprint!("{prompt}");
let _ = std::io::stderr().flush();
let mut line = String::new();
if std::io::BufReader::new(tty).read_line(&mut line).is_err() {
return false;
}
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
/// Launch `omnidev refresh` fully detached so shell startup never waits on it.
fn spawn_detached_refresh() {
let Ok(exe) = std::env::current_exe() else {
return;
};
let _ = Command::new(exe)
.arg("refresh")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}