diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs index 122130c..a1354ac 100644 --- a/crates/hephd/src/selfupdate.rs +++ b/crates/hephd/src/selfupdate.rs @@ -85,10 +85,116 @@ pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result impl std::future::Future> + Send; +} + +/// The production source: the forge's `releases/latest` over HTTP. +pub struct ForgeReleaseSource { + http: reqwest::Client, + url: String, +} + +impl ForgeReleaseSource { + /// Source backed by the daemon's shared client, hitting [`RELEASES_LATEST_URL`]. + pub fn new(http: reqwest::Client) -> Self { + Self { + http, + url: RELEASES_LATEST_URL.to_string(), + } + } +} + +impl ReleaseSource for ForgeReleaseSource { + async fn latest_tag(&self) -> Result { + fetch_latest_tag(&self.http, &self.url).await + } +} + +/// The result of one self-update check — kept separate from logging so it can be +/// asserted in tests. +#[derive(Debug, PartialEq, Eq)] +pub enum CheckOutcome { + /// The running version is at or ahead of the latest release. + UpToDate, + /// A strictly newer release exists, named by this tag (e.g. `v1.0.4`). + UpdateAvailable(String), + /// The check failed (forge unreachable, bad body, unparseable version). + Failed(String), +} + +/// Run one check against `source`, comparing the latest tag to `current`. Never +/// returns `Err` — a failure is folded into [`CheckOutcome::Failed`] so the loop +/// keeps going (a flaky forge must never crash or stall the daemon). +pub async fn check_release(source: &S, current: &str) -> CheckOutcome { + match source.latest_tag().await { + Ok(tag) => match update_available(current, &tag) { + Ok(true) => CheckOutcome::UpdateAvailable(tag), + Ok(false) => CheckOutcome::UpToDate, + Err(e) => CheckOutcome::Failed(e.to_string()), + }, + Err(e) => CheckOutcome::Failed(e.to_string()), + } +} + +/// The background poll loop (notify-only for now): tick on `interval`, check for +/// a newer release, and log the outcome. Runs forever; spawned as a task. +pub async fn run_poll_loop(source: S, interval: Duration, current: &'static str) { + let mut tick = tokio::time::interval(interval); + loop { + tick.tick().await; + match check_release(&source, current).await { + CheckOutcome::UpdateAvailable(tag) => { + tracing::info!(%tag, current, "self-update: newer release available") + } + CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"), + CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"), + } + } +} + #[cfg(test)] mod tests { use super::*; + /// A canned release source for deterministic loop/decision tests. + struct FakeSource(Result); + impl ReleaseSource for FakeSource { + async fn latest_tag(&self) -> Result { + self.0.clone().map_err(|e| anyhow::anyhow!(e)) + } + } + + #[tokio::test] + async fn check_release_reports_outcomes_from_a_stubbed_source() { + // Newer release available. + let s = FakeSource(Ok("v1.0.4".into())); + assert_eq!( + check_release(&s, "1.0.3 (sha)").await, + CheckOutcome::UpdateAvailable("v1.0.4".into()) + ); + // Already current. + let s = FakeSource(Ok("v1.0.3".into())); + assert_eq!( + check_release(&s, "1.0.3 (sha)").await, + CheckOutcome::UpToDate + ); + // Fetch failure → folded into Failed, never a panic/Err. + let s = FakeSource(Err("forge unreachable")); + assert!(matches!( + check_release(&s, "1.0.3 (sha)").await, + CheckOutcome::Failed(_) + )); + // Malformed tag → Failed. + let s = FakeSource(Ok("not-a-tag".into())); + assert!(matches!( + check_release(&s, "1.0.3 (sha)").await, + CheckOutcome::Failed(_) + )); + } + #[test] fn config_defaults_interval_and_honors_override() { assert_eq!(SelfUpdateConfig::new(None).interval, DEFAULT_INTERVAL); diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 2b6b614..0de5278 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -21,7 +21,7 @@ use heph_core::Store; use crate::oauth::{self, TokenStore}; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; -use crate::selfupdate::SelfUpdateConfig; +use crate::selfupdate::{self, SelfUpdateConfig}; use crate::sync::{self, SharedStore}; /// How a spoke obtains the bearer token it presents to its hub (tech-spec §13). @@ -116,18 +116,22 @@ impl Daemon { self.ctx.store.clone() } - /// If self-update is enabled, start its background poller. For now this only - /// announces the mode at startup; the polling + apply cycle is wired in - /// later self-update work. No-op when the mode is off. + /// If self-update is enabled, spawn its background poller: every + /// `cfg.interval` it checks the forge for a newer release and (for now) logs + /// when one is available. No-op when the mode is off. pub fn spawn_self_update_loop(&self) { let Some(cfg) = self.ctx.self_update.clone() else { return; }; + let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone()); tracing::info!( interval_secs = cfg.interval.as_secs(), current = heph_core::VERSION, "self-update enabled" ); + tokio::spawn(async move { + selfupdate::run_poll_loop(source, cfg.interval, heph_core::VERSION).await; + }); } /// If this is a spoke (`hub_url` set), spawn a background task that syncs the