mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
Stage 298: PR #1674 — feat(tasks): add scheduled job profile selector by @Michaelyklam
This commit is contained in:
@@ -258,6 +258,24 @@ class cron_profile_context_for_home:
|
||||
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("cron_profile_context_for_home: cron.jobs unavailable")
|
||||
|
||||
# cron.scheduler snapshots _hermes_home at import time and run_job()
|
||||
# reads config/.env from that module global. Patch it alongside
|
||||
# cron.jobs so manual WebUI runs actually execute under the selected
|
||||
# profile, not merely write output metadata there (#617).
|
||||
self._prev_cs = None
|
||||
try:
|
||||
import cron.scheduler as _cs
|
||||
self._prev_cs = (
|
||||
getattr(_cs, '_hermes_home', None),
|
||||
getattr(_cs, '_LOCK_DIR', None),
|
||||
getattr(_cs, '_LOCK_FILE', None),
|
||||
)
|
||||
_cs._hermes_home = self._home
|
||||
_cs._LOCK_DIR = self._home / 'cron'
|
||||
_cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("cron_profile_context_for_home: cron.scheduler unavailable")
|
||||
except Exception:
|
||||
_cron_env_lock.release()
|
||||
raise
|
||||
@@ -275,6 +293,12 @@ class cron_profile_context_for_home:
|
||||
_cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
if getattr(self, '_prev_cs', None) is not None:
|
||||
try:
|
||||
import cron.scheduler as _cs
|
||||
_cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
finally:
|
||||
_cron_env_lock.release()
|
||||
return False
|
||||
@@ -313,6 +337,20 @@ class cron_profile_context:
|
||||
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("cron_profile_context: cron.jobs unavailable; env-var only")
|
||||
|
||||
self._prev_cs = None
|
||||
try:
|
||||
import cron.scheduler as _cs
|
||||
self._prev_cs = (
|
||||
getattr(_cs, '_hermes_home', None),
|
||||
getattr(_cs, '_LOCK_DIR', None),
|
||||
getattr(_cs, '_LOCK_FILE', None),
|
||||
)
|
||||
_cs._hermes_home = home
|
||||
_cs._LOCK_DIR = home / 'cron'
|
||||
_cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("cron_profile_context: cron.scheduler unavailable; env-var only")
|
||||
except Exception:
|
||||
_cron_env_lock.release()
|
||||
raise
|
||||
@@ -333,6 +371,12 @@ class cron_profile_context:
|
||||
_cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
if getattr(self, '_prev_cs', None) is not None:
|
||||
try:
|
||||
import cron.scheduler as _cs
|
||||
_cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
finally:
|
||||
_cron_env_lock.release()
|
||||
return False
|
||||
@@ -462,6 +506,14 @@ def _set_hermes_home(home: Path):
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("Failed to patch cron.jobs module")
|
||||
|
||||
try:
|
||||
import cron.scheduler as _cs
|
||||
_cs._hermes_home = home
|
||||
_cs._LOCK_DIR = home / 'cron'
|
||||
_cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock'
|
||||
except (ImportError, AttributeError):
|
||||
logger.debug("Failed to patch cron.scheduler module")
|
||||
|
||||
|
||||
def _reload_dotenv(home: Path):
|
||||
"""Load .env from the profile dir into os.environ with profile isolation.
|
||||
|
||||
+113
-32
@@ -245,52 +245,119 @@ def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIM
|
||||
return text[-limit:]
|
||||
|
||||
|
||||
def _run_cron_tracked(job, profile_home=None):
|
||||
|
||||
|
||||
def _cron_job_for_api(job: dict) -> dict:
|
||||
"""Return a cron job payload with the #617 optional profile field present.
|
||||
|
||||
Legacy jobs intentionally persist without ``profile`` so they keep the
|
||||
scheduler's server-default behavior. The API still returns ``profile: None``
|
||||
so the UI can label that state explicitly instead of guessing.
|
||||
"""
|
||||
payload = dict(job or {})
|
||||
payload.setdefault("profile", None)
|
||||
return payload
|
||||
|
||||
|
||||
def _cron_jobs_for_api(jobs) -> list[dict]:
|
||||
return [_cron_job_for_api(job) for job in (jobs or [])]
|
||||
|
||||
|
||||
def _available_cron_profile_names() -> set[str]:
|
||||
from api.profiles import list_profiles_api
|
||||
|
||||
names = {"default"}
|
||||
for profile in list_profiles_api():
|
||||
try:
|
||||
name = str(profile.get("name") or "").strip()
|
||||
except AttributeError:
|
||||
continue
|
||||
if name:
|
||||
names.add(name)
|
||||
return names
|
||||
|
||||
|
||||
def _normalize_cron_profile_value(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
profile = str(value).strip()
|
||||
if not profile:
|
||||
return None
|
||||
if profile not in _available_cron_profile_names():
|
||||
raise ValueError(f"Unknown profile: {profile}")
|
||||
return profile
|
||||
|
||||
|
||||
def _profile_home_for_cron_job(job: dict):
|
||||
"""Resolve the execution profile for a cron job, with graceful fallback.
|
||||
|
||||
A missing/blank profile preserves legacy server-default behavior. If a job
|
||||
points at a profile that was deleted after save, fall back to the active
|
||||
server profile and log a warning instead of crashing the Run Now path.
|
||||
"""
|
||||
from api.profiles import get_active_hermes_home, get_hermes_home_for_profile
|
||||
|
||||
raw = str((job or {}).get("profile") or "").strip()
|
||||
if not raw:
|
||||
return get_active_hermes_home()
|
||||
if raw not in _available_cron_profile_names():
|
||||
logger.warning(
|
||||
"Cron job %s references missing profile %r; falling back to server default",
|
||||
(job or {}).get("id", "?"), raw,
|
||||
)
|
||||
return get_active_hermes_home()
|
||||
return get_hermes_home_for_profile(raw)
|
||||
|
||||
|
||||
def _run_cron_tracked(job, profile_home=None, execution_profile_home=None):
|
||||
"""Wrapper that tracks running state around cron.scheduler.run_job.
|
||||
|
||||
``profile_home`` pins HERMES_HOME for this worker thread so output files
|
||||
and run metadata land in the profile that triggered the run, not the
|
||||
process-global default. Captured at dispatch time because the thread runs
|
||||
after the HTTP request (and its TLS profile) has already been cleared.
|
||||
``profile_home`` is the cron store that owns the job row/output metadata.
|
||||
``execution_profile_home`` is the selected per-job profile used to load
|
||||
agent config/.env while running. When no job profile is selected, both homes
|
||||
are the same and legacy server-default behavior is preserved.
|
||||
"""
|
||||
from cron.scheduler import run_job # import here — runs inside a worker thread
|
||||
from cron.jobs import mark_job_run, save_job_output
|
||||
|
||||
job_id = job.get("id", "")
|
||||
execution_profile_home = execution_profile_home or profile_home
|
||||
|
||||
# Pin HERMES_HOME for the duration of this thread using a dedicated
|
||||
# context manager variant that accepts the profile home directly
|
||||
# (threads have no TLS, so get_active_hermes_home() can't resolve).
|
||||
ctx = None
|
||||
if profile_home is not None:
|
||||
def _with_cron_home(home, fn):
|
||||
if home is None:
|
||||
return fn()
|
||||
from api.profiles import cron_profile_context_for_home
|
||||
|
||||
ctx = cron_profile_context_for_home(profile_home)
|
||||
ctx.__enter__()
|
||||
with cron_profile_context_for_home(home):
|
||||
return fn()
|
||||
|
||||
try:
|
||||
success, output, final_response, error = run_job(job)
|
||||
save_job_output(job_id, output)
|
||||
success, output, final_response, error = _with_cron_home(
|
||||
execution_profile_home, lambda: run_job(job)
|
||||
)
|
||||
|
||||
# Match the scheduled cron path: an apparently successful run with no
|
||||
# final response should not leave the job looking healthy.
|
||||
if success and not final_response:
|
||||
success = False
|
||||
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
|
||||
# Persist output and run metadata back to the job's owning cron store,
|
||||
# even when the selected execution profile is different.
|
||||
def _persist_success():
|
||||
save_job_output(job_id, output)
|
||||
|
||||
mark_job_run(job_id, success, error)
|
||||
# Match the scheduled cron path: an apparently successful run with no
|
||||
# final response should not leave the job looking healthy.
|
||||
_success, _error = success, error
|
||||
if _success and not final_response:
|
||||
_success = False
|
||||
_error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
|
||||
|
||||
mark_job_run(job_id, _success, _error)
|
||||
|
||||
_with_cron_home(profile_home, _persist_success)
|
||||
except Exception as e:
|
||||
logger.exception("Manual cron run failed for job %s", job_id)
|
||||
try:
|
||||
mark_job_run(job_id, False, str(e))
|
||||
_with_cron_home(profile_home, lambda: mark_job_run(job_id, False, str(e)))
|
||||
except Exception:
|
||||
logger.debug("Failed to mark manual cron run failure for %s", job_id)
|
||||
finally:
|
||||
if ctx is not None:
|
||||
try:
|
||||
ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
logger.debug("Failed to release cron_profile_context for %s", job_id)
|
||||
_mark_cron_done(job_id)
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
@@ -2612,7 +2679,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
from api.profiles import cron_profile_context
|
||||
|
||||
with cron_profile_context():
|
||||
return j(handler, {"jobs": list_jobs(include_disabled=True)})
|
||||
return j(handler, {"jobs": _cron_jobs_for_api(list_jobs(include_disabled=True))})
|
||||
|
||||
if parsed.path == "/api/crons/output":
|
||||
from api.profiles import cron_profile_context
|
||||
@@ -5729,8 +5796,9 @@ def _handle_cron_create(handler, body):
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
from cron.jobs import create_job
|
||||
from cron.jobs import create_job, update_job
|
||||
|
||||
profile = _normalize_cron_profile_value(body.get("profile"))
|
||||
job = create_job(
|
||||
prompt=body["prompt"],
|
||||
schedule=body["schedule"],
|
||||
@@ -5739,7 +5807,9 @@ def _handle_cron_create(handler, body):
|
||||
skills=body.get("skills") or [],
|
||||
model=body.get("model") or None,
|
||||
)
|
||||
return j(handler, {"ok": True, "job": job})
|
||||
if profile is not None:
|
||||
job = update_job(job["id"], {"profile": profile}) or job
|
||||
return j(handler, {"ok": True, "job": _cron_job_for_api(job)})
|
||||
except Exception as e:
|
||||
return j(handler, {"error": str(e)}, status=400)
|
||||
|
||||
@@ -5751,11 +5821,21 @@ def _handle_cron_update(handler, body):
|
||||
return bad(handler, str(e))
|
||||
from cron.jobs import update_job
|
||||
|
||||
updates = {k: v for k, v in body.items() if k != "job_id" and v is not None}
|
||||
try:
|
||||
updates = {}
|
||||
for k, v in body.items():
|
||||
if k == "job_id":
|
||||
continue
|
||||
if k == "profile":
|
||||
updates[k] = _normalize_cron_profile_value(v)
|
||||
elif v is not None:
|
||||
updates[k] = v
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
job = update_job(body["job_id"], updates)
|
||||
if not job:
|
||||
return bad(handler, "Job not found", 404)
|
||||
return j(handler, {"ok": True, "job": job})
|
||||
return j(handler, {"ok": True, "job": _cron_job_for_api(job)})
|
||||
|
||||
|
||||
def _handle_cron_delete(handler, body):
|
||||
@@ -5801,7 +5881,8 @@ def _handle_cron_run(handler, body):
|
||||
from api.profiles import get_active_hermes_home
|
||||
|
||||
_profile_home = get_active_hermes_home()
|
||||
threading.Thread(target=_run_cron_tracked, args=(job, _profile_home), daemon=True).start()
|
||||
_execution_profile_home = _profile_home_for_cron_job(job)
|
||||
threading.Thread(target=_run_cron_tracked, args=(job, _profile_home, _execution_profile_home), daemon=True).start()
|
||||
return j(handler, {"ok": True, "job_id": job_id, "status": "running"})
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -904,6 +904,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'Prompt',
|
||||
cron_deliver_label: 'Deliver output to',
|
||||
cron_deliver_local: 'Local (save output only)',
|
||||
cron_profile_label: 'Profile',
|
||||
cron_profile_server_default: 'server default',
|
||||
cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.',
|
||||
cron_skills_label: 'Skills',
|
||||
cron_skills_placeholder: 'Add skills (optional)…',
|
||||
cron_skills_edit_hint: 'Skill list is not editable after creation.',
|
||||
@@ -1874,6 +1877,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'プロンプト',
|
||||
cron_deliver_label: '出力先',
|
||||
cron_deliver_local: 'ローカル (出力を保存のみ)',
|
||||
cron_profile_label: 'プロフィール',
|
||||
cron_profile_server_default: 'サーバーデフォルト',
|
||||
cron_profile_server_default_hint: '実行時に WebUI サーバーのデフォルトプロフィールを使用します。プロフィールのない既存ジョブはこの従来の動作を維持します。',
|
||||
cron_skills_label: 'スキル',
|
||||
cron_skills_placeholder: 'スキルを追加 (任意)…',
|
||||
cron_skills_edit_hint: 'スキル一覧は作成後に編集できません。',
|
||||
@@ -2657,6 +2663,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'Запрос',
|
||||
cron_deliver_label: 'Доставлять вывод',
|
||||
cron_deliver_local: 'Локально (только сохранение)',
|
||||
cron_profile_label: 'Профиль',
|
||||
cron_profile_server_default: 'по умолчанию сервера',
|
||||
cron_profile_server_default_hint: 'Использует профиль WebUI-сервера по умолчанию во время запуска. Существующие задания без профиля сохраняют это поведение.',
|
||||
cron_skills_label: 'Навыки',
|
||||
cron_skills_placeholder: 'Добавить навыки (необязательно)…',
|
||||
cron_skills_edit_hint: 'Список навыков нельзя изменить после создания.',
|
||||
@@ -3549,6 +3558,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'Prompt',
|
||||
cron_deliver_label: 'Entregar salida a',
|
||||
cron_deliver_local: 'Local (solo guardar salida)',
|
||||
cron_profile_label: 'Perfil',
|
||||
cron_profile_server_default: 'predeterminado del servidor',
|
||||
cron_profile_server_default_hint: 'Usa el perfil predeterminado del servidor WebUI durante la ejecución. Los trabajos existentes sin perfil conservan este comportamiento heredado.',
|
||||
cron_skills_label: 'Habilidades',
|
||||
cron_skills_placeholder: 'Añadir habilidades (opcional)…',
|
||||
cron_skills_edit_hint: 'La lista de habilidades no es editable después de crear.',
|
||||
@@ -4186,6 +4198,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'Prompt',
|
||||
cron_deliver_label: 'Ausgabe senden an',
|
||||
cron_deliver_local: 'Lokal (nur speichern)',
|
||||
cron_profile_label: 'Profil',
|
||||
cron_profile_server_default: 'Serverstandard',
|
||||
cron_profile_server_default_hint: 'Verwendet zur Laufzeit das Standardprofil des WebUI-Servers. Bestehende Jobs ohne Profil behalten dieses Legacy-Verhalten.',
|
||||
cron_skills_label: 'Fähigkeiten',
|
||||
cron_skills_placeholder: 'Fähigkeiten hinzufügen (optional)…',
|
||||
cron_skills_edit_hint: 'Die Fähigkeitenliste kann nach der Erstellung nicht bearbeitet werden.',
|
||||
@@ -5355,6 +5370,9 @@ const LOCALES = {
|
||||
cron_prompt_label: '提示词',
|
||||
cron_deliver_label: '输出位置',
|
||||
cron_deliver_local: '本地(仅保存输出)',
|
||||
cron_profile_label: '配置档',
|
||||
cron_profile_server_default: '服务器默认',
|
||||
cron_profile_server_default_hint: '运行时使用 WebUI 服务器默认配置档。没有配置档的现有作业会保留此旧行为。',
|
||||
cron_skills_label: '技能',
|
||||
cron_skills_placeholder: '添加技能(可选)…',
|
||||
cron_skills_edit_hint: '创建后无法再编辑技能列表。',
|
||||
@@ -6405,6 +6423,9 @@ const LOCALES = {
|
||||
cron_prompt_label: '提示',
|
||||
cron_deliver_label: '發送至',
|
||||
cron_deliver_local: '僅本地儲存',
|
||||
cron_profile_label: '設定檔',
|
||||
cron_profile_server_default: '伺服器預設',
|
||||
cron_profile_server_default_hint: '執行時使用 WebUI 伺服器預設設定檔。沒有設定檔的既有工作會保留此舊行為。',
|
||||
cron_skills_label: '技能',
|
||||
cron_skills_placeholder: '選用技能(逗號分隔)',
|
||||
cron_skills_edit_hint: '定義要載入的技能',
|
||||
@@ -7215,6 +7236,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'Prompt',
|
||||
cron_deliver_label: 'Entregar output para',
|
||||
cron_deliver_local: 'Local (salvar output apenas)',
|
||||
cron_profile_label: 'Perfil',
|
||||
cron_profile_server_default: 'padrão do servidor',
|
||||
cron_profile_server_default_hint: 'Usa o perfil padrão do servidor WebUI no momento da execução. Tarefas existentes sem perfil mantêm esse comportamento legado.',
|
||||
cron_deliver_origin: 'Origem (mesmo chat)',
|
||||
cron_deliver_telegram: 'Telegram',
|
||||
cron_deliver_discord: 'Discord',
|
||||
@@ -8153,6 +8177,9 @@ const LOCALES = {
|
||||
cron_prompt_label: 'Prompt',
|
||||
cron_deliver_label: 'Deliver output to',
|
||||
cron_deliver_local: 'Local (save output only)',
|
||||
cron_profile_label: 'Profile',
|
||||
cron_profile_server_default: 'server default',
|
||||
cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.',
|
||||
cron_skills_label: 'Skills',
|
||||
cron_skills_placeholder: 'Add skills (optional)…',
|
||||
cron_skills_edit_hint: 'Skill list is not editable after creation.',
|
||||
|
||||
+77
-4
@@ -270,6 +270,58 @@ function _cronStatusMeta(job) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function _cronProfileName(profile){
|
||||
return (profile || '').toString().trim();
|
||||
}
|
||||
|
||||
function _cronProfileLabel(profile){
|
||||
const name = _cronProfileName(profile);
|
||||
return name || (t('cron_profile_server_default') || 'server default');
|
||||
}
|
||||
|
||||
function _cronProfileTitle(profile){
|
||||
const name = _cronProfileName(profile);
|
||||
if (name) return (t('cron_profile_label') || 'Profile') + ': ' + name;
|
||||
return t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time';
|
||||
}
|
||||
|
||||
async function loadCronProfiles(){
|
||||
if (_cronProfilesCache) return _cronProfilesCache;
|
||||
try {
|
||||
const data = await api('/api/profiles');
|
||||
_cronProfilesCache = Array.isArray(data.profiles) ? data.profiles : [];
|
||||
} catch(e) {
|
||||
_cronProfilesCache = [];
|
||||
}
|
||||
return _cronProfilesCache;
|
||||
}
|
||||
|
||||
function _cronProfileOptions(selected){
|
||||
const current = _cronProfileName(selected);
|
||||
const profiles = Array.isArray(_cronProfilesCache) ? _cronProfilesCache : [];
|
||||
const seen = new Set(['']);
|
||||
const opts = [`<option value=""${current ? '' : ' selected'}>${esc(t('cron_profile_server_default') || 'server default')}</option>`];
|
||||
for (const p of profiles) {
|
||||
const name = _cronProfileName(p && p.name);
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
const label = p && p.is_default ? `${name} (${t('default') || 'default'})` : name;
|
||||
opts.push(`<option value="${esc(name)}"${current === name ? ' selected' : ''}>${esc(label)}</option>`);
|
||||
}
|
||||
if (current && !seen.has(current)) {
|
||||
opts.push(`<option value="${esc(current)}" selected>${esc(current)} (${esc(t('not_available') || 'not available')})</option>`);
|
||||
}
|
||||
return opts.join('');
|
||||
}
|
||||
|
||||
function _refreshCronProfileSelect(selected){
|
||||
const sel = $('cronFormProfile');
|
||||
if (!sel) return;
|
||||
const keep = selected === undefined ? sel.value : selected;
|
||||
sel.innerHTML = _cronProfileOptions(keep);
|
||||
}
|
||||
|
||||
function _cronDiagnostics(job) {
|
||||
const fields = {
|
||||
id: job.id,
|
||||
@@ -297,6 +349,7 @@ async function loadCrons(animate) {
|
||||
refreshBtn.disabled = true;
|
||||
}
|
||||
try {
|
||||
await loadCronProfiles();
|
||||
const data = await api('/api/crons');
|
||||
_cronList = data.jobs || [];
|
||||
if (!_cronList.length) {
|
||||
@@ -311,10 +364,13 @@ async function loadCrons(animate) {
|
||||
item.id = 'cron-' + job.id;
|
||||
const status = _cronStatusMeta(job);
|
||||
const isNewRun = _cronNewJobIds.has(String(job.id));
|
||||
const profileLabel = _cronProfileLabel(job.profile);
|
||||
const profileTitle = _cronProfileTitle(job.profile);
|
||||
item.innerHTML = `
|
||||
<div class="cron-header">
|
||||
${isNewRun ? '<span class="cron-new-dot" title="New run"></span>' : ''}
|
||||
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
|
||||
<span class="cron-profile-badge" title="${esc(profileTitle)}">${esc(profileLabel)}</span>
|
||||
<span class="cron-status ${status.listClass}">${esc(status.label)}</span>
|
||||
</div>`;
|
||||
item.onclick = () => openCronDetail(job.id, item);
|
||||
@@ -349,6 +405,8 @@ function _renderCronDetail(job){
|
||||
const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || '';
|
||||
const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—';
|
||||
const deliver = job.deliver || 'local';
|
||||
const profileLabel = _cronProfileLabel(job.profile);
|
||||
const profileTitle = _cronProfileTitle(job.profile);
|
||||
const lastError = job.last_error ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
|
||||
const attention = status.state === 'needs_attention' || status.state === 'schedule_error';
|
||||
const croniterHint = job.last_error && /croniter/i.test(job.last_error)
|
||||
@@ -375,6 +433,7 @@ function _renderCronDetail(job){
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Deliver</div><div class="detail-row-value">${esc(deliver)}</div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_profile_label') || 'Profile')}</div><div class="detail-row-value"><span class="detail-badge active" title="${esc(profileTitle)}">${esc(profileLabel)}</span></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(skills)}</div></div>
|
||||
${lastError}
|
||||
</div>
|
||||
@@ -557,6 +616,7 @@ function duplicateCurrentCron(){
|
||||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || 'local',
|
||||
profile: job.profile || '',
|
||||
isEdit: false,
|
||||
});
|
||||
if (!_cronSkillsCache) {
|
||||
@@ -581,6 +641,7 @@ async function deleteCurrentCron(){
|
||||
let _cronSelectedSkills=[];
|
||||
let _cronIsDuplicate = false;
|
||||
let _cronSkillsCache=null;
|
||||
let _cronProfilesCache=null;
|
||||
|
||||
function openCronCreate(){
|
||||
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
||||
@@ -589,9 +650,10 @@ function openCronCreate(){
|
||||
_cronMode = 'create';
|
||||
_cronIsDuplicate = false;
|
||||
_cronSelectedSkills = [];
|
||||
_renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', isEdit:false });
|
||||
_renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', isEdit:false });
|
||||
_cronSkillsCache = null;
|
||||
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
|
||||
loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{});
|
||||
}
|
||||
|
||||
function openCronEdit(job){
|
||||
@@ -605,6 +667,7 @@ function openCronEdit(job){
|
||||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || 'local',
|
||||
profile: job.profile || '',
|
||||
isEdit: true,
|
||||
});
|
||||
if (!_cronSkillsCache) {
|
||||
@@ -612,9 +675,10 @@ function openCronEdit(job){
|
||||
} else {
|
||||
_bindCronSkillPicker();
|
||||
}
|
||||
loadCronProfiles().then(()=>_refreshCronProfileSelect(job.profile || '')).catch(()=>{});
|
||||
}
|
||||
|
||||
function _renderCronForm({ name, schedule, prompt, deliver, isEdit }){
|
||||
function _renderCronForm({ name, schedule, prompt, deliver, profile, isEdit }){
|
||||
const title = $('taskDetailTitle');
|
||||
const body = $('taskDetailBody');
|
||||
const empty = $('taskDetailEmpty');
|
||||
@@ -645,6 +709,13 @@ function _renderCronForm({ name, schedule, prompt, deliver, isEdit }){
|
||||
${deliverOpt('telegram','Telegram')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="detail-form-row">
|
||||
<label for="cronFormProfile">${esc(t('cron_profile_label') || 'Profile')}</label>
|
||||
<select id="cronFormProfile">
|
||||
${_cronProfileOptions(profile)}
|
||||
</select>
|
||||
<div class="detail-form-hint">${esc(t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time')}</div>
|
||||
</div>
|
||||
<div class="detail-form-row">
|
||||
<label for="cronFormSkillSearch">${esc(t('cron_skills_label') || 'Skills')}</label>
|
||||
<div class="skill-picker-wrap">
|
||||
@@ -729,18 +800,20 @@ async function saveCronForm(){
|
||||
const schEl=$('cronFormSchedule');
|
||||
const promptEl=$('cronFormPrompt');
|
||||
const delivEl=$('cronFormDeliver');
|
||||
const profileEl=$('cronFormProfile');
|
||||
const errEl=$('cronFormError');
|
||||
if(!schEl||!promptEl||!errEl) return;
|
||||
const name=(nameEl?nameEl.value:'').trim();
|
||||
const schedule=schEl.value.trim();
|
||||
const prompt=promptEl.value.trim();
|
||||
const deliver=delivEl?delivEl.value:'local';
|
||||
const profile=profileEl?profileEl.value:'';
|
||||
errEl.style.display='none';
|
||||
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
|
||||
if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}
|
||||
try{
|
||||
if (_editingCronId) {
|
||||
const updates = {job_id: _editingCronId, schedule, prompt};
|
||||
const updates = {job_id: _editingCronId, schedule, prompt, profile: profile};
|
||||
if (name) updates.name = name;
|
||||
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
|
||||
const editedId = _editingCronId;
|
||||
@@ -752,7 +825,7 @@ async function saveCronForm(){
|
||||
if (job) openCronDetail(editedId);
|
||||
return;
|
||||
}
|
||||
const body={schedule,prompt,deliver};
|
||||
const body={schedule,prompt,deliver,profile: profile};
|
||||
if(_cronIsDuplicate) body.enabled=false;
|
||||
if(name)body.name=name;
|
||||
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
|
||||
|
||||
@@ -645,6 +645,7 @@
|
||||
.cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;}
|
||||
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;}
|
||||
.cron-profile-badge{font-size:10px;font-weight:650;padding:2px 7px;border-radius:99px;flex-shrink:0;max-width:92px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:1px solid var(--border);color:var(--muted);background:var(--surface-subtle);}
|
||||
.cron-status.active{background:rgba(34,197,94,.15);color:var(--success);}
|
||||
.cron-status.paused{background:var(--accent-bg-strong);color:var(--accent-text);}
|
||||
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Regression coverage for issue #617 scheduled-job profile selection."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class _JSONHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers = {}
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _payload(handler):
|
||||
return json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
def test_cron_api_serializes_legacy_profile_as_explicit_server_default():
|
||||
from api.routes import _cron_job_for_api
|
||||
|
||||
legacy = {"id": "legacy", "name": "Legacy job"}
|
||||
payload = _cron_job_for_api(legacy)
|
||||
|
||||
assert payload["profile"] is None
|
||||
assert "profile" not in legacy, "API serialization must not mutate stored legacy jobs"
|
||||
|
||||
|
||||
def test_cron_profile_value_validates_against_existing_profiles(monkeypatch):
|
||||
import api.profiles as profiles
|
||||
from api.routes import _normalize_cron_profile_value
|
||||
|
||||
monkeypatch.setattr(
|
||||
profiles,
|
||||
"list_profiles_api",
|
||||
lambda: [
|
||||
{"name": "default"},
|
||||
{"name": "research"},
|
||||
],
|
||||
)
|
||||
|
||||
assert _normalize_cron_profile_value(" research ") == "research"
|
||||
assert _normalize_cron_profile_value("") is None
|
||||
assert _normalize_cron_profile_value(None) is None
|
||||
with pytest.raises(ValueError, match="Unknown profile: missing"):
|
||||
_normalize_cron_profile_value("missing")
|
||||
|
||||
|
||||
def test_cron_create_api_persists_profile_and_returns_it(monkeypatch):
|
||||
import api.profiles as profiles
|
||||
import api.routes as routes
|
||||
|
||||
created = {
|
||||
"id": "job617",
|
||||
"name": "Profiled job",
|
||||
"prompt": "ping",
|
||||
"schedule": {"kind": "interval", "minutes": 60},
|
||||
}
|
||||
updated = {**created, "profile": "research"}
|
||||
calls = []
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or dict(created)
|
||||
cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or dict(updated)
|
||||
|
||||
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}])
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_create(
|
||||
handler,
|
||||
{
|
||||
"name": "Profiled job",
|
||||
"prompt": "ping",
|
||||
"schedule": "every 60m",
|
||||
"deliver": "local",
|
||||
"profile": "research",
|
||||
},
|
||||
)
|
||||
|
||||
body = _payload(handler)
|
||||
assert handler.status == 200
|
||||
assert body["ok"] is True
|
||||
assert body["job"]["profile"] == "research"
|
||||
assert calls[0][0] == "create"
|
||||
assert calls[1] == ("update", "job617", {"profile": "research"})
|
||||
|
||||
|
||||
def test_cron_create_api_rejects_unknown_profile_before_persisting(monkeypatch):
|
||||
import api.profiles as profiles
|
||||
import api.routes as routes
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.create_job = lambda **kwargs: pytest.fail("invalid profiles must not create jobs")
|
||||
cron_jobs.update_job = lambda *args, **kwargs: pytest.fail("invalid profiles must not update jobs")
|
||||
|
||||
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}])
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_create(
|
||||
handler,
|
||||
{"prompt": "ping", "schedule": "every 60m", "profile": "missing"},
|
||||
)
|
||||
|
||||
assert handler.status == 400
|
||||
assert "Unknown profile: missing" in _payload(handler)["error"]
|
||||
|
||||
|
||||
def test_cron_update_api_accepts_profile_clear_and_rejects_unknown(monkeypatch):
|
||||
import api.profiles as profiles
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
|
||||
def update_job(job_id, updates):
|
||||
calls.append((job_id, updates))
|
||||
return {"id": job_id, "name": "Updated", **updates}
|
||||
|
||||
cron_jobs.update_job = update_job
|
||||
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "research"}])
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_update(handler, {"job_id": "job617", "profile": ""})
|
||||
assert handler.status == 200
|
||||
assert _payload(handler)["job"]["profile"] is None
|
||||
assert calls == [("job617", {"profile": None})]
|
||||
|
||||
bad_handler = _JSONHandler()
|
||||
routes._handle_cron_update(bad_handler, {"job_id": "job617", "profile": "ghost"})
|
||||
assert bad_handler.status == 400
|
||||
assert "Unknown profile: ghost" in _payload(bad_handler)["error"]
|
||||
assert calls == [("job617", {"profile": None})]
|
||||
|
||||
|
||||
def test_manual_cron_run_uses_execution_profile_but_persists_to_owning_store(monkeypatch):
|
||||
import api.profiles as profiles
|
||||
import api.routes as routes
|
||||
|
||||
events = []
|
||||
|
||||
class Ctx:
|
||||
def __init__(self, home):
|
||||
self.home = str(home)
|
||||
|
||||
def __enter__(self):
|
||||
events.append(("enter", self.home))
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
events.append(("exit", self.home))
|
||||
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.save_job_output = lambda job_id, output: events.append(("save", job_id, output))
|
||||
cron_jobs.mark_job_run = lambda job_id, success, error=None: events.append(("mark", job_id, success, error))
|
||||
cron_scheduler = types.ModuleType("cron.scheduler")
|
||||
cron_scheduler.run_job = lambda job: events.append(("run", job["id"])) or (True, "output", "final", None)
|
||||
|
||||
monkeypatch.setattr(profiles, "cron_profile_context_for_home", Ctx)
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler)
|
||||
|
||||
routes._mark_cron_running("job617")
|
||||
routes._run_cron_tracked(
|
||||
{"id": "job617"},
|
||||
profile_home="/hermes/default",
|
||||
execution_profile_home="/hermes/profiles/research",
|
||||
)
|
||||
|
||||
assert events == [
|
||||
("enter", "/hermes/profiles/research"),
|
||||
("run", "job617"),
|
||||
("exit", "/hermes/profiles/research"),
|
||||
("enter", "/hermes/default"),
|
||||
("save", "job617", "output"),
|
||||
("mark", "job617", True, None),
|
||||
("exit", "/hermes/default"),
|
||||
]
|
||||
assert routes._is_cron_running("job617") == (False, 0.0)
|
||||
|
||||
|
||||
def test_cron_profile_selector_source_hooks_present():
|
||||
panels = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
css = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
i18n = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "async function loadCronProfiles()" in panels
|
||||
assert "api('/api/profiles')" in panels
|
||||
assert "id=\"cronFormProfile\"" in panels
|
||||
assert "profile: profile" in panels
|
||||
assert "job.profile" in panels
|
||||
assert "cron-profile-badge" in panels
|
||||
assert ".cron-profile-badge" in css
|
||||
assert "cron_profile_server_default" in i18n
|
||||
assert "cron_profile_server_default_hint" in i18n
|
||||
@@ -215,7 +215,7 @@ def test_cron_worker_does_not_silently_fall_back_on_profile_context_failure():
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
|
||||
idx = src.find("def _run_cron_tracked(job, profile_home=None):")
|
||||
idx = src.find("def _run_cron_tracked(job")
|
||||
assert idx != -1, "_run_cron_tracked not found"
|
||||
body = src[idx : idx + 2000]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user