316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Session-DB access for the dashboard: per-profile SessionDB opening with schema heal, latest-descendant lookup and the auto-archive ticker.
|
|
|
|
Split out of ``hermes_cli.web_server``; every externally used name is re-imported
|
|
there, so ``web_server.<name>`` keeps resolving (and monkeypatching) as before.
|
|
Helpers that tests patch on ``web_server`` are reached lazily through it.
|
|
"""
|
|
|
|
import logging
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, Optional
|
|
|
|
# Same logger the code used before extraction (record parity).
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session detail endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _session_latest_descendant(session_id: str, db):
|
|
"""Resolve a session id to the newest child leaf session.
|
|
|
|
/model may create child sessions. Dashboard refresh should continue the
|
|
newest child instead of reopening the old parent.
|
|
"""
|
|
def row_get(row, key, index):
|
|
if isinstance(row, dict):
|
|
return row.get(key)
|
|
try:
|
|
return row[key]
|
|
except Exception:
|
|
try:
|
|
return row[index]
|
|
except Exception:
|
|
return None
|
|
|
|
sid = db.resolve_session_id(session_id)
|
|
if not sid or not db.get_session(sid):
|
|
return None, []
|
|
|
|
conn = (
|
|
getattr(db, "conn", None)
|
|
or getattr(db, "_conn", None)
|
|
or getattr(db, "connection", None)
|
|
or getattr(db, "_connection", None)
|
|
)
|
|
|
|
rows = []
|
|
if conn is not None:
|
|
raw_rows = conn.execute(
|
|
"""
|
|
WITH RECURSIVE descendants(id, parent_session_id, started_at) AS (
|
|
SELECT id, parent_session_id, started_at FROM sessions WHERE id = ?
|
|
UNION
|
|
SELECT s.id, s.parent_session_id, s.started_at
|
|
FROM sessions s
|
|
JOIN descendants d ON s.parent_session_id = d.id
|
|
)
|
|
SELECT id, parent_session_id, started_at FROM descendants
|
|
""",
|
|
(sid,),
|
|
).fetchall()
|
|
for row in raw_rows:
|
|
rows.append({
|
|
"id": row_get(row, "id", 0),
|
|
"parent_session_id": row_get(row, "parent_session_id", 1),
|
|
"started_at": row_get(row, "started_at", 2),
|
|
})
|
|
else:
|
|
rows = db.list_sessions_rich(limit=10000, offset=0, compact_rows=True)
|
|
|
|
children = {}
|
|
for row in rows:
|
|
rid = row.get("id")
|
|
parent = row.get("parent_session_id")
|
|
if rid and parent:
|
|
children.setdefault(parent, []).append(row)
|
|
|
|
def started(row):
|
|
try:
|
|
return float(row.get("started_at") or 0)
|
|
except Exception:
|
|
return 0.0
|
|
|
|
current = sid
|
|
path = [sid]
|
|
seen = {sid}
|
|
|
|
while children.get(current):
|
|
candidates = [r for r in children[current] if r.get("id") not in seen]
|
|
if not candidates:
|
|
break
|
|
candidates.sort(key=started, reverse=True)
|
|
current = candidates[0]["id"]
|
|
path.append(current)
|
|
seen.add(current)
|
|
|
|
return current, path
|
|
|
|
|
|
# Serialises the one-time writable schema bootstrap for read-only opens.
|
|
# Concurrent first-load polls otherwise race sqlite file creation: the losers
|
|
# open mode=ro against a store whose schema is still being written and every
|
|
# query raises "no such table: sessions".
|
|
_session_db_bootstrap_lock = threading.Lock()
|
|
|
|
|
|
def _session_db_read_probe_statements() -> tuple:
|
|
"""Stale-schema probes for read-only opens, derived from SCHEMA_SQL.
|
|
|
|
Read-only opens skip _reconcile_columns(), so an older store would
|
|
otherwise 500 on every poll until something opened it writable. Derived
|
|
from the same schema the writable reconciler applies, so any column
|
|
added there is probed here automatically — the previous hand-written
|
|
probe listed four columns and went stale the first time a new column
|
|
(sessions.last_activity_at) shipped, leaving the desktop sidebar empty
|
|
after `hermes update` until the first message forced a writable open.
|
|
"""
|
|
from hermes_state_schema import schema_read_probe_statements
|
|
|
|
return schema_read_probe_statements()
|
|
|
|
|
|
# Stores where a heal WRITABLE OPEN SUCCEEDED and the read probe still
|
|
# failed afterwards: the schema problem is one reconciliation cannot fix
|
|
# (e.g. a NOT-NULL-without-default column SQLite refuses to ADD). Retrying
|
|
# the full writable init on every poll would hammer a live DB for nothing,
|
|
# so such stores fall back to the raw read-only open until restart. A
|
|
# FAILED writable open (transient lock) is deliberately NOT recorded —
|
|
# the next poll retries the heal.
|
|
_session_db_heal_exhausted: set = set()
|
|
|
|
# Deduplicates the heal-failure warning per store per process, so a
|
|
# persistent problem is loud once instead of once per sidebar poll.
|
|
_session_db_heal_warned: set = set()
|
|
|
|
|
|
def _open_session_db_at_path(db_path: Path, *, read_only: bool):
|
|
"""Open a SessionDB at an explicit path with an explicit access mode.
|
|
|
|
Writable opens keep the full init and repair path. Read-only opens
|
|
bootstrap a missing or zero-byte store once, and heal an older or
|
|
malformed schema through one writable open before reopening read-only.
|
|
The healthy read path never takes a write lock or requests a checkpoint.
|
|
|
|
Scope of the heal: the probe checks every table/column declared in
|
|
SCHEMA_SQL (see ``schema_read_probe_statements``), so ANY schema
|
|
addition escalates a stale store to a one-time writable open — the same
|
|
reconcile the store's own backend runs at startup. Tables created
|
|
outside SCHEMA_SQL (telemetry ``tel_*``, FTS shadow tables) are
|
|
deliberately outside both the probe and the heal.
|
|
"""
|
|
from hermes_cli.web_server import (
|
|
_session_db_heal_exhausted,
|
|
_session_db_heal_warned,
|
|
_session_db_read_probe_statements,
|
|
)
|
|
import sqlite3
|
|
|
|
from hermes_state import SessionDB, is_malformed_schema_error
|
|
|
|
if not read_only:
|
|
return SessionDB(db_path=db_path, read_only=False)
|
|
|
|
def _needs_bootstrap() -> bool:
|
|
try:
|
|
return db_path.stat().st_size == 0
|
|
except FileNotFoundError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
if _needs_bootstrap():
|
|
with _session_db_bootstrap_lock:
|
|
if _needs_bootstrap():
|
|
SessionDB(db_path=db_path, read_only=False).close()
|
|
|
|
def _open_probed():
|
|
db = SessionDB(db_path=db_path, read_only=True)
|
|
# Unit-test fakes may replace SessionDB without exposing a raw
|
|
# connection. Probe only real connections.
|
|
conn = getattr(db, "_conn", None)
|
|
if conn is not None and str(db_path) not in _session_db_heal_exhausted:
|
|
try:
|
|
for statement in _session_db_read_probe_statements():
|
|
conn.execute(statement).fetchone()
|
|
except BaseException:
|
|
db.close()
|
|
raise
|
|
return db
|
|
|
|
try:
|
|
return _open_probed()
|
|
except (sqlite3.DatabaseError, UnicodeDecodeError) as exc:
|
|
message = str(exc).lower()
|
|
stale_schema = "no such table" in message or "no such column" in message
|
|
if not stale_schema and not (
|
|
# UnicodeDecodeError = pysqlite could not decode SQLite's own
|
|
# error message because corrupt file bytes were embedded in it
|
|
# (#98924). The one-writable-open heal is the only repair path,
|
|
# so route it through the same dispatch as malformed schema.
|
|
is_malformed_schema_error(exc) or isinstance(exc, UnicodeDecodeError)
|
|
):
|
|
raise
|
|
SessionDB(db_path=db_path, read_only=False).close()
|
|
try:
|
|
return _open_probed()
|
|
except (sqlite3.DatabaseError, UnicodeDecodeError) as still_stale:
|
|
message = str(still_stale).lower()
|
|
if "no such table" not in message and "no such column" not in message:
|
|
raise
|
|
# The writable open succeeded but the store is STILL behind the
|
|
# probe: reconciliation cannot fix this one. Serve reads without
|
|
# the probe (queries touching the broken part will still fail,
|
|
# everything else works) and stop paying the writable init per
|
|
# poll.
|
|
_session_db_heal_exhausted.add(str(db_path))
|
|
if str(db_path) not in _session_db_heal_warned:
|
|
_session_db_heal_warned.add(str(db_path))
|
|
_log.warning(
|
|
"state.db at %s is missing schema that a writable "
|
|
"reconcile could not add (%s); read paths may partially "
|
|
"fail until the store is repaired",
|
|
db_path,
|
|
still_stale,
|
|
)
|
|
return _open_probed()
|
|
|
|
|
|
def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool):
|
|
"""Open a SessionDB with an explicit access mode for a profile.
|
|
|
|
``profile`` None/empty selects this process's own ``state.db``. A named
|
|
profile opens that profile's on-disk store directly. Access-mode
|
|
semantics are documented on :func:`_open_session_db_at_path`.
|
|
"""
|
|
from hermes_cli.web_server import _cron_profile_home
|
|
from hermes_state import _default_db_path
|
|
|
|
if profile:
|
|
_name, home = _cron_profile_home(profile)
|
|
db_path = Path(home) / "state.db"
|
|
else:
|
|
db_path = Path(_default_db_path())
|
|
return _open_session_db_at_path(db_path, read_only=read_only)
|
|
|
|
|
|
# In-process throttle for the opportunistic auto-archive trigger, keyed by
|
|
# profile. Bounds the config.yaml read to at most once per this window per
|
|
# profile; the actual sweep is throttled far more coarsely by state_meta
|
|
# (sessions.min_interval_hours) inside maybe_auto_archive.
|
|
_AUTO_ARCHIVE_CHECK_INTERVAL_S = 300.0
|
|
_last_auto_archive_check: Dict[str, float] = {}
|
|
|
|
|
|
def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
|
|
"""Run the config-gated stale-session auto-archive for ``profile``.
|
|
|
|
The Desktop backend is spawned as ``hermes serve`` — it runs neither the
|
|
interactive CLI nor the messaging gateway, so neither of those startup
|
|
hooks fire for Desktop users. Triggering the (double-throttled, config-off
|
|
by default) sweep from the session-list path is what makes
|
|
``sessions.auto_archive`` take effect there. Never raises.
|
|
"""
|
|
from hermes_cli.web_server import _open_session_db_for_profile
|
|
try:
|
|
key = profile or ""
|
|
now = time.monotonic()
|
|
last = _last_auto_archive_check.get(key)
|
|
if last is not None and now - last < _AUTO_ARCHIVE_CHECK_INTERVAL_S:
|
|
return
|
|
_last_auto_archive_check[key] = now
|
|
|
|
from hermes_cli.config import load_config as _load_full_config
|
|
cfg = (_load_full_config().get("sessions") or {})
|
|
if not cfg.get("auto_archive", False):
|
|
return
|
|
db = _open_session_db_for_profile(profile, read_only=False)
|
|
try:
|
|
db.maybe_auto_archive(
|
|
idle_days=float(cfg.get("auto_archive_days", 3)),
|
|
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
|
)
|
|
finally:
|
|
db.close()
|
|
except Exception as exc:
|
|
_log.debug("opportunistic auto-archive skipped: %s", exc)
|
|
|
|
|
|
async def _auto_archive_ticker_loop(
|
|
interval_s: float = 3600.0, initial_delay_s: float = 90.0
|
|
) -> None:
|
|
"""Live timer for the stale-session auto-archive (primary profile).
|
|
|
|
A long-running Desktop/serve backend must keep sweeping on schedule even
|
|
when no ``/api/sessions`` request arrives to fire the opportunistic
|
|
trigger — e.g. the app sits open for days on an idle chat. The real
|
|
cadence is still owned by state_meta (``sessions.min_interval_hours``)
|
|
inside ``maybe_auto_archive``; this loop is only the poll rate.
|
|
"""
|
|
|
|
def _sweep() -> None:
|
|
_maybe_auto_archive_for_profile(None)
|
|
|
|
await asyncio.sleep(initial_delay_s)
|
|
while True:
|
|
try:
|
|
await asyncio.to_thread(_sweep)
|
|
except Exception as exc:
|
|
_log.debug("auto-archive tick skipped: %s", exc)
|
|
await asyncio.sleep(interval_s)
|