diff --git a/docs/config.md b/docs/config.md index 5c1d4d1..dcd8733 100644 --- a/docs/config.md +++ b/docs/config.md @@ -369,6 +369,13 @@ The proxy binary is automatically downloaded from [CLIProxyAPI](https://github.c | `sessions.max_sessions` | int | `500` | Max active (non-archived) sessions before cleanup | | `sessions.cron_session_mode` | string | `per_run` | `per_run` (unique session per cron run) or `reuse` (shared session per job) | +**Starred sessions are exempt from all auto-archival.** A session starred via +the star toggle (web sidebar, or the Telegram `/sessions` list / `/star`) is +never auto-closed: it is skipped by the idle cutoff and the +`archive_after_days` backstop, and is off-budget for `max_sessions` — neither +counted toward the cap nor evicted. It stays resumable until explicitly +unstarred, archived, or deleted. + ## Retention Opt-in `nerve.db` maintenance. Disabled by default. When enabled, a background diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index 4d1005d..2d1512d 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -609,6 +609,37 @@ async def list_sessions( limit=limit, include_archived=include_archived, ) + async def set_starred(self, session_id: str, starred: bool) -> bool: + """Star or unstar a session. + + Starred sessions are exempt from every auto-archival path (the idle + cutoff, the age backstop, and the overflow eviction), so they stay + resumable until explicitly unstarred or deleted. Returns the new + starred state; raises ``ValueError`` if the session does not exist. + """ + 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, {"starred": 1 if starred else 0}, + ) + return starred + + async def toggle_starred(self, session_id: str) -> bool: + """Flip a session's starred flag. + + Returns the new starred state; raises ``ValueError`` if the session + does not exist. + """ + session = await self.db.get_session(session_id) + if not session: + raise ValueError(f"Session {session_id} not found") + new_starred = not session.get("starred") + await self.db.update_session_fields( + session_id, {"starred": 1 if new_starred else 0}, + ) + return new_starred + # ------------------------------------------------------------------ # # Archive & cleanup # # ------------------------------------------------------------------ # @@ -652,7 +683,13 @@ async def run_cleanup( ones parked on an unanswered ``ask_user`` question, whose pending notification is expired so it doesn't dangle on a closed session. Cron/persistent sessions are excluded from the short cutoff and only - hit the ``archive_after_days`` backstop. Returns cleanup statistics. + hit the ``archive_after_days`` backstop. Starred sessions are exempt + from every auto-archival path (short cutoff, backstop, and the + max-session overflow eviction) and are off-budget for the + ``max_sessions`` cap — they are neither counted toward it nor evicted, + so the cap governs only cap-eligible (non-starred) sessions. A starred + session is kept until it is explicitly unstarred or deleted. Returns + cleanup statistics. """ now = datetime.now(timezone.utc) @@ -682,8 +719,10 @@ async def run_cleanup( for s in stale: await self.archive_session(s["id"]) - # Enforce max session count (archive oldest beyond limit) - count = await self.db.count_active_sessions() + # Enforce max session count (archive oldest beyond limit). Starred + # sessions are off-budget: excluded from the count and never evicted, + # so the cap governs only cap-eligible (non-starred) sessions. + count = await self.db.count_active_sessions(exclude_starred=True) overflow = 0 if count > max_sessions: excess = await self.db.get_oldest_sessions( diff --git a/nerve/channels/router.py b/nerve/channels/router.py index 29f2b80..32f7d8f 100644 --- a/nerve/channels/router.py +++ b/nerve/channels/router.py @@ -430,6 +430,14 @@ async def list_sessions(self, limit: int = 20) -> list[dict[str, Any]]: """List sessions, most recently updated first.""" return await self.engine.sessions.list_sessions(limit=limit) + async def set_session_starred(self, session_id: str, starred: bool) -> bool: + """Star/unstar a session. Starred sessions are never auto-archived.""" + return await self.engine.sessions.set_starred(session_id, starred) + + async def toggle_session_starred(self, session_id: str) -> bool: + """Toggle a session's starred flag. Returns the new state.""" + return await self.engine.sessions.toggle_starred(session_id) + async def get_session(self, session_id: str) -> dict[str, Any] | None: """Fetch a session row (title/status/…), or None if it is gone.""" return await self.engine.db.get_session(session_id) diff --git a/nerve/channels/telegram.py b/nerve/channels/telegram.py index 7812c58..81d66fa 100644 --- a/nerve/channels/telegram.py +++ b/nerve/channels/telegram.py @@ -228,11 +228,14 @@ def _format_reply_context(message: Any) -> str: def _session_label(session: dict, current_id: str | None) -> str: - """Button label for one session: title (or short id), current marked ✓.""" + """Button label for one session: title (or short id), current marked ✓, + starred (kept-alive) marked ⭐.""" title = (session.get("title") or "").strip() or session.get("id", "?") if len(title) > _SESSION_LABEL_MAX: title = title[: _SESSION_LABEL_MAX - 1] + "…" prefix = "✓ " if session.get("id") == current_id else "" + if session.get("starred"): + prefix += "⭐ " return f"{prefix}{title}" @@ -255,9 +258,17 @@ def build_sessions_view( # ids are short, but guard defensively so a button always round-trips. if sid is None or len(cb.encode("utf-8")) > 64: continue - rows.append( - [InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)] - ) + row = [InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)] + # Per-session star toggle: ⭐ = kept alive (never auto-closed), + # ☆ = normal. Add it only when its callback also round-trips. + star_cb = f"sessstar:{sid}" + if len(star_cb.encode("utf-8")) <= 64: + row.append( + InlineKeyboardButton( + "⭐" if s.get("starred") else "☆", callback_data=star_cb, + ) + ) + rows.append(row) rows.append([InlineKeyboardButton("➕ New session", callback_data="sess:new")]) # The New-session button switches routing to a fresh session WITHOUT @@ -277,6 +288,7 @@ def build_sessions_view( if current_title: text += f"\nCurrent: {current_title}" text += "\n➕ New session keeps the current one running." + text += "\n⭐ keeps a session alive (never auto-closed); tap ☆/⭐ to toggle." else: text = "No sessions yet — tap ➕ to start one." return text, InlineKeyboardMarkup(rows) @@ -505,6 +517,8 @@ def _build_application(self) -> Application: app.add_handler(CommandHandler("pair", self._handle_pair)) app.add_handler(CommandHandler("session", self._handle_session)) app.add_handler(CommandHandler("sessions", self._handle_sessions)) + app.add_handler(CommandHandler("star", self._handle_star)) + app.add_handler(CommandHandler("unstar", self._handle_unstar)) app.add_handler(CommandHandler("new", self._handle_new_session)) app.add_handler(CommandHandler("stop", self._handle_stop)) app.add_handler(CommandHandler("restart", self._handle_restart)) @@ -1091,6 +1105,37 @@ async def _handle_sessions(self, update: Update, context: Any) -> None: text, markup = await self._sessions_view_for(channel_key) await update.message.reply_text(text, reply_markup=markup) + async def _handle_star(self, update: Update, context: Any) -> None: + """Handle /star — star the current session so it never auto-closes.""" + await self._set_current_starred(update, True) + + async def _handle_unstar(self, update: Update, context: Any) -> None: + """Handle /unstar — clear the current session's kept-alive flag.""" + await self._set_current_starred(update, False) + + async def _set_current_starred(self, update: Update, starred: bool) -> None: + self._touch() + if not self._is_authorized(update.effective_user.id): + return + channel_key = f"telegram:{update.effective_chat.id}" + current = await self.router.get_last_session(channel_key) + if not current: + await update.message.reply_text("No active session to star.") + return + try: + await self.router.set_session_starred(current, starred) + except ValueError as e: + await update.message.reply_text(str(e)) + return + if starred: + await update.message.reply_text( + "⭐ Session starred — it won't auto-close when idle." + ) + else: + await update.message.reply_text( + "☆ Session unstarred — normal auto-close applies." + ) + async def _handle_new_session(self, update: Update, context: Any) -> None: """Handle /new [title] — stop current session, create and switch to a new one.""" self._touch() @@ -1755,6 +1800,7 @@ async def _handle_session_button(self, query: Any) -> None: ``sess:new`` — create a fresh session (current keeps running) ``sess:`` — switch routing to , then show its history ``sesstail::`` — widen the catch-up window (informational only) + ``sessstar:`` — toggle the session's starred (kept-alive) flag After a switch/create the card is replaced by that session's recent history (native order, oldest→newest) so the user can catch up — which @@ -1784,6 +1830,24 @@ async def _handle_session_button(self, query: Any) -> None: await self._edit_session_tail(query, sid, window) return + if data.startswith("sessstar:"): + sid = data.split(":", 1)[1] + try: + now_starred = await self.router.toggle_session_starred(sid) + except ValueError: + await query.answer( + "That session is no longer available", show_alert=True, + ) + else: + await query.answer( + "⭐ Kept alive — won't auto-close" + if now_starred + else "☆ Normal — may auto-close when idle" + ) + text, markup = await self._sessions_view_for(channel_key) + await self._safe_edit(query, text, markup) + return + # sess:new / sess: target = data.split(":", 1)[1] sid = target @@ -1817,7 +1881,11 @@ async def _handle_callback_query(self, update: Update, context: Any) -> None: return # /sessions inline keyboard: switch/create/list + history buttons. - if query.data.startswith("sess:") or query.data.startswith("sesstail:"): + if ( + query.data.startswith("sess:") + or query.data.startswith("sesstail:") + or query.data.startswith("sessstar:") + ): await self._handle_session_button(query) return diff --git a/nerve/config.py b/nerve/config.py index eaee664..4c8e549 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -635,7 +635,7 @@ def from_dict(cls, d: dict) -> BackupConfig: @dataclass class SessionsConfig: archive_after_days: int = 30 - interactive_archive_after_hours: int = 0 # Interactive (web/telegram/…) sessions auto-close after this many idle hours (0 = disabled; opt in via config) + interactive_archive_after_hours: int = 0 # Interactive (web/telegram/…) sessions auto-close after this many idle hours (0 = disabled; opt in via config). Starred sessions are exempt and never auto-close. max_sessions: int = 500 cron_session_mode: str = "per_run" # "per_run" or "reuse" memorize_interval_minutes: int = 30 # Background memorization sweep interval diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index 658348d..331e2a8 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -302,13 +302,18 @@ async def get_sessions_by_status(self, statuses: list[str]) -> list[dict]: async def get_stale_sessions( self, before_iso: str, exclude_ids: list[str] | None = None, ) -> list[dict]: - """Get idle/stopped/error sessions not updated since before_iso.""" + """Get idle/stopped/error sessions not updated since before_iso. + + Starred sessions are excluded: a starred session is never + auto-archived, regardless of how long it has been idle. + """ excludes = exclude_ids or [] if excludes: placeholders = ",".join("?" for _ in excludes) query = f""" SELECT * FROM sessions WHERE status IN ('idle', 'stopped', 'error') + AND starred = 0 AND updated_at < ? AND id NOT IN ({placeholders}) """ @@ -317,6 +322,7 @@ async def get_stale_sessions( query = """ SELECT * FROM sessions WHERE status IN ('idle', 'stopped', 'error') + AND starred = 0 AND updated_at < ? """ params = (before_iso,) @@ -330,7 +336,8 @@ async def get_stale_interactive_sessions( Scoped by ``source`` (web/telegram/…) so cron and persistent sessions keep their own long rotation and are never archived at the short - interactive cutoff. Returns [] when ``sources`` is empty. + interactive cutoff. Starred sessions are also excluded — a starred + session is never auto-archived. Returns [] when ``sources`` is empty. """ if not sources: return [] @@ -338,30 +345,42 @@ async def get_stale_interactive_sessions( query = f""" SELECT * FROM sessions WHERE status IN ('idle', 'stopped', 'error') + AND starred = 0 AND source IN ({src}) AND updated_at < ? """ async with self.db.execute(query, (*sources, before_iso)) as cursor: return [dict(row) async for row in cursor] - async def count_active_sessions(self) -> int: - """Count non-archived sessions.""" - async with self.db.execute( - "SELECT COUNT(*) FROM sessions WHERE status != 'archived'" - ) as cursor: + async def count_active_sessions(self, exclude_starred: bool = False) -> int: + """Count non-archived sessions. + + When ``exclude_starred`` is set, starred sessions are omitted: they are + off-budget for the ``max_sessions`` cap, so the overflow check counts + only cap-eligible sessions. + """ + query = "SELECT COUNT(*) FROM sessions WHERE status != 'archived'" + if exclude_starred: + query += " AND starred = 0" + async with self.db.execute(query) as cursor: row = await cursor.fetchone() return row[0] if row else 0 async def get_oldest_sessions( self, count: int, exclude_ids: list[str] | None = None, ) -> list[dict]: - """Get the oldest non-active, non-archived sessions for cleanup.""" + """Get the oldest non-active, non-archived sessions for cleanup. + + Starred sessions are excluded: they are never evicted to enforce the + session-count limit. + """ excludes = exclude_ids or [] if excludes: placeholders = ",".join("?" for _ in excludes) query = f""" SELECT * FROM sessions WHERE status NOT IN ('active', 'archived') + AND starred = 0 AND id NOT IN ({placeholders}) ORDER BY updated_at ASC LIMIT ? """ @@ -370,6 +389,7 @@ async def get_oldest_sessions( query = """ SELECT * FROM sessions WHERE status NOT IN ('active', 'archived') + AND starred = 0 ORDER BY updated_at ASC LIMIT ? """ params = (count,) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 1a07041..7fabc0d 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -520,6 +520,91 @@ async def test_cleanup_hours_excludes_cron_sessions( assert (await db.get_session("idle-cron"))["status"] == "idle" + async def test_starred_exempt_from_interactive_cutoff( + self, sm: SessionManager, db: Database, + ): + """A starred interactive session survives the short idle cutoff.""" + await sm.get_or_create("idle-star", source="web") + await self._set_idle_hours_ago(db, "idle-star", hours=5) + await sm.set_starred("idle-star", True) + + stats = await sm.run_cleanup( + archive_after_days=30, interactive_archive_after_hours=1, + ) + + assert stats["archived_interactive"] == 0 + assert (await db.get_session("idle-star"))["status"] == "idle" + + async def test_starred_exempt_from_stale_backstop( + self, sm: SessionManager, db: Database, + ): + """A starred session survives the long age backstop.""" + await sm.get_or_create("stale-star") + await db.update_session_fields( + "stale-star", {"status": "idle", "starred": 1}, + ) + await db.db.execute( + "UPDATE sessions SET updated_at = '2020-01-01T00:00:00' WHERE id = 'stale-star'" + ) + await db.db.commit() + + await sm.run_cleanup(archive_after_days=30, max_sessions=1000) + + assert (await db.get_session("stale-star"))["status"] == "idle" + + async def test_starred_off_budget_no_eviction_when_only_starred( + self, sm: SessionManager, db: Database, + ): + """Starred sessions are off-budget: a population of only starred + sessions over the cap triggers no overflow eviction at all.""" + for i in range(3): + sid = f"star-{i}" + await sm.get_or_create(sid) + await db.update_session_fields(sid, {"status": "idle", "starred": 1}) + + stats = await sm.run_cleanup(archive_after_days=30, max_sessions=1) + + assert stats["archived_overflow"] == 0 + for i in range(3): + assert (await db.get_session(f"star-{i}"))["status"] == "idle" + + async def test_overflow_bounds_unstarred_and_ignores_starred( + self, sm: SessionManager, db: Database, + ): + """The cap governs only non-starred sessions: starred are off-budget + and untouched, while unstarred are evicted down to the limit.""" + for i in range(2): + sid = f"kept-star-{i}" + await sm.get_or_create(sid) + await db.update_session_fields(sid, {"status": "idle", "starred": 1}) + for i in range(3): + sid = f"disposable-{i}" + await sm.get_or_create(sid) + await db.update_session_fields(sid, {"status": "idle"}) + + await sm.run_cleanup(archive_after_days=30, max_sessions=1) + + # Both starred survive (off-budget, non-evictable). + for i in range(2): + assert (await db.get_session(f"kept-star-{i}"))["status"] == "idle" + # Unstarred are bounded to the cap regardless of the starred count. + live_unstarred = [ + s for s in await db.list_sessions(include_archived=False) + if not s.get("starred") + ] + assert len(live_unstarred) == 1 + + async def test_set_and_toggle_starred(self, sm: SessionManager, db: Database): + await sm.get_or_create("star-me") + assert await sm.set_starred("star-me", True) is True + assert (await db.get_session("star-me"))["starred"] == 1 + assert await sm.toggle_starred("star-me") is False + assert (await db.get_session("star-me"))["starred"] == 0 + + async def test_set_starred_not_found(self, sm: SessionManager): + with pytest.raises(ValueError): + await sm.set_starred("nonexistent", True) + @pytest.mark.asyncio class TestOrphanRecovery: diff --git a/tests/test_telegram_sessions.py b/tests/test_telegram_sessions.py index 204ba9e..fb30223 100644 --- a/tests/test_telegram_sessions.py +++ b/tests/test_telegram_sessions.py @@ -235,3 +235,71 @@ def test_tail_active_status_shows_live_emoji(): {"id": "a", "title": "T", "status": "active"}, [], total=0, window=6, tzname="UTC", ) assert "🟢" in text and "•" not in text + + +# --- star (kept-alive) toggle --------------------------------------------- # + +def test_starred_session_shows_marker_and_filled_toggle(): + sessions = [ + {"id": "aaaa1111", "title": "Kept", "source": "web", "starred": True}, + {"id": "bbbb2222", "title": "Normal", "source": "web"}, + ] + _text, markup = build_sessions_view(sessions, current_id=None) + by_cb = {b.callback_data: b for b in _flat(markup)} + # Starred: switch label carries ⭐; its toggle button is the filled star. + assert by_cb["sess:aaaa1111"].text == "⭐ Kept" + assert by_cb["sessstar:aaaa1111"].text == "⭐" + # Normal: no marker; toggle button is the hollow star. + assert by_cb["sess:bbbb2222"].text == "Normal" + assert by_cb["sessstar:bbbb2222"].text == "☆" + + +def test_sessions_view_explains_star_keeps_alive(): + text, _markup = build_sessions_view( + [{"id": "aaaa1111", "title": "Work", "source": "web"}], current_id=None, + ) + assert "keeps a session alive" in text + + +@pytest.mark.asyncio +async def test_sessstar_callback_toggles_and_rerenders(): + from nerve.channels.telegram import TelegramChannel + + sessions = [{"id": "keep01", "title": "Keep", "source": "web"}] + ch = TelegramChannel.__new__(TelegramChannel) + router = _FakeRouter(sessions, {"keep01": 3}, current="keep01") + toggled = {} + + async def _toggle(sid): + toggled["sid"] = sid + return True + + router.toggle_session_starred = _toggle + ch.router = router + + edited = {} + + async def _safe_edit(query, text, markup, **kw): + edited["text"] = text + + ch._safe_edit = _safe_edit + + answers = [] + + class _Chat: + id = 1 + + class _Msg: + chat = _Chat() + + class _Query: + data = "sessstar:keep01" + message = _Msg() + + async def answer(self, *a, **k): + answers.append(a[0] if a else "") + + await ch._handle_session_button(_Query()) + assert toggled["sid"] == "keep01" # toggle routed with the id + assert edited # list re-rendered in place + assert answers and "Kept alive" in answers[0]