feat: add cron job profile support
This commit is contained in:
committed by
daimon-nous[bot]
parent
47bc8e080d
commit
bb9ecb2178
340
tests/cron/test_cron_profile.py
Normal file
340
tests/cron/test_cron_profile.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""Tests for per-job profile support in cron jobs.
|
||||
|
||||
Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime
|
||||
HERMES_HOME scoping, and tick() serialization for profile jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_cron_profile_home(tmp_path, monkeypatch):
|
||||
"""Create an isolated Hermes root with a named profile and temp cron store."""
|
||||
root = tmp_path / "hermes-root"
|
||||
profile_home = root / "profiles" / "support"
|
||||
profile_home.mkdir(parents=True)
|
||||
(root / "cron").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron")
|
||||
monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json")
|
||||
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output")
|
||||
|
||||
return root, profile_home
|
||||
|
||||
|
||||
class TestNormalizeProfile:
|
||||
def test_none_and_empty_return_none(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
assert _normalize_profile(None) is None
|
||||
assert _normalize_profile("") is None
|
||||
assert _normalize_profile(" ") is None
|
||||
|
||||
def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
assert _normalize_profile("Default") == "default"
|
||||
|
||||
def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
assert _normalize_profile("Support") == "support"
|
||||
|
||||
def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_profile("invalid!")
|
||||
|
||||
def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home):
|
||||
from cron.jobs import _normalize_profile
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_normalize_profile("missing")
|
||||
|
||||
|
||||
class TestCreateAndUpdateJobProfile:
|
||||
def test_create_stores_profile_id(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="hello", schedule="every 1h", profile="Support")
|
||||
stored = get_job(job["id"])
|
||||
|
||||
assert stored is not None
|
||||
assert stored["profile"] == "support"
|
||||
|
||||
def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="hello", schedule="every 1h")
|
||||
stored = get_job(job["id"])
|
||||
|
||||
assert stored is not None
|
||||
assert stored.get("profile") is None
|
||||
|
||||
def test_create_accepts_explicit_default(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="hello", schedule="every 1h", profile="default")
|
||||
stored = get_job(job["id"])
|
||||
|
||||
assert stored is not None
|
||||
assert stored["profile"] == "default"
|
||||
|
||||
def test_update_sets_and_clears_profile(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, get_job, update_job
|
||||
|
||||
job = create_job(prompt="x", schedule="every 1h")
|
||||
update_job(job["id"], {"profile": "Support"})
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["profile"] == "support"
|
||||
|
||||
update_job(job["id"], {"profile": ""})
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["profile"] is None
|
||||
|
||||
def test_update_rejects_missing_profile(self, isolated_cron_profile_home):
|
||||
from cron.jobs import create_job, update_job
|
||||
|
||||
job = create_job(prompt="x", schedule="every 1h")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
update_job(job["id"], {"profile": "missing"})
|
||||
|
||||
|
||||
class TestCronjobToolProfile:
|
||||
def test_create_and_list_with_profile(self, isolated_cron_profile_home):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
prompt="hi",
|
||||
schedule="every 1h",
|
||||
profile="Support",
|
||||
)
|
||||
)
|
||||
assert created["success"] is True
|
||||
assert created["job"]["profile"] == "support"
|
||||
|
||||
listing = json.loads(cronjob(action="list"))
|
||||
assert listing["jobs"][0]["profile"] == "support"
|
||||
|
||||
def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
prompt="hi",
|
||||
schedule="every 1h",
|
||||
profile="Support",
|
||||
)
|
||||
)
|
||||
updated = json.loads(
|
||||
cronjob(action="update", job_id=created["job_id"], profile="")
|
||||
)
|
||||
|
||||
assert updated["success"] is True
|
||||
assert "profile" not in updated["job"]
|
||||
|
||||
def test_schema_advertises_profile(self):
|
||||
from tools.cronjob_tools import CRONJOB_SCHEMA
|
||||
|
||||
assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"]
|
||||
desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"]
|
||||
assert "hermes profile" in desc.lower()
|
||||
|
||||
|
||||
class TestRunJobProfileContext:
|
||||
@staticmethod
|
||||
def _install_agent_stubs(monkeypatch, observed: dict):
|
||||
import sys
|
||||
import cron.scheduler as sched
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, **kwargs):
|
||||
observed["hermes_home_during_init"] = os.environ.get("HERMES_HOME")
|
||||
observed["scheduler_home_during_init"] = str(sched._get_hermes_home())
|
||||
observed["skip_context_files"] = kwargs.get("skip_context_files")
|
||||
|
||||
def run_conversation(self, *_a, **_kw):
|
||||
observed["hermes_home_during_run"] = os.environ.get("HERMES_HOME")
|
||||
observed["scheduler_home_during_run"] = str(sched._get_hermes_home())
|
||||
return {"final_response": "done", "messages": []}
|
||||
|
||||
def get_activity_summary(self):
|
||||
return {"seconds_since_activity": 0.0}
|
||||
|
||||
def close(self):
|
||||
observed["closed"] = True
|
||||
|
||||
fake_mod = type(sys)("run_agent")
|
||||
fake_mod.AIAgent = FakeAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_mod)
|
||||
|
||||
from hermes_cli import runtime_provider as runtime_provider
|
||||
|
||||
monkeypatch.setattr(
|
||||
runtime_provider,
|
||||
"resolve_runtime_provider",
|
||||
lambda **_kw: {
|
||||
"provider": "test",
|
||||
"api_key": "test-key",
|
||||
"base_url": "http://test.local",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi")
|
||||
monkeypatch.setattr(sched, "_resolve_origin", lambda job: None)
|
||||
monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None)
|
||||
monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None)
|
||||
monkeypatch.setattr(sched, "_hermes_home", None)
|
||||
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")
|
||||
|
||||
import dotenv
|
||||
|
||||
def fake_load_dotenv(path, *_a, **_kw):
|
||||
observed.setdefault("dotenv_paths", []).append(str(path))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)
|
||||
|
||||
def test_run_job_sets_and_restores_profile_home(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
self._install_agent_stubs(monkeypatch, observed)
|
||||
|
||||
job = {
|
||||
"id": "abc",
|
||||
"name": "profile-job",
|
||||
"profile": "support",
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
success, _output, response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, f"run_job failed: error={error!r} response={response!r}"
|
||||
assert observed["dotenv_paths"] == [str(profile_home / ".env")]
|
||||
assert observed["hermes_home_during_init"] == str(profile_home.resolve())
|
||||
assert observed["hermes_home_during_run"] == str(profile_home.resolve())
|
||||
assert observed["scheduler_home_during_init"] == str(profile_home.resolve())
|
||||
assert observed["scheduler_home_during_run"] == str(profile_home.resolve())
|
||||
assert observed["skip_context_files"] is True
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
assert sched._get_hermes_home() == root
|
||||
|
||||
def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, profile_home = isolated_cron_profile_home
|
||||
scripts_dir = profile_home / "scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "print_home.py").write_text(
|
||||
"import os\nprint(os.environ.get('HERMES_HOME', ''))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(sched, "_hermes_home", None)
|
||||
|
||||
job = {
|
||||
"id": "script1",
|
||||
"name": "profile-script",
|
||||
"profile": "support",
|
||||
"script": "print_home.py",
|
||||
"no_agent": True,
|
||||
}
|
||||
|
||||
success, _doc, response, error = sched.run_job(job)
|
||||
|
||||
assert success is True, error
|
||||
assert response.strip() == str(profile_home.resolve())
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
assert sched._get_hermes_home() == root
|
||||
|
||||
def test_run_job_without_profile_leaves_hermes_home_untouched(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, _profile_home = isolated_cron_profile_home
|
||||
observed: dict = {}
|
||||
self._install_agent_stubs(monkeypatch, observed)
|
||||
|
||||
job = {
|
||||
"id": "noprof",
|
||||
"name": "no-profile-job",
|
||||
"profile": None,
|
||||
"schedule_display": "manual",
|
||||
}
|
||||
|
||||
success, *_ = sched.run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert observed["hermes_home_during_init"] == str(root)
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
|
||||
def test_run_job_rejects_missing_runtime_profile(
|
||||
self, isolated_cron_profile_home, monkeypatch
|
||||
):
|
||||
import cron.scheduler as sched
|
||||
|
||||
root, _profile_home = isolated_cron_profile_home
|
||||
monkeypatch.setattr(sched, "_hermes_home", None)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
sched.run_job(
|
||||
{
|
||||
"id": "missing-profile",
|
||||
"name": "missing-profile-job",
|
||||
"profile": "missing",
|
||||
}
|
||||
)
|
||||
|
||||
assert os.environ["HERMES_HOME"] == str(root)
|
||||
|
||||
|
||||
class TestTickProfilePartition:
|
||||
def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch):
|
||||
import threading
|
||||
import cron.scheduler as sched
|
||||
|
||||
profile_job = {"id": "a", "name": "A", "profile": "default"}
|
||||
parallel_job = {"id": "b", "name": "B", "profile": None}
|
||||
|
||||
monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_job, parallel_job])
|
||||
monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None)
|
||||
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def fake_run_job(job):
|
||||
calls.append((job["id"], threading.current_thread().name))
|
||||
return True, "output", "response", None
|
||||
|
||||
monkeypatch.setattr(sched, "run_job", fake_run_job)
|
||||
monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None)
|
||||
monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None)
|
||||
monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None)
|
||||
|
||||
n = sched.tick(verbose=False)
|
||||
|
||||
assert n == 2
|
||||
ids = [job_id for job_id, _thread_name in calls]
|
||||
assert ids.index("a") < ids.index("b")
|
||||
main_thread_name = threading.current_thread().name
|
||||
profile_thread_name = next(thread for job_id, thread in calls if job_id == "a")
|
||||
assert profile_thread_name == main_thread_name
|
||||
@@ -55,6 +55,7 @@ class TestCronCommandLifecycle:
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=["maps", "blogwatcher"],
|
||||
profile="default",
|
||||
clear_skills=False,
|
||||
)
|
||||
)
|
||||
@@ -63,6 +64,7 @@ class TestCronCommandLifecycle:
|
||||
assert updated["name"] == "Edited Job"
|
||||
assert updated["prompt"] == "Revised prompt"
|
||||
assert updated["schedule_display"] == "every 120m"
|
||||
assert updated["profile"] == "default"
|
||||
|
||||
cron_command(
|
||||
Namespace(
|
||||
@@ -75,12 +77,14 @@ class TestCronCommandLifecycle:
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
profile="",
|
||||
clear_skills=True,
|
||||
)
|
||||
)
|
||||
cleared = get_job(job["id"])
|
||||
assert cleared["skills"] == []
|
||||
assert cleared["skill"] is None
|
||||
assert cleared["profile"] is None
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Updated job" in out
|
||||
@@ -96,6 +100,7 @@ class TestCronCommandLifecycle:
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=["blogwatcher", "maps"],
|
||||
profile="default",
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
@@ -105,3 +110,4 @@ class TestCronCommandLifecycle:
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
assert jobs[0]["profile"] == "default"
|
||||
|
||||
Reference in New Issue
Block a user