Skip to content

Commit cc0edf8

Browse files
Jammy2211claude
andauthored
morning_timer.sh — schedule the morning routine overnight on the dev box (#258)
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 <noreply@anthropic.com>
1 parent 751d5ab commit cc0edf8

4 files changed

Lines changed: 200 additions & 1 deletion

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ for the organism's morning and general starting point: what ran overnight, the
1717
Heart's readiness headline, who in the community is waiting on a reply, what
1818
to resume, and the upkeep doors — each actionable row with a one-tap 📋
1919
copy-for-Claude command. Regenerated each morning; the local sync/clean leg is
20-
one terminal command, `bash bin/morning.sh`.
20+
one terminal command, `bash bin/morning.sh` — or schedule it overnight on the
21+
dev box with `bash bin/morning_timer.sh install`, so the board is already
22+
fresh when you wake.
2123

2224
## How PyAutoBrain works
2325

bin/morning.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
# bash PyAutoBrain/bin/morning.sh --no-publish # skip the dev-box publish
1717
# DRY_RUN=1 bash PyAutoBrain/bin/morning.sh # preview clean-slate only
1818
#
19+
# To run overnight automatically (so the board is fresh on waking), schedule
20+
# it on this machine once: bash PyAutoBrain/bin/morning_timer.sh install
21+
#
1922
# Both steps are the recoverable, git-aware ones /wake_up auto-ran (its
2023
# guardrail): sync skips any repo with real uncommitted work; clean-slate
2124
# deletes only untracked REGENERABLE artifacts and reports orphans instead of

bin/morning_timer.sh

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env bash
2+
#
3+
# morning_timer.sh — schedule bin/morning.sh to run overnight on THIS machine,
4+
# so the sync/clean/publish leg is already done when the human wakes up and
5+
# the board reads "observed this morning" from the first glance.
6+
#
7+
# Every leg of morning.sh is local (it syncs and cleans YOUR checkouts and
8+
# publishes YOUR dev-box observation), so the schedule must live on the dev
9+
# box — the board's own cloud refresh (brain_board.yml, 05:30 UTC) is separate
10+
# and unaffected. The publish push re-triggers a board render anyway, so the
11+
# board updates minutes after the local run regardless of ordering.
12+
#
13+
# bash PyAutoBrain/bin/morning_timer.sh install [HH:MM] # default 05:30 local
14+
# bash PyAutoBrain/bin/morning_timer.sh uninstall
15+
# bash PyAutoBrain/bin/morning_timer.sh status
16+
# bash PyAutoBrain/bin/morning_timer.sh print [HH:MM] # show what install
17+
# # would write
18+
#
19+
# Backend: systemd user units (pyauto-morning.service/.timer, Persistent=true
20+
# so a night the machine slept through is caught up at next wake/boot) when
21+
# `systemctl --user` works; otherwise a marked crontab line. Override with
22+
# MORNING_TIMER_MODE=systemd|cron.
23+
#
24+
# Prerequisites on the dev box: non-interactive git credentials (the publish
25+
# leg pushes to the Brain's main), and for the systemd path a user session at
26+
# the fire time — enable lingering once (`loginctl enable-linger $USER`) so
27+
# the timer fires before first login; install says so if it is off.
28+
#
29+
# Caveat: clean_slate deletes untracked REGENERABLE datasets. If something
30+
# else runs on this machine overnight and writes datasets it still needs,
31+
# schedule after it finishes, or point the timer at
32+
# `morning.sh --no-publish`-style variants by editing the unit.
33+
34+
set -u
35+
36+
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
37+
MORNING="$HERE/morning.sh"
38+
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
39+
LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pyauto"
40+
MARKER="# pyauto-morning (installed by morning_timer.sh)"
41+
42+
when="${2:-05:30}"
43+
case "$when" in
44+
[0-2][0-9]:[0-5][0-9]) ;;
45+
*) echo "morning_timer: time must be HH:MM (got '$when')" >&2; exit 2 ;;
46+
esac
47+
hh="${when%%:*}"; mm="${when##*:}"
48+
if [ "$((10#$hh))" -gt 23 ]; then
49+
echo "morning_timer: hour must be 00-23 (got '$hh')" >&2; exit 2
50+
fi
51+
52+
mode="${MORNING_TIMER_MODE:-}"
53+
if [ -z "$mode" ]; then
54+
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
55+
mode=systemd
56+
elif command -v crontab >/dev/null 2>&1; then
57+
mode=cron
58+
else
59+
echo "morning_timer: neither a systemd user session nor crontab is available" >&2
60+
exit 1
61+
fi
62+
fi
63+
64+
service_unit() {
65+
cat <<EOF
66+
[Unit]
67+
Description=PyAuto morning routine (sync + clean + dev-box publish)
68+
69+
[Service]
70+
Type=oneshot
71+
ExecStart=/bin/bash $MORNING
72+
EOF
73+
}
74+
75+
timer_unit() {
76+
cat <<EOF
77+
[Unit]
78+
Description=Run the PyAuto morning routine overnight
79+
80+
[Timer]
81+
OnCalendar=*-*-* $hh:$mm:00
82+
Persistent=true
83+
84+
[Install]
85+
WantedBy=timers.target
86+
EOF
87+
}
88+
89+
cron_line() {
90+
# Strip a leading zero so cron never sees an octal-looking field.
91+
printf '%d %d * * * /bin/bash %s >> %s/morning.log 2>&1 %s\n' \
92+
"$((10#$mm))" "$((10#$hh))" "$MORNING" "$LOG_DIR" "$MARKER"
93+
}
94+
95+
case "${1:-status}" in
96+
print)
97+
if [ "$mode" = systemd ]; then
98+
echo "# $UNIT_DIR/pyauto-morning.service"; service_unit
99+
echo; echo "# $UNIT_DIR/pyauto-morning.timer"; timer_unit
100+
else
101+
echo "# crontab line"; cron_line
102+
fi
103+
;;
104+
install)
105+
if [ "$mode" = systemd ]; then
106+
mkdir -p "$UNIT_DIR"
107+
service_unit > "$UNIT_DIR/pyauto-morning.service"
108+
timer_unit > "$UNIT_DIR/pyauto-morning.timer"
109+
systemctl --user daemon-reload
110+
systemctl --user enable --now pyauto-morning.timer
111+
echo "installed: pyauto-morning.timer fires daily at $when local (Persistent=true"
112+
echo "catches up a night the machine slept through). Logs: journalctl --user -u pyauto-morning"
113+
if command -v loginctl >/dev/null 2>&1 \
114+
&& [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]; then
115+
echo "note: lingering is OFF — before first login the timer cannot fire."
116+
echo " enable once with: loginctl enable-linger $USER"
117+
fi
118+
else
119+
mkdir -p "$LOG_DIR"
120+
{ crontab -l 2>/dev/null | grep -vF "$MARKER"; cron_line; } | crontab -
121+
echo "installed: crontab entry fires daily at $when local. Log: $LOG_DIR/morning.log"
122+
echo "note: plain cron does not catch up runs the machine slept through."
123+
fi
124+
;;
125+
uninstall)
126+
if [ "$mode" = systemd ]; then
127+
systemctl --user disable --now pyauto-morning.timer 2>/dev/null
128+
rm -f "$UNIT_DIR/pyauto-morning.service" "$UNIT_DIR/pyauto-morning.timer"
129+
systemctl --user daemon-reload
130+
echo "uninstalled pyauto-morning.timer"
131+
else
132+
crontab -l 2>/dev/null | grep -vF "$MARKER" | crontab -
133+
echo "removed the pyauto-morning crontab entry"
134+
fi
135+
;;
136+
status)
137+
if [ "$mode" = systemd ]; then
138+
systemctl --user list-timers pyauto-morning.timer --no-pager 2>/dev/null \
139+
|| echo "pyauto-morning.timer is not installed"
140+
else
141+
crontab -l 2>/dev/null | grep -F "$MARKER" \
142+
|| echo "no pyauto-morning crontab entry"
143+
fi
144+
;;
145+
*)
146+
echo "usage: morning_timer.sh install [HH:MM] | uninstall | status | print [HH:MM]" >&2
147+
exit 2
148+
;;
149+
esac

tests/test_morning_timer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Contract tests for bin/morning_timer.sh — the overnight scheduler for the
2+
morning routine. Hermetic: only the `print` mode runs (it renders exactly what
3+
`install` would write, without touching systemd or crontab), forced onto each
4+
backend via MORNING_TIMER_MODE."""
5+
6+
import os
7+
import subprocess
8+
from pathlib import Path
9+
10+
BRAIN_HOME = Path(__file__).resolve().parents[1]
11+
SCRIPT = BRAIN_HOME / "bin" / "morning_timer.sh"
12+
MORNING = BRAIN_HOME / "bin" / "morning.sh"
13+
14+
15+
def _run(mode, *args):
16+
return subprocess.run(
17+
["bash", str(SCRIPT), *args],
18+
capture_output=True, text=True,
19+
env={**os.environ, "MORNING_TIMER_MODE": mode},
20+
)
21+
22+
23+
def test_systemd_print_renders_persistent_timer_at_the_given_time():
24+
r = _run("systemd", "print", "04:45")
25+
assert r.returncode == 0, r.stderr
26+
assert f"ExecStart=/bin/bash {MORNING}" in r.stdout
27+
assert "OnCalendar=*-*-* 04:45:00" in r.stdout
28+
# A night the machine slept through is caught up, not skipped.
29+
assert "Persistent=true" in r.stdout
30+
31+
32+
def test_cron_print_renders_marked_line_without_octal_fields():
33+
r = _run("cron", "print", "05:05")
34+
assert r.returncode == 0, r.stderr
35+
# Leading zeros stripped (05 would read as octal-ish cron noise) and the
36+
# marker present so uninstall can find its own line.
37+
assert f"5 5 * * * /bin/bash {MORNING}" in r.stdout
38+
assert "# pyauto-morning" in r.stdout
39+
40+
41+
def test_bad_times_are_rejected():
42+
assert _run("cron", "print", "4x:45").returncode == 2
43+
assert _run("cron", "print", "25:00").returncode == 2
44+
# Default time is valid.
45+
assert _run("cron", "print").returncode == 0

0 commit comments

Comments
 (0)