From 13cd03ea7e61ab64460375b61579d037118f78bc Mon Sep 17 00:00:00 2001 From: max Date: Mon, 3 Aug 2026 12:50:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(metadocs):=20systemd=20user=20timer=20?= =?UTF-8?q?=E2=80=94=20the=20daily=20job=20works=20on=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schedule.py was launchd-only: a Linux install got a plist nothing would ever read. Now the platform picks the native backend — launchd agent on macOS, systemd user units on Linux (Persistent=true carries the same laptop semantics: a run missed while asleep fires once on wake) — and anything else gets an honest error handing over the manual path instead of silence. enable/disable/is_enabled grew injectable platform+runner for hermetic tests; the CLI surfaces the unsupported-platform error as exit 1. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/session_recall/metadocs/cli.py | 8 +- src/session_recall/metadocs/schedule.py | 138 ++++++++++++++++++++---- tests/test_metadocs.py | 38 +++++++ 4 files changed, 162 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 36e19f7..1b4d4d8 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ One file per entry, with related PRs and source sessions in the frontmatter. ```bash session-recall metadocs init ~/meta-docs --from-today # memory starts now session-recall metadocs run # one pass now -session-recall metadocs enable # daily launchd job (default 21:00) +session-recall metadocs enable # daily job: launchd (macOS) / systemd user timer (Linux) session-recall metadocs status session-recall metadocs index-history --days 30 # opt-in: distill the past, once ``` diff --git a/src/session_recall/metadocs/cli.py b/src/session_recall/metadocs/cli.py index f8e58eb..dc67258 100644 --- a/src/session_recall/metadocs/cli.py +++ b/src/session_recall/metadocs/cli.py @@ -103,9 +103,13 @@ def run(args: argparse.Namespace) -> int: return 1 if cmd == "enable": - path = schedule.enable(cfg.daily_at) + try: + path = schedule.enable(cfg.daily_at) + except RuntimeError as exc: + print(exc) + return 1 print(f"daily job on, {cfg.daily_at} every day (agent: {path})\n" - "a missed run (mac asleep) fires once on wake") + "a missed run (machine asleep or off) fires once on wake") return 0 if cmd == "disable": diff --git a/src/session_recall/metadocs/schedule.py b/src/session_recall/metadocs/schedule.py index bb489aa..20c20a0 100644 --- a/src/session_recall/metadocs/schedule.py +++ b/src/session_recall/metadocs/schedule.py @@ -1,10 +1,13 @@ -"""The cron: a launchd agent that runs `session-recall metadocs run` daily. - -launchd rather than cron on purpose — a Mac that was asleep at the scheduled -minute runs the job once on wake (StartCalendarInterval semantics), which is -exactly right for a daily distill on a laptop. `enable` writes the plist and -loads it; `disable` unloads and removes it. Nothing else in the codebase -starts the job: turning it on is a human act, like everything scheduled. +"""The cron: a daily `session-recall metadocs run`, scheduled the native way. + +macOS gets a launchd agent, Linux a systemd user timer — chosen over plain +cron on both because of the same laptop reality: a machine that was asleep at +the scheduled minute must run the job once on wake (StartCalendarInterval +semantics on launchd, `Persistent=true` on systemd). Anything else — say, +Windows — gets an honest error instead of a unit file nothing will read. +`enable` writes the schedule and arms it; `disable` disarms and removes it. +Nothing else in the codebase starts the job: turning it on is a human act, +like everything scheduled. """ import os @@ -16,19 +19,22 @@ from .. import config as app_config LABEL = "tech.absolutemode.session-recall.metadocs" - - -def plist_path() -> Path: - return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist" +UNIT = "session-recall-metadocs" # systemd user unit basename def _cli_binary() -> str: """The console script that lives next to the running interpreter — the - same environment the user installed; PATH at launchd time is not ours.""" + same environment the user installed; PATH at scheduler time is not ours.""" candidate = Path(sys.executable).with_name("session-recall") return str(candidate) if candidate.exists() else "session-recall" +# -- launchd (macOS) ---------------------------------------------------------- + +def plist_path() -> Path: + return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist" + + def build_plist(daily_at: str, log_path: Path) -> dict: hour, minute = (int(x) for x in daily_at.split(":")) return { @@ -44,29 +50,119 @@ def build_plist(daily_at: str, log_path: Path) -> dict: } -def enable(daily_at: str) -> Path: - log_path = app_config.DATA_DIR / "metadocs.log" +def _launchd_enable(daily_at: str, log_path: Path, runner) -> Path: path = plist_path() path.parent.mkdir(parents=True, exist_ok=True) with open(path, "wb") as f: plistlib.dump(build_plist(daily_at, log_path), f) # bootout first so a re-enable with a new time actually takes effect - subprocess.run(["launchctl", "unload", str(path)], capture_output=True) - done = subprocess.run(["launchctl", "load", str(path)], - capture_output=True, text=True) + runner(["launchctl", "unload", str(path)]) + done = runner(["launchctl", "load", str(path)]) if done.returncode != 0: raise RuntimeError(f"launchctl load failed: {done.stderr.strip()}") return path -def disable() -> bool: +def _launchd_disable(runner) -> bool: path = plist_path() if not path.exists(): return False - subprocess.run(["launchctl", "unload", str(path)], capture_output=True) + runner(["launchctl", "unload", str(path)]) path.unlink() return True -def is_enabled() -> bool: - return plist_path().exists() +# -- systemd user timer (Linux) ----------------------------------------------- + +def systemd_dir() -> Path: + return Path.home() / ".config" / "systemd" / "user" + + +def build_units(daily_at: str, log_path: Path) -> dict[str, str]: + """The two unit files, as text — pure so tests read them without systemd. + `Persistent=true` is the launchd wake-up semantics: a missed run fires + once when the machine is back.""" + hour, minute = (int(x) for x in daily_at.split(":")) + path_env = os.environ.get("PATH", "/usr/bin:/bin") + service = f"""\ +[Unit] +Description=session-recall meta docs — daily distill + +[Service] +Type=oneshot +ExecStart={_cli_binary()} metadocs run +Environment=PATH={path_env} +StandardOutput=append:{log_path} +StandardError=append:{log_path} +""" + timer = f"""\ +[Unit] +Description=session-recall meta docs — daily schedule + +[Timer] +OnCalendar=*-*-* {hour:02d}:{minute:02d}:00 +Persistent=true + +[Install] +WantedBy=timers.target +""" + return {f"{UNIT}.service": service, f"{UNIT}.timer": timer} + + +def _systemd_enable(daily_at: str, log_path: Path, runner) -> Path: + d = systemd_dir() + d.mkdir(parents=True, exist_ok=True) + for name, text in build_units(daily_at, log_path).items(): + (d / name).write_text(text) + runner(["systemctl", "--user", "daemon-reload"]) + done = runner(["systemctl", "--user", "enable", "--now", f"{UNIT}.timer"]) + if done.returncode != 0: + raise RuntimeError(f"systemctl enable failed: {done.stderr.strip()}") + return d / f"{UNIT}.timer" + + +def _systemd_disable(runner) -> bool: + timer = systemd_dir() / f"{UNIT}.timer" + if not timer.exists(): + return False + runner(["systemctl", "--user", "disable", "--now", f"{UNIT}.timer"]) + for name in (f"{UNIT}.timer", f"{UNIT}.service"): + (systemd_dir() / name).unlink(missing_ok=True) + runner(["systemctl", "--user", "daemon-reload"]) + return True + + +# -- the platform switch ------------------------------------------------------ + +def _run(argv): + return subprocess.run(argv, capture_output=True, text=True) + + +def enable(daily_at: str, platform: str | None = None, runner=_run) -> Path: + platform = platform or sys.platform + log_path = app_config.DATA_DIR / "metadocs.log" + if platform == "darwin": + return _launchd_enable(daily_at, log_path, runner) + if platform.startswith("linux"): + return _systemd_enable(daily_at, log_path, runner) + raise RuntimeError( + f"no scheduler backend for {platform!r} — run `session-recall metadocs " + "run` from your own scheduler instead") + + +def disable(platform: str | None = None, runner=_run) -> bool: + platform = platform or sys.platform + if platform == "darwin": + return _launchd_disable(runner) + if platform.startswith("linux"): + return _systemd_disable(runner) + return False + + +def is_enabled(platform: str | None = None) -> bool: + platform = platform or sys.platform + if platform == "darwin": + return plist_path().exists() + if platform.startswith("linux"): + return (systemd_dir() / f"{UNIT}.timer").exists() + return False diff --git a/tests/test_metadocs.py b/tests/test_metadocs.py index a3a3768..1c59898 100644 --- a/tests/test_metadocs.py +++ b/tests/test_metadocs.py @@ -545,6 +545,44 @@ def test_plist_shape(tmp_path): assert "PATH" in plist["EnvironmentVariables"] +def test_systemd_units_shape(tmp_path): + units = schedule.build_units("21:30", tmp_path / "m.log") + timer = units["session-recall-metadocs.timer"] + service = units["session-recall-metadocs.service"] + assert "OnCalendar=*-*-* 21:30:00" in timer + assert "Persistent=true" in timer, "a missed run must fire on wake, like launchd" + assert "metadocs run" in service + assert "Environment=PATH=" in service, "the distiller shells out to the agent CLI" + assert str(tmp_path / "m.log") in service + + +def test_systemd_enable_writes_units_and_arms_the_timer(tmp_path, monkeypatch): + monkeypatch.setattr(schedule.Path, "home", classmethod(lambda cls: tmp_path)) + calls = [] + + def runner(argv): + calls.append(argv) + class R: returncode, stderr = 0, "" + return R() + + path = schedule.enable("09:15", platform="linux", runner=runner) + assert path.exists() and path.name == "session-recall-metadocs.timer" + assert (path.parent / "session-recall-metadocs.service").exists() + assert ["systemctl", "--user", "daemon-reload"] in calls + assert calls[-1][-2:] == ["--now", "session-recall-metadocs.timer"] + assert schedule.is_enabled(platform="linux") + + assert schedule.disable(platform="linux", runner=runner) is True + assert not schedule.is_enabled(platform="linux") + assert not path.exists() + + +def test_unsupported_platform_fails_honestly(): + with pytest.raises(RuntimeError) as exc: + schedule.enable("09:00", platform="win32", runner=lambda argv: None) + assert "metadocs run" in str(exc.value), "the error must hand over the manual path" + + def test_watermarks_survive_reload(tmp_path): m = Watermarks(tmp_path / "w.json") m.advance("claude", "s", 42)