Skip to content

feat: add read-only session lineage report endpoint - #2012

Merged
1 commit merged into
nesquena:masterfrom
dso2ng:feat/session-lineage-report
May 10, 2026
Merged

feat: add read-only session lineage report endpoint#2012
1 commit merged into
nesquena:masterfrom
dso2ng:feat/session-lineage-report

Conversation

@dso2ng

@dso2ng dso2ng commented May 10, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

What Changed

  • Add read_session_lineage_report(db_path, session_id, max_hops=20) in api.agent_sessions.
  • Add GET /api/session/lineage/report?session_id=<sid>.
  • Return a read-only report with:
    • mutation: false
    • found, lineage_key, tip_session_id
    • bounded segments with tip / hidden_segment roles
    • non-continuation children with child_session roles
    • manual_review: true for bounded/pathological continuation cases
  • Add focused tests for linear compression/cli-close chains, cross-source guard behavior, child-session branches, bounded/manual-review output, endpoint success, and endpoint 404.

Why It Matters

This gives WebUI a small backend counterpart to the already-shipped lineage sidebar projection without adding archive/delete actions, background workers, storage migrations, or UI changes. Future UI work can lazily fetch this report only when needed.

Verification

  • /home/dso2ng/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_session_lineage_report.py tests/test_session_lineage_metadata_api.py tests/test_session_lineage_collapse.py -q -> 22 passed
  • /home/dso2ng/.hermes/hermes-agent/venv/bin/python -m py_compile api/agent_sessions.py api/routes.py api/models.py
  • git diff --check
  • Added-line non-ASCII guard -> non_ascii_added_lines=0

Risks / Follow-ups

  • This endpoint is intentionally read-only; it does not return archive_candidates or delete_candidates and does not mutate WebUI JSON or state.db.
  • It only reports rows derivable from state.db.sessions; missing/old schemas degrade to a safe not-found/empty report.
  • Frontend full-segment lazy expansion should remain a separate follow-up after this contract is reviewed.
  • fix: prefer latest compressed session segment #2011 is a separate frontend/sidebar tip-selection fix and should be allowed to land independently.

Model Used

  • Provider: OpenAI Codex via Hermes Agent
  • Model: gpt-5.5

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

Reading api/agent_sessions.py:439-595 on the PR head (the new read_session_lineage_report + helpers), api/routes.py:3185-3193 (the new GET handler), and the test file at tests/test_session_lineage_report.py:1-186. Also cross-checked against the existing helpers in api/agent_sessions.py:73-186 (_optional_col, _is_continuation_session) and the existing lineage-walk consumers at :231, 258, 279. The contract is well-defined and stays inside the read-only invariant the PR description claims.

Code reference

The endpoint dispatcher is appropriately minimal:

# api/routes.py:3188-3193
if parsed.path == "/api/session/lineage/report":
    sid = parse_qs(parsed.query).get("session_id", [""])[0]
    if not sid:
        return bad(handler, "session_id required", 400)
    report = read_session_lineage_report(_active_state_db_path(), sid)
    if not report.get("found"):
        return bad(handler, "Session not found", 404)
    return j(handler, report)

And read_session_lineage_report correctly bounds the parent walk and uses the existing _is_continuation_session predicate (so cross-source / Telegram-rooted parents are excluded from segments and the cross-surface test at test_lineage_report_keeps_cross_surface_parent_out_of_hidden_segments validates that). Manual-review flagging at :526 distinguishes "we hit max_hops" from "we found a cycle":

# api/agent_sessions.py:520-528 (PR head)
for _hop in range(max(0, int(max_hops))):
    parent_id = current.get('parent_session_id')
    parent = fetch_one(parent_id)
    if not parent or parent_id in seen:
        manual_review = bool(parent_id and parent_id in seen)
        break
    if not _is_continuation_session(parent, current):
        break
    segments.append(parent)
    seen.add(parent_id)
    current = parent
else:
    manual_review = True

The for/else is the right idiom here — the else only runs when the loop completes without break, i.e. when max_hops is exhausted. Nice.

Diagnosis

Mostly small/optional. One contract concern.

1. Empty-then-found mismatch when id row exists but pre-schema

_empty_lineage_report returns found=False, which the route handler at :3192 translates to a 404. But read_session_lineage_report also returns _empty_lineage_report(sid) (still found=False) at :489-491 when the schema lacks the required columns:

# api/agent_sessions.py:484-491
required = {'id', 'parent_session_id', 'end_reason'}
if not required.issubset(session_cols):
    return _empty_lineage_report(sid)

That's effectively "404 because the DB schema is too old to answer". A caller can't distinguish that from "404 because the session id doesn't exist". Suggest either:

  • A 500 / 503 status with a schema_unsupported: true body for that branch, or
  • Setting found=True plus a schema_unsupported: true flag and letting the caller decide.

Mostly matters for diagnostic UX once a caller is built on top.

2. _active_state_db_path() may return None on some onboarding states

Reading api/routes.py:6307 and surrounding usage, _active_state_db_path() can return None when no profile has bootstrapped its state.db yet. The PR's read_session_lineage_report does db_path = Path(db_path) at :475 which will raise TypeError on None before the db_path.exists() check, so the endpoint would 500 rather than 404. Suggest:

def read_session_lineage_report(db_path, session_id, max_hops=20):
    sid = str(session_id or '').strip()
    if not sid:
        return _empty_lineage_report('')
    if not db_path:
        return _empty_lineage_report(sid)
    db_path = Path(db_path)
    if not db_path.exists():
        return _empty_lineage_report(sid)

Two-line patch, prevents an onboarding-window crash if anyone calls this endpoint very early.

3. _lineage_report_row exposes started_at/updated_at as raw DB types

At :441-452 the row helper returns started_at straight off the row dict, which is a SQLite REAL (float Unix epoch in your test fixture). Callers will need to know whether to format it as ISO. The existing API surface in api/models.py tends to surface these as ISO strings via _iso_from_epoch or similar — worth a one-liner alignment so the new endpoint matches the rest of the API. Not a correctness issue, just consistency.

4. Test exercise breadth

The endpoint test at :153 patches routes.j and routes._active_state_db_path and drives routes.handle_get directly. That's the right shape, but the path matcher at routes.py:3185 is a long if/elif chain — it's worth one negative test that confirms paths not matching /api/session/lineage/report are not accidentally routed to the new handler (e.g., /api/session/lineage/report/foo). The parsed.path exact-match guards against this, but having a test pin the contract prevents future refactors from broadening the match.

Otherwise, the read-only property, archive_candidates/delete_candidates exclusion (asserted explicitly in the first test), mutation: false flag, and the manual_review semantics are all clear and match the PR description.

Verification

Per cron policy I read the worktree but did not execute the tests. The schema-probe path, the exact-equal path matcher, and the bounded-walk are well-formed; the PR description's 22 passed from the dso2ng test run is plausible given the test coverage I read. LGTM with the two small _active_state_db_path() and schema-probe-vs-not-found polish items above.

@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in a42adbe May 10, 2026
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 10, 2026
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
bernyforce pushed a commit to bernyforce/hermes-webui that referenced this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants