diff --git a/docs/SPEC.md b/docs/SPEC.md index b6cfa09..6c08df9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -409,7 +409,25 @@ 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/`, 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 + 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 +491,8 @@ 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 + 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 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..a7a3bab --- /dev/null +++ b/src/notifier.rs @@ -0,0 +1,111 @@ +//! 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(), + ), + } +} + +/// 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<'_>) { + 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/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()), + } + } +} diff --git a/systemd/pkgwatch-failure.service b/systemd/pkgwatch-failure.service new file mode 100644 index 0000000..35e2aaf --- /dev/null +++ b/systemd/pkgwatch-failure.service @@ -0,0 +1,11 @@ +# 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 +# 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" 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..409c0a1 --- /dev/null +++ b/systemd/pkgwatch.timer @@ -0,0 +1,18 @@ +# 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. +# +# 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 at login and hourly + +[Timer] +OnStartupSec=10s +OnCalendar=hourly +Persistent=false + +[Install] +WantedBy=timers.target