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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user