diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml index ec781c4b..49138a2e 100644 --- a/.github/workflows/shell-tests.yml +++ b/.github/workflows/shell-tests.yml @@ -27,7 +27,6 @@ jobs: set -euo pipefail tests=( test_i3blocks_persist_common.sh - test_music_parallelism.sh test_setup_dns_blocker.sh ) failed=0 @@ -124,7 +123,9 @@ jobs: - uses: actions/checkout@v4 - # The complete Arch-dependent set. All eight verified locally in + # The complete Arch-dependent set. Five of the original eight moved to + # github.com/kuhyx/digital-wellbeing with the code they test; they run + # bare on Ubuntu there. The two that remain were verified locally in # `docker run --cap-add=SYS_PTRACE archlinux:base-devel`. # # test_security_hardening.sh inspects a configured machine (chattr flags, @@ -136,13 +137,8 @@ jobs: run: | set -euo pipefail tests=( - test_hosts_guard_pacman_integration.sh test_i3blocks_efficiency.sh - test_makepkg_capped.sh - test_makepkg_wrapper.sh - test_pacman_wrapper_security.sh test_security_hardening.sh - test_shutdown_timer_monitor.sh ) failed=0 for t in "${tests[@]}"; do diff --git a/.gitignore b/.gitignore index 9b4fbb00..755796bd 100644 --- a/.gitignore +++ b/.gitignore @@ -381,7 +381,6 @@ bottles/ # archwiki_rag local vector index: regenerated by `reindex`, machine-specific, # and full of binary sqlite/parquet blobs. -linux_configuration/periodic_background/digital_wellbeing/data/ # =========================================================================== # billsplit (Flutter) — launcher icons & platform assets MUST live in-repo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49317b01..6132896d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,12 +34,7 @@ repos: hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - # chattr +i (self-commitment lock on the shutdown-enforcement - # script) makes this file unwritable on purpose; the fixer hooks - # can't open it for writing even as the owning user. - exclude: ^linux_configuration/periodic_background/digital_wellbeing/setup_midnight_shutdown\.sh$ - id: end-of-file-fixer - exclude: ^linux_configuration/periodic_background/digital_wellbeing/setup_midnight_shutdown\.sh$ - id: check-yaml args: [--unsafe] - id: check-json @@ -87,15 +82,14 @@ repos: pass_filenames: false always_run: true - id: directory-depth-cap - name: Report paths over the directory-depth cap - # Repo-wide, and deliberately --warn for now: 47 paths under - # periodic_background/{hosts,digital_wellbeing} are over the cap and - # can only come down by extracting those two units, which is blocked - # on a manual guard repoint. Running it warn-only keeps the number - # visible on every commit without blocking unrelated work. Drop - # --warn once both units are out; that is the last step of the - # extraction, not a separate decision. - entry: bash meta/scripts/check_directory_depth.sh --warn --all + name: Enforce the directory-depth cap + # Repo-wide and ENFORCING. It ran --warn while the 46 paths under + # periodic_background/{hosts,digital_wellbeing} were over the cap; + # both units are now their own repos (kuhyx/hosts-blocker, + # kuhyx/digital-wellbeing) and the count is 0, so the cap is real. + # Re-adding --warn would silently re-open the hole it was there to + # close: put the offending path somewhere legal instead. + entry: bash meta/scripts/check_directory_depth.sh --all language: system pass_filenames: false always_run: true diff --git a/AGENTS.md b/AGENTS.md index fea6f3df..06991922 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,13 @@ Extracted to their own repos: [`steam-backlog-enforcer`](https://github.com/kuhy [`phone-focus-mode`](https://github.com/kuhyx/phone-focus-mode) (with `python_pkg/focus_policy`), [`hosts-blocker`](https://github.com/kuhyx/hosts-blocker) (lives at -`~/hosts-blocker`; monorepo callers resolve it via -`linux_configuration/lib/extracted_repos.sh`). +`~/hosts-blocker`), +[`digital-wellbeing`](https://github.com/kuhyx/digital-wellbeing) (lives at +`~/digital-wellbeing`; carries the six suites that used to sit in +`linux_configuration/tests/`). Monorepo callers resolve both via +`linux_configuration/lib/extracted_repos.sh`, which reads the invoking user's +home from `SUDO_USER` — never `$HOME`, which is `/root` under sudo and under +systemd. Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). ## Adding a Flutter app diff --git a/docs/superpowers/contracts/extract-digital-wellbeing-2026-08-23.json b/docs/superpowers/contracts/extract-digital-wellbeing-2026-08-23.json new file mode 100644 index 00000000..cd4abd98 --- /dev/null +++ b/docs/superpowers/contracts/extract-digital-wellbeing-2026-08-23.json @@ -0,0 +1,22 @@ +{ + "title": "Extract digital_wellbeing, move its six test suites with it, and make the directory-depth cap enforce", + "objective": "Clear the last 41 directory-depth violations by moving the unit to github.com/kuhyx/digital-wellbeing, then delete --warn from the depth hook so the cap is real -- the point of the whole campaign.", + "acceptance_criteria": [ + "check_directory_depth.sh --all exits 0 with --warn removed from the hook entry.", + "The extracted repo's history spans all three paths the unit has lived at (63 commits), plus the 24 from the separately-filtered test suites.", + "All 80 tracked files are byte-identical to the monorepo copies; node_modules and __pycache__ are excluded.", + "All six moved suites plus every lib/tests/run_all.sh pass from ~/digital-wellbeing, with no root, pacman or Arch userland -- verified under env -i.", + "Every moved test name is deleted from BOTH hardcoded arrays in .github/workflows/shell-tests.yml; every name still listed there resolves to a file that exists.", + "check_and_enable_services.sh runs to completion and reports ok for pacman_wrapper, makepkg_wrapper, midnight_shutdown, startup_monitor, periodic_systems and hosts.", + "The pacman-wrapper drift manifest verifies with sha256sum -c after regeneration.", + "report_live_state.sh --check reports ZERO stale linux_configuration/scripts/ references (down from 8).", + "No lint suppression is added anywhere." + ], + "out_of_scope": [ + "Fixing the unwrapped discord/signal-desktop binaries -- pre-existing live state, identical at baseline (18 passed / 1 failed / 5 skipped both before and after).", + "The workout_locker error, which concerns the separate screen-locker repo.", + "The Nextcloud dedup, measured and recommended against in the handoff.", + "Reformatting vendor/common.sh, which is a verbatim copy." + ], + "verifier": "./meta/scripts/check_directory_depth.sh --all exits 0; ~/digital-wellbeing/tests/run_all.sh passes all seven suites; grep for the six moved test names in shell-tests.yml returns 0; sudo check_and_enable_services.sh completes with no unbound-variable error and no missing-script error; sudo sha256sum -c --status /var/lib/pacman-wrapper/source.sha256 exits 0; report_live_state.sh --check greps 0 for linux_configuration/scripts/; shellcheck clean on all nine changed shell files." +} diff --git a/docs/superpowers/evidence/extract-digital-wellbeing-2026-08-23.json b/docs/superpowers/evidence/extract-digital-wellbeing-2026-08-23.json new file mode 100644 index 00000000..e48de6d6 --- /dev/null +++ b/docs/superpowers/evidence/extract-digital-wellbeing-2026-08-23.json @@ -0,0 +1,92 @@ +{ + "intent": "Extract the last over-cap unit into github.com/kuhyx/digital-wellbeing (cloned at ~/digital-wellbeing), then delete --warn from the directory-depth-cap hook so the cap enforces for real. This is the closing step of the restructure campaign.", + "scope": [ + "REMOVED: linux_configuration/periodic_background/digital_wellbeing/ (80 tracked files)", + "REMOVED: 6 test suites + 2 test libs from linux_configuration/tests/", + "REWIRED: check_and_enable_services.sh, setup_periodic_system.sh, install_core_system.sh, fresh-install/main.sh, installers/install_ungoogled_chromium_ubuntu.sh, tests/test_security_hardening.sh, tests/lib/security_apps.sh, tests/lib/security_pacman.sh", + "CONFIG: .pre-commit-config.yaml (--warn removed, 2 stale excludes dropped), .github/workflows/shell-tests.yml, meta/shell-coverage-allowlist.txt, meta/pyproject.toml, .gitignore, meta/scripts/check_directory_depth.sh", + "LIVE: /usr/local/bin/periodic-system-maintenance.sh", + "NON-GOAL: the unwrapped discord/signal-desktop binaries (pre-existing) and the workout_locker error (different repo)" + ], + "changes": [ + "Filtered history across all three paths the unit has lived at (63 commits). The current path alone holds 2 -- the same trap as hosts-blocker, and the reason the count was checked before filtering rather than after.", + "The six suites that test this code lived in linux_configuration/tests/, not beside it. They were filtered separately (24 commits) and merged as a second root, then their hardcoded periodic_background/digital_wellbeing/ prefixes rewritten for the new root-level layout. A path filter of the unit alone would have left them in the monorepo testing code that had left it.", + "Vendored linux_configuration/lib/common.sh into vendor/ rather than trimming it: set_actual_user_vars calls get_actual_user and get_actual_user_home, so a 4-function trim breaks silently. It went to vendor/ and not lib/ because test_music_parallelism.sh copies the unit's lib/ wholesale into a scratch worktree while stubbing the shared lib -- with both in one directory the real common.sh overwrote the stub and the test measured the wrong library.", + "Generalised that test's layout-drift guard from a required ../ prefix to (../)*; the script now sits at the repo root so the prefix is empty. The guard caught this move, which is what it was written for.", + "Dropped the two .pre-commit-config.yaml excludes for setup_midnight_shutdown.sh instead of moving them: lsattr shows nothing in the unit is chattr +i any more, and the fixer hooks leave the file untouched when allowed to run.", + "Removed --warn from the directory-depth-cap hook and reworded its comment from 'deliberately --warn for now' to state that re-adding it re-opens the hole." + ], + "verification": [ + { + "command": "./meta/scripts/check_directory_depth.sh --all", + "result": "pass", + "evidence": "'all checked paths are within 2 directories', exit 0. 46 -> 41 (after hosts) -> 0. The cap now enforces without --warn." + }, + { + "command": "per-root commit counts before filtering", + "result": "pass", + "evidence": "current path 2, scripts/periodic_background/ 41, scripts/digital_wellbeing/ 23; 63 on main. The 94 an --all union reported included unreachable refs and one merge commit that filter-repo correctly prunes." + }, + { + "command": "cmp each of the 80 tracked files against the monorepo copy", + "result": "pass", + "evidence": "ALL 80 TRACKED FILES IDENTICAL. node_modules (340 files) and __pycache__ correctly excluded -- the repo has 80 tracked files, not the 423 on disk." + }, + { + "command": "~/digital-wellbeing/tests/run_all.sh", + "result": "pass", + "evidence": "All six regression suites plus the discovered lib/tests/run_all.sh pass: 'All digital-wellbeing suites passed.'" + }, + { + "command": "env -i PATH=/usr/bin:/bin HOME=/tmp bash ./tests/run_all.sh", + "result": "pass", + "evidence": "Passes under a minimal environment, and no suite needs root or invokes pacman -- so the new repo needs no arch-tests job even though five of these ran in the monorepo's Arch container." + }, + { + "command": "sudo check_and_enable_services.sh (first attempt)", + "result": "fail", + "evidence": "'line 61: DW_REPO: unbound variable' -- the definition sat below its first use. shellcheck and grep both passed the file; only running it caught this." + }, + { + "command": "sudo check_and_enable_services.sh (after moving the block)", + "result": "pass", + "evidence": "Runs to completion: pacman_wrapper ok, makepkg_wrapper ok, midnight_shutdown ok, startup_monitor ok, periodic_systems ok, hosts ok, guest_mode_removal ok. Applied 3 fix(es)." + }, + { + "command": "sudo sha256sum -c --status /var/lib/pacman-wrapper/source.sha256", + "result": "pass", + "evidence": "Exit 0. The manifest embeds ABSOLUTE paths and had named deleted scripts/ paths since the flatten, so the drift check reported a permanent false 'stale or tampered'. It now names ~/digital-wellbeing and verifies -- with unchanged hashes, confirming the extracted bytes are identical." + }, + { + "command": "report_live_state.sh --check | grep -c linux_configuration/scripts/", + "result": "pass", + "evidence": "0, down from the 8 pre-existing stale references at session start. Removals only; none added at any point." + }, + { + "command": "grep the six moved test names in .github/workflows/shell-tests.yml", + "result": "pass", + "evidence": "0 matches, and all four names still listed (test_i3blocks_persist_common, test_setup_dns_blocker, test_i3blocks_efficiency, test_security_hardening) resolve to files that exist." + }, + { + "command": "test_security_hardening.sh, current tree vs a detached worktree at HEAD", + "result": "pass", + "evidence": "18 passed / 1 failed / 5 skipped in BOTH. The one failure is 'Compulsive block wrappers installed' -- discord and signal-desktop lack their /usr/bin wrapper. Live-system state, identical before and after." + }, + { + "command": "shellcheck on all nine changed shell files", + "result": "pass", + "evidence": "ALL CLEAN, no severity filter." + } + ], + "risks": [ + "vendor/common.sh duplicates 163 lines of the monorepo lib. The two copies may drift; that is accepted, and the README says so, so a future session does not 're-fix' it by re-coupling the repos.", + "The pacman wrapper's drift manifest is regenerated by the checker's own fix path. A machine that clones digital-wellbeing to a non-default location must re-run install_pacman_wrapper.sh or the manifest will name the wrong paths.", + "install_core_system.sh on a fresh machine now hard-stops at three modules until both extracted repos are cloned. Deliberate and loud; INSTALLER_FIX_TASK.md tracks fresh-machine installer defects.", + "The depth cap is now enforcing, so any future path more than two directories deep is a hard commit failure rather than a warning." + ], + "rollback": [ + "git revert this commit to restore the unit, the six suites, the two test libs and every rewired caller, and to put --warn back on the depth hook.", + "Restore /var/tmp/periodic-system-maintenance.sh.pre-dw to /usr/local/bin/.", + "After rollback validate: check_directory_depth.sh --all reports 41 violations again, and sudo check_and_enable_services.sh still completes without an unbound-variable error." + ] +} diff --git a/linux_configuration/.githooks/pre-commit b/linux_configuration/.githooks/pre-commit index 3a25c149..78307cc0 100755 --- a/linux_configuration/.githooks/pre-commit +++ b/linux_configuration/.githooks/pre-commit @@ -81,8 +81,16 @@ fi # --threshold 2: fail if more than 2% duplication # Excluded from the scan, and why: # .venv/ vendored third-party shell (virtualenv, tqdm completions). +# .ci-mirror-venv/ # Not ours, and it alone pushed the repo over the bar: the # same scan reads 2.50% with it and 1.67% without. +# .ci-mirror-venv/ is the SAME third-party shell under a +# different name -- the clean-venv the ci-mirror pre-push gate +# builds. It is untracked and generated, but jscpd walks the +# working tree, not the index, so it was still being scanned. +# Extracting digital_wellbeing shrank the denominator enough +# for those virtualenv/tqdm clones to push the total to 2.09% +# and block a commit that added no duplication at all. # lib/tests/ a fixture IS repetitive by design; the 2% rule exists to # stop duplicated production logic, not shared test setup. # Needed headroom for the Phase 3 coverage campaign. @@ -92,7 +100,7 @@ jscpd_output=$("$JSCPD_BIN" \ --min-tokens 25 \ --threshold 2 \ --reporters "console" \ - --ignore "**/node_modules/**,**/.git/**,**/misc/**,**/*.txt,**/.venv/**,**/lib/tests/**,**/tests/**" \ + --ignore "**/node_modules/**,**/.git/**,**/misc/**,**/*.txt,**/.venv/**,**/.ci-mirror-venv/**,**/lib/tests/**,**/tests/**" \ . 2>&1) || jscpd_exit=$? if [[ ${jscpd_exit:-0} -ne 0 ]]; then diff --git a/linux_configuration/fresh-install/main.sh b/linux_configuration/fresh-install/main.sh index 6f481e3e..213bdc3e 100755 --- a/linux_configuration/fresh-install/main.sh +++ b/linux_configuration/fresh-install/main.sh @@ -179,7 +179,7 @@ fi cd ~/linux-configuration sudo "$HOME/hosts-blocker/install.sh" i3/install.sh -periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh +"$HOME/digital-wellbeing/pacman/install_pacman_wrapper.sh" fixes/nvidia_troubleshoot.sh sudo features/setup_activitywatch.sh sudo utils/setup_media_organizer.sh diff --git a/linux_configuration/install_core_system.sh b/linux_configuration/install_core_system.sh index 3811234c..81c11494 100755 --- a/linux_configuration/install_core_system.sh +++ b/linux_configuration/install_core_system.sh @@ -8,7 +8,7 @@ # # SECONDARY modules (prompted unless --all / --none given): # 4. Steam backlog enforcer – python_pkg/steam_backlog_enforcer/ -# 5. Pacman wrapper – periodic_background/digital_wellbeing/pacman/ +# 5. Pacman wrapper – github.com/kuhyx/digital-wellbeing (~/digital-wellbeing) # 6. i3 configuration – i3/ # 7. Compulsive opening block – block_compulsive_opening.sh # @@ -135,7 +135,7 @@ run_installer "Hosts blocking" \ bash "$(extracted_repo_dir hosts-blocker)/install.sh" run_installer "Midnight shutdown timer" \ - bash "$LINUX_CONFIG/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh" + bash "$(extracted_repo_dir digital-wellbeing)/setup_midnight_shutdown.sh" # ═══════════════════════════════════════════════════════════════════════════════ # SECONDARY MODULES (prompted unless --all / --none) @@ -146,13 +146,13 @@ ask_install "Steam backlog enforcer" \ sudo bash "$REPO_ROOT/python_pkg/steam_backlog_enforcer/install.sh" ask_install "Pacman wrapper" \ - bash "$LINUX_CONFIG/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh" + bash "$(extracted_repo_dir digital-wellbeing)/pacman/install_pacman_wrapper.sh" ask_install "i3 configuration" \ bash "$LINUX_CONFIG/i3/install.sh" ask_install "Compulsive opening blockade" \ - sudo bash "$LINUX_CONFIG/periodic_background/digital_wellbeing/block_compulsive_opening.sh" install + sudo bash "$(extracted_repo_dir digital-wellbeing)/block_compulsive_opening.sh" install # ═══════════════════════════════════════════════════════════════════════════════ print_summary diff --git a/linux_configuration/installers/install_ungoogled_chromium_ubuntu.sh b/linux_configuration/installers/install_ungoogled_chromium_ubuntu.sh index 1c70edfe..a572dd05 100755 --- a/linux_configuration/installers/install_ungoogled_chromium_ubuntu.sh +++ b/linux_configuration/installers/install_ungoogled_chromium_ubuntu.sh @@ -40,7 +40,9 @@ set -Eeuo pipefail SCRIPT_NAME=${0##*/} SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" -DIGITAL_WELLBEING_DIR="$SCRIPT_DIR/../periodic_background/digital_wellbeing" +# shellcheck source=../lib/extracted_repos.sh +source "$SCRIPT_DIR/../lib/extracted_repos.sh" +DIGITAL_WELLBEING_DIR="$(extracted_repo_dir digital-wellbeing)" SEEDER_SCRIPT="$DIGITAL_WELLBEING_DIR/seed_ublock_storage.js" UBLOCK_EXT_PATH="/usr/share/chromium/extensions/ublock-origin" diff --git a/linux_configuration/periodic_background/check_and_enable_services.sh b/linux_configuration/periodic_background/check_and_enable_services.sh index ef8fa604..f23d825f 100755 --- a/linux_configuration/periodic_background/check_and_enable_services.sh +++ b/linux_configuration/periodic_background/check_and_enable_services.sh @@ -57,38 +57,41 @@ NC=$'\033[0m' # No Color SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" CONFIG_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +# Resolve the invoking user's home: this script re-execs itself via sudo, so +# $HOME would be /root here. +REAL_USER="${SUDO_USER:-${USER:-$(id -un)}}" +REAL_HOME="$(getent passwd "$REAL_USER" 2>/dev/null | cut -d: -f6)" +[[ -n $REAL_HOME ]] || REAL_HOME="/home/$REAL_USER" +HOSTS_BLOCKER_REPO="${HOSTS_BLOCKER_DIR:-$REAL_HOME/hosts-blocker}" +# digital-wellbeing was EXTRACTED too (github.com/kuhyx/digital-wellbeing). +DW_REPO="${DIGITAL_WELLBEING_DIR:-$REAL_HOME/digital-wellbeing}" + # Script paths -PACMAN_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh" -MAKEPKG_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_makepkg_wrapper.sh" +PACMAN_WRAPPER_INSTALL="$DW_REPO/pacman/install_pacman_wrapper.sh" +MAKEPKG_WRAPPER_INSTALL="$DW_REPO/pacman/install_makepkg_wrapper.sh" # Drift manifests written by the two installers above (see deployment_drift). PACMAN_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/source.sha256" MAKEPKG_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/makepkg-source.sha256" -MIDNIGHT_SHUTDOWN_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh" -STARTUP_MONITOR_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/setup_pc_startup_monitor.sh" +MIDNIGHT_SHUTDOWN_SCRIPT="$DW_REPO/setup_midnight_shutdown.sh" +STARTUP_MONITOR_SCRIPT="$DW_REPO/setup_pc_startup_monitor.sh" PERIODIC_SYSTEM_SCRIPT="$CONFIG_DIR/periodic_background/setup_periodic_system.sh" # hosts-blocker was EXTRACTED (github.com/kuhyx/hosts-blocker). Resolved # below, after REAL_HOME is computed, for the same reason screen-locker is. HOSTS_INSTALL_SCRIPT="" GUARD_LIB_MIGRATE_SCRIPT="$CONFIG_DIR/./fixes/migrate_hosts_guard_to_guard_lib.sh" -COMPULSIVE_BLOCK_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/block_compulsive_opening.sh" -LEECHBLOCK_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/install_leechblock.sh" -REMOVE_GUEST_MODE_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/remove_guest_mode.sh" -VBOX_HOSTS_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" +COMPULSIVE_BLOCK_SCRIPT="$DW_REPO/block_compulsive_opening.sh" +LEECHBLOCK_SCRIPT="$DW_REPO/install_leechblock.sh" +REMOVE_GUEST_MODE_SCRIPT="$DW_REPO/remove_guest_mode.sh" +VBOX_HOSTS_SCRIPT="$DW_REPO/virtualbox/enforce_vbox_hosts.sh" # screen-locker was EXTRACTED out of this monorepo into its own repo # (github.com/kuhyx/screen-locker, checked out at ~/screen-locker), so these # paths deliberately live outside testsAndMisc. They used to point at # python_pkg/screen_locker/, which stopped existing at extraction time — the # result was check_workout_locker reporting a red "error" for a service that was # installed and enabled the whole time, while its "fix" silently did nothing. -# Resolve the invoking user's home: this script re-execs itself via sudo, so -# $HOME would be /root here. -REAL_USER="${SUDO_USER:-${USER:-$(id -un)}}" -REAL_HOME="$(getent passwd "$REAL_USER" 2>/dev/null | cut -d: -f6)" -[[ -n $REAL_HOME ]] || REAL_HOME="/home/$REAL_USER" WORKOUT_LOCKER_REPO="$REAL_HOME/screen-locker" WORKOUT_LOCKER_INSTALL_SCRIPT="$WORKOUT_LOCKER_REPO/install_systemd.sh" WORKOUT_LOCKER_SCRIPT="$WORKOUT_LOCKER_REPO/screen_locker/screen_lock.py" -HOSTS_BLOCKER_REPO="${HOSTS_BLOCKER_DIR:-$REAL_HOME/hosts-blocker}" HOSTS_INSTALL_SCRIPT="$HOSTS_BLOCKER_REPO/install.sh" # A MISSING repair script means THIS TOOL is broken: it silently stops repairing diff --git a/linux_configuration/periodic_background/digital_wellbeing/.gitignore b/linux_configuration/periodic_background/digital_wellbeing/.gitignore deleted file mode 100644 index c2658d7d..00000000 --- a/linux_configuration/periodic_background/digital_wellbeing/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/linux_configuration/periodic_background/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md b/linux_configuration/periodic_background/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md deleted file mode 100644 index c91dbdd1..00000000 --- a/linux_configuration/periodic_background/digital_wellbeing/README_COMPULSIVE_BLOCK_LLM.md +++ /dev/null @@ -1,245 +0,0 @@ -# Block Compulsive Opening - LLM Reference Guide - -> **For AI assistants**: This document explains the compulsive opening blocker so you can make correct modifications. - -## System Purpose - -Limit messaging apps (Beeper, Signal, Discord) to **one launch per hour** to reduce compulsive checking behavior. - -## How It Works - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ LAUNCH INTERCEPTION │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ User clicks "Discord" in app launcher │ -│ ↓ │ -│ /usr/bin/discord (wrapper script) │ -│ ↓ │ -│ exec /usr/local/bin/block-compulsive-opening.sh wrapper discord │ -│ ↓ │ -│ Check: ~/.local/state/compulsive-block/discord.lastopen │ -│ ↓ │ -│ ┌─────────────────┴─────────────────┐ │ -│ │ │ │ -│ ▼ Not opened this hour ▼ Already opened │ -│ Record opening time Show notification │ -│ Launch real binary Exit with error │ -│ /opt/discord/Discord │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## File Locations - -| File | Purpose | -| ------------------------------------------------------ | --------------------------- | -| `/usr/local/bin/block-compulsive-opening.sh` | Installed main script | -| `/usr/bin/beeper` | Wrapper (replaces original) | -| `/usr/bin/signal-desktop` | Wrapper (replaces original) | -| `/usr/bin/discord` | Wrapper (replaces original) | -| `/usr/bin/*.orig` or `SYMLINK:*` | Original binaries/links | -| `~/.local/state/compulsive-block/*.lastopen` | Per-app hour tracking | -| `~/.local/state/compulsive-block/compulsive-block.log` | Activity log | -| `/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook` | Auto-rewrap hook | - -## Managed Applications - -```bash -declare -A APPS=( - ["beeper"]="/usr/bin/beeper" - ["signal-desktop"]="/usr/bin/signal-desktop" - ["discord"]="/usr/bin/discord" -) - -declare -A REAL_BINARIES=( - ["beeper"]="/opt/beeper/beepertexts" - ["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop" - ["discord"]="/opt/discord/Discord" -) -``` - -## State Tracking - -Hour key format: `YYYY-MM-DD-HH` (e.g., `2026-02-02-14`) - -State file content: Just the hour key string - -```bash -# Check if opened this hour -cat ~/.local/state/compulsive-block/discord.lastopen -# Output: 2026-02-02-14 - -# Current hour -date '+%Y-%m-%d-%H' -# Output: 2026-02-02-15 (different = can open again) -``` - -## Wrapper Installation Process - -When `install_all()` runs: - -1. Copies script to `/usr/local/bin/block-compulsive-opening.sh` -2. For each app: - - If original is a symlink: Save `SYMLINK:/target/path` to `.orig` - - If original is a file: Move to `.orig` - - Create wrapper script at original location: - ```bash - #!/bin/bash - exec /usr/local/bin/block-compulsive-opening.sh wrapper "discord" "$@" - ``` -3. Install pacman hook for auto-rewrap - -## Pacman Hook - -After beeper/signal/discord package updates, the hook re-wraps them: - -```ini -[Trigger] -Operation = Upgrade -Operation = Install -Type = Package -Target = beeper -Target = signal-desktop -Target = discord - -[Action] -When = PostTransaction -Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet -``` - -The `rewrap-quiet` command: - -- Checks if wrapper was overwritten (doesn't contain "block-compulsive-opening") -- If overwritten: removes stale `.orig`, re-installs wrapper -- Logs to activity log - -## Commands - -```bash -# Install all wrappers (requires root) -sudo ./block_compulsive_opening.sh install - -# Uninstall all wrappers (requires root) -sudo ./block_compulsive_opening.sh uninstall - -# Check status of all apps -./block_compulsive_opening.sh status - -# Reset a specific app (allow opening again this hour) -./block_compulsive_opening.sh reset discord - -# Reset all apps -./block_compulsive_opening.sh reset-all -``` - -## Log Format - -``` -2026-02-02 14:30:15 - ALLOWED: discord opened (first time this hour: 2026-02-02-14) -2026-02-02 14:30:15 - LAUNCHED: discord with PID 12345 (auto-close in 10m) -2026-02-02 14:38:15 - (notification: "Session will end in 2 minutes") -2026-02-02 14:40:15 - AUTO-CLOSED: discord (PID 12345) after 10m -2026-02-02 14:45:22 - BLOCKED: discord launch prevented (already opened this hour: 2026-02-02-14) -2026-02-02 15:01:03 - ALLOWED: discord opened (first time this hour: 2026-02-02-15) -2026-02-02 15:30:00 - RESET: discord state cleared by user -``` - -## Auto-Close Timer (Session Limit) - -Apps are automatically closed after **10 minutes** to prevent indefinite usage: - -1. When app launches, a background daemon is spawned -2. At **8 minutes**: Warning notification "Session will end in 2 minutes" -3. At **10 minutes**: App is closed with SIGTERM, then SIGKILL if needed -4. State file `~/.local/state/compulsive-block/.running` tracks PID and start time - -**Configuration variables** (in script): - -```bash -AUTO_CLOSE_TIMEOUT_MINUTES=10 # Total session length -AUTO_CLOSE_WARNING_MINUTES=2 # Warning before close -``` - -## Adding a New App - -1. Add to `APPS` associative array: - -```bash -declare -A APPS=( - # ... existing apps ... - ["newapp"]="/usr/bin/newapp" -) -``` - -2. Add to `REAL_BINARIES`: - -```bash -declare -A REAL_BINARIES=( - # ... existing apps ... - ["newapp"]="/opt/newapp/actual-binary" -) -``` - -3. Add to pacman hook targets (if installed via pacman): - -```ini -Target = newapp -``` - -4. Reinstall: - -```bash -sudo ./block_compulsive_opening.sh install -``` - -## Debugging - -### Check if wrapper is installed - -```bash -cat /usr/bin/discord -# Should show wrapper script, not binary - -ls -la /usr/bin/discord.orig -# Should exist (or check for SYMLINK: content) -``` - -### Check current state - -```bash -./block_compulsive_opening.sh status -# Shows: which apps are wrapped, last open times, current hour -``` - -### Test manually - -```bash -# Simulate wrapper call -/usr/local/bin/block-compulsive-opening.sh wrapper discord -``` - -### View logs - -```bash -tail -f ~/.local/state/compulsive-block/compulsive-block.log -``` - -## Notification Behavior - -When blocked, shows desktop notification: - -- Title: "🚫 discord Blocked" -- Message: "Already opened this hour. Wait until the next hour." -- Urgency: critical -- Timeout: 5000ms - -Uses `notify-send` (falls back silently if not available). - -## DO NOT - -1. ❌ Delete `.orig` files (cannot restore original binaries) -2. ❌ Manually edit wrapper scripts at `/usr/bin/` (will be overwritten) -3. ❌ Assume app is "blocked" once notification shows (it ran, just not again) -4. ❌ Remove pacman hook without understanding auto-rewrap won't work diff --git a/linux_configuration/periodic_background/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md b/linux_configuration/periodic_background/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md deleted file mode 100644 index 181e47e6..00000000 --- a/linux_configuration/periodic_background/digital_wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md +++ /dev/null @@ -1,162 +0,0 @@ -# Midnight Shutdown System - LLM Reference Guide - -> **For AI assistants**: This document explains the automatic shutdown system so you can make correct modifications. - -## System Purpose - -Automatically shut down the PC during configured time windows to enforce healthy sleep schedules: - -- **Monday-Wednesday**: Shutdown at 24:00 (midnight) -- **Thursday-Sunday**: Shutdown at 24:00 (midnight) -- **Morning**: Safe time starts at 00:00 (effectively no morning block) - -The times above are defaults; actual values in `/etc/shutdown-schedule.conf`. - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ SHUTDOWN SYSTEM LAYERS │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ Layer 1: Systemd Timer │ -│ ───────────────────── │ -│ day-specific-shutdown.timer fires every minute │ -│ day-specific-shutdown.service runs the check script │ -│ │ -│ Layer 2: Check Script │ -│ ──────────────────── │ -│ /usr/local/bin/day-specific-shutdown-check.sh │ -│ Reads config, checks current time, initiates shutdown if in window │ -│ │ -│ Layer 3: Config Protection │ -│ ──────────────────────── │ -│ /etc/shutdown-schedule.conf has chattr +i │ -│ Canonical copy at /usr/local/share/locked-shutdown-schedule.conf │ -│ Path watcher auto-restores if tampered │ -│ │ -│ Layer 4: Timer Monitor │ -│ ───────────────────── │ -│ shutdown-timer-monitor.service watches timer status │ -│ Re-enables timer if user tries to disable it │ -│ │ -│ Layer 5: Script Protection │ -│ ──────────────────────── │ -│ Setup script blocks making schedule MORE LENIENT │ -│ Can only make it STRICTER without the unlock script │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## File Locations - -| File | Purpose | Protection | -| ----------------------------------------------------- | ------------------- | ----------------------- | -| `/etc/shutdown-schedule.conf` | Runtime config | chattr +i, path watcher | -| `/usr/local/share/locked-shutdown-schedule.conf` | Canonical copy | chattr +i | -| `/usr/local/bin/day-specific-shutdown-check.sh` | Shutdown logic | None | -| `/usr/local/bin/day-specific-shutdown-manager.sh` | Status/management | None | -| `/usr/local/bin/shutdown-timer-monitor.sh` | Timer re-enabler | None | -| `/usr/local/sbin/enforce-shutdown-schedule.sh` | Config restoration | None | -| `/usr/local/sbin/unlock-shutdown-schedule` | Delayed config edit | None | -| `/etc/systemd/system/day-specific-shutdown.timer` | Timer unit | systemd | -| `/etc/systemd/system/day-specific-shutdown.service` | Service unit | systemd | -| `/etc/systemd/system/shutdown-schedule-guard.path` | Config watcher | systemd | -| `/etc/systemd/system/shutdown-schedule-guard.service` | Enforcement | systemd | -| `/etc/systemd/system/shutdown-timer-monitor.service` | Timer guardian | systemd | -| `/var/log/shutdown-schedule-guard.log` | Tampering log | None | - -## Config File Format - -```bash -# /etc/shutdown-schedule.conf - -# Shutdown hour for Monday-Wednesday (24-hour format) -MON_WED_HOUR=21 - -# Shutdown hour for Thursday-Sunday (24-hour format) -THU_SUN_HOUR=22 - -# Morning end hour (shutdown window ends at this hour) -MORNING_END_HOUR=5 -``` - -**Interpretation**: - -- Mon-Wed: Shutdown if current hour >= 21 OR current hour < 5 -- Thu-Sun: Shutdown if current hour >= 22 OR current hour < 5 - -## Schedule Protection Logic - -The setup script (`setup_midnight_shutdown.sh`) has constants at the top: - -```bash -SCHEDULE_MON_WED_HOUR=24 -SCHEDULE_THU_SUN_HOUR=24 -SCHEDULE_MORNING_END_HOUR=0 -``` - -When re-run, it compares these to the canonical config: - -| Change Type | Action | -| -------------------------- | ------------------------------------ | -| Making shutdown EARLIER | ✅ Allowed without unlock | -| Making shutdown LATER | ❌ Blocked, requires unlock | -| Making morning end EARLIER | ❌ Always blocked | -| Making morning end LATER | ✅ Allowed (extends shutdown window) | - -Example blocked attempt: - -``` -╔══════════════════════════════════════════════════════════════════╗ -║ ❌ SCHEDULE MODIFICATION BLOCKED - CHEATING DETECTED! ❌ ║ -╚══════════════════════════════════════════════════════════════════╝ - -You modified the script to make the shutdown schedule MORE LENIENT: - • Mon-Wed shutdown: 21:00 → 23:00 (later) - -Nice try! But this is exactly the kind of late-night bargaining -that this protection is designed to prevent. 😉 -``` - -## Unlock Script Behavior - -`/usr/local/sbin/unlock-shutdown-schedule`: - -1. Stops `shutdown-schedule-guard.path` -2. Removes chattr from both config files -3. Opens editor on temp copy -4. Checks what changed: - - **Stricter (earlier)**: No delay, applies immediately - - **Lenient (later)**: 45-second countdown, then applies - - **Lower morning end**: **ALWAYS BLOCKED** (cannot shorten window) -5. Updates both config and canonical -6. Re-applies chattr +i -7. Restarts path watcher - -## Integration Points - -### i3blocks Countdown - -`i3blocks/shutdown_countdown.sh` reads the config to show time remaining: - -```bash -source /etc/shutdown-schedule.conf -# Calculates and displays "Shutdown in X:XX" -``` - -### Screen Locker - -`screen_lock.py` can adjust shutdown time: - -- **Sick day**: Moves shutdown 1.5 hours EARLIER (penalty) -- **Workout completed**: Moves shutdown 1.5 hours LATER (reward) - -Uses `adjust_shutdown_schedule.sh` helper script. - -## More detail - -Split out to stay under the 250-line cap. - -- [Systemd units, check-script logic and common tasks](llm-notes/units-and-tasks.md) -- [Known vulnerabilities, troubleshooting and hard stops](llm-notes/vulns-and-troubleshooting.md) diff --git a/linux_configuration/periodic_background/digital_wellbeing/block_compulsive_opening.sh b/linux_configuration/periodic_background/digital_wellbeing/block_compulsive_opening.sh deleted file mode 100755 index 5fe9ff00..00000000 --- a/linux_configuration/periodic_background/digital_wellbeing/block_compulsive_opening.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/bin/bash -# Block Compulsive Opening Script -# Limits messaging apps (Beeper, Signal, Discord) to one launch per hour -# -# Each app can only be opened once per hour. If already opened this hour, -# subsequent launch attempts are blocked with a notification. -# -# Installation moves real binaries to *.real and symlinks to wrapper scripts. - -set -euo pipefail - -# The phases live in four libs. They are found in TWO layouts, because this -# script runs from both: -# -# repo: /lib/cco_*.sh -# deployed: /usr/local/bin/cco_*.sh (flat, beside the entry script) -# -# install_all copies the libs flat next to the installed entry script rather -# than creating /usr/local/bin/lib, matching how pacman_lock_lib.sh and -# heavy_job_lock.sh are already deployed there. -# -# readlink -f because /usr/bin/ wrappers exec this through its absolute -# path and the installed copy may itself be reached via a symlink. -# -# This fails CLOSED: a missing lib aborts. The pacman fail-open pattern is -# wrong here — a half-loaded blocker that silently stops blocking is the exact -# failure this tool exists to prevent, and unlike pacman you do not need this -# script to repair it. -_CCO_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" -if [[ -d "$_CCO_DIR/lib" ]]; then - _CCO_LIB_DIR="$_CCO_DIR/lib" -else - _CCO_LIB_DIR="$_CCO_DIR" -fi - -for _cco_lib in cco_state cco_wrapper cco_install cco_report; do - if [[ ! -r "$_CCO_LIB_DIR/${_cco_lib}.sh" ]]; then - echo "Error: missing library ${_cco_lib}.sh in $_CCO_LIB_DIR" >&2 - echo "The installation is incomplete; re-run: sudo $0 install" >&2 - exit 1 - fi -done -unset _cco_lib - -# shellcheck source=lib/cco_state.sh -. "$_CCO_LIB_DIR/cco_state.sh" -# shellcheck source=lib/cco_wrapper.sh -. "$_CCO_LIB_DIR/cco_wrapper.sh" -# shellcheck source=lib/cco_install.sh -. "$_CCO_LIB_DIR/cco_install.sh" -# shellcheck source=lib/cco_report.sh -. "$_CCO_LIB_DIR/cco_report.sh" - -# Send desktop notification (inlined from common.sh to avoid dependency issues -# when script is installed to /usr/local/bin) -notify() { - local title="$1" - local message="$2" - local urgency="${3:-normal}" - local timeout="${4:-5000}" - - if command -v notify-send &>/dev/null; then - notify-send -u "$urgency" -t "$timeout" "$title" "$message" 2>/dev/null || true - fi -} - -# Configuration -STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/compulsive-block" -LOG_FILE="$STATE_DIR/compulsive-block.log" - -# Auto-close timeout in minutes (apps forcefully closed after this) -AUTO_CLOSE_TIMEOUT_MINUTES=10 -# Warning before auto-close (in minutes before timeout) -AUTO_CLOSE_WARNING_MINUTES=2 - -# Per-app timeout overrides (apps not listed use AUTO_CLOSE_TIMEOUT_MINUTES) -declare -A APP_TIMEOUT_MINUTES=( - ["beeper"]=20 - ["signal-desktop"]=20 -) - -# Apps to limit (name -> binary path) -# These are the primary wrapper locations (what the user calls) -declare -A APPS=( - ["beeper"]="/usr/bin/beeper" - ["signal-desktop"]="/usr/bin/signal-desktop" - ["discord"]="/usr/bin/discord" -) - -# The wrapper runs as the user ($SUDO_USER unset); the installer runs as root -# under sudo ($SUDO_USER set) — resolve the same home in both cases. -_cco_user="${SUDO_USER:-${USER:-$(id -un)}}" -_cco_home="$(getent passwd "$_cco_user" 2>/dev/null | cut -d: -f6)" -[[ -n $_cco_home ]] || _cco_home="/home/$_cco_user" - -# Actual executable paths (the real binaries to exec after wrapper check) -# These are where the real code lives -# -# discord: the `discord` package ships ONLY /usr/bin/discord — a bootstrap shell -# script that downloads the app into ~/.config/discord/ and execs -# ~/.config/discord/Discord (a symlink to the current app-/Discord that -# Discord's own updater maintains). /opt/discord/Discord belongs to a DIFFERENT -# discord package and does not exist here, so the old value made install_wrapper's -# `[[ -x $real_binary ]]` gate fail forever ("discord real binary not found") and -# discord was never limited. We exec the preserved LAUNCHER (.orig) rather than -# the app directly, so Discord's bootstrap/self-update still runs — pointing -# straight at ~/.config/discord/Discord would break launching whenever the -# updater moved that symlink. -declare -A REAL_BINARIES=( - ["beeper"]="/opt/beeper/beepertexts" - ["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop" - ["discord"]="/usr/bin/discord.orig" -) - -# Pattern for detecting a RUNNING instance (pgrep -f). Defaults to the exec -# target, which is right when that target is the app itself. It is wrong when the -# exec target is a launcher that exec()s away: /usr/bin/discord.orig replaces -# itself with ~/.config/discord//Discord, so nothing would ever match -# ".orig" and the running-state tracking would look stale immediately. -declare -A PROCESS_MATCH=( - ["discord"]="$_cco_home/.config/discord/" -) - -# Main entry point -main() { - case "${1:-help}" in - install) - if [[ $EUID -ne 0 ]]; then - echo "Error: install requires root privileges" - echo "Run: sudo $0 install" - exit 1 - fi - install_all - ;; - uninstall) - if [[ $EUID -ne 0 ]]; then - echo "Error: uninstall requires root privileges" - echo "Run: sudo $0 uninstall" - exit 1 - fi - uninstall_all - ;; - status) - show_status - ;; - reset) - if [[ -z ${2:-} ]]; then - echo "Error: specify app to reset" - echo "Apps: ${!APPS[*]}" - exit 1 - fi - reset_app "$2" - ;; - reset-all) - reset_all - ;; - rewrap-quiet) - # Called by pacman hook - quietly re-wrap apps after package updates - if [[ $EUID -ne 0 ]]; then - exit 1 - fi - rewrap_quiet - ;; - wrapper) - if [[ -z ${2:-} ]]; then - echo "Error: wrapper requires app name" - exit 1 - fi - wrapper_main "${@:2}" - ;; - help | -h | --help) - show_usage - ;; - *) - echo "Unknown command: $1" - show_usage - exit 1 - ;; - esac -} - -main "$@" diff --git a/linux_configuration/periodic_background/digital_wellbeing/heavy_job_lock.sh b/linux_configuration/periodic_background/digital_wellbeing/heavy_job_lock.sh deleted file mode 100755 index 1c98fc2c..00000000 --- a/linux_configuration/periodic_background/digital_wellbeing/heavy_job_lock.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/bin/bash - -# ============================================================================ -# heavy_job_lock.sh - one heavy job at a time on this machine -# -# This box has 7.6 GB. On 2026-08-03 a Gradle/Flutter APK build, a full -# `pytest --cov` run and Chromium overlapped; an earlier overlap the same week -# got a whole session OOM-killed (exit 137/143). The rule "one heavy job at a -# time" was written down in ~/.claude/memories/mistakes.md - but a rule that -# only works when a human remembers it is deployment hygiene, not a fix. This -# makes the overlap impossible instead. -# -# Usage as a CLI: -# heavy_job_lock.sh --name gradle -- flutter build apk --debug -# heavy_job_lock.sh --name pytest --fail-fast -- pytest --cov -# -# Usage as a library: -# source /path/to/heavy_job_lock.sh -# with_heavy_lock gradle -- ./gradlew assembleDebug -# -# Setup (once, as root - /run is tmpfs so the lock needs recreating on boot): -# sudo heavy_job_lock.sh --install -# -# Design notes: -# - ONE system-wide lock shared between root and the user. pacman runs as -# root while builds run as the user, so a per-user /run/user/$UID lock -# would give them separate locks and no mutual exclusion at all. -# - The lock file is mode 0666 (created by root via tmpfiles.d) precisely so -# an unprivileged build can take the same lock a root pacman takes. -# - FAIL OPEN, always. A lock this central must never be able to brick -# package management or builds: if the lock file cannot be created, or the -# wait times out, we warn and run the command anyway. Blocking work forever -# is a worse failure than the memory contention we are avoiding. -# ============================================================================ - -HEAVY_JOB_LOCK_FILE="${HEAVY_JOB_LOCK_FILE:-/run/heavy-job.lock}" -# Long enough for a real Gradle build to finish ahead of us, short enough that -# a wedged holder cannot stall a machine indefinitely. -HEAVY_JOB_LOCK_TIMEOUT="${HEAVY_JOB_LOCK_TIMEOUT:-1800}" -HEAVY_JOB_TMPFILES_CONF="/etc/tmpfiles.d/heavy-job-lock.conf" - -_hjl_warn() { printf '[heavy-job-lock] %s\n' "$*" >&2; } - -# Who holds it right now? Best-effort: the holder writes " " -# after acquiring, so a refusal can name the culprit instead of just failing. -_hjl_holder() { - local holder="" - [[ -r $HEAVY_JOB_LOCK_FILE ]] && read -r holder <"$HEAVY_JOB_LOCK_FILE" 2>/dev/null - printf '%s' "${holder:-unknown}" -} - -# Create the lock file world-writable. Only meaningful as root; unprivileged -# callers just discover they cannot and fail open. -_hjl_ensure_lock_file() { - [[ -e $HEAVY_JOB_LOCK_FILE ]] && return 0 - if [[ $EUID -eq 0 ]]; then - install -m 0666 /dev/null "$HEAVY_JOB_LOCK_FILE" 2>/dev/null && return 0 - else - # Braces around the redirect: a failing redirection is reported by the - # shell itself, before the command runs, so `cmd 2>/dev/null` does not - # suppress it. The group does. - { : >"$HEAVY_JOB_LOCK_FILE"; } 2>/dev/null && - chmod 0666 "$HEAVY_JOB_LOCK_FILE" 2>/dev/null && return 0 - fi - return 1 -} - -# with_heavy_lock [--fail-fast] -- -with_heavy_lock() { - local name="$1" - shift - local fail_fast=0 - - while [[ $# -gt 0 ]]; do - case "$1" in - --fail-fast) - fail_fast=1 - shift - ;; - --) - shift - break - ;; - *) break ;; - esac - done - - if [[ $# -eq 0 ]]; then - _hjl_warn "with_heavy_lock: no command given" - return 2 - fi - - if ! _hjl_ensure_lock_file; then - _hjl_warn "cannot create $HEAVY_JOB_LOCK_FILE - running '$name' UNSERIALISED" - _hjl_warn "run 'sudo $0 --install' once to enable serialisation" - "$@" - return $? - fi - - local lock_fd - # `<>` (read-write), NOT `>`. Redirecting with `>` truncates the file at - # open time, so a second caller wiped the holder's " " - # line before it could read it — every refusal said "held by unknown". - exec {lock_fd}<>"$HEAVY_JOB_LOCK_FILE" || { - _hjl_warn "cannot open lock - running '$name' unserialised" - "$@" - return $? - } - - local acquired=0 - if ((fail_fast == 1)); then - if flock --nonblock "$lock_fd"; then - acquired=1 - else - _hjl_warn "refusing to start '$name': held by $(_hjl_holder)" - exec {lock_fd}>&- - return 75 # EX_TEMPFAIL - fi - else - local holder - holder="$(_hjl_holder)" - if ! flock --nonblock "$lock_fd"; then - _hjl_warn "waiting for '$holder' to finish before starting '$name' (timeout ${HEAVY_JOB_LOCK_TIMEOUT}s)" - fi - if flock --timeout "$HEAVY_JOB_LOCK_TIMEOUT" "$lock_fd"; then - acquired=1 - else - # Fail open: see the header. A stuck holder must not permanently - # block builds or package management. - _hjl_warn "timed out after ${HEAVY_JOB_LOCK_TIMEOUT}s waiting for $(_hjl_holder)" - _hjl_warn "proceeding UNSERIALISED with '$name'" - fi - fi - - if ((acquired == 1)); then - printf '%s %s %s\n' "$name" "$$" "$(date -Is 2>/dev/null || echo unknown)" \ - >"$HEAVY_JOB_LOCK_FILE" 2>/dev/null || true - fi - - local rc=0 - "$@" || rc=$? - - # Closing the fd releases the lock; do it explicitly so the holder line is - # not left implying we still hold it. - exec {lock_fd}>&- - return "$rc" -} - -# ---------------------------------------------------------------------------- -# CLI (skipped when this file is sourced as a library) -# ---------------------------------------------------------------------------- -_hjl_install() { - if [[ $EUID -ne 0 ]]; then - _hjl_warn "--install must run as root" - return 1 - fi - # /run is tmpfs: without a tmpfiles.d entry the lock vanishes every boot and - # the first unprivileged caller silently falls back to unserialised. - printf 'f %s 0666 root root -\n' "$HEAVY_JOB_LOCK_FILE" >"$HEAVY_JOB_TMPFILES_CONF" - systemd-tmpfiles --create "$HEAVY_JOB_TMPFILES_CONF" 2>/dev/null || true - _hjl_ensure_lock_file || { - _hjl_warn "failed to create $HEAVY_JOB_LOCK_FILE" - return 1 - } - printf 'installed %s and %s\n' "$HEAVY_JOB_TMPFILES_CONF" "$HEAVY_JOB_LOCK_FILE" -} - -_hjl_usage() { - cat < [--fail-fast] -- - $(basename "$0") --install # once, as root - $(basename "$0") --status - -Serialises heavy jobs (builds, coverage runs, pacman transactions) so only one -runs at a time on this memory-constrained machine. Always fails open. - -Options: - --name