Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -311,19 +315,28 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
) : (
<>
{runs.map(run => (
<button
className={cn(
'truncate rounded-md px-1.5 py-0.5 text-left text-[0.6875rem] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
run.id === selectedSessionId
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={run.id}
onClick={() => onOpenRun(run.id)}
type="button"
>
{formatRunTime(run.last_active || run.started_at)}
</button>
isSyntheticCronOutputRun(run) ? (
<div
className="truncate rounded-md px-1.5 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) tabular-nums"
key={run.id}
>
{formatRunTime(run.last_active || run.started_at)}
</div>
) : (
<button
className={cn(
'truncate rounded-md px-1.5 py-0.5 text-left text-[0.6875rem] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
run.id === selectedSessionId
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={run.id}
onClick={() => onOpenRun(run.id)}
type="button"
>
{formatRunTime(run.last_active || run.started_at)}
</button>
)
))}
</>
)}
Expand Down
42 changes: 29 additions & 13 deletions apps/desktop/src/app/cron/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -679,19 +683,31 @@ function CronJobRuns({
<div className="py-1 text-xs text-muted-foreground">{c.noRuns}</div>
) : (
<div className="flex flex-col gap-px">
{runs.map(run => (
<button
className="row-hover flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
key={run.id}
onClick={() => onOpenSession?.(run.id)}
type="button"
>
<span className="truncate text-foreground/85">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground/55 tabular-nums">
{formatRunTime(run.last_active || run.started_at)}
</span>
</button>
))}
{runs.map(run =>
isSyntheticCronOutputRun(run) ? (
<div
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-xs"
key={run.id}
>
<span className="truncate text-foreground/85">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground/55 tabular-nums">
{formatRunTime(run.last_active || run.started_at)}
</span>
</div>
) : (
<button
className="row-hover flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
key={run.id}
onClick={() => onOpenSession?.(run.id)}
type="button"
>
<span className="truncate text-foreground/85">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground/55 tabular-nums">
{formatRunTime(run.last_active || run.started_at)}
</span>
</button>
)
)}
</div>
)}
</div>
Expand Down
150 changes: 150 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

save_job_output() writes this filename using _hermes_now() (cron/jobs.py:1921), which may be a configured IANA timezone. Attaching the server's current fixed offset changes the represented instant when those differ and is wrong for files across DST. Use hermes_time.get_timezone() when configured (or resolve local wall time for the filename's date) and add a started_at regression assertion.



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.

Expand All @@ -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"):
Expand All @@ -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"] = (
Expand Down
Loading
Loading