fix: expose session lineage metadata in API (#1370)

PR #1358 added the client-side lineage collapse helper, but
/api/sessions often did not include _lineage_root_id for the WebUI
JSON sessions visible in the sidebar. In that case the helper has no
grouping key and multiple same-title continuation rows remain visible.

This PR:
- Reads parent_session_id and end_reason from state.db.sessions for
  the WebUI sidebar's session ids
- Walks the parent chain when end_reason is 'compression' or
  'cli_close', producing _lineage_root_id and _compression_segment_count
- Cycle-detects via a 'seen' set
- Preserves projected lineage metadata on imported/gateway session rows
- Allows sidebar collapse to group cross-surface continuation chains
  (CLI-close → WebUI continuation) while keeping non-continuation
  parent rows flat

Co-authored-by: Dennis Soong <dso2ng@gmail.com>
This commit is contained in:
Dennis Soong
2026-04-30 23:04:49 +00:00
committed by nesquena-hermes
parent ffd11037b1
commit 7da1e074e4
4 changed files with 256 additions and 1 deletions
+65
View File
@@ -255,3 +255,68 @@ def read_importable_agent_session_rows(
if limit is None:
return projected
return projected[:max(0, int(limit))]
def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]:
"""Return compression-lineage metadata for known WebUI sidebar sessions.
WebUI sessions are persisted as JSON files, but Hermes Agent also mirrors
them into ``state.db.sessions`` for insights/session history. Compression
and cross-surface continuation create parent chains there. ``/api/sessions``
needs to surface that lineage to the sidebar so client-side collapse can
group logical continuations without mutating or deleting any session files.
Missing DBs, old schemas, or incomplete rows degrade to an empty mapping.
"""
wanted = {str(sid) for sid in (session_ids or []) if sid}
db_path = Path(db_path)
if not wanted or not db_path.exists():
return {}
try:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols:
return {}
cur.execute("SELECT id, parent_session_id, end_reason FROM sessions")
rows = {row['id']: dict(row) for row in cur.fetchall()}
except Exception:
return {}
metadata: dict[str, dict] = {}
for sid in wanted:
row = rows.get(sid)
if not row:
continue
parent_id = row.get('parent_session_id')
if parent_id:
metadata.setdefault(sid, {})['parent_session_id'] = parent_id
root_id = sid
current_id = sid
segment_count = 1
seen = {sid}
while True:
current = rows.get(current_id)
parent_id = current.get('parent_session_id') if current else None
parent = rows.get(parent_id) if parent_id else None
if not parent or parent_id in seen:
break
if parent.get('end_reason') not in {'compression', 'cli_close'}:
break
root_id = parent_id
current_id = parent_id
seen.add(parent_id)
segment_count += 1
if root_id != sid:
entry = metadata.setdefault(sid, {})
entry['_lineage_root_id'] = root_id
entry['_compression_segment_count'] = segment_count
return metadata
+32 -1
View File
@@ -15,7 +15,7 @@ from api.config import (
get_effective_default_model, _get_session_agent_lock,
)
from api.workspace import get_last_workspace
from api.agent_sessions import read_importable_agent_session_rows
from api.agent_sessions import read_importable_agent_session_rows, read_session_lineage_metadata
logger = logging.getLogger(__name__)
@@ -746,6 +746,31 @@ def _hide_from_default_sidebar(session: dict) -> bool:
return source == 'cron' or sid.startswith('cron_')
def _active_state_db_path() -> Path:
"""Return state.db for the active Hermes profile, degrading to HERMES_HOME."""
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
return hermes_home / 'state.db'
def _enrich_sidebar_lineage_metadata(sessions: list[dict]) -> None:
"""Attach state.db compression lineage metadata used by sidebar collapse."""
try:
metadata = read_session_lineage_metadata(
_active_state_db_path(),
{s.get('session_id') for s in sessions},
)
except Exception:
return
for session in sessions:
sid = session.get('session_id')
if sid in metadata:
session.update(metadata[sid])
def all_sessions():
active_stream_ids = _active_stream_ids()
# Phase C: try index first for O(1) read; fall back to full scan
@@ -804,6 +829,7 @@ def all_sessions():
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
_enrich_sidebar_lineage_metadata(result)
return result
except Exception:
logger.debug("Failed to load session index, falling back to full scan")
@@ -832,6 +858,7 @@ def all_sessions():
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
_enrich_sidebar_lineage_metadata(result)
return result
@@ -1015,6 +1042,10 @@ def get_cli_sessions() -> list:
'raw_source': row.get('raw_source'),
'session_source': row.get('session_source'),
'source_label': row.get('source_label'),
'parent_session_id': row.get('parent_session_id'),
'_lineage_root_id': row.get('_lineage_root_id'),
'_lineage_tip_id': row.get('_lineage_tip_id'),
'_compression_segment_count': row.get('_compression_segment_count'),
'is_cli_session': True,
})
except Exception as _cli_err: