From 8d922ddadd4dba4d63e8fef5dea9b3838320a654 Mon Sep 17 00:00:00 2001 From: Jean Clawd Date: Thu, 23 Apr 2026 07:43:51 +0200 Subject: [PATCH] fix: normalize imported session timestamps OpenClaw session imports can leave ISO-8601 timestamps in SessionDB rows. Normalize legacy string timestamps before rendering or comparing session activity so session listing and resume surfaces do not crash on imported history. --- hermes_cli/main.py | 22 +++++++++++++++++ hermes_state.py | 33 ++++++++++++++++++++++++++ tests/hermes_cli/test_relative_time.py | 10 ++++++++ tests/test_hermes_state.py | 16 +++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 tests/hermes_cli/test_relative_time.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ec0441f8b2..b62a69a289 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -198,8 +198,30 @@ from hermes_constants import AI_GATEWAY_BASE_URL, OPENROUTER_BASE_URL logger = logging.getLogger(__name__) +def _coerce_timestamp(ts): + """Best-effort parse for epoch or ISO-8601 timestamps.""" + if ts is None or ts == "": + return None + if isinstance(ts, (int, float)): + return float(ts) + if isinstance(ts, str): + raw = ts.strip() + if not raw: + return None + try: + return float(raw) + except ValueError: + pass + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + def _relative_time(ts) -> str: """Format a timestamp as relative time (e.g., '2h ago', 'yesterday').""" + ts = _coerce_timestamp(ts) if not ts: return "?" delta = _time.time() - ts diff --git a/hermes_state.py b/hermes_state.py index 0ea9815b5a..9bf4360b01 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -21,6 +21,7 @@ import re import sqlite3 import threading import time +from datetime import datetime from pathlib import Path from hermes_constants import get_hermes_home from typing import Any, Callable, Dict, List, Optional, TypeVar @@ -29,6 +30,32 @@ logger = logging.getLogger(__name__) T = TypeVar("T") + +def _normalize_timestamp(value: Any) -> Any: + """Best-effort normalization for legacy/imported timestamp values. + + Session rows are supposed to store REAL epoch seconds, but imported data may + contain ISO-8601 strings. Normalize those on read so downstream callers can + safely compare/subtract timestamps without exploding. + """ + if value is None or value == "": + return value + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + raw = value.strip() + if not raw: + return value + try: + return float(raw) + except ValueError: + pass + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() + except ValueError: + return value + return value + DEFAULT_DB_PATH = get_hermes_home() / "state.db" SCHEMA_VERSION = 8 @@ -858,6 +885,9 @@ class SessionDB: sessions = [] for row in rows: s = dict(row) + for key in ("started_at", "ended_at", "last_active"): + if key in s: + s[key] = _normalize_timestamp(s.get(key)) # Build the preview from the raw substring raw = s.pop("_preview_raw", "").strip() if raw: @@ -930,6 +960,9 @@ class SessionDB: if not row: return None s = dict(row) + for key in ("started_at", "ended_at", "last_active"): + if key in s: + s[key] = _normalize_timestamp(s.get(key)) raw = s.pop("_preview_raw", "").strip() if raw: text = raw[:60] diff --git a/tests/hermes_cli/test_relative_time.py b/tests/hermes_cli/test_relative_time.py new file mode 100644 index 0000000000..443c8195ce --- /dev/null +++ b/tests/hermes_cli/test_relative_time.py @@ -0,0 +1,10 @@ +import time + +from hermes_cli.main import _relative_time + + +def test_relative_time_accepts_iso_timestamps(): + ts = time.time() - 7200 + iso = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts)) + + assert _relative_time(iso) == "2h ago" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f405cf8bd5..43a97e2d9d 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1438,6 +1438,22 @@ class TestListSessionsRich: # No messages, so last_active falls back to started_at assert sessions[0]["last_active"] == sessions[0]["started_at"] + def test_last_active_normalizes_legacy_iso_started_at(self, db): + db.create_session("legacy", "cli") + db._conn.execute( + "UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", + ("2026-04-09T12:00:00", "2026-04-09T13:00:00", "legacy"), + ) + db._conn.commit() + + sessions = db.list_sessions_rich() + legacy = next(s for s in sessions if s["id"] == "legacy") + + assert isinstance(legacy["started_at"], float) + assert isinstance(legacy["ended_at"], float) + assert isinstance(legacy["last_active"], float) + assert legacy["last_active"] == legacy["started_at"] + def test_rich_list_includes_title(self, db): db.create_session("s1", "cli") db.set_session_title("s1", "refactoring auth")