From b373881c86a16a4982bce074928a37b3a9521fd8 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:45:26 +0200 Subject: [PATCH 1/6] 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 From 071cf27f72c58c3737c25c969e37b42ff53d24df Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:48:28 +0200 Subject: [PATCH 2/6] Run a check shortly after login, not just on the hourly tick Co-Authored-By: Claude Sonnet 5 --- systemd/pkgwatch.timer | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/systemd/pkgwatch.timer b/systemd/pkgwatch.timer index fb65478..c8f436f 100644 --- a/systemd/pkgwatch.timer +++ b/systemd/pkgwatch.timer @@ -1,9 +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. +# Periodic, not persistent (see docs/SPEC.md > Vision): no catch-up burst +# for missed ticks. The laptop is only on while logged in, so the user +# manager starting (= login) is what matters: OnStartupSec runs a check +# shortly after login instead of waiting up to an hour for the next tick. [Unit] Description=Run pkgwatch hourly [Timer] +OnStartupSec=2min OnCalendar=hourly RandomizedDelaySec=5min Persistent=false From 7a00f412bbc5c6991df0719029f5b3949d4f9361 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:49:04 +0200 Subject: [PATCH 3/6] Check 10s after login instead of 2min Co-Authored-By: Claude Sonnet 5 --- systemd/pkgwatch.timer | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/systemd/pkgwatch.timer b/systemd/pkgwatch.timer index c8f436f..486afc3 100644 --- a/systemd/pkgwatch.timer +++ b/systemd/pkgwatch.timer @@ -1,12 +1,12 @@ # Periodic, not persistent (see docs/SPEC.md > Vision): no catch-up burst # for missed ticks. The laptop is only on while logged in, so the user # manager starting (= login) is what matters: OnStartupSec runs a check -# shortly after login instead of waiting up to an hour for the next tick. +# right after login (10s, so the session bus and network are up) instead of waiting up to an hour for the next tick. [Unit] Description=Run pkgwatch hourly [Timer] -OnStartupSec=2min +OnStartupSec=10s OnCalendar=hourly RandomizedDelaySec=5min Persistent=false From 1f84460a6562b7ec1f94601c2f53da9951173798 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:49:14 +0200 Subject: [PATCH 4/6] Drop RandomizedDelaySec so the login check really is immediate Co-Authored-By: Claude Sonnet 5 --- systemd/pkgwatch.timer | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/systemd/pkgwatch.timer b/systemd/pkgwatch.timer index 486afc3..409c0a1 100644 --- a/systemd/pkgwatch.timer +++ b/systemd/pkgwatch.timer @@ -1,14 +1,17 @@ # Periodic, not persistent (see docs/SPEC.md > Vision): no catch-up burst # for missed ticks. The laptop is only on while logged in, so the user # manager starting (= login) is what matters: OnStartupSec runs a check -# right after login (10s, so the session bus and network are up) instead of waiting up to an hour for the next tick. +# right after login (10s, so the session bus and network are up) instead +# of waiting up to an hour for the next tick. +# +# No RandomizedDelaySec: it applies to every trigger, including the +# startup one, and a single machine has no herd to spread out anyway. [Unit] -Description=Run pkgwatch hourly +Description=Run pkgwatch at login and hourly [Timer] OnStartupSec=10s OnCalendar=hourly -RandomizedDelaySec=5min Persistent=false [Install] From 38a32a0c603973b4c0a89fbc13ca6435bd66c623 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:51:10 +0200 Subject: [PATCH 5/6] Apply review feedback: docs, doc comment, lifetimes Co-Authored-By: Claude Sonnet 5 --- docs/SPEC.md | 20 +++++++++++++------- src/notifier.rs | 8 +++++--- systemd/pkgwatch-failure.service | 2 ++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 99acc50..6c08df9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -409,13 +409,18 @@ 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. *(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.)* + on this box. *(Implemented — user-level units under `systemd/`, run 10s + after login and then hourly, non-persistent. Install from the main + checkout:* + + ```sh + 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 @@ -486,6 +491,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), + per-package `check_interval` (the timer is a fixed hourly tick), 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 diff --git a/src/notifier.rs b/src/notifier.rs index 4c6cf8f..a7a3bab 100644 --- a/src/notifier.rs +++ b/src/notifier.rs @@ -18,7 +18,7 @@ pub enum Event<'a> { /// (summary, body) for `event` — pure, so the wording is unit-testable /// without a notification daemon. -fn message(event: Event) -> (String, String) { +fn message(event: Event<'_>) -> (String, String) { match event { Event::NeedsReview { name, version } => ( format!("{name} {version} needs review"), @@ -31,13 +31,15 @@ fn message(event: Event) -> (String, String) { } } -pub fn notify(event: Event) { +/// Best-effort desktop notification via the real `notify-send`; never +/// fails the caller. +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) { +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]) diff --git a/systemd/pkgwatch-failure.service b/systemd/pkgwatch-failure.service index f40b413..35e2aaf 100644 --- a/systemd/pkgwatch-failure.service +++ b/systemd/pkgwatch-failure.service @@ -6,4 +6,6 @@ Description=Notify that a pkgwatch run failed [Service] Type=oneshot +# Absolute path: systemd requires one, unlike the in-process notifier, +# which resolves notify-send from PATH. ExecStart=/usr/bin/notify-send --app-name=pkgwatch --urgency=critical "pkgwatch run failed" "See: journalctl --user -u pkgwatch.service" From dd42bcbe75554fd45988b4d8d2ff3a071886157e Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 09:54:14 +0200 Subject: [PATCH 6/6] Fix ETXTBSY flake in stub-script tests write_executable_script now probe-execs the script and retries on Text file busy, so it returns only once the script is actually runnable. Co-Authored-By: Claude Sonnet 5 --- src/test_support.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/test_support.rs b/src/test_support.rs index 49b701a..d53c80b 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,21 +1,54 @@ //! Test-only fixture helpers shared across modules' `#[cfg(test)]` code -//! (`publisher`, `sanity`) — not production code, and not built outside +//! (`publisher`, `sanity`, `notifier`) — not production code, and not built outside //! `cargo test`. See docs/ARCHITECTURE.md > "organize by pipeline stage, not //! by layer": this exists to remove one specific piece of duplication //! (two near-identical copies of "write an executable shell script"), not //! as a general test-utils dump. use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +/// Set by `wait_until_executable`'s probe so the script exits before +/// running its real body. +const PROBE_ENV: &str = "PKGWATCH_TEST_SCRIPT_PROBE"; +/// `ETXTBSY`: exec of a file some process still has open for writing. +const TEXT_FILE_BUSY: i32 = 26; /// Writes an executable `#!/bin/sh` script named `name` into `dir`, /// running `body` as its contents. Used to stand in for a real binary /// (`repo-add`, a package's own `--version` command) in tests, without /// needing the real tool installed or a mutated global `PATH`. +/// +/// Doesn't return until the script can actually be exec'd. `cargo test` +/// runs tests on parallel threads, and a `fork` on another thread between +/// this function's write-open and close leaves the child holding a copy of +/// the write fd until it execs, so an immediate exec of the new script can +/// fail with `Text file busy`. Once no holder is left none can appear (our +/// fd is closed), so a probe exec that succeeds proves later ones will. pub(crate) fn write_executable_script(dir: &Path, name: &str, body: &str) -> PathBuf { let path = dir.join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::write( + &path, + format!("#!/bin/sh\n[ -z \"${PROBE_ENV}\" ] || exit 0\n{body}\n"), + ) + .unwrap(); let mut perms = std::fs::metadata(&path).unwrap().permissions(); std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); std::fs::set_permissions(&path, perms).unwrap(); + wait_until_executable(&path); path } + +fn wait_until_executable(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match Command::new(path).env(PROBE_ENV, "1").status() { + Ok(_) => return, + Err(err) if err.raw_os_error() == Some(TEXT_FILE_BUSY) && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(err) => panic!("probe-exec of {} failed: {err}", path.display()), + } + } +}