Files
hermes-agent/hermes_cli/session_lost_and_found.py
T

542 lines
20 KiB
Python

"""Last-resort page-level salvage for an unreadable session database schema.
The SQLite command-line shell ships a page-level ``.recover`` command that walks raw pages and
rebuilds rows it cannot attribute to a schema into ``lost_and_found`` tables of the shape::
"""
from __future__ import annotations
import re
import shutil
import sqlite3
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Optional
# Hermes session ids are timestamps: 20260812_135332_ab12cd. This is the
# strongest sentinel available for classifying schema-less rows.
SESSION_ID_PATTERN = re.compile(r"^\d{8}_\d{6}_")
MESSAGE_ROLES = frozenset({"user", "assistant", "tool", "system"})
# Values observed in sessions.source across gateway platforms and tooling.
KNOWN_SOURCES = frozenset({
"cli", "telegram", "discord", "slack", "whatsapp", "signal", "matrix",
"irc", "email", "x", "twitter", "api", "gateway", "web", "dashboard",
"tool", "subagent", "cron", "recovered", "imported", "acp",
})
# Historical physical layouts of the sessions table. Columns are only ever
# appended (ALTER TABLE ADD COLUMN), so an older record is a strict prefix of
# the current column order.
SESSIONS_LAYOUT_NFIELDS = frozenset({55, 54, 52})
SESSIONS_LEGACY_MINIMAL_NFIELD = 14
SESSION_MODEL_USAGE_NFIELD = 18
# Plausible unix-epoch window for started_at heuristics on legacy layouts.
_EPOCH_LOW = 1_000_000_000.0 # 2001
_EPOCH_HIGH = 4_000_000_000.0 # 2096
SQLITE3_CLI_GUIDANCE = (
"A last-resort page-level salvage is available when a `.recover`-capable "
"`sqlite3` command-line shell is installed: its `.recover` command can "
"rebuild rows into lost_and_found tables even when the table schemas are "
"unreadable (this is a CLI-only feature, not part of Python's sqlite3 "
"module, and some distro builds lack it — the shell must include the "
"sqlite_dbpage extension, as the official builds from sqlite.org do). "
"Install such a sqlite3 CLI (e.g. `brew install sqlite` or the "
"precompiled sqlite-tools from sqlite.org) so it is on PATH, then re-run "
"with --allow-partial."
)
class LostAndFoundError(RuntimeError):
"""Raised when the CLI .recover pass cannot produce a usable database."""
def find_sqlite3_cli() -> Optional[str]:
"""Return a ``.recover``-capable sqlite3 CLI path, or None.
PATH presence is not enough: distro builds can ship a shell without the ``sqlite_dbpage``
virtual table ``.recover`` needs, failing every recovery. Probe on a scratch DB once instead
of discovering it mid-recovery.
"""
binary = shutil.which("sqlite3")
if binary is None:
return None
return binary if _cli_supports_recover(binary) else None
def _cli_supports_recover(binary: str) -> bool:
"""True when ``binary`` can run ``.recover`` (has sqlite_dbpage)."""
scratch_dir = tempfile.mkdtemp(prefix="hermes-recover-probe-")
scratch = Path(scratch_dir) / "probe.db"
try:
conn = sqlite3.connect(str(scratch))
try:
conn.execute("CREATE TABLE t (x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
finally:
conn.close()
probe = subprocess.run(
[binary, "-readonly", str(scratch), ".recover"],
capture_output=True,
timeout=30,
)
if probe.returncode != 0:
return False
return b"sqlite_dbpage" not in probe.stderr
except (OSError, subprocess.SubprocessError, sqlite3.Error):
return False
finally:
shutil.rmtree(scratch_dir, ignore_errors=True)
def run_cli_lost_and_found_recover(
source: Path,
lf_path: Path,
sqlite3_bin: str,
*,
timeout: float = 3600.0,
) -> dict[str, Any]:
"""Run ``sqlite3 <source> .recover`` streamed into a fresh scratch DB."""
attempts: list[dict[str, Any]] = []
for command in (".recover --ignore-freelist", ".recover"):
if lf_path.exists():
lf_path.unlink()
dump = subprocess.Popen(
[sqlite3_bin, "-readonly", str(source), command],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
load = subprocess.Popen(
[sqlite3_bin, str(lf_path)],
stdin=dump.stdout,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
assert dump.stdout is not None
dump.stdout.close() # let dump receive SIGPIPE if load dies
try:
_, load_err = load.communicate(timeout=timeout)
dump_err = dump.stderr.read() if dump.stderr is not None else b""
dump.wait(timeout=60)
except subprocess.TimeoutExpired:
dump.kill()
load.kill()
raise LostAndFoundError(
f"sqlite3 .recover timed out after {timeout:.0f}s"
)
attempt = {
"command": command,
"dump_returncode": dump.returncode,
"load_returncode": load.returncode,
"dump_stderr_tail": dump_err.decode("utf-8", "replace")[-2000:],
"load_stderr_tail": load_err.decode("utf-8", "replace")[-2000:],
}
attempts.append(attempt)
if _lost_and_found_db_usable(lf_path):
attempt["usable"] = True
return {"binary": sqlite3_bin, "attempts": attempts}
attempt["usable"] = False
raise LostAndFoundError(
"sqlite3 .recover did not produce a usable lost_and_found database: "
+ "; ".join(
f"[{a['command']}] dump rc={a['dump_returncode']} "
f"load rc={a['load_returncode']} "
f"{a['dump_stderr_tail'] or a['load_stderr_tail']}".strip()
for a in attempts
)
)
def _lost_and_found_db_usable(lf_path: Path) -> bool:
if not lf_path.exists() or lf_path.stat().st_size == 0:
return False
try:
conn = sqlite3.connect(str(lf_path))
try:
return conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1"
).fetchone() is not None
finally:
conn.close()
except sqlite3.DatabaseError:
return False
def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")')]
def _notnull_defaults(conn: sqlite3.Connection, table: str) -> dict[int, Any]:
"""Map column index -> substitute value for NOT NULL columns.
Page-level salvage can return NULLs where the live schema says NOT NULL (torn cells, old rows).
Dropping a whole row over one damaged optional counter would defeat the lane, so such NULLs
get the schema default (or '' / 0 when none is declared).
"""
substitutes: dict[int, Any] = {}
for index, row in enumerate(conn.execute(f'PRAGMA table_info("{table}")')):
if not row[3]: # notnull flag
continue
default = row[4]
if default is None:
declared = str(row[2] or "").upper()
substitutes[index] = 0 if ("INT" in declared or "REAL" in declared) else ""
continue
substitutes[index] = _parse_sql_default(str(default))
return substitutes
def _parse_sql_default(text: str) -> Any:
"""Coerce a ``PRAGMA table_info`` default literal: quoted string, int, float, or raw."""
if text.startswith("'") and text.endswith("'"):
return text[1:-1]
for cast in (int, float):
try:
return cast(text)
except ValueError:
continue
return text
def _is_session_id(value: Any) -> bool:
return isinstance(value, str) and bool(SESSION_ID_PATTERN.match(value))
def _looks_like_source(value: Any) -> bool:
if not isinstance(value, str) or not value:
return False
return value in KNOWN_SOURCES or bool(re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", value))
def classify_lost_and_found_row(
nfield: int,
cells: tuple[Any, ...],
) -> Optional[str]:
"""Classify one lost_and_found record by field count + sentinel values."""
if len(cells) >= 3 and cells[0] is None:
# Rowid-alias tables store their INTEGER PRIMARY KEY as NULL in the
# record; messages is the only canonical table shaped like that with
# a session id second and a role third.
if (
isinstance(cells[1], str)
and cells[1]
and isinstance(cells[2], str)
and cells[2] in MESSAGE_ROLES
):
return "messages"
return None
if not _is_session_id(cells[0] if cells else None):
return None
second = cells[1] if len(cells) > 1 else None
if nfield == SESSION_MODEL_USAGE_NFIELD:
# 18 fields, session id first, model string second.
return "session_model_usage" if isinstance(second, str) and second else None
# Known sessions layouts, or an unknown historical one (>= 30 fields): a
# session-id first cell plus a recognizable source string is still strong
# enough for a prefix map.
if (
nfield in SESSIONS_LAYOUT_NFIELDS
or nfield == SESSIONS_LEGACY_MINIMAL_NFIELD
or nfield >= 30
) and _looks_like_source(second):
return "sessions"
return None
def _heuristic_started_at(cells: tuple[Any, ...]) -> float:
for value in cells:
if isinstance(value, (int, float)) and _EPOCH_LOW <= float(value) <= _EPOCH_HIGH:
return float(value)
return 0.0
def _insert_prefix_row(
dest: sqlite3.Connection,
table: str,
dest_columns: list[str],
values: list[Any],
notnull_substitutes: Optional[dict[int, Any]] = None,
) -> bool:
if notnull_substitutes:
values = [
notnull_substitutes[index]
if value is None and index in notnull_substitutes
else value
for index, value in enumerate(values)
]
columns = dest_columns[: len(values)]
quoted = ", ".join(f'"{column}"' for column in columns)
placeholders = ", ".join("?" for _ in columns)
cursor = dest.execute(
f'INSERT OR IGNORE INTO "{table}" ({quoted}) VALUES ({placeholders})',
values,
)
return cursor.rowcount == 1
def _copy_direct_tables(
lf_conn: sqlite3.Connection,
dest: sqlite3.Connection,
) -> dict[str, int]:
"""Copy rows .recover managed to attribute to real canonical tables."""
# Lazy import: session_recovery imports this module inside a function, so
# a module-level import here would be circular.
from hermes_cli.session_recovery import (
_AUXILIARY_TABLE_SCHEMAS,
_AUXILIARY_TABLES,
_CANONICAL_TABLES,
)
copied: dict[str, int] = {}
for table in (*_CANONICAL_TABLES, *_AUXILIARY_TABLES):
source_columns = _table_columns(lf_conn, table)
if not source_columns:
continue
dest_columns = _table_columns(dest, table)
if not dest_columns and table in _AUXILIARY_TABLE_SCHEMAS:
# Lazily-created gateway table: base SessionDB never made it on
# the fresh destination, so create it before copying.
_AUXILIARY_TABLE_SCHEMAS[table](dest)
dest_columns = _table_columns(dest, table)
columns = [c for c in dest_columns if c in source_columns]
if not columns:
continue
quoted = ", ".join(f'"{c}"' for c in columns)
placeholders = ", ".join("?" for _ in columns)
rows = lf_conn.execute(f'SELECT {quoted} FROM "{table}"').fetchall()
if not rows:
copied[table] = 0
continue
before = int(dest.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
dest.executemany(
f'INSERT OR IGNORE INTO "{table}" ({quoted}) VALUES ({placeholders})',
rows,
)
after = int(dest.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
copied[table] = after - before
return copied
def map_lost_and_found_rows(
lf_conn: sqlite3.Connection,
dest: sqlite3.Connection,
) -> dict[str, Any]:
"""Best-effort mapping of a .recover output DB into a fresh SessionDB."""
report: dict[str, Any] = {
"direct_table_rows": {},
"mapped": {"sessions": 0, "messages": 0, "session_model_usage": 0},
"legacy_minimal_sessions": 0,
"unmapped_rows": 0,
"insert_conflicts": 0,
"lost_and_found_tables": [],
}
dest.execute("BEGIN IMMEDIATE")
try:
report["direct_table_rows"] = _copy_direct_tables(lf_conn, dest)
# Per-kind destination columns + NOT NULL substitutes. Never fabricate
# identity fields: a row whose session id / role / source cell is
# genuinely NULL was already rejected by classify_lost_and_found_row,
# so the substitutions only fill NOT NULL bookkeeping counters/flags.
targets: dict[str, tuple[list[str], dict[int, Any]]] = {}
for kind_name, protected in (
("sessions", (0, 1)),
("messages", (1, 2)),
("session_model_usage", (0, 1)),
):
defaults = _notnull_defaults(dest, kind_name)
for index in protected:
defaults.pop(index, None)
targets[kind_name] = (_table_columns(dest, kind_name), defaults)
lf_tables = [
str(row[0])
for row in lf_conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name LIKE 'lost_and_found%'"
)
]
report["lost_and_found_tables"] = lf_tables
for lf_table in lf_tables:
lf_columns = _table_columns(lf_conn, lf_table)
if lf_columns[:3] != ["rootpgno", "pgno", "nfield"]:
continue
for row in lf_conn.execute(f'SELECT * FROM "{lf_table}"'):
try:
nfield = int(row[2]) if row[2] is not None else 0
except (TypeError, ValueError):
report["unmapped_rows"] += 1
continue
lf_rowid = row[3]
cells = tuple(row[4 : 4 + max(nfield, 0)])
kind = classify_lost_and_found_row(nfield, cells)
if kind is None:
report["unmapped_rows"] += 1
continue
columns, defaults = targets[kind]
try:
if kind == "messages":
# Rowid-alias PK is NULL in the record; use the lost_and_found rowid.
values = [lf_rowid, *cells[1 : min(nfield, len(columns))]]
inserted = _insert_prefix_row(dest, kind, columns, values, defaults)
elif kind == "session_model_usage":
values = list(cells[: len(columns)])
inserted = _insert_prefix_row(dest, kind, columns, values, defaults)
elif nfield == SESSIONS_LEGACY_MINIMAL_NFIELD:
# A pre-modern layout whose column order is unknown:
# salvage identity + timing rather than guessing 14
# positional meanings.
inserted = bool(
dest.execute(
"INSERT OR IGNORE INTO sessions "
"(id, source, started_at, title) "
"VALUES (?, ?, ?, ?)",
(
cells[0],
cells[1] if _looks_like_source(cells[1])
else "recovered",
_heuristic_started_at(cells),
"[best-effort recovered] legacy session "
"row (layout unknown)",
),
).rowcount
== 1
)
if inserted:
report["legacy_minimal_sessions"] += 1
else:
values = list(cells[: min(nfield, len(columns))])
inserted = _insert_prefix_row(dest, kind, columns, values, defaults)
except sqlite3.DatabaseError:
report["unmapped_rows"] += 1
continue
if inserted:
report["mapped"][kind] += 1
else:
report["insert_conflicts"] += 1
dest.execute("COMMIT")
except BaseException:
dest.execute("ROLLBACK")
raise
return report
def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
"""Fabricate placeholder parents for salvaged child rows.
Salvaged children (messages, model-usage rows) are NEVER deleted for foreign-key cleanup — a
fabricated parent is cheaper than losing the only surviving copy of the user's data. Stubs are
clearly marked.
"""
result: dict[str, Any] = {
"sessions_stubbed": 0,
"messages_retained": 0,
"usage_rows_retained": 0,
}
dest.execute("BEGIN IMMEDIATE")
try:
orphan_ids: dict[str, dict[str, Any]] = {}
for session_id, first_ts, count in dest.execute(
"SELECT m.session_id, MIN(m.timestamp), COUNT(*) FROM messages AS m "
"WHERE m.session_id IS NOT NULL AND NOT EXISTS "
"(SELECT 1 FROM sessions WHERE sessions.id = m.session_id) "
"GROUP BY m.session_id"
):
orphan_ids[str(session_id)] = {
"started_at": float(first_ts) if first_ts is not None else 0.0,
"message_count": int(count),
}
for (session_id,) in dest.execute(
"SELECT DISTINCT u.session_id FROM session_model_usage AS u "
"WHERE u.session_id IS NOT NULL AND NOT EXISTS "
"(SELECT 1 FROM sessions WHERE sessions.id = u.session_id)"
):
orphan_ids.setdefault(
str(session_id), {"started_at": 0.0, "message_count": 0}
)
sequence = 1
for session_id, info in sorted(orphan_ids.items()):
while True:
title = (
f"[best-effort recovered {sequence}] session metadata "
"was unreadable"
)
sequence += 1
if (
dest.execute(
"SELECT 1 FROM sessions WHERE title = ? LIMIT 1",
(title,),
).fetchone()
is None
):
break
dest.execute(
"INSERT INTO sessions (id, source, started_at, title, "
"message_count) VALUES (?, 'recovered', ?, ?, ?)",
(
session_id,
info["started_at"],
title,
info["message_count"],
),
)
result["sessions_stubbed"] += 1
result["messages_retained"] += info["message_count"]
result["usage_rows_retained"] = int(
dest.execute("SELECT COUNT(*) FROM session_model_usage").fetchone()[0]
)
# Repair dangling intra-sessions references without deleting rows.
dest.execute(
"UPDATE sessions SET parent_session_id = NULL "
"WHERE parent_session_id IS NOT NULL AND NOT EXISTS "
"(SELECT 1 FROM sessions AS p WHERE p.id = sessions.parent_session_id)"
)
dest.execute(
"UPDATE sessions SET system_prompt_hash = NULL "
"WHERE system_prompt_hash IS NOT NULL AND NOT EXISTS "
"(SELECT 1 FROM system_prompts "
"WHERE system_prompts.hash = sessions.system_prompt_hash)"
)
dest.execute("COMMIT")
except BaseException:
dest.execute("ROLLBACK")
raise
return result
def rebuild_fts_indexes(dest: sqlite3.Connection) -> dict[str, str]:
"""Rebuild derived FTS indexes from the salvaged canonical rows."""
results: dict[str, str] = {}
for table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"):
if not _table_columns(dest, table):
continue
try:
dest.execute(f'INSERT INTO "{table}" ("{table}") VALUES (\'rebuild\')')
results[table] = "rebuilt"
except sqlite3.DatabaseError as exc:
results[table] = f"rebuild failed: {exc}"
return results