feat: factory-based DB wiring and ServiceError notifications in TUI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:27:08 -04:00
parent 208a9028e7
commit 98ea8a1a5c
3 changed files with 155 additions and 90 deletions
+2 -3
View File
@@ -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)
factory = get_session_factory(engine)
svc = ProjectService(factory)
for p in svc.list_all():
click.echo(f" {p.name} ({p.path})")
+43 -13
View File
@@ -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.
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
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())
+106 -70
View File
@@ -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]