refactor(hermes_cli): collapse defensive layers in sessions_cmd, join bracket groups in session recovery modules
This commit is contained in:
@@ -82,11 +82,7 @@ def _cli_supports_recover(binary: str) -> bool:
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
probe = subprocess.run(
|
||||
[binary, "-readonly", str(scratch), ".recover"],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
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
|
||||
@@ -129,9 +125,7 @@ def run_cli_lost_and_found_recover(
|
||||
except subprocess.TimeoutExpired:
|
||||
dump.kill()
|
||||
load.kill()
|
||||
raise LostAndFoundError(
|
||||
f"sqlite3 .recover timed out after {timeout:.0f}s"
|
||||
)
|
||||
raise LostAndFoundError(f"sqlite3 .recover timed out after {timeout:.0f}s")
|
||||
attempt = {
|
||||
"command": command,
|
||||
"dump_returncode": dump.returncode,
|
||||
@@ -162,9 +156,7 @@ def _lost_and_found_db_usable(lf_path: Path) -> bool:
|
||||
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
|
||||
return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1").fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.DatabaseError:
|
||||
@@ -218,22 +210,14 @@ def _looks_like_source(value: Any) -> bool:
|
||||
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]:
|
||||
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
|
||||
):
|
||||
if (isinstance(cells[1], str) and cells[1] and isinstance(cells[2], str) and cells[2] in MESSAGE_ROLES):
|
||||
return "messages"
|
||||
return None
|
||||
|
||||
@@ -281,26 +265,16 @@ def _insert_prefix_row(
|
||||
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,
|
||||
)
|
||||
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]:
|
||||
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,
|
||||
)
|
||||
from hermes_cli.session_recovery import (_AUXILIARY_TABLE_SCHEMAS, _AUXILIARY_TABLES, _CANONICAL_TABLES)
|
||||
|
||||
copied: dict[str, int] = {}
|
||||
for table in (*_CANONICAL_TABLES, *_AUXILIARY_TABLES):
|
||||
@@ -323,19 +297,13 @@ def _copy_direct_tables(
|
||||
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,
|
||||
)
|
||||
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]:
|
||||
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] = {
|
||||
@@ -356,11 +324,7 @@ def map_lost_and_found_rows(
|
||||
# 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)),
|
||||
):
|
||||
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)
|
||||
@@ -447,11 +411,7 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
|
||||
clearly marked.
|
||||
"""
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"sessions_stubbed": 0,
|
||||
"messages_retained": 0,
|
||||
"usage_rows_retained": 0,
|
||||
}
|
||||
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]] = {}
|
||||
@@ -470,42 +430,24 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
|
||||
"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}
|
||||
)
|
||||
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"
|
||||
)
|
||||
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
|
||||
):
|
||||
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"],
|
||||
),
|
||||
(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]
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -79,9 +79,7 @@ def _tool_call_name_and_args(tool_call: Any) -> Tuple[str, Mapping[str, Any]]:
|
||||
return name, raw_args if isinstance(raw_args, Mapping) else {}
|
||||
|
||||
|
||||
def _iter_assistant_tool_calls(
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
) -> Iterable[Tuple[str, Mapping[str, Any]]]:
|
||||
def _iter_assistant_tool_calls(messages: Sequence[Mapping[str, Any]]) -> Iterable[Tuple[str, Mapping[str, Any]]]:
|
||||
for msg in messages:
|
||||
if not isinstance(msg, Mapping):
|
||||
continue
|
||||
@@ -96,9 +94,7 @@ def _iter_assistant_tool_calls(
|
||||
yield name, args
|
||||
|
||||
|
||||
def _count_visible_turns(
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
) -> Tuple[int, int, int]:
|
||||
def _count_visible_turns(messages: Sequence[Mapping[str, Any]]) -> Tuple[int, int, int]:
|
||||
"""Return ``(user_turn_count, assistant_turn_count, tool_message_count)``."""
|
||||
roles = Counter(msg.get("role") for msg in messages if isinstance(msg, Mapping))
|
||||
return roles["user"], roles["assistant"], roles["tool"]
|
||||
|
||||
@@ -18,12 +18,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from hermes_state import (
|
||||
FTS_STORAGE_VERSION,
|
||||
SCHEMA_VERSION,
|
||||
SessionDB,
|
||||
_db_opens_cleanly,
|
||||
)
|
||||
from hermes_state import (FTS_STORAGE_VERSION, SCHEMA_VERSION, SessionDB, _db_opens_cleanly)
|
||||
|
||||
|
||||
ProgressCallback = Callable[[dict[str, Any]], None]
|
||||
@@ -38,10 +33,7 @@ _CANONICAL_TABLES = (
|
||||
"async_delegations",
|
||||
)
|
||||
|
||||
_TOPIC_TABLES = (
|
||||
"telegram_dm_topic_mode",
|
||||
"telegram_dm_topic_bindings",
|
||||
)
|
||||
_TOPIC_TABLES = ("telegram_dm_topic_mode", "telegram_dm_topic_bindings")
|
||||
|
||||
|
||||
def _init_delivery_ledger_schema(conn: sqlite3.Connection) -> None:
|
||||
@@ -63,12 +55,7 @@ _AUXILIARY_TABLE_SCHEMAS: dict[str, Callable[[sqlite3.Connection], None]] = {
|
||||
|
||||
_AUXILIARY_TABLES = tuple(_AUXILIARY_TABLE_SCHEMAS)
|
||||
|
||||
_INVENTORY_TABLES = (
|
||||
*_CANONICAL_TABLES,
|
||||
"state_meta",
|
||||
*_TOPIC_TABLES,
|
||||
*_AUXILIARY_TABLES,
|
||||
)
|
||||
_INVENTORY_TABLES = (*_CANONICAL_TABLES, "state_meta", *_TOPIC_TABLES, *_AUXILIARY_TABLES)
|
||||
|
||||
# These values describe derived indexes or the schema that owns an optional
|
||||
# table. A fresh destination must generate them from its own current schema.
|
||||
@@ -134,9 +121,7 @@ def _validate_paths(
|
||||
for suffix in _SIDECAR_SUFFIXES:
|
||||
candidate = _sidecar_path(output, suffix)
|
||||
if os.path.lexists(candidate):
|
||||
raise SessionRecoverySafetyError(
|
||||
f"Refusing to overwrite existing recovery output: {candidate}"
|
||||
)
|
||||
raise SessionRecoverySafetyError(f"Refusing to overwrite existing recovery output: {candidate}")
|
||||
|
||||
work_root = (
|
||||
work_dir.expanduser().resolve(strict=True)
|
||||
@@ -144,9 +129,7 @@ def _validate_paths(
|
||||
else (output.parent if output is not None else source.parent)
|
||||
)
|
||||
if not work_root.is_dir():
|
||||
raise SessionRecoverySafetyError(
|
||||
f"Recovery work directory is not a directory: {work_root}"
|
||||
)
|
||||
raise SessionRecoverySafetyError(f"Recovery work directory is not a directory: {work_root}")
|
||||
return source, output, work_root
|
||||
|
||||
|
||||
@@ -180,11 +163,7 @@ def _same_filesystem(left: Path, right: Path) -> bool:
|
||||
return left.anchor.casefold() == right.anchor.casefold()
|
||||
|
||||
|
||||
def _disk_space_preflight(
|
||||
source: Path,
|
||||
work_root: Path,
|
||||
output_parent: Optional[Path],
|
||||
) -> dict[str, Any]:
|
||||
def _disk_space_preflight(source: Path, work_root: Path, output_parent: Optional[Path]) -> dict[str, Any]:
|
||||
"""Require space for the disposable bundle, output, and safety headroom."""
|
||||
|
||||
bundle_bytes = sum(
|
||||
@@ -355,9 +334,7 @@ def _copy_rows(
|
||||
destination.executemany(insert_sql, rows)
|
||||
result["copied_rows"] += len(rows)
|
||||
if progress_cb is not None:
|
||||
progress_cb(
|
||||
{"table": table, "copied_rows": result["copied_rows"], "source_rows": expected_rows}
|
||||
)
|
||||
progress_cb({"table": table, "copied_rows": result["copied_rows"], "source_rows": expected_rows})
|
||||
except sqlite3.DatabaseError as exc:
|
||||
result["status"] = "partial" if result["copied_rows"] else "failed"
|
||||
result["error"] = str(exc)
|
||||
@@ -370,10 +347,7 @@ def _copy_rows(
|
||||
return result
|
||||
|
||||
|
||||
def _table_inventory(
|
||||
conn: sqlite3.Connection,
|
||||
table: str,
|
||||
) -> dict[str, Any]:
|
||||
def _table_inventory(conn: sqlite3.Connection, table: str) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"available": False, "columns": [], "rows": None}
|
||||
try:
|
||||
columns = _table_columns(conn, table)
|
||||
@@ -419,10 +393,7 @@ def _snapshot_and_inspect(
|
||||
work_root: Path,
|
||||
) -> tuple[tempfile.TemporaryDirectory[str], Path, dict[str, Any]]:
|
||||
before = _source_fingerprint(source)
|
||||
temp_dir = tempfile.TemporaryDirectory(
|
||||
prefix="hermes-session-recovery-",
|
||||
dir=str(work_root),
|
||||
)
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix="hermes-session-recovery-", dir=str(work_root))
|
||||
snapshot_dir = Path(temp_dir.name)
|
||||
try:
|
||||
snapshot_source, copied = _copy_source_bundle(source, snapshot_dir)
|
||||
@@ -454,11 +425,7 @@ def _snapshot_and_inspect(
|
||||
raise
|
||||
|
||||
|
||||
def inspect_session_database(
|
||||
source_path: Path,
|
||||
*,
|
||||
work_dir: Optional[Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
def inspect_session_database(source_path: Path, *, work_dir: Optional[Path] = None) -> dict[str, Any]:
|
||||
"""Inspect canonical table readability without opening the source itself."""
|
||||
|
||||
source, _, work_root = _validate_paths(source_path, work_dir=work_dir)
|
||||
@@ -476,10 +443,7 @@ def inspect_session_database(
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
def _ensure_auxiliary_destination_schema(
|
||||
destination: sqlite3.Connection,
|
||||
table: str,
|
||||
) -> None:
|
||||
def _ensure_auxiliary_destination_schema(destination: sqlite3.Connection, table: str) -> None:
|
||||
"""Create a lazy auxiliary table on the recovered destination.
|
||||
|
||||
Recovery initializes the destination via base ``SessionDB``, which does not create gateway-owned
|
||||
@@ -517,14 +481,11 @@ def _copy_table(
|
||||
source_rows: Optional[int],
|
||||
) -> dict[str, Any]:
|
||||
"""Copy one canonical table: straight chunked copy, or rowid-range salvage when ``salvage``."""
|
||||
copy_kwargs = dict(chunk_size=chunk_size, progress_cb=progress_cb, source_rows=source_rows)
|
||||
if table == "state_meta":
|
||||
return _copy_state_meta(
|
||||
source, destination, salvage=salvage, chunk_size=chunk_size, progress_cb=progress_cb, source_rows=source_rows
|
||||
)
|
||||
return _copy_state_meta(source, destination, salvage=salvage, **copy_kwargs)
|
||||
if salvage:
|
||||
return _copy_table_salvage(
|
||||
source, destination, table, chunk_size=chunk_size, progress_cb=progress_cb, source_rows=source_rows
|
||||
)
|
||||
return _copy_table_salvage(source, destination, table, **copy_kwargs)
|
||||
result: dict[str, Any] = {"source_rows": source_rows, "copied_rows": 0}
|
||||
columns = _compatible_columns(source, destination, table, result)
|
||||
if columns is None:
|
||||
@@ -544,12 +505,7 @@ def _copy_table(
|
||||
)
|
||||
|
||||
|
||||
def _append_skipped_range(
|
||||
ranges: list[dict[str, Any]],
|
||||
low: int,
|
||||
high: int,
|
||||
error: str,
|
||||
) -> None:
|
||||
def _append_skipped_range(ranges: list[dict[str, Any]], low: int, high: int, error: str) -> None:
|
||||
"""Record skipped rowid ranges without producing one entry per row."""
|
||||
|
||||
if ranges and ranges[-1]["high"] + 1 == low and ranges[-1]["error"] == error:
|
||||
@@ -558,10 +514,7 @@ def _append_skipped_range(
|
||||
ranges.append({"low": low, "high": high, "error": error})
|
||||
|
||||
|
||||
def _salvage_rowid_bounds(
|
||||
source: sqlite3.Connection,
|
||||
table: str,
|
||||
) -> dict[str, Any]:
|
||||
def _salvage_rowid_bounds(source: sqlite3.Connection, table: str) -> dict[str, Any]:
|
||||
"""Find the readable rowid edges without scanning the complete table."""
|
||||
|
||||
result: dict[str, Any] = {"errors": [], "fallback_edges": []}
|
||||
@@ -606,13 +559,7 @@ def _salvage_rowid_bounds(
|
||||
return result
|
||||
|
||||
|
||||
def _probe_populated_edge(
|
||||
source: sqlite3.Connection,
|
||||
table: str,
|
||||
*,
|
||||
edge: str,
|
||||
anchor: int,
|
||||
) -> dict[str, Any]:
|
||||
def _probe_populated_edge(source: sqlite3.Connection, table: str, *, edge: str, anchor: int) -> dict[str, Any]:
|
||||
"""Find a finite bound for a damaged rowid edge (issue #80205).
|
||||
|
||||
Substituting the whole rowid domain for a failed edge probe made range bisection burn the entire
|
||||
@@ -634,9 +581,7 @@ def _probe_populated_edge(
|
||||
span = 1
|
||||
while True:
|
||||
candidate = position + span if ascending else position - span
|
||||
if (ascending and candidate >= domain_limit) or (
|
||||
not ascending and candidate <= domain_limit
|
||||
):
|
||||
if (ascending and candidate >= domain_limit) or (not ascending and candidate <= domain_limit):
|
||||
# No clean empty-tail answer before the domain edge; keep the
|
||||
# domain fallback rather than inventing a bound.
|
||||
result["bound"] = domain_limit
|
||||
@@ -776,9 +721,7 @@ def _copy_table_salvage(
|
||||
progress_cb: Optional[ProgressCallback],
|
||||
source_rows: Optional[int],
|
||||
insert_prefix: str = "INSERT",
|
||||
row_filter: Optional[
|
||||
Callable[[tuple[Any, ...], tuple[str, ...]], bool]
|
||||
] = None,
|
||||
row_filter: Optional[Callable[[tuple[Any, ...], tuple[str, ...]], bool]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Best-effort rowid-range copy that continues past damaged source pages."""
|
||||
|
||||
@@ -829,10 +772,7 @@ def _copy_table_salvage(
|
||||
if skipped_ranges:
|
||||
result["status"] = "partial" if result["copied_rows"] else "failed"
|
||||
result["error"] = f"{len(skipped_ranges)} rowid range(s) skipped"
|
||||
elif (
|
||||
source_rows is not None
|
||||
and result["copied_rows"] + result["excluded_rows"] != source_rows
|
||||
):
|
||||
elif (source_rows is not None and result["copied_rows"] + result["excluded_rows"] != source_rows):
|
||||
result["status"] = "partial"
|
||||
result["error"] = (
|
||||
f"copied {result['copied_rows']} and excluded "
|
||||
@@ -927,10 +867,7 @@ def _copy_state_meta(
|
||||
filtered_source_rows: Optional[int] = None
|
||||
try:
|
||||
filtered_source_rows = int(
|
||||
source.execute(
|
||||
f"SELECT COUNT(*) FROM state_meta WHERE key NOT IN ({placeholders})",
|
||||
params,
|
||||
).fetchone()[0]
|
||||
source.execute(f"SELECT COUNT(*) FROM state_meta WHERE key NOT IN ({placeholders})", params).fetchone()[0]
|
||||
)
|
||||
except sqlite3.DatabaseError:
|
||||
pass # the copy loop below will return the concrete read error
|
||||
@@ -949,9 +886,7 @@ def _copy_state_meta(
|
||||
)
|
||||
|
||||
|
||||
def _reconstruct_missing_sessions(
|
||||
destination: sqlite3.Connection,
|
||||
) -> dict[str, Any]:
|
||||
def _reconstruct_missing_sessions(destination: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Recreate placeholder session rows for salvaged orphaned messages.
|
||||
|
||||
When the ``sessions`` b-tree is damaged worse than ``messages``, salvage can recover the
|
||||
@@ -1008,18 +943,11 @@ def _reconcile(destination: sqlite3.Connection, table: str, where: str, mutation
|
||||
return count
|
||||
|
||||
|
||||
_DEPENDENT_TABLES = (
|
||||
"messages",
|
||||
"session_model_usage",
|
||||
"compression_locks",
|
||||
"telegram_dm_topic_bindings",
|
||||
)
|
||||
_DEPENDENT_TABLES = ("messages", "session_model_usage", "compression_locks", "telegram_dm_topic_bindings")
|
||||
_RELINK_COUNTERS = ("session_prompt_refs_cleared", "sessions_parent_cleared")
|
||||
|
||||
|
||||
def _cleanup_partial_orphans(
|
||||
destination: sqlite3.Connection,
|
||||
) -> dict[str, Any]:
|
||||
def _cleanup_partial_orphans(destination: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Reconcile references to sessions that could not be salvaged.
|
||||
|
||||
Messages are never discarded for lack of a session row: their owning session is reconstructed as
|
||||
@@ -1257,10 +1185,7 @@ def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any
|
||||
fts_keys = tuple(key for key in _GENERATED_META_KEYS if key.startswith("fts_"))
|
||||
placeholders = ", ".join("?" for _ in fts_keys)
|
||||
with _immediate_transaction(destination):
|
||||
destination.execute(
|
||||
f"DELETE FROM state_meta WHERE key IN ({placeholders})",
|
||||
fts_keys,
|
||||
)
|
||||
destination.execute(f"DELETE FROM state_meta WHERE key IN ({placeholders})", fts_keys)
|
||||
destination.execute(
|
||||
"INSERT INTO state_meta(key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -1442,11 +1367,7 @@ def recover_session_database(
|
||||
if chunk_size <= 0:
|
||||
raise SessionRecoverySafetyError("chunk_size must be greater than zero")
|
||||
|
||||
source, output, work_root = _validate_paths(
|
||||
source_path,
|
||||
output_path=output_path,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
source, output, work_root = _validate_paths(source_path, output_path=output_path, work_dir=work_dir)
|
||||
assert output is not None
|
||||
disk_space = _disk_space_preflight(source, work_root, output.parent)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ working and imports stay one-way. Browse picker: :mod:`hermes_cli.sessions_cmd_b
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -111,12 +112,9 @@ def _cmd_repair(args):
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
_repair_db = SessionDB()
|
||||
try:
|
||||
with SessionDB() as _repair_db:
|
||||
n = _repair_db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
||||
print(f"✓ Repaired — {n} sessions recovered.")
|
||||
finally:
|
||||
_repair_db.close()
|
||||
print(f"✓ Repaired — {n} sessions recovered.")
|
||||
except Exception:
|
||||
print("✓ Repaired.")
|
||||
return
|
||||
@@ -201,8 +199,7 @@ def _cmd_recover(args):
|
||||
class _RecoveryProgress:
|
||||
"""`recover` progress printer: one live-updating `` <table>: n/total`` line per table."""
|
||||
|
||||
def __init__(self):
|
||||
self.table = None
|
||||
table = None
|
||||
|
||||
def __call__(self, info):
|
||||
table = info.get("table")
|
||||
@@ -396,14 +393,11 @@ def _export_html(args, collect):
|
||||
sessions = collect()
|
||||
if sessions is None:
|
||||
return
|
||||
if len(sessions) == 1:
|
||||
content = generate_html_export(sessions[0])
|
||||
else:
|
||||
content = generate_multi_session_html_export(sessions)
|
||||
single = len(sessions) == 1
|
||||
content = generate_html_export(sessions[0]) if single else generate_multi_session_html_export(sessions)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
suffix = "" if len(sessions) == 1 else "s"
|
||||
print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)")
|
||||
print(f"Exported {len(sessions)} session{'' if single else 's'} to {args.output} (HTML)")
|
||||
|
||||
|
||||
def _export_jsonl(args, collect):
|
||||
@@ -577,9 +571,7 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
|
||||
if db.delete_session(resolved_session_id, sessions_dir=_sessions_dir(), expected_delete_ids=delete_target_ids):
|
||||
delegate_count = len(delete_target_ids) - 1
|
||||
delegate_suffix = (
|
||||
""
|
||||
if not delegate_count
|
||||
else f" and {delegate_count} delegate session{'' if delegate_count == 1 else 's'}"
|
||||
f" and {delegate_count} delegate session{'' if delegate_count == 1 else 's'}" if delegate_count else ""
|
||||
)
|
||||
print(f"Deleted exported session '{resolved_session_id}'{delegate_suffix}.")
|
||||
else:
|
||||
@@ -594,8 +586,7 @@ def _cmd_delete(db, args):
|
||||
return _not_found(args.session_id)
|
||||
# The user named this id directly so the delete is honored, but a pin is a
|
||||
# "keep" flag and silently destroying it is surprising — say so.
|
||||
_get_session = getattr(db, "get_session", None)
|
||||
_meta = (_get_session(resolved_session_id) or {}) if callable(_get_session) else {}
|
||||
_meta = db.get_session(resolved_session_id) or {}
|
||||
_pinned_note = " (this session is PINNED)" if _meta.get("pinned") else ""
|
||||
if not args.yes:
|
||||
if not _confirm_prompt(
|
||||
@@ -671,18 +662,12 @@ def _note_pinned_skipped(db, filters, action):
|
||||
"""Pinned sessions are excluded by default from bulk prune/archive (pin =
|
||||
durable keep). `prune --include-pinned` opts in; archive has no such flag,
|
||||
so archive always spares pinned rows. Tell the user how many were spared."""
|
||||
_count_matches = getattr(db, "count_prune_matches", None)
|
||||
if not callable(_count_matches):
|
||||
return
|
||||
_base = {k: v for k, v in filters.items() if k != "include_pinned"}
|
||||
try:
|
||||
skipped = max(
|
||||
int(_count_matches(**_base, include_pinned=True))
|
||||
- int(_count_matches(**_base, include_pinned=False)),
|
||||
0,
|
||||
)
|
||||
except TypeError:
|
||||
return # a db double without include_pinned support — skip the note
|
||||
skipped = max(
|
||||
int(db.count_prune_matches(**_base, include_pinned=True))
|
||||
- int(db.count_prune_matches(**_base, include_pinned=False)),
|
||||
0,
|
||||
)
|
||||
if not skipped:
|
||||
return
|
||||
suffix = "" if skipped == 1 else "s"
|
||||
@@ -778,12 +763,10 @@ def _cmd_prune_or_archive(db, args, action):
|
||||
return
|
||||
|
||||
if action == "prune":
|
||||
count = db.prune_sessions(sessions_dir=_sessions_dir(), **filters)
|
||||
print(f"Pruned {count} session(s).")
|
||||
print(f"Pruned {db.prune_sessions(sessions_dir=_sessions_dir(), **filters)} session(s).")
|
||||
else:
|
||||
count = db.archive_sessions(**filters)
|
||||
print(
|
||||
f"Archived {count} session(s). They're hidden from listings "
|
||||
f"Archived {db.archive_sessions(**filters)} session(s). They're hidden from listings "
|
||||
"but fully recoverable (nothing was deleted)."
|
||||
)
|
||||
|
||||
@@ -996,8 +979,6 @@ def _cmd_optimize_storage(db, args):
|
||||
# VACUUM needs a full second copy — require headroom ≈ current file size.
|
||||
do_vacuum = not getattr(args, "no_vacuum", False)
|
||||
try:
|
||||
import shutil
|
||||
|
||||
free_bytes = shutil.disk_usage(db_path.parent).free
|
||||
except Exception:
|
||||
free_bytes = None
|
||||
@@ -1146,11 +1127,9 @@ def cmd_sessions(args, sessions_parser=None):
|
||||
print(f"Error: Could not open session database: {e}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
with db:
|
||||
handler = _DB_HANDLERS.get(action)
|
||||
if handler is None:
|
||||
sessions_parser.print_help()
|
||||
return
|
||||
return handler(db, args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user