From 50f9ad70fc841c4218b63c79cd29a2337b13d941 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:23:54 -0700 Subject: [PATCH] fix(dashboard): populate cron delivery dropdown from configured platforms (#40218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * fix(dashboard): populate cron delivery dropdown from configured platforms The dashboard cron-create/edit dropdown hardcoded five delivery options (local, telegram, discord, slack, email), so users on Matrix — or any other backend-supported platform — had no way to pick their channel even though the cron scheduler delivers to all of them. It also offered Telegram/Discord/etc. to users who never set those up. - cron/scheduler.py: add cron_delivery_targets() — the single source of truth. Intersects gateway-configured platforms with cron-deliverable ones and reports whether each platform's home channel is set. - web_server.py: GET /api/cron/delivery-targets exposes that list (+ the implicit local option) to the dashboard. - CronPage.tsx: both modals render options from the endpoint. Configured platforms missing a home channel still appear, annotated "set a home channel first" (option B), so the user knows what to fix. Edit modal preserves a job's current target even if it's no longer configured. Local-only state shows a "configure a platform under Channels" hint. Validation: scheduler + endpoint E2E'd with a Matrix gateway (home set and unset); 5 new tests; tests/cron + tests/hermes_cli/test_web_server green (366 passed). --- cron/scheduler.py | 41 +++++++++++ hermes_cli/web_server.py | 28 ++++++++ tests/cron/test_scheduler.py | 72 +++++++++++++++++++ tests/hermes_cli/test_web_server.py | 28 ++++++++ web/src/i18n/en.ts | 3 + web/src/i18n/types.ts | 2 + web/src/lib/api.ts | 9 +++ web/src/pages/CronPage.tsx | 107 ++++++++++++++++++++-------- 8 files changed, 259 insertions(+), 31 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index d0240e9af6..38b7b95ab7 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -448,6 +448,47 @@ def _iter_home_target_platforms(): pass +def cron_delivery_targets() -> list[dict]: + """Return the platforms a cron job can auto-deliver to. + + Single source of truth for any UI (dashboard dropdown, etc.) that lets a + user pick a cron delivery target. A platform is included when it is a valid + cron delivery platform AND its gateway is configured (enabled + credentials + present). Each entry reports whether the platform's home target (the + room/channel cron posts to) is set — a platform can be configured for + interactive use but still lack the home target an unattended cron job needs. + + Returns a list of dicts: ``{"id", "name", "home_target_set", "home_env_var"}`` + ordered by the gateway's canonical platform order. Callers should always + prepend the implicit ``local`` option themselves — it needs no config. + """ + targets: list[dict] = [] + try: + from gateway.config import load_gateway_config + + gateway_config = load_gateway_config() + connected = {p.value for p in gateway_config.get_connected_platforms()} + except Exception: + logger.debug("cron_delivery_targets: gateway config unavailable", exc_info=True) + connected = set() + + for name in _iter_home_target_platforms(): + if name not in connected: + continue + if not _is_known_delivery_platform(name): + continue + env_var = _resolve_home_env_var(name) + targets.append( + { + "id": name, + "name": name.replace("_", " ").title(), + "home_target_set": bool(_get_home_target_chat_id(name)), + "home_env_var": env_var or None, + } + ) + return targets + + def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]: """Resolve one concrete auto-delivery target for a cron job.""" diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c45a21961b..e5f93085f9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5554,6 +5554,34 @@ async def create_cron_job(body: CronJobCreate, profile: str = "default"): raise HTTPException(status_code=400, detail=str(e)) +@app.get("/api/cron/delivery-targets") +async def get_cron_delivery_targets(): + """Delivery targets the cron dropdown should offer. + + Always includes the implicit ``local`` option. Beyond that, the list is + derived dynamically from the configured gateway platforms via + ``cron.scheduler.cron_delivery_targets()`` — no hardcoded platform list. A + configured platform that hasn't set its cron home channel is still returned + with ``home_target_set: false`` so the UI can surface it as "configure a + home channel first" rather than hiding it. + """ + targets = [ + { + "id": "local", + "name": "Local (save only)", + "home_target_set": True, + "home_env_var": None, + } + ] + try: + from cron.scheduler import cron_delivery_targets + + targets.extend(cron_delivery_targets()) + except Exception: + _log.exception("GET /api/cron/delivery-targets failed") + return {"targets": targets} + + @app.put("/api/cron/jobs/{job_id}") async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 78f3ab4224..432beb764f 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -2627,3 +2627,75 @@ class TestSendMediaTimeoutCancelsFuture: # 2. Second file still got dispatched — one timeout doesn't abort the batch adapter.send_video.assert_called_once() assert adapter.send_video.call_args[1]["video_path"] == str(fast.resolve()) + + +class TestCronDeliveryTargets: + """``cron_delivery_targets`` powers the dashboard delivery dropdown. + + It must list every configured + cron-deliverable platform (no hardcoded + set), flag whether each has its home channel set, and never include + platforms whose gateway isn't configured. + """ + + def _patch_connected(self, monkeypatch, names): + import gateway.config as gateway_config + + class _Platform: + def __init__(self, value): + self.value = value + + class _GatewayConfig: + def get_connected_platforms(self_inner): + return [_Platform(n) for n in names] + + monkeypatch.setattr( + gateway_config, "load_gateway_config", lambda: _GatewayConfig() + ) + + def test_lists_configured_platforms_flagging_missing_home_channel(self, monkeypatch): + from cron.scheduler import cron_delivery_targets + + self._patch_connected(monkeypatch, ["matrix", "telegram"]) + monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False) + monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False) + + targets = {t["id"]: t for t in cron_delivery_targets()} + + assert set(targets) == {"matrix", "telegram"} + # Configured but no home channel → surfaced, flagged for the UI. + assert targets["matrix"]["home_target_set"] is False + assert targets["matrix"]["home_env_var"] == "MATRIX_HOME_ROOM" + assert targets["telegram"]["home_target_set"] is False + + def test_home_channel_set_marks_target_ready(self, monkeypatch): + from cron.scheduler import cron_delivery_targets + + self._patch_connected(monkeypatch, ["matrix"]) + monkeypatch.setenv("MATRIX_HOME_ROOM", "!room:matrix.org") + + targets = {t["id"]: t for t in cron_delivery_targets()} + + assert targets["matrix"]["home_target_set"] is True + + def test_unconfigured_platforms_excluded(self, monkeypatch): + from cron.scheduler import cron_delivery_targets + + # Only telegram is connected; matrix env var set but gateway not configured. + self._patch_connected(monkeypatch, ["telegram"]) + monkeypatch.setenv("MATRIX_HOME_ROOM", "!room:matrix.org") + + ids = {t["id"] for t in cron_delivery_targets()} + + assert ids == {"telegram"} + assert "matrix" not in ids + + def test_no_gateway_config_returns_empty(self, monkeypatch): + import gateway.config as gateway_config + from cron.scheduler import cron_delivery_targets + + def _boom(): + raise RuntimeError("no gateway config") + + monkeypatch.setattr(gateway_config, "load_gateway_config", _boom) + + assert cron_delivery_targets() == [] diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e690b43d4d..f6ce7ed7bc 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -775,6 +775,34 @@ class TestWebServerEndpoints: assert resp.json()["gateway_state"] == "startup_failed" assert resp.json()["gateway_platforms"] == {} + def test_cron_delivery_targets_lists_configured_platforms(self, monkeypatch): + """The cron dropdown endpoint returns Local + configured platforms dynamically.""" + import gateway.config as gateway_config + + class _Platform: + def __init__(self, value): + self.value = value + + class _GatewayConfig: + def get_connected_platforms(self): + return [_Platform("matrix")] + + monkeypatch.setattr( + gateway_config, "load_gateway_config", lambda: _GatewayConfig() + ) + monkeypatch.setenv("MATRIX_HOME_ROOM", "!room:matrix.org") + + resp = self.client.get("/api/cron/delivery-targets") + + assert resp.status_code == 200 + targets = {t["id"]: t for t in resp.json()["targets"]} + # Local is always offered; matrix appears because its gateway is configured. + assert "local" in targets + assert "matrix" in targets + assert targets["matrix"]["home_target_set"] is True + # No hardcoded telegram/discord/slack/email when they aren't configured. + assert "telegram" not in targets + def test_get_config_schema(self): resp = self.client.get("/api/config/schema") assert resp.status_code == 200 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 315e552229..9cde64dec6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -279,6 +279,9 @@ export const en: Translations = { discord: "Discord", slack: "Slack", email: "Email", + needsHomeChannel: "set a home channel first", + noneConfigured: + "No messaging platforms configured. Set one up under Channels to deliver reports.", }, }, diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 26b14ff073..6d745ba763 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -297,6 +297,8 @@ export interface Translations { discord: string; slack: string; email: string; + needsHomeChannel?: string; + noneConfigured?: string; }; }; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2ee4c83354..6d3335e7c9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -390,6 +390,8 @@ export const api = { // Cron jobs getCronJobs: (profile = "all") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`), + getCronDeliveryTargets: () => + fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"), createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, { method: "POST", @@ -1513,6 +1515,13 @@ export interface CronJob { last_error?: string | null; } +export interface CronDeliveryTarget { + id: string; + name: string; + home_target_set: boolean; + home_env_var: string | null; +} + export interface SkillInfo { name: string; description: string; diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 47c6ee8437..55317b3f99 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; -import type { CronJob, ProfileInfo } from "@/lib/api"; +import type { CronJob, CronDeliveryTarget, ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { DEFAULT_SCHEDULE_STATE, @@ -157,6 +157,9 @@ export default function CronPage() { onClose: closeCreateModal, }); const [deliver, setDeliver] = useState("local"); + const [deliveryTargets, setDeliveryTargets] = useState([ + { id: "local", name: "Local", home_target_set: true, home_env_var: null }, + ]); const [creating, setCreating] = useState(false); const createProfile = selectedProfile === "all" ? "default" : selectedProfile; @@ -198,12 +201,76 @@ export default function CronPage() { .catch(() => setProfiles([])); }, []); + useEffect(() => { + api + .getCronDeliveryTargets() + .then((res) => setDeliveryTargets(res.targets)) + .catch(() => + // Fall back to local-only so the modal still works if the endpoint fails. + setDeliveryTargets([ + { id: "local", name: "Local", home_target_set: true, home_env_var: null }, + ]), + ); + }, []); + useEffect(() => { loadJobs(); }, [loadJobs]); const scheduleString = buildScheduleString(scheduleState); + // Label for a delivery option. Configured platforms missing their cron home + // channel are still offered (option B), annotated so the user knows what to + // fix rather than wondering why delivery silently no-ops. + const deliverLabel = useCallback( + (target: CronDeliveryTarget): string => { + const base = target.id === "local" ? t.cron.delivery.local : target.name; + if (target.id !== "local" && !target.home_target_set) { + const hint = t.cron.delivery.needsHomeChannel ?? "set a home channel first"; + return `${base} — ${hint}`; + } + return base; + }, + [t.cron.delivery], + ); + + const renderDeliverOptions = useCallback( + () => + deliveryTargets.map((target) => ( + + {deliverLabel(target)} + + )), + [deliveryTargets, deliverLabel], + ); + + // The edit modal must always show the job's current target, even if that + // platform is no longer configured (e.g. job created via CLI, or the + // gateway was later removed) — otherwise the value would silently vanish + // from the dropdown and saving would drop it. + const renderEditDeliverOptions = useCallback( + (current: string) => { + const known = new Set(deliveryTargets.map((target) => target.id)); + const options = deliveryTargets.map((target) => ( + + {deliverLabel(target)} + + )); + if (current && !known.has(current)) { + options.push( + + {current} + , + ); + } + return options; + }, + [deliveryTargets, deliverLabel], + ); + + const onlyLocalAvailable = + deliveryTargets.filter((target) => target.id !== "local").length === 0; + const handleCreate = async () => { if (!prompt.trim() || !scheduleString) { showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error"); @@ -447,22 +514,14 @@ export default function CronPage() { value={deliver} onValueChange={(v) => setDeliver(v)} > - - {t.cron.delivery.local} - - - {t.cron.delivery.telegram} - - - {t.cron.delivery.discord} - - - {t.cron.delivery.slack} - - - {t.cron.delivery.email} - + {renderDeliverOptions()} + {onlyLocalAvailable && ( +

+ {t.cron.delivery.noneConfigured ?? + "No messaging platforms configured. Set one up under Channels to deliver reports."} +

+ )}
@@ -552,21 +611,7 @@ export default function CronPage() { value={editDeliver} onValueChange={(v) => setEditDeliver(v)} > - - {t.cron.delivery.local} - - - {t.cron.delivery.telegram} - - - {t.cron.delivery.discord} - - - {t.cron.delivery.slack} - - - {t.cron.delivery.email} - + {renderEditDeliverOptions(editDeliver)}