mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge PR #2033 into stage-334
This commit is contained in:
@@ -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
|
||||
|
||||
+18
-9
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
<label for="cronFormSchedule">${esc(t('cron_schedule_label') || 'Schedule')}</label>
|
||||
<input type="text" id="cronFormSchedule" value="${esc(schedule || '')}" placeholder="0 9 * * * — every 1h — @daily" autocomplete="off" required>
|
||||
<div class="detail-form-hint">${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}</div>
|
||||
<div id="cronFormScheduleOnceWarning" class="detail-form-warning cron-once-warning" style="display:none">${esc(t('cron_schedule_once_warning') || "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.")}</div>
|
||||
</div>
|
||||
<div class="detail-form-row ${isNoAgent ? 'cron-no-agent-prompt-row' : ''}">
|
||||
<label for="cronFormPrompt">${esc(t('cron_prompt_label') || 'Prompt')}</label>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user