3a0e7df799
A concurrent WAL checkpoint / reset / frame-flush can surface SQLITE_IOERR to a reader on a perfectly healthy database: a mode=ro connection cannot perform the WAL recovery the read needs, because recovery writes the -shm index and read-only mode refuses. The window is millisecond-scale. Today that one-shot error escapes the SessionDB read-only constructor, and GET /api/sessions turns it into a 500 the desktop reads as an authoritative empty list. Retry it, bounded, in the constructor so every read-only opener is covered — the sidebar poll, cross-profile aggregation, recall, browse — rather than at one route. A persistent IOERR still exhausts the budget and propagates. Remaining transient failures answer 503, so the client keeps the list it has. On the write path, BEGIN IMMEDIATE can hit the same transient IOERR before the callback runs. That one is safe to retry on the same connection because nothing has been mutated; once the callback starts, settlement is unknown and the error propagates. Never close()+reopen to heal it — close() cancels this process's POSIX advisory locks on the file for every sibling connection, and a list poll's reader must stay disposable so a replaced state.db is observed and the pre-repair forensic backup stays reachable. Fixes #100436 Co-authored-by: rkfshakti <rkfshakti@users.noreply.github.com> Co-authored-by: AKAZIK-py <AKAZIK-py@users.noreply.github.com>
929 lines
38 KiB
Python
929 lines
38 KiB
Python
"""Session dashboard routes (extracted verbatim from web_server.py).
|
|
|
|
Three routers because the original registration points are far apart and
|
|
global route order matters: ``list_router`` (GET /api/sessions) was registered
|
|
before the profiles ``sessions_router`` include, ``search_router``
|
|
(GET /api/sessions/search) right after it, and ``manage_router`` (the
|
|
mutation/detail endpoints) thousands of lines later - each is mounted at its
|
|
original registration point so the app's route table is byte-identical.
|
|
|
|
Handler bodies are byte-identical; web_server-owned helpers are reached via
|
|
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
|
|
``monkeypatch.setattr(web_server, "_helper", ...)`` keep working.
|
|
"""
|
|
|
|
import asyncio # noqa: F401 — used by handlers
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import time # noqa: F401
|
|
from typing import Any, Dict, List, Optional # noqa: F401
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request # noqa: F401
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from hermes_cli.web_deps import late
|
|
from hermes_cli.web_models import (
|
|
BulkDeleteSessions,
|
|
SessionImport,
|
|
SessionOwnerBackfill,
|
|
SessionPrune,
|
|
SessionRename,
|
|
)
|
|
from hermes_state import is_malformed_db_error, is_transient_sqlite_error
|
|
|
|
# Same logger the handlers used before extraction (identical logger object).
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
|
|
list_router = APIRouter()
|
|
search_router = APIRouter()
|
|
manage_router = APIRouter()
|
|
|
|
# Late-bound web_server helpers (resolved at call time; cycle-safe,
|
|
# monkeypatch-transparent).
|
|
_cron_default_profile = late("_cron_default_profile")
|
|
_cron_profile_home = late("_cron_profile_home")
|
|
_import_sessions_for_profile = late("_import_sessions_for_profile")
|
|
_maybe_auto_archive_for_profile = late("_maybe_auto_archive_for_profile")
|
|
_open_session_db_for_profile = late("_open_session_db_for_profile")
|
|
_prune_sessions = late("_prune_sessions")
|
|
_read_session_import_body = late("_read_session_import_body")
|
|
_session_latest_descendant = late("_session_latest_descendant")
|
|
_strip_session_list_rows = late("_strip_session_list_rows")
|
|
|
|
|
|
def _resolve_session_id(db, session_id: str) -> Optional[str]:
|
|
"""Resolve *session_id*, distinguishing "absent" from "unreadable".
|
|
|
|
A corrupt ``state.db`` does not raise on every read. The exact-match
|
|
lookup goes through the ``sessions`` primary-key index, so a damaged
|
|
index simply *misses* and returns None — indistinguishable from a
|
|
session that was never there. Only the prefix fallback scans the base
|
|
b-tree and raises ``database disk image is malformed``.
|
|
|
|
Both outcomes used to end at ``404 Session not found``: one silently, one
|
|
as an unhandled 500 that the Desktop surfaced as "session unavailable".
|
|
Reporting a corrupt store as an empty one cost a day of looking at
|
|
session logic during the 2026-08-31 incident, so classify it here and say
|
|
what is actually wrong.
|
|
"""
|
|
try:
|
|
return db.resolve_session_id(session_id)
|
|
except sqlite3.DatabaseError as exc:
|
|
if not is_malformed_db_error(exc):
|
|
raise
|
|
_log.error(
|
|
"state.db is corrupt while resolving session %s: %s", session_id, exc
|
|
)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=(
|
|
"Session store is corrupt (database disk image is malformed). "
|
|
"Sessions cannot be read until it is repaired — run "
|
|
"`hermes doctor` for diagnosis."
|
|
),
|
|
) from exc
|
|
|
|
|
|
@list_router.get("/api/sessions")
|
|
def get_sessions(
|
|
# ``le=100`` caps the page size (idea from #39200): an unbounded limit
|
|
# lets one request drag every session row (plus correlated-subquery
|
|
# preview work) out of SQLite in a single hit.
|
|
limit: int = Query(20, ge=0, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
min_messages: int = 0,
|
|
archived: str = "exclude",
|
|
order: str = "created",
|
|
source: str = None,
|
|
sources: str = None,
|
|
exclude_sources: str = None,
|
|
cwd_prefix: str = None,
|
|
full: bool = False,
|
|
profile: Optional[str] = None,
|
|
):
|
|
"""List sessions.
|
|
|
|
``archived`` controls how soft-archived sessions are treated:
|
|
``exclude`` (default) hides them, ``only`` returns just the archived ones
|
|
(used by the desktop "Archived sessions" settings panel), and ``include``
|
|
returns both.
|
|
|
|
``order`` controls pagination order: ``created`` (default, by original
|
|
start time) or ``recent`` (by latest activity across the compression
|
|
chain). ``recent`` keeps a long-running conversation on the first page
|
|
after it auto-compresses into a fresh continuation id.
|
|
|
|
Rows omit ``system_prompt``/``model_config`` (the payload-dominating
|
|
fields no list UI reads) unless ``full=1`` is passed.
|
|
"""
|
|
if archived not in ("exclude", "only", "include"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="archived must be one of: exclude, only, include",
|
|
)
|
|
if order not in ("created", "recent"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="order must be one of: created, recent",
|
|
)
|
|
profile_name: Optional[str] = None
|
|
if profile:
|
|
profile_name, _ = _cron_profile_home(profile)
|
|
try:
|
|
# Auto-archive is the only configured write on this GET path. Run it
|
|
# through a dedicated maintenance connection, close that writer, then
|
|
# open the listing connection read-only.
|
|
_maybe_auto_archive_for_profile(profile)
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
min_message_count = max(0, min_messages)
|
|
archived_only = archived == "only"
|
|
include_archived = archived == "include"
|
|
# Optional source scoping: ``source`` includes a single class,
|
|
# ``sources`` includes any of several comma-separated classes, and
|
|
# ``exclude_sources`` (comma-separated) drops classes. The desktop
|
|
# uses these to split recents (exclude=cron) from the cron-jobs
|
|
# section (source=cron) into two independent lists.
|
|
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
|
|
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
|
|
sessions = db.list_sessions_rich(
|
|
source=source or None,
|
|
sources=source_list or None,
|
|
exclude_sources=exclude_list or None,
|
|
cwd_prefix=(cwd_prefix or None),
|
|
limit=limit,
|
|
offset=offset,
|
|
min_message_count=min_message_count,
|
|
include_archived=include_archived,
|
|
archived_only=archived_only,
|
|
order_by_last_active=order == "recent",
|
|
# SQL-level projection: when the caller didn't ask for full
|
|
# rows, skip the system_prompt blob inside SQLite too (pairs
|
|
# with the API-level _strip_session_list_rows below).
|
|
compact_rows=not full,
|
|
include_pinned=True,
|
|
)
|
|
total = db.session_count(
|
|
source=source or None,
|
|
sources=source_list or None,
|
|
cwd_prefix=(cwd_prefix or None),
|
|
exclude_sources=exclude_list or None,
|
|
min_message_count=min_message_count,
|
|
include_archived=include_archived,
|
|
archived_only=archived_only,
|
|
exclude_children=True,
|
|
)
|
|
now = time.time()
|
|
# Same ownership contract as get_session_detail: rows are stamped
|
|
# with the serving profile even when the request wasn't explicitly
|
|
# scoped, so default-profile rows never circulate unowned.
|
|
row_profile = profile_name or _cron_default_profile()
|
|
for s in sessions:
|
|
s["is_active"] = (
|
|
s.get("ended_at") is None
|
|
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
|
)
|
|
s["profile"] = row_profile
|
|
s["is_default_profile"] = row_profile == "default"
|
|
# SQLite stores the flag as 0/1; expose a real JSON boolean.
|
|
s["archived"] = bool(s.get("archived"))
|
|
s["pinned"] = bool(s.get("pinned"))
|
|
if not full:
|
|
_strip_session_list_rows(sessions)
|
|
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
|
finally:
|
|
db.close()
|
|
except HTTPException:
|
|
raise
|
|
except sqlite3.OperationalError as exc:
|
|
_log.exception("GET /api/sessions failed")
|
|
# 503, not 500: the store is busy, not gone. The desktop keeps the
|
|
# sidebar it already has instead of reading a 500 as an authoritative
|
|
# empty list. Retrying the OPEN here is deliberately not done — the
|
|
# bounded retry lives in SessionDB's read-only constructor, so every
|
|
# read-only opener gets it, not just this route.
|
|
transient = is_transient_sqlite_error(exc)
|
|
raise HTTPException(
|
|
status_code=503 if transient else 500,
|
|
detail=(
|
|
"Session store is busy (disk I/O or lock). Retry; the list was not cleared."
|
|
if transient
|
|
else "Internal server error"
|
|
),
|
|
) from exc
|
|
except Exception:
|
|
_log.exception("GET /api/sessions failed")
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
|
|
@search_router.get("/api/sessions/search")
|
|
async def search_sessions(
|
|
q: str = "",
|
|
limit: int = 20,
|
|
profile: Optional[str] = None,
|
|
source: str = None,
|
|
sources: str = None,
|
|
exclude_sources: str = None,
|
|
):
|
|
"""Search sessions by ID plus full-text message content using FTS5.
|
|
|
|
Direct session-id matches are surfaced first, then FTS message-content
|
|
matches. Results are deduped by compression lineage, not by raw
|
|
``session_id``. Auto-compression rotates a conversation onto a fresh
|
|
session id (and leaves the old segment's messages in the FTS index), so one
|
|
logical chat can own many ``sessions`` rows that all match the same query.
|
|
Branches also use ``parent_session_id``, but they are real alternate
|
|
conversations; don't collapse branch-specific hits back into the parent.
|
|
"""
|
|
if not q or not q.strip():
|
|
return {"results": []}
|
|
try:
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
safe_limit = max(1, min(int(limit or 20), 100))
|
|
source_filter = source or None
|
|
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
|
|
include_sources = [source_filter] if source_filter else (source_list or None)
|
|
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
|
|
now = time.time()
|
|
|
|
# Walk parent_session_id to the compression root, memoized so a
|
|
# chain of compression segments only costs one walk. We deliberately
|
|
# stop at branch/delegate edges: those sessions may diverge from the
|
|
# parent and should remain searchable on their own.
|
|
root_cache: dict = {}
|
|
|
|
def compression_root(session_id: str) -> str:
|
|
if not session_id:
|
|
return session_id
|
|
if session_id in root_cache:
|
|
return root_cache[session_id]
|
|
chain = []
|
|
cur = session_id
|
|
visited = set()
|
|
root = session_id
|
|
while cur and cur not in visited:
|
|
visited.add(cur)
|
|
chain.append(cur)
|
|
if cur in root_cache:
|
|
root = root_cache[cur]
|
|
break
|
|
try:
|
|
s = db.get_session(cur)
|
|
except Exception:
|
|
s = None
|
|
if not s:
|
|
root = cur
|
|
break
|
|
parent = s.get("parent_session_id") if isinstance(s, dict) else None
|
|
if not parent:
|
|
root = cur
|
|
break
|
|
try:
|
|
parent_session = db.get_session(parent)
|
|
except Exception:
|
|
parent_session = None
|
|
if not parent_session:
|
|
root = cur
|
|
break
|
|
parent_ended_at = parent_session.get("ended_at")
|
|
started_at = s.get("started_at")
|
|
is_compression_edge = (
|
|
parent_session.get("end_reason") == "compression"
|
|
and parent_ended_at is not None
|
|
and started_at is not None
|
|
and started_at >= parent_ended_at
|
|
)
|
|
if not is_compression_edge:
|
|
root = cur
|
|
break
|
|
cur = parent
|
|
for node in chain:
|
|
root_cache[node] = root
|
|
return root
|
|
|
|
tip_cache: dict = {}
|
|
|
|
def lineage_tip(root_id: str) -> str:
|
|
if root_id in tip_cache:
|
|
return tip_cache[root_id]
|
|
tip = root_id
|
|
try:
|
|
resolved = db.get_compression_tip(root_id)
|
|
if resolved:
|
|
tip = resolved
|
|
except Exception:
|
|
pass
|
|
tip_cache[root_id] = tip
|
|
return tip
|
|
|
|
# Both ID matches and content matches share one keyspace, keyed by
|
|
# compression lineage root, so an id-hit and a content-hit on the
|
|
# same logical conversation collapse to a single result. The first
|
|
# hit for a lineage wins; ID matches run first and take priority.
|
|
seen: dict = {}
|
|
|
|
def add_lineage_result(raw_sid: str, payload: dict) -> None:
|
|
if not raw_sid:
|
|
return
|
|
root = compression_root(raw_sid)
|
|
if root in seen or len(seen) >= safe_limit:
|
|
return
|
|
payload = dict(payload)
|
|
sid = lineage_tip(root)
|
|
payload["session_id"] = sid
|
|
payload["lineage_root"] = root
|
|
try:
|
|
row = db.get_session_rich_row(sid)
|
|
except Exception:
|
|
row = None
|
|
if row:
|
|
payload.update(
|
|
{
|
|
"id": row.get("id") or sid,
|
|
"source": row.get("source"),
|
|
"model": row.get("model"),
|
|
"title": row.get("title"),
|
|
"started_at": row.get("started_at"),
|
|
"ended_at": row.get("ended_at"),
|
|
"last_active": row.get("last_active") or row.get("started_at"),
|
|
"is_active": (
|
|
row.get("ended_at") is None
|
|
and (now - (row.get("last_active") or row.get("started_at") or 0)) < 300
|
|
),
|
|
"message_count": row.get("message_count") or 0,
|
|
"tool_call_count": row.get("tool_call_count") or 0,
|
|
"input_tokens": row.get("input_tokens") or 0,
|
|
"output_tokens": row.get("output_tokens") or 0,
|
|
"preview": row.get("preview"),
|
|
"parent_session_id": row.get("parent_session_id"),
|
|
"archived": bool(row.get("archived")),
|
|
}
|
|
)
|
|
else:
|
|
payload["id"] = sid
|
|
seen[root] = payload
|
|
|
|
# Direct ID matches first: users often paste a session id from CLI,
|
|
# logs, or another Hermes surface. FTS can't find those unless the
|
|
# id happens to appear in message text. search_sessions_by_id is
|
|
# SQL-bounded, so this stays cheap even with thousands of sessions.
|
|
for row in db.search_sessions_by_id(
|
|
q,
|
|
limit=safe_limit,
|
|
include_archived=True,
|
|
source=source_filter,
|
|
sources=source_list or None,
|
|
exclude_sources=exclude_list or None,
|
|
):
|
|
sid = row.get("id")
|
|
preview = (row.get("preview") or "").strip()
|
|
snippet = preview or f"Session ID: {sid}"
|
|
add_lineage_result(
|
|
sid,
|
|
{
|
|
"snippet": snippet,
|
|
"role": None,
|
|
"source": row.get("source"),
|
|
"model": row.get("model"),
|
|
"session_started": row.get("started_at"),
|
|
},
|
|
)
|
|
|
|
# Auto-add prefix wildcards so partial words match
|
|
# e.g. "nimb" → "nimb*" matches "nimby"
|
|
# Preserve quoted phrases and existing wildcards as-is
|
|
import re
|
|
terms = []
|
|
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
|
|
if token.startswith('"') or token.endswith("*"):
|
|
terms.append(token)
|
|
else:
|
|
terms.append(token + "*")
|
|
prefix_query = " ".join(terms)
|
|
# Over-fetch so lineage dedup can still surface `limit` distinct
|
|
# conversations even when several hits collapse onto one root.
|
|
fetch_limit = max(safe_limit * 5, 50)
|
|
matches = db.search_messages(
|
|
query=prefix_query,
|
|
source_filter=include_sources,
|
|
exclude_sources=exclude_list or None,
|
|
limit=fetch_limit,
|
|
fields=(
|
|
"session_id",
|
|
"role",
|
|
"snippet",
|
|
"source",
|
|
"model",
|
|
"session_started",
|
|
),
|
|
)
|
|
|
|
for m in matches:
|
|
if len(seen) >= safe_limit:
|
|
break
|
|
add_lineage_result(
|
|
m["session_id"],
|
|
{
|
|
"snippet": m.get("snippet", ""),
|
|
"role": m.get("role"),
|
|
"source": m.get("source"),
|
|
"model": m.get("model"),
|
|
"session_started": m.get("session_started"),
|
|
},
|
|
)
|
|
return {"results": list(seen.values())}
|
|
finally:
|
|
db.close()
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
_log.exception("GET /api/sessions/search failed")
|
|
raise HTTPException(status_code=500, detail="Search failed")
|
|
|
|
|
|
@manage_router.post("/api/sessions/bulk-delete")
|
|
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
|
"""Delete every session in ``body.ids`` in a single DB transaction.
|
|
|
|
Backs the dashboard's bulk-select-and-delete flow on the sessions
|
|
page. POST (not DELETE) because most HTTP clients refuse to send a
|
|
request body on DELETE and a body is the natural shape for a list
|
|
of IDs — Starlette accepts both, but POSTing a list keeps proxies,
|
|
curl, and the browser ``fetch`` API consistent.
|
|
|
|
Per-row contract matches :meth:`SessionDB.delete_sessions`:
|
|
|
|
* Unknown IDs are silently skipped (the response ``deleted`` count
|
|
reflects what really happened, not the input length). This is
|
|
deliberate — UI selection state can race against another tab's
|
|
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
|
|
batch.
|
|
* Children of every deleted parent are orphaned, not cascade-
|
|
deleted.
|
|
* Active and archived sessions ARE deleted when explicitly
|
|
selected — unlike ``DELETE /api/sessions/empty``, the user
|
|
hand-picked the rows so we trust the selection.
|
|
* Like the other session-delete endpoints, this does NOT pass a
|
|
``sessions_dir`` through; on-disk transcript / request-dump
|
|
cleanup runs at the CLI/agent layer on the next prune pass.
|
|
|
|
The response carries the actual deleted count, so the dashboard
|
|
can surface it in a toast. The IDs that were removed are not
|
|
echoed back because the client already knows what it asked to
|
|
delete (unknown IDs are silently skipped — see contract above)
|
|
and can prune its in-memory list directly from the request.
|
|
"""
|
|
# Enforce a hard cap so a runaway/typo'd selection can't lock the
|
|
# DB writer for an extended window. The dashboard pages 20 rows
|
|
# at a time; 500 covers a "select all on every page in a
|
|
# reasonable scrollback" worst case without opening the door to
|
|
# multi-thousand-row transactions.
|
|
if len(body.ids) > 500:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="ids must contain at most 500 entries",
|
|
)
|
|
def _delete() -> int:
|
|
db = _open_session_db_for_profile(body.profile, read_only=False)
|
|
try:
|
|
return db.delete_sessions(body.ids)
|
|
finally:
|
|
db.close()
|
|
|
|
deleted = await asyncio.to_thread(_delete)
|
|
return {"ok": True, "deleted": deleted}
|
|
|
|
|
|
@manage_router.post("/api/sessions/import")
|
|
async def import_sessions_endpoint(request: Request):
|
|
"""Import one or more sessions exported from the dashboard or CLI.
|
|
|
|
This is intentionally separate from ``/api/ops/import``: that endpoint
|
|
restores a whole Hermes backup archive, while this endpoint is scoped to
|
|
session rows/messages and is safe to use from the Sessions page.
|
|
"""
|
|
try:
|
|
raw_body = await _read_session_import_body(request)
|
|
body = SessionImport.model_validate_json(raw_body)
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="Invalid session import payload") from exc
|
|
|
|
try:
|
|
result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
if not result.get("ok", False):
|
|
raise HTTPException(status_code=400, detail=result)
|
|
return result
|
|
|
|
|
|
@manage_router.get("/api/sessions/empty/count")
|
|
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
|
|
"""Return the number of empty, ended, non-archived sessions.
|
|
|
|
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
|
|
UI hides the affordance so users aren't presented with a button
|
|
that does nothing. Cheap, single-COUNT query.
|
|
"""
|
|
def _count() -> int:
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
return db.count_empty_sessions()
|
|
finally:
|
|
db.close()
|
|
|
|
return {"count": await asyncio.to_thread(_count)}
|
|
|
|
|
|
@manage_router.delete("/api/sessions/empty")
|
|
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
|
|
"""Delete every empty, ended, non-archived session in a single
|
|
transaction.
|
|
|
|
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
|
|
|
|
* "Empty" means the session owns no rows in ``messages`` at all — not
|
|
merely ``message_count == 0``. A rewound or in-place-compacted chat
|
|
keeps its dropped turns as soft-archived (``active = 0``) rows while
|
|
the counter reads zero, and those rows are the only recoverable copy
|
|
of the transcript (#95868).
|
|
* Active sessions are skipped (``ended_at IS NULL``) so a live
|
|
agent isn't yanked mid-handshake.
|
|
* Archived sessions are skipped — the user explicitly chose to
|
|
keep those rows.
|
|
* Children of deleted parents are orphaned, not cascade-deleted.
|
|
|
|
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
|
|
below, this doesn't pass a ``sessions_dir`` through — the on-disk
|
|
transcript / request-dump cleanup is wired at the CLI/agent layer
|
|
but the web server historically leaves file cleanup to the next
|
|
prune-on-startup pass. Matching that pre-existing trade-off keeps
|
|
the two delete endpoints' DB-vs-disk behaviour consistent.
|
|
"""
|
|
def _delete() -> int:
|
|
db = _open_session_db_for_profile(profile, read_only=False)
|
|
try:
|
|
return db.delete_empty_sessions()
|
|
finally:
|
|
db.close()
|
|
|
|
deleted = await asyncio.to_thread(_delete)
|
|
return {"ok": True, "deleted": deleted}
|
|
|
|
|
|
@manage_router.get("/api/sessions/stats")
|
|
async def get_session_stats(profile: Optional[str] = None):
|
|
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
|
|
|
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
|
path isn't captured as a session id by the parameterized route.
|
|
"""
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
total = db.session_count(include_archived=True)
|
|
active_store = db.session_count(include_archived=False)
|
|
archived = db.session_count(archived_only=True)
|
|
messages = db.message_count()
|
|
by_source: Dict[str, int] = {}
|
|
try:
|
|
by_source = db.session_count_by_source(
|
|
include_archived=True,
|
|
exclude_children=True,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"total": total,
|
|
"active_store": active_store,
|
|
"archived": archived,
|
|
"messages": messages,
|
|
"by_source": by_source,
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@manage_router.get("/api/sessions/{session_id}")
|
|
async def get_session_detail(session_id: str, profile: Optional[str] = None):
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
sid = _resolve_session_id(db, session_id)
|
|
session = db.get_session(sid) if sid else None
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
# Always stamp the owning profile — the serving profile is known even
|
|
# when the request carries no ``?profile=`` (it's this process's own
|
|
# profile). Stamping only on explicit ``?profile=`` left rows for the
|
|
# default/primary profile systematically unowned, so multi-profile
|
|
# clients resolved them to whichever gateway happened to be active
|
|
# (cross-profile open asymmetry, #67603 family).
|
|
session["profile"] = (
|
|
_cron_profile_home(profile)[0] if profile else _cron_default_profile()
|
|
)
|
|
session["is_default_profile"] = session["profile"] == "default"
|
|
return session
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@manage_router.get("/api/sessions/{session_id}/latest-descendant")
|
|
async def get_session_latest_descendant(
|
|
session_id: str,
|
|
profile: Optional[str] = None,
|
|
):
|
|
def _lookup():
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
return _session_latest_descendant(session_id, db)
|
|
finally:
|
|
db.close()
|
|
|
|
latest, path = await asyncio.to_thread(_lookup)
|
|
if not latest:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return {
|
|
"requested_session_id": path[0] if path else session_id,
|
|
"session_id": latest,
|
|
"path": path,
|
|
"changed": bool(path and latest != path[0]),
|
|
}
|
|
|
|
|
|
@manage_router.get("/api/sessions/{session_id}/messages")
|
|
async def get_session_messages(
|
|
session_id: str,
|
|
profile: Optional[str] = None,
|
|
limit: Optional[int] = Query(None, ge=0),
|
|
offset: int = Query(0, ge=0),
|
|
order: Optional[str] = Query(None),
|
|
include_compacted: bool = Query(False),
|
|
):
|
|
if order not in (None, "oldest", "latest"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="order must be one of: oldest, latest",
|
|
)
|
|
|
|
def _read():
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
sid = _resolve_session_id(db, session_id)
|
|
if not sid:
|
|
return None
|
|
sid = db.resolve_resume_session_id(sid)
|
|
# Always page this endpoint. An omitted limit used to load an
|
|
# entire transcript, which can be hundreds of thousands of rows
|
|
# for a runaway session and exhaust the dashboard process. Keep
|
|
# explicit pagination anchored at the start, while the default
|
|
# dashboard view returns the latest page in chronological order.
|
|
default_page = limit is None
|
|
latest_page = order == "latest" or (order is None and default_page)
|
|
_limit = 500 if default_page else min(limit, 500)
|
|
return sid, _limit, db.get_messages(
|
|
sid,
|
|
limit=_limit,
|
|
offset=offset,
|
|
latest=latest_page,
|
|
include_compacted=include_compacted,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
result = await asyncio.to_thread(_read)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
sid, _limit, messages = result
|
|
from agent.compaction_display import project_compaction_message_for_display
|
|
from agent.context_compressor import is_compaction_summary_message
|
|
|
|
projected_messages = []
|
|
for message in messages:
|
|
if not is_compaction_summary_message(message):
|
|
projected_messages.append(message)
|
|
continue
|
|
display_view = project_compaction_message_for_display(message)
|
|
projected = message.copy()
|
|
if display_view is None:
|
|
if not projected.get("display_kind"):
|
|
projected["display_kind"] = "hidden"
|
|
else:
|
|
# Keep the physical content for inspection/export compatibility;
|
|
# Desktop consumes this display-only projection. A legacy hidden
|
|
# wrapper must not hide a successfully recovered live ask.
|
|
projected["display_content"] = display_view.get("content")
|
|
projected.pop("display_kind", None)
|
|
projected_messages.append(projected)
|
|
return {
|
|
"session_id": sid,
|
|
"messages": projected_messages,
|
|
"pagination": {
|
|
"limit": _limit,
|
|
"offset": offset,
|
|
"order": order or ("latest" if limit is None else "oldest"),
|
|
"returned": len(projected_messages),
|
|
},
|
|
}
|
|
|
|
|
|
@manage_router.delete("/api/sessions/{session_id}")
|
|
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
|
|
# ``profile`` deletes a session belonging to another (local) profile by
|
|
# opening its state.db directly. Remote profiles never reach here — the
|
|
# desktop routes their DELETE to the remote backend. Omit for current/default.
|
|
def _delete():
|
|
db = _open_session_db_for_profile(profile, read_only=False)
|
|
try:
|
|
# Resolve exact ids / unique prefixes like every other session endpoint
|
|
# (detail, messages, rename, export all do). A session that no longer
|
|
# exists is an idempotent success: DELETE's contract is "ensure it's
|
|
# gone", and the desktop optimistically removes the row then RESTORES it
|
|
# on any error — so a 404 on an already-absent row resurrected a ghost
|
|
# row and surfaced "session not found". /goal + auto-compression churn
|
|
# leaves transient empty rows (reaped by empty-session hygiene) that
|
|
# race the sidebar snapshot, which is exactly when this fired. Mirrors
|
|
# the bulk-delete endpoint, which already treats ghost ids as success.
|
|
sid = _resolve_session_id(db, session_id)
|
|
if not sid:
|
|
return {"ok": True, "already_absent": True}
|
|
db.delete_session(sid)
|
|
return {"ok": True}
|
|
finally:
|
|
db.close()
|
|
|
|
return await asyncio.to_thread(_delete)
|
|
|
|
|
|
@manage_router.post("/api/sessions/owner-backfill")
|
|
async def backfill_session_owner_profiles(body: SessionOwnerBackfill):
|
|
"""Stamp legacy ``profile_name = NULL`` session rows with this store's own
|
|
serving-profile identity (#94724 legacy-session migration).
|
|
|
|
Pre-#95407 rows never recorded an owning profile. That was fine while one
|
|
backend served everything, but a Desktop with registry topology (≥2
|
|
registered connections) fails closed on unowned rows by design — leaving
|
|
every pre-campaign session unresumable with no migration path. Each
|
|
profile's ``state.db`` belongs to exactly one profile, so stamping that
|
|
store's own name is a single-match backfill, never a guess; the value
|
|
written is the SAME serving-profile identity the list endpoints already
|
|
stamp onto outgoing rows (``row_profile`` in ``get_sessions``). Idempotent
|
|
and one-shot-per-row: non-NULL owners are never overwritten and a second
|
|
call reports 0.
|
|
"""
|
|
profile_name: Optional[str] = None
|
|
if body.profile:
|
|
profile_name, _ = _cron_profile_home(body.profile)
|
|
stamp = profile_name or _cron_default_profile()
|
|
|
|
def _backfill():
|
|
db = _open_session_db_for_profile(body.profile, read_only=False)
|
|
try:
|
|
return db.backfill_null_session_profiles(stamp)
|
|
finally:
|
|
db.close()
|
|
|
|
try:
|
|
stamped = await asyncio.to_thread(_backfill)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
_log.exception("POST /api/sessions/owner-backfill failed")
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
if stamped:
|
|
_log.info(
|
|
"owner-backfill: stamped %d legacy NULL-profile session row(s) with profile %r",
|
|
stamped,
|
|
stamp,
|
|
)
|
|
return {"ok": True, "stamped": stamped, "profile": stamp}
|
|
|
|
|
|
@manage_router.patch("/api/sessions/{session_id}")
|
|
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
|
"""Update a session: rename, archive, hide, pin, and/or mark read/unread.
|
|
|
|
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
|
|
restores the session; ``hidden`` controls generic list visibility;
|
|
``pinned`` sets the durable keep flag (exempts the
|
|
session from the auto-archive sweep); ``unread`` toggles the read-state
|
|
watermark (True = explicitly unread, False = read up to now — see
|
|
``SessionDB.set_session_read``). Any field may be omitted. ``profile``
|
|
targets another profile's session.
|
|
"""
|
|
db = _open_session_db_for_profile(body.profile, read_only=False)
|
|
try:
|
|
sid = _resolve_session_id(db, session_id)
|
|
if not sid:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
if (
|
|
body.title is None
|
|
and body.archived is None
|
|
and body.hidden is None
|
|
and body.pinned is None
|
|
and body.unread is None
|
|
):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Nothing to update; provide 'title', 'archived', 'hidden', 'pinned', and/or 'unread'.",
|
|
)
|
|
if body.title is not None:
|
|
try:
|
|
db.set_session_title(sid, body.title or "")
|
|
except ValueError as e:
|
|
# Title too long, invalid characters, or already in use.
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
if body.archived is not None:
|
|
db.set_session_archived(sid, body.archived)
|
|
if body.hidden is not None:
|
|
db.set_session_hidden(sid, body.hidden)
|
|
if body.pinned is not None:
|
|
db.set_session_pinned(sid, body.pinned)
|
|
if body.unread is not None:
|
|
db.set_session_read(sid, read=not body.unread)
|
|
result = {"ok": True, "title": db.get_session_title(sid) or ""}
|
|
if body.archived is not None:
|
|
result["archived"] = bool(body.archived)
|
|
if body.hidden is not None:
|
|
result["hidden"] = bool(body.hidden)
|
|
if body.pinned is not None:
|
|
result["pinned"] = bool(body.pinned)
|
|
if body.unread is not None:
|
|
result["unread"] = bool(body.unread)
|
|
return result
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@manage_router.get("/api/sessions/{session_id}/export")
|
|
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
|
|
"""Stream a single session (metadata + messages) as JSON."""
|
|
def _prepare_export():
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
sid = _resolve_session_id(db, session_id)
|
|
return (sid, db.get_session(sid)) if sid else None
|
|
finally:
|
|
db.close()
|
|
|
|
prepared = await asyncio.to_thread(_prepare_export)
|
|
if prepared is None or prepared[1] is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
sid, session = prepared
|
|
|
|
def _stream_export():
|
|
db = _open_session_db_for_profile(profile, read_only=True)
|
|
try:
|
|
metadata = json.dumps(
|
|
jsonable_encoder(session),
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
)
|
|
yield metadata[:-1] + ',"messages":['
|
|
|
|
# Keyset pagination (id > last_seen): O(n) total over the
|
|
# transcript, vs OFFSET's O(n²) on huge sessions.
|
|
last_id = None
|
|
first = True
|
|
while True:
|
|
messages = db.get_messages(
|
|
sid,
|
|
limit=500,
|
|
after_id=last_id if last_id is not None else 0,
|
|
)
|
|
for message in messages:
|
|
if not first:
|
|
yield ","
|
|
yield json.dumps(
|
|
jsonable_encoder(message),
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
)
|
|
first = False
|
|
if len(messages) < 500:
|
|
break
|
|
last_id = messages[-1].get("id")
|
|
if last_id is None:
|
|
break # defensive: cannot keyset without row ids
|
|
|
|
yield "]}"
|
|
finally:
|
|
db.close()
|
|
|
|
return StreamingResponse(
|
|
_stream_export(),
|
|
media_type="application/json",
|
|
)
|
|
|
|
|
|
@manage_router.post("/api/sessions/prune")
|
|
async def prune_sessions_endpoint(body: SessionPrune):
|
|
"""Delete ended sessions matching filters without blocking the event loop."""
|
|
return await asyncio.to_thread(_prune_sessions, body)
|