mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-05 12:42:30 +00:00
feat(dashboard): expose cron job execution fields
This commit is contained in:
parent
50f6855217
commit
c655cdf2c1
9 changed files with 1589 additions and 366 deletions
|
|
@ -7874,17 +7874,141 @@ async def get_logs(
|
|||
|
||||
|
||||
class CronJobCreate(BaseModel):
|
||||
prompt: str
|
||||
prompt: str = ""
|
||||
schedule: str
|
||||
name: str = ""
|
||||
deliver: str = "local"
|
||||
skills: Optional[List[str]] = None
|
||||
model: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
context_from: Optional[Any] = None
|
||||
enabled_toolsets: Optional[List[str]] = None
|
||||
workdir: Optional[str] = None
|
||||
no_agent: bool = False
|
||||
|
||||
|
||||
class CronJobUpdate(BaseModel):
|
||||
updates: dict
|
||||
|
||||
|
||||
def _cron_optional_text(value: Any, *, strip_trailing_slash: bool = False) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if strip_trailing_slash:
|
||||
text = text.rstrip("/")
|
||||
return text or None
|
||||
|
||||
|
||||
def _cron_string_list(value: Any) -> Optional[List[str]]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
raw_items = re.split(r"[\n,]", value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
raw_items = value
|
||||
else:
|
||||
return None
|
||||
items = [str(item).strip() for item in raw_items if str(item).strip()]
|
||||
return items or None
|
||||
|
||||
|
||||
def _normalize_dashboard_cron_script(value: Any, profile_home: Path) -> Optional[str]:
|
||||
"""Validate a dashboard-selected cron script against the profile sandbox."""
|
||||
text = _cron_optional_text(value)
|
||||
if not text:
|
||||
return None
|
||||
|
||||
scripts_root = (profile_home / "scripts").resolve()
|
||||
raw_path = Path(text).expanduser()
|
||||
candidate = raw_path.resolve() if raw_path.is_absolute() else (scripts_root / raw_path).resolve()
|
||||
try:
|
||||
relative = candidate.relative_to(scripts_root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"script must be inside {scripts_root}",
|
||||
) from exc
|
||||
if not candidate.exists():
|
||||
raise HTTPException(status_code=400, detail=f"script does not exist: {candidate}")
|
||||
if not candidate.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"script is not a file: {candidate}")
|
||||
return str(relative)
|
||||
|
||||
|
||||
def _validate_dashboard_cron_effective_job(job: Dict[str, Any]) -> None:
|
||||
prompt = _cron_optional_text(job.get("prompt"))
|
||||
script = _cron_optional_text(job.get("script"))
|
||||
skills = _cron_string_list(job.get("skills")) or _cron_string_list(job.get("skill"))
|
||||
no_agent = bool(job.get("no_agent"))
|
||||
|
||||
if no_agent:
|
||||
if not script:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="no_agent=True requires a script",
|
||||
)
|
||||
return
|
||||
|
||||
if not (prompt or skills or script):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="agent cron jobs require a prompt, skill, or script",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_dashboard_cron_updates(
|
||||
updates: Dict[str, Any],
|
||||
profile_home: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""Normalize dashboard JSON into cron.jobs.update_job's storage shape.
|
||||
|
||||
This intentionally stays in the dashboard adapter layer: cron/jobs.py is the
|
||||
source of truth for scheduling behaviour; the dashboard only translates form
|
||||
payloads into the shapes that existing core functions already accept.
|
||||
"""
|
||||
normalized = dict(updates or {})
|
||||
|
||||
for key in ("model", "provider", "workdir"):
|
||||
if key in normalized:
|
||||
normalized[key] = _cron_optional_text(normalized[key])
|
||||
if "script" in normalized:
|
||||
normalized["script"] = _normalize_dashboard_cron_script(
|
||||
normalized["script"],
|
||||
profile_home,
|
||||
)
|
||||
if "base_url" in normalized:
|
||||
normalized["base_url"] = _cron_optional_text(
|
||||
normalized["base_url"], strip_trailing_slash=True
|
||||
)
|
||||
if "deliver" in normalized:
|
||||
normalized["deliver"] = _cron_optional_text(normalized["deliver"]) or "local"
|
||||
if "context_from" in normalized:
|
||||
normalized["context_from"] = _cron_string_list(normalized["context_from"])
|
||||
if "enabled_toolsets" in normalized:
|
||||
normalized["enabled_toolsets"] = _cron_string_list(normalized["enabled_toolsets"])
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_dashboard_cron_context_from(
|
||||
refs: Optional[List[str]],
|
||||
profile_name: str,
|
||||
) -> None:
|
||||
if not refs:
|
||||
return
|
||||
for ref in refs:
|
||||
if not _call_cron_for_profile(profile_name, "get_job", ref):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"context_from job '{ref}' not found in profile "
|
||||
f"'{profile_name}'"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_CRON_PROFILE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
|
|
@ -7922,7 +8046,7 @@ def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[st
|
|||
return annotated
|
||||
|
||||
|
||||
def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwargs):
|
||||
def _call_cron_for_profile(target_profile: Optional[str], func_name: str, *args, **kwargs):
|
||||
"""Run cron.jobs helpers against the selected profile's cron directory.
|
||||
|
||||
cron.jobs keeps CRON_DIR/JOBS_FILE/OUTPUT_DIR as module globals resolved
|
||||
|
|
@ -7930,13 +8054,18 @@ def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwar
|
|||
process that can inspect many profiles, so temporarily retarget those
|
||||
globals while holding a lock and restore them immediately after the call.
|
||||
"""
|
||||
profile_name, home = _cron_profile_home(profile)
|
||||
profile_name, home = _cron_profile_home(target_profile)
|
||||
with _CRON_PROFILE_LOCK:
|
||||
from cron import jobs as cron_jobs
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
old_cron_dir = cron_jobs.CRON_DIR
|
||||
old_jobs_file = cron_jobs.JOBS_FILE
|
||||
old_output_dir = cron_jobs.OUTPUT_DIR
|
||||
token = set_hermes_home_override(str(home))
|
||||
cron_jobs.CRON_DIR = home / "cron"
|
||||
cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json"
|
||||
cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output"
|
||||
|
|
@ -7946,6 +8075,7 @@ def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwar
|
|||
cron_jobs.CRON_DIR = old_cron_dir
|
||||
cron_jobs.JOBS_FILE = old_jobs_file
|
||||
cron_jobs.OUTPUT_DIR = old_output_dir
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
if isinstance(result, list):
|
||||
return [_annotate_cron_job(j, profile_name, home) for j in result]
|
||||
|
|
@ -8043,15 +8173,37 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
|
|||
@app.post("/api/cron/jobs")
|
||||
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
|
||||
try:
|
||||
profile_name, profile_home = _cron_profile_home(profile)
|
||||
script = _normalize_dashboard_cron_script(body.script, profile_home)
|
||||
skills = _cron_string_list(body.skills)
|
||||
context_from = _cron_string_list(body.context_from)
|
||||
_validate_dashboard_cron_context_from(context_from, profile_name)
|
||||
no_agent = bool(body.no_agent)
|
||||
_validate_dashboard_cron_effective_job({
|
||||
"prompt": body.prompt,
|
||||
"skills": skills,
|
||||
"script": script,
|
||||
"no_agent": no_agent,
|
||||
})
|
||||
return _call_cron_for_profile(
|
||||
profile,
|
||||
profile_name,
|
||||
"create_job",
|
||||
prompt=body.prompt,
|
||||
prompt=body.prompt or "",
|
||||
schedule=body.schedule,
|
||||
name=body.name,
|
||||
deliver=body.deliver,
|
||||
skills=body.skills,
|
||||
deliver=_cron_optional_text(body.deliver) or "local",
|
||||
skills=skills,
|
||||
model=_cron_optional_text(body.model),
|
||||
provider=_cron_optional_text(body.provider),
|
||||
base_url=_cron_optional_text(body.base_url, strip_trailing_slash=True),
|
||||
script=script,
|
||||
context_from=context_from,
|
||||
enabled_toolsets=_cron_string_list(body.enabled_toolsets),
|
||||
workdir=_cron_optional_text(body.workdir),
|
||||
no_agent=no_agent,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_log.exception("POST /api/cron/jobs failed")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
|
@ -8091,7 +8243,28 @@ async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[st
|
|||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
try:
|
||||
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
|
||||
profile_name, profile_home = _cron_profile_home(selected)
|
||||
existing = _call_cron_for_profile(profile_name, "get_job", job_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
updates = _normalize_dashboard_cron_updates(
|
||||
body.updates,
|
||||
profile_home,
|
||||
)
|
||||
if "context_from" in updates:
|
||||
_validate_dashboard_cron_context_from(
|
||||
updates.get("context_from"),
|
||||
profile_name,
|
||||
)
|
||||
execution_fields = {"prompt", "skill", "skills", "script", "no_agent"}
|
||||
if execution_fields.intersection(updates):
|
||||
effective = {**existing, **updates}
|
||||
if "skills" in updates and "skill" not in updates:
|
||||
effective["skill"] = None
|
||||
_validate_dashboard_cron_effective_job(effective)
|
||||
job = _call_cron_for_profile(profile_name, "update_job", job_id, updates)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not job:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue