Files
hermes-agent/hermes_cli/web_routers/sessions.py
T
Brooklyn Nicholson 3a0e7df799 fix(state): a busy session store reads as busy, not as damaged or empty
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>
2026-09-01 22:42:19 -05:00

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)