Metadata-Version: 2.4
Name: routinely
Version: 0.2.0
Summary: Install system-native scheduled jobs (launchd agents, systemd user timers) from a declarative spec in pyproject.toml
Project-URL: Issues, https://github.com/etjones/routinely/issues
Project-URL: Repository, https://github.com/etjones/routinely
Author-email: Evan Jones <evan_t_jones@mac.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cron,launchd,linux,macos,scheduled-jobs,scheduler,systemd,timer
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# Routinely

[![CI](https://github.com/etjones/routinely/actions/workflows/ci.yml/badge.svg)](https://github.com/etjones/routinely/actions/workflows/ci.yml)

**Scheduled jobs that just work — on macOS and Linux.**

Sometimes I just want something to run at a specific time, or I just want to make 
sure it's always running. Routinely tries to make that simple by managing the 
native scheduling systems of MacOS & Linux (`launchd` & `systemd`).

It's not doing anything fancy- it's just keeping me from having to look up syntax 
for `launchd` and `systemd` every time I need to schedule something.

I've run into problems in the past where scheduled jobs would silently fail for 
unknown reasons, usually environment-related. Routinely tries to make that 
debugging process easier by providing tools to test the job in the same 
environment the scheduler will use, and log the output to help identify issues.

- `routinely doctor` - checks for common issues before installing
- `routinely kick` - runs the job immediately in the same environment as the scheduler
- `routinely install` - installs the job as a native scheduled task
- `routinely status` - shows the last run time and next scheduled run time
- `routinely uninstall` - removes the job from the native scheduler

Add Routinely as a dependency, include a `[tool.routinely]` section in your 
`pyproject.toml`, and run `routinely install`. After that, everything should 
Just Work.


## Contents

- [Quickstart](#quickstart)
- [How it works](#how-it-works)
- [Config reference](#config-reference)
- [Schedule syntax](#schedule-syntax) — [phrases](#phrase-forms) · [cron](#cron-form)
- [Long-running services](#long-running-services-schedule--always) — `schedule = "always"`
- [CLI reference](#cli-reference)
- [`routinely doctor`](#routinely-doctor) — preflight diagnostics
- [`routinely kick`](#routinely-kick--test-the-job-for-real) — test the job for real
- [Why does my job not run?](#why-does-my-job-not-run)
- [launchd vs systemd semantics](#launchd-vs-systemd-same-knob-different-guarantees)
- [What Routinely is not](#what-routinely-is-not)
- [Development](#development)
- [Authorship](#authorship)

## Quickstart

1. Add `routinely` to your project. 

   ```sh
   uv add routinely
   ```

2. Add a job block to your `pyproject.toml`:

   ```toml
   [tool.routinely.digest]
   command  = "python -m digest.main"
   schedule = "daily at 06:00"
   catch_up = true
   ```

3. Install it:

   ```sh
   uv run routinely install
   ```

The job will be installed at the appropriate location 
(macOS: `~/Library/LaunchAgents/`, Linux: `~/.config/systemd/user/`) 
and will start running on the specified schedule.
`routinely render` shows the exact unit file
before anything touches your system.

## How it works

`launchd` agents are described in a relatively complex XML [PList format](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html). 
By default, it doesn't expand environment variables or resolve `~` in paths. 
It can be difficult to specify paths correctly in a plist, and Routinely makes 
it easier by resolving paths at install time. Don't worry about virtual 
environments or committing absolute paths - Routinely handles it for you.

Routinely renders the unit at **install time** from the
`pyproject.toml`block spec above, resolving paths against the project root, `$HOME`, and
the active interpreter (`sys.executable`) — so the committed config stays
machine-independent, and moving a project just means running
`routinely install` again. Preflight checks run before anything is written,
catching the failure modes launchd never reports.

Likewise, `systemd` services on Linux are described in their own 
[format](https://fedoraproject.org/wiki/Packaging:Systemd). 
Routinely handles the same path resolution and environment variable expansion 
for systemd as well. With a couple exceptions (described [below](#systemd-considerations)), 
Routinely provides the same experience for both platforms.

Requires Python 3.11+. Zero runtime dependencies.

## Example Routinely Blocks
You can probably copy and paste these into your `pyproject.toml`. Syntax docs 
are below, but this is probably enough to get you started.

- Run daily at 2:00 AM
```toml
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "Daily at 2:00"
catch_up = true
```

- Run every hour, at X:00
```toml
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "Hourly"
catch_up = true
```

- Keep a long-running service always running
```toml
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "always"
```

- Run a job once a week on Monday at 3:00 AM
```toml
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "Weekly on Monday at 3:00"
catch_up = true
```

- Complex cron schedules (here: run every weekday at 6:00 AM, 9:00 AM, and 3:00 PM)
```toml
[tool.routinely.<$YOUR_JOB_NAME>]
command = "python $YOUR_JOB_NAME.py"
schedule = "0 6,9,15 * * 1-5"
catch_up = true
```


## Config reference

All keys live under `[tool.routinely.<job-name>]`. One block per job;
multiple blocks are fine.

| Key | Required | Default | Meaning |
|---|---|---|---|
| `command` | yes | — | Command line, shell-split. A leading `python`/`python3`/`python3.x` is replaced with the interpreter running `routinely install` — so `uv run` / an active venv "just works". A leading relative path resolves against the project root; a bare command is looked up on `PATH` at install time. |
| `schedule` | yes | — | 5-field cron (`"0 6 * * *"`), one of four scheduled phrase forms, or `"always"` for a [long-running service](#long-running-services-schedule--always). Full grammar: [Schedule syntax](#schedule-syntax). |
| `catch_up` | no | `false` | Run a missed slot when the machine comes back. See the semantics table below — **this is the key whose meaning differs most across platforms.** |
| `label` | no | `local.<project-name>.<job-name>` | Unit identity: launchd label / systemd unit basename. Uninstall works by label even after the project moves. |
| `working_directory` | no | `"."` (project root) | Relative paths resolve against the project root; `~` expands at install time. |
| `stdout`, `stderr` | no | macOS: `~/Library/Logs/<job>/{out,err}.log`; Linux: the journal | Log destinations. Parent directories are created for you. |
| `environment` | no | `{}` | Extra environment variables. Scheduled jobs inherit **no** shell environment; set `PATH` explicitly if your job spawns other tools. |

The **project root** is the git toplevel, or the directory containing
`pyproject.toml` if there's no git repo.


## Schedule syntax

A `schedule` string is either [**5-field cron**](https://manp.gs/mac/5/crontab) or one of [**five phrase
forms**](#phrase-forms). The phrases are Routinely's own (there is no external standard for
them); this section is their complete definition, and
`src/routinely/schedule.py` is the source of truth. Parsing is
case-insensitive and whitespace-insensitive (`"Daily  at 6:00"` is fine).
Anything that isn't a recognized phrase is parsed as cron; anything invalid
is rejected at `render`/`install` time with an error naming the bad field —
nothing falls through silently.

### Phrase forms

| Phrase | Grammar | Equivalent | Meaning |
|---|---|---|---|
| `"hourly"` | exactly that word | cron `0 * * * *` | at minute 0 of every hour |
| `"daily at HH:MM"` | `HH` = 0–23 (one or two digits), `MM` = exactly two digits 00–59 | cron `MM HH * * *` | once a day at that local time |
| `"weekdays at HH:MM"` | same time rule | cron `MM HH * * 1-5` | Monday–Friday at that local time |
| `"every N <unit>"` | `N` ≥ 1; unit `s`/`sec(s)`/`second(s)`, `m`/`min(s)`/`minute(s)`, `h`/`hr(s)`/`hour(s)`; space before the unit optional (`15m` or `15 minutes`) | — no cron equivalent | an **interval**, not a calendar time |
| `"always"` | exactly that word | — not a schedule at all | a **long-running service**: no timer, the init system starts it at login/boot and restarts it on failure — see [Long-running services](#long-running-services-schedule--always) |

That's the whole phrase language — there is deliberately no `"monthly"`,
`"every tuesday"`, or natural-language date parsing. Anything beyond these
five shapes is cron's job.

**Calendar vs interval matters.** The first three phrases produce calendar
schedules (launchd `StartCalendarInterval` / systemd `OnCalendar`), which
support catch-up semantics. `every N …` produces an interval (launchd
`StartInterval` / systemd `OnBootSec` + `OnUnitActiveSec`), which fires
"every N since the last run" with no fixed wall-clock alignment — and on
macOS, interval firings missed during sleep are simply lost (see the
semantics table below).

### Cron form

Standard 5 fields — `minute hour day-of-month month day-of-week` — with the
usual constructs per field:

- `*` — any value
- `5` — a single value
- `1,15` — a list
- `9-17` — an inclusive range
- `*/15`, `9-17/2`, `5/20` — steps over a range (`5/20` = from 5 to the
  field max, every 20)

Ranges: minute 0–59, hour 0–23, day 1–31, month 1–12, weekday 0–7 (0 and 7
are both Sunday; numeric only — `mon`/`jan` names are not supported).
Descending ranges (`5-1`) are rejected rather than wrapped.

One classic cron subtlety is preserved on both platforms: if **both**
day-of-month and day-of-week are restricted, the job runs when **either**
matches (`0 6 1,15 * 1` = the 1st, the 15th, *and* every Monday).

All times are local time. There is no timezone field and no seconds field.


## Long-running services: `schedule = "always"`

Some things aren't jobs — a local web server, a watcher, a bridge process —
they should just *be running*. `schedule = "always"` covers that case with
the same spec and the same commands:

```toml
[tool.routinely.serve]
command  = "uv run --no-dev uvicorn --factory plug_rest.app:create_app --host 0.0.0.0 --port 8000"
schedule = "always"
```

Instead of a timer pair, Routinely renders a plain service unit and lets the
init system do the supervising:

- **macOS**: a launchd agent with `RunAtLoad = true` and
  `KeepAlive = {SuccessfulExit = false}` — launchd starts it at load/login
  and restarts it whenever it exits with a non-zero status. A clean `exit 0`
  stays stopped (that's your off switch from inside the process).
- **Linux**: a systemd user service with `Restart=on-failure`,
  `RestartSec=3`, `After=network-online.target`, and
  `WantedBy=default.target` — no `.timer` unit at all. `install` enables and
  (re)starts the service.

The commands map naturally:

- `install` / `uninstall` / `render` / `logs` — unchanged.
- `status` — shows whether the service is loaded and **running (with its
  pid)** instead of a next-fire time.
- `kick` — **restarts** the service (`launchctl kickstart -k` /
  `systemctl restart`), which is what you want after a code change.
- `doctor` — same path/syntax checks, plus: on Linux it warns when
  `loginctl` linger is off (without it the service stops at logout and won't
  start until you log in — fix with `loginctl enable-linger`), and on both
  platforms it warns if `catch_up` is set, which is meaningless for a
  service that's always running.

Reinstalling an always-service restarts it, so `routinely install` is also
the "deploy the config change" command.

One honesty note on parity: systemd restarts the service on any failure,
including abnormal signals; launchd's `SuccessfulExit = false` keys off the
exit status. And restart pacing differs — systemd waits `RestartSec=3` and
applies its start-rate limiting, while launchd throttles respawns on its own
(roughly a 10-second minimum between starts).

## CLI reference

```
routinely install   [name]   # render → validate → install → load. Idempotent.
routinely render    [name] [--platform launchd|systemd]   # print unit(s), touch nothing
routinely doctor    [name] [--json]   # preflight diagnostics (see below)
routinely kick      [name] [--restart]   # run the job NOW, in the real scheduled environment
routinely status    [name] [--json]   # loaded? last exit? next fire? — same view on both platforms
routinely logs      [name] [-f] [-n N]
routinely uninstall [name] [--label LABEL]   # by label; works after the project moves
```

With no `name`, commands operate on every job in the project. Everything is
**user scope** (`~/Library/LaunchAgents`, `systemctl --user`) — no sudo.
For `schedule = "always"` services, `kick` restarts the service and `status`
reports running state (pid) instead of a next-fire time.

`install` is a reinstall when the job already exists (unload → rewrite →
reload), so it's also how you apply config changes. It refuses to install if
any preflight check fails, and tells you what to fix.

**For scripts and agents:** `status --json` and `doctor --json` emit
structured JSON instead of the human formatting. Exit codes are meaningful
everywhere: `doctor` exits 1 if any check fails, `status` exits 1 if any
selected job is not loaded, `install` exits 1 on refusal or error, and all
commands exit 0 on success. Nothing ever prompts interactively, `install` is
safe to retry, and `kick` lets an automated caller verify a job end-to-end
without waiting for its schedule.

## `routinely doctor` - preflight diagnostics

Every check corresponds to a real way launchd jobs die silently:

```
$ routinely doctor
✓ label        com.evanjones.paperdigest
✓ program      .../.venv/bin/paper-digest -> .../.venv/bin/python3
✓ working dir  /Users/…/daily_scholar_digest
✓ log dir      /Users/…/Library/Logs/paper-digest (created)
✓ unit syntax  plutil -lint OK
✓ loaded       yes · last exit 0 · next fire 2026-07-22 06:00
```

- program exists **and is executable** — the #1 cause of a silently-dead agent
- working directory exists
- log parent directories exist (created if missing — launchd won't)
- rendered unit passes `plutil -lint` (macOS) / `systemd-analyze verify` (Linux)
- loaded state, last exit code, next fire time
- Linux: warns if `loginctl` linger is off (user timers stop at logout)
- warns when a cron expression explodes combinatorially on launchd (see below)

## `routinely kick` — test the job for real

Running your command in a terminal proves almost nothing about how it behaves
under the scheduler: your shell has a full `PATH`, your environment variables,
your working directory. The scheduled run has **none** of that — launchd
starts jobs with an empty environment, and "works in my terminal, dies at 6am"
is the classic launch-agent debugging time sink.

`kick` asks the init system itself to run the job immediately
(`launchctl kickstart` / `systemctl --user start`), so it executes with
exactly the scheduled run's environment, working directory, and log
destinations:

```sh
routinely kick          # fire the job now
routinely logs -f       # watch what it did
routinely status        # …and how it exited
```

`--restart` kills a currently-running instance first (`kickstart -k` /
`systemctl restart`); without it, kick only starts the job if it isn't
already running.

Two things kick deliberately does **not** do:

- It doesn't bypass your app's own run policy. If the job internally decides
  "already ran today, nothing to do," a kick runs the process and the process
  declines — which is itself a useful test. Give your app a force flag (e.g.
  `--once`) if you need to override its policy.
- It doesn't simulate the environment by re-spawning the process itself. The
  init system is the only thing that ever runs your job, so what you debug is
  what ships.

The debugging loop, in order:

1. `routinely doctor` — static checks: paths, permissions, syntax, loaded
   state. Catches most silent failures before anything runs.
2. `routinely kick` — dynamic check: does the job actually work under the
   scheduler, right now?
3. `routinely logs` / `routinely status` — what happened and how it exited.

## Why does my job not run?

launchd fails **silently** — no error, no log, the job just never fires. In
rough order of likelihood:

1. **The interpreter path is stale.** You recreated `.venv` (e.g. fresh
   `uv sync`), so the path baked into the installed plist no longer exists.
   Run `routinely doctor` — the program check fails — then
   `routinely install` to re-render against the new interpreter.
2. **You edited config but didn't reinstall.** Neither launchd nor systemd
   watches files: units are read at load time. `routinely install` again.
3. **The machine was asleep or off at the scheduled time** and
   `catch_up = false`. That slot is simply gone. Set `catch_up = true` if a
   late run is better than no run.
4. **Interval schedules (`every 15m`) miss firings during sleep on macOS** —
   per `launchd.plist(5)`, that's inherent to `StartInterval`. Use a calendar
   schedule if catch-up matters.
5. **The job ran but crashed instantly.** `routinely status` shows the last
   exit code; `routinely logs` shows stderr. A common cause: the job's own
   subprocesses need a `PATH` you haven't set in `environment`. Reproduce it
   on demand with `routinely kick` instead of waiting for the next fire.
6. **Linux: you logged out.** `systemctl --user` timers stop at logout unless
   `loginctl enable-linger` is set. `doctor` warns about this.
7. **macOS said "Background Items Added"** and someone clicked it off in
   System Settings → General → Login Items. Re-enable it there.

## launchd vs systemd: same knob, different guarantees

Routinely maps one spec to both platforms honestly rather than pretending
parity:

| Situation | launchd (macOS) | systemd (Linux) |
|---|---|---|
| Slot missed while **asleep** (calendar) | Fires on next wake; multiple missed slots coalesce into one | `Persistent=true` catches it at next timer evaluation |
| Slot missed while **powered off** (calendar) | Covered only by `RunAtLoad` → runs at next login | `Persistent=true` runs it at next boot (last-trigger time is tracked on disk) |
| Extra runs when nothing was missed | **Yes**: `catch_up` = `RunAtLoad`, which fires at install and at *every* login — launchd keeps no last-run state | No: `Persistent` fires only if a slot was actually missed |
| Slot missed while asleep (interval) | **Lost** — `StartInterval` limitation | Timer resumes; `OnBootSec` restarts it after reboot |
| Schedule expressiveness | Single integers per field: `*/15 9-17 * * 1-5` expands to **180** calendar dicts (Routinely warns past 24 and suggests an interval) | `OnCalendar` expresses lists/ranges compactly |
| DST / timezone change | Cached next-fire date can be wrong until reload | Recomputed |
| Logs | Files (launchd has no journal) | journald (`routinely logs` wraps `journalctl`) |
| Keep-alive for `schedule = "always"` | `KeepAlive` `SuccessfulExit=false`: restarts on **non-zero exit**; a clean `exit 0` stays stopped; launchd self-throttles respawns (~10 s) | `Restart=on-failure` (any failure incl. signals) with `RestartSec=3`; systemd start-rate limiting applies |
| Last exit code in `status` | Reported after every run | **Reported only after failures.** A successful oneshot resets its exec state to the same zeros as a never-ran service (verified live), and the fix — `RemainAfterExit=yes` — would stop the timer and `kick` from re-triggering the job. Success is inferred from the journal (`routinely logs`), not the exit code. |
| Unit syntax preflight | `plutil -lint` (always available) | `systemd-analyze verify --user` needs systemd ≥ 250 (Ubuntu 24.04+); on older hosts `doctor` downgrades the check to a warning |

The practical consequence: **a `catch_up = true` job must tolerate being
started when there's nothing to do** — e.g. "already ran today? exit 0".
That's by design: Routinely schedules *opportunities to run*; whether a
run is actually due is application state, and only the application can judge
it.

macOS bonus: `install` points the job at a descriptively-named symlink to the
interpreter, so Login Items and `ps` show your job's name instead of
"python".

## What Routinely is not

- **Not a run-policy engine** — "at most once per day" logic belongs in your
  app, which owns the state that defines "done".
- **Not a supervisor itself** and **not a task queue** — `schedule = "always"`
  delegates keep-alive to launchd/systemd rather than running any Routinely
  process of its own; if you need process groups, dependency graphs, or
  managed restart policies beyond "restart on failure", see supervisord,
  Celery et al. Scope here is unit-file lifecycle: render, validate, install,
  inspect, remove.

## Development

```sh
just test    # run the test suite
just run …   # run the CLI from source
```

Renderers are pure functions and both are tested on every host platform; no
test touches `launchctl`, `systemctl`, or your `LaunchAgents` directory.
Contributing with an AI agent? Start with [AGENTS.md](https://github.com/etjones/routinely/blob/main/AGENTS.md) — it holds
the invariants and the list of behaviors that look like bugs but aren't.

## Authorship

Routinely was pair-programmed with [Claude Code](https://claude.com/claude-code)
(Claude Fable 5), with every change human-directed and human-reviewed; the
`Co-Authored-By` trailers in the git history mark the AI's hand, and
[CONVERSATION.md](https://github.com/etjones/routinely/blob/main/CONVERSATION.md) is a running log of the collaboration —
including the design arguments and the live findings on real hardware.
Trust, though, should come from the evidence rather than the byline: the
behavior documented here is backed by a unit suite that runs on every
commit, live validation against real launchd and systemd (including the
platform quirks in the semantics table above, several of which were
discovered empirically), and a production job that has been running on this
code throughout its development.
