From 98ea8a1a5c8a6c59ff238efedbf6c9ab1ca354dd Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 19:27:08 -0400 Subject: [PATCH] feat: factory-based DB wiring and ServiceError notifications in TUI Co-Authored-By: Claude Fable 5 --- src/hqt/cli.py | 9 ++- src/hqt/tui/app.py | 60 ++++++++++++---- tests/test_tui.py | 176 +++++++++++++++++++++++++++------------------ 3 files changed, 155 insertions(+), 90 deletions(-) diff --git a/src/hqt/cli.py b/src/hqt/cli.py index 91ddefc..7069f7f 100644 --- a/src/hqt/cli.py +++ b/src/hqt/cli.py @@ -131,8 +131,7 @@ def list_cmd(): settings = get_settings() ensure_db(settings) engine = get_engine(settings) - Session = get_session_factory(engine) - with Session() as db: - svc = ProjectService(db) - for p in svc.list_all(): - click.echo(f" {p.name} ({p.path})") + factory = get_session_factory(engine) + svc = ProjectService(factory) + for p in svc.list_all(): + click.echo(f" {p.name} ({p.path})") diff --git a/src/hqt/tui/app.py b/src/hqt/tui/app.py index e2f2da5..6252098 100644 --- a/src/hqt/tui/app.py +++ b/src/hqt/tui/app.py @@ -1,5 +1,8 @@ import logging +from collections.abc import Coroutine +from typing import Any +from sqlalchemy.orm import sessionmaker from textual.app import App, ComposeResult from textual.binding import Binding from textual.screen import ModalScreen @@ -8,6 +11,7 @@ from textual.widgets import Footer from hqt.config import get_settings from hqt.db.engine import ensure_db, get_engine, get_session_factory +from hqt.errors import ServiceError from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db from hqt.projects.service import ProjectService from hqt.sessions.service import SessionService @@ -78,7 +82,9 @@ class HqtApp(App): # project_id -> last selected session_id, so switching projects restores # each project's previously highlighted session. self._selected_session_by_project: dict[int, int] = {} - self._db_session = None + # Set in on_mount; declared non-optional so call sites (incl. tests + # that seed via app._db_factory()) don't need None-narrowing. + self._db_factory: sessionmaker = None # ty: ignore[invalid-assignment] self._session_service: SessionService | None = None self._project_service: ProjectService | None = None @@ -90,6 +96,22 @@ class HqtApp(App): assert self._session_service is not None return self._session_service + def _run_service_worker(self, coro: Coroutine[Any, Any, None]) -> None: + """run_worker, but ServiceError surfaces as a notification. + + Services raise ServiceError for expected failures (rows deleted + underneath an action, harness uninstalled); an uncaught exception + would kill the worker and crash the app. + """ + + async def _wrapped() -> None: + try: + await coro + except ServiceError as err: + self.notify(str(err), severity="error") + + self.run_worker(_wrapped()) + def compose(self) -> ComposeResult: # No Header: the project and session panels share the top row directly # (ProjectList docks left, SessionList fills the rest), with the keybind @@ -103,9 +125,9 @@ class HqtApp(App): settings = get_settings() ensure_db(settings) engine = get_engine(settings) - factory = get_session_factory(engine) - self._db_session = factory() - ensure_harnesses_in_db(self._db_session) + self._db_factory = get_session_factory(engine) + with self._db_factory() as db: + ensure_harnesses_in_db(db) runner = TmuxRunner(settings.tmux_path, settings.tui_session_name) tmux = TmuxManager(runner) # Theme the tmux status bar to match the Catppuccin Frappé TUI. The TUI @@ -115,8 +137,8 @@ class HqtApp(App): self.run_worker(runner.apply_theme(home_window=settings.tui_window_name)) harnesses = discover_harnesses() log.info("Discovered harnesses: %s", list(harnesses.keys())) - self._project_service = ProjectService(self._db_session) - self._session_service = SessionService(self._db_session, tmux, harnesses) + self._project_service = ProjectService(self._db_factory) + self._session_service = SessionService(self._db_factory, tmux, harnesses) self._load_projects() self.set_interval(3, self._poll_sessions) log.info("TUI mounted successfully") @@ -132,7 +154,11 @@ class HqtApp(App): async def _poll_sessions(self) -> None: # Sync every session's tmux window label (session-wide), then update the # UI for the selected project from the same computed status — no recompute. - infos = await self._sessions().sync_window_labels() + try: + infos = await self._sessions().sync_window_labels() + except ServiceError as err: + log.warning("Session poll failed: %s", err) + return if self._selected_project_id is not None: subset = [ i for i in infos if i.session.project_id == self._selected_project_id @@ -157,7 +183,7 @@ class HqtApp(App): def on_project_selected(self, message: ProjectSelected) -> None: self._selected_project_id = message.project_id - self.run_worker(self._refresh_sessions(restore_selection=True)) + self._run_service_worker(self._refresh_sessions(restore_selection=True)) def on_session_selected(self, message: SessionSelected) -> None: if self._selected_project_id is not None: @@ -172,7 +198,11 @@ class HqtApp(App): def on_dismiss(result: tuple[str, str] | None) -> None: if result: name, path = result - self._projects().create(name, path) + try: + self._projects().create(name, path) + except ServiceError as err: + self.notify(str(err), severity="error") + return self._load_projects() self.push_screen(ProjectFormScreen(), on_dismiss) @@ -192,7 +222,7 @@ class HqtApp(App): name, path = result try: self._projects().update(project_id, name, path) - except ValueError as err: + except ServiceError as err: self.notify(str(err), severity="error") return self._load_projects() @@ -239,7 +269,7 @@ class HqtApp(App): ) await self._refresh_sessions() - self.run_worker(_do()) + self._run_service_worker(_do()) self.push_screen(NewSessionScreen(harness_names), on_dismiss) @@ -263,7 +293,7 @@ class HqtApp(App): if not ok: self.notify("Failed to attach session", severity="error") - self.run_worker(_do()) + self._run_service_worker(_do()) def action_delete_session(self) -> None: sid = self.query_one(SessionList).get_selected_session_id() @@ -273,7 +303,7 @@ class HqtApp(App): await self._sessions().delete_session(sid) await self._refresh_sessions() - self.run_worker(_do()) + self._run_service_worker(_do()) def action_rename_session(self) -> None: sid = self.query_one(SessionList).get_selected_session_id() @@ -288,7 +318,7 @@ class HqtApp(App): def on_dismiss(result: str | None) -> None: if result is not None: self._sessions().rename_session(sid, result) - self.run_worker(self._refresh_sessions()) + self._run_service_worker(self._refresh_sessions()) self.push_screen( RenameSessionScreen(initial_nickname=sess.nickname or ""), @@ -303,4 +333,4 @@ class HqtApp(App): await self._sessions().stop_session(sid) await self._refresh_sessions() - self.run_worker(_do()) + self._run_service_worker(_do()) diff --git a/tests/test_tui.py b/tests/test_tui.py index d60ff2f..eaa972f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -49,20 +49,23 @@ async def test_session_refresh_no_duplicate_ids(): async with app.run_test(size=(120, 40)): from hqt.db.models import Project, Harness, Session + seed_db = app._db_factory() proj = Project(name="test", path="/tmp/test") - app._db_session.add(proj) - app._db_session.flush() - harness = app._db_session.query(Harness).first() + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() sess = Session( project_id=proj.id, harness_id=harness.id, tmux_session_name="hqt-test-dup", archived=False, ) - app._db_session.add(sess) - app._db_session.commit() + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + seed_db.close() - app._selected_project_id = proj.id + app._selected_project_id = proj_id # Call refresh multiple times rapidly (simulates poll timer overlap) await app._refresh_sessions() await app._refresh_sessions() @@ -81,20 +84,24 @@ async def test_session_selection_preserved_across_refresh(): from hqt.db.models import Project, Harness, Session from hqt.tui.widgets.session_list import SessionList + seed_db = app._db_factory() proj = Project(name="test", path="/tmp/test-sel") - app._db_session.add(proj) - app._db_session.flush() - harness = app._db_session.query(Harness).first() + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() sess = Session( project_id=proj.id, harness_id=harness.id, tmux_session_name="hqt-test-sel", archived=False, ) - app._db_session.add(sess) - app._db_session.commit() + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + sess_id = sess.id + seed_db.close() - app._selected_project_id = proj.id + app._selected_project_id = proj_id await app._refresh_sessions() await pilot.pause() @@ -104,12 +111,12 @@ async def test_session_selection_preserved_across_refresh(): await pilot.press("down") await pilot.pause() - assert sl.get_selected_session_id() == sess.id + assert sl.get_selected_session_id() == sess_id # Refresh should preserve selection await app._refresh_sessions() await pilot.pause() - assert sl.get_selected_session_id() == sess.id + assert sl.get_selected_session_id() == sess_id # --------------------------------------------------------------------------- @@ -205,20 +212,24 @@ async def test_stop_session_action_calls_service_and_refreshes(): from hqt.db.models import Project, Harness, Session from hqt.tui.widgets.session_list import SessionList + seed_db = app._db_factory() proj = Project(name="stop-proj", path="/tmp/stop-proj") - app._db_session.add(proj) - app._db_session.flush() - harness = app._db_session.query(Harness).first() + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() sess = Session( project_id=proj.id, harness_id=harness.id, tmux_session_name="hqt-stop-test", archived=False, ) - app._db_session.add(sess) - app._db_session.commit() + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + sess_id = sess.id + seed_db.close() - app._selected_project_id = proj.id + app._selected_project_id = proj_id await app._refresh_sessions() await pilot.pause() @@ -228,7 +239,7 @@ async def test_stop_session_action_calls_service_and_refreshes(): lv.focus() await pilot.press("down") await pilot.pause() - assert sl.get_selected_session_id() == sess.id + assert sl.get_selected_session_id() == sess_id # Replace service with a mock that records calls mock_service = MagicMock() @@ -241,7 +252,7 @@ async def test_stop_session_action_calls_service_and_refreshes(): await app.run_action("stop_session") await pilot.pause() - mock_service.stop_session.assert_awaited_once_with(sess.id) + mock_service.stop_session.assert_awaited_once_with(sess_id) mock_service.list_sessions.assert_awaited() @@ -269,11 +280,14 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh(): async with app.run_test(size=(120, 40)) as pilot: from hqt.db.models import Project + seed_db = app._db_factory() proj = Project(name="seq-proj", path="/tmp/seq-proj") - app._db_session.add(proj) - app._db_session.flush() - app._db_session.commit() - app._selected_project_id = proj.id + seed_db.add(proj) + seed_db.flush() + seed_db.commit() + proj_id = proj.id + seed_db.close() + app._selected_project_id = proj_id call_order: list[str] = [] @@ -335,22 +349,26 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure(): async with app.run_test(size=(120, 40)) as pilot: from hqt.db.models import Project + seed_db = app._db_factory() proj = Project(name="fail-proj", path="/tmp/fail-proj") - app._db_session.add(proj) - app._db_session.flush() - app._db_session.commit() - app._selected_project_id = proj.id + seed_db.add(proj) + seed_db.flush() + seed_db.commit() + proj_id = proj.id + app._selected_project_id = proj_id # Build a minimal fake Session object - harness_row = app._db_session.query(Harness).first() + harness_row = seed_db.query(Harness).first() fake_sess = DBSession( - project_id=proj.id, + project_id=proj_id, harness_id=harness_row.id, tmux_session_name="hqt-fail", archived=False, ) - app._db_session.add(fake_sess) - app._db_session.flush() + seed_db.add(fake_sess) + seed_db.flush() + seed_db.expunge(fake_sess) + seed_db.close() fail_result = CreateSessionResult( session=fake_sess, @@ -404,21 +422,25 @@ async def test_new_session_on_dismiss_no_notify_on_success(): async with app.run_test(size=(120, 40)) as pilot: from hqt.db.models import Project + seed_db = app._db_factory() proj = Project(name="ok-proj", path="/tmp/ok-proj") - app._db_session.add(proj) - app._db_session.flush() - app._db_session.commit() - app._selected_project_id = proj.id + seed_db.add(proj) + seed_db.flush() + seed_db.commit() + proj_id = proj.id + app._selected_project_id = proj_id - harness_row = app._db_session.query(Harness).first() + harness_row = seed_db.query(Harness).first() fake_sess = DBSession( - project_id=proj.id, + project_id=proj_id, harness_id=harness_row.id, tmux_session_name="hqt-ok", archived=False, ) - app._db_session.add(fake_sess) - app._db_session.flush() + seed_db.add(fake_sess) + seed_db.flush() + seed_db.expunge(fake_sess) + seed_db.close() ok_result = CreateSessionResult( session=fake_sess, spawn_ok=True, spawn_error="" @@ -890,11 +912,12 @@ async def test_first_session_selected_and_remembered_per_project(): from hqt.tui.widgets.project_list import ProjectSelected from hqt.tui.widgets.session_list import SessionList - h = app._db_session.query(Harness).first() + seed_db = app._db_factory() + h = seed_db.query(Harness).first() pa = Project(name="A", path="/tmp/proj-A") pb = Project(name="B", path="/tmp/proj-B") - app._db_session.add_all([pa, pb]) - app._db_session.flush() + seed_db.add_all([pa, pb]) + seed_db.flush() a1 = Session( project_id=pa.id, harness_id=h.id, @@ -913,35 +936,38 @@ async def test_first_session_selected_and_remembered_per_project(): tmux_session_name="hqt-b1", archived=False, ) - app._db_session.add_all([a1, a2, b1]) - app._db_session.commit() + seed_db.add_all([a1, a2, b1]) + seed_db.commit() + pa_id, pb_id = pa.id, pb.id + a1_id, a2_id, b1_id = a1.id, a2.id, b1.id + seed_db.close() sl = app.query_one(SessionList) # Enter project A: first session auto-selected. - app.on_project_selected(ProjectSelected(pa.id)) + app.on_project_selected(ProjectSelected(pa_id)) await app.workers.wait_for_complete() await pilot.pause() - assert sl.get_selected_session_id() == a1.id + assert sl.get_selected_session_id() == a1_id # Move selection to the second session in A. lv = sl.query_one("#session-list") lv.focus() await pilot.press("down") await pilot.pause() - assert sl.get_selected_session_id() == a2.id + assert sl.get_selected_session_id() == a2_id # Switch to project B: its first session is auto-selected. - app.on_project_selected(ProjectSelected(pb.id)) + app.on_project_selected(ProjectSelected(pb_id)) await app.workers.wait_for_complete() await pilot.pause() - assert sl.get_selected_session_id() == b1.id + assert sl.get_selected_session_id() == b1_id # Switch back to A: the previously selected session (a2) is restored. - app.on_project_selected(ProjectSelected(pa.id)) + app.on_project_selected(ProjectSelected(pa_id)) await app.workers.wait_for_complete() await pilot.pause() - assert sl.get_selected_session_id() == a2.id + assert sl.get_selected_session_id() == a2_id # --------------------------------------------------------------------------- @@ -1076,10 +1102,11 @@ async def test_rename_session_opens_prefilled_and_updates(): from hqt.tui.screens.rename_session import RenameSessionScreen from textual.widgets import Input + seed_db = app._db_factory() proj = Project(name="ren-proj", path="/tmp/ren-proj") - app._db_session.add(proj) - app._db_session.flush() - harness = app._db_session.query(Harness).first() + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() sess = Session( project_id=proj.id, harness_id=harness.id, @@ -1087,10 +1114,13 @@ async def test_rename_session_opens_prefilled_and_updates(): tmux_session_name="hqt-ren", archived=False, ) - app._db_session.add(sess) - app._db_session.commit() + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + sess_id = sess.id + seed_db.close() - app._selected_project_id = proj.id + app._selected_project_id = proj_id await app._refresh_sessions() await pilot.pause() @@ -1098,7 +1128,7 @@ async def test_rename_session_opens_prefilled_and_updates(): sl.query_one("#session-list").focus() await pilot.press("down") await pilot.pause() - assert sl.get_selected_session_id() == sess.id + assert sl.get_selected_session_id() == sess_id await app.run_action("rename_session") await pilot.pause() @@ -1111,7 +1141,9 @@ async def test_rename_session_opens_prefilled_and_updates(): await app.workers.wait_for_complete() await pilot.pause() - assert app._db_session.get(Session, sess.id).nickname == "after" + verify_db = app._db_factory() + assert verify_db.get(Session, sess_id).nickname == "after" + verify_db.close() # --------------------------------------------------------------------------- @@ -1153,10 +1185,11 @@ async def test_session_list_jk_navigation(): app = HqtApp() async with app.run_test(size=(120, 40)) as pilot: + seed_db = app._db_factory() proj = Project(name="jk-proj", path="/tmp/jk-proj") - app._db_session.add(proj) - app._db_session.flush() - harness = app._db_session.query(Harness).first() + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() sessions = [] for n in range(2): s = Session( @@ -1166,11 +1199,14 @@ async def test_session_list_jk_navigation(): tmux_session_name=f"hqt-jk-{n}", archived=False, ) - app._db_session.add(s) + seed_db.add(s) sessions.append(s) - app._db_session.commit() + seed_db.commit() + proj_id = proj.id + session_ids = [s.id for s in sessions] + seed_db.close() - app._selected_project_id = proj.id + app._selected_project_id = proj_id # restore_selection mirrors a real project switch, which highlights the # first session — giving j/k a defined starting point. await app._refresh_sessions(restore_selection=True) @@ -1181,7 +1217,7 @@ async def test_session_list_jk_navigation(): await pilot.pause() await pilot.press("j") await pilot.pause() - assert sl.get_selected_session_id() == sessions[1].id + assert sl.get_selected_session_id() == session_ids[1] await pilot.press("k") await pilot.pause() - assert sl.get_selected_session_id() == sessions[0].id + assert sl.get_selected_session_id() == session_ids[0]