Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions nerve/agent/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,58 @@ async def archive_session(self, session_id: str) -> None:
await self.db.log_session_event(session_id, "archived", {})
logger.info("Archived session %s", session_id)

async def unarchive_session(self, session_id: str) -> None:
"""Restore an archived session to ``idle`` so it's resumable again.

Inverse of :meth:`archive_session`: clears ``archived_at`` and flips
the status back to idle. ``sdk_session_id`` stays cleared (archive
dropped it) — the next open resumes with fresh context, like any
idle session.
"""
session = await self.db.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
await self.db.update_session_fields(session_id, {
"status": SessionStatus.IDLE.value,
"archived_at": None,
})
await self.db.log_session_event(session_id, "unarchived", {})
logger.info("Unarchived session %s", session_id)

async def list_starred_sessions(self) -> list[dict]:
"""Starred, non-archived sessions — always returned, never truncated."""
return await self.db.list_starred_sessions()

async def list_conversation_sessions(
self, limit: int | None = None, offset: int = 0,
) -> list[dict]:
"""One page of the sidebar feed — non-archived, non-system, non-starred."""
return await self.db.list_conversation_sessions(limit=limit, offset=offset)

async def count_conversation_sessions(self) -> int:
"""Number of pageable conversations (drives the feed's has_more)."""
return await self.db.count_conversation_sessions()

async def list_archived_sessions(
self, limit: int | None = None, offset: int = 0,
) -> list[dict]:
"""One page of archived sessions for the sidebar's lazy Archived group."""
return await self.db.list_archived_sessions(limit=limit, offset=offset)

async def count_archived_sessions(self) -> int:
"""Number of archived sessions (cheap badge count)."""
return await self.db.count_archived_sessions()

async def list_system_sessions(
self, limit: int | None = None, offset: int = 0,
) -> list[dict]:
"""One page of system (cron/hook) sessions for the lazy System group."""
return await self.db.list_system_sessions(limit=limit, offset=offset)

async def count_system_sessions(self) -> int:
"""Number of pageable system sessions (cheap badge count)."""
return await self.db.count_system_sessions()

async def run_cleanup(
self,
archive_after_days: int = DEFAULT_ARCHIVE_AFTER_DAYS,
Expand Down
5 changes: 5 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,10 @@ class SessionsConfig:
sticky_period_minutes: int = 120 # Reuse session if active within this window
client_idle_timeout_minutes: int = 60 # Auto-disconnect clients idle longer than this (0 = disabled)
star_project_hook: bool = False # opt-in; fire an internal agent turn on star/unstar transition
# Rows per sidebar request: caps the conversation feed and sizes one lazy
# Archived/System page. 0 = unlimited (a group loads in a single request).
# Starred sessions are exempt and always returned in full.
sidebar_page_size: int = 50

@classmethod
@_coerced
Expand All @@ -1869,6 +1873,7 @@ def from_dict(cls, d: dict) -> SessionsConfig:
sticky_period_minutes=d.get("sticky_period_minutes", 120),
client_idle_timeout_minutes=d.get("client_idle_timeout_minutes", 60),
star_project_hook=d.get("star_project_hook", False),
sidebar_page_size=max(0, _lenient_int(d.get("sidebar_page_size"), 50)),
)


Expand Down
94 changes: 94 additions & 0 deletions nerve/db/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
import json
from datetime import datetime, timezone

# Sources the sidebar treats as "system": machine-driven runs that must never
# compete with human conversations for the feed's page window. Every other
# source (web, telegram, api, external, workflow, …) is a conversation — the
# split is by exclusion, so a new source shows up in the feed by default
# instead of silently rendering nowhere.
SYSTEM_SOURCES = ("cron", "hook")
_SYSTEM_SQL = "('" + "', '".join(SYSTEM_SOURCES) + "')"


class SessionStore:
"""Mixin providing session CRUD and lifecycle operations."""
Expand Down Expand Up @@ -85,6 +93,92 @@ async def count_sessions(self, include_archived: bool = False) -> int:
row = await cursor.fetchone()
return row[0] if row else 0

async def _page(self, sql: str, params: tuple, limit: int | None, offset: int) -> list[dict]:
"""Run a sidebar list query with an optional page window.

``limit=None`` means unbounded — the LIMIT/OFFSET clause is omitted
entirely rather than passing a sentinel, so an unlimited sidebar is a
plain full scan of the (already narrow) predicate.
"""
if limit is None:
async with self.db.execute(sql, params) as cursor:
return [dict(row) async for row in cursor]
async with self.db.execute(
f"{sql} LIMIT ? OFFSET ?", (*params, limit, max(0, offset)),
) as cursor:
return [dict(row) async for row in cursor]

async def _count(self, where: str) -> int:
async with self.db.execute(f"SELECT COUNT(*) FROM sessions WHERE {where}") as cursor:
row = await cursor.fetchone()
return row[0] if row else 0

async def list_starred_sessions(self) -> list[dict]:
"""Every non-archived starred session, newest first — NEVER truncated.

Starred rows are off-budget for the sidebar page size (a star is a
durable pin), and they are returned regardless of source, so a starred
cron session is pinned in the feed instead of hiding in System.
"""
return await self._page(
"SELECT * FROM sessions WHERE starred = 1 AND status != 'archived'"
" ORDER BY updated_at DESC", (), None, 0,
)

async def list_conversation_sessions(
self, limit: int | None = None, offset: int = 0,
) -> list[dict]:
"""Main sidebar feed page: non-archived, non-system, non-starred.

The page window applies *after* system sources are excluded, so cron
traffic can never crowd conversations out of the feed. Sources are
filtered by exclusion, not by a whitelist: anything that is not
cron/hook (web, telegram, api, external, workflow, …) is a conversation.
"""
return await self._page(
"SELECT * FROM sessions"
f" WHERE status != 'archived' AND starred = 0 AND source NOT IN {_SYSTEM_SQL}"
" ORDER BY updated_at DESC", (), limit, offset,
)

async def count_conversation_sessions(self) -> int:
"""Pageable conversations (drives the feed's has_more)."""
return await self._count(
f"status != 'archived' AND starred = 0 AND source NOT IN {_SYSTEM_SQL}",
)

async def list_archived_sessions(
self, limit: int | None = None, offset: int = 0,
) -> list[dict]:
"""Archived sessions page, most recently archived first — lazily
fetched when the sidebar Archived group is expanded."""
return await self._page(
"SELECT * FROM sessions WHERE status = 'archived'"
" ORDER BY archived_at DESC", (), limit, offset,
)

async def count_archived_sessions(self) -> int:
"""Count archived sessions (drives the collapsed badge + has_more)."""
return await self._count("status = 'archived'")

async def list_system_sessions(
self, limit: int | None = None, offset: int = 0,
) -> list[dict]:
"""System sessions page (cron/hook), newest first — lazily fetched when
the sidebar System group is expanded. Starred rows are excluded: they
are already pinned in the feed, so every session shows exactly once."""
return await self._page(
"SELECT * FROM sessions"
f" WHERE status != 'archived' AND starred = 0 AND source IN {_SYSTEM_SQL}"
" ORDER BY updated_at DESC", (), limit, offset,
)

async def count_system_sessions(self) -> int:
"""Count pageable system sessions (drives the badge + has_more)."""
return await self._count(
f"status != 'archived' AND starred = 0 AND source IN {_SYSTEM_SQL}",
)

async def search_sessions(self, query: str, limit: int = 100) -> list[dict]:
"""Search sessions by title (LIKE match), across all non-archived sessions."""
sql = (
Expand Down
78 changes: 73 additions & 5 deletions nerve/gateway/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,49 @@ async def _attach_review_loops(deps, sessions: list[dict]) -> None:
s["review_loop"] = _loop_summary(lp)


@router.get("/api/sessions")
async def list_sessions(user: dict = Depends(require_auth)):
deps = get_deps()
sessions = await deps.engine.sessions.list_sessions()
def _page_size() -> int | None:
"""Sidebar page size from config; ``None`` when configured unlimited."""
size = get_config().sessions.sidebar_page_size
return size if size and size > 0 else None


async def _decorate(deps, sessions: list[dict]) -> list[dict]:
"""Attach the live per-row bits every sidebar list needs."""
running_ids = deps.engine.sessions.get_running_ids()
awaiting_ids = get_awaiting_ids()
for s in sessions:
s["is_running"] = s["id"] in running_ids
s["awaiting_input"] = s["id"] in awaiting_ids
await _attach_review_loops(deps, sessions)
return {"sessions": sessions}
return sessions


def _page_meta(page: list[dict], offset: int, total: int, limit: int | None) -> dict:
"""``has_more``/``next_offset`` for the client's '...' control."""
seen = offset + len(page)
return {"has_more": limit is not None and seen < total, "next_offset": seen}


@router.get("/api/sessions")
async def list_sessions(offset: int = 0, user: dict = Depends(require_auth)):
"""Sidebar feed: one page of conversations, plus every starred session.

The page window covers only non-archived, non-system, non-starred rows, so
cron traffic can never displace conversations. Starred rows ride along
in full on the first page (``offset=0``) and are never truncated.
"""
deps = get_deps()
limit = _page_size()
page = await deps.engine.sessions.list_conversation_sessions(limit=limit, offset=offset)
total = await deps.engine.sessions.count_conversation_sessions()
sessions = page if offset else await deps.engine.sessions.list_starred_sessions() + page
await _decorate(deps, sessions)
return {
"sessions": sessions,
"archived_count": await deps.engine.sessions.count_archived_sessions(),
"system_count": await deps.engine.sessions.count_system_sessions(),
**_page_meta(page, offset, total, limit),
}


@router.get("/api/sessions/search")
Expand All @@ -163,6 +195,28 @@ async def search_sessions(q: str, user: dict = Depends(require_auth)):
return {"sessions": sessions}


@router.get("/api/sessions/archived")
async def list_archived_sessions(offset: int = 0, user: dict = Depends(require_auth)):
"""One page of archived sessions — fetched only when the group is expanded."""
deps = get_deps()
limit = _page_size()
page = await deps.engine.sessions.list_archived_sessions(limit=limit, offset=offset)
total = await deps.engine.sessions.count_archived_sessions()
await _decorate(deps, page)
return {"sessions": page, **_page_meta(page, offset, total, limit)}


@router.get("/api/sessions/system")
async def list_system_sessions(offset: int = 0, user: dict = Depends(require_auth)):
"""One page of system (cron/hook) sessions — fetched only when expanded."""
deps = get_deps()
limit = _page_size()
page = await deps.engine.sessions.list_system_sessions(limit=limit, offset=offset)
total = await deps.engine.sessions.count_system_sessions()
await _decorate(deps, page)
return {"sessions": page, **_page_meta(page, offset, total, limit)}


@router.post("/api/sessions")
async def create_session(req: SessionCreateRequest, user: dict = Depends(require_auth)):
deps = get_deps()
Expand Down Expand Up @@ -319,6 +373,12 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir
fields["title"] = req["title"]
if "starred" in req:
fields["starred"] = 1 if req["starred"] else 0
# Starring an archived session restores it first, then stars — so the
# star->project hook below fires on a live (idle) session. "archived"
# is the persisted SessionStatus.ARCHIVED value.
if fields["starred"] == 1 and session.get("status") == "archived":
fields["status"] = "idle"
fields["archived_at"] = None
if "model" in req:
requested_model = str(req["model"] or "").strip()
if not requested_model:
Expand Down Expand Up @@ -466,6 +526,14 @@ async def archive_session(session_id: str, user: dict = Depends(require_auth)):
return {"archived": True}


@router.post("/api/sessions/{session_id}/unarchive")
async def unarchive_session(session_id: str, user: dict = Depends(require_auth)):
"""Restore an archived session (Archived group → Unarchive / Star)."""
deps = get_deps()
await deps.engine.sessions.unarchive_session(session_id)
return {"unarchived": True}


@router.get("/api/sessions/{session_id}/events")
async def get_session_events(
session_id: str, limit: int = 50, user: dict = Depends(require_auth),
Expand Down
Loading
Loading