Skip to content
Closed
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
19 changes: 9 additions & 10 deletions hermes_cli/web_routers/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
_disable_unselected_skills = late("_disable_unselected_skills")
_fallback_profile_dicts = late("_fallback_profile_dicts")
_hub_action_name = late("_hub_action_name")
_open_session_db_for_profile = late("_open_session_db_for_profile")
_profile_setup_command = late("_profile_setup_command")
_profile_to_dict = late("_profile_to_dict")
_resolve_profile_dir = late("_resolve_profile_dir")
Expand Down Expand Up @@ -75,7 +76,7 @@ def get_profiles_sessions(
exclude_sources: str = None,
full: bool = False,
):
"""Unified, read-only session list aggregated across ALL profiles.
"""Unified session list aggregated across ALL profiles.

Intentionally process-light: this opens each profile's ``state.db`` directly
from disk — it does NOT spawn a dashboard backend per profile. Each returned
Expand All @@ -92,7 +93,6 @@ def get_profiles_sessions(
if order not in ("created", "recent"):
raise HTTPException(status_code=400, detail="order must be one of: created, recent")

from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod

targets: List[Tuple[str, Path]] = []
Expand Down Expand Up @@ -132,10 +132,9 @@ def get_profiles_sessions(
if not db_path.exists():
continue
try:
# Read-only: this loop runs on every sidebar refresh, so it must
# never DDL/write-lock another profile's live DB (see SessionDB
# read_only docstring).
db = SessionDB(db_path=db_path, read_only=True)
# Healthy stores stay read-only on every refresh. Older stores get
# one writable schema reconciliation before reopening read-only.
db = _open_session_db_for_profile(name, read_only=True)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
continue
Expand Down Expand Up @@ -217,16 +216,16 @@ def get_profiles_sessions_sidebar(
``/api/profiles/sessions`` calls they reopened every profile's ``state.db``
three times and re-counted each refresh. This opens each DB once and runs
the three filtered queries together, returning the three windows in one
payload. Read-only and process-light, same row projection and 300s active
heuristic as ``/api/profiles/sessions``.
payload. Healthy stores stay read-only and process-light; an older store
gets one schema reconciliation before the read is retried. Uses the same
row projection and 300s active heuristic as ``/api/profiles/sessions``.

The caller passes the source taxonomy (``recents_exclude`` /
``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays
taxonomy-agnostic like the per-slice endpoint. All three slices use
``min_messages=1`` / ``archived=exclude`` / recency order, matching the
desktop's per-slice calls.
"""
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod

# cron + messaging are cross-profile; recents is scoped to recents_profile.
Expand Down Expand Up @@ -290,7 +289,7 @@ def _slice(db, *, source=None, exclude=None, cap):
if not db_path.exists():
continue
try:
db = SessionDB(db_path=db_path, read_only=True)
db = _open_session_db_for_profile(name, read_only=True)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
continue
Expand Down
36 changes: 25 additions & 11 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11195,16 +11195,29 @@ def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str
# query raises "no such table: sessions".
_session_db_bootstrap_lock = threading.Lock()

# Stale-schema probe for read-only opens: compiled against the newest columns
# the dashboard read paths query. Reads at most one row per table. Read-only
# opens skip _reconcile_columns(), so an older store would otherwise 500 on
# every poll until something opened it writable.
_SESSION_DB_READ_PROBE_SQL = (
"SELECT (SELECT archived FROM sessions LIMIT 1), "
"(SELECT pinned FROM sessions LIMIT 1), "
"(SELECT active FROM messages LIMIT 1), "
"(SELECT compacted FROM messages LIMIT 1)"
)
# Read-only opens skip _reconcile_columns(), so probe every table and column
# declared in SCHEMA_SQL before serving dashboard reads. Deriving these probes
# from the schema keeps future column additions on the existing self-heal path.
@functools.lru_cache(maxsize=1)
def _session_db_read_probe_sqls() -> Tuple[str, ...]:
from hermes_state_common import SCHEMA_SQL
from hermes_state_schema import SessionSchemaMixin

def _quote(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"'

expected = SessionSchemaMixin._parse_schema_columns(SCHEMA_SQL)
probes: List[str] = []
for table_name, columns in expected.items():
safe_table = _quote(table_name)
safe_columns = [
f"{safe_table}.{_quote(column)}" for column in columns
]
probes.append(
f"SELECT {', '.join(safe_columns)} "
f"FROM {safe_table} LIMIT 0"
)
return tuple(probes)


def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool):
Expand Down Expand Up @@ -11250,7 +11263,8 @@ def _open_probed():
conn = getattr(db, "_conn", None)
if conn is not None:
try:
conn.execute(_SESSION_DB_READ_PROBE_SQL).fetchone()
for probe_sql in _session_db_read_probe_sqls():
conn.execute(probe_sql)
except BaseException:
db.close()
raise
Expand Down
62 changes: 61 additions & 1 deletion tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,10 @@ def test_get_sessions_fresh_store_returns_empty_list(self):
assert response.json()["sessions"] == []
assert response.json()["total"] == 0

@pytest.mark.parametrize("missing_column", ["archived", "pinned"])
@pytest.mark.parametrize(
"missing_column",
["archived", "pinned", "last_read_at", "profile_name"],
)
def test_get_sessions_heals_stale_schema_store(self, missing_column):
import sqlite3

Expand Down Expand Up @@ -434,6 +437,63 @@ def test_get_sessions_heals_stale_schema_store(self, missing_column):
healed.close()
assert missing_column in columns

@pytest.mark.parametrize(
"endpoint",
[
"/api/profiles/sessions?profile=legacy&limit=50&offset=0",
(
"/api/profiles/sessions/sidebar?recents_profile=legacy"
"&recents_limit=50"
),
],
)
def test_profile_session_lists_heal_stale_schema_store(self, endpoint):
import sqlite3

from hermes_cli import profiles as profiles_mod
from hermes_state import SessionDB

profile_home = profiles_mod.get_profile_dir("legacy")
profile_home.mkdir(parents=True)
db_path = profile_home / "state.db"
seed = SessionDB(db_path=db_path)
try:
seed.create_session("stale-profile-schema", source="cli")
seed.append_message(
"stale-profile-schema",
role="user",
content="hello",
)
finally:
seed.close()

legacy = sqlite3.connect(str(db_path))
try:
legacy.execute("ALTER TABLE sessions DROP COLUMN last_read_at")
legacy.commit()
finally:
legacy.close()

response = self.client.get(endpoint)

assert response.status_code == 200
payload = response.json()
if "/sidebar" in endpoint:
sessions = payload["recents"]["sessions"]
else:
sessions = payload["sessions"]
assert [row["id"] for row in sessions] == ["stale-profile-schema"]
assert payload["errors"] == []

healed = sqlite3.connect(str(db_path))
try:
columns = {
row[1] for row in healed.execute("PRAGMA table_info(sessions)")
}
finally:
healed.close()
assert "last_read_at" in columns

def test_get_sessions_zero_byte_store_returns_empty_list(self):
from hermes_constants import get_hermes_home

Expand Down