From 87e15ba0b07ae2f22fef684db90fcf0ddaf38e72 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Feb 2026 15:21:45 +0100 Subject: [PATCH 1/3] feat(scheduler): add auto-update check via GitHub Releases API (#588) Add periodic update checking as a built-in scheduler task. Zeph queries GitHub Releases API for the latest version and notifies the user when a newer release is available. - TaskKind::UpdateCheck variant with UpdateCheckHandler in zeph-scheduler - Version comparison via semver crate against CARGO_PKG_VERSION - auto_update_check config field (default: true) with ZEPH_AUTO_UPDATE_CHECK env override - One-shot check at startup when scheduler feature is disabled - Typed ReleaseInfo struct, HTTP status validation, response body limit - recv_optional handles closed channel correctly (no spinning select arm) - Init wizard updated with auto-update configuration step --- CHANGELOG.md | 5 + Cargo.lock | 2 + Cargo.toml | 1 + crates/zeph-core/src/agent/mod.rs | 23 ++- crates/zeph-core/src/config/env.rs | 5 + crates/zeph-core/src/config/tests.rs | 51 +++++- crates/zeph-core/src/config/types.rs | 8 + crates/zeph-scheduler/Cargo.toml | 2 + crates/zeph-scheduler/src/lib.rs | 2 + crates/zeph-scheduler/src/store.rs | 10 ++ crates/zeph-scheduler/src/task.rs | 13 ++ crates/zeph-scheduler/src/update_check.rs | 205 ++++++++++++++++++++++ src/init.rs | 26 ++- src/main.rs | 78 +++++++- 14 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 crates/zeph-scheduler/src/update_check.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cf41981c..1db7611e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `zeph vault` CLI subcommands: `init` (generate age keypair), `set` (store secret), `get` (retrieve secret), `list` (show keys), `rm` (remove secret) (#598) - Atomic file writes for vault operations with temp+rename strategy (#598) - Default vault directory resolution via XDG_CONFIG_HOME / APPDATA / HOME (#598) +- Auto-update check via GitHub Releases API with configurable scheduler task (#588) +- `auto_update_check` config field (default: true) with `ZEPH_AUTO_UPDATE_CHECK` env override +- `TaskKind::UpdateCheck` variant and `UpdateCheckHandler` in zeph-scheduler +- One-shot update check at startup when scheduler feature is disabled +- `--init` wizard step for auto-update check configuration ### Fixed - Restore `--vault`, `--vault-key`, `--vault-path` CLI flags lost during clap migration (#587) diff --git a/Cargo.lock b/Cargo.lock index edf628d4..a489bb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9023,6 +9023,8 @@ version = "0.11.0" dependencies = [ "chrono", "cron", + "reqwest 0.13.2", + "semver", "serde", "serde_json", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index 0e451de6..99d775ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ ratatui = "0.30" regex = "1.12" reqwest = { version = "0.13", default-features = false } rmcp = "0.15" +semver = "1.0.27" scrape-core = "0.2.2" subtle = "2.6" rubato = "0.16" diff --git a/crates/zeph-core/src/agent/mod.rs b/crates/zeph-core/src/agent/mod.rs index db6e83a7..f144d29e 100644 --- a/crates/zeph-core/src/agent/mod.rs +++ b/crates/zeph-core/src/agent/mod.rs @@ -156,6 +156,7 @@ pub struct Agent { cost_tracker: Option, cached_prompt_tokens: u64, stt: Option>, + update_notify_rx: Option>, } impl Agent { @@ -253,6 +254,7 @@ impl Agent { cost_tracker: None, cached_prompt_tokens: initial_prompt_tokens, stt: None, + update_notify_rx: None, } } @@ -262,6 +264,12 @@ impl Agent { self } + #[must_use] + pub fn with_update_notifications(mut self, rx: mpsc::Receiver) -> Self { + self.update_notify_rx = Some(rx); + self + } + #[must_use] pub fn with_max_tool_iterations(mut self, max: usize) -> Self { self.runtime.max_tool_iterations = max; @@ -657,6 +665,12 @@ impl Agent { self.reload_config(); continue; } + Some(msg) = recv_optional(&mut self.update_notify_rx) => { + if let Err(e) = self.channel.send(&msg).await { + tracing::warn!("failed to send update notification: {e}"); + } + continue; + } }; let Some(msg) = incoming else { break }; self.drain_channel(); @@ -1088,7 +1102,14 @@ async fn shutdown_signal(rx: &mut watch::Receiver) { async fn recv_optional(rx: &mut Option>) -> Option { match rx { - Some(rx) => rx.recv().await, + Some(inner) => { + if let Some(v) = inner.recv().await { + Some(v) + } else { + *rx = None; + std::future::pending().await + } + } None => std::future::pending().await, } } diff --git a/crates/zeph-core/src/config/env.rs b/crates/zeph-core/src/config/env.rs index 2848cdf4..1d884a27 100644 --- a/crates/zeph-core/src/config/env.rs +++ b/crates/zeph-core/src/config/env.rs @@ -151,6 +151,11 @@ impl Config { }); stt.model = v; } + if let Ok(v) = std::env::var("ZEPH_AUTO_UPDATE_CHECK") + && let Ok(enabled) = v.parse::() + { + self.agent.auto_update_check = enabled; + } if let Ok(v) = std::env::var("ZEPH_A2A_ENABLED") && let Ok(enabled) = v.parse::() { diff --git a/crates/zeph-core/src/config/tests.rs b/crates/zeph-core/src/config/tests.rs index 5cefbf6d..15cf518e 100644 --- a/crates/zeph-core/src/config/tests.rs +++ b/crates/zeph-core/src/config/tests.rs @@ -5,7 +5,7 @@ use serial_test::serial; use super::*; -const ENV_KEYS: [&str; 49] = [ +const ENV_KEYS: [&str; 50] = [ "ZEPH_LLM_PROVIDER", "ZEPH_LLM_BASE_URL", "ZEPH_LLM_MODEL", @@ -55,6 +55,7 @@ const ENV_KEYS: [&str; 49] = [ "ZEPH_INDEX_REPO_MAP_TOKENS", "ZEPH_STT_PROVIDER", "ZEPH_STT_MODEL", + "ZEPH_AUTO_UPDATE_CHECK", ]; fn clear_env() { @@ -2367,3 +2368,51 @@ fn env_override_stt_provider_only() { assert_eq!(stt.provider, "whisper"); assert_eq!(stt.model, "whisper-1"); } + +#[test] +fn config_default_auto_update_check_is_true() { + let config = Config::default(); + assert!(config.agent.auto_update_check); +} + +#[test] +#[serial] +fn env_override_auto_update_check_false() { + clear_env(); + let mut config = Config::default(); + assert!(config.agent.auto_update_check); + + unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "false") }; + config.apply_env_overrides(); + unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; + + assert!(!config.agent.auto_update_check); +} + +#[test] +#[serial] +fn env_override_auto_update_check_true() { + clear_env(); + let mut config = Config::default(); + config.agent.auto_update_check = false; + + unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "true") }; + config.apply_env_overrides(); + unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; + + assert!(config.agent.auto_update_check); +} + +#[test] +#[serial] +fn env_override_auto_update_check_invalid_ignored() { + clear_env(); + let mut config = Config::default(); + assert!(config.agent.auto_update_check); + + unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "not-a-bool") }; + config.apply_env_overrides(); + unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; + + assert!(config.agent.auto_update_check); +} diff --git a/crates/zeph-core/src/config/types.rs b/crates/zeph-core/src/config/types.rs index d9415da5..aa3dd52c 100644 --- a/crates/zeph-core/src/config/types.rs +++ b/crates/zeph-core/src/config/types.rs @@ -52,6 +52,10 @@ fn default_max_tool_iterations() -> usize { 10 } +fn default_auto_update_check() -> bool { + true +} + #[derive(Debug, Deserialize, Serialize)] pub struct AgentConfig { pub name: String, @@ -59,6 +63,8 @@ pub struct AgentConfig { pub max_tool_iterations: usize, #[serde(default)] pub summary_model: Option, + #[serde(default = "default_auto_update_check")] + pub auto_update_check: bool, } /// LLM provider backend selector. @@ -984,6 +990,7 @@ impl Default for Config { name: "Zeph".into(), max_tool_iterations: 10, summary_model: None, + auto_update_check: default_auto_update_check(), }, llm: LlmConfig { provider: ProviderKind::Ollama, @@ -1055,5 +1062,6 @@ mod tests { assert_eq!(back.memory.sqlite_path, config.memory.sqlite_path); assert_eq!(back.memory.history_limit, config.memory.history_limit); assert_eq!(back.vault.backend, config.vault.backend); + assert_eq!(back.agent.auto_update_check, config.agent.auto_update_check); } } diff --git a/crates/zeph-scheduler/Cargo.toml b/crates/zeph-scheduler/Cargo.toml index a4f87618..e7c6ce5d 100644 --- a/crates/zeph-scheduler/Cargo.toml +++ b/crates/zeph-scheduler/Cargo.toml @@ -9,6 +9,8 @@ repository.workspace = true [dependencies] chrono.workspace = true cron = "0.15" +reqwest = { workspace = true, features = ["json", "rustls"] } +semver.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] } diff --git a/crates/zeph-scheduler/src/lib.rs b/crates/zeph-scheduler/src/lib.rs index 69c24acd..e749164e 100644 --- a/crates/zeph-scheduler/src/lib.rs +++ b/crates/zeph-scheduler/src/lib.rs @@ -4,8 +4,10 @@ mod error; mod scheduler; mod store; mod task; +pub mod update_check; pub use error::SchedulerError; pub use scheduler::Scheduler; pub use store::JobStore; pub use task::{ScheduledTask, TaskHandler, TaskKind}; +pub use update_check::UpdateCheckHandler; diff --git a/crates/zeph-scheduler/src/store.rs b/crates/zeph-scheduler/src/store.rs index a19119b8..fc5614bd 100644 --- a/crates/zeph-scheduler/src/store.rs +++ b/crates/zeph-scheduler/src/store.rs @@ -12,6 +12,16 @@ impl JobStore { Self { pool } } + /// Open (or create) a `JobStore` from a `SQLite` file path. + /// + /// # Errors + /// + /// Returns `SchedulerError::Database` if the connection cannot be established. + pub async fn open(path: &str) -> Result { + let pool = SqlitePool::connect(&format!("sqlite:{path}?mode=rwc")).await?; + Ok(Self { pool }) + } + /// Initialize the `scheduled_jobs` table. /// /// # Errors diff --git a/crates/zeph-scheduler/src/task.rs b/crates/zeph-scheduler/src/task.rs index 8f0abe20..a5d925eb 100644 --- a/crates/zeph-scheduler/src/task.rs +++ b/crates/zeph-scheduler/src/task.rs @@ -11,6 +11,7 @@ pub enum TaskKind { MemoryCleanup, SkillRefresh, HealthCheck, + UpdateCheck, Custom(String), } @@ -21,6 +22,7 @@ impl TaskKind { "memory_cleanup" => Self::MemoryCleanup, "skill_refresh" => Self::SkillRefresh, "health_check" => Self::HealthCheck, + "update_check" => Self::UpdateCheck, other => Self::Custom(other.to_owned()), } } @@ -31,6 +33,7 @@ impl TaskKind { Self::MemoryCleanup => "memory_cleanup", Self::SkillRefresh => "skill_refresh", Self::HealthCheck => "health_check", + Self::UpdateCheck => "update_check", Self::Custom(s) => s, } } @@ -84,10 +87,20 @@ mod tests { TaskKind::MemoryCleanup ); assert_eq!(TaskKind::MemoryCleanup.as_str(), "memory_cleanup"); + assert_eq!( + TaskKind::from_str_kind("skill_refresh"), + TaskKind::SkillRefresh + ); + assert_eq!(TaskKind::SkillRefresh.as_str(), "skill_refresh"); assert_eq!( TaskKind::from_str_kind("health_check"), TaskKind::HealthCheck ); + assert_eq!( + TaskKind::from_str_kind("update_check"), + TaskKind::UpdateCheck + ); + assert_eq!(TaskKind::UpdateCheck.as_str(), "update_check"); assert_eq!( TaskKind::from_str_kind("custom_job"), TaskKind::Custom("custom_job".into()) diff --git a/crates/zeph-scheduler/src/update_check.rs b/crates/zeph-scheduler/src/update_check.rs new file mode 100644 index 00000000..9b082f60 --- /dev/null +++ b/crates/zeph-scheduler/src/update_check.rs @@ -0,0 +1,205 @@ +use std::future::Future; +use std::pin::Pin; + +use semver::Version; +use serde::Deserialize; +use tokio::sync::mpsc; + +use crate::error::SchedulerError; +use crate::task::TaskHandler; + +const GITHUB_RELEASES_URL: &str = "https://api.github.com/repos/bug-ops/zeph/releases/latest"; +const MAX_RESPONSE_BYTES: usize = 64 * 1024; + +pub struct UpdateCheckHandler { + current_version: &'static str, + notify_tx: mpsc::Sender, + http_client: reqwest::Client, +} + +#[derive(Deserialize)] +struct ReleaseInfo { + tag_name: Option, +} + +impl UpdateCheckHandler { + /// Create a new handler. + /// + /// `current_version` should be `env!("CARGO_PKG_VERSION")`. + /// Notifications are sent as formatted strings via `notify_tx`. + /// + /// # Panics + /// + /// Panics if the underlying `reqwest` client cannot be constructed (unreachable in practice). + #[must_use] + pub fn new(current_version: &'static str, notify_tx: mpsc::Sender) -> Self { + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(format!("zeph/{current_version}")) + .build() + .expect("reqwest client builder should not fail with timeout and user_agent"); + Self { + current_version, + notify_tx, + http_client, + } + } + + /// Extract and compare versions; returns `Some(remote_version_str)` when remote > current. + fn newer_version(current: &str, tag_name: &str) -> Option { + let remote_str = tag_name.trim_start_matches('v'); + if remote_str.is_empty() { + return None; + } + let current_v = Version::parse(current).ok()?; + let remote_v = Version::parse(remote_str).ok()?; + if remote_v > current_v { + Some(remote_str.to_owned()) + } else { + None + } + } +} + +impl TaskHandler for UpdateCheckHandler { + fn execute( + &self, + _config: &serde_json::Value, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let resp = self + .http_client + .get(GITHUB_RELEASES_URL) + .header("Accept", "application/vnd.github+json") + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + tracing::warn!("update check request failed: {e}"); + return Ok(()); + } + }; + + if !resp.status().is_success() { + tracing::warn!("update check: HTTP {}", resp.status()); + return Ok(()); + } + + let bytes = match resp.bytes().await { + Ok(b) => b, + Err(e) => { + tracing::warn!("update check: failed to read response body: {e}"); + return Ok(()); + } + }; + if bytes.len() > MAX_RESPONSE_BYTES { + tracing::warn!( + "update check: response body too large ({} bytes), skipping", + bytes.len() + ); + return Ok(()); + } + let info: ReleaseInfo = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(e) => { + tracing::warn!("update check response parse failed: {e}"); + return Ok(()); + } + }; + + let Some(tag_name) = info.tag_name else { + tracing::warn!("update check: missing tag_name in response"); + return Ok(()); + }; + + match Self::newer_version(self.current_version, &tag_name) { + Some(remote) => { + let msg = format!( + "New version available: v{remote} (current: v{}).\nUpdate: https://github.com/bug-ops/zeph/releases/tag/v{remote}", + self.current_version + ); + tracing::debug!("update available: {remote}"); + let _ = self.notify_tx.send(msg).await; + } + None => { + tracing::debug!( + current = self.current_version, + remote = tag_name, + "no update available" + ); + } + } + + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_version_detects_upgrade() { + assert_eq!( + UpdateCheckHandler::newer_version("0.11.0", "v0.12.0"), + Some("0.12.0".to_owned()) + ); + } + + #[test] + fn newer_version_same_version_no_notify() { + assert_eq!(UpdateCheckHandler::newer_version("0.11.0", "v0.11.0"), None); + } + + #[test] + fn newer_version_older_remote_no_notify() { + assert_eq!(UpdateCheckHandler::newer_version("0.11.0", "v0.10.0"), None); + } + + #[test] + fn newer_version_strips_v_prefix() { + assert_eq!( + UpdateCheckHandler::newer_version("1.0.0", "v2.0.0"), + Some("2.0.0".to_owned()) + ); + assert_eq!( + UpdateCheckHandler::newer_version("1.0.0", "2.0.0"), + Some("2.0.0".to_owned()) + ); + } + + #[test] + fn newer_version_invalid_current_returns_none() { + assert_eq!( + UpdateCheckHandler::newer_version("not-semver", "v1.0.0"), + None + ); + } + + #[test] + fn newer_version_invalid_remote_returns_none() { + assert_eq!( + UpdateCheckHandler::newer_version("1.0.0", "v-garbage"), + None + ); + } + + #[test] + fn newer_version_empty_tag_returns_none() { + assert_eq!(UpdateCheckHandler::newer_version("1.0.0", ""), None); + } + + // Prerelease versions (e.g. 0.12.0-rc.1) compare as greater than 0.11.0 per semver spec. + // This is intentional: users should be notified of release candidates if they appear + // on the GitHub releases/latest endpoint (which typically only returns stable releases). + #[test] + fn newer_version_prerelease_is_notified() { + assert_eq!( + UpdateCheckHandler::newer_version("0.11.0", "v0.12.0-rc.1"), + Some("0.12.0-rc.1".to_owned()) + ); + } +} diff --git a/src/init.rs b/src/init.rs index 1a843b35..377119c1 100644 --- a/src/init.rs +++ b/src/init.rs @@ -39,6 +39,7 @@ pub(crate) struct WizardState { pub(crate) orchestrator_fallback_base_url: Option, pub(crate) orchestrator_fallback_api_key: Option, pub(crate) orchestrator_fallback_compatible_name: Option, + pub(crate) auto_update_check: bool, } #[derive(Default, Clone, Copy)] @@ -56,6 +57,7 @@ pub fn run(output: Option) -> anyhow::Result<()> { let mut state = WizardState { vault_backend: "env".into(), semantic_enabled: true, + auto_update_check: true, ..WizardState::default() }; @@ -63,6 +65,7 @@ pub fn run(output: Option) -> anyhow::Result<()> { step_llm(&mut state)?; step_memory(&mut state)?; step_channel(&mut state)?; + step_update_check(&mut state)?; step_review_and_write(&state, output)?; Ok(()) @@ -150,7 +153,7 @@ fn prompt_provider_config(label: &str) -> anyhow::Result { } fn step_llm(state: &mut WizardState) -> anyhow::Result<()> { - println!("== Step 2/5: LLM Provider ==\n"); + println!("== Step 2/6: LLM Provider ==\n"); let use_age = state.vault_backend == "age"; @@ -286,7 +289,7 @@ fn step_llm_provider(state: &mut WizardState, use_age: bool) -> anyhow::Result<( } fn step_memory(state: &mut WizardState) -> anyhow::Result<()> { - println!("== Step 3/5: Memory ==\n"); + println!("== Step 3/6: Memory ==\n"); state.sqlite_path = Some( Input::new() @@ -314,7 +317,7 @@ fn step_memory(state: &mut WizardState) -> anyhow::Result<()> { } fn step_channel(state: &mut WizardState) -> anyhow::Result<()> { - println!("== Step 4/5: Channel ==\n"); + println!("== Step 4/6: Channel ==\n"); let use_age = state.vault_backend == "age"; @@ -381,7 +384,7 @@ fn step_channel(state: &mut WizardState) -> anyhow::Result<()> { } fn step_vault(state: &mut WizardState) -> anyhow::Result<()> { - println!("== Step 1/5: Secrets Backend ==\n"); + println!("== Step 1/6: Secrets Backend ==\n"); let backends = ["env (environment variables)", "age (encrypted file)"]; let selection = Select::new() @@ -402,6 +405,7 @@ fn step_vault(state: &mut WizardState) -> anyhow::Result<()> { pub(crate) fn build_config(state: &WizardState) -> Config { let mut config = Config::default(); + config.agent.auto_update_check = state.auto_update_check; let provider = state.provider.unwrap_or(ProviderKind::Ollama); let orchestrator = if provider == ProviderKind::Orchestrator { @@ -552,8 +556,20 @@ fn build_orchestrator_config(state: &WizardState) -> Option }) } +fn step_update_check(state: &mut WizardState) -> anyhow::Result<()> { + println!("== Step 5/6: Update Check ==\n"); + + state.auto_update_check = Confirm::new() + .with_prompt("Enable automatic update checks?") + .default(true) + .interact()?; + + println!(); + Ok(()) +} + fn step_review_and_write(state: &WizardState, output: Option) -> anyhow::Result<()> { - println!("== Step 5/5: Review & Write ==\n"); + println!("== Step 6/6: Review & Write ==\n"); let config = build_config(state); let toml_str = toml::to_string_pretty(&config)?; diff --git a/src/main.rs b/src/main.rs index 271f2bcc..e33aff1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use std::time::Duration; use clap::{Parser, Subcommand}; use zeph_core::vault::AgeVaultProvider; -#[cfg(any(feature = "a2a", feature = "tui"))] +#[cfg(any(feature = "a2a", feature = "tui", feature = "scheduler"))] use tokio::sync::watch; use zeph_channels::AnyChannel; use zeph_channels::CliChannel; @@ -35,6 +35,8 @@ use zeph_index::{ use zeph_llm::any::AnyProvider; #[cfg(feature = "index")] use zeph_llm::provider::LlmProvider; +#[cfg(feature = "scheduler")] +use zeph_scheduler::{JobStore, ScheduledTask, Scheduler, TaskKind, UpdateCheckHandler}; #[cfg(feature = "tui")] use zeph_tui::{App, EventReader, TuiChannel}; @@ -480,6 +482,9 @@ async fn main() -> anyhow::Result<()> { let agent = agent.with_mcp(mcp_tools, mcp_registry, Some(mcp_manager), &config.mcp); let agent = agent.with_learning(config.skills.learning.clone()); + #[cfg(feature = "scheduler")] + let agent = bootstrap_scheduler(agent, config, shutdown_rx.clone()).await; + #[cfg(feature = "candle")] let agent = if config .llm @@ -1039,6 +1044,77 @@ async fn create_channel(config: &Config) -> anyhow::Result { create_channel_inner(config).await } +#[cfg(feature = "scheduler")] +async fn bootstrap_scheduler( + agent: zeph_core::agent::Agent, + config: &Config, + shutdown_rx: watch::Receiver, +) -> zeph_core::agent::Agent +where + C: zeph_core::channel::Channel, + T: zeph_tools::executor::ToolExecutor, +{ + if !config.scheduler.enabled { + if config.agent.auto_update_check { + // Fire-and-forget single check at startup when scheduler is disabled. + let (tx, rx) = tokio::sync::mpsc::channel(1); + let handler = UpdateCheckHandler::new(env!("CARGO_PKG_VERSION"), tx); + tokio::spawn(async move { + let _ = handler.execute(&serde_json::Value::Null).await; + }); + return agent.with_update_notifications(rx); + } + return agent; + } + + let store = match JobStore::open(&config.memory.sqlite_path).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("scheduler: failed to open store: {e}"); + return agent; + } + }; + + let mut scheduler = Scheduler::new(store, shutdown_rx); + + let agent = if config.agent.auto_update_check { + let (update_tx, update_rx) = tokio::sync::mpsc::channel(4); + let update_task = match ScheduledTask::new( + "update_check", + "0 0 9 * * *", + TaskKind::UpdateCheck, + serde_json::Value::Null, + ) { + Ok(t) => t, + Err(e) => { + tracing::warn!("scheduler: invalid update_check cron: {e}"); + return agent; + } + }; + scheduler.add_task(update_task); + scheduler.register_handler( + &TaskKind::UpdateCheck, + Box::new(UpdateCheckHandler::new( + env!("CARGO_PKG_VERSION"), + update_tx, + )), + ); + agent.with_update_notifications(update_rx) + } else { + agent + }; + + if let Err(e) = scheduler.init().await { + tracing::warn!("scheduler init failed: {e}"); + return agent; + } + + tokio::spawn(async move { scheduler.run().await }); + tracing::info!("scheduler started"); + + agent +} + #[cfg(not(feature = "tui"))] fn init_subscriber(config_path: &std::path::Path) { use tracing_subscriber::layer::SubscriberExt; From 36dd339689ac9188336c2700e6c7cd3988007d00 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Feb 2026 15:27:33 +0100 Subject: [PATCH 2/3] docs: add auto-update check documentation (#588) Update mdbook guide, feature flags, getting started, and crate READMEs to cover the new auto-update check feature including configuration, environment overrides, and scheduler integration. --- README.md | 3 +- crates/zeph-core/README.md | 18 ++++++++++ crates/zeph-scheduler/README.md | 42 ++++++++++++++++++++--- docs/src/feature-flags.md | 2 +- docs/src/getting-started/configuration.md | 7 ++-- docs/src/guide/scheduler.md | 24 +++++++++++++ 6 files changed, 88 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2936f524..36251e17 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Automatic prompt caching for Anthropic and OpenAI providers. Repeated system pro - **Parallel context preparation** via `try_join!` — skills, memory, code context fetched concurrently - **Byte-length token estimation** — fast approximation without tokenizer overhead - **Config hot-reload** — change runtime parameters without restarting the agent +- **Auto-update check** — optional daily check against GitHub releases; notification delivered to the active channel (`ZEPH_AUTO_UPDATE_CHECK=false` to disable) - **Pipeline API** — composable, type-safe step chains for LLM calls, vector retrieval, JSON extraction, and parallel execution [Token efficiency deep dive →](https://bug-ops.github.io/zeph/architecture/token-efficiency.html) @@ -382,7 +383,7 @@ Always compiled in: `openai`, `compatible`, `orchestrator`, `router`, `self-lear | `daemon` | Component supervisor | | `pdf` | PDF document loading for RAG | | `stt` | Speech-to-text via OpenAI Whisper API | -| `scheduler` | Cron-based periodic tasks | +| `scheduler` | Cron-based periodic tasks; auto-update check runs daily at 09:00 | | `otel` | OpenTelemetry OTLP export | | `full` | Everything above | diff --git a/crates/zeph-core/README.md b/crates/zeph-core/README.md index 121bc9ae..e49dcb88 100644 --- a/crates/zeph-core/README.md +++ b/crates/zeph-core/README.md @@ -26,6 +26,24 @@ Core orchestration crate for the Zeph agent. Manages the main agent loop, bootst **Re-exports:** `Agent` +## Configuration + +Key `AgentConfig` fields (TOML section `[agent]`): + +| Field | Type | Default | Env override | Description | +|-------|------|---------|--------------|-------------| +| `name` | string | `"zeph"` | — | Agent display name | +| `max_tool_iterations` | usize | `10` | — | Max tool calls per turn | +| `summary_model` | string? | `null` | — | Model used for context summarization | +| `auto_update_check` | bool | `true` | `ZEPH_AUTO_UPDATE_CHECK` | Check GitHub releases for a newer version on startup / via scheduler | + +```toml +[agent] +auto_update_check = true # set to false to disable update notifications +``` + +Set `ZEPH_AUTO_UPDATE_CHECK=false` to disable without changing the config file. + ## Usage ```toml diff --git a/crates/zeph-scheduler/README.md b/crates/zeph-scheduler/README.md index cf7f1650..d6c16183 100644 --- a/crates/zeph-scheduler/README.md +++ b/crates/zeph-scheduler/README.md @@ -4,20 +4,54 @@ Cron-based periodic task scheduler with SQLite persistence. ## Overview -Runs recurring tasks on cron schedules, persisting job state and last-run timestamps in SQLite. Ships with built-in tasks for memory cleanup, skill refresh, and health checks. Feature-gated behind `scheduler`. +Runs recurring tasks on cron schedules, persisting job state and last-run timestamps in SQLite. Ships with built-in tasks for memory cleanup, skill refresh, health checks, and automatic update detection. Feature-gated behind `scheduler`. ## Key Modules - **scheduler** — `Scheduler` event loop managing job evaluation and dispatch - **store** — `JobStore` for SQLite-backed job persistence - **task** — `ScheduledTask`, `TaskHandler`, `TaskKind` defining task types and execution +- **update_check** — `UpdateCheckHandler` for GitHub releases version check - **error** — `SchedulerError` error types ## Built-in Tasks -- `memory_cleanup` — prune expired memory entries -- `skill_refresh` — hot-reload changed skill files -- `health_check` — periodic self-diagnostics +| Kind | String key | Description | +|------|-----------|-------------| +| `TaskKind::MemoryCleanup` | `memory_cleanup` | Prune expired memory entries | +| `TaskKind::SkillRefresh` | `skill_refresh` | Hot-reload changed skill files | +| `TaskKind::HealthCheck` | `health_check` | Periodic self-diagnostics | +| `TaskKind::UpdateCheck` | `update_check` | Check GitHub releases for a newer version | + +## UpdateCheckHandler + +`UpdateCheckHandler` implements `TaskHandler` and queries the GitHub releases API to compare the running version against the latest published release. When a newer version is detected it sends a human-readable notification over an `mpsc::Sender` channel. + +```rust +use tokio::sync::mpsc; +use zeph_scheduler::{ScheduledTask, Scheduler, TaskKind, UpdateCheckHandler}; + +let (tx, rx) = mpsc::channel(4); +let handler = UpdateCheckHandler::new(env!("CARGO_PKG_VERSION"), tx); + +let task = ScheduledTask::new( + "update_check", + "0 0 9 * * *", // daily at 09:00 + TaskKind::UpdateCheck, + serde_json::Value::Null, +)?; +scheduler.add_task(task); +scheduler.register_handler(&TaskKind::UpdateCheck, Box::new(handler)); +``` + +Notification format sent via the channel: + +``` +New version available: v0.12.0 (current: v0.11.0). +Update: https://github.com/bug-ops/zeph/releases/tag/v0.12.0 +``` + +Behaviour on error (network failure, non-2xx response, oversized body, parse error, invalid semver) — logs a `warn` message and returns `Ok(())`. The check is best-effort and never crashes the agent. ## Usage diff --git a/docs/src/feature-flags.md b/docs/src/feature-flags.md index b61e589c..86cda633 100644 --- a/docs/src/feature-flags.md +++ b/docs/src/feature-flags.md @@ -29,7 +29,7 @@ Zeph uses Cargo feature flags to control optional functionality. As of M26, eigh | `index` | AST-based code indexing and semantic retrieval via tree-sitter ([guide](guide/code-indexing.md)) | | `gateway` | HTTP gateway for webhook ingestion with bearer auth and rate limiting ([guide](guide/gateway.md)) | | `daemon` | Daemon supervisor with component lifecycle, PID file, and health monitoring ([guide](guide/daemon.md)) | -| `scheduler` | Cron-based periodic task scheduler with SQLite persistence ([guide](guide/scheduler.md)) | +| `scheduler` | Cron-based periodic task scheduler with SQLite persistence, including the `update_check` handler for automatic version notifications ([guide](guide/scheduler.md)) | | `stt` | Speech-to-text transcription via OpenAI Whisper API ([guide](guide/audio-input.md)) | | `otel` | OpenTelemetry tracing export via OTLP/gRPC ([guide](guide/observability.md)) | | `pdf` | PDF document loading via [pdf-extract](https://crates.io/crates/pdf-extract) for the document ingestion pipeline | diff --git a/docs/src/getting-started/configuration.md b/docs/src/getting-started/configuration.md index 4082abe5..a2b7adad 100644 --- a/docs/src/getting-started/configuration.md +++ b/docs/src/getting-started/configuration.md @@ -2,13 +2,14 @@ ## Configuration Wizard -Run `zeph init` to generate a `config.toml` interactively. The wizard walks through five steps: +Run `zeph init` to generate a `config.toml` interactively. The wizard walks through six steps: 1. **Secrets backend** -- choose `env` (environment variables) or `age` (encrypted file). When `age` is selected, API key prompts are skipped in subsequent steps since secrets are stored via `zeph vault set` instead. 2. **LLM Provider** -- select Ollama (local), Claude, OpenAI, Orchestrator (multi-model routing), or a compatible endpoint. Orchestrator prompts for a primary and fallback provider, enabling automatic failover. Provide the base URL, model name, and API key as needed (skipped for age backend). Choose an embedding model (default: `qwen3-embedding`). 3. **Memory** -- set the SQLite database path and optionally enable semantic memory with Qdrant. 4. **Channel** -- pick CLI (default), Telegram, Discord, or Slack. Provide tokens and credentials for the selected channel (token prompts skipped for age backend). -5. **Review and write** -- inspect the generated TOML, confirm the output path, and save. +5. **Update check** -- choose whether to enable automatic version checks against GitHub Releases (default: enabled). +6. **Review and write** -- inspect the generated TOML, confirm the output path, and save. Specify the output path directly: @@ -77,6 +78,7 @@ Check for `config reloaded` in the log to confirm a successful reload. [agent] name = "Zeph" max_tool_iterations = 10 # Max tool loop iterations per response (default: 10) +auto_update_check = true # Query GitHub Releases API for newer versions (default: true) [llm] provider = "ollama" # ollama, claude, openai, candle, compatible, orchestrator, router @@ -239,3 +241,4 @@ rate_limit = 60 | `ZEPH_STT_MODEL` | STT model name (default: `whisper-1`) | | `ZEPH_CONFIG` | Path to config file (default: `config/default.toml`) | | `ZEPH_TUI` | Enable TUI dashboard: `true` or `1` (requires `tui` feature) | +| `ZEPH_AUTO_UPDATE_CHECK` | Enable automatic update checks: `true` or `false` (default: `true`) | diff --git a/docs/src/guide/scheduler.md b/docs/src/guide/scheduler.md index 137da261..845730f6 100644 --- a/docs/src/guide/scheduler.md +++ b/docs/src/guide/scheduler.md @@ -48,9 +48,33 @@ Standard cron features are supported: ranges (`1-5`), lists (`1,3,5`), steps (`* | `memory_cleanup` | Remove old conversation history entries | | `skill_refresh` | Re-scan skill directories for changes | | `health_check` | Internal health verification | +| `update_check` | Query GitHub Releases API for a newer version | Custom kinds are also supported. Register a handler implementing the `TaskHandler` trait for any custom `kind` string. +## Update Check + +The `update_check` task uses `UpdateCheckHandler` to query the GitHub Releases API and compare the running version against the latest release. When a newer version is detected, a notification message is emitted to the agent channel. + +The update check is controlled by `auto_update_check` in `[agent]` (default: `true`). It is independent of the scheduler feature flag: + +- **With `scheduler` feature enabled**: the check runs daily at 09:00 UTC via a cron task (`0 0 9 * * *`). +- **Without `scheduler` feature**: a single one-shot check is performed at startup. + +To add the update check to the scheduler task list explicitly: + +```toml +[agent] +auto_update_check = true # default; set to false to disable entirely + +[[scheduler.tasks]] +name = "update_check" +cron = "0 0 9 * * *" # daily at 09:00 UTC +kind = "update_check" +``` + +The handler uses a 10-second HTTP timeout and caps the response body at 64 KB. Network errors and non-2xx responses are logged as warnings and treated as no-ops, so a failed check never interrupts normal agent operation. + ## TaskHandler Trait Implement `TaskHandler` to define custom task logic: From 527b927489d95a638b1e1a8889444e58ca9ebc97 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Thu, 19 Feb 2026 15:37:22 +0100 Subject: [PATCH 3/3] test(scheduler): add wiremock integration tests for update check handler Add 8 HTTP mock tests covering: successful newer version notification, same version skip, HTTP 404/429/500 error handling, malformed JSON, missing tag_name, and oversized response body. Introduce with_base_url() builder on UpdateCheckHandler for test configurability. --- Cargo.lock | 52 ++++++ Cargo.toml | 1 + crates/zeph-scheduler/Cargo.toml | 3 +- crates/zeph-scheduler/src/update_check.rs | 194 +++++++++++++++++++++- 4 files changed, 248 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a489bb18..0ad3deae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tokio-tar" version = "0.5.6" @@ -1454,6 +1464,24 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deltae" version = "0.3.2" @@ -8615,6 +8643,29 @@ version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -9032,6 +9083,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "wiremock", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 99d775ad..4aee284c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ sqlx = { version = "0.8", default-features = false, features = ["macros"] } teloxide = { version = "0.17", default-features = false, features = ["rustls", "ctrlc_handler", "macros"] } tempfile = "3" testcontainers = "0.27" +wiremock = "0.6.5" thiserror = "2.0" tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } tokio = "1" diff --git a/crates/zeph-scheduler/Cargo.toml b/crates/zeph-scheduler/Cargo.toml index e7c6ce5d..2e991dad 100644 --- a/crates/zeph-scheduler/Cargo.toml +++ b/crates/zeph-scheduler/Cargo.toml @@ -15,12 +15,13 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] } thiserror.workspace = true -tokio = { workspace = true, features = ["sync", "time"] } +tokio = { workspace = true, features = ["macros", "sync", "time"] } tracing.workspace = true [dev-dependencies] tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +wiremock.workspace = true [lints] workspace = true diff --git a/crates/zeph-scheduler/src/update_check.rs b/crates/zeph-scheduler/src/update_check.rs index 9b082f60..6884631d 100644 --- a/crates/zeph-scheduler/src/update_check.rs +++ b/crates/zeph-scheduler/src/update_check.rs @@ -15,6 +15,8 @@ pub struct UpdateCheckHandler { current_version: &'static str, notify_tx: mpsc::Sender, http_client: reqwest::Client, + /// Base URL for the GitHub releases API. Configurable for testing. + base_url: String, } #[derive(Deserialize)] @@ -42,9 +44,17 @@ impl UpdateCheckHandler { current_version, notify_tx, http_client, + base_url: GITHUB_RELEASES_URL.to_owned(), } } + /// Override the releases API URL. Intended for tests only. + #[must_use] + pub fn with_base_url(mut self, url: impl Into) -> Self { + self.base_url = url.into(); + self + } + /// Extract and compare versions; returns `Some(remote_version_str)` when remote > current. fn newer_version(current: &str, tag_name: &str) -> Option { let remote_str = tag_name.trim_start_matches('v'); @@ -69,7 +79,7 @@ impl TaskHandler for UpdateCheckHandler { Box::pin(async move { let resp = self .http_client - .get(GITHUB_RELEASES_URL) + .get(&self.base_url) .header("Accept", "application/vnd.github+json") .send() .await; @@ -139,8 +149,19 @@ impl TaskHandler for UpdateCheckHandler { #[cfg(test)] mod tests { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + use super::*; + fn make_handler( + current_version: &'static str, + tx: mpsc::Sender, + server_url: &str, + ) -> UpdateCheckHandler { + UpdateCheckHandler::new(current_version, tx).with_base_url(server_url) + } + #[test] fn newer_version_detects_upgrade() { assert_eq!( @@ -202,4 +223,175 @@ mod tests { Some("0.12.0-rc.1".to_owned()) ); } + + #[tokio::test] + async fn test_execute_newer_version_sends_notification() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": "v99.0.0" + }))) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + handler + .execute(&serde_json::Value::Null) + .await + .expect("handler must not return an error"); + + let msg = rx.try_recv().expect("notification must be sent"); + assert!( + msg.contains("99.0.0"), + "notification should mention new version" + ); + assert!( + msg.contains("0.11.0"), + "notification should mention current version" + ); + } + + #[tokio::test] + async fn test_execute_same_version_no_notification() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": "v0.11.0" + }))) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + handler + .execute(&serde_json::Value::Null) + .await + .expect("handler must not return an error"); + + assert!( + rx.try_recv().is_err(), + "no notification expected for same version" + ); + } + + #[tokio::test] + async fn test_execute_http_404_no_notification_no_panic() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + let result = handler.execute(&serde_json::Value::Null).await; + assert!(result.is_ok(), "handler must return Ok on 404"); + assert!(rx.try_recv().is_err(), "no notification expected on 404"); + } + + #[tokio::test] + async fn test_execute_http_429_rate_limit_graceful() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + let result = handler.execute(&serde_json::Value::Null).await; + assert!(result.is_ok(), "handler must return Ok on 429"); + assert!(rx.try_recv().is_err(), "no notification expected on 429"); + } + + #[tokio::test] + async fn test_execute_http_500_server_error_graceful() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + let result = handler.execute(&serde_json::Value::Null).await; + assert!(result.is_ok(), "handler must return Ok on 500"); + assert!(rx.try_recv().is_err(), "no notification expected on 500"); + } + + #[tokio::test] + async fn test_execute_malformed_json_graceful() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_string("this is not json {{{")) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + let result = handler.execute(&serde_json::Value::Null).await; + assert!(result.is_ok(), "handler must return Ok on malformed JSON"); + assert!( + rx.try_recv().is_err(), + "no notification expected for malformed JSON" + ); + } + + #[tokio::test] + async fn test_execute_missing_tag_name_graceful() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "Latest Release", + "published_at": "2024-01-01" + }))) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + let result = handler.execute(&serde_json::Value::Null).await; + assert!(result.is_ok(), "handler must return Ok on missing tag_name"); + assert!( + rx.try_recv().is_err(), + "no notification expected for missing tag_name" + ); + } + + #[tokio::test] + async fn test_execute_oversized_body_graceful() { + let server = MockServer::start().await; + // Body larger than MAX_RESPONSE_BYTES (64 KB): 65 537 bytes + let large_body = "x".repeat(MAX_RESPONSE_BYTES + 1); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_string(large_body)) + .mount(&server) + .await; + + let (tx, mut rx) = mpsc::channel(1); + let handler = make_handler("0.11.0", tx, &server.uri()); + + let result = handler.execute(&serde_json::Value::Null).await; + assert!(result.is_ok(), "handler must return Ok for oversized body"); + assert!( + rx.try_recv().is_err(), + "no notification expected for oversized body" + ); + } }