After `hermes update`, the desktop sidebar showed "No sessions yet" until the user's first message. #72424 added sessions.last_activity_at, which list_sessions_rich now selects — but column adds only land through _reconcile_columns() in the writable _init_schema, and read-only opens skip that by design. Every sidebar read path opens state.db read-only, so each poll raised "no such column: s.last_activity_at" until the first prompt's lazy session-row persist forced a writable open and reconciled. A heal for exactly this class already existed (_open_session_db_for_profile probes the read-only handle and does a one-time writable reopen on staleness), but its probe was a hand-written four-column list that never learned last_activity_at — it went stale three days after shipping. And the batched sidebar route (/api/profiles/sessions/sidebar) bypassed the helper entirely, swallowing per-profile failures into an errors array the desktop never surfaces, so the incident produced an empty sidebar with clean logs. The fix removes the maintenance burden instead of paying it once more: - hermes_state_schema.schema_read_probe_statements() derives one `SELECT <every declared column> FROM <table> LIMIT 0` per table from SCHEMA_SQL via the existing _parse_schema_columns() — the same source of truth the writable reconciler diffs against, so any future ADD COLUMN is probed with no list to update. Column references are table-qualified: an unqualified double-quoted identifier that fails to resolve silently degrades to a string literal (SQLite's double-quoted-string misfeature) and would make the probe pass on exactly the store it exists to catch. - web_server splits the heal into a path-level _open_session_db_at_path (semantics unchanged) so the cross-profile session routes can share it; both profiles.py loops and _count_status_active_sessions (the remaining raw read-only sibling) now open through it. The heal stays a helper rather than a SessionDB classmethod on purpose: escalation-to-writable must remain an explicit caller decision — update_cmd.py opens read-only mid-update and must never write. - Exhaustion guard: if the writable heal SUCCEEDS and the re-probe still fails (a schema problem ADD COLUMN cannot express), the store is marked exhausted — warn once, skip the probe, serve reads probe-less — instead of re-running the full writable init on every poll against a possibly live DB. A FAILED writable open (transient lock) is deliberately not recorded, so the next poll retries the heal. - The per-profile swallow sites in profiles.py now also log a deduplicated warning, so a persistent read failure is loud in errors.log even though the response errors array stays invisible to the sidebar. Tests: probe/SCHEMA_SQL coverage invariants (tests/test_schema_read_probe.py), last_activity_at added to the /api/sessions heal parametrize, a sidebar-route heal test reproducing the shipped symptom (errors == [] and the session returned against a store missing the column), and an exhaustion test pinning exactly one writable open. The sidebar and last_activity_at tests fail on main.
101 lines
3.9 KiB
Python
101 lines
3.9 KiB
Python
"""Contract tests for schema_read_probe_statements().
|
|
|
|
Read-only SessionDB opens skip _reconcile_columns() by design, so dashboard
|
|
read paths heal stale stores via a probe-then-writable-reopen dance in
|
|
``hermes_cli.web_server._open_session_db_at_path``. These tests pin the
|
|
probe's contract: it is DERIVED from SCHEMA_SQL (any column added there is
|
|
covered automatically — the previous hand-written probe went stale within
|
|
days) and it must fail at prepare time on a store missing any declared
|
|
column or table.
|
|
"""
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from hermes_state_common import DEFERRED_INDEX_SQL, SCHEMA_SQL
|
|
from hermes_state_schema import SessionSchemaMixin, schema_read_probe_statements
|
|
|
|
|
|
def _fresh_schema_conn() -> sqlite3.Connection:
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.executescript(SCHEMA_SQL)
|
|
conn.executescript(DEFERRED_INDEX_SQL)
|
|
return conn
|
|
|
|
|
|
class TestSchemaReadProbeStatements:
|
|
def test_probes_cover_every_declared_column(self):
|
|
"""Invariant: every column SCHEMA_SQL declares appears in a probe.
|
|
|
|
This is the anti-staleness contract — a column added to SCHEMA_SQL
|
|
must be probed without anyone remembering to update a list.
|
|
"""
|
|
expected = SessionSchemaMixin._parse_schema_columns(SCHEMA_SQL)
|
|
statements = schema_read_probe_statements()
|
|
by_table = {}
|
|
for statement in statements:
|
|
for table in expected:
|
|
if f'FROM "{table}"' in statement:
|
|
by_table[table] = statement
|
|
for table, cols in expected.items():
|
|
assert table in by_table, f"no probe statement for table {table}"
|
|
for col in cols:
|
|
assert f'"{table}"."{col}"' in by_table[table], (
|
|
f"column {table}.{col} declared in SCHEMA_SQL but not probed"
|
|
)
|
|
|
|
def test_probes_pass_on_fresh_schema(self):
|
|
conn = _fresh_schema_conn()
|
|
try:
|
|
for statement in schema_read_probe_statements():
|
|
conn.execute(statement).fetchone()
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_probes_fail_on_missing_column(self):
|
|
"""The shipped regression: a store predating sessions.last_activity_at
|
|
|
|
(#72424) passed the old hand-written probe, then 500'd inside
|
|
list_sessions_rich on every sidebar poll until the user's first
|
|
message forced a writable open.
|
|
"""
|
|
conn = _fresh_schema_conn()
|
|
try:
|
|
conn.execute("ALTER TABLE sessions DROP COLUMN last_activity_at")
|
|
# The failure must come from the sessions probe naming the exact
|
|
# column — not incidentally from some other statement — so a
|
|
# probe-generation bug that misassigns columns to tables can't
|
|
# sneak through.
|
|
sessions_probe = next(
|
|
s
|
|
for s in schema_read_probe_statements()
|
|
if 'FROM "sessions"' in s
|
|
)
|
|
with pytest.raises(sqlite3.OperationalError) as excinfo:
|
|
conn.execute(sessions_probe)
|
|
assert "no such column: sessions.last_activity_at" in str(
|
|
excinfo.value
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_probes_fail_on_missing_table(self):
|
|
conn = sqlite3.connect(":memory:")
|
|
try:
|
|
conn.executescript(SCHEMA_SQL)
|
|
conn.executescript("DROP TABLE gateway_routing")
|
|
gateway_probe = next(
|
|
s
|
|
for s in schema_read_probe_statements()
|
|
if 'FROM "gateway_routing"' in s
|
|
)
|
|
with pytest.raises(sqlite3.OperationalError) as excinfo:
|
|
conn.execute(gateway_probe)
|
|
assert "no such table" in str(excinfo.value).lower()
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_probe_statements_are_cached(self):
|
|
assert schema_read_probe_statements() is schema_read_probe_statements()
|