From b373881c86a16a4982bce074928a37b3a9521fd8 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:45:26 +0200 Subject: [PATCH] Add systemd timer and desktop notifications Hourly user-level pkgwatch.timer/.service, an OnFailure= notifier, and a notifier module that sends notify-send alerts when a tier 4-6 release is queued for review or a tier 1-3 release is published. Co-Authored-By: Claude Sonnet 5 --- docs/SPEC.md | 17 ++++- src/main.rs | 1 + src/notifier.rs | 109 +++++++++++++++++++++++++++++++ src/pipeline.rs | 9 +++ systemd/pkgwatch-failure.service | 9 +++ systemd/pkgwatch.service | 17 +++++ systemd/pkgwatch.timer | 12 ++++ 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/notifier.rs create mode 100644 systemd/pkgwatch-failure.service create mode 100644 systemd/pkgwatch.service create mode 100644 systemd/pkgwatch.timer diff --git a/docs/SPEC.md b/docs/SPEC.md index b6cfa09..99acc50 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -409,7 +409,20 @@ Open questions on the schema: earlier run. No reject/dismiss command yet — see Status below.)* - **Scheduling**: systemd `.service` (oneshot) + `.timer` running it periodically, matching the pattern already used for other periodic tasks - on this box. *(Not implemented — still a single one-shot `cargo run`.)* + on this box. *(Implemented — user-level units under `systemd/`, hourly, + non-persistent. Install from the main checkout: + `cargo build --release && mkdir -p ~/.config/systemd/user && + cp systemd/* ~/.config/systemd/user/ && systemctl --user daemon-reload && + systemctl --user enable --now pkgwatch.timer`. `pkgwatch.service` + sets `WorkingDirectory` to `~/dev/pkgwatch` because `packages.d/`, + `state/` and `work/` resolve relative to cwd.)* +- **Notifications**: `notifier.rs` sends a desktop notification + (`notify-send`) when a tier 4-6 release is newly queued for review or a + tier 1-3 release is published; both are best-effort and never fail a + run. A non-zero exit (verification/build/network failure) triggers + `pkgwatch-failure.service` via `OnFailure=`. Approving via + `pkgwatch review --approve` doesn't notify — the operator is already at + the terminal. ## Prior art / reference points @@ -473,7 +486,7 @@ Open questions on the schema: ever actually fails on it. - [ ] Not yet implemented: `pkgwatch review --reject` (a pending review can only be approved or left pending, not dismissed), - scheduling/`check_interval`, non-GitHub sources, `minisign`/tier-1 + non-GitHub sources, `minisign`/tier-1 method, retention/pruning of old versions in the local repo (see Scaling > Local repo retention), staggering/auth for GitHub API rate limits at higher package counts. diff --git a/src/main.rs b/src/main.rs index 300ac6b..9f992b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod config; mod fetcher; mod github; mod hash; +mod notifier; mod pipeline; mod publisher; mod sanity; diff --git a/src/notifier.rs b/src/notifier.rs new file mode 100644 index 0000000..4c6cf8f --- /dev/null +++ b/src/notifier.rs @@ -0,0 +1,109 @@ +//! Tells the operator, via a desktop notification, that a package needs +//! attention (a tier 4-6 release awaiting review) or just landed in the +//! local repo. Best-effort: a missing `notify-send` or session bus must +//! never fail a run, since the pipeline's real outcome is already in +//! state and stdout. + +use std::path::Path; +use std::process::Command; + +/// What happened to a package that the operator should hear about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Event<'a> { + /// Tier 4-6: verified, waiting on `pkgwatch review --approve`. + NeedsReview { name: &'a str, version: &'a str }, + /// Tier 1-3: built and added to the local repo, waiting on `pacman -Syu`. + Published { name: &'a str, version: &'a str }, +} + +/// (summary, body) for `event` — pure, so the wording is unit-testable +/// without a notification daemon. +fn message(event: Event) -> (String, String) { + match event { + Event::NeedsReview { name, version } => ( + format!("{name} {version} needs review"), + format!("Run `pkgwatch review {name} --approve`, then `sudo pacman -Syu`."), + ), + Event::Published { name, version } => ( + format!("{name} {version} published"), + "Run `sudo pacman -Syu` to install it.".to_string(), + ), + } +} + +pub fn notify(event: Event) { + notify_with(Path::new("notify-send"), event); +} + +/// `notify_send_bin` is injectable for the same reason as +/// `publisher::publish_with`'s `repo_add_bin`. +fn notify_with(notify_send_bin: &Path, event: Event) { + let (summary, body) = message(event); + let result = Command::new(notify_send_bin) + .args(["--app-name=pkgwatch", "--", &summary, &body]) + .status(); + match result { + Ok(status) if status.success() => {} + Ok(status) => eprintln!(" warning: notify-send exited with {status}"), + Err(err) => eprintln!(" warning: could not run notify-send: {err}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::write_executable_script; + + #[test] + fn message_for_review_names_the_approve_command() { + let (summary, body) = message(Event::NeedsReview { + name: "claude-code", + version: "v2.1.278", + }); + assert_eq!(summary, "claude-code v2.1.278 needs review"); + assert!(body.contains("pkgwatch review claude-code --approve")); + } + + #[test] + fn message_for_publish_points_at_pacman() { + let (summary, body) = message(Event::Published { + name: "uv", + version: "0.12.17", + }); + assert_eq!(summary, "uv 0.12.17 published"); + assert!(body.contains("pacman -Syu")); + } + + #[test] + fn notify_invokes_binary_with_summary_and_body() { + let stub_dir = tempfile::tempdir().unwrap(); + let log_path = stub_dir.path().join("invoked_with.txt"); + let stub = write_executable_script( + stub_dir.path(), + "fake-notify-send", + &format!("printf '%s\\n' \"$@\" > {}", log_path.display()), + ); + + notify_with( + &stub, + Event::Published { + name: "uv", + version: "0.12.17", + }, + ); + + let invoked_with = std::fs::read_to_string(&log_path).unwrap(); + assert!(invoked_with.contains("uv 0.12.17 published")); + } + + #[test] + fn notify_survives_missing_binary() { + notify_with( + Path::new("/nonexistent/notify-send"), + Event::NeedsReview { + name: "x", + version: "1", + }, + ); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs index 8d0b037..5b8d5bc 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -10,6 +10,7 @@ use crate::checker; use crate::config::{self, Package}; use crate::fetcher::{self, DownloadedAsset}; use crate::github::GithubEndpoints; +use crate::notifier::{self, Event}; use crate::publisher; use crate::sanity; use crate::state; @@ -151,6 +152,10 @@ fn process_package( state::save_last_version(state_dir, name, &latest)?; state::clear_pending_version(state_dir, name)?; println!(" published {name} {latest}"); + notifier::notify(Event::Published { + name, + version: &latest, + }); } TierAction::StillPending => { println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)"); @@ -165,6 +170,10 @@ fn process_package( " tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)" ), } + notifier::notify(Event::NeedsReview { + name, + version: &latest, + }); } } Ok(()) diff --git a/systemd/pkgwatch-failure.service b/systemd/pkgwatch-failure.service new file mode 100644 index 0000000..f40b413 --- /dev/null +++ b/systemd/pkgwatch-failure.service @@ -0,0 +1,9 @@ +# Triggered by pkgwatch.service's OnFailure=. Covers what the in-process +# notifier can't: a verification failure, build failure, or network error +# exits non-zero, and that would otherwise only show up in the journal. +[Unit] +Description=Notify that a pkgwatch run failed + +[Service] +Type=oneshot +ExecStart=/usr/bin/notify-send --app-name=pkgwatch --urgency=critical "pkgwatch run failed" "See: journalctl --user -u pkgwatch.service" diff --git a/systemd/pkgwatch.service b/systemd/pkgwatch.service new file mode 100644 index 0000000..232a5d4 --- /dev/null +++ b/systemd/pkgwatch.service @@ -0,0 +1,17 @@ +# User-level oneshot: one check -> fetch -> verify -> build -> publish pass. +# Install: see docs/SPEC.md > Scheduling. +# +# WorkingDirectory matters: packages.d/, state/ and work/ are all resolved +# relative to the cwd, so this must be the main checkout, not a worktree. +# The binary is the release build in that same checkout (`cargo build +# --release`), so a rebuild is what picks up code changes. +[Unit] +Description=pkgwatch: check tracked packages for new upstream releases +OnFailure=pkgwatch-failure.service + +[Service] +Type=oneshot +WorkingDirectory=%h/dev/pkgwatch +ExecStart=%h/dev/pkgwatch/target/release/pkgwatch +# Builds (makepkg, large Go/Rust binaries) can legitimately take a while. +TimeoutStartSec=30min diff --git a/systemd/pkgwatch.timer b/systemd/pkgwatch.timer new file mode 100644 index 0000000..fb65478 --- /dev/null +++ b/systemd/pkgwatch.timer @@ -0,0 +1,12 @@ +# Periodic, not persistent (see docs/SPEC.md > Vision): a missed run while +# the machine was off just gets caught by the next tick, no catch-up burst. +[Unit] +Description=Run pkgwatch hourly + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=5min +Persistent=false + +[Install] +WantedBy=timers.target