feat: cron run status tracking and watch mode (#526)

Backend:
- Track running cron jobs in thread-safe dict (job_id → start_time)
- Wrapper _run_cron_tracked() marks done on completion
- New GET /api/crons/status?job_id=... returns {running, elapsed}
- New GET /api/crons/status returns all running jobs

Frontend:
- After 'Run Now', enters watch mode with 3s polling
- Shows running indicator (spinner + elapsed timer) in detail card
- Auto-detects running jobs when opening detail view
- Stops watch and refreshes output on job completion
- Cleanup on detail view switch

Note: True SSE streaming is not possible because the hermes-agent
scheduler writes output files only on completion. This polling
approach provides real-time status feedback within that constraint.
This commit is contained in:
bergeouss authored and Hermes Agent committed 2026-04-29 04:32:00 +00:00
1 parent f2f7224b8d
commit 98ed2d804b
4 files changed
+133 -4

No files matched your search

+51 -2
View File
@@ -18,6 +18,38 @@ from urllib.parse import parse_qs
logger = logging.getLogger(__name__)
# ── Cron run tracking ────────────────────────────────────────────────────────
# Track job IDs currently being executed so the frontend can poll status.
_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp
_RUNNING_CRON_LOCK = threading.Lock()
def _mark_cron_running(job_id: str):
with _RUNNING_CRON_LOCK:
_RUNNING_CRON_JOBS[job_id] = time.time()
def _mark_cron_done(job_id: str):
with _RUNNING_CRON_LOCK:
_RUNNING_CRON_JOBS.pop(job_id, None)
def _is_cron_running(job_id: str) -> tuple[bool, float]:
"""Return (is_running, elapsed_seconds)."""
with _RUNNING_CRON_LOCK:
t = _RUNNING_CRON_JOBS.get(job_id)
if t is None:
return False, 0.0
return True, time.time() - t
def _run_cron_tracked(job):
"""Wrapper that tracks running state around cron.scheduler.run_job."""
try:
run_job(job)
finally:
_mark_cron_done(job.get("id", ""))
_PROVIDER_ALIASES = {
"claude": "anthropic",
"gpt": "openai",
@@ -1115,6 +1147,9 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/crons/recent":
return _handle_cron_recent(handler, parsed)
if parsed.path == "/api/crons/status":
return _handle_cron_status(handler, parsed)
# ── Skills API (GET) ──
if parsed.path == "/api/skills":
from tools.skills_tool import skills_list as _skills_list
@@ -2640,6 +2675,19 @@ def _handle_cron_output(handler, parsed):
return j(handler, {"job_id": job_id, "outputs": outputs})
def _handle_cron_status(handler, parsed):
"""Return running status for one or all cron jobs."""
qs = parse_qs(parsed.query)
job_id = qs.get("job_id", [""])[0]
if job_id:
running, elapsed = _is_cron_running(job_id)
return j(handler, {"job_id": job_id, "running": running, "elapsed": round(elapsed, 1)})
# Return status for all running jobs
with _RUNNING_CRON_LOCK:
all_running = {jid: round(time.time() - t, 1) for jid, t in _RUNNING_CRON_JOBS.items()}
return j(handler, {"running": all_running})
def _handle_cron_recent(handler, parsed):
"""Return cron jobs that have completed since a given timestamp."""
import datetime
@@ -3114,8 +3162,9 @@ def _handle_cron_run(handler, body):
job = get_job(job_id)
if not job:
return bad(handler, "Job not found", 404)
threading.Thread(target=run_job, args=(job,), daemon=True).start()
return j(handler, {"ok": True, "job_id": job_id, "status": "triggered"})
_mark_cron_running(job_id)
threading.Thread(target=_run_cron_tracked, args=(job,), daemon=True).start()
return j(handler, {"ok": True, "job_id": job_id, "status": "running"})
def _handle_cron_pause(handler, body):
+1 -1
View File
@@ -4150,7 +4150,7 @@ const LOCALES = {
cron_status_paused: 'paused',
cron_status_error: 'error',
cron_status_active: 'active',
cron_status_running: 'running\u2026',
cron_status_running: '\uc2e4\ud589 \uc911\u2026',
cron_status_needs_attention: '주의 필요',
cron_attention_desc: '이 반복 작업에 다음 실행 시간이 없습니다. 스케줄러가 다음 실행을 계산하지 못했을 수 있습니다.',
cron_attention_croniter_hint: 'Gateway 런타임에 croniter 패키지가 없을 수 있습니다. cron 지원이 포함된 환경으로 Gateway를 재시작한 후 이 작업을 재개하세요.',
+75 -1
View File
@@ -430,7 +430,9 @@ function openCronDetail(id, el){
if (dot) dot.remove();
_cronPreFormDetail = null;
_editingCronId = null;
_stopCronWatch();
_renderCronDetail(job);
_checkCronWatchOnDetail(id);
}
function _clearCronDetail(){
@@ -708,11 +710,83 @@ function _cronOutputSnippet(content) {
return body.slice(0, 600) || '(empty)';
}
// ── Cron run watch ────────────────────────────────────────────────────────────
let _cronWatchInterval = null;
let _cronWatchStart = null;
let _cronWatchTimerInterval = null;
function _startCronWatch(jobId) {
_stopCronWatch();
_cronWatchStart = Date.now();
_cronWatchInterval = setInterval(async () => {
try {
const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`);
if (!data.running) {
_stopCronWatch();
if (_currentCronDetail && _currentCronDetail.id === jobId) {
_loadCronDetailRuns(jobId);
}
return;
}
// Still running — update elapsed
if (_currentCronDetail && _currentCronDetail.id === jobId) {
const el = $('cronRunningIndicator');
if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed(data.elapsed);
}
} catch(e) { /* ignore poll errors */ }
}, 3000);
// Timer update every second
_cronWatchTimerInterval = setInterval(() => {
if (_currentCronDetail && _cronWatchStart) {
const el = $('cronRunningIndicator');
if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed((Date.now() - _cronWatchStart) / 1000);
}
}, 1000);
// Inject running indicator into detail card
if (_currentCronDetail && _currentCronDetail.id === jobId) {
_injectRunningIndicator();
}
}
function _stopCronWatch() {
if (_cronWatchInterval) { clearInterval(_cronWatchInterval); _cronWatchInterval = null; }
if (_cronWatchTimerInterval) { clearInterval(_cronWatchTimerInterval); _cronWatchTimerInterval = null; }
_cronWatchStart = null;
const el = $('cronRunningIndicator');
if (el) el.remove();
}
function _injectRunningIndicator() {
const card = $('cronDetailRuns');
if (!card || $('cronRunningIndicator')) return;
const div = document.createElement('div');
div.id = 'cronRunningIndicator';
div.className = 'cron-running-indicator';
div.innerHTML = `<span class="cron-watch-spinner"></span><span>${esc(t('cron_status_running'))}</span><span class="cron-watch-elapsed">0s</span>`;
card.insertAdjacentElement('beforebegin', div);
}
function _formatElapsed(seconds) {
if (seconds < 60) return Math.round(seconds) + 's';
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return m + 'm ' + s + 's';
}
function _checkCronWatchOnDetail(jobId) {
// When opening a detail view, check if job is running
api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => {
if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) {
_startCronWatch(jobId);
}
}).catch(() => {});
}
async function cronRun(id) {
try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast(t('cron_job_triggered'));
setTimeout(() => { if (_currentCronDetail && _currentCronDetail.id === id) _loadCronDetailRuns(id); }, 5000);
_startCronWatch(id);
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
}
+6
View File
@@ -2292,6 +2292,12 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-alert p{margin:0 0 8px;font-size:13px;line-height:1.45;color:var(--text);}
.detail-alert p:last-child{margin-bottom:0;}
.detail-alert-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
/* Cron running indicator */
.cron-running-indicator{display:flex;align-items:center;gap:8px;padding:10px 14px;margin-bottom:12px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.3);border-radius:8px;font-size:13px;color:var(--text);}
.cron-watch-spinner{width:14px;height:14px;border:2px solid rgba(59,130,246,.3);border-top-color:rgba(59,130,246,.9);border-radius:50%;animation:cron-spin .8s linear infinite;flex-shrink:0;}
@keyframes cron-spin{to{transform:rotate(360deg)}}
.cron-watch-elapsed{color:var(--muted);font-variant-numeric:tabular-nums;margin-left:auto;}
.detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;max-height:240px;overflow-y:auto;}
.detail-run-item{border-top:1px solid var(--border);padding:8px 0;}
.detail-run-item:first-child{border-top:none;}