Files
Juan DenisandClaude Opus 5 b83cb5ca82 feat(cron): rebuild /cron on the Domains list pattern
The page kept a shape the rest of the app moved off: a four-card KPI
strip, its own in-body search input, a hand-rolled table, four icon
buttons per row, and an edit modal on row click.

It now matches /domains: the segmented filter carries the counts (no
KPI strip), SearchField lives in the top bar, the list is the shared
DataTable, and a row opens the DS Drawer. The top bar keeps its title
— /cron has no tab group, and the reference mock shows a titled bar
there; the body was what was stale.

Last run / Next run / Status columns need data list_jobs() never
returned: it skipped the schedule_human / next_run / run-tracking joins
that jobs_for_application() has done since plan 34, so the admin list
could only ever show enabled-vs-disabled. Apply the same enrichment,
with tests covering the schedule join, the last-run join, and the
no-history case.

The drawer's run history uses GET /cron/jobs/<id>/runs, which already
existed and only ScheduledTasksCard consumed. It's admin-only and
empty until a job carries the tracking shim, so both cases are stated
in the drawer (with an enable-tracking action) rather than rendering a
blank table.

Also fixes styles that never applied: .run-output-*, .spinner-inline
and the modal field overrides were nested under .cron-page, but Radix
Dialog and Sheet portal to <body>. Hoisted to top level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:44:40 -04:00

259 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Cron run tracking: the shim, ingest endpoint, and edge-triggered alerts.
Plan 21 Phase 4 (#13–#17). The crontab-rewrite half is Linux-only; these cover
the OS-agnostic parts — shim exit-code/output round-trip, run recording +
status-transition logic, ingest auth, and the failure/recovery notification.
"""
import sys
import pytest
from app.services.cron_service import CronService
from app.services.cron_run_service import CronRunService
from app.models.cron_run import OUTPUT_TAIL_LIMIT
@pytest.fixture
def cron_store(tmp_path, monkeypatch):
import app.services.cron_service as mod
monkeypatch.setattr(mod, 'JOBS_FILE', str(tmp_path / 'cron_jobs.json'))
monkeypatch.setattr(CronService, 'is_linux', classmethod(lambda cls: False))
return mod
# --------------------------------------------------------------------------- #
# The shim (app/cron_runner.py)
# --------------------------------------------------------------------------- #
def test_shim_passes_through_exit_code(monkeypatch):
import app.cron_runner as runner
captured = {}
monkeypatch.setattr(runner, '_report',
lambda *a, **k: captured.update(args=a))
code = runner.main(['job_1', '--', sys.executable, '-c', 'import sys; sys.exit(3)'])
assert code == 3
# _report(job_id, started, finished, exit_code, tail)
assert captured['args'][0] == 'job_1'
assert captured['args'][3] == 3
def test_shim_captures_output_tail(monkeypatch, capsys):
import app.cron_runner as runner
captured = {}
monkeypatch.setattr(runner, '_report',
lambda *a, **k: captured.update(tail=a[4]))
code = runner.main(['j', '--', sys.executable, '-c', 'print("hello-from-job")'])
assert code == 0
assert 'hello-from-job' in captured['tail']
# Output is passed through to real stdout too (cron MAILTO stays intact).
assert 'hello-from-job' in capsys.readouterr().out
def test_shim_usage_error_without_separator(monkeypatch):
import app.cron_runner as runner
assert runner.main(['job_1', 'echo', 'hi']) == 2
# --------------------------------------------------------------------------- #
# record_run + status-transition logic
# --------------------------------------------------------------------------- #
def test_record_run_first_failure_is_a_transition(app):
_, transition = CronRunService.record_run('jobA', exit_code=1)
assert transition == 'failure'
def test_record_run_repeat_failure_not_a_transition(app):
CronRunService.record_run('jobB', exit_code=1)
_, transition = CronRunService.record_run('jobB', exit_code=1)
assert transition is None
def test_record_run_recovery_transition(app):
CronRunService.record_run('jobC', exit_code=1)
_, transition = CronRunService.record_run('jobC', exit_code=0)
assert transition == 'recovery'
def test_record_run_first_success_no_transition(app):
_, transition = CronRunService.record_run('jobD', exit_code=0)
assert transition is None
def test_record_run_caps_output_tail(app):
big = 'x' * (OUTPUT_TAIL_LIMIT + 5000)
run, _ = CronRunService.record_run('jobE', exit_code=0, output_tail=big)
assert len(run.output_tail) == OUTPUT_TAIL_LIMIT
def test_stats_success_rate(app):
for code in (0, 0, 1, 0):
CronRunService.record_run('jobF', exit_code=code)
stats = CronRunService.stats('jobF')
assert stats['total'] == 4
assert stats['success'] == 3
assert stats['success_rate'] == 0.75
assert stats['last_status'] == 'success'
# --------------------------------------------------------------------------- #
# ingest endpoint (localhost + admin token)
# --------------------------------------------------------------------------- #
def test_ingest_records_run(client, auth_headers):
resp = client.post('/api/v1/cron/runs/ingest', headers=auth_headers, json={
'job_id': 'job_ingest', 'exit_code': 0, 'output_tail': 'ok',
})
assert resp.status_code == 201
assert resp.get_json()['status'] == 'success'
def test_ingest_rejects_non_localhost(client, auth_headers):
resp = client.post('/api/v1/cron/runs/ingest', headers=auth_headers,
environ_base={'REMOTE_ADDR': '10.1.2.3'},
json={'job_id': 'x', 'exit_code': 0})
assert resp.status_code == 403
def test_ingest_requires_job_id(client, auth_headers):
resp = client.post('/api/v1/cron/runs/ingest', headers=auth_headers,
json={'exit_code': 0})
assert resp.status_code == 400
def test_ingest_requires_auth(client):
resp = client.post('/api/v1/cron/runs/ingest', json={'job_id': 'x', 'exit_code': 0})
assert resp.status_code in (401, 403, 422)
# --------------------------------------------------------------------------- #
# edge-triggered notification via ingest
# --------------------------------------------------------------------------- #
def test_ingest_notifies_on_failure_transition(client, auth_headers, cron_store, monkeypatch):
sent = []
from app.plugins_sdk import notify
monkeypatch.setattr(notify, 'send',
lambda event, **kw: sent.append((event, kw.get('data'))))
jid = CronService.add_job('0 0 * * *', '/bin/false', name='Failing Job')['job_id']
# First failure -> one alert.
client.post('/api/v1/cron/runs/ingest', headers=auth_headers,
json={'job_id': jid, 'exit_code': 1})
# Second failure -> NO new alert (edge-triggered, not per-occurrence).
client.post('/api/v1/cron/runs/ingest', headers=auth_headers,
json={'job_id': jid, 'exit_code': 1})
failed = [s for s in sent if s[0] == 'cron.job_failed']
assert len(failed) == 1
assert failed[0][1]['name'] == 'Failing Job'
# Recovery -> one recovery alert.
client.post('/api/v1/cron/runs/ingest', headers=auth_headers,
json={'job_id': jid, 'exit_code': 0})
assert any(s[0] == 'cron.job_recovered' for s in sent)
def test_ingest_respects_alert_opt_out(client, auth_headers, cron_store, monkeypatch):
sent = []
from app.plugins_sdk import notify
monkeypatch.setattr(notify, 'send', lambda event, **kw: sent.append(event))
jid = CronService.add_job('0 0 * * *', '/bin/false', name='Quiet Job')['job_id']
meta = CronService._load_jobs_metadata()
meta['jobs'][jid]['alert_on_failure'] = False
CronService._save_jobs_metadata(meta)
client.post('/api/v1/cron/runs/ingest', headers=auth_headers,
json={'job_id': jid, 'exit_code': 1})
assert 'cron.job_failed' not in sent
# --------------------------------------------------------------------------- #
# tracking toggle + wrapper construction
# --------------------------------------------------------------------------- #
def test_wrapper_command_shape():
wrapped = CronService._crontab_command('/usr/bin/task.sh', True, 'job_9')
assert 'serverkit_cron_run.py' in wrapped
assert 'job_9 -- /usr/bin/task.sh' in wrapped
# Untracked is byte-identical to the bare command.
assert CronService._crontab_command('/usr/bin/task.sh', False, 'job_9') == '/usr/bin/task.sh'
def test_set_tracking_updates_metadata(cron_store):
jid = CronService.add_job('0 0 * * *', '/usr/bin/task.sh', name='T')['job_id']
assert CronService.set_tracking(jid, True)['tracked'] is True
assert CronService.get_job(jid)['tracked'] is True
assert CronService.set_tracking(jid, False)['tracked'] is False
assert CronService.get_job(jid)['tracked'] is False
# --------------------------------------------------------------------------- #
# "Run now" records a CronRun too (#18)
# --------------------------------------------------------------------------- #
def test_run_now_records_history(app, cron_store, monkeypatch):
import subprocess
jid = CronService.add_job('0 0 * * *', '/bin/true', name='M')['job_id']
class _Result:
returncode = 0
stdout = 'done'
stderr = ''
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: _Result())
res = CronService.run_job_now(jid)
assert res['success']
runs = CronRunService.recent_runs(jid)
assert len(runs) == 1
assert runs[0]['status'] == 'success'
# --------------------------------------------------------------------------- #
# list_jobs() enrichment — the admin /cron table renders Last run / Next run /
# Status straight off these keys, so the list has to carry the same joins
# jobs_for_application() does. Before this, only the for-app surface had them.
# --------------------------------------------------------------------------- #
def test_list_jobs_carries_schedule_and_next_run(app, cron_store):
CronService.add_job('0 0 * * *', '/usr/bin/task.sh', name='Nightly')
job = CronService.list_jobs()['jobs'][0]
assert job['schedule_human']
assert job['next_run'] # croniter resolves a concrete next fire
assert job['tracked'] is False
def test_list_jobs_carries_last_run_join(app, cron_store, monkeypatch):
import subprocess
jid = CronService.add_job('0 0 * * *', '/bin/false', name='Flaky')['job_id']
class _Result:
returncode = 1
stdout = ''
stderr = 'boom'
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: _Result())
CronService.run_job_now(jid)
job = next(j for j in CronService.list_jobs()['jobs'] if str(j['id']) == str(jid))
assert job['last_status'] == 'failure'
assert job['last_run']
assert job['success_rate'] == 0.0
def test_list_jobs_without_history_has_null_run_fields(app, cron_store):
CronService.add_job('*/5 * * * *', '/usr/bin/task.sh', name='Fresh')
job = CronService.list_jobs()['jobs'][0]
assert job['last_run'] is None
assert job['last_status'] is None