Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
8 changes: 6 additions & 2 deletions src/session_recall/metadocs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
138 changes: 117 additions & 21 deletions src/session_recall/metadocs/schedule.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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
38 changes: 38 additions & 0 deletions tests/test_metadocs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading