From 31b755fb68aba9dfcbd2122c1f7ede7fb9dfa7f5 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Thu, 9 Jul 2026 02:34:48 -0700 Subject: [PATCH] fix(desktop): show script-only cron run history --- .../app/chat/sidebar/cron-jobs-section.tsx | 39 +++-- apps/desktop/src/app/cron/index.tsx | 42 +++-- hermes_cli/web_server.py | 150 ++++++++++++++++++ tests/hermes_cli/test_web_server.py | 122 ++++++++++++++ 4 files changed, 327 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index e6fb6fda71b92..c8769466b6988 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -56,6 +56,10 @@ function formatRunTime(seconds?: null | number): string { return Number.isNaN(date.valueOf()) ? '—' : fmtDayTime.format(date) } +function isSyntheticCronOutputRun(run: SessionInfo): boolean { + return run.source === 'cron_output' +} + interface SidebarCronJobsSectionProps { jobs: CronJob[] label: string @@ -311,19 +315,28 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s ) : ( <> {runs.map(run => ( - + isSyntheticCronOutputRun(run) ? ( +
+ {formatRunTime(run.last_active || run.started_at)} +
+ ) : ( + + ) ))} )} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index a3d229ac5af53..ffbc62602cf93 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -610,6 +610,10 @@ function formatRunTime(seconds?: null | number): string { return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString() } +function isSyntheticCronOutputRun(run: SessionInfo): boolean { + return run.source === 'cron_output' +} + // Runs are produced by the background scheduler tick (no UI signal), so poll // while the panel is open + on tab re-focus so a fired run shows up within a few // seconds instead of waiting for a reload. @@ -679,19 +683,31 @@ function CronJobRuns({
{c.noRuns}
) : (
- {runs.map(run => ( - - ))} + {runs.map(run => + isSyntheticCronOutputRun(run) ? ( +
+ {run.title?.trim() || run.preview?.trim() || run.id} + + {formatRunTime(run.last_active || run.started_at)} + +
+ ) : ( + + ) + )}
)} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2ba21a8ed959a..c8e73ca1da609 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -10185,6 +10185,153 @@ async def get_cron_job(job_id: str, profile: Optional[str] = None): return await _run_cron_dashboard_io(_get_cron_job_sync, job_id, profile) +_CRON_OUTPUT_FILENAME_FORMAT = "%Y-%m-%d_%H-%M-%S" + + +def _cron_output_runs_dir(profile: Optional[str], job_id: str) -> Path: + if profile: + try: + _, profile_home = _cron_profile_home(profile) + except Exception: + profile_home = get_hermes_home() + else: + profile_home = get_hermes_home() + return Path(profile_home) / "cron" / "output" / job_id + + +def _cron_output_run_timestamp(path: Path) -> Optional[float]: + try: + naive = datetime.strptime(path.stem, _CRON_OUTPUT_FILENAME_FORMAT) + except ValueError: + return None + return naive.replace(tzinfo=datetime.now().astimezone().tzinfo).timestamp() + + +def _cron_output_run_preview(path: Path, max_chars: int = 180) -> str: + try: + raw = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + preview = re.sub(r"\s+", " ", raw).strip() + if len(preview) <= max_chars: + return preview + return preview[: max_chars - 1].rstrip() + "…" + + +def _cron_job_last_run_timestamp(job: Optional[Dict[str, Any]]) -> Optional[float]: + if not isinstance(job, dict): + return None + raw = job.get("last_run_at") + if isinstance(raw, (int, float)): + return float(raw) + if isinstance(raw, str): + text = raw.strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +def _cron_output_status_label(job: Optional[Dict[str, Any]]) -> str: + if not isinstance(job, dict): + return "" + status = str(job.get("last_status") or "").strip() + if not status: + return "" + return status.replace("_", " ").upper() + + +def _list_cron_output_runs( + job: Optional[Dict[str, Any]], + canonical_job_id: str, + profile: Optional[str], + limit: int, +) -> List[Dict[str, Any]]: + output_dir = _cron_output_runs_dir(profile, canonical_job_id) + try: + files = sorted( + (path for path in output_dir.glob("*.md") if path.is_file()), + key=lambda path: path.name, + reverse=True, + ) + except OSError: + files = [] + + latest_ts = _cron_job_last_run_timestamp(job) + latest_status = _cron_output_status_label(job) + runs: List[Dict[str, Any]] = [] + + for index, path in enumerate(files[:limit]): + started_at = _cron_output_run_timestamp(path) + if started_at is None: + try: + started_at = path.stat().st_mtime + except OSError: + started_at = 0.0 + preview = _cron_output_run_preview(path) + title = preview or "Script-only run" + if ( + index == 0 + and latest_status + and (latest_ts is None or abs(latest_ts - started_at) <= 120) + ): + title = f"{latest_status} · {title}" + runs.append( + { + "id": f"cron_output:{canonical_job_id}:{path.stem}", + "title": title, + "preview": preview or None, + "source": "cron_output", + "started_at": started_at, + "last_active": started_at, + "ended_at": started_at, + "input_tokens": 0, + "output_tokens": 0, + "message_count": 0, + "tool_call_count": 0, + "model": None, + "cwd": None, + "archived": False, + "is_active": False, + } + ) + + if runs: + return runs + + if latest_ts is None: + return [] + + preview = "" + if isinstance(job, dict): + preview = str(job.get("last_error") or "").strip() + title = _cron_output_status_label(job) or "Script-only run" + if preview: + title = f"{title} · {preview}" + return [ + { + "id": f"cron_output:{canonical_job_id}:latest", + "title": title, + "preview": preview or None, + "source": "cron_output", + "started_at": latest_ts, + "last_active": latest_ts, + "ended_at": latest_ts, + "input_tokens": 0, + "output_tokens": 0, + "message_count": 0, + "tool_call_count": 0, + "model": None, + "cwd": None, + "archived": False, + "is_active": False, + } + ] + + def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: int = 20): """Run sessions produced by a cron job, newest first. @@ -10203,6 +10350,7 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: selected = profile or _find_cron_job_profile(job_id) # job_id may be a human name; resolve to the canonical id used in run-session ids. canonical = job_id + job = None if selected: job = _call_cron_for_profile(selected, "get_job", job_id) if job and job.get("id"): @@ -10216,6 +10364,8 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: db = _open_session_db_for_profile(selected) try: runs = db.list_cron_job_runs(canonical, limit=limit_n, offset=0) + if not runs: + return {"runs": _list_cron_output_runs(job, canonical, selected, limit_n), "limit": limit_n} now = time.time() for s in runs: s["is_active"] = ( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 519ccf1518e0e..3cb441917f622 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -7100,3 +7100,125 @@ def test_ticker_skipped_without_desktop(self, monkeypatch, _isolate_hermes_home) with self._client(): assert not called.wait(0.5), "ticker must not run outside the desktop app" + + +class TestCronRunHistoryFallback: + def test_falls_back_to_output_docs_when_no_session_runs_exist(self, monkeypatch, _isolate_hermes_home): + import hermes_cli.web_server as ws + from hermes_constants import get_hermes_home + + job_id = "job-script-only" + output_dir = get_hermes_home() / "cron" / "output" / job_id + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "2026-07-08_09-00-00.md").write_text("older output\n", encoding="utf-8") + (output_dir / "2026-07-08_09-05-00.md").write_text("latest output\n", encoding="utf-8") + + class _FakeDB: + def list_cron_job_runs(self, canonical, limit, offset): + assert canonical == job_id + assert limit == 2 + assert offset == 0 + return [] + + def close(self): + pass + + monkeypatch.setattr(ws, "_find_cron_job_profile", lambda _job_id: "default") + monkeypatch.setattr(ws, "_open_session_db_for_profile", lambda _profile: _FakeDB()) + monkeypatch.setattr( + ws, + "_call_cron_for_profile", + lambda _profile, cmd, *_args, **_kwargs: { + "id": job_id, + "last_status": "ok", + } + if cmd == "get_job" + else None, + ) + + result = ws._list_cron_job_runs_sync(job_id, limit=2) + + runs = result["runs"] + assert result["limit"] == 2 + assert [run["id"] for run in runs] == [ + f"cron_output:{job_id}:2026-07-08_09-05-00", + f"cron_output:{job_id}:2026-07-08_09-00-00", + ] + assert runs[0]["source"] == "cron_output" + assert runs[0]["title"].startswith("OK · latest output") + assert runs[1]["title"] == "older output" + assert all(run["is_active"] is False for run in runs) + + def test_keeps_session_runs_when_db_history_exists(self, monkeypatch, _isolate_hermes_home): + import hermes_cli.web_server as ws + from hermes_constants import get_hermes_home + + job_id = "job-with-sessions" + output_dir = get_hermes_home() / "cron" / "output" / job_id + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "2026-07-08_09-05-00.md").write_text("fallback output\n", encoding="utf-8") + + class _FakeDB: + def list_cron_job_runs(self, canonical, limit, offset): + assert canonical == job_id + return [ + { + "id": "cron_job-with-sessions_00000001", + "source": "cron", + "started_at": 123.0, + "last_active": 125.0, + "ended_at": 126.0, + "archived": False, + } + ] + + def close(self): + pass + + monkeypatch.setattr(ws, "_find_cron_job_profile", lambda _job_id: "default") + monkeypatch.setattr(ws, "_open_session_db_for_profile", lambda _profile: _FakeDB()) + monkeypatch.setattr( + ws, + "_call_cron_for_profile", + lambda _profile, cmd, *_args, **_kwargs: {"id": job_id} if cmd == "get_job" else None, + ) + monkeypatch.setattr(ws.time, "time", lambda: 200.0) + + result = ws._list_cron_job_runs_sync(job_id, limit=5) + + assert [run["id"] for run in result["runs"]] == ["cron_job-with-sessions_00000001"] + assert result["runs"][0]["source"] == "cron" + assert result["runs"][0]["is_active"] is False + + def test_surfaces_latest_run_when_only_job_metadata_exists(self, monkeypatch, _isolate_hermes_home): + import hermes_cli.web_server as ws + + job_id = "job-last-run-only" + + class _FakeDB: + def list_cron_job_runs(self, canonical, limit, offset): + assert canonical == job_id + return [] + + def close(self): + pass + + monkeypatch.setattr(ws, "_find_cron_job_profile", lambda _job_id: "default") + monkeypatch.setattr(ws, "_open_session_db_for_profile", lambda _profile: _FakeDB()) + monkeypatch.setattr( + ws, + "_call_cron_for_profile", + lambda _profile, cmd, *_args, **_kwargs: { + "id": job_id, + "last_status": "error", + "last_error": "command exited 1", + "last_run_at": "2026-07-08T17:00:00+00:00", + } + if cmd == "get_job" + else None, + ) + + result = ws._list_cron_job_runs_sync(job_id, limit=5) + + assert [run["id"] for run in result["runs"]] == [f"cron_output:{job_id}:latest"] + assert result["runs"][0]["title"] == "ERROR · command exited 1"