SQLite dynamic typing lets a TEXT cell ('not-a-timestamp'), inf/nan or a
garbage double (8.4e252 salvaged from a damaged page) sit in a REAL
timestamp column. Every reader called datetime.fromtimestamp()/float
arithmetic on the raw cell, so ONE bad row raised TypeError/OverflowError
out of the row loop and took down the whole `hermes sessions list`/browse
table (#102399), all three exporters — JSONL/MD, QMD, HTML (#102352) —
and `hermes insights` (#99959).
Fix the class with ONE helper, hermes_cli.timefmt.coerce_epoch(): a
stored cell becomes float epoch seconds inside a sane 1970..2103 window
or None after a WARNING that names the session id. Every reader routes
through it — relative_time (list/browse/resume picker), format_epoch
(prune/candidates tables), the three exporters' timestamp formatters,
insights' _get_sessions/_day/period range — so a bad row renders as
'?'/'N/A'/raw text for that one cell and the command completes.
Write side: hermes_state_messages._coerce_timestamp (append_message,
append_messages_batch, import) and the import path's started_at now use
the same window, so a new out-of-range timestamp falls back to now()
instead of being persisted — new bad rows cannot be written by Hermes.
Reported-by: #102399, #102352, #99959 reporters; kokhlo's insights
analysis pointed at every reporting site, not just line 860.
59 lines
2.4 KiB
Python
59 lines
2.4 KiB
Python
"""Small shared time-formatting helpers for CLI output."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
import time as _time
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Epoch-seconds window a stored timestamp must fall in to be trusted: 1970 .. ~2103 (inside 32-bit
|
|
# ``time_t`` so ``fromtimestamp`` accepts it on every platform). SQLite dynamic typing lets a TEXT
|
|
# cell, ``inf``/``nan`` or a garbage double (``8.4e252`` salvaged from a damaged page) sit in a REAL
|
|
# column; ``datetime.fromtimestamp`` then raises and one bad row killed the whole listing, export
|
|
# or report (#102399, #102352, #99959).
|
|
EPOCH_MIN = 0.0
|
|
EPOCH_MAX = 4_200_000_000.0
|
|
|
|
|
|
def coerce_epoch(value: Any, *, session_id: Optional[str] = None, field: str = "timestamp") -> Optional[float]:
|
|
"""A stored timestamp cell as float epoch seconds, or ``None`` when it cannot be trusted.
|
|
|
|
Numbers, numeric strings and ``datetime`` are accepted; anything else, non-finite values and
|
|
values outside ``EPOCH_MIN..EPOCH_MAX`` return ``None`` after a WARNING naming the session so
|
|
the corrupt row can be found. ``None``/``""`` mean "unset" and stay silent. Every reader that
|
|
renders a row timestamp goes through here (a bad row degrades to one ``?`` cell, never a dead
|
|
command) and every writer uses it to refuse persisting a new bad row.
|
|
"""
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
ts = float(value.timestamp()) if isinstance(value, datetime) else float(value)
|
|
except (TypeError, ValueError):
|
|
ts = math.nan
|
|
if not (EPOCH_MIN <= ts <= EPOCH_MAX): # also False for nan
|
|
logger.warning("Ignoring corrupt %s %r%s", field, value, f" on session {session_id}" if session_id else "")
|
|
return None
|
|
return ts
|
|
|
|
|
|
def relative_time(ts, *, session_id: Optional[str] = None) -> str:
|
|
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday'); ``?`` when unset or corrupt."""
|
|
if not ts or (ts := coerce_epoch(ts, session_id=session_id, field="last_active")) is None:
|
|
return "?"
|
|
delta = _time.time() - ts
|
|
if delta < 60:
|
|
return "just now"
|
|
if delta < 3600:
|
|
return f"{int(delta / 60)}m ago"
|
|
if delta < 86400:
|
|
return f"{int(delta / 3600)}h ago"
|
|
if delta < 172800:
|
|
return "yesterday"
|
|
if delta < 604800:
|
|
return f"{int(delta / 86400)}d ago"
|
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|