Give cron jobs an on/off switch in the web UI - #321
Draft
alex-clickhouse wants to merge 1 commit into
Draft
Conversation
Hot-reload already applied an enable/disable from the cron files without a
restart; nothing could write the flag, so the only way to pause a job was to
edit YAML by hand and POST /api/cron/reload. Adds the missing half: a switch
on each job in the Cron Jobs list and on the selected job's card, behind
POST /api/cron/jobs/<id>/{enable,disable}.
The write is surgical rather than a re-dump. save_jobs() would have been the
obvious reuse and is the wrong tool here — safe_dump cannot round-trip
comments, so pausing one job would delete every note in a hand-written cron
file and materialize each omitted default as an explicit key. Instead only the
scalar after `enabled:` is rewritten, or one line inserted, with the position
taken from yaml.compose's source marks so a prompt that happens to mention
`enabled: true` cannot be mistaken for the flag. The result is diffed against
the original as data before the file is touched: the requested flag must have
moved and nothing else may have.
Write and reload are atomic together. A reload can be refused for reasons that
have nothing to do with the job being toggled — another job's schedule typo, a
malformed file — and the file is put back when it is, because the alternative
is a job the YAML calls disabled that keeps firing until something else
reloads.
Jobs report a per-job toggle_refusal, so the UI renders a locked switch with
the reason instead of one that fails on click: source runners have no flag in
the cron files, and under lockdown the cron files are reviewed config where
the answer is a PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
CI: the backend failure here is pre-existing, not from this change
All 8 are MCP test modules. This PR touches no MCP code and no dependency Cause: Already being fixed elsewhere, so deliberately not addressed here:
This branch should go green once either lands; happy to rebase on request. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Cron hot-reload already applies an
enabled:change from the cron files withouta restart — but nothing could write that flag, so pausing a job meant editing
YAML by hand and calling
POST /api/cron/reload. This adds the missing half.while a job is on, kept visible once it is off) and on the selected job's card.
Same track-and-knob shape as the skills toggle, so the two read as one control.
POST /api/cron/jobs/<id>/enableand.../disable, following thetwo-route enable/disable pattern already used by
routes/external_agents.py.CronService.set_job_enabled()writes the flag, then reloads.Why the write is surgical, not a re-dump
save_jobs()was the obvious reuse and is the wrong tool:safe_dumpcannotround-trip comments, so pausing one job would silently delete every note in a
hand-written cron file and materialize each omitted default as an explicit key.
That is the same reasoning — and the same choice — as the settings writer in
nerve/bootstrap.py, which refuses rather than discard someone's rationale.So only the smallest span that carries the answer is rewritten: the scalar after
enabled:, or a single inserted line. Two details worth review:yaml.composegives every node its source offsets, so the job is found bystructure. A prompt that happens to contain
enabled: true— there is a testfor exactly this — cannot be mistaken for the flag, which is the corruption a
text search or an indentation heuristic eventually produces while reporting
success.
flag must have moved and nothing else may have differed. A surgical text edit
that parses is still a text edit; this is what separates "the file now says
what I meant" from "the old value is gone".
No new dependency —
ruamel.yamlwould round-trip comments natively, but pyyamlplus source marks covers the one field this needs.
Atomicity
The write and the reload happen together under the reload lock, and a refused
reload puts the file back. A reload can fail for reasons unrelated to the job
being toggled — another job's schedule typo, a malformed file — and if the file
kept the new flag it would claim a job is off while the scheduler kept firing it
until something else reloaded. That is the one outcome an off switch may not
produce.
Refusals are reported, not discovered by clicking
GET /api/cron/jobsnow carries a per-jobtoggle_refusal, so the UI renders alocked switch with the reason as its tooltip instead of a live-looking one that
fails:
syncsection is enabled.403and the change belongs in a PR. Guarded via
ensure_path_not_tracked_config,the same guard
save_jobs()already carries, with a test asserting the file isleft untouched — a guard that refuses after writing is not a guard.
A job defined in both files is edited in the one that won the merge, leaving
the shadowed
system.yamlcopy alone. Status codes are404/403/400,and the clause order matters:
ConfigErrorsubclassesValueError, so catchingthe broad one first would report a schedule typo as a missing job (test included).
Testing
python -m pytest tests/ -q— 3331 passednpm run build— clean (tsc -b+ vite)npm run test— 115 passed (9 files), incl. a newcronStore.test.tsnpm run lint— 64 problems on the touched files, identical toorigin/main; no new lint errors (the repo has 150 pre-existing)New coverage: comment/format preservation, the prompt-mentions-the-key trap,
insert-when-absent, quoted flag, bare top-level list (old installs), missing
trailing newline, flow style, unparseable file, rollback on refused reload,
system-vs-user file targeting, the lockdown guard, and the response-shape
collision below.
Notes for the reviewer
**summaryover its own fields, and
reload()'s summary has anenabledof its ownmeaning the count of scheduled jobs. A caller reading
enabledgot aplausible integer instead of the flag it set. The summary is now nested under
reload, with a test pinning it.<button>and already nests
TriggerButton/ChatLinkinside it; the switch follows thatpattern rather than restructuring the row. Nested interactive elements are an
a11y problem worth a focused follow-up.
load_jobsalready resolves such a file unpredictably (last wins), so refusing is the safe
read.
🤖 Generated with Claude Code