From 34694a1f029b998adf23a2f522bca5dbcf39a54d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 08:42:50 +0000 Subject: [PATCH] fix(#4768): degrade GET /api/crons gracefully when the cron module is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/routes.py | 8 +++- tests/test_issue4768_cron_module_missing.py | 49 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue4768_cron_module_missing.py diff --git a/api/routes.py b/api/routes.py index 22437be3..d6ab0ff7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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(): diff --git a/tests/test_issue4768_cron_module_missing.py b/tests/test_issue4768_cron_module_missing.py new file mode 100644 index 00000000..b3be98a4 --- /dev/null +++ b/tests/test_issue4768_cron_module_missing.py @@ -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