* fix(state): publish structural state.db corruption as one profile-level state A structurally corrupt state.db showed up differently on every surface: the sidebar endpoint returned 200 with empty slices plus an errors row, /api/sessions returned 500, /api/status said components.storage ok and readiness was green. None of them said the store was damaged, so Desktop rendered it as deleted history (#72046). hermes_state_health is now the single latch, keyed by resolved state.db path: - SessionDB._halt_db_corrupt, the SessionDB read helpers, the web profile reader and the readiness probe publish into it, only for structural corruption (not FTS-scoped damage, not the malformed-schema case the web open path heals). - gateway.readiness reports it (state_db degraded/corrupt, and session_store unavailable/corrupt even when the handle cache says ok), which also feeds /api/status components.storage (now with reason: corrupt). - /api/sessions, /api/profiles/sessions and /api/profiles/sessions/sidebar carry storage: {profile: "corrupt"}; /api/sessions returns 503 state_db_corrupt instead of 500. - A peer SessionDB handle in the same process refuses writes on a latched path with the existing StateDbCorruptError, so gateway/agent transcript diversion and classify_persistence_error keep working unchanged. The latch never clears on its own and resets on restart, the recovery boundary StateDbCorruptError already documents. Co-authored-by: konsisumer <konsisumer@users.noreply.github.com> * fix(desktop): say the session store is damaged instead of an empty sidebar The sidebar reads the list endpoints' new storage map into $corruptSessionStores and renders a persistent destructive Alert above the session list naming the affected profile(s). The copy says missing chats were not deleted and points at the non-destructive path (quit Hermes, then `hermes sessions recover --source <state.db> --inspect-only` or restore a snapshot) plus the recovery guide; it does not recommend `sessions repair` for structural damage. Co-authored-by: konsisumer <konsisumer@users.noreply.github.com> --------- Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""Profile-level session-storage health: one process-wide latch per state.db path.
|
|
|
|
A structurally corrupt state.db used to show up as an empty or partial Desktop sidebar,
|
|
green readiness and a 500 from ``/api/sessions``: every surface guessed on its own and
|
|
none said "the store is damaged" (#72046). This module is the single place that fact is
|
|
recorded. Writers (``SessionDB._halt_db_corrupt``), readers (``SessionDB`` read helpers)
|
|
and the readiness probe publish into it; ``gateway.readiness`` (``state_db`` /
|
|
``session_store`` checks, hence ``/api/status`` ``components.storage``) and the session
|
|
list endpoints read from it, so Desktop and readiness cannot disagree.
|
|
|
|
Only structural corruption latches: bare ``SQLITE_CORRUPT`` / ``SQLITE_NOTADB`` with no
|
|
FTS provenance. FTS-scoped damage has its own fail-open path with canonical rows intact,
|
|
and a malformed-schema row is healed by the web open path, so neither is reported here.
|
|
|
|
The latch never clears on its own. A corrupt image does not heal, and a store that
|
|
flickers between "ok" and "corrupt" is the silent failure this replaces. It resets when
|
|
the process restarts, which is the recovery boundary ``StateDbCorruptError`` already
|
|
documents (stop Hermes, recover or restore, start again). No marker is written to disk:
|
|
the file it would describe is the one that is damaged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sqlite3
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Dict, Optional
|
|
|
|
from hermes_state_errors import (
|
|
classify_persistence_error,
|
|
is_fts_scoped_corruption_error,
|
|
is_malformed_schema_error,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STORAGE_OK = "ok"
|
|
STORAGE_CORRUPT = "corrupt"
|
|
|
|
_lock = threading.Lock()
|
|
_corrupt: Dict[str, str] = {} # resolved db path -> first error text (log only, never served)
|
|
|
|
|
|
def _key(db_path) -> str:
|
|
return str(Path(db_path).expanduser().resolve(strict=False))
|
|
|
|
|
|
def is_structural_corruption_error(exc: BaseException) -> bool:
|
|
"""Canonical B-tree/schema/freelist damage: a corrupt/NOTADB error SQLite does not scope to
|
|
the FTS index, and not the malformed-schema case the web open path repairs."""
|
|
return (
|
|
isinstance(exc, sqlite3.DatabaseError)
|
|
and not is_fts_scoped_corruption_error(exc)
|
|
and not is_malformed_schema_error(exc)
|
|
and classify_persistence_error(exc) == "corrupt"
|
|
)
|
|
|
|
|
|
def mark_storage_corrupt(db_path, reason: object) -> None:
|
|
"""Latch *db_path* as corrupt for the life of this process (idempotent)."""
|
|
key = _key(db_path)
|
|
with _lock:
|
|
if key in _corrupt:
|
|
return
|
|
_corrupt[key] = str(reason)
|
|
logger.error(
|
|
"state.db at %s is structurally corrupt (%s); session storage is reported as corrupt "
|
|
"until Hermes restarts on a recovered or restored file. Stop Hermes, then run "
|
|
"`hermes sessions recover --source %s --inspect-only` or restore a snapshot.",
|
|
db_path, reason, db_path,
|
|
)
|
|
|
|
|
|
def note_storage_error(db_path, exc: BaseException) -> bool:
|
|
"""Latch *db_path* when *exc* is structural corruption; True when it was."""
|
|
if not is_structural_corruption_error(exc):
|
|
return False
|
|
mark_storage_corrupt(db_path, exc)
|
|
return True
|
|
|
|
|
|
def storage_state(db_path) -> str:
|
|
"""``"corrupt"`` once this process has seen structural corruption on *db_path*, else ``"ok"``."""
|
|
with _lock:
|
|
return STORAGE_CORRUPT if _key(db_path) in _corrupt else STORAGE_OK
|
|
|
|
|
|
def storage_corrupt_reason(db_path) -> Optional[str]:
|
|
"""The first error text latched for *db_path* (logs/diagnostics only), or None."""
|
|
with _lock:
|
|
return _corrupt.get(_key(db_path))
|
|
|
|
|
|
def reset_storage_state(db_path=None) -> None:
|
|
"""Forget the latch for *db_path* (all paths when None). For tests and a verified in-process
|
|
recovery; nothing in the runtime clears it on its own."""
|
|
with _lock:
|
|
if db_path is None:
|
|
_corrupt.clear()
|
|
else:
|
|
_corrupt.pop(_key(db_path), None)
|
|
|
|
|
|
__all__ = [
|
|
"STORAGE_CORRUPT",
|
|
"STORAGE_OK",
|
|
"is_structural_corruption_error",
|
|
"mark_storage_corrupt",
|
|
"note_storage_error",
|
|
"reset_storage_state",
|
|
"storage_corrupt_reason",
|
|
"storage_state",
|
|
]
|