feat: worktree columns on sessions + migration v2

Add worktree_path and worktree_branch nullable columns to the Session
model, with a migration v2 that ALTERs the existing sessions table.
Tests cover fresh DB stamping at v2, v1→v2 upgrade path, and row-level
defaults (None when unset, populated when provided).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:03:38 -04:00
parent 682c6a3f74
commit 860f1b59bd
3 changed files with 99 additions and 10 deletions
+9 -1
View File
@@ -7,12 +7,20 @@ from hqt.db.models import Base
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Ordered schema migrations. Each entry upgrades the schema from the previous # Ordered schema migrations. Each entry upgrades the schema from the previous
# version to `version`. Version 1 (BASELINE_VERSION) is the schema produced by # version to `version`. Version 1 (BASELINE_VERSION) is the schema produced by
# Base.metadata.create_all, so entries here start at version 2, e.g.: # Base.metadata.create_all, so entries here start at version 2, e.g.:
# (2, lambda conn: conn.exec_driver_sql( # (2, lambda conn: conn.exec_driver_sql(
# "ALTER TABLE sessions ADD COLUMN foo TEXT")), # "ALTER TABLE sessions ADD COLUMN foo TEXT")),
MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [] def _migrate_v2(conn: Connection) -> None:
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_path TEXT")
conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT")
MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [
(2, _migrate_v2),
]
BASELINE_VERSION = 1 BASELINE_VERSION = 1
LATEST_VERSION = MIGRATIONS[-1][0] if MIGRATIONS else BASELINE_VERSION LATEST_VERSION = MIGRATIONS[-1][0] if MIGRATIONS else BASELINE_VERSION
+2
View File
@@ -38,6 +38,8 @@ class Session(Base):
tmux_session_name: Mapped[str] = mapped_column(unique=True) tmux_session_name: Mapped[str] = mapped_column(unique=True)
harness_session_id: Mapped[str | None] = mapped_column(default=None) harness_session_id: Mapped[str | None] = mapped_column(default=None)
model: Mapped[str | None] = mapped_column(default=None) model: Mapped[str | None] = mapped_column(default=None)
worktree_path: Mapped[str | None] = mapped_column(default=None)
worktree_branch: Mapped[str | None] = mapped_column(default=None)
created_at: Mapped[datetime] = mapped_column(insert_default=func.now()) created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
last_activity_at: Mapped[datetime | None] = mapped_column(default=None) last_activity_at: Mapped[datetime | None] = mapped_column(default=None)
archived: Mapped[bool] = mapped_column(default=False) archived: Mapped[bool] = mapped_column(default=False)
+88 -9
View File
@@ -86,15 +86,19 @@ def test_fresh_db_stamped_latest_version(tmp_path):
assert _user_version(get_engine(settings)) == migrations.LATEST_VERSION assert _user_version(get_engine(settings)) == migrations.LATEST_VERSION
def test_unversioned_existing_db_stamped_baseline(tmp_path): def test_unversioned_existing_db_stamped_latest(tmp_path):
# Simulates a user DB created before versioning existed: tables present, # Simulates a user DB created before versioning existed: tables present,
# user_version still 0. # user_version still 0. migrate() should apply all pending migrations and
# stamp the DB at LATEST_VERSION (not just BASELINE_VERSION).
settings = _tmp_settings(tmp_path) settings = _tmp_settings(tmp_path)
settings.db_path.parent.mkdir(parents=True, exist_ok=True) settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings) engine = get_engine(settings)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
migrate(engine) migrate(engine)
assert _user_version(engine) == migrations.BASELINE_VERSION assert _user_version(engine) == migrations.LATEST_VERSION
def test_db_from_newer_hqt_raises(tmp_path): def test_db_from_newer_hqt_raises(tmp_path):
@@ -109,20 +113,95 @@ def test_db_from_newer_hqt_raises(tmp_path):
def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch): def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch):
settings = _tmp_settings(tmp_path) settings = _tmp_settings(tmp_path)
ensure_db(settings) # stamps BASELINE_VERSION ensure_db(settings) # stamps migrations.LATEST_VERSION (currently 2)
applied: list[int] = [] applied: list[int] = []
# Use versions above current LATEST_VERSION so none have been applied yet.
monkeypatch.setattr( monkeypatch.setattr(
migrations, migrations,
"MIGRATIONS", "MIGRATIONS",
[(2, lambda conn: applied.append(2)), (3, lambda conn: applied.append(3))], [
*migrations.MIGRATIONS,
(3, lambda conn: applied.append(3)),
(4, lambda conn: applied.append(4)),
],
) )
monkeypatch.setattr(migrations, "LATEST_VERSION", 3) monkeypatch.setattr(migrations, "LATEST_VERSION", 4)
engine = get_engine(settings) engine = get_engine(settings)
migrate(engine) migrate(engine)
assert applied == [2, 3] assert applied == [3, 4]
assert _user_version(engine) == 3 assert _user_version(engine) == 4
migrate(engine) # re-run is a no-op migrate(engine) # re-run is a no-op
assert applied == [2, 3] assert applied == [3, 4]
def _session_column_names(engine) -> list[str]:
with engine.connect() as conn:
rows = conn.exec_driver_sql("PRAGMA table_info(sessions)").fetchall()
return [row[1] for row in rows]
def test_fresh_db_has_worktree_columns(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
cols = _session_column_names(engine)
assert "worktree_path" in cols
assert "worktree_branch" in cols
assert _user_version(engine) == 2
def test_v1_db_upgraded_to_v2(tmp_path):
# Build a v1 database: create all tables, drop the two new columns, stamp v1.
settings = _tmp_settings(tmp_path)
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings)
Base.metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_path")
conn.exec_driver_sql("ALTER TABLE sessions DROP COLUMN worktree_branch")
conn.exec_driver_sql("PRAGMA user_version = 1")
migrate(engine)
cols = _session_column_names(engine)
assert "worktree_path" in cols
assert "worktree_branch" in cols
assert _user_version(engine) == 2
def test_session_worktree_fields_default_none(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
factory = get_session_factory(engine)
with factory() as session:
p = Project(name="wt-proj", path="/tmp/wt-proj")
h = Harness(name="claude-code-wt")
session.add_all([p, h])
session.commit()
# Session without worktree fields
s_plain = Session(
project_id=p.id,
harness_id=h.id,
tmux_session_name="hqt-plain",
)
# Session with worktree fields
s_wt = Session(
project_id=p.id,
harness_id=h.id,
tmux_session_name="hqt-worktree",
worktree_path="/tmp/wt",
worktree_branch="feature/x",
)
session.add_all([s_plain, s_wt])
session.commit()
assert s_plain.worktree_path is None
assert s_plain.worktree_branch is None
assert s_wt.worktree_path == "/tmp/wt"
assert s_wt.worktree_branch == "feature/x"
def test_rows_stay_readable_after_session_closes(tmp_path): def test_rows_stay_readable_after_session_closes(tmp_path):