mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +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
134
cron/jobs.py
134
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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -471,7 +471,8 @@ export const api = {
|
|||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
||||
getModelOptions: () => fetchJSON<ModelOptionsResponse>("/api/model/options"),
|
||||
getModelOptions: (profile?: string) =>
|
||||
fetchJSON<ModelOptionsResponse>(`/api/model/options${profileQuery(profile)}`),
|
||||
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
|
||||
getMoaModels: () => fetchJSON<MoaConfigResponse>("/api/model/moa"),
|
||||
saveMoaModels: (body: MoaConfigResponse) =>
|
||||
|
|
@ -529,7 +530,7 @@ export const api = {
|
|||
fetchJSON<CronJob[]>(`/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<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -539,7 +540,7 @@ export const api = {
|
|||
fetchJSON<CronJob>(`/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<CronJob>(
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
123
web/src/lib/cron-job.test.ts
Normal file
123
web/src/lib/cron-job.test.ts
Normal file
|
|
@ -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> = {}): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
99
web/src/lib/cron-job.ts
Normal file
99
web/src/lib/cron-job.ts
Normal file
|
|
@ -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<CronJobMutation, "prompt" | "skills" | "script">,
|
||||
): 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 : "",
|
||||
};
|
||||
}
|
||||
123
web/src/lib/schedule.test.ts
Normal file
123
web/src/lib/schedule.test.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<label
|
||||
key={skill.name}
|
||||
key={item.name}
|
||||
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
|
||||
title={skill.description || undefined}
|
||||
title={item.description || undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-foreground"
|
||||
checked={selected.includes(skill.name)}
|
||||
onChange={(e) => toggle(skill.name, e.target.checked)}
|
||||
checked={selected.includes(item.name)}
|
||||
onChange={(e) => toggle(item.name, e.target.checked)}
|
||||
/>
|
||||
<span className="font-mono-ui truncate">{skill.name}</span>
|
||||
<span className="font-mono-ui truncate">{item.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<SelectOption key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectOption>
|
||||
)),
|
||||
...(current && !known.has(current)
|
||||
? [
|
||||
<SelectOption key={current} value={current}>
|
||||
{current}
|
||||
</SelectOption>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function CronAdvancedFields({
|
||||
idPrefix,
|
||||
form,
|
||||
onChange,
|
||||
modelOptions,
|
||||
availableToolsets,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
form: CronJobEditorState;
|
||||
onChange: (form: CronJobEditorState) => void;
|
||||
modelOptions: ModelOptionsResponse | null;
|
||||
availableToolsets: ToolsetInfo[];
|
||||
}) {
|
||||
const update = <K extends keyof CronJobEditorState,>(
|
||||
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 (
|
||||
<details className="border border-border bg-background/30 p-3" open>
|
||||
<summary className="cursor-pointer text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Advanced fields
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-provider`}>Provider</Label>
|
||||
<Select
|
||||
id={`${idPrefix}-provider`}
|
||||
value={form.provider}
|
||||
onValueChange={(v) => {
|
||||
onChange({ ...form, provider: v, model: "" });
|
||||
}}
|
||||
>
|
||||
<SelectOption value="">Default</SelectOption>
|
||||
{selectOptions(
|
||||
form.provider,
|
||||
providers.map((p) => ({ value: p.slug, label: p.name })),
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-model`}>Model</Label>
|
||||
<Select
|
||||
id={`${idPrefix}-model`}
|
||||
value={form.model}
|
||||
onValueChange={(v) => update("model", v)}
|
||||
>
|
||||
<SelectOption value="">Default</SelectOption>
|
||||
{selectOptions(
|
||||
form.model,
|
||||
models.map((model) => ({ value: model, label: model })),
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-base-url`}>Base URL override</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-base-url`}
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={form.base_url}
|
||||
onChange={(e) => update("base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-foreground"
|
||||
checked={form.no_agent}
|
||||
onChange={(e) => update("no_agent", e.target.checked)}
|
||||
/>
|
||||
no_agent: run the script only and deliver stdout verbatim
|
||||
</label>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-script`}>Script</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-script`}
|
||||
value={form.script}
|
||||
onChange={(e) => update("script", e.target.value)}
|
||||
placeholder="relative/path/in/scripts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-workdir`}>Workdir</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-workdir`}
|
||||
value={form.workdir}
|
||||
onChange={(e) => update("workdir", e.target.value)}
|
||||
placeholder="/absolute/project/path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-context-from`}>context_from job IDs</Label>
|
||||
<textarea
|
||||
id={`${idPrefix}-context-from`}
|
||||
className="flex min-h-[64px] w-full border border-border bg-background/40 px-3 py-2 text-xs font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder="one job id per line"
|
||||
value={form.context_from}
|
||||
onChange={(e) => update("context_from", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-toolsets`}>enabled_toolsets</Label>
|
||||
<NameCheckboxPicker
|
||||
id={`${idPrefix}-toolsets`}
|
||||
available={availableToolsets}
|
||||
selected={form.enabled_toolsets}
|
||||
onChange={(v) => update("enabled_toolsets", v)}
|
||||
emptyLabel="No toolsets available."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface CronJobFormFieldsProps {
|
||||
idPrefix: string;
|
||||
autoFocus?: boolean;
|
||||
form: CronJobEditorState;
|
||||
resources: CronJobFormResources;
|
||||
onChange: (form: CronJobEditorState) => void;
|
||||
}
|
||||
|
||||
function CronJobFormFields({
|
||||
idPrefix,
|
||||
autoFocus,
|
||||
form,
|
||||
resources,
|
||||
onChange,
|
||||
}: CronJobFormFieldsProps) {
|
||||
const { t } = useI18n();
|
||||
const { availableSkills, availableToolsets, deliveryTargets, modelOptions } = resources;
|
||||
const update = <K extends keyof CronJobEditorState,>(
|
||||
key: K,
|
||||
next: CronJobEditorState[K],
|
||||
) => {
|
||||
onChange({ ...form, [key]: next });
|
||||
};
|
||||
const onlyLocalAvailable =
|
||||
deliveryTargets.filter((target) => target.id !== "local").length === 0;
|
||||
|
||||
const deliveryOptions = selectOptions(
|
||||
form.deliver,
|
||||
deliveryTargets.map((target) => {
|
||||
const base = target.id === "local" ? t.cron.delivery.local : target.name;
|
||||
if (target.id !== "local" && !target.home_target_set) {
|
||||
const hint = t.cron.delivery.needsHomeChannel ?? "set a home channel first";
|
||||
return { value: target.id, label: `${base} — ${hint}` };
|
||||
}
|
||||
return { value: target.id, label: base };
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-name`}>{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-name`}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={form.name}
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-prompt`}>{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id={`${idPrefix}-prompt`}
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={form.prompt}
|
||||
onChange={(e) => update("prompt", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScheduleBuilder
|
||||
value={form.scheduleState}
|
||||
onChange={(state) => update("scheduleState", state)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-deliver`}>{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id={`${idPrefix}-deliver`}
|
||||
value={form.deliver}
|
||||
onValueChange={(v) => update("deliver", v)}
|
||||
>
|
||||
{deliveryOptions}
|
||||
</Select>
|
||||
{onlyLocalAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.cron.delivery.noneConfigured ??
|
||||
"No messaging platforms configured. Set one up under Channels to deliver reports."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-skills`}>Skills (optional)</Label>
|
||||
<NameCheckboxPicker
|
||||
id={`${idPrefix}-skills`}
|
||||
available={availableSkills}
|
||||
selected={form.skills}
|
||||
onChange={(skills) => update("skills", skills)}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected skills are loaded before the prompt runs — the cron
|
||||
sets when, the skill sets how.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CronAdvancedFields
|
||||
idPrefix={`${idPrefix}-advanced`}
|
||||
form={form}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
availableToolsets={availableToolsets}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getJobName(job: CronJob): string {
|
||||
return asText(job.name).trim();
|
||||
}
|
||||
|
|
@ -148,6 +462,26 @@ function getJobState(job: CronJob): string {
|
|||
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
|
||||
}
|
||||
|
||||
function getRepeatDisplay(job: CronJob): string {
|
||||
const repeat = job.repeat;
|
||||
if (!repeat || repeat.times == null) return "forever";
|
||||
const completed = repeat.completed ?? 0;
|
||||
return completed > 0 ? `${completed}/${repeat.times}` : `${repeat.times} times`;
|
||||
}
|
||||
|
||||
function getJobMode(job: CronJob): string {
|
||||
if (job.no_agent) return "no_agent";
|
||||
if (job.script) return "script+agent";
|
||||
return "agent";
|
||||
}
|
||||
|
||||
function getModelDisplay(job: CronJob): string {
|
||||
const provider = asText(job.provider);
|
||||
const model = asText(job.model);
|
||||
if (provider && model) return `${provider}/${model}`;
|
||||
return model || provider;
|
||||
}
|
||||
|
||||
function getJobProfile(job: CronJob): string {
|
||||
return asText(job.profile) || asText(job.profile_name) || "default";
|
||||
}
|
||||
|
|
@ -201,36 +535,25 @@ export default function CronPage() {
|
|||
|
||||
// New job modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// The schedule is now constructed via the ScheduleBuilder; we keep
|
||||
// the full builder state so flipping between modes during edit
|
||||
// doesn't erase the user's intermediate inputs. The actual string
|
||||
// sent to the backend is derived via ``buildScheduleString`` at
|
||||
// submit time.
|
||||
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
const [createProfile, setCreateProfile] = useState("default");
|
||||
const [createForm, setCreateForm] = useState<CronJobEditorState>(
|
||||
emptyCronJobForm,
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [jobSkills, setJobSkills] = useState<string[]>([]);
|
||||
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
|
||||
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
|
||||
]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
|
||||
|
||||
// Edit job modal state
|
||||
const [editJob, setEditJob] = useState<CronJob | null>(null);
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const [editSchedule, setEditSchedule] = useState("");
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDeliver, setEditDeliver] = useState("local");
|
||||
const [editSkills, setEditSkills] = useState<string[]>([]);
|
||||
const [editForm, setEditForm] = useState<CronJobEditorState>(
|
||||
emptyCronJobForm,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const closeEditModal = useCallback(() => setEditJob(null), []);
|
||||
const editModalRef = useModalBehavior({
|
||||
|
|
@ -243,16 +566,14 @@ export default function CronPage() {
|
|||
// Keyed on the create-modal profile; the edit modal reuses the list —
|
||||
// a job's current skills are always shown even if not in it.
|
||||
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
|
||||
const [availableToolsets, setAvailableToolsets] = useState<ToolsetInfo[]>([]);
|
||||
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
|
||||
|
||||
const resourceProfile = editJob ? getJobProfile(editJob) : createProfile;
|
||||
|
||||
const openEditModal = useCallback((job: CronJob) => {
|
||||
setEditJob(job);
|
||||
setEditPrompt(getJobPrompt(job));
|
||||
setEditSchedule(
|
||||
asText(job.schedule?.expr) || asText(job.schedule_display) || "",
|
||||
);
|
||||
setEditName(getJobName(job));
|
||||
setEditDeliver(asText(job.deliver) || "local");
|
||||
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
|
||||
setEditForm(editorFormFromJob(job));
|
||||
}, []);
|
||||
|
||||
const loadJobs = useCallback(() => {
|
||||
|
|
@ -286,102 +607,44 @@ export default function CronPage() {
|
|||
loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
// Load installed skills for the profile new jobs will be created under.
|
||||
// "" / "default" maps to the dashboard's own profile via the optional
|
||||
// ?profile= scoping on /api/skills.
|
||||
// Load resources from the profile the create/edit form actually targets.
|
||||
// Pass "default" explicitly so the global dashboard profile switch cannot
|
||||
// redirect a default-profile cron form to some other profile.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkills(createProfile === "default" ? undefined : createProfile)
|
||||
.then((s) => {
|
||||
if (!cancelled)
|
||||
setAvailableSkills(
|
||||
[...s].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
})
|
||||
.catch(() => !cancelled && setAvailableSkills([]));
|
||||
Promise.all([
|
||||
api.getSkills(resourceProfile).catch(() => []),
|
||||
api.getToolsets(resourceProfile).catch(() => []),
|
||||
api.getModelOptions(resourceProfile).catch(() => null),
|
||||
]).then(([skills, toolsets, options]) => {
|
||||
if (cancelled) return;
|
||||
setAvailableSkills([...skills].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setAvailableToolsets([...toolsets].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setModelOptions(options);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [createProfile]);
|
||||
|
||||
const scheduleString = buildScheduleString(scheduleState);
|
||||
|
||||
// Label for a delivery option. Configured platforms missing their cron home
|
||||
// channel are still offered (option B), annotated so the user knows what to
|
||||
// fix rather than wondering why delivery silently no-ops.
|
||||
const deliverLabel = useCallback(
|
||||
(target: CronDeliveryTarget): string => {
|
||||
const base = target.id === "local" ? t.cron.delivery.local : target.name;
|
||||
if (target.id !== "local" && !target.home_target_set) {
|
||||
const hint = t.cron.delivery.needsHomeChannel ?? "set a home channel first";
|
||||
return `${base} — ${hint}`;
|
||||
}
|
||||
return base;
|
||||
},
|
||||
[t.cron.delivery],
|
||||
);
|
||||
|
||||
const renderDeliverOptions = useCallback(
|
||||
() =>
|
||||
deliveryTargets.map((target) => (
|
||||
<SelectOption key={target.id} value={target.id}>
|
||||
{deliverLabel(target)}
|
||||
</SelectOption>
|
||||
)),
|
||||
[deliveryTargets, deliverLabel],
|
||||
);
|
||||
|
||||
// The edit modal must always show the job's current target, even if that
|
||||
// platform is no longer configured (e.g. job created via CLI, or the
|
||||
// gateway was later removed) — otherwise the value would silently vanish
|
||||
// from the dropdown and saving would drop it.
|
||||
const renderEditDeliverOptions = useCallback(
|
||||
(current: string) => {
|
||||
const known = new Set(deliveryTargets.map((target) => target.id));
|
||||
const options = deliveryTargets.map((target) => (
|
||||
<SelectOption key={target.id} value={target.id}>
|
||||
{deliverLabel(target)}
|
||||
</SelectOption>
|
||||
));
|
||||
if (current && !known.has(current)) {
|
||||
options.push(
|
||||
<SelectOption key={current} value={current}>
|
||||
{current}
|
||||
</SelectOption>,
|
||||
);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
[deliveryTargets, deliverLabel],
|
||||
);
|
||||
|
||||
const onlyLocalAvailable =
|
||||
deliveryTargets.filter((target) => target.id !== "local").length === 0;
|
||||
}, [resourceProfile]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!prompt.trim() || !scheduleString) {
|
||||
const payload = buildCronJobPayloadFromEditor(createForm);
|
||||
if (
|
||||
!payload.schedule ||
|
||||
(!payload.no_agent && !cronJobHasExecutionContent(payload))
|
||||
) {
|
||||
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
||||
return;
|
||||
}
|
||||
if (payload.no_agent && !payload.script) {
|
||||
showToast("no_agent jobs require a script", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createCronJob(
|
||||
{
|
||||
prompt: prompt.trim(),
|
||||
schedule: scheduleString,
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
skills: jobSkills.length > 0 ? jobSkills : undefined,
|
||||
},
|
||||
createProfile,
|
||||
);
|
||||
await api.createCronJob(payload, createProfile);
|
||||
showToast(t.common.create + " ✓", "success");
|
||||
setPrompt("");
|
||||
setScheduleState(DEFAULT_SCHEDULE_STATE);
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
setJobSkills([]);
|
||||
setCreateForm(emptyCronJobForm());
|
||||
setCreateModalOpen(false);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
|
|
@ -393,21 +656,23 @@ export default function CronPage() {
|
|||
|
||||
const handleEdit = async () => {
|
||||
if (!editJob) return;
|
||||
if (!editPrompt.trim() || !editSchedule.trim()) {
|
||||
const payload = buildCronJobPayloadFromEditor(editForm);
|
||||
if (
|
||||
!payload.schedule ||
|
||||
(!payload.no_agent && !cronJobHasExecutionContent(payload))
|
||||
) {
|
||||
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
||||
return;
|
||||
}
|
||||
if (payload.no_agent && !payload.script) {
|
||||
showToast("no_agent jobs require a script", "error");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateCronJob(
|
||||
editJob.id,
|
||||
{
|
||||
prompt: editPrompt.trim(),
|
||||
schedule: editSchedule.trim(),
|
||||
name: editName.trim(),
|
||||
deliver: editDeliver,
|
||||
skills: editSkills,
|
||||
},
|
||||
payload,
|
||||
getJobProfile(editJob),
|
||||
);
|
||||
showToast("Saved changes ✓", "success");
|
||||
|
|
@ -483,7 +748,10 @@ export default function CronPage() {
|
|||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
onClick={() => {
|
||||
setCreateProfile(selectedProfile === "all" ? "default" : selectedProfile);
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
|
|
@ -491,7 +759,7 @@ export default function CronPage() {
|
|||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
}, [setEnd, t.common.create, loading, selectedProfile]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -552,7 +820,7 @@ export default function CronPage() {
|
|||
aria-modal="true"
|
||||
aria-labelledby="create-cron-title"
|
||||
>
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[90vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
|
@ -572,13 +840,13 @@ export default function CronPage() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="min-h-0 overflow-y-auto p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-profile">Profile</Label>
|
||||
<Select
|
||||
id="cron-profile"
|
||||
value={createProfile}
|
||||
onValueChange={(v) => setSelectedProfile(v)}
|
||||
onValueChange={(v) => setCreateProfile(v)}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<SelectOption key={profile.name} value={profile.name}>
|
||||
|
|
@ -588,65 +856,19 @@ export default function CronPage() {
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="cron-name"
|
||||
autoFocus
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="cron-prompt"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScheduleBuilder
|
||||
value={scheduleState}
|
||||
onChange={setScheduleState}
|
||||
<CronJobFormFields
|
||||
idPrefix="cron"
|
||||
autoFocus
|
||||
form={createForm}
|
||||
onChange={setCreateForm}
|
||||
resources={{
|
||||
availableSkills,
|
||||
availableToolsets,
|
||||
modelOptions,
|
||||
deliveryTargets,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
{renderDeliverOptions()}
|
||||
</Select>
|
||||
{onlyLocalAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.cron.delivery.noneConfigured ??
|
||||
"No messaging platforms configured. Set one up under Channels to deliver reports."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-skills">Skills (optional)</Label>
|
||||
<SkillsPicker
|
||||
id="cron-skills"
|
||||
available={availableSkills}
|
||||
selected={jobSkills}
|
||||
onChange={setJobSkills}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected skills are loaded before the prompt runs — the cron
|
||||
sets when, the skill sets how.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
|
|
@ -673,7 +895,7 @@ export default function CronPage() {
|
|||
aria-modal="true"
|
||||
aria-labelledby="edit-cron-title"
|
||||
>
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[90vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
|
@ -693,64 +915,24 @@ export default function CronPage() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="edit-cron-name"
|
||||
autoFocus
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto p-5 grid gap-4">
|
||||
<CronJobFormFields
|
||||
idPrefix="edit-cron"
|
||||
autoFocus
|
||||
form={editForm}
|
||||
onChange={setEditForm}
|
||||
resources={{
|
||||
availableSkills,
|
||||
availableToolsets,
|
||||
modelOptions,
|
||||
deliveryTargets,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="edit-cron-prompt"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={editPrompt}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-schedule">{t.cron.schedule}</Label>
|
||||
<Input
|
||||
id="edit-cron-schedule"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={editSchedule}
|
||||
onChange={(e) => setEditSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="edit-cron-deliver"
|
||||
value={editDeliver}
|
||||
onValueChange={(v) => setEditDeliver(v)}
|
||||
>
|
||||
{renderEditDeliverOptions(editDeliver)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-skills">Skills</Label>
|
||||
<SkillsPicker
|
||||
id="edit-cron-skills"
|
||||
available={availableSkills}
|
||||
selected={editSkills}
|
||||
onChange={setEditSkills}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground font-mono-ui truncate pr-4">
|
||||
{editJob.id}
|
||||
</span>
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
|
|
@ -810,6 +992,11 @@ export default function CronPage() {
|
|||
const deliver = asText(job.deliver);
|
||||
const profile = getJobProfile(job);
|
||||
const jobKey = getJobKey(job);
|
||||
const mode = getJobMode(job);
|
||||
const modelDisplay = getModelDisplay(job);
|
||||
const toolsets = Array.isArray(job.enabled_toolsets)
|
||||
? job.enabled_toolsets.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card key={jobKey}>
|
||||
|
|
@ -833,6 +1020,19 @@ export default function CronPage() {
|
|||
: `${job.skills.length} skills`}
|
||||
</Badge>
|
||||
)}
|
||||
{mode !== "agent" && (
|
||||
<Badge tone="outline">{mode}</Badge>
|
||||
)}
|
||||
{modelDisplay && (
|
||||
<Badge tone="outline" title={modelDisplay}>
|
||||
model
|
||||
</Badge>
|
||||
)}
|
||||
{toolsets.length > 0 && (
|
||||
<Badge tone="outline" title={toolsets.join(", ")}>
|
||||
{toolsets.length} toolsets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasName && promptText && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
|
|
@ -843,6 +1043,7 @@ export default function CronPage() {
|
|||
<span className="font-mono-ui">
|
||||
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
|
||||
</span>
|
||||
<span>repeat: {getRepeatDisplay(job)}</span>
|
||||
<span>
|
||||
{t.cron.last}: {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
|
|
@ -850,6 +1051,11 @@ export default function CronPage() {
|
|||
{t.cron.next}: {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_delivery_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
delivery: {job.last_delivery_error}
|
||||
</p>
|
||||
)}
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{job.last_error}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue