DEV Community

John
John

Posted on • Originally published at hexisteme.github.io

Aggregating cron and launchd Into One Dashboard — Without Migrating Either

Originally published on hexisteme notes.

If you run a personal fleet of automation on one machine — a handful of cron jobs, several macOS LaunchAgents, a few long-running Python agents, and some MCP servers — you hit a failure mode that has nothing to do with any single job. The schedulers do not know about each other. cron and launchd are separate worlds; the Python agents are a third; the MCP servers a fourth. There is no single place that answers the only question that matters: what is scheduled, did it run, and what did it produce?

So things rot quietly. A daily report generator stops and you do not notice for a week. An ingest job's output piles up unread. A calibration job silently never fires again after a reboot. None of these throw an error you would see — they just stop, and the absence is invisible until you go looking.

The instinct is to reach for a scheduler or a monitoring SaaS. I evaluated 25 of them, and they all failed for the same two reasons.

Why off-the-shelf tools don't fit

The candidates split into a few camps, each with a disqualifying assumption:

Tool category (examples) Why it doesn't fit
launchd GUIs — Lingon X, LaunchControl launchd only; can't see cron. No link from a job to its output files.
Schedulers — Cronicle, Rundeck, Airflow Can't read your existing registrations. They want you to re-create every job inside their system — invasive migration.
Cron monitoring SaaS — heartbeat / dead-man-switch Every job must curl a ping endpoint on success. That's editing every crontab line (migration + instrumentation), and it can't see a job that never started.
Dashboards — Homepage, Dashy Link collections. No run state, no exit codes, no outputs.
Agent observability OSS (e.g. 1.4k-star session monitors) Built to watch one runtime's sessions — not a heterogeneous cron + launchd + agent + MCP mix.

The pattern across all 25 is the same: they are push systems that require migration. You move your jobs into their model, or you instrument each job to report into them. In a high-churn setup where you add new programs and MCP servers constantly, that tax compounds — every new thing has to be migrated or it is invisible.

The gap, stated precisely: there was no tool that does read-only aggregation of existing cron + launchd registrations, with a purpose description per job and a link to each job's output files. So the design constraint became: aggregate without migrating, and read without instrumenting.

The approach: read-only aggregation

Instead of asking jobs to report in, read the state the OS already keeps. A deterministic collector unifies four read-only sources into one schema:

# cron
crontab -l                         # schedule + command per line

# launchd
~/Library/LaunchAgents/*.plist     # parse with plutil -convert json -o - <file>
                                   # (keep -o -: without it plutil rewrites the plist in place)
launchctl print gui/$UID/<label>   # load state, last exit code

# resident services
ps                                 # is the long-running process alive?
Enter fullscreen mode Exit fullscreen mode

Normalize every entry into a single record — call it a ScheduledJob: a label, the source (cron or launchd), the schedule, the command, and the log path. Now cron lines and launchd plists live in one list, sorted and searchable, with no change to either scheduler. Nothing was migrated; the collector only reads registrations that already exist.

Two things are still missing from a bare job list, and they are the difference between a dashboard you ignore and one you use.

Linking a job to its outputs

A job that "ran successfully" is not interesting; the file it produced is. To connect the two without instrumenting anything, lean on a convention you can enforce yourself: every job writes its artifacts under <project>/report/. Store one glob per job in a small catalog, and the dashboard lists each job's newest matching files by modification time — plus anything at the plist StandardOutPath. Clicking opens the file. This output shelf is the payload: you see what each job made, not just that it exited 0.

Detecting the run that didn't happen

The worst failure in scheduled work is silent — the job that should have run and didn't. Push monitoring is blind to it: a missing heartbeat looks the same as a paused job, and a job that never started sends nothing at all. A read-only aggregator can derive the signal directly by comparing the schedule to the freshness of the log. If a daily job's StandardOutPath was last written outside its expected window, raise an "expected run, no output" flag on the card. No instrumentation — just the gap between when it should have run and when its log last changed.

That catches the job that never ran. It does not catch the job that ran and did nothing — and I have to be blunt here, because I learned the difference the expensive way after writing this.

A log mtime is written by the job itself. It answers "did this process touch its own file?", not "did anything happen?" So a job wedged in a retry loop keeps appending to its log, its mtime advances every cycle, and the card stays green while nothing useful ships. That is not hypothetical: my publishing job ran on schedule for four straight days, wrote to its log every morning, kept every freshness signal green — and published nothing, because the queue upstream had quietly emptied. The dashboard I am describing in this post told me it was fine.

So take the schedule-vs-log-mtime check for exactly what it is: a cheap detector for absence of execution, and nothing more. For the jobs that matter, score an effect the job cannot produce by merely running — a downstream artifact count, rows appended to a ledger someone else owns, a remote page fetched back and verified. Where a job genuinely has no external effect (a cache warmer, a log rotator), keep the mtime signal but label the card self-reported, so you know what its green light is worth. I wrote up that fix, and the reader who caught the flaw, in Green for four days while nothing shipped.

Safety: read-and-run only, on localhost

A dashboard with a run button is a foot-gun unless you bound it hard. Four invariants:

  • Bind 127.0.0.1 only. Never exposed off the machine.
  • Whitelist, no free text. The run button triggers exactly launchctl kickstart <label> or a script whose absolute path is registered in the catalog. There is no input box that accepts an arbitrary command.
  • Read and run only — never edit or delete. The dashboard cannot modify or remove a registration. Changes happen where they always did (your crontab, your plists). A monitor that becomes a control panel is how you corrupt your schedulers.
  • No secrets in the UI. Show plist EnvironmentVariables key names, never values.

The whole thing is a small FastAPI + htmx app — a few hundred lines, two dependencies. The leverage is not the framework; it is reading state that already exists instead of demanding that jobs report to you. With one distinction I did not draw sharply enough at the time: a crontab line, a plist, the process table, an artifact in someone else's directory — those are written by somebody other than the job, and they are evidence. A job's own log and its own output directory are the job talking about itself. Both are "state on disk." Only one of them can contradict the job.

The general principle

If you control the convention — here, a per-project report/ directory — and the OS already records the state you need — schedules, exit codes, log mtimes — you do not need a heavier system. You need a read-only view that joins what is already on disk. Migration and instrumentation are costs you pay forever; a glob is a cost you pay once. For a single-operator machine that keeps growing, "aggregate without migrating" scales where "move everything into one scheduler" quietly does not.

The reframe that made this tractable was giving up on the idea that jobs should report their status. They already leave traces everywhere — a crontab line, a plist, a log file's timestamp, a directory of outputs. The dashboard just has to read them.

But read them knowing who wrote them. The crontab and the process table are written by the system; the log and the output directory are written by the job. Aggregating without migrating is still the right trade — I would build this again tomorrow. Just don't mistake a trace the job left about itself for proof that the job did its work. That was the one mistake in this design, and it cost me four silent days before I saw it.

More notes at hexisteme.github.io/notes.

Top comments (0)