"""Manual `hermes cron run` forwarding for relay-fronted delivery targets. A standalone CLI process has no live relay adapter and no standalone sender, so a manual run that targets a relay-fronted platform must forward to the running gateway (whose live relay adapter owns that delivery) rather than execute in-process and hit the native standalone fallback. """ import json from unittest.mock import patch from tools import cronjob_tools class _Resp: def __init__(self, status): self.status_code = status self.text = "" class TestRelayFrontedDeliveryPlatforms: def test_empty_when_nothing_fronted(self): with patch("gateway.relay.relay_fronted_platforms", return_value=set()): assert cronjob_tools._relay_fronted_delivery_platforms({"id": "j1"}) == set() def test_detects_fronted_delivery_platform(self): job = {"id": "j1", "deliver": "discord"} with patch("gateway.relay.relay_fronted_platforms", return_value={"discord"}), patch( "cron.scheduler._resolve_delivery_targets", return_value=[{"platform": "discord", "chat_id": "123"}], ): assert cronjob_tools._relay_fronted_delivery_platforms(job) == {"discord"} def test_ignores_non_fronted_platform(self): job = {"id": "j1", "deliver": "discord"} with patch("gateway.relay.relay_fronted_platforms", return_value={"telegram"}), patch( "cron.scheduler._resolve_delivery_targets", return_value=[{"platform": "discord", "chat_id": "123"}], ): assert cronjob_tools._relay_fronted_delivery_platforms(job) == set() class TestForwardRelayFrontedRun: def test_none_on_native_topology(self): with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value=set() ): assert cronjob_tools._forward_relay_fronted_run({"id": "j1"}) is None def test_forwards_on_success(self): with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", return_value=_Resp(200)): out = json.loads(cronjob_tools._forward_relay_fronted_run({"id": "j1"})) assert out["success"] is True assert out["forwarded_to_gateway"] is True def test_errors_when_gateway_unreachable(self): with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", side_effect=Exception("down")): out = json.loads(cronjob_tools._forward_relay_fronted_run({"id": "j1"})) assert out["success"] is False assert "relay-fronted" in out["error"] def test_errors_on_gateway_4xx(self): with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", return_value=_Resp(401)): out = json.loads(cronjob_tools._forward_relay_fronted_run({"id": "j1"})) assert out["success"] is False assert "relay-fronted" in out["error"] def test_posts_to_run_route_with_bearer(self): sent = {} def fake_post(url, headers=None, json=None, timeout=None): sent["url"] = url sent["headers"] = headers return _Resp(200) with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", side_effect=fake_post), patch( "agent.secret_scope.get_secret", return_value="secret-key-16chars" ): cronjob_tools._forward_relay_fronted_run({"id": "abc123"}) assert sent["url"].endswith("/api/jobs/abc123/run") assert sent["url"].startswith("http://127.0.0.1:") assert sent["headers"]["Authorization"] == "Bearer secret-key-16chars" def test_honors_api_server_host_env(self, monkeypatch): """API_SERVER_HOST must reach the forward URL (adapter bind parity).""" monkeypatch.setenv("API_SERVER_HOST", "10.9.8.7") sent = {} def fake_post(url, headers=None, json=None, timeout=None): sent["url"] = url return _Resp(200) with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", side_effect=fake_post): cronjob_tools._forward_relay_fronted_run({"id": "j1"}) assert sent["url"].startswith("http://10.9.8.7:") def test_wildcard_bind_dials_loopback(self, monkeypatch): """0.0.0.0 is a bind address, not a dial address — use loopback.""" monkeypatch.setenv("API_SERVER_HOST", "0.0.0.0") sent = {} def fake_post(url, headers=None, json=None, timeout=None): sent["url"] = url return _Resp(200) with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", side_effect=fake_post): cronjob_tools._forward_relay_fronted_run({"id": "j1"}) assert sent["url"].startswith("http://127.0.0.1:") def test_forwards_transient_prompt_in_body(self): """cronjob(action='run', prompt=...) context rides the forward body.""" sent = {} def fake_post(url, headers=None, json=None, timeout=None): sent["json"] = json return _Resp(200) with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", side_effect=fake_post): cronjob_tools._forward_relay_fronted_run( {"id": "j1"}, extra_prompt="focus on EU numbers" ) assert sent["json"] == {"prompt": "focus on EU numbers"} def test_empty_body_without_prompt(self): sent = {} def fake_post(url, headers=None, json=None, timeout=None): sent["json"] = json return _Resp(200) with patch.object( cronjob_tools, "_relay_fronted_delivery_platforms", return_value={"discord"} ), patch("httpx.post", side_effect=fake_post): cronjob_tools._forward_relay_fronted_run({"id": "j1"}) assert sent["json"] == {} class TestManualRunPromptConsumption: """The stamped transient prompt reaches the fire that consumes the manual occurrence, and only that fire.""" def test_run_one_job_consumes_stamped_prompt(self): from cron import scheduler captured = {} def fake_body(job, **kwargs): captured["extra_prompt"] = kwargs.get("extra_prompt") return True job = { "id": "j1", "manual_run_at": "2026-08-28T00:00:00+00:00", "manual_run_prompt": "focus on EU numbers", } with patch.object(scheduler, "_run_one_job_body", side_effect=fake_body), patch.object( scheduler, "_run_with_fire_claim_heartbeat", side_effect=lambda j, fn: fn(None) ): assert scheduler.run_one_job(job) is True assert captured["extra_prompt"] == "focus on EU numbers" def test_explicit_extra_prompt_wins_over_stamp(self): from cron import scheduler captured = {} def fake_body(job, **kwargs): captured["extra_prompt"] = kwargs.get("extra_prompt") return True job = { "id": "j1", "manual_run_at": "2026-08-28T00:00:00+00:00", "manual_run_prompt": "stale stamp", } with patch.object(scheduler, "_run_one_job_body", side_effect=fake_body), patch.object( scheduler, "_run_with_fire_claim_heartbeat", side_effect=lambda j, fn: fn(None) ): scheduler.run_one_job(job, extra_prompt="direct context") assert captured["extra_prompt"] == "direct context" def test_stamp_ignored_without_manual_run_intent(self): """A leftover prompt with no manual_run_at (defensive) is not injected.""" from cron import scheduler captured = {} def fake_body(job, **kwargs): captured["extra_prompt"] = kwargs.get("extra_prompt") return True job = {"id": "j1", "manual_run_prompt": "orphaned"} with patch.object(scheduler, "_run_one_job_body", side_effect=fake_body), patch.object( scheduler, "_run_with_fire_claim_heartbeat", side_effect=lambda j, fn: fn(None) ): scheduler.run_one_job(job) assert captured["extra_prompt"] is None class TestTriggerJobPromptStamp: def test_trigger_stamps_and_mark_run_clears(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) import cron.jobs as jobs_mod import importlib importlib.reload(jobs_mod) job = jobs_mod.create_job( prompt="daily report", schedule="0 9 * * *", name="stamp-test" ) triggered = jobs_mod.trigger_job(job["id"], extra_prompt="just EU today") assert triggered["manual_run_prompt"] == "just EU today" assert triggered["manual_run_at"] == triggered["next_run_at"] jobs_mod.mark_job_run(job["id"], success=True) after = jobs_mod.get_job(job["id"]) assert "manual_run_prompt" not in after assert "manual_run_at" not in after def test_retrigger_without_prompt_clears_stale_stamp(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) import cron.jobs as jobs_mod import importlib importlib.reload(jobs_mod) job = jobs_mod.create_job( prompt="daily report", schedule="0 9 * * *", name="stamp-test-2" ) jobs_mod.trigger_job(job["id"], extra_prompt="first context") retriggered = jobs_mod.trigger_job(job["id"]) assert retriggered.get("manual_run_prompt") is None