fix(#4768): degrade GET /api/crons gracefully when the cron module is absent

In split-container / minimal Docker deployments the WebUI image may not ship the
agent's cron package on its import path, so the Tasks tab's GET /api/crons did
'from cron.jobs import list_jobs' and 500'd with ModuleNotFoundError — breaking the
whole tab. Now the import is guarded and returns {jobs: [], cron_unavailable: true}
instead, so the Tasks tab loads (empty) instead of erroring. Happy path unchanged.
Co-authored-by: reporter
This commit is contained in:
nesquena-hermes 2026-06-24 08:42:50 +00:00
parent d2a64d7fb9
commit 34694a1f02
2 changed files with 56 additions and 1 deletions

View file

@ -10188,7 +10188,13 @@ def handle_get(handler, parsed) -> bool:
# os.environ (process-global) at call time. Wrap in cron_profile_context
# so the TLS-active profile's jobs.json is read, not the process default.
if parsed.path == "/api/crons":
from cron.jobs import list_jobs
# #4768: in split-container / minimal Docker deployments the WebUI image may
# not ship the agent's `cron` package on its import path. Degrade gracefully
# (empty list + cron_unavailable flag) instead of 500ing the whole Task tab.
try:
from cron.jobs import list_jobs
except ModuleNotFoundError:
return j(handler, {"jobs": [], "cron_unavailable": True})
from api.profiles import cron_profile_context
with cron_profile_context():

View file

@ -0,0 +1,49 @@
"""Regression test for #4768.
In split-container / minimal Docker deployments the WebUI image may not ship the
agent's ``cron`` package on its import path. Before the fix, ``GET /api/crons``
(the Tasks tab's initial load) did ``from cron.jobs import list_jobs`` with no
guard, so a ``ModuleNotFoundError`` bubbled up as a 500 and broke the whole tab.
The fix degrades gracefully: an empty job list with a ``cron_unavailable`` flag.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).resolve().parent.parent
ROUTES = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
def _api_crons_branch() -> str:
"""Return the source of the `if parsed.path == "/api/crons":` GET branch."""
marker = ROUTES.index('if parsed.path == "/api/crons":')
# Stop at the next sibling branch so we only inspect this handler.
nxt = ROUTES.index('if parsed.path == "/api/crons/output":', marker)
return ROUTES[marker:nxt]
def test_api_crons_guards_missing_cron_module():
"""The /api/crons GET branch must wrap the cron.jobs import in a guard that
returns a graceful payload instead of letting ModuleNotFoundError 500."""
branch = _api_crons_branch()
assert "from cron.jobs import list_jobs" in branch
assert "except ModuleNotFoundError" in branch, (
"GET /api/crons must catch ModuleNotFoundError so the Tasks tab does not "
"500 when the cron package is absent (#4768)."
)
# The except path returns an empty list + an unavailable flag (not a re-raise).
assert "cron_unavailable" in branch
# The try guards the import specifically (not some unrelated block).
try_idx = branch.index("try:")
import_idx = branch.index("from cron.jobs import list_jobs")
except_idx = branch.index("except ModuleNotFoundError")
assert try_idx < import_idx < except_idx
def test_api_crons_still_lists_jobs_on_happy_path():
"""The guard must not remove the normal behavior: when cron imports fine, the
branch still calls list_jobs(include_disabled=True) under cron_profile_context."""
branch = _api_crons_branch()
assert "list_jobs(include_disabled=True)" in branch
assert "cron_profile_context()" in branch
assert "_cron_jobs_for_api(" in branch