From c655cdf2c193850d5400cd8e6c6862b82be7dab1 Mon Sep 17 00:00:00 2001 From: Versun Date: Sat, 27 Jun 2026 15:57:50 +0800 Subject: [PATCH] feat(dashboard): expose cron job execution fields --- cron/jobs.py | 134 ++-- hermes_cli/web_server.py | 189 ++++- .../test_web_server_cron_profiles.py | 341 +++++++++ web/src/lib/api.ts | 41 +- web/src/lib/cron-job.test.ts | 123 +++ web/src/lib/cron-job.ts | 99 +++ web/src/lib/schedule.test.ts | 123 +++ web/src/lib/schedule.ts | 193 +++-- web/src/pages/CronPage.tsx | 712 +++++++++++------- 9 files changed, 1589 insertions(+), 366 deletions(-) create mode 100644 web/src/lib/cron-job.test.ts create mode 100644 web/src/lib/cron-job.ts create mode 100644 web/src/lib/schedule.test.ts diff --git a/cron/jobs.py b/cron/jobs.py index 3cab8488d2f..0c10c65c9fb 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -32,7 +32,7 @@ except ImportError: # pragma: no cover - non-Windows from datetime import datetime, timedelta from pathlib import Path from hermes_constants import get_hermes_home -from typing import Optional, Dict, List, Any, Union +from typing import Optional, Dict, List, Any, Tuple, Union logger = logging.getLogger(__name__) @@ -771,6 +771,70 @@ def _resolve_default_model_snapshot() -> Optional[str]: return None +def _normalize_job_optional_text(value: Any, *, strip_trailing_slash: bool = False) -> Optional[str]: + if not isinstance(value, str): + return None + text = value.strip() + if strip_trailing_slash: + text = text.rstrip("/") + return text or None + + +def _compute_provider_model_snapshots( + *, + provider: Any, + model: Any, + base_url: Any, + no_agent: Any, +) -> Tuple[Optional[str], Optional[str]]: + """Snapshot unpinned inference axes for the provider/model drift guard. + + Agent cron jobs with unpinned provider/model follow global config at fire + time. Capture the current resolution for each unpinned axis so a later + global switch fails closed instead of silently changing spend. Pinned axes + and no-agent script jobs intentionally carry no snapshot. + """ + normalized_provider = _normalize_job_optional_text(provider) + normalized_model = _normalize_job_optional_text(model) + normalized_base_url = _normalize_job_optional_text( + base_url, + strip_trailing_slash=True, + ) + if bool(no_agent): + return None, None + + provider_snapshot: Optional[str] = None + model_snapshot: Optional[str] = None + if normalized_provider is None: + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + + runtime_kwargs = {"requested": None} + if normalized_base_url: + runtime_kwargs["explicit_base_url"] = normalized_base_url + snap = resolve_runtime_provider(**runtime_kwargs) + snap_provider = str(snap.get("provider") or "").strip().lower() + provider_snapshot = snap_provider or None + except Exception: + provider_snapshot = None + if normalized_model is None: + try: + model_snapshot = _resolve_default_model_snapshot() or None + except Exception: + model_snapshot = None + return provider_snapshot, model_snapshot + + +def _normalized_inference_axes(job: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: + """Return the stored inference-routing fields in their semantic form.""" + return ( + _normalize_job_optional_text(job.get("provider")), + _normalize_job_optional_text(job.get("model")), + _normalize_job_optional_text(job.get("base_url"), strip_trailing_slash=True), + bool(job.get("no_agent")), + ) + + def create_job( prompt: Optional[str], schedule: str, @@ -855,12 +919,9 @@ def create_job( now = _hermes_now().isoformat() normalized_skills = _normalize_skill_list(skill, skills) - normalized_model = str(model).strip() if isinstance(model, str) else None - normalized_provider = str(provider).strip() if isinstance(provider, str) else None - normalized_base_url = str(base_url).strip().rstrip("/") if isinstance(base_url, str) else None - normalized_model = normalized_model or None - normalized_provider = normalized_provider or None - normalized_base_url = normalized_base_url or None + normalized_model = _normalize_job_optional_text(model) + normalized_provider = _normalize_job_optional_text(provider) + normalized_base_url = _normalize_job_optional_text(base_url, strip_trailing_slash=True) normalized_script = str(script).strip() if isinstance(script, str) else None normalized_script = normalized_script or None normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None @@ -889,45 +950,12 @@ def create_job( prompt_text = _coerce_job_text(prompt) label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" - # Provider/model-drift guard (#44585). When the caller does NOT pin a - # provider and/or model, the job follows the global default — model.default - # in config.yaml and whatever resolve_runtime_provider() picks at fire time. - # That global state can change (e.g. a temporary switch to a paid provider - # OR a paid model like claude-fable-5 on the SAME provider), and an unpinned - # job would then silently inherit it and spend real money. To detect that, - # snapshot what resolution WOULD pick *right now*, at creation, for each - # axis the job leaves unpinned. The fire-time guard (run_job) fails closed - # when an unpinned job's currently-resolved provider OR model differs from - # its snapshot. - # - # Only captured for agent-backed jobs (no_agent script jobs make no paid - # inference). Each axis is snapshotted only when that axis is unpinned — - # a pinned provider/model doesn't drift with global state. Fail-open to None - # on any resolution error so job creation never breaks; a missing snapshot - # preserves the legacy no-guard behaviour for that axis. - provider_snapshot: Optional[str] = None - model_snapshot: Optional[str] = None - if not normalized_no_agent: - if normalized_provider is None: - try: - from hermes_cli.runtime_provider import resolve_runtime_provider - _runtime_kwargs = {"requested": None} - if normalized_base_url: - _runtime_kwargs["explicit_base_url"] = normalized_base_url - _snap = resolve_runtime_provider(**_runtime_kwargs) - _snap_provider = str(_snap.get("provider") or "").strip().lower() - provider_snapshot = _snap_provider or None - except Exception: - provider_snapshot = None - if normalized_model is None: - # Mirror the fire-time unpinned-model resolution (run_job reads - # config.yaml model.default / model). Capture that value so a later - # swap of the global default model is detected even when the - # provider is unchanged (e.g. a premium model on the same endpoint). - try: - model_snapshot = _resolve_default_model_snapshot() or None - except Exception: - model_snapshot = None + provider_snapshot, model_snapshot = _compute_provider_model_snapshots( + provider=normalized_provider, + model=normalized_model, + base_url=normalized_base_url, + no_agent=normalized_no_agent, + ) job = { "id": job_id, @@ -1063,8 +1091,12 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] else: updates["workdir"] = _normalize_workdir(_wd) + previous_inference_axes = _normalized_inference_axes(job) updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates + inference_fields_changed = bool( + {"provider", "model", "base_url", "no_agent"}.intersection(updates) + ) and _normalized_inference_axes(updated) != previous_inference_axes if "skills" in updates or "skill" in updates: normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills")) @@ -1086,6 +1118,16 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] if updated.get("state") != "paused": updated["next_run_at"] = compute_next_run(updated_schedule) + if inference_fields_changed: + provider_snapshot, model_snapshot = _compute_provider_model_snapshots( + provider=updated.get("provider"), + model=updated.get("model"), + base_url=updated.get("base_url"), + no_agent=updated.get("no_agent"), + ) + updated["provider_snapshot"] = provider_snapshot + updated["model_snapshot"] = model_snapshot + if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"): updated["next_run_at"] = compute_next_run(updated["schedule"]) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3d4bf45b56c..ec1a4adbf39 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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: diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index bf8f6e219c3..f8fa1e008a5 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -106,6 +106,34 @@ async def test_list_cron_jobs_specific_profile_filters_results(isolated_profiles assert jobs[0]["profile"] == "worker_alpha" +@pytest.mark.asyncio +async def test_create_cron_job_normalizes_representative_core_fields( + isolated_profiles, tmp_path +): + from hermes_cli import web_server + + scripts_dir = isolated_profiles["worker_alpha"] / "scripts" + scripts_dir.mkdir() + (scripts_dir / "collect-status.py").write_text("print('ok')\n", encoding="utf-8") + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="summarize upstream status", + schedule="every 1h", + name="full-core-mapping", + base_url="https://example.invalid/v1/", + script=str(scripts_dir / "collect-status.py"), + no_agent=True, + ), + profile="worker_alpha", + ) + + assert job["name"] == "full-core-mapping" + assert job["base_url"] == "https://example.invalid/v1" + assert job["script"] == "collect-status.py" + assert job["no_agent"] is True + + @pytest.mark.asyncio async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles): from hermes_cli import web_server @@ -131,6 +159,319 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr assert worker_jobs[0]["enabled"] is False +@pytest.mark.asyncio +async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profiles, tmp_path): + from hermes_cli import web_server + + scripts_dir = isolated_profiles["worker_alpha"] / "scripts" + scripts_dir.mkdir() + (scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8") + job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="normalizes-dashboard-fields", + ) + + updated = await web_server.update_cron_job( + job["id"], + web_server.CronJobUpdate( + updates={ + "base_url": "https://example.invalid/v1/", + "script": str(scripts_dir / "collect.py"), + "context_from": "", + "no_agent": True, + } + ), + profile="worker_alpha", + ) + + assert updated["base_url"] == "https://example.invalid/v1" + assert updated["script"] == "collect.py" + assert updated["context_from"] is None + assert updated["no_agent"] is True + + +@pytest.mark.asyncio +async def test_create_cron_job_rejects_script_outside_profile_scripts( + isolated_profiles, tmp_path +): + from hermes_cli import web_server + + outside = tmp_path / "outside.py" + outside.write_text("print('nope')\n", encoding="utf-8") + + with pytest.raises(HTTPException) as exc: + await web_server.create_cron_job( + web_server.CronJobCreate( + schedule="every 1h", + script=str(outside), + no_agent=True, + ), + profile="worker_alpha", + ) + + assert exc.value.status_code == 400 + assert "inside" in exc.value.detail + + +@pytest.mark.asyncio +async def test_create_cron_job_rejects_empty_agent_job(isolated_profiles): + from hermes_cli import web_server + + with pytest.raises(HTTPException) as exc: + await web_server.create_cron_job( + web_server.CronJobCreate(schedule="every 1h"), + profile="worker_alpha", + ) + + assert exc.value.status_code == 400 + assert "prompt, skill, or script" in exc.value.detail + + +@pytest.mark.asyncio +async def test_update_cron_job_no_agent_reuses_existing_script(isolated_profiles): + from hermes_cli import web_server + + scripts_dir = isolated_profiles["worker_alpha"] / "scripts" + scripts_dir.mkdir() + (scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8") + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + schedule="every 1h", + script=str(scripts_dir / "collect.py"), + ), + profile="worker_alpha", + ) + + updated = await web_server.update_cron_job( + job["id"], + web_server.CronJobUpdate(updates={"no_agent": True}), + profile="worker_alpha", + ) + + assert updated["no_agent"] is True + assert updated["script"] == "collect.py" + + +@pytest.mark.asyncio +async def test_dashboard_cron_rejects_missing_context_from(isolated_profiles): + from hermes_cli import web_server + + with pytest.raises(HTTPException) as create_exc: + await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="process missing upstream", + schedule="every 1h", + context_from=["missing-job-id"], + ), + profile="worker_alpha", + ) + + assert create_exc.value.status_code == 400 + assert "missing-job-id" in create_exc.value.detail + + job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="context-update-target", + ) + + with pytest.raises(HTTPException) as update_exc: + await web_server.update_cron_job( + job["id"], + web_server.CronJobUpdate( + updates={ + "context_from": ["missing-job-id"], + } + ), + profile="worker_alpha", + ) + + assert update_exc.value.status_code == 400 + assert "missing-job-id" in update_exc.value.detail + + +@pytest.mark.asyncio +async def test_dashboard_cron_context_from_is_profile_scoped(isolated_profiles): + from hermes_cli import web_server + + default_job = web_server._call_cron_for_profile( + "default", + "create_job", + prompt="default upstream", + schedule="every 1h", + name="default-upstream", + ) + worker_upstream = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="worker upstream", + schedule="every 1h", + name="worker-upstream", + ) + + with pytest.raises(HTTPException): + await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="worker downstream", + schedule="every 1h", + context_from=[default_job["id"]], + ), + profile="worker_alpha", + ) + + job = await web_server.create_cron_job( + web_server.CronJobCreate( + prompt="worker downstream", + schedule="every 1h", + context_from=[worker_upstream["id"]], + ), + profile="worker_alpha", + ) + + assert job["context_from"] == [worker_upstream["id"]] + + +@pytest.mark.asyncio +async def test_update_cron_job_refreshes_snapshots_when_unpinning( + isolated_profiles, + monkeypatch, +): + from hermes_cli import runtime_provider, web_server + + monkeypatch.setattr( + runtime_provider, + "resolve_runtime_provider", + lambda **kwargs: {"provider": "worker-provider"}, + ) + + job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="pinned-job", + provider="fixed-provider", + model="fixed-model", + ) + + assert job["provider_snapshot"] is None + assert job["model_snapshot"] is None + + updated = await web_server.update_cron_job( + job["id"], + web_server.CronJobUpdate( + updates={ + "provider": None, + "model": None, + } + ), + profile="worker_alpha", + ) + + assert updated["provider"] is None + assert updated["model"] is None + assert updated["provider_snapshot"] == "worker-provider" + assert updated["model_snapshot"] == "test-model" + + +@pytest.mark.asyncio +async def test_dashboard_cron_noop_inference_fields_keep_existing_snapshots( + isolated_profiles, + monkeypatch, +): + from hermes_cli import runtime_provider, web_server + + current_provider = {"name": "initial-provider"} + monkeypatch.setattr( + runtime_provider, + "resolve_runtime_provider", + lambda **kwargs: {"provider": current_provider["name"]}, + ) + + job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="dashboard-edit-job", + ) + + assert job["provider_snapshot"] == "initial-provider" + assert job["model_snapshot"] == "test-model" + + current_provider["name"] = "changed-provider" + (isolated_profiles["worker_alpha"] / "config.yaml").write_text( + "model: changed-model\n", + encoding="utf-8", + ) + + updated = await web_server.update_cron_job( + job["id"], + web_server.CronJobUpdate( + updates={ + "name": "dashboard-edit-job-renamed", + "provider": None, + "model": None, + "base_url": None, + "no_agent": False, + } + ), + profile="worker_alpha", + ) + + assert updated["name"] == "dashboard-edit-job-renamed" + assert updated["provider_snapshot"] == "initial-provider" + assert updated["model_snapshot"] == "test-model" + + +@pytest.mark.asyncio +async def test_update_cron_job_clears_snapshots_for_no_agent( + isolated_profiles, + monkeypatch, +): + from hermes_cli import runtime_provider, web_server + + monkeypatch.setattr( + runtime_provider, + "resolve_runtime_provider", + lambda **kwargs: {"provider": "worker-provider"}, + ) + scripts_dir = isolated_profiles["worker_alpha"] / "scripts" + scripts_dir.mkdir() + (scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8") + + job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="agent-to-script-job", + ) + + assert job["provider_snapshot"] == "worker-provider" + assert job["model_snapshot"] == "test-model" + + updated = await web_server.update_cron_job( + job["id"], + web_server.CronJobUpdate( + updates={ + "script": str(scripts_dir / "collect.py"), + "no_agent": True, + } + ), + profile="worker_alpha", + ) + + assert updated["provider_snapshot"] is None + assert updated["model_snapshot"] is None + + @pytest.mark.asyncio async def test_update_cron_job_rejects_id_mutation(isolated_profiles): """Dashboard surfaces a 400 (not a 500 or silent rename) when an diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b6c02e3d4cb..983baffae0f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -471,7 +471,8 @@ export const api = { getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), getModelInfo: () => fetchJSON("/api/model/info"), - getModelOptions: () => fetchJSON("/api/model/options"), + getModelOptions: (profile?: string) => + fetchJSON(`/api/model/options${profileQuery(profile)}`), getAuxiliaryModels: () => fetchJSON("/api/model/auxiliary"), getMoaModels: () => fetchJSON("/api/model/moa"), saveMoaModels: (body: MoaConfigResponse) => @@ -529,7 +530,7 @@ export const api = { 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; skills?: string[] }, profile = "default") => + createCronJob: (job: CronJobMutation, profile = "default") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -539,7 +540,7 @@ export const api = { fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }), updateCronJob: ( id: string, - updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] }, + updates: CronJobMutation, profile = "default", ) => fetchJSON( @@ -1895,6 +1896,27 @@ export interface ModelsAnalyticsResponse { period_days: number; } +export interface CronJobRepeat { + times: number | null; + completed?: number; +} + +export interface CronJobMutation { + name?: string; + prompt?: string; + schedule?: string; + deliver?: string; + skills?: string[]; + provider?: string | null; + model?: string | null; + base_url?: string | null; + script?: string | null; + no_agent?: boolean; + context_from?: string[] | null; + enabled_toolsets?: string[] | null; + workdir?: string | null; +} + export interface CronJob { id: string; profile?: string | null; @@ -1905,14 +1927,24 @@ export interface CronJob { prompt?: string | null; script?: string | null; skills?: string[] | null; - schedule?: { kind?: string; expr?: string; display?: string }; + schedule?: { kind?: string; expr?: string; run_at?: string; display?: string }; schedule_display?: string | null; + repeat?: CronJobRepeat | null; enabled: boolean; state?: string | null; deliver?: string | null; + model?: string | null; + provider?: string | null; + base_url?: string | null; + no_agent?: boolean | null; + context_from?: string[] | string | null; + enabled_toolsets?: string[] | null; + workdir?: string | null; last_run_at?: string | null; next_run_at?: string | null; + last_status?: string | null; last_error?: string | null; + last_delivery_error?: string | null; } export interface CronDeliveryTarget { @@ -2049,6 +2081,7 @@ export interface ModelOptionProvider { is_user_defined?: boolean; source?: string; warning?: string; + authenticated?: boolean; } export interface ModelOptionsResponse { diff --git a/web/src/lib/cron-job.test.ts b/web/src/lib/cron-job.test.ts new file mode 100644 index 00000000000..2b4b3433d0f --- /dev/null +++ b/web/src/lib/cron-job.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCronJobPayload, + cronJobHasExecutionContent, + cronJobFormFromJob, + splitCronList, + type CronJobFormState, +} from "./cron-job"; +import type { CronJob } from "./api"; + +function form(overrides: Partial = {}): CronJobFormState { + return { + name: "", + prompt: "prompt", + schedule: "every 1h", + deliver: "local", + skills: [], + provider: "", + model: "", + base_url: "", + script: "", + no_agent: false, + context_from: "", + enabled_toolsets: [], + workdir: "", + ...overrides, + }; +} + +describe("splitCronList", () => { + it("normalizes comma and newline separated cron list fields", () => { + expect(splitCronList(" web, terminal\nfile ,, ")).toEqual([ + "web", + "terminal", + "file", + ]); + }); +}); + +describe("buildCronJobPayload", () => { + it("normalizes list fields and base URLs", () => { + const payload = buildCronJobPayload( + form({ + base_url: "https://example.invalid/v1/", + enabled_toolsets: ["web", ""], + context_from: "upstream-a\nupstream-b", + }), + ); + + expect(payload).toMatchObject({ + base_url: "https://example.invalid/v1", + context_from: ["upstream-a", "upstream-b"], + enabled_toolsets: ["web"], + }); + }); + + it("keeps clear operations explicit for update payloads", () => { + const payload = buildCronJobPayload(form({ schedule: "every 2h" })); + + expect(payload).toMatchObject({ + schedule: "every 2h", + provider: null, + model: null, + base_url: null, + script: null, + no_agent: false, + context_from: null, + enabled_toolsets: null, + workdir: null, + }); + }); +}); + +describe("cronJobHasExecutionContent", () => { + it("treats a script as execution content for agent-backed cron jobs", () => { + const payload = buildCronJobPayload( + form({ prompt: "", skills: [], script: "collect-status.py" }), + ); + + expect(cronJobHasExecutionContent(payload)).toBe(true); + }); + + it("rejects payloads with no prompt, skills, or script", () => { + const payload = buildCronJobPayload(form({ prompt: "", skills: [], script: "" })); + + expect(cronJobHasExecutionContent(payload)).toBe(false); + }); +}); + +describe("cronJobFormFromJob", () => { + it("preserves schedule fallback and editable list fields", () => { + const job: CronJob = { + id: "abc", + enabled: true, + schedule_display: "every 1h", + context_from: ["upstream-a", "upstream-b"], + enabled_toolsets: ["web"], + }; + + expect(cronJobFormFromJob(job)).toMatchObject({ + schedule: "every 1h", + context_from: "upstream-a\nupstream-b", + enabled_toolsets: ["web"], + }); + }); + + it("prefers one-shot run_at over the human display string", () => { + const job: CronJob = { + id: "once-job", + enabled: true, + schedule: { + kind: "once", + run_at: "2026-02-03T14:00:00+08:00", + }, + schedule_display: "once at 2026-02-03 14:00", + }; + + expect(cronJobFormFromJob(job)).toMatchObject({ + schedule: "2026-02-03T14:00:00+08:00", + }); + }); +}); diff --git a/web/src/lib/cron-job.ts b/web/src/lib/cron-job.ts new file mode 100644 index 00000000000..118f37c5db2 --- /dev/null +++ b/web/src/lib/cron-job.ts @@ -0,0 +1,99 @@ +import type { CronJob, CronJobMutation } from "./api"; + +export interface CronJobFormState { + name: string; + prompt: string; + schedule: string; + deliver: string; + skills: string[]; + provider: string; + model: string; + base_url: string; + script: string; + no_agent: boolean; + context_from: string; + enabled_toolsets: string[]; + workdir: string; +} + +export function splitCronList(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + if (typeof value !== "string") return []; + return value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function optionalText(value: string): string | null { + const text = value.trim(); + return text || null; +} + +function optionalBaseUrl(value: string): string | null { + const text = optionalText(value); + return text ? text.replace(/\/+$/, "") : null; +} + +function listToText(value: unknown, separator: string): string { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean).join(separator); + } + return typeof value === "string" ? value : ""; +} + +export function buildCronJobPayload(form: CronJobFormState): CronJobMutation { + const contextFrom = splitCronList(form.context_from); + const enabledToolsets = form.enabled_toolsets.filter(Boolean); + return { + name: form.name.trim(), + prompt: form.prompt.trim(), + schedule: form.schedule.trim(), + deliver: form.deliver.trim() || "local", + skills: form.skills.filter(Boolean), + provider: optionalText(form.provider), + model: optionalText(form.model), + base_url: optionalBaseUrl(form.base_url), + script: optionalText(form.script), + no_agent: Boolean(form.no_agent), + context_from: contextFrom.length > 0 ? contextFrom : null, + enabled_toolsets: enabledToolsets.length > 0 ? enabledToolsets : null, + workdir: optionalText(form.workdir), + }; +} + +export function cronJobHasExecutionContent( + job: Pick, +): boolean { + const prompt = typeof job.prompt === "string" ? job.prompt.trim() : ""; + const script = typeof job.script === "string" ? job.script.trim() : ""; + const skills = Array.isArray(job.skills) + ? job.skills.map((skill) => String(skill).trim()).filter(Boolean) + : []; + return Boolean(prompt || script || skills.length > 0); +} + +export function cronJobFormFromJob(job: CronJob): CronJobFormState { + return { + name: typeof job.name === "string" ? job.name : "", + prompt: typeof job.prompt === "string" ? job.prompt : "", + schedule: + (typeof job.schedule?.expr === "string" && job.schedule.expr) || + (typeof job.schedule?.run_at === "string" && job.schedule.run_at) || + (typeof job.schedule_display === "string" ? job.schedule_display : ""), + deliver: typeof job.deliver === "string" && job.deliver ? job.deliver : "local", + skills: Array.isArray(job.skills) ? job.skills.filter(Boolean) : [], + provider: typeof job.provider === "string" ? job.provider : "", + model: typeof job.model === "string" ? job.model : "", + base_url: typeof job.base_url === "string" ? job.base_url : "", + script: typeof job.script === "string" ? job.script : "", + no_agent: Boolean(job.no_agent), + context_from: listToText(job.context_from, "\n"), + enabled_toolsets: Array.isArray(job.enabled_toolsets) + ? job.enabled_toolsets.filter(Boolean) + : splitCronList(job.enabled_toolsets), + workdir: typeof job.workdir === "string" ? job.workdir : "", + }; +} diff --git a/web/src/lib/schedule.test.ts b/web/src/lib/schedule.test.ts new file mode 100644 index 00000000000..3fce6b60ed9 --- /dev/null +++ b/web/src/lib/schedule.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + buildScheduleString, + DEFAULT_SCHEDULE_STATE, + parseScheduleString, +} from "./schedule"; + +describe("parseScheduleString", () => { + it("parses recurring interval strings", () => { + expect(parseScheduleString("every 30m")).toMatchObject({ + mode: "interval", + intervalValue: 30, + intervalUnit: "minutes", + }); + expect(parseScheduleString("every 2h")).toMatchObject({ + mode: "interval", + intervalValue: 2, + intervalUnit: "hours", + }); + expect(parseScheduleString("every 1d")).toMatchObject({ + mode: "interval", + intervalValue: 1, + intervalUnit: "days", + }); + }); + + it("parses ISO timestamps into once mode", () => { + expect(parseScheduleString("2026-02-03T14:00:00")).toMatchObject({ + mode: "once", + onceAt: "2026-02-03T14:00", + }); + expect(parseScheduleString("2026-02-03T14:00")).toMatchObject({ + mode: "once", + onceAt: "2026-02-03T14:00", + }); + }); + + it("parses daily cron expressions", () => { + expect(parseScheduleString("0 9 * * *")).toMatchObject({ + mode: "daily", + timeOfDay: "09:00", + }); + }); + + it("parses weekly cron expressions", () => { + expect(parseScheduleString("30 14 * * 1,3,5")).toMatchObject({ + mode: "weekly", + timeOfDay: "14:30", + weekdays: [1, 3, 5], + }); + }); + + it("normalizes cron Sunday 7 into the builder's Sunday 0", () => { + expect(parseScheduleString("30 14 * * 1,7")).toMatchObject({ + mode: "weekly", + timeOfDay: "14:30", + weekdays: [1, 0], + }); + }); + + it("parses monthly cron expressions", () => { + expect(parseScheduleString("0 9 15 * *")).toMatchObject({ + mode: "monthly", + timeOfDay: "09:00", + dayOfMonth: 15, + }); + }); + + it("falls back to custom for unsupported schedule strings", () => { + expect(parseScheduleString("0 9 * * 1-5")).toMatchObject({ + mode: "custom", + custom: "0 9 * * 1-5", + }); + expect(parseScheduleString("@daily")).toMatchObject({ + mode: "custom", + custom: "@daily", + }); + expect(parseScheduleString("2026-02-03T14:00:00Z")).toMatchObject({ + mode: "custom", + custom: "2026-02-03T14:00:00Z", + }); + expect(parseScheduleString("2026-02-03T14:00:00+08:00")).toMatchObject({ + mode: "custom", + custom: "2026-02-03T14:00:00+08:00", + }); + expect(parseScheduleString("0 9 * * 1,8")).toMatchObject({ + mode: "custom", + custom: "0 9 * * 1,8", + }); + expect(parseScheduleString("0 9 1,15 * *")).toMatchObject({ + mode: "custom", + custom: "0 9 1,15 * *", + }); + }); + + it("returns the default state for empty input", () => { + expect(parseScheduleString("")).toEqual(DEFAULT_SCHEDULE_STATE); + }); +}); + +describe("buildScheduleString round-trip", () => { + it("rebuilds the schedule string from parsed state", () => { + const cases: [string, string][] = [ + ["every 30m", "every 30m"], + ["every 2h", "every 2h"], + ["every 1d", "every 1d"], + ["0 9 * * *", "0 9 * * *"], + ["30 14 * * 1,3,5", "30 14 * * 1,3,5"], + ["30 14 * * 1,7", "30 14 * * 0,1"], + ["0 9 15 * *", "0 9 15 * *"], + ["0 9 1,15 * *", "0 9 1,15 * *"], + ["2026-02-03T14:00:00", "2026-02-03T14:00:00"], + ["2026-02-03T14:00", "2026-02-03T14:00:00"], + ["2026-02-03T14:00:00Z", "2026-02-03T14:00:00Z"], + ["2026-02-03T14:00:00+08:00", "2026-02-03T14:00:00+08:00"], + ]; + for (const [input, expected] of cases) { + const state = parseScheduleString(input); + expect(buildScheduleString(state)).toBe(expected); + } + }); +}); diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts index d36fa52c244..22d8681d43b 100644 --- a/web/src/lib/schedule.ts +++ b/web/src/lib/schedule.ts @@ -147,6 +147,134 @@ export function buildScheduleString(state: ScheduleBuilderState): string { } } +/** Parse schedules emitted by buildScheduleString; unknown strings stay custom. */ +export function parseScheduleString( + schedule: string, +): ScheduleBuilderState { + const trimmed = schedule.trim(); + if (!trimmed) return { ...DEFAULT_SCHEDULE_STATE }; + + // ISO timestamp (one-shot). + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(trimmed)) { + return { + ...DEFAULT_SCHEDULE_STATE, + mode: "once", + onceAt: trimmed.slice(0, 16), + }; + } + + // Recurring interval. + const intervalMatch = /^every\s+(\d+)\s*([mhd])$/i.exec(trimmed); + if (intervalMatch) { + const value = Number.parseInt(intervalMatch[1], 10); + const suffix = intervalMatch[2].toLowerCase(); + const unit: IntervalUnit = + suffix === "d" ? "days" : suffix === "h" ? "hours" : "minutes"; + return { + ...DEFAULT_SCHEDULE_STATE, + mode: "interval", + intervalValue: Number.isFinite(value) && value > 0 ? value : 1, + intervalUnit: unit, + }; + } + + // 5-field cron expression. + const parsedCron = parseSimpleCronExpression(trimmed); + if (parsedCron) { + if (parsedCron.mode === "daily") { + return { ...DEFAULT_SCHEDULE_STATE, mode: "daily", timeOfDay: parsedCron.time }; + } + if (parsedCron.mode === "weekly") { + return { + ...DEFAULT_SCHEDULE_STATE, + mode: "weekly", + timeOfDay: parsedCron.time, + weekdays: parsedCron.weekdays ?? [], + }; + } + return { + ...DEFAULT_SCHEDULE_STATE, + mode: "monthly", + timeOfDay: parsedCron.time, + dayOfMonth: parsedCron.dayOfMonth ?? 1, + }; + } + + // Fallback: preserve the raw string in custom mode. + return { ...DEFAULT_SCHEDULE_STATE, mode: "custom", custom: trimmed }; +} + +/** + * Shared helper: recognise the simple, well-shaped 5-field cron patterns + * that both the human-readable describer and the schedule builder care + * about. Returns a structured result or ``null`` when the expression has + * ranges, steps, per-month rules, or other complexity. + */ +function parseSimpleCronExpression( + expr: string, +): { mode: "daily" | "weekly" | "monthly"; time: string; weekdays?: Weekday[]; dayOfMonth?: number } | null { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return null; + const [minField, hourField, domField, monField, dowField] = parts; + + if (monField !== "*") return null; + + const isLiteralOrList = (f: string) => /^\d+(,\d+)*$|^\*$/.test(f); + if ( + !isLiteralOrList(minField) || + !isLiteralOrList(hourField) || + !isLiteralOrList(domField) || + !isLiteralOrList(dowField) + ) { + return null; + } + + if (minField === "*" || hourField === "*") return null; + + const minutes = minField.split(",").map((n) => parseInt(n, 10)); + const hours = hourField.split(",").map((n) => parseInt(n, 10)); + if (minutes.length !== 1 || hours.length !== 1) return null; + if ( + !Number.isFinite(minutes[0]) || + !Number.isFinite(hours[0]) || + hours[0] < 0 || + hours[0] > 23 || + minutes[0] < 0 || + minutes[0] > 59 + ) { + return null; + } + const time = `${pad2(hours[0])}:${pad2(minutes[0])}`; + + const domAll = domField === "*"; + const dowAll = dowField === "*"; + + if (domAll && dowAll) { + return { mode: "daily", time }; + } + + if (domAll && !dowAll) { + const weekdays: Weekday[] = []; + for (const part of dowField.split(",")) { + const day = parseInt(part, 10); + if (!Number.isFinite(day) || day < 0 || day > 7) return null; + const normalized = (day === 7 ? 0 : day) as Weekday; + if (!weekdays.includes(normalized)) weekdays.push(normalized); + } + if (weekdays.length === 0) return null; + return { mode: "weekly", time, weekdays }; + } + + if (!domAll && dowAll) { + if (!/^\d+$/.test(domField)) return null; + const dom = parseInt(domField, 10); + if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null; + return { mode: "monthly", time, dayOfMonth: dom }; + } + + return null; +} + function parseTimeOfDay(value: string): { hour: number; minute: number } | null { if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null; const [hh, mm] = value.split(":"); @@ -273,71 +401,26 @@ function describeCronExpression( expr: string, strings: ScheduleDescribeStrings, ): string | null { - const parts = expr.trim().split(/\s+/); - if (parts.length !== 5) return null; - const [minField, hourField, domField, monField, dowField] = parts; + const parsed = parseSimpleCronExpression(expr); + if (!parsed) return null; - const month = monField === "*"; - if (!month) return null; // we don't try to humanize per-month rules - - const isLiteralOrList = (f: string) => - /^\d+(,\d+)*$/.test(f) || /^\*$/.test(f); - if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null; - if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null; - - // Star minutes/hours would mean "every minute" / "every hour" — we'd - // need a step-value handler ("*/15") to describe that cleanly, and - // that path is power-user territory. Bail to raw display. - if (minField === "*" || hourField === "*") return null; - - const minutes = minField.split(",").map((n) => parseInt(n, 10)); - const hours = hourField.split(",").map((n) => parseInt(n, 10)); - if (minutes.length !== 1 || hours.length !== 1) return null; - if ( - !Number.isFinite(minutes[0]) || - !Number.isFinite(hours[0]) || - hours[0] < 0 || - hours[0] > 23 || - minutes[0] < 0 || - minutes[0] > 59 - ) { - return null; - } - const time = `${pad2(hours[0])}:${pad2(minutes[0])}`; - - const domAll = domField === "*"; - const dowAll = dowField === "*"; - - if (domAll && dowAll) { - return strings.dailyAt.replace("{time}", time); + if (parsed.mode === "daily") { + return strings.dailyAt.replace("{time}", parsed.time); } - if (domAll && !dowAll) { - const days = dowField - .split(",") - .map((n) => parseInt(n, 10)) - .filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[]; - if (days.length === 0) return null; - const labels = days + if (parsed.mode === "weekly") { + const labels = (parsed.weekdays ?? []) .map((d) => strings.weekdaysShort[d]) .filter(Boolean) .join(", "); return strings.weeklyAt .replace("{days}", labels) - .replace("{time}", time); + .replace("{time}", parsed.time); } - if (!domAll && dowAll) { - const dom = parseInt(domField, 10); - if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null; - return strings.monthlyAt - .replace("{day}", strings.ordinal(dom)) - .replace("{time}", time); - } - - // Both day-of-month AND day-of-week set is unusual and cron's - // OR-semantics for that combo are confusing — fall back to raw. - return null; + return strings.monthlyAt + .replace("{day}", strings.ordinal(parsed.dayOfMonth ?? 1)) + .replace("{time}", parsed.time); } function pad2(n: number): string { diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index d69af7cbaf8..dda3db7871e 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -6,7 +6,20 @@ 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, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api"; +import type { + CronJob, + CronDeliveryTarget, + ModelOptionsResponse, + ProfileInfo, + SkillInfo, + ToolsetInfo, +} from "@/lib/api"; +import { + buildCronJobPayload, + cronJobHasExecutionContent, + cronJobFormFromJob, + type CronJobFormState, +} from "@/lib/cron-job"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { DEFAULT_SCHEDULE_STATE, @@ -16,6 +29,7 @@ import { buildScheduleString, describeSchedule, englishOrdinal, + parseScheduleString, type ScheduleBuilderState, type ScheduleDescribeStrings, } from "@/lib/schedule"; @@ -53,14 +67,7 @@ function getJobPrompt(job: CronJob): string { return asText(job.prompt); } -/** Compact multi-select for attaching skills to a cron job. - * - * A checkbox list (native inputs — the `onValueChange` rule is Select-only) - * capped to a scrollable box. Skills already on the job but missing from the - * available list (e.g. removed from disk, or the job was created via CLI in - * another profile) are still rendered so saving doesn't silently drop them. - */ -function SkillsPicker({ +function NameCheckboxPicker({ id, available, selected, @@ -68,12 +75,12 @@ function SkillsPicker({ emptyLabel, }: { id: string; - available: SkillInfo[]; + available: Array<{ name: string; description?: string | null }>; selected: string[]; - onChange: (skills: string[]) => void; + onChange: (names: string[]) => void; emptyLabel: string; }) { - const names = available.map((s) => s.name); + const names = available.map((item) => item.name); const orphaned = selected.filter((s) => !names.includes(s)); const all = [...orphaned.map((name) => ({ name, description: "" })), ...available]; @@ -91,25 +98,332 @@ function SkillsPicker({ id={id} className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1" > - {all.map((skill) => ( + {all.map((item) => ( ))} ); } +interface CronJobEditorState extends CronJobFormState { + scheduleState: ScheduleBuilderState; +} + +interface CronJobFormResources { + availableSkills: SkillInfo[]; + availableToolsets: ToolsetInfo[]; + modelOptions: ModelOptionsResponse | null; + deliveryTargets: CronDeliveryTarget[]; +} + +function emptyCronJobForm(): CronJobEditorState { + return { + name: "", + prompt: "", + schedule: "", + deliver: "local", + skills: [], + provider: "", + model: "", + base_url: "", + script: "", + no_agent: false, + context_from: "", + enabled_toolsets: [], + workdir: "", + scheduleState: { ...DEFAULT_SCHEDULE_STATE }, + }; +} + +function editorFormFromJob(job: CronJob): CronJobEditorState { + const form = cronJobFormFromJob(job); + return { ...form, scheduleState: parseScheduleString(form.schedule) }; +} + +function buildCronJobPayloadFromEditor(form: CronJobEditorState) { + const { scheduleState, ...payloadForm } = form; + return buildCronJobPayload({ + ...payloadForm, + schedule: buildScheduleString(scheduleState), + }); +} + +function selectOptions( + current: string, + options: Array<{ value: string; label: string }>, +) { + const known = new Set(options.map((option) => option.value)); + return [ + ...options.map((option) => ( + + {option.label} + + )), + ...(current && !known.has(current) + ? [ + + {current} + , + ] + : []), + ]; +} + +function CronAdvancedFields({ + idPrefix, + form, + onChange, + modelOptions, + availableToolsets, +}: { + idPrefix: string; + form: CronJobEditorState; + onChange: (form: CronJobEditorState) => void; + modelOptions: ModelOptionsResponse | null; + availableToolsets: ToolsetInfo[]; +}) { + const update = ( + key: K, + next: CronJobEditorState[K], + ) => { + onChange({ ...form, [key]: next }); + }; + + const providers = (modelOptions?.providers ?? []).filter( + (p) => p.authenticated !== false, + ); + const selectedProvider = providers.find((p) => p.slug === form.provider); + const models = selectedProvider?.models ?? []; + + return ( +
+ + Advanced fields + +
+
+
+ + +
+
+ + +
+
+ +
+ + update("base_url", e.target.value)} + /> +
+ +
+ +
+ + update("script", e.target.value)} + placeholder="relative/path/in/scripts" + /> +
+
+ +
+ + update("workdir", e.target.value)} + placeholder="/absolute/project/path" + /> +
+ +
+
+ +