Skip to content

Give cron jobs an on/off switch in the web UI - #321

Draft
alex-clickhouse wants to merge 1 commit into
mainfrom
alex-clickhouse/cron-enable-toggle
Draft

Give cron jobs an on/off switch in the web UI#321
alex-clickhouse wants to merge 1 commit into
mainfrom
alex-clickhouse/cron-enable-toggle

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

What

Cron hot-reload already applies an enabled: change from the cron files without
a 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.

  • UI — an on/off switch on each job in the Cron Jobs list (revealed on hover
    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.
  • APIPOST /api/cron/jobs/<id>/enable and .../disable, following the
    two-route enable/disable pattern already used by routes/external_agents.py.
  • ServiceCronService.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_dump cannot
round-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:

  • The position comes from the parser, not from pattern-matching.
    yaml.compose gives every node its source offsets, so the job is found by
    structure. A prompt that happens to contain enabled: true — there is a test
    for exactly this — cannot be mistaken for the flag, which is the corruption a
    text search or an indentation heuristic eventually produces while reporting
    success.
  • The result is checked as data before the file is touched. The requested
    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.yaml would round-trip comments natively, but pyyaml
plus 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/jobs now carries a per-job toggle_refusal, so the UI renders a
locked switch with the reason as its tooltip instead of a live-looking one that
fails:

  • Source runners have no flag in the cron files — they exist because their
    sync section is enabled.
  • Under lockdown the cron files are reviewed config, so the API answers 403
    and 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 is
    left 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.yaml copy alone. Status codes are 404 / 403 / 400,
and the clause order matters: ConfigError subclasses ValueError, so catching
the broad one first would report a schedule typo as a missing job (test included).

Testing

  • python -m pytest tests/ -q3331 passed
  • npm run build — clean (tsc -b + vite)
  • npm run test115 passed (9 files), incl. a new cronStore.test.ts
  • npm run lint — 64 problems on the touched files, identical to
    origin/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

  • One bug this caught during development: the response spread **summary
    over its own fields, and reload()'s summary has an enabled of its own
    meaning the count of scheduled jobs. A caller reading enabled got a
    plausible integer instead of the flag it set. The summary is now nested under
    reload, with a test pinning it.
  • Pre-existing, deliberately not fixed here: the sidebar row is a <button>
    and already nests TriggerButton/ChatLink inside it; the switch follows that
    pattern rather than restructuring the row. Nested interactive elements are an
    a11y problem worth a focused follow-up.
  • A file with duplicate job ids is refused rather than edited. load_jobs
    already resolves such a file unpredictably (last wins), so refusing is the safe
    read.

🤖 Generated with Claude Code

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>
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

CI: the backend failure here is pre-existing, not from this change

Backend tests (Python 3.13) fails at collection, before any test runs:

nerve/mcp_server/http.py:32: in <module>
    from mcp.server.lowlevel.server import request_ctx
E   ImportError: cannot import name 'request_ctx' from 'mcp.server.lowlevel.server'
Interrupted: 8 errors during collection

All 8 are MCP test modules. This PR touches no MCP code and no dependency
files
git diff origin/main..HEAD --name-only | grep -i mcp is empty.

Cause: mcp is not a direct dependency, it arrives transitively via
claude-agent-sdk, so there is no bound holding it back. CI resolved
mcp==2.0.0, which removed request_ctx; my local venv still has
mcp==1.29.0, which is why the same suite is 3331 passed locally. Frontend
build passes in CI.

Already being fixed elsewhere, so deliberately not addressed here:

This branch should go green once either lands; happy to rebase on request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant