mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(kanban): show dashboard cron jobs across profiles
Salvages #27568 by @SerenityTn. Dashboard cron page now lists cron jobs from all profiles, with profile-aware filter UI and storage routing. Includes test coverage for cross-profile listing, mutation, deletion, and validation. Also fixes orphan conflict markers in config.py left by an earlier salvage merge (kanban.dispatch_stale_timeout_seconds was double-nested in HEAD/PR markers from #28452 salvage of #23790).
This commit is contained in:
parent
264e85b3dd
commit
1a5172742e
4 changed files with 416 additions and 62 deletions
|
|
@ -2560,73 +2560,181 @@ class CronJobUpdate(BaseModel):
|
|||
updates: dict
|
||||
|
||||
|
||||
_CRON_PROFILE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _cron_profile_dicts() -> List[Dict[str, Any]]:
|
||||
"""Return dashboard profile records, falling back to a directory scan."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
try:
|
||||
return [_profile_to_dict(p) for p in profiles_mod.list_profiles()]
|
||||
except Exception:
|
||||
_log.exception("Failed to list profiles for cron dashboard; falling back to directory scan")
|
||||
return _fallback_profile_dicts(profiles_mod)
|
||||
|
||||
|
||||
def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
|
||||
"""Resolve a profile query value to (profile_name, HERMES_HOME)."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
raw = (profile or "default").strip() or "default"
|
||||
try:
|
||||
canon = profiles_mod.normalize_profile_name(raw)
|
||||
profiles_mod.validate_profile_name(canon)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not profiles_mod.profile_exists(canon):
|
||||
raise HTTPException(status_code=404, detail=f"Profile '{canon}' does not exist.")
|
||||
return canon, profiles_mod.get_profile_dir(canon)
|
||||
|
||||
|
||||
def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[str, Any]:
|
||||
annotated = dict(job)
|
||||
annotated["profile"] = profile
|
||||
annotated["profile_name"] = profile
|
||||
annotated["hermes_home"] = str(home)
|
||||
annotated["is_default_profile"] = profile == "default"
|
||||
return annotated
|
||||
|
||||
|
||||
def _call_cron_for_profile(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
|
||||
from the process HERMES_HOME at import time. The dashboard is a single
|
||||
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)
|
||||
with _CRON_PROFILE_LOCK:
|
||||
from cron import jobs as cron_jobs
|
||||
|
||||
old_cron_dir = cron_jobs.CRON_DIR
|
||||
old_jobs_file = cron_jobs.JOBS_FILE
|
||||
old_output_dir = cron_jobs.OUTPUT_DIR
|
||||
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"
|
||||
try:
|
||||
result = getattr(cron_jobs, func_name)(*args, **kwargs)
|
||||
finally:
|
||||
cron_jobs.CRON_DIR = old_cron_dir
|
||||
cron_jobs.JOBS_FILE = old_jobs_file
|
||||
cron_jobs.OUTPUT_DIR = old_output_dir
|
||||
|
||||
if isinstance(result, list):
|
||||
return [_annotate_cron_job(j, profile_name, home) for j in result]
|
||||
if isinstance(result, dict):
|
||||
return _annotate_cron_job(result, profile_name, home)
|
||||
return result
|
||||
|
||||
|
||||
def _find_cron_job_profile(job_id: str) -> Optional[str]:
|
||||
for profile in _cron_profile_dicts():
|
||||
name = str(profile.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
jobs = _call_cron_for_profile(name, "list_jobs", True)
|
||||
if any(j.get("id") == job_id or j.get("name") == job_id for j in jobs):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/api/cron/jobs")
|
||||
async def list_cron_jobs():
|
||||
from cron.jobs import list_jobs
|
||||
return list_jobs(include_disabled=True)
|
||||
async def list_cron_jobs(profile: str = "all"):
|
||||
requested = (profile or "all").strip()
|
||||
if requested.lower() != "all":
|
||||
return _call_cron_for_profile(requested, "list_jobs", True)
|
||||
|
||||
jobs: List[Dict[str, Any]] = []
|
||||
for item in _cron_profile_dicts():
|
||||
name = str(item.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
|
||||
except Exception:
|
||||
_log.exception("Failed to list cron jobs for profile %s", name)
|
||||
return jobs
|
||||
|
||||
|
||||
@app.get("/api/cron/jobs/{job_id}")
|
||||
async def get_cron_job(job_id: str):
|
||||
from cron.jobs import get_job
|
||||
job = get_job(job_id)
|
||||
async def get_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "get_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs")
|
||||
async def create_cron_job(body: CronJobCreate):
|
||||
from cron.jobs import create_job
|
||||
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
|
||||
try:
|
||||
job = create_job(prompt=body.prompt, schedule=body.schedule,
|
||||
name=body.name, deliver=body.deliver)
|
||||
return job
|
||||
return _call_cron_for_profile(
|
||||
profile,
|
||||
"create_job",
|
||||
prompt=body.prompt,
|
||||
schedule=body.schedule,
|
||||
name=body.name,
|
||||
deliver=body.deliver,
|
||||
)
|
||||
except Exception as e:
|
||||
_log.exception("POST /api/cron/jobs failed")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.put("/api/cron/jobs/{job_id}")
|
||||
async def update_cron_job(job_id: str, body: CronJobUpdate):
|
||||
from cron.jobs import update_job
|
||||
job = update_job(job_id, body.updates)
|
||||
async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs/{job_id}/pause")
|
||||
async def pause_cron_job(job_id: str):
|
||||
from cron.jobs import pause_job
|
||||
job = pause_job(job_id)
|
||||
async def pause_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "pause_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs/{job_id}/resume")
|
||||
async def resume_cron_job(job_id: str):
|
||||
from cron.jobs import resume_job
|
||||
job = resume_job(job_id)
|
||||
async def resume_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "resume_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs/{job_id}/trigger")
|
||||
async def trigger_cron_job(job_id: str):
|
||||
from cron.jobs import trigger_job
|
||||
job = trigger_job(job_id)
|
||||
async def trigger_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "trigger_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.delete("/api/cron/jobs/{job_id}")
|
||||
async def delete_cron_job(job_id: str):
|
||||
from cron.jobs import remove_job
|
||||
if not remove_job(job_id):
|
||||
async def delete_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not _call_cron_for_profile(selected, "remove_job", job_id):
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue