feat(db_store): decouple migration from store construction
lint / lint (push) Failing after 2m4s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 1m9s

Remove auto-migration from DbStore.__init__; add explicit check_migrations()
and migrate() methods. The CLI now checks both stores on every startup and
prompts the user to confirm before migrating. supervise.prepare() calls
.migrate() directly now that __init__ no longer does it implicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 17:16:18 +00:00
parent a4d82e5ff2
commit 5fcca2060f
8 changed files with 88 additions and 19 deletions
+6
View File
@@ -12,11 +12,17 @@ from unittest.mock import patch
import bot_bottle.cli as climod
from bot_bottle.cli import main
from bot_bottle.db_store import DbStore
from bot_bottle.log import Die
from bot_bottle.manifest import ManifestError
class TestMainDispatch(unittest.TestCase):
def setUp(self) -> None:
patcher = patch.object(DbStore, "check_migrations", return_value=True)
self._mock_check = patcher.start()
self.addCleanup(patcher.stop)
def test_no_args_prints_usage_returns_2(self) -> None:
with patch("sys.stderr", io.StringIO()):
self.assertEqual(2, main([]))
+4
View File
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.audit_store import AuditStore
from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import (
AuditEntry,
Proposal,
@@ -113,6 +115,7 @@ class TestQueueIO(unittest.TestCase):
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.")
self._home_patch = self._patch_home(Path(self._tmp.name))
self.slug = "dev"
QueueStore("").migrate()
def tearDown(self):
self._home_patch()
@@ -222,6 +225,7 @@ class TestAuditLog(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-audit.")
self._home_patch = self._patch_home(Path(self._tmp.name))
AuditStore().migrate()
def tearDown(self):
self._home_patch()
+4
View File
@@ -12,7 +12,9 @@ from pathlib import Path
from unittest.mock import patch
from bot_bottle import supervise
from bot_bottle.audit_store import AuditStore
from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.queue_store import QueueStore
from bot_bottle.supervise import (
Proposal,
STATUS_APPROVED,
@@ -59,6 +61,8 @@ class _FakeHomeMixin:
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original)
QueueStore("").migrate()
AuditStore().migrate()
def _teardown_fake_home(self):
self._restore_home()
+28 -14
View File
@@ -45,19 +45,22 @@ class TestPathHelpers(unittest.TestCase):
class TestReadMalformed(unittest.TestCase):
def test_read_proposal_missing_row(self) -> None:
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {"HOME": d}), \
self.assertRaises(FileNotFoundError):
read_proposal("slug", "p")
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(FileNotFoundError):
read_proposal("slug", "p")
def test_read_response_missing_row(self) -> None:
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {"HOME": d}), \
self.assertRaises(FileNotFoundError):
read_response("slug", "p")
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(FileNotFoundError):
read_response("slug", "p")
def test_list_pending_reads_db_only(self) -> None:
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
supervise.write_proposal(_proposal())
pending = list_pending_proposals("slug")
self.assertEqual(1, len(pending))
@@ -66,6 +69,7 @@ class TestReadMalformed(unittest.TestCase):
def test_list_pending_skips_when_response_present(self) -> None:
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
p = _proposal()
supervise.write_proposal(p)
supervise.write_response("slug", supervise.Response(
@@ -79,15 +83,17 @@ class TestReadMalformed(unittest.TestCase):
class TestWaitForResponse(unittest.TestCase):
def test_missing_response_times_out(self) -> None:
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {"HOME": d}), \
self.assertRaises(TimeoutError):
wait_for_response("slug", "p", deadline=time.monotonic())
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(TimeoutError):
wait_for_response("slug", "p", deadline=time.monotonic())
def test_empty_db_response_does_not_count(self) -> None:
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {"HOME": d}), \
self.assertRaises(TimeoutError):
wait_for_response("slug", "p", deadline=time.monotonic())
with patch.dict("os.environ", {"HOME": d}):
QueueStore("slug").migrate()
with self.assertRaises(TimeoutError):
wait_for_response("slug", "p", deadline=time.monotonic())
class TestReadAuditEntries(unittest.TestCase):
@@ -99,6 +105,7 @@ class TestReadAuditEntries(unittest.TestCase):
def test_reads_entries_from_db(self) -> None:
with tempfile.TemporaryDirectory() as home, \
patch.dict("os.environ", {"HOME": home}):
AuditStore().migrate()
write_audit_entry(AuditEntry(
timestamp="t",
bottle_slug="slug",
@@ -142,6 +149,7 @@ class TestStoreGuardBranches(unittest.TestCase):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
self.assertTrue(db.is_file())
self.assertEqual(db, store.db_path)
@@ -149,6 +157,7 @@ class TestStoreGuardBranches(unittest.TestCase):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
db.unlink()
self.assertEqual([], store.list_pending_proposals())
@@ -156,6 +165,7 @@ class TestStoreGuardBranches(unittest.TestCase):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
db.unlink()
self.assertEqual([], store.list_all_pending_proposals())
@@ -163,27 +173,31 @@ class TestStoreGuardBranches(unittest.TestCase):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
store.migrate()
db.unlink()
store.archive_proposal("anything") # must not raise
def test_queue_store_chmod_oserror_is_swallowed(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "q.db"
store = QueueStore("key", db_path=db)
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
QueueStore("key", db_path=db) # must not raise
store.migrate() # must not raise
def test_audit_store_missing_db_read_returns_empty(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "a.db"
store = AuditStore(db_path=db)
store.migrate()
db.unlink()
self.assertEqual([], store.read_audit_entries("egress", "slug"))
def test_audit_store_chmod_oserror_is_swallowed(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "a.db"
store = AuditStore(db_path=db)
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
AuditStore(db_path=db) # must not raise
store.migrate() # must not raise
if __name__ == "__main__":
+4
View File
@@ -17,6 +17,8 @@ from unittest.mock import patch
# bare name `supervise`.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bottle"))
import supervise as _sv # noqa: E402 # type: ignore
import queue_store as _qs # noqa: E402 # type: ignore
import audit_store as _as # noqa: E402 # type: ignore
from bot_bottle import supervise_server # noqa: E402
from bot_bottle.supervise_server import (
@@ -267,6 +269,8 @@ class TestHandleToolsCall(unittest.TestCase):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
self._home_patch = self._patch_home(Path(self._tmp.name))
self.config = ServerConfig(bottle_slug="dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
def tearDown(self):
self._home_patch()