From a340e0ecdaa2833633d9e534995bc1c7396aca47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 17:52:36 +0000 Subject: [PATCH] =?UTF-8?q?brain:=20morning=5Ftimer.sh=20=E2=80=94=20sched?= =?UTF-8?q?ule=20the=20morning=20routine=20overnight=20on=20the=20dev=20bo?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install/uninstall/status/print over a systemd user timer (pyauto-morning.timer, Persistent=true so a slept-through night is caught up; lingering hint when off) with a marked-crontab fallback — one command and the sync/clean/publish leg has already run by the time the human wakes, so the board reads 'observed this morning' at first glance. The board's own cloud refresh is separate and unaffected; the publish push re-triggers a render anyway. Hour range validated; cron fields zero-stripped. 3 hermetic tests over the print projection. Co-Authored-By: Claude --- README.md | 4 +- bin/morning.sh | 3 + bin/morning_timer.sh | 149 ++++++++++++++++++++++++++++++++++++ tests/test_morning_timer.py | 45 +++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100755 bin/morning_timer.sh create mode 100644 tests/test_morning_timer.py diff --git a/README.md b/README.md index 3862235..e173203 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ for the organism's morning and general starting point: what ran overnight, the Heart's readiness headline, who in the community is waiting on a reply, what to resume, and the upkeep doors — each actionable row with a one-tap 📋 copy-for-Claude command. Regenerated each morning; the local sync/clean leg is -one terminal command, `bash bin/morning.sh`. +one terminal command, `bash bin/morning.sh` — or schedule it overnight on the +dev box with `bash bin/morning_timer.sh install`, so the board is already +fresh when you wake. ## How PyAutoBrain works diff --git a/bin/morning.sh b/bin/morning.sh index 322c9e2..4357fe0 100755 --- a/bin/morning.sh +++ b/bin/morning.sh @@ -16,6 +16,9 @@ # bash PyAutoBrain/bin/morning.sh --no-publish # skip the dev-box publish # DRY_RUN=1 bash PyAutoBrain/bin/morning.sh # preview clean-slate only # +# To run overnight automatically (so the board is fresh on waking), schedule +# it on this machine once: bash PyAutoBrain/bin/morning_timer.sh install +# # Both steps are the recoverable, git-aware ones /wake_up auto-ran (its # guardrail): sync skips any repo with real uncommitted work; clean-slate # deletes only untracked REGENERABLE artifacts and reports orphans instead of diff --git a/bin/morning_timer.sh b/bin/morning_timer.sh new file mode 100755 index 0000000..8d68bec --- /dev/null +++ b/bin/morning_timer.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# morning_timer.sh — schedule bin/morning.sh to run overnight on THIS machine, +# so the sync/clean/publish leg is already done when the human wakes up and +# the board reads "observed this morning" from the first glance. +# +# Every leg of morning.sh is local (it syncs and cleans YOUR checkouts and +# publishes YOUR dev-box observation), so the schedule must live on the dev +# box — the board's own cloud refresh (brain_board.yml, 05:30 UTC) is separate +# and unaffected. The publish push re-triggers a board render anyway, so the +# board updates minutes after the local run regardless of ordering. +# +# bash PyAutoBrain/bin/morning_timer.sh install [HH:MM] # default 05:30 local +# bash PyAutoBrain/bin/morning_timer.sh uninstall +# bash PyAutoBrain/bin/morning_timer.sh status +# bash PyAutoBrain/bin/morning_timer.sh print [HH:MM] # show what install +# # would write +# +# Backend: systemd user units (pyauto-morning.service/.timer, Persistent=true +# so a night the machine slept through is caught up at next wake/boot) when +# `systemctl --user` works; otherwise a marked crontab line. Override with +# MORNING_TIMER_MODE=systemd|cron. +# +# Prerequisites on the dev box: non-interactive git credentials (the publish +# leg pushes to the Brain's main), and for the systemd path a user session at +# the fire time — enable lingering once (`loginctl enable-linger $USER`) so +# the timer fires before first login; install says so if it is off. +# +# Caveat: clean_slate deletes untracked REGENERABLE datasets. If something +# else runs on this machine overnight and writes datasets it still needs, +# schedule after it finishes, or point the timer at +# `morning.sh --no-publish`-style variants by editing the unit. + +set -u + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +MORNING="$HERE/morning.sh" +UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pyauto" +MARKER="# pyauto-morning (installed by morning_timer.sh)" + +when="${2:-05:30}" +case "$when" in + [0-2][0-9]:[0-5][0-9]) ;; + *) echo "morning_timer: time must be HH:MM (got '$when')" >&2; exit 2 ;; +esac +hh="${when%%:*}"; mm="${when##*:}" +if [ "$((10#$hh))" -gt 23 ]; then + echo "morning_timer: hour must be 00-23 (got '$hh')" >&2; exit 2 +fi + +mode="${MORNING_TIMER_MODE:-}" +if [ -z "$mode" ]; then + if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then + mode=systemd + elif command -v crontab >/dev/null 2>&1; then + mode=cron + else + echo "morning_timer: neither a systemd user session nor crontab is available" >&2 + exit 1 + fi +fi + +service_unit() { + cat <> %s/morning.log 2>&1 %s\n' \ + "$((10#$mm))" "$((10#$hh))" "$MORNING" "$LOG_DIR" "$MARKER" +} + +case "${1:-status}" in + print) + if [ "$mode" = systemd ]; then + echo "# $UNIT_DIR/pyauto-morning.service"; service_unit + echo; echo "# $UNIT_DIR/pyauto-morning.timer"; timer_unit + else + echo "# crontab line"; cron_line + fi + ;; + install) + if [ "$mode" = systemd ]; then + mkdir -p "$UNIT_DIR" + service_unit > "$UNIT_DIR/pyauto-morning.service" + timer_unit > "$UNIT_DIR/pyauto-morning.timer" + systemctl --user daemon-reload + systemctl --user enable --now pyauto-morning.timer + echo "installed: pyauto-morning.timer fires daily at $when local (Persistent=true" + echo "catches up a night the machine slept through). Logs: journalctl --user -u pyauto-morning" + if command -v loginctl >/dev/null 2>&1 \ + && [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]; then + echo "note: lingering is OFF — before first login the timer cannot fire." + echo " enable once with: loginctl enable-linger $USER" + fi + else + mkdir -p "$LOG_DIR" + { crontab -l 2>/dev/null | grep -vF "$MARKER"; cron_line; } | crontab - + echo "installed: crontab entry fires daily at $when local. Log: $LOG_DIR/morning.log" + echo "note: plain cron does not catch up runs the machine slept through." + fi + ;; + uninstall) + if [ "$mode" = systemd ]; then + systemctl --user disable --now pyauto-morning.timer 2>/dev/null + rm -f "$UNIT_DIR/pyauto-morning.service" "$UNIT_DIR/pyauto-morning.timer" + systemctl --user daemon-reload + echo "uninstalled pyauto-morning.timer" + else + crontab -l 2>/dev/null | grep -vF "$MARKER" | crontab - + echo "removed the pyauto-morning crontab entry" + fi + ;; + status) + if [ "$mode" = systemd ]; then + systemctl --user list-timers pyauto-morning.timer --no-pager 2>/dev/null \ + || echo "pyauto-morning.timer is not installed" + else + crontab -l 2>/dev/null | grep -F "$MARKER" \ + || echo "no pyauto-morning crontab entry" + fi + ;; + *) + echo "usage: morning_timer.sh install [HH:MM] | uninstall | status | print [HH:MM]" >&2 + exit 2 + ;; +esac diff --git a/tests/test_morning_timer.py b/tests/test_morning_timer.py new file mode 100644 index 0000000..cc65594 --- /dev/null +++ b/tests/test_morning_timer.py @@ -0,0 +1,45 @@ +"""Contract tests for bin/morning_timer.sh — the overnight scheduler for the +morning routine. Hermetic: only the `print` mode runs (it renders exactly what +`install` would write, without touching systemd or crontab), forced onto each +backend via MORNING_TIMER_MODE.""" + +import os +import subprocess +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +SCRIPT = BRAIN_HOME / "bin" / "morning_timer.sh" +MORNING = BRAIN_HOME / "bin" / "morning.sh" + + +def _run(mode, *args): + return subprocess.run( + ["bash", str(SCRIPT), *args], + capture_output=True, text=True, + env={**os.environ, "MORNING_TIMER_MODE": mode}, + ) + + +def test_systemd_print_renders_persistent_timer_at_the_given_time(): + r = _run("systemd", "print", "04:45") + assert r.returncode == 0, r.stderr + assert f"ExecStart=/bin/bash {MORNING}" in r.stdout + assert "OnCalendar=*-*-* 04:45:00" in r.stdout + # A night the machine slept through is caught up, not skipped. + assert "Persistent=true" in r.stdout + + +def test_cron_print_renders_marked_line_without_octal_fields(): + r = _run("cron", "print", "05:05") + assert r.returncode == 0, r.stderr + # Leading zeros stripped (05 would read as octal-ish cron noise) and the + # marker present so uninstall can find its own line. + assert f"5 5 * * * /bin/bash {MORNING}" in r.stdout + assert "# pyauto-morning" in r.stdout + + +def test_bad_times_are_rejected(): + assert _run("cron", "print", "4x:45").returncode == 2 + assert _run("cron", "print", "25:00").returncode == 2 + # Default time is valid. + assert _run("cron", "print").returncode == 0