feat(dashboard): expose cron job execution fields

This commit is contained in:
Versun 2026-06-27 15:57:50 +08:00 committed by Teknium
parent 50f6855217
commit c655cdf2c1
9 changed files with 1589 additions and 366 deletions

View file

@ -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"])

View file

@ -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:

View file

@ -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

View file

@ -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 {

View 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
View 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 : "",
};
}

View 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);
}
});
});

View file

@ -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 {

View file

@ -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}