diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index a168fe4..6e7acdb 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -78,44 +78,6 @@ cable leaves. Capture steps in HARDWARE-QUEUE. ## NEXT -### 128. Auto-prune system + HM generations: >14 days, but keep ≥3 past - -Bernardo 2026-07-15 (refined same day): automatic cleanup of **both** -NixOS (system) and Home Manager (per-user) generations so boot menu, -desktop rollback, and store do not grow without bound, with a safety floor: - -1. **Age:** candidates older than **14 days** may be removed. -2. **Count floor:** always keep the **current** generation and at least - **3 past** generations — even if those three are older than 14 days. -3. Combined: delete only gens that are **both** older than 14 days **and** - older than the third-most-recent past generation. Gens within 14 days - stay regardless of how many there are (until they age out). -4. **Apply the same rule independently** to the system profile and to each - user's Home Manager profile (desktop rollback in Recovery › Desktop). - -**Why this shape:** pure age GC (`--delete-older-than`) can wipe a machine -that is rarely rebuilt down to one generation and remove useful rollback -targets; pure count limits (`configurationLimit`) drop recent work just -because the user iterates a lot. Age + floor keeps travel-laptop machines -safe and busy desktops tidy. 14d matches the existing store GC cadence -rather than a longer 30d hold. - -**Today:** `nix.gc` is already weekly with `--delete-older-than 14d` -(`modules/nixos/default.nix`) — store GC, **no** min-generation floor, and -it is not framed as an explicit dual system+HM policy. This item should -replace/refine that (or add a dedicated pruner if stock GC cannot express -the floor on both profiles). Bootloader list -(`boot.loader.systemd-boot.configurationLimit`) may need to stay in sync -so removed system profiles do not leave dead boot entries. - -Pass = weekly (or post-rebuild) cleanup enforces 14d + keep ≥3 past on -**system and HM** generations; a machine with only old gens never goes -below current+3 on either profile; docs/RECOVERY still matches boot-menu -and desktop rollback; doctor or a check asserts the timer/unit exists and -the prune script's dry-run logic. V3: box with many gens — boot menu + -`nix-env -p /nix/var/nix/profiles/system --list-generations` + -`home-manager generations` after a prune. - ### 120. A netinstall ISO, next to the fat offline one Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index e442271..797e179 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,17 @@ Template: --- +## 2026-07-15 — #128 generation prune (14d + keep ≥3 past, system+HM) +- **Task:** Implement auto-prune for system and Home Manager generations. +- **Did:** `pkgs/nomarchy-gen-prune` + `modules/nixos/gen-prune.nix` weekly + timer; pure select (current + 3 past always; else drop if age>14d); store + GC without `--delete-older-than`; bootloader refresh after system prune. + Doctor fix + RECOVERY note. Closed #128. +- **Verified:** V0/V1 — `checks.gen-prune` (self-test + dry-run + timer + assert + no age-only gc options). V3: run on a multi-gen box. +- **Pending:** V3 on real machine with many gens. +- **Next suggestion:** human #115/#120, or hardware queue. + ## 2026-07-15 — File #128 generation auto-prune policy (no code) - **Task:** Bernardo: auto-clean gens older than N days but always keep ≥3 past; include HM; prefer 14d over 30d. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 51f26a5..c96fb7c 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -65,6 +65,11 @@ broken one. Make the fix stick from the working boot: revert the change in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix` back), then `nomarchy-rebuild`. +**Automatic prune (#128):** a weekly timer (`nomarchy-gen-prune`) removes +system *and* Home Manager generations that are **older than 14 days** and +**beyond the three most recent past gens** (current + ≥3 past always kept). +Manual: `sudo nomarchy-gen-prune` (or `--dry-run`). + ## 4. Files went missing or wrong — snapshots (BTRFS installs) With `nomarchy.system.snapper.enable` (the installer's default on diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e162a59..470a716 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -427,6 +427,11 @@ Design/decision records and a running log of shipped work (items marked default `$TERMINAL` / SUPER+Return, doctor + calendar + what-changed use `kitty --class=com.nomarchy.*`. Ghostty module and install-time GL probe removed. V3: Acer SUPER+Return + doctor float. +- ✓ **Generation prune (#128):** weekly `nomarchy-gen-prune` (system + HM): + delete only gens older than **14 days** that are also beyond the **3 most + recent past** gens (current always kept). Stock `nix.gc` no longer uses + `--delete-older-than` (floor-free). `checks.gen-prune` self-test + unit + wiring. Manual: `sudo nomarchy-gen-prune` / `--dry-run`. - ✓ **Recovery + System IA + Control Center retired (#111 / #105 / #110):** System menu is Connectivity / Devices / Recovery / Preferences (root still six entries). Recovery is scope-first: Desktop generation (HM), System boot diff --git a/flake.nix b/flake.nix index 6b4e293..065d513 100644 --- a/flake.nix +++ b/flake.nix @@ -90,6 +90,7 @@ nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { }; nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { }; nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { }; + nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { }; # pull / rebuild / home (+ legacy aliases) — also on HM so a home # switch can refresh a broken system-package nomarchy-pull. # Includes nomarchy-what-changed (generation readability, #82). @@ -604,6 +605,36 @@ touch $out ''; + # Generation prune (#128): pure selection self-test (14d + keep ≥3 + # past) baked into the script; assert timer+service on the distro. + gen-prune = + let + node = self.nixosConfigurations.nomarchy; + timerWanted = + builtins.elem "timers.target" + (node.config.systemd.timers.nomarchy-gen-prune.wantedBy or [ ]); + execStart = + node.config.systemd.services.nomarchy-gen-prune.serviceConfig.ExecStart or ""; + gcOpts = node.config.nix.gc.options or ""; + noAgeWipe = + gcOpts == "" + || !(nixpkgs.lib.hasInfix "delete-older-than" gcOpts); + in + assert nixpkgs.lib.assertMsg timerWanted + "gen-prune: timer not wanted by timers.target"; + assert nixpkgs.lib.assertMsg (execStart != "") + "gen-prune: service ExecStart empty"; + assert nixpkgs.lib.assertMsg noAgeWipe + "gen-prune: nix.gc.options must not use --delete-older-than (got: ${gcOpts})"; + pkgs.runCommand "nomarchy-gen-prune" + { nativeBuildInputs = [ pkgs.bash ]; } + '' + set -eu + ${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --self-test + ${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --dry-run >/dev/null + touch $out + ''; + # Installer keyboard no-variant regression (item 91): gum's `(none)` # row is display-only. Exercise it through the real template # patcher, then build the exact us + empty-variant console keymap diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index b4c99a3..8205fde 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -35,7 +35,11 @@ let in if builtins.isBool v then v else null; in { - imports = [ ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix ]; + imports = [ + ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix + ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix + ./gen-prune.nix # #128 system+HM generation prune (14d, keep ≥3 past) + ]; config = { # Distro branding. distroName flows into /etc/os-release PRETTY_NAME, @@ -512,11 +516,8 @@ in # dirty" warning fires on every rebuild and is pure noise here. warn-dirty = lib.mkDefault false; }; - gc = { - automatic = lib.mkDefault true; - dates = lib.mkDefault "weekly"; - options = lib.mkDefault "--delete-older-than 14d"; - }; + # Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128). }; }; } + diff --git a/modules/nixos/gen-prune.nix b/modules/nixos/gen-prune.nix new file mode 100644 index 0000000..96d628d --- /dev/null +++ b/modules/nixos/gen-prune.nix @@ -0,0 +1,41 @@ +# #128 — weekly generation prune: system + Home Manager profiles. +# Policy: drop gens older than 14 days only when they are beyond the +# three most recent *past* generations (current always kept). +{ config, lib, pkgs, ... }: + +{ + config = { + environment.systemPackages = [ pkgs.nomarchy-gen-prune ]; + + # Stock nix.gc --delete-older-than has no keep-N floor and would fight + # this policy. Keep weekly store GC for dead paths only; generation + # selection is nomarchy-gen-prune's job. + nix.gc = { + automatic = lib.mkDefault true; + dates = lib.mkDefault "weekly"; + # Empty options → collect unreferenced store paths only (no age-based + # profile generation wipe). + options = lib.mkDefault ""; + }; + + systemd.services.nomarchy-gen-prune = { + description = "Prune old NixOS and Home Manager generations (14d, keep ≥3 past)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune"; + }; + # After the stock store GC timer if both fire weekly. + after = [ "nix-gc.service" ]; + }; + + systemd.timers.nomarchy-gen-prune = { + description = "Weekly Nix generation prune (Nomarchy #128)"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "weekly"; + Persistent = true; + RandomizedDelaySec = "1h"; + }; + }; + }; +} diff --git a/pkgs/nomarchy-doctor/nomarchy-doctor.sh b/pkgs/nomarchy-doctor/nomarchy-doctor.sh index 2bfd05a..9757657 100644 --- a/pkgs/nomarchy-doctor/nomarchy-doctor.sh +++ b/pkgs/nomarchy-doctor/nomarchy-doctor.sh @@ -60,7 +60,7 @@ for mp in / /boot /nix; do avail=$(numfmt --to=iec $((availkb * 1024))) if [ "$use" -ge 90 ]; then bad "$mp is ${use}% full (${avail} free)" \ - "sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)" + "sudo nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)" else ok "$mp has space (${use}% used, ${avail} free)" fi @@ -127,6 +127,21 @@ else skip "system generation age (no system profile)" fi +# ── generation prune (#128: 14d + keep ≥3 past, system + HM) ───────── +if [ -n "$(systemctl list-unit-files nomarchy-gen-prune.timer --no-legend --plain 2>/dev/null)" ]; then + if systemctl is-enabled --quiet nomarchy-gen-prune.timer 2>/dev/null; then + ok "generation prune timer is enabled (14d, keep ≥3 past)" + else + bad "nomarchy-gen-prune.timer is installed but not enabled" \ + "systemctl enable --now nomarchy-gen-prune.timer" + fi +elif command -v nomarchy-gen-prune >/dev/null 2>&1; then + warn "nomarchy-gen-prune is on PATH but no timer unit" \ + "rebuild the system so gen-prune.nix ships the weekly timer" +else + skip "generation prune (package not on this machine)" +fi + # ── snapper timeline (when enabled) ────────────────────────────────── if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then if systemctl is-active --quiet snapper-timeline.timer; then diff --git a/pkgs/nomarchy-gen-prune/default.nix b/pkgs/nomarchy-gen-prune/default.nix new file mode 100644 index 0000000..fb6171c --- /dev/null +++ b/pkgs/nomarchy-gen-prune/default.nix @@ -0,0 +1,228 @@ +{ lib +, writeShellScriptBin +, coreutils +, gnugrep +, gawk +, nix +, findutils +}: + +# #128 — prune system + Home Manager generations: +# drop only if older than 14 days AND beyond the 3 most recent *past* +# gens (current + ≥3 past always kept). Store reclaim is a plain +# nix-collect-garbage after (no --delete-older-than — that would ignore +# the floor). +writeShellScriptBin "nomarchy-gen-prune" '' + set -euo pipefail + PATH=${lib.makeBinPath [ coreutils gnugrep gawk nix findutils ]}:$PATH + + MAX_AGE_DAYS=''${NOMARCHY_GEN_PRUNE_MAX_AGE_DAYS:-14} + KEEP_PAST=''${NOMARCHY_GEN_PRUNE_KEEP_PAST:-3} + DRY=0 + SELFTEST=0 + + usage() { + echo "usage: nomarchy-gen-prune [--dry-run] [--self-test]" >&2 + echo " Prune NixOS system + Home Manager profile generations:" >&2 + echo " delete only if older than ''${MAX_AGE_DAYS}d and beyond the" >&2 + echo " ''${KEEP_PAST} most recent past gens (current always kept)." >&2 + exit 64 + } + + for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + --self-test) SELFTEST=1 ;; + -h|--help) usage ;; + *) usage ;; + esac + done + + log() { echo "nomarchy-gen-prune: $*" >&2; } + + # Pure selection: stdin lines "NUM EPOCH [current]" + # stdout: generation numbers to delete (one per line). + # Always protect current + up to KEEP_PAST gens with number < current + # (highest first). Others delete only if EPOCH < now - MAX_AGE_DAYS. + select_deletions() { + local now keep_past max_age + now=$(date +%s) + keep_past=$KEEP_PAST + max_age=$MAX_AGE_DAYS + awk -v now="$now" -v keep_past="$keep_past" -v max_age="$max_age" ' + NF < 2 { next } + { + num = $1 + 0 + ts = $2 + 0 + cur = (NF >= 3 && $3 == "current") + nums[n] = num; tss[num] = ts; if (cur) current = num; n++ + } + END { + if (n == 0) exit 0 + if (current == 0) { + # No marker: treat highest generation number as current. + current = nums[0] + for (i = 1; i < n; i++) if (nums[i] > current) current = nums[i] + } + # Past gens: number < current, sort desc. + pn = 0 + for (i = 0; i < n; i++) { + if (nums[i] < current) { past[pn++] = nums[i] } + } + # bubble-sort past desc (small n) + for (i = 0; i < pn; i++) + for (j = i + 1; j < pn; j++) + if (past[j] > past[i]) { t = past[i]; past[i] = past[j]; past[j] = t } + protect[current] = 1 + for (i = 0; i < pn && i < keep_past; i++) protect[past[i]] = 1 + cutoff = now - (max_age * 86400) + for (i = 0; i < n; i++) { + g = nums[i] + if (protect[g]) continue + if (tss[g] < cutoff) print g + } + } + ' + } + + if [ "$SELFTEST" = 1 ]; then + # Fixture: now-fixed via epoch math in the data itself. + # current=10; past 9,8,7 protected; 6 is >14d old → delete; 5 is young → keep. + export KEEP_PAST=3 MAX_AGE_DAYS=14 + now=$(date +%s) + old=$((now - 20 * 86400)) + young=$((now - 2 * 86400)) + got=$(printf '%s\n' \ + "10 $young current" \ + "9 $old" \ + "8 $old" \ + "7 $old" \ + "6 $old" \ + "5 $young" \ + | select_deletions) + echo "$got" | grep -qx 6 || { echo "self-test: expected only 6, got: [$got]" >&2; exit 1; } + # Rarely rebuilt: only old gens → delete none below floor (keep 10+9+8+7) + got=$(printf '%s\n' \ + "10 $old current" \ + "9 $old" \ + "8 $old" \ + "7 $old" \ + "6 $old" \ + | select_deletions) + echo "$got" | grep -qx 6 || { echo "self-test: floor failed, got: [$got]" >&2; exit 1; } + # Only 3 gens total, all old → delete nothing + got=$(printf '%s\n' \ + "3 $old current" \ + "2 $old" \ + "1 $old" \ + | select_deletions) + [ -z "$got" ] || { echo "self-test: expected empty, got: [$got]" >&2; exit 1; } + log "self-test ok" + exit 0 + fi + + # Enumerate "NUM EPOCH [current]" for a profile symlink base. + # profile is the live link, e.g. /nix/var/nix/profiles/system + list_gens() { + local profile=$1 + local dir base cur_base cur_num name num ts + [ -e "$profile" ] || [ -L "$profile" ] || return 0 + dir=$(dirname -- "$profile") + base=$(basename -- "$profile") + cur_base=$(basename -- "$(readlink "$profile" 2>/dev/null || true)") + cur_num= + case "$cur_base" in + "$base"-*-link) + cur_num=''${cur_base#"$base"-} + cur_num=''${cur_num%-link} + ;; + esac + # shellcheck disable=SC2035 + for link in "$dir"/"$base"-*-link; do + [ -e "$link" ] || [ -L "$link" ] || continue + name=$(basename -- "$link") + case "$name" in + "$base"-*-link) ;; + *) continue ;; + esac + num=''${name#"$base"-} + num=''${num%-link} + case "$num" in *[!0-9]*|"") continue ;; esac + ts=$(stat -c %Y -- "$link" 2>/dev/null || echo 0) + if [ -n "$cur_num" ] && [ "$num" = "$cur_num" ]; then + printf '%s %s current\n' "$num" "$ts" + else + printf '%s %s\n' "$num" "$ts" + fi + done + } + + prune_profile() { + local profile=$1 label=$2 + local list dels + if [ ! -e "$profile" ] && [ ! -L "$profile" ]; then + log "skip $label (no profile at $profile)" + return 0 + fi + list=$(list_gens "$profile" || true) + if [ -z "$list" ]; then + log "skip $label (no generation links)" + return 0 + fi + dels=$(printf '%s\n' "$list" | select_deletions) + if [ -z "$dels" ]; then + log "$label: nothing to prune" + return 0 + fi + # shellcheck disable=SC2086 + set -- $dels + log "$label: delete generations $* (keep current + $KEEP_PAST past; age>$MAX_AGE_DAYS d)" + if [ "$DRY" = 1 ]; then + log "$label: dry-run — not deleting" + return 0 + fi + # nix-env needs a writeable profile; system requires root. + nix-env -p "$profile" --delete-generations "$@" \ + || log "$label: nix-env --delete-generations failed (need root for system?)" + } + + prune_profile /nix/var/nix/profiles/system "system" + + # Home Manager: per-user under /nix/var/nix/profiles and XDG state. + if [ -d /nix/var/nix/profiles/per-user ]; then + for u in /nix/var/nix/profiles/per-user/*; do + [ -d "$u" ] || continue + prune_profile "$u/home-manager" "home-manager($(basename "$u"))" + done + fi + # Standalone / modern HM state dir for real users' homes. + if [ -d /home ]; then + for h in /home/*; do + [ -d "$h" ] || continue + prune_profile "$h/.local/state/nix/profiles/home-manager" \ + "home-manager-xdg($(basename "$h"))" + done + fi + # root's XDG state if present + prune_profile /root/.local/state/nix/profiles/home-manager "home-manager-xdg(root)" + + if [ "$DRY" = 1 ]; then + log "dry-run done (no store GC)" + exit 0 + fi + + # Reclaim store paths freed by gen deletion — no --delete-older-than + # (would re-introduce floor-free age GC on any remaining profiles). + log "nix-collect-garbage (dead store paths only)" + nix-collect-garbage || true + + # Refresh systemd-boot entries so removed system gens disappear from + # the menu without waiting for the next rebuild. + if [ -x /run/current-system/bin/switch-to-configuration ]; then + log "refreshing bootloader entries" + /run/current-system/bin/switch-to-configuration boot >/dev/null 2>&1 \ + || log "bootloader refresh skipped (non-fatal)" + fi + + log "done" +''