diff --git a/src/hqt/sessions/service.py b/src/hqt/sessions/service.py index c11e6be..d8d4a4b 100644 --- a/src/hqt/sessions/service.py +++ b/src/hqt/sessions/service.py @@ -826,6 +826,37 @@ class SessionService: db.delete(sess) db.commit() + async def archive_session(self, session_id: int) -> None: + """Archive a session: kill its tmux window but keep the DB row. + + A recoverable "remove from view" — the window/harness process is torn + down (freeing resources) but the row, ``harness_session_id``, and any + worktree are preserved so the session can be restored or referenced + later. No-op if the session does not exist. + """ + with self.factory() as db: + sess = db.get(Session, session_id) + if sess is None: + return + if await self.tmux.window_exists(sess.tmux_session_name): + await self.tmux.kill(sess.tmux_session_name) + sess.archived = True + db.commit() + + async def unarchive_session(self, session_id: int) -> None: + """Restore an archived session by clearing its archived flag. + + The tmux window stays gone (the session reads as dead until resumed); + this only makes the row visible in the active list again. No-op if the + session does not exist. + """ + with self.factory() as db: + sess = db.get(Session, session_id) + if sess is None: + return + sess.archived = False + db.commit() + async def worktree_state_for(self, session_id: int) -> WorktreeState | None: """Return the WorktreeState for a session's worktree, or None. diff --git a/tests/test_sessions.py b/tests/test_sessions.py index bbf4902..5a5d271 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -2170,3 +2170,43 @@ async def test_list_sessions_archived_filter(service, db, tmux): archived = await service.list_sessions(project_id=1, archived=True) assert [i.session.id for i in archived] == [arch_id] + + +@pytest.mark.asyncio +async def test_archive_session_kills_window_and_keeps_row(service, db, tmux): + await service.create_session(project_id=1, harness_name="claude-code") + await service.archive_session(1) + + tmux.kill.assert_called_once_with("hqt-1") + db.expire_all() + row = db.get(Session, 1) + assert row is not None + assert row.archived is True + # Excluded from the default (active) listing. + assert await service.list_sessions(project_id=1) == [] + + +@pytest.mark.asyncio +async def test_archive_session_no_kill_when_window_missing(service, db, tmux): + await service.create_session(project_id=1, harness_name="claude-code") + tmux.window_exists = AsyncMock(return_value=False) + tmux.kill.reset_mock() + + await service.archive_session(1) + + tmux.kill.assert_not_called() + db.expire_all() + assert db.get(Session, 1).archived is True + + +@pytest.mark.asyncio +async def test_unarchive_session_restores_to_active_list(service, db, tmux): + await service.create_session(project_id=1, harness_name="claude-code") + await service.archive_session(1) + + await service.unarchive_session(1) + + db.expire_all() + assert db.get(Session, 1).archived is False + active = await service.list_sessions(project_id=1) + assert [i.session.id for i in active] == [1]