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
38 changes: 38 additions & 0 deletions nerve/agent/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,44 @@ 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_archived_sessions(self, limit: int = 200) -> list[dict]:
"""Archived sessions for the sidebar's lazily-loaded Archived group."""
return await self.db.list_archived_sessions(limit=limit)

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

async def list_active_sessions(self) -> list[dict]:
"""Main sidebar feed — non-archived, non-system sessions, unbounded."""
return await self.db.list_active_sessions()

async def list_system_sessions(self, limit: int = 200) -> list[dict]:
"""System (cron/hook) sessions for the sidebar's lazy System group."""
return await self.db.list_system_sessions(limit=limit)

async def count_system_sessions(self) -> int:
"""Number of non-archived 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
56 changes: 56 additions & 0 deletions nerve/db/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ async def count_sessions(self, include_archived: bool = False) -> int:
row = await cursor.fetchone()
return row[0] if row else 0

async def list_archived_sessions(self, limit: int = 200) -> list[dict]:
"""Archived sessions, most recently archived first.

Separate from :meth:`list_sessions` (which always excludes archived):
the sidebar fetches this lazily, only when the Archived group is
expanded.
"""
sql = (
"SELECT * FROM sessions WHERE status = 'archived' "
"ORDER BY archived_at DESC LIMIT ?"
)
async with self.db.execute(sql, (limit,)) as cursor:
return [dict(row) async for row in cursor]

async def count_archived_sessions(self) -> int:
"""Count archived sessions (drives the collapsed sidebar badge)."""
async with self.db.execute(
"SELECT COUNT(*) FROM sessions WHERE status = 'archived'"
) as cursor:
row = await cursor.fetchone()
return row[0] if row else 0

async def list_active_sessions(self) -> list[dict]:
"""Main sidebar feed: every non-archived conversation session, newest
first, UNBOUNDED. Excludes system sources (cron/hook) — those now load
lazily via :meth:`list_system_sessions`, so cron traffic no longer
crowds the conversation list. No LIMIT: the client renders everything.
"""
query = (
"SELECT * FROM sessions"
" WHERE status != 'archived' AND source NOT IN ('cron', 'hook')"
" ORDER BY updated_at DESC"
)
async with self.db.execute(query) as cursor:
return [dict(row) async for row in cursor]

async def list_system_sessions(self, limit: int = 200) -> list[dict]:
"""System sessions (cron/hook), newest first — lazily fetched when the
sidebar System group is expanded."""
query = (
"SELECT * FROM sessions"
" WHERE status != 'archived' AND source IN ('cron', 'hook')"
" ORDER BY updated_at DESC LIMIT ?"
)
async with self.db.execute(query, (limit,)) as cursor:
return [dict(row) async for row in cursor]

async def count_system_sessions(self) -> int:
"""Count non-archived system sessions (drives the collapsed System badge)."""
async with self.db.execute(
"SELECT COUNT(*) FROM sessions"
" WHERE status != 'archived' AND source IN ('cron', 'hook')"
) as cursor:
row = await cursor.fetchone()
return row[0] if row else 0

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
48 changes: 46 additions & 2 deletions nerve/gateway/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,16 @@ async def _attach_review_loops(deps, sessions: list[dict]) -> None:
@router.get("/api/sessions")
async def list_sessions(user: dict = Depends(require_auth)):
deps = get_deps()
sessions = await deps.engine.sessions.list_sessions()
sessions = await deps.engine.sessions.list_active_sessions()
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}
archived_count = await deps.engine.sessions.count_archived_sessions()
system_count = await deps.engine.sessions.count_system_sessions()
return {"sessions": sessions, "archived_count": archived_count, "system_count": system_count}


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


@router.get("/api/sessions/archived")
async def list_archived_sessions(user: dict = Depends(require_auth)):
"""Archived sessions — lazily fetched when the sidebar Archived group is expanded."""
deps = get_deps()
sessions = await deps.engine.sessions.list_archived_sessions()
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}


@router.get("/api/sessions/system")
async def list_system_sessions(user: dict = Depends(require_auth)):
"""System sessions (cron/hook) — lazily fetched when the sidebar System group is expanded."""
deps = get_deps()
sessions = await deps.engine.sessions.list_system_sessions()
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}


@router.post("/api/sessions")
async def create_session(req: SessionCreateRequest, user: dict = Depends(require_auth)):
deps = get_deps()
Expand Down Expand Up @@ -314,6 +344,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 not fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
old_starred = int(session.get("starred") or 0)
Expand Down Expand Up @@ -443,6 +479,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
87 changes: 87 additions & 0 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,93 @@ async def test_archive_session(self, sm: SessionManager, db: Database):
assert session["status"] == "archived"
assert session["archived_at"] is not None

async def test_unarchive_session(self, sm: SessionManager, db: Database):
await sm.get_or_create("unarch-1")
await sm.archive_session("unarch-1")
await sm.unarchive_session("unarch-1")
session = await db.get_session("unarch-1")
assert session["status"] == "idle"
assert session["archived_at"] is None

async def test_unarchive_logs_event(self, sm: SessionManager, db: Database):
await sm.get_or_create("unarch-ev")
await sm.archive_session("unarch-ev")
await sm.unarchive_session("unarch-ev")
events = await db.get_session_events("unarch-ev")
assert any(e["event_type"] == "unarchived" for e in events)

async def test_unarchive_missing_raises(self, sm: SessionManager):
with pytest.raises(ValueError):
await sm.unarchive_session("does-not-exist")

async def test_list_archived_only_archived(self, sm: SessionManager, db: Database):
await sm.get_or_create("keep-live")
await db.update_session_fields("keep-live", {"status": "idle"})
await sm.get_or_create("arch-listed")
await sm.archive_session("arch-listed")
archived_ids = {s["id"] for s in await sm.list_archived_sessions()}
assert "arch-listed" in archived_ids
assert "keep-live" not in archived_ids
# The default sidebar feed (list_sessions) must still exclude archived.
live_ids = {s["id"] for s in await sm.list_sessions()}
assert "arch-listed" not in live_ids

async def test_count_archived_sessions(self, sm: SessionManager):
assert await sm.count_archived_sessions() == 0
await sm.get_or_create("cnt-1")
await sm.archive_session("cnt-1")
await sm.get_or_create("cnt-2")
await sm.archive_session("cnt-2")
assert await sm.count_archived_sessions() == 2

async def test_star_archived_field_write_restores(self, sm: SessionManager, db: Database):
"""The update_session route composites star+unarchive by writing these
fields together; verify that write restores the row to a live, starred
state (status idle, archived_at cleared)."""
await sm.get_or_create("star-arch")
await sm.archive_session("star-arch")
await db.update_session_fields(
"star-arch", {"starred": 1, "status": "idle", "archived_at": None},
)
session = await db.get_session("star-arch")
assert session["starred"] == 1
assert session["status"] == "idle"
assert session["archived_at"] is None

async def test_list_active_excludes_system_and_archived(self, sm: SessionManager):
await sm.get_or_create("feed-web", source="web")
await sm.get_or_create("feed-cron", source="cron")
await sm.get_or_create("feed-arch", source="web")
await sm.archive_session("feed-arch")
ids = {s["id"] for s in await sm.list_active_sessions()}
assert "feed-web" in ids
assert "feed-cron" not in ids # system source excluded from main feed
assert "feed-arch" not in ids # archived excluded

async def test_list_active_is_unbounded(self, sm: SessionManager):
# Regression: the old sidebar feed capped non-starred sessions at 50.
for i in range(55):
await sm.get_or_create(f"many-{i}", source="web")
active = await sm.list_active_sessions()
assert len([s for s in active if s["id"].startswith("many-")]) == 55

async def test_list_system_only_system(self, sm: SessionManager):
await sm.get_or_create("sys-cron", source="cron")
await sm.get_or_create("sys-hook", source="hook")
await sm.get_or_create("sys-web", source="web")
ids = {s["id"] for s in await sm.list_system_sessions()}
assert {"sys-cron", "sys-hook"} <= ids
assert "sys-web" not in ids

async def test_count_system_sessions(self, sm: SessionManager):
assert await sm.count_system_sessions() == 0
await sm.get_or_create("c-cron", source="cron")
await sm.get_or_create("c-hook", source="hook")
await sm.get_or_create("c-web", source="web")
await sm.get_or_create("c-arch", source="cron")
await sm.archive_session("c-arch")
assert await sm.count_system_sessions() == 2 # archived cron excluded

async def test_archive_disconnects_client(self, sm: SessionManager):
await sm.get_or_create("arch-2")
# Simulate a client
Expand Down
6 changes: 5 additions & 1 deletion web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ export const api = {
}>('/models'),

// Sessions
listSessions: () => request<{ sessions: any[] }>('/sessions'),
listSessions: () => request<{ sessions: any[]; archived_count: number; system_count: number }>('/sessions'),
listArchivedSessions: () => request<{ sessions: any[] }>('/sessions/archived'),
listSystemSessions: () => request<{ sessions: any[] }>('/sessions/system'),
searchSessions: (q: string) =>
request<{ sessions: any[] }>(`/sessions/search?q=${encodeURIComponent(q)}`),
getSession: (id: string) => request<any>(`/sessions/${id}`),
Expand Down Expand Up @@ -319,6 +321,8 @@ export const api = {
request<any>(`/sessions/${id}/resume`, { method: 'POST' }),
archiveSession: (id: string) =>
request<any>(`/sessions/${id}/archive`, { method: 'POST' }),
unarchiveSession: (id: string) =>
request<any>(`/sessions/${id}/unarchive`, { method: 'POST' }),
getSessionStatus: (id: string) =>
request<any>(`/sessions/${id}/status`),
getSessionEvents: (id: string, limit = 50) =>
Expand Down
Loading
Loading