From a27f1bf7dbb800ab630111f4e7ea973b807a47f1 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 11 May 2026 07:03:17 +0800 Subject: [PATCH] Clarify one-shot cron schedules --- CHANGELOG.md | 6 ++ static/i18n.js | 27 ++++--- static/panels.js | 27 +++++++ static/style.css | 2 + tests/test_issue2031_cron_once_visibility.py | 82 ++++++++++++++++++++ 5 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 tests/test_issue2031_cron_once_visibility.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c75a2334..d3be963b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Hermes Web UI -- Changelog +## [Unreleased] + +### Fixed + +- **bug(cron): clarify one-shot schedule deletion semantics** ([#2031](https://github.com/nesquena/hermes-webui/issues/2031)). The Scheduled Jobs form now makes the Hermes Agent cron contract visible: recurring jobs should use `every 30m` or a cron expression, while bare durations/dates such as `30m`, `2h`, or `2026-05-11T08:00` create one-shot jobs that are removed after they run. Adds a live warning under the Schedule input when the entered value matches the one-shot forms. + ## [v0.51.39] — 2026-05-10 — Release O (4-PR contributor batch — Railway docker fix + Stop-button race + provider resolver + live context tracking) ### Fixed diff --git a/static/i18n.js b/static/i18n.js index a625dcd9..2dec4953 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1011,7 +1011,8 @@ const LOCALES = { cron_name_label: 'Name', cron_name_placeholder: 'Optional', cron_schedule_label: 'Schedule', - cron_schedule_hint: "Cron expression or shorthand like 'every 1h'.", + cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", + cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", cron_prompt_label: 'Prompt', cron_deliver_label: 'Deliver output to', cron_deliver_local: 'Local (save output only)', @@ -2095,7 +2096,8 @@ const LOCALES = { cron_name_label: '名前', cron_name_placeholder: '任意', cron_schedule_label: 'スケジュール', - cron_schedule_hint: "Cron 式または 'every 1h' のような短縮形。", + cron_schedule_hint: "繰り返し実行には 'every 1h' または Cron 式を使います。'30m' のような期間だけの指定は 1 回だけ実行されます。", + cron_schedule_once_warning: "'30m' のような期間指定は 1 回だけ実行され、実行後に削除されます。繰り返すには 'every 30m' を使ってください。", cron_prompt_label: 'プロンプト', cron_deliver_label: '出力先', cron_deliver_local: 'ローカル (出力を保存のみ)', @@ -2985,7 +2987,8 @@ const LOCALES = { cron_name_label: 'Имя', cron_name_placeholder: 'Необязательно', cron_schedule_label: 'Расписание', - cron_schedule_hint: "Cron-выражение или сокращение, например 'every 1h'.", + cron_schedule_hint: "Для повторяющихся заданий используйте 'every 1h' или cron-выражение. Простые интервалы вроде '30m' выполняются один раз.", + cron_schedule_once_warning: "Интервалы вроде '30m' выполняются один раз и удаляются после запуска. Используйте 'every 30m' для повторяющегося задания.", cron_prompt_label: 'Запрос', cron_deliver_label: 'Доставлять вывод', cron_deliver_local: 'Локально (только сохранение)', @@ -3990,7 +3993,8 @@ const LOCALES = { cron_name_label: 'Nombre', cron_name_placeholder: 'Opcional', cron_schedule_label: 'Programación', - cron_schedule_hint: "Expresión cron o abreviatura como 'every 1h'.", + cron_schedule_hint: "Usa 'every 1h' o una expresión cron para trabajos recurrentes. Duraciones como '30m' se ejecutan una sola vez.", + cron_schedule_once_warning: "Las duraciones como '30m' se ejecutan una vez y se eliminan después de correr. Usa 'every 30m' para mantener un trabajo recurrente.", cron_prompt_label: 'Prompt', cron_deliver_label: 'Entregar salida a', cron_deliver_local: 'Local (solo guardar salida)', @@ -4741,7 +4745,8 @@ const LOCALES = { cron_duplicated: 'Aufgabe dupliziert (pausiert)', cron_name_placeholder: 'Optional', cron_schedule_label: 'Zeitplan', - cron_schedule_hint: "Cron-Ausdruck oder Kurzform wie 'every 1h'.", + cron_schedule_hint: "Für wiederkehrende Aufgaben 'every 1h' oder einen Cron-Ausdruck verwenden. Reine Dauern wie '30m' laufen einmal.", + cron_schedule_once_warning: "Dauerangaben wie '30m' laufen einmal und werden nach der Ausführung entfernt. Verwende 'every 30m' für eine wiederkehrende Aufgabe.", cron_prompt_label: 'Prompt', cron_deliver_label: 'Ausgabe senden an', cron_deliver_local: 'Lokal (nur speichern)', @@ -6022,7 +6027,8 @@ const LOCALES = { cron_name_label: '名称', cron_name_placeholder: '可选', cron_schedule_label: '计划', - cron_schedule_hint: "Cron 表达式或简写,例如 'every 1h'。", + cron_schedule_hint: "循环任务请用 'every 1h' 或 Cron 表达式。像 '30m' 这样的裸时长只会运行一次。", + cron_schedule_once_warning: "像 '30m' 这样的时长写法只会运行一次,并在运行后移除。要保留循环任务,请使用 'every 30m'。", cron_prompt_label: '提示词', cron_deliver_label: '输出位置', cron_deliver_local: '本地(仅保存输出)', @@ -7236,7 +7242,8 @@ const LOCALES = { // Cron labels cron_name_label: '任務名稱', cron_schedule_label: '排程', - cron_schedule_hint: '例如: 0 9 * * *, every 2h, 30m', + cron_schedule_hint: "循環任務請用 'every 1h' 或 Cron 表達式。像 '30m' 這樣的裸時長只會執行一次。", + cron_schedule_once_warning: "像 '30m' 這樣的時長寫法只會執行一次,並在執行後移除。要保留循環任務,請使用 'every 30m'。", cron_prompt_label: '提示', cron_deliver_label: '發送至', cron_deliver_local: '僅本地儲存', @@ -8142,7 +8149,8 @@ const LOCALES = { cron_name_label: 'Nome', cron_name_placeholder: 'Opcional', cron_schedule_label: 'Agendamento', - cron_schedule_hint: "Expressão Cron ou shorthand como 'every 1h'.", + cron_schedule_hint: "Use 'every 1h' ou uma expressão Cron para tarefas recorrentes. Durações como '30m' rodam uma vez.", + cron_schedule_once_warning: "Durações como '30m' rodam uma vez e são removidas após executar. Use 'every 30m' para manter uma tarefa recorrente.", cron_prompt_label: 'Prompt', cron_deliver_label: 'Entregar output para', cron_deliver_local: 'Local (salvar output apenas)', @@ -9192,7 +9200,8 @@ const LOCALES = { cron_name_label: 'Name', cron_name_placeholder: 'Optional', cron_schedule_label: 'Schedule', - cron_schedule_hint: "Cron expression or shorthand like 'every 1h'.", + cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", + cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", cron_prompt_label: 'Prompt', cron_deliver_label: 'Deliver output to', cron_deliver_local: 'Local (save output only)', diff --git a/static/panels.js b/static/panels.js index 7f73e503..86075f1b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -231,6 +231,26 @@ function _isRecurringCronJob(job) { return kind === 'cron' || kind === 'interval'; } +function _cronScheduleKindForInput(value) { + const schedule = String(value || '').trim(); + if (!schedule) return ''; + const lower = schedule.toLowerCase(); + if (lower.startsWith('every ')) return 'interval'; + if (lower.startsWith('@')) return 'cron'; + const parts = schedule.split(/\s+/); + if (parts.length >= 5 && parts.slice(0, 5).every(p => /^[\d*\-,/]+$/.test(p))) return 'cron'; + if (schedule.includes('T') || /^\d{4}-\d{2}-\d{2}/.test(schedule)) return 'once'; + if (/^\d+\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i.test(schedule)) return 'once'; + return ''; +} + +function _syncCronScheduleWarning() { + const input = $('cronFormSchedule'); + const warning = $('cronFormScheduleOnceWarning'); + if (!input || !warning) return; + warning.style.display = _cronScheduleKindForInput(input.value) === 'once' ? '' : 'none'; +} + function _hasUnlimitedRepeat(job) { return !!(job && job.repeat && job.repeat.times == null); } @@ -722,6 +742,7 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, no_agent=fa
${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}
+
@@ -759,6 +780,12 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, no_agent=fa if (empty) empty.style.display = 'none'; _setCronHeaderButtons(isEdit ? 'edit' : 'create'); _renderCronSkillTags(); + const scheduleEl = $('cronFormSchedule'); + if (scheduleEl) { + scheduleEl.addEventListener('input', _syncCronScheduleWarning); + scheduleEl.addEventListener('change', _syncCronScheduleWarning); + _syncCronScheduleWarning(); + } const focusEl = $('cronFormName'); if (focusEl) focusEl.focus(); } diff --git a/static/style.css b/static/style.css index 2e438f37..b1ce4985 100644 --- a/static/style.css +++ b/static/style.css @@ -3216,6 +3216,8 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .detail-form-row input:disabled{opacity:.6;cursor:not-allowed;} .detail-form-row textarea{resize:vertical;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;} .detail-form-row .detail-form-hint{font-size:11px;color:var(--muted);line-height:1.5;} +.detail-form-warning{font-size:11px;line-height:1.5;border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:rgba(245,158,11,.98);border-radius:8px;padding:8px 10px;} +.cron-once-warning{margin-top:2px;} .detail-form-row label.detail-form-check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer;font-weight:400;} .detail-form-row label.detail-form-check input{accent-color:var(--accent,var(--link));} .detail-form-error{font-size:12px;color:var(--error,#e05);padding:8px 10px;border:1px solid color-mix(in srgb,var(--error,#e05) 35%,transparent);background:color-mix(in srgb,var(--error,#e05) 8%,transparent);border-radius:8px;line-height:1.5;} diff --git a/tests/test_issue2031_cron_once_visibility.py b/tests/test_issue2031_cron_once_visibility.py new file mode 100644 index 00000000..9bc00aea --- /dev/null +++ b/tests/test_issue2031_cron_once_visibility.py @@ -0,0 +1,82 @@ +"""Regression coverage for #2031 one-shot cron schedule visibility.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +PANELS_JS = ROOT / "static" / "panels.js" +STYLE_CSS = ROOT / "static" / "style.css" +I18N_JS = ROOT / "static" / "i18n.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _cron_schedule_source() -> str: + src = PANELS_JS.read_text(encoding="utf-8") + start = src.find("function _cronScheduleKindForInput") + if start < 0: + pytest.fail("_cronScheduleKindForInput is missing") + end = src.find("function _hasUnlimitedRepeat", start) + if end < 0: + pytest.fail("_cronScheduleKindForInput must stay near the cron schedule helpers") + return src[start:end] + + +def _run_node(script: str) -> str: + proc = subprocess.run( + [NODE, "-e", script], + check=True, + capture_output=True, + text=True, + ) + return proc.stdout.strip() + + +def test_cron_schedule_input_classifier_flags_agent_one_shot_forms(): + script = _cron_schedule_source() + r""" +const cases = { + "30m": _cronScheduleKindForInput("30m"), + "2h": _cronScheduleKindForInput("2h"), + "1 day": _cronScheduleKindForInput("1 day"), + "2026-05-11": _cronScheduleKindForInput("2026-05-11"), + "2026-05-11T08:00": _cronScheduleKindForInput("2026-05-11T08:00"), + "every 30m": _cronScheduleKindForInput("every 30m"), + "Every 2h": _cronScheduleKindForInput("Every 2h"), + "0 9 * * *": _cronScheduleKindForInput("0 9 * * *"), + "not_a_schedule": _cronScheduleKindForInput("not_a_schedule"), +}; +console.log(JSON.stringify(cases)); +""" + kinds = json.loads(_run_node(script)) + + assert kinds["30m"] == "once" + assert kinds["2h"] == "once" + assert kinds["1 day"] == "once" + assert kinds["2026-05-11"] == "once" + assert kinds["2026-05-11T08:00"] == "once" + assert kinds["every 30m"] == "interval" + assert kinds["Every 2h"] == "interval" + assert kinds["0 9 * * *"] == "cron" + assert kinds["not_a_schedule"] == "" + + +def test_cron_form_surfaces_one_shot_warning_copy_and_styles(): + panels = PANELS_JS.read_text(encoding="utf-8") + style = STYLE_CSS.read_text(encoding="utf-8") + i18n = I18N_JS.read_text(encoding="utf-8") + + assert "id=\"cronFormScheduleOnceWarning\"" in panels + assert "cron_schedule_once_warning" in panels + assert "_syncCronScheduleWarning" in panels + assert "addEventListener('input', _syncCronScheduleWarning" in panels + assert "addEventListener('change', _syncCronScheduleWarning" in panels + + assert ".cron-once-warning" in style + assert i18n.count("cron_schedule_once_warning") >= 9 + assert "Duration forms like '30m' run once" in i18n