refactor(hermes_state_portability): fold coerce wrappers, shared _rich_rows, compact docstrings

This commit is contained in:
Teknium
2026-09-02 18:21:56 -07:00
parent e9dfc808ee
commit 410f9a0fa6
+64 -92
View File
@@ -1,9 +1,7 @@
"""Session listing/rich rows, export, and import (portability) for SessionDB.
Plain mixin consumed by ``hermes_state.SessionDB``: no ``__init__``, no state
of its own; methods use host attributes established by ``SessionDB.__init__``.
Must never import hermes_state (cycle) — shared constants live in
hermes_state_common.
Plain mixin for ``hermes_state.SessionDB`` (no ``__init__``/state of its own).
Must never import hermes_state (cycle); shared constants live in hermes_state_common.
"""
import logging
@@ -14,7 +12,7 @@ from typing import Any, Dict, List, Optional
from agent.skill_commands import SKILL_SCAFFOLD_SQL_LIKE
from hermes_state_common import SCHEMA_SQL, _PREVIEW_RAW_SUBQUERY_SQL, _shape_preview, _sql_session_last_active
# Keep the pre-split logger identity so log filtering/capture is unchanged.
# Pre-split logger identity so log filtering/capture is unchanged.
logger = logging.getLogger("hermes_state")
_IMPORT_SESSION_TEXT_FIELDS = (
@@ -64,9 +62,9 @@ _IMPORT_FLOAT_COLS = ("ended_at", "estimated_cost_usd", "actual_cost_usd")
def _rich_select(select_cols: str, where: str, tail: str = "", prompt_select: Optional[str] = "") -> str:
"""``list_sessions_rich``-shaped SELECT: resolved prompt (``prompt_select``
fragment; None omits prompt columns AND the join), preview, last_active.
Whitespace matches the historical inline queries (SQL text is pinned)."""
"""``list_sessions_rich``-shaped SELECT: resolved prompt (``prompt_select`` fragment;
None omits prompt columns AND the join), preview, last_active. Whitespace matches
the historical inline queries (SQL text is pinned)."""
prompt_join = "" if prompt_select is None else "LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash"
return f"""
SELECT {select_cols}{prompt_select or ""},
@@ -86,8 +84,8 @@ class SessionPortabilityMixin:
@classmethod
def _compact_session_cols(cls) -> str:
"""``s.``-prefixed SELECT list of every SCHEMA_SQL ``sessions`` column
except prompt storage internals (the compact_rows projection)."""
"""``s.``-prefixed SELECT list of every SCHEMA_SQL ``sessions`` column except
prompt storage internals (the compact_rows projection)."""
if cls._session_compact_cols_sql is None:
declared = cls._parse_schema_columns(SCHEMA_SQL)["sessions"]
cls._session_compact_cols_sql = ", ".join(
@@ -106,10 +104,12 @@ class SessionPortabilityMixin:
with self._lock:
return self._conn.execute(sql, params).fetchall()
def _rich_rows(self, sql: str, params=()) -> List[Dict[str, Any]]:
return [self._rich_row(row) for row in self._locked_rows(sql, params)]
def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]:
"""Distinct non-empty session cwds with usage stats, for repo discovery.
Aggregates across ALL history so every repo the user worked in
surfaces; children/branches count (a worktree session is a real
"""Distinct non-empty session cwds with usage stats, for repo discovery. Aggregates
across ALL history; children/branches count (a worktree session is a real
workspace signal)."""
where = "cwd IS NOT NULL AND TRIM(cwd) != ''"
if not include_archived:
@@ -125,40 +125,34 @@ class SessionPortabilityMixin:
]
def list_cron_job_runs(self, job_id: str, limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
"""List the run sessions produced by a single cron job, newest first.
Cron runs are flat sessions with id ``cron_{job_id}_{timestamp}``; they
never compress or branch, so this skips ``list_sessions_rich``'s
compression-chain CTE / leading-wildcard ``id_query`` path (which
seeds from EVERY ``source='cron'`` row). Instead a ``[prefix,
prefix_hi)`` index range scan on id filtered to ``source='cron'``, so
work scales with the requested window. Returns the
``list_sessions_rich`` row shape (``preview`` + ``last_active``)."""
"""Run sessions of one cron job, newest first, in the ``list_sessions_rich`` row shape.
Cron runs are flat ``cron_{job_id}_{timestamp}`` sessions that never compress or
branch, so this skips ``list_sessions_rich``'s compression-chain CTE /
leading-wildcard ``id_query`` path (which seeds from EVERY ``source='cron'`` row)
for a ``[prefix, prefix_hi)`` id range scan that scales with the window."""
prefix = f"cron_{job_id}_"
# Half-open upper bound: bump the final byte so the range covers exactly
# the ids starting with ``prefix``.
# Half-open upper bound: bump the final byte so the range covers exactly the prefix.
prefix_hi = prefix[:-1] + chr(ord(prefix[-1]) + 1)
query = _rich_select(
"s.*", "s.source = 'cron' AND s.id >= ? AND s.id < ?",
"\n ORDER BY s.started_at DESC, s.id DESC\n LIMIT ? OFFSET ?",
prompt_select=f",\n {_PROMPT_RESOLVED_SQL}",
)
rows = self._locked_rows(query, (prefix, prefix_hi, limit, offset))
return [self._rich_row(row) for row in rows]
return self._rich_rows(query, (prefix, prefix_hi, limit, offset))
def _get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]:
"""One session with the ``list_sessions_rich`` enriched columns, or
None. ``compact_rows=True`` omits the ``system_prompt`` blob."""
"""One session with the ``list_sessions_rich`` enriched columns, or None.
``compact_rows=True`` omits the ``system_prompt`` blob."""
return self._get_session_rich_rows_batch([session_id], compact_rows=compact_rows).get(session_id)
def _get_session_rich_rows_batch(self, session_ids, compact_rows: bool = False) -> Dict[str, Dict[str, Any]]:
"""Enriched rows for many sessions in one query, keyed by id; missing
ids are simply absent. Resolves a page of compression tips in one
round trip instead of one query per root row."""
"""Enriched rows for many sessions in one query, keyed by id; missing ids are absent
(a page of compression tips resolves in one round trip)."""
ids = [sid for sid in session_ids if sid]
if not ids:
return {}
# Old SQLite caps bound variables at 999 (SQLITE_MAX_VARIABLE_NUMBER);
# limit=10000 callers exist. Chunk here — the single choke point.
# Old SQLite caps bound variables at 999 (SQLITE_MAX_VARIABLE_NUMBER); limit=10000
# callers exist. Chunk here — the single choke point.
_CHUNK = 900
if len(ids) > _CHUNK:
result: Dict[str, Dict[str, Any]] = {}
@@ -172,18 +166,16 @@ class SessionPortabilityMixin:
f"s.id IN ({','.join('?' for _ in ids)})",
prompt_select=None if compact_rows else f", {_PROMPT_RESOLVED_SQL}",
)
rows = self._locked_rows(query, ids)
return {s["id"]: s for s in map(self._rich_row, rows)}
return {s["id"]: s for s in self._rich_rows(query, ids)}
def get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]:
"""Public wrapper for :meth:`_get_session_rich_row` (web server hydration)."""
return self._get_session_rich_row(session_id, compact_rows=compact_rows)
def list_skill_scaffolded_sessions(self, limit: int = 200) -> List[Dict[str, Any]]:
"""Titled sessions whose first user turn was a ``/skill`` invocation.
Their titles were generated from the expanded skill body, so they
describe the skill, not the request. Returns ``id``, ``title`` and the
first-turn ``content`` so callers can re-derive what was typed. Newest first."""
"""Titled sessions whose first user turn was a ``/skill`` invocation (their titles
describe the expanded skill body, not the request). Returns ``id``, ``title`` and
the first-turn ``content`` so callers can re-derive what was typed. Newest first."""
rows = self._locked_rows(
"""
SELECT s.id, s.title, m.content
@@ -234,20 +226,15 @@ class SessionPortabilityMixin:
def adopt_session_lineage_from(
self, donor_db: Any, session_id: str, *, retire_donor: bool = True
) -> Dict[str, Any]:
"""Adopt *session_id*'s full compression lineage from *donor_db* (a
full SessionDB — the mixin cannot import it).
Stranded-bot-session heal: before the desktop routed session RPCs by
target session, a profile bot's rows accumulated in the DEFAULT
profile's state.db; this moves the conversation to where routing now
looks. Pure composition ``donor_db.export_session_lineage()`` ->
``self.import_sessions()``: routing/handoff/activity fields reset,
already-present ids skipped (idempotent re-adoption). With
``retire_donor`` and a complete adoption, donor rows are ARCHIVED
(never deleted) with ``end_reason='adopted_by_profile'`` — deliberately
NOT in the recoverable set, so resurrection cannot undo an adoption.
Returns the ``import_sessions`` dict plus ``adopted`` and
``donor_retired`` (True only when EVERY segment's retirement applied)."""
"""Adopt *session_id*'s full compression lineage from *donor_db* (stranded-bot-session
heal: a profile bot's rows accumulated in the DEFAULT profile's state.db before the
desktop routed session RPCs by target session). Pure composition
``donor_db.export_session_lineage()`` -> ``self.import_sessions()``: runtime
fields reset, already-present ids skipped (idempotent). With ``retire_donor`` and
a complete adoption, donor rows are ARCHIVED (never deleted) with
``end_reason='adopted_by_profile'`` — deliberately NOT in the recoverable set, so
resurrection cannot undo an adoption. Returns the ``import_sessions`` dict plus
``adopted`` and ``donor_retired`` (True only when EVERY segment retired)."""
payload = donor_db.export_session_lineage(session_id)
if not payload:
return {
@@ -256,10 +243,9 @@ class SessionPortabilityMixin:
}
segments = payload.get("segments") or [payload]
# Divergence guard: a segment we will SKIP (already here) may have kept
# growing in the donor after a partial adoption; retiring it would strand
# those messages behind a non-recoverable archive. Still import, but
# refuse to retire when the donor is ahead.
# Divergence guard: a segment we will SKIP (already here) may have kept growing in
# the donor after a partial adoption; retiring it would strand those messages
# behind a non-recoverable archive. Still import, but refuse to retire.
donor_ahead = False
for seg in segments:
seg_id = seg.get("id")
@@ -294,12 +280,11 @@ class SessionPortabilityMixin:
return {**result, "adopted": adopted, "donor_retired": donor_retired}
def _retire_donor_segment(self, donor_db: Any, seg_id: str) -> bool:
"""Archive one adopted donor segment; False when skipped or failed.
TOCTOU close-out: the divergence guard used EXPORT-TIME counts; re-read
both stores right before stamping so donor growth never lands behind a
non-recoverable archive (equal-count CONTENT divergence is accepted —
bytes stay in the donor either way, only reachability differs). A
retirement failure must not fail the adoption (a later resume retries
"""Archive one adopted donor segment; False when skipped or failed. TOCTOU close-out:
the divergence guard used EXPORT-TIME counts; re-read both stores right before
stamping so donor growth never lands behind a non-recoverable archive
(equal-count CONTENT divergence is accepted — bytes stay in the donor either way).
A retirement failure must not fail the adoption (a later resume retries
idempotently), but never claims success it didn't have."""
try:
donor_now = len(donor_db.get_messages(seg_id))
@@ -312,8 +297,8 @@ class SessionPortabilityMixin:
seg_id, donor_now, local_now,
)
return False
# First end_reason wins in end_session(); reopen so the adoption
# boundary is stamped even on ended segments.
# First end_reason wins in end_session(); reopen so the adoption boundary is
# stamped even on ended segments.
donor_db.reopen_session(seg_id)
donor_db.end_session(seg_id, "adopted_by_profile")
donor_db.set_session_archived(seg_id, True)
@@ -359,14 +344,6 @@ class SessionPortabilityMixin:
except (TypeError, ValueError):
return default
@classmethod
def _float_or_none(cls, value: Any) -> Optional[float]:
return cls._coerce_or(value, float, None)
@classmethod
def _int_or_default(cls, value: Any, default: int = 0) -> int:
return cls._coerce_or(value, int, default)
@staticmethod
def _import_int_or_none(value: Any, field: str) -> Optional[int]:
if value is None:
@@ -415,8 +392,8 @@ class SessionPortabilityMixin:
return {"session": clean_session, "messages": clean_messages}
def _validate_import_payload(self, sessions: List[Dict[str, Any]]) -> tuple:
"""Size/shape/type validation of the whole payload; returns
``(normalized_items, errors)``. Every rejected entry is reported."""
"""Size/shape/type validation of the whole payload; returns ``(normalized_items,
errors)``. Every rejected entry is reported."""
normalized: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
seen_ids: set[str] = set()
@@ -474,15 +451,15 @@ class SessionPortabilityMixin:
def _import_session_row(self, conn, raw: Dict[str, Any], messages: List[Dict[str, Any]], session_id: str) -> None:
"""INSERT one normalized session + its messages; counts fixed up after."""
started_at = self._float_or_none(raw.get("started_at"))
started_at = self._coerce_or(raw.get("started_at"), float, None)
params = {
"id": session_id, "source": str(raw.get("source") or "import"),
"system_prompt_hash": self._store_system_prompt(conn, raw.get("system_prompt")),
"started_at": time.time() if started_at is None else started_at,
"archived": 1 if raw.get("archived") else 0,
**{col: raw.get(col) for col in _IMPORT_PASSTHROUGH_COLS},
**{col: self._float_or_none(raw.get(col)) for col in _IMPORT_FLOAT_COLS},
**{col: self._int_or_default(raw.get(col)) for col in _IMPORT_INT_COLS},
**{col: self._coerce_or(raw.get(col), float, None) for col in _IMPORT_FLOAT_COLS},
**{col: self._coerce_or(raw.get(col), int, 0) for col in _IMPORT_INT_COLS},
}
conn.execute(_IMPORT_SESSION_INSERT_SQL, params)
sanitized_messages = [
@@ -497,10 +474,9 @@ class SessionPortabilityMixin:
@staticmethod
def _attach_import_parents(conn, parent_updates: List[tuple]) -> int:
"""Re-attach imported children whose parent exists (in the store or
the same payload) without creating a cycle; returns the detached count.
Only the closing edge of a cycle is dropped, so later entries can
still attach to the now-root session."""
"""Re-attach imported children whose parent exists (in the store or the same payload)
without creating a cycle; returns the detached count. Only the closing edge of a
cycle is dropped, so later entries can still attach to the now-root session."""
parent_by_child = dict(parent_updates)
def _would_create_cycle(session_id: str, parent_id: str) -> bool:
@@ -532,17 +508,13 @@ class SessionPortabilityMixin:
return detached
def import_sessions(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Import sessions exported by :meth:`export_session` or ``export_all``.
Existing ids are skipped. A child keeps its parent only when the parent
exists or is in the same payload; otherwise it is detached so partial
imports pass FK validation. Gateway routing, handoff, rewind and other
live runtime state are reset: this restores history, not ownership of
a live channel or process.
Activity contract: export INCLUDES ``last_activity_*`` (durable row
fields) but import RESETS them to NULL — resurrecting a stale
"working ..." label would fabricate activity the watchdog and listings
act on. Intentional asymmetry, pinned by regression test."""
"""Import sessions exported by :meth:`export_session` or ``export_all``. Existing ids
are skipped. A child keeps its parent only when the parent exists or is in the
same payload; otherwise it is detached so partial imports pass FK validation.
Gateway routing, handoff, rewind and other live runtime state are reset: this
restores history, not ownership of a live channel or process. Export INCLUDES
``last_activity_*`` but import RESETS them to NULL — resurrecting a stale
"working ..." label would fabricate activity the watchdog acts on (pinned)."""
if not isinstance(sessions, list):
raise ValueError("sessions must be a list")
if len(sessions) > self._IMPORT_MAX_SESSIONS: