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.
This commit is contained in:
Jean Clawd 2026-04-23 07:43:51 +02:00
parent 88b6eb9ad1
commit 8d922ddadd
4 changed files with 81 additions and 0 deletions

View file

@ -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

View file

@ -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]

View file

@ -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"

View file

@ -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")