From 080ddd9364b88a0023dd7f039439829418e70544 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:23:45 -0400 Subject: [PATCH] feat: add Session.sandbox_json column and migration Adds a nullable sandbox_json TEXT column to the sessions table via a new version-3 migration. Updates LATEST_VERSION to 3 and adjusts existing migration tests to account for the new column and version number. Co-Authored-By: Claude Opus 4.8 --- src/hqt/db/migrations.py | 5 ++++ src/hqt/db/models.py | 1 + tests/test_db.py | 63 ++++++++++++++++++++++++++++++++++------ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/hqt/db/migrations.py b/src/hqt/db/migrations.py index d234f6c..2f42478 100644 --- a/src/hqt/db/migrations.py +++ b/src/hqt/db/migrations.py @@ -18,8 +18,13 @@ def _migrate_v2(conn: Connection) -> None: conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT") +def _migrate_v3(conn: Connection) -> None: + conn.exec_driver_sql("ALTER TABLE sessions ADD COLUMN sandbox_json TEXT") + + MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [ (2, _migrate_v2), + (3, _migrate_v3), ] BASELINE_VERSION = 1 diff --git a/src/hqt/db/models.py b/src/hqt/db/models.py index d90f89e..448726e 100644 --- a/src/hqt/db/models.py +++ b/src/hqt/db/models.py @@ -38,6 +38,7 @@ class Session(Base): tmux_session_name: Mapped[str] = mapped_column(unique=True) harness_session_id: Mapped[str | None] = mapped_column(default=None) model: Mapped[str | None] = mapped_column(default=None) + sandbox_json: 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()) diff --git a/tests/test_db.py b/tests/test_db.py index d28d876..349f1c1 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -97,6 +97,7 @@ def test_unversioned_existing_db_stamped_latest(tmp_path): 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("ALTER TABLE sessions DROP COLUMN sandbox_json") migrate(engine) assert _user_version(engine) == migrations.LATEST_VERSION @@ -113,7 +114,7 @@ def test_db_from_newer_hqt_raises(tmp_path): def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch): settings = _tmp_settings(tmp_path) - ensure_db(settings) # stamps migrations.LATEST_VERSION (currently 2) + ensure_db(settings) # stamps migrations.LATEST_VERSION (currently 3) applied: list[int] = [] # Use versions above current LATEST_VERSION so none have been applied yet. monkeypatch.setattr( @@ -121,17 +122,17 @@ def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch): "MIGRATIONS", [ *migrations.MIGRATIONS, - (3, lambda conn: applied.append(3)), (4, lambda conn: applied.append(4)), + (5, lambda conn: applied.append(5)), ], ) - monkeypatch.setattr(migrations, "LATEST_VERSION", 4) + monkeypatch.setattr(migrations, "LATEST_VERSION", 5) engine = get_engine(settings) migrate(engine) - assert applied == [3, 4] - assert _user_version(engine) == 4 + assert applied == [4, 5] + assert _user_version(engine) == 5 migrate(engine) # re-run is a no-op - assert applied == [3, 4] + assert applied == [4, 5] def _session_column_names(engine) -> list[str]: @@ -147,11 +148,11 @@ def test_fresh_db_has_worktree_columns(tmp_path): cols = _session_column_names(engine) assert "worktree_path" in cols assert "worktree_branch" in cols - assert _user_version(engine) == 2 + assert _user_version(engine) == 3 def test_v1_db_upgraded_to_v2(tmp_path): - # Build a v1 database: create all tables, drop the two new columns, stamp v1. + # Build a v1 database: create all tables, drop v2+ columns, stamp v1. settings = _tmp_settings(tmp_path) settings.db_path.parent.mkdir(parents=True, exist_ok=True) engine = get_engine(settings) @@ -159,6 +160,7 @@ def test_v1_db_upgraded_to_v2(tmp_path): 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("ALTER TABLE sessions DROP COLUMN sandbox_json") conn.exec_driver_sql("PRAGMA user_version = 1") migrate(engine) @@ -166,7 +168,8 @@ def test_v1_db_upgraded_to_v2(tmp_path): cols = _session_column_names(engine) assert "worktree_path" in cols assert "worktree_branch" in cols - assert _user_version(engine) == 2 + assert "sandbox_json" in cols + assert _user_version(engine) == 3 def test_session_worktree_fields_default_none(tmp_path): @@ -215,3 +218,45 @@ def test_rows_stay_readable_after_session_closes(tmp_path): session.add(p) session.commit() assert p.name == "x" # would raise DetachedInstanceError without the flag + + +def test_latest_version_is_three(): + assert migrations.LATEST_VERSION == 3 + + +def test_migration_adds_sandbox_json_column(tmp_path): + # Simulate a baseline DB whose sessions table predates the column. + settings = _tmp_settings(tmp_path) + settings.db_path.parent.mkdir(parents=True, exist_ok=True) + engine = get_engine(settings) + with engine.begin() as conn: + conn.exec_driver_sql( + "CREATE TABLE sessions (" + "id INTEGER PRIMARY KEY, project_id INTEGER, harness_id INTEGER, " + "tmux_session_name TEXT)" + ) + conn.exec_driver_sql("PRAGMA user_version = 2") + migrate(engine) + cols = [c["name"] for c in inspect(engine).get_columns("sessions")] + assert "sandbox_json" in cols + assert _user_version(engine) == 3 + + +def test_session_round_trips_sandbox_json(tmp_path): + settings = _tmp_settings(tmp_path) + ensure_db(settings) + factory = get_session_factory(get_engine(settings)) + with factory() as session: + p = Project(name="proj", path="/tmp/proj-sandbox") + h = Harness(name="claude-sandbox") + session.add_all([p, h]) + session.commit() + s = Session( + project_id=p.id, + harness_id=h.id, + tmux_session_name="hqt-9", + sandbox_json='{"fs": "rw", "net": true}', + ) + session.add(s) + session.commit() + assert session.get(Session, s.id).sandbox_json == '{"fs": "rw", "net": true}'