Skip to content
Draft
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
28 changes: 28 additions & 0 deletions docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,34 @@ Notes:
- `show_session_label` is **restart-only**: changing it and reloading has no
effect until the daemon restarts.

### Turning a job on and off

Each cron job has an on/off switch in the web UI (**Cron Jobs** — in the job list
and on the selected job's card). It is a thin wrapper over the reload above:

`POST /api/cron/jobs/<id>/enable` · `POST /api/cron/jobs/<id>/disable`

Both write `enabled:` for that one job in whichever file it was loaded from, then
reload, so the change survives a restart instead of lasting until the next one.

- Only the flag is rewritten — the scalar after `enabled:`, or one inserted line.
Comments, key order and formatting elsewhere in the file are preserved, and a
job that never had an `enabled:` key does not gain one until you toggle it.
- The write and the reload are **atomic together**. If the reload is refused —
because of another job's typo, say — the file is put back, so it can never
claim a job is off while the scheduler keeps firing it.
- A job defined in both files is edited in the one that **won** the merge
(`jobs.yaml`), leaving the shadowed `system.yaml` copy alone.
- **Source runners have no switch.** They exist because their `sync` section is
enabled; turn them off there.
- Under [lockdown](config.md#lockdown-remote-only-read-only) the cron files are
reviewed config, so the switch is disabled and the API answers `403`. Change
`enabled:` in the config repo and open a PR.

Failures come back as `404` (no such job), `403` (lockdown) or `400` — the last
covering a file this edit cannot be expressed in, such as a job written in flow
style (`- {id: x, ...}`) that has no `enabled:` key to overwrite.

## Job Definition

```yaml
Expand Down
193 changes: 193 additions & 0 deletions nerve/cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,199 @@ def load_jobs(
return jobs


class JobEditError(Exception):
"""A cron file could not be edited in place, with the reason why.

Carries an operator-facing message: every raise site names the file and what
about it defeated the edit, because the answer is always "go and change that
line by hand" and the message is the only thing that says which line.
"""


def _find_jobs_sequence(root: yaml.Node, jobs_file: Path) -> yaml.SequenceNode:
"""The node holding the job list, for either shape :func:`load_jobs` accepts.

``jobs:`` under a mapping is the documented form; a bare top-level list is
the one old installs still have (see :func:`load_jobs`). Both are read at
startup, so both have to be editable — supporting only the first would give
those installs a toggle that returns success and changes nothing.
"""
if isinstance(root, yaml.SequenceNode):
return root
if isinstance(root, yaml.MappingNode):
for key, value in root.value:
if isinstance(key, yaml.ScalarNode) and key.value == "jobs":
if not isinstance(value, yaml.SequenceNode):
raise JobEditError(
f"{jobs_file}: 'jobs' is not a list"
)
return value
raise JobEditError(f"{jobs_file}: no 'jobs' list to edit")


def _mapping_entry(
mapping: yaml.MappingNode, name: str,
) -> tuple[yaml.ScalarNode, yaml.Node] | None:
"""The ``(key, value)`` node pair for *name*, or ``None`` if absent."""
for key, value in mapping.value:
if isinstance(key, yaml.ScalarNode) and key.value == name:
return key, value
return None


def set_job_enabled_in_file(
jobs_file: Path, job_id: str, enabled: bool,
) -> bool:
"""Flip one job's ``enabled`` flag in *jobs_file*. Returns whether it changed.

Rewrites the smallest span of text that carries the answer — the scalar after
``enabled:``, or a single inserted line — rather than re-dumping the
document. :func:`save_jobs` is the other option and the wrong one for a
toggle: ``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. The same reasoning, and the same choice,
as the settings writer in :mod:`nerve.bootstrap`.

Locating the span is left to the parser. ``yaml.compose`` gives every node
the offsets it occupied in the source, so the job is found by structure and
the edit lands at a real position — where matching ``enabled:`` by text or
indentation would be guessing, and would eventually guess on a job whose
prompt happens to contain the word.

The result is parsed and compared against the original before anything is
written: the requested flag must have moved and nothing else may have. A
surgical text edit that type-checks is still a text edit, and the cheap
structural check is what separates "the file now says what I meant" from
"the old value is gone".

Raises :class:`JobEditError` if the file can't be parsed, holds no such job,
or is shaped so the flag can't be placed.
"""
try:
text = jobs_file.read_text(encoding="utf-8")
except OSError as e:
raise JobEditError(f"Cannot read {jobs_file}: {e}") from e

try:
root = yaml.compose(text)
except yaml.YAMLError as e:
raise JobEditError(f"Cannot parse {jobs_file}: {e}") from e
if root is None:
raise JobEditError(f"{jobs_file} is empty")

sequence = _find_jobs_sequence(root, jobs_file)

target: yaml.MappingNode | None = None
for item in sequence.value:
if not isinstance(item, yaml.MappingNode):
continue
found = _mapping_entry(item, "id")
if (
found is not None
and isinstance(found[1], yaml.ScalarNode)
and found[1].value == job_id
):
target = item
break
if target is None:
raise JobEditError(f"{jobs_file} defines no job {job_id!r}")

literal = "true" if enabled else "false"
existing = _mapping_entry(target, "enabled")

if existing is not None:
_, value_node = existing
if not isinstance(value_node, yaml.ScalarNode):
raise JobEditError(
f"{jobs_file}: job {job_id!r} has a non-scalar 'enabled' value "
"— edit it by hand"
)
start, end = value_node.start_mark.index, value_node.end_mark.index
# Anything after `enabled:` on that line — a trailing comment most
# often — sits outside the scalar's span and survives untouched.
new_text = text[:start] + literal + text[end:]
else:
# No flag to overwrite, so one is added. It goes directly beneath `id:`,
# at the column the mapping's keys already use: the first key of a
# sequence item shares its line with the `- `, so taking the indent from
# `id` rather than from that line is what keeps the insert aligned with
# its siblings instead of with the dash.
if target.flow_style:
raise JobEditError(
f"{jobs_file}: job {job_id!r} is written in flow style "
"({...}) and has no 'enabled' key — add one by hand"
)
id_key, id_value = _mapping_entry(target, "id") # type: ignore[misc]
indent = " " * id_key.start_mark.column
lines = text.splitlines(keepends=True)
at = id_value.end_mark.line + 1
# A file whose last line has no newline would otherwise get the new key
# appended to it.
if at > 0 and lines[at - 1] and not lines[at - 1].endswith("\n"):
lines[at - 1] += "\n"
lines.insert(at, f"{indent}enabled: {literal}\n")
new_text = "".join(lines)

if new_text == text:
return False

_verify_only_enabled_changed(text, new_text, job_id, enabled, jobs_file)

try:
jobs_file.write_text(new_text, encoding="utf-8")
except OSError as e:
raise JobEditError(f"Cannot write {jobs_file}: {e}") from e
return True


def _verify_only_enabled_changed(
before: str, after: str, job_id: str, enabled: bool, jobs_file: Path,
) -> None:
"""Refuse an edit that did anything besides set *job_id*'s flag.

Compares the two documents as data: the target job's ``enabled`` must now be
*enabled*, and every other key of every job — including the rest of the
target's — must be identical. A span computed from stale offsets, a second
job sharing the id, a truncating write: each shows up here as a diff nobody
asked for, before the file is touched.
"""
def jobs_of(text: str) -> list[dict]:
data = yaml.safe_load(text) or {}
raw = data.get("jobs", []) if isinstance(data, dict) else data
return [j for j in raw if isinstance(j, dict)] if isinstance(raw, list) else []

try:
old_jobs, new_jobs = jobs_of(before), jobs_of(after)
except yaml.YAMLError as e:
raise JobEditError(
f"{jobs_file}: editing job {job_id!r} would leave unparseable "
f"YAML ({e}) — file not written"
) from e

if len(old_jobs) != len(new_jobs):
raise JobEditError(
f"{jobs_file}: editing job {job_id!r} changed the job count "
f"({len(old_jobs)} → {len(new_jobs)}) — file not written"
)

for old, new in zip(old_jobs, new_jobs):
is_target = old.get("id") == job_id
if is_target and new.get("enabled") is not enabled:
raise JobEditError(
f"{jobs_file}: setting job {job_id!r} to enabled={enabled} "
f"produced {new.get('enabled')!r} — file not written"
)
stripped_old = {k: v for k, v in old.items() if k != "enabled"}
stripped_new = {k: v for k, v in new.items() if k != "enabled"}
if stripped_old != stripped_new or (
not is_target and old.get("enabled") != new.get("enabled")
):
raise JobEditError(
f"{jobs_file}: editing job {job_id!r} would also change job "
f"{old.get('id')!r} — file not written"
)


def save_jobs(jobs: list[CronJob], jobs_file: Path) -> None:
"""Save cron jobs to a YAML file.

Expand Down
105 changes: 105 additions & 0 deletions nerve/cron/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import logging
from datetime import datetime, timezone, tzinfo
from pathlib import Path
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo

Expand All @@ -21,9 +22,11 @@
from nerve.config import ConfigError, NerveConfig
from nerve.cron.jobs import (
CronJob,
JobEditError,
describe_reserved_job_ids,
is_reserved_job_id,
load_jobs,
set_job_enabled_in_file,
)
from nerve.db import Database

Expand Down Expand Up @@ -427,6 +430,101 @@ async def _reload_locked(self) -> dict:
GATE_REGISTRY.update(gates_before)
raise

def job_source_file(self, job: CronJob) -> Path:
"""The cron file *job* was loaded from, and the one a toggle writes.

Follows the merge in :meth:`_load_merged_jobs`: a user job shadowing a
system job of the same id is the one that got scheduled, so it is also
the one an edit has to land in. Writing the system file there would
change the copy that loses and leave the running job as it was.
"""
if job.metadata.get("_source") == "system":
return self.config.cron.system_file
return self.config.cron.jobs_file

def toggle_refusal(self, job: CronJob) -> str | None:
"""Why this job's enabled flag can't be toggled from the API, or ``None``.

Reported per job by :meth:`list_jobs` so the UI can render the control as
unavailable and say why, instead of offering a switch that fails when
pressed. Only lockdown refuses today, and it refuses by path: on a
migrated install the cron files are inside the reviewed config subtree,
where the answer is a reviewed PR rather than a live write.
"""
from nerve.config import tracked_config_write_refusal

return tracked_config_write_refusal(self.job_source_file(job))

async def set_job_enabled(self, job_id: str, enabled: bool) -> dict:
"""Persist *job_id*'s enabled flag, then apply it to the scheduler.

The pair has to be atomic in both directions, because the file is what
the next reload and the next restart read, and the scheduler is what
actually fires. So the write and the reload happen under the reload lock,
and a reload that refuses puts the file back: the alternative is a job
the YAML calls disabled that goes on running until something else
reloads, which is the one outcome an off switch may not produce.

A reload can refuse for reasons that have nothing to do with this job —
another job's schedule typo, a malformed file — and that is exactly when
the rollback earns its place. ``enabled`` is applied by
:meth:`_reload_from_disk` like any other field: newly disabled jobs are
unscheduled, newly enabled ones are given a trigger.

Returns this job's resulting state, whether the file needed changing, and
the reload summary under ``reload``. Raises ``ValueError`` for an unknown
job, :class:`JobEditError` if the file cannot carry the change, and
:class:`nerve.config.LockdownError` if it is tracked config.
"""
from nerve.config import ensure_path_not_tracked_config

job = next((j for j in self._jobs if j.id == job_id), None)
if job is None:
raise ValueError(f"No such cron job: {job_id!r}")

target = self.job_source_file(job)
ensure_path_not_tracked_config(target, f"toggle cron job {job_id!r} in")

async with self._reload_lock:
try:
before = target.read_text(encoding="utf-8")
except OSError as e:
raise JobEditError(f"Cannot read {target}: {e}") from e

changed = set_job_enabled_in_file(target, job_id, enabled)
try:
summary = await self._reload_locked()
except BaseException:
# BaseException so a cancellation rolls back too — same reason
# _reload_locked restores the gate registry on one.
if changed:
try:
target.write_text(before, encoding="utf-8")
except OSError:
logger.exception(
"Cron toggle of %s failed and %s could not be "
"restored — the file now says enabled=%s while the "
"scheduler is unchanged",
job_id, target, enabled,
)
raise

logger.info(
"Cron job %s %s via API (%s)",
job_id, "enabled" if enabled else "disabled",
"file updated" if changed else "file already said so",
)
return {
"job_id": job_id,
"enabled": enabled,
"changed": changed,
"file": str(target),
# Nested, not spread: reload()'s summary has an "enabled" of its own
# and it means the *count* of scheduled jobs. Spread, it would quietly
# replace this job's new state with a number.
"reload": summary,
}

async def _reload_from_disk(
self, gates_before: dict[str, type["CronGate"]],
) -> dict:
Expand Down Expand Up @@ -1457,6 +1555,7 @@ async def list_jobs(self) -> list[dict]:
"gates": [gate.describe() for gate in job.gates],
"next_run": next_run.isoformat() if next_run else None,
"last_session_id": last_session_id,
"toggle_refusal": self.toggle_refusal(job),
})

# Include source runners
Expand All @@ -1475,6 +1574,12 @@ async def list_jobs(self) -> list[dict]:
"enabled": True,
"next_run": next_run.isoformat() if next_run else None,
"last_session_id": None,
# A source runner exists because its sync section is configured
# and enabled; there is no per-runner flag in the cron files for
# a toggle to write.
"toggle_refusal": (
"Source runners follow the sync config, not cron YAML"
),
})

return result
Loading
Loading