diff --git a/shared/archived_memories.py b/shared/archived_memories.py index 3c099ba..69b8255 100644 --- a/shared/archived_memories.py +++ b/shared/archived_memories.py @@ -10,6 +10,30 @@ class ArchivedMemories: def __init__(self, cm: AsyncConnectionManager | None = None): self._cm = cm or connection_manager + self._ready = False + + async def ensure(self) -> None: + """Create the table if it is not there yet, once per instance. + + Same shape as `DreamBuffer.ensure`, and for the same reason: this class + is constructed in several places that never run migrations, so a method + that touches the table has to be able to bring it into existence. + + Why it was needed: `archive()` and `get_archived()` used to assume the + table existed. On a database that had run the migration it did, which + hid the problem — `tests/test_shared/test_shared.py::test_archived_memories` + passed only when another test had migrated the shared session database + first. Run alone, or with `-p no:randomly`, it failed with + `no such table: archived_memories`. A test that needs a neighbour to + pass is not testing the class. + """ + if self._ready: + return + conn = await self._cm.get(DB_NAME) + cols = [r[1] for r in await (await conn.execute("PRAGMA table_info(archived_memories)")).fetchall()] + if not cols: + await self._init_db() + self._ready = True async def _init_db(self) -> None: await self._cm.execute_script( @@ -36,6 +60,7 @@ async def archive( original_id: int | None = None, reason: str = "manual", ) -> int: + await self.ensure() conn = await self._cm.get(DB_NAME) cursor = await conn.execute( "INSERT INTO archived_memories (user_id, original_id, content, memory_type, importance, archive_reason) VALUES (?, ?, ?, ?, ?, ?)", @@ -46,6 +71,7 @@ async def archive( return int(last_id) if last_id is not None else 0 async def get_archived(self, user_id: str = "default", limit: int = 50) -> list[dict[str, Any]]: + await self.ensure() conn = await self._cm.get(DB_NAME) cursor = await conn.execute( "SELECT * FROM archived_memories WHERE user_id=? ORDER BY archived_at DESC LIMIT ?", @@ -64,11 +90,13 @@ async def get_archived(self, user_id: str = "default", limit: int = 50) -> list[ ] async def count(self, user_id: str = "default") -> int: + await self.ensure() conn = await self._cm.get(DB_NAME) row = await (await conn.execute("SELECT COUNT(*) FROM archived_memories WHERE user_id=?", (user_id,))).fetchone() return int(row[0]) if row else 0 async def restore(self, archived_id: int) -> dict[str, Any] | None: + await self.ensure() conn = await self._cm.get(DB_NAME) row = await (await conn.execute("SELECT * FROM archived_memories WHERE id=?", (archived_id,))).fetchone() if row: diff --git a/tests/test_shared/test_shared.py b/tests/test_shared/test_shared.py index e688cdb..2ec5592 100644 --- a/tests/test_shared/test_shared.py +++ b/tests/test_shared/test_shared.py @@ -7,37 +7,78 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -def test_dream_buffer(): +def test_dream_buffer(tmp_path): + from shared.connection import AsyncConnectionManager from shared.dream_buffer import DreamBuffer async def t(): - db = DreamBuffer() + cm = AsyncConnectionManager(base_dir=str(tmp_path)) + db = DreamBuffer(cm=cm) await db.add("test_sh", "s1", "msg", 0.5) assert await db.count("test_sh") >= 1 await db.cleanup_old(max_age_hours=0, max_count=0) + await cm.close_all() asyncio.run(t()) -def test_archived_memories(): +def test_archived_memories(tmp_path): + """Свой manager на tmp_path, а не глобальный. + + Тест строил `ArchivedMemories()` без `cm`, то есть на глобальном + `connection_manager`, и тем самым писал прямо в рабочий каталог памяти: + в `~/.mcp-ariel-memory/memory.db` осталось 32 строки `user_id='test_sh'` + от 24.08.2026 — до того, как conftest получил фикстуру `hermetic_global_db` + (коммит 90c9149). После неё тест стал ещё и непроходимым в одиночку: + таблицу `archived_memories` создавала только миграция, а без соседа, + который её прогонял, вызов падал на `no such table`. + + Хозяин базы теперь свой, и он же проверяет настоящее свойство класса: + таблица появляется сама (см. `ArchivedMemories.ensure`). + """ from shared.archived_memories import ArchivedMemories + from shared.connection import AsyncConnectionManager async def t(): - am = ArchivedMemories() + cm = AsyncConnectionManager(base_dir=str(tmp_path)) + am = ArchivedMemories(cm=cm) await am.archive("test_sh", "Old memory", importance=0.2, reason="test") archived = await am.get_archived("test_sh") assert len(archived) >= 1 + await cm.close_all() + + asyncio.run(t()) + + +def test_archived_memories_works_on_a_migrated_database(tmp_path): + """На мигрированной базе класс работает и таблицу не ломает.""" + from shared.archived_memories import ArchivedMemories + from shared.connection import AsyncConnectionManager + from shared.migrations import MigrationManager + + async def t(): + cm = AsyncConnectionManager(base_dir=str(tmp_path)) + await MigrationManager(cm=cm).migrate() + am = ArchivedMemories(cm=cm) + assert await am.count("test_sh") == 0 + await am.archive("test_sh", "Migrated db row", reason="test") + assert await am.count("test_sh") == 1 + await cm.close_all() asyncio.run(t()) -def test_embedding_cache(): +def test_embedding_cache(tmp_path): + """Тот же принцип: свой manager, чтобы файл не трогал общий каталог вовсе.""" + from shared.connection import AsyncConnectionManager from shared.embeddings import EmbeddingCache async def t(): - ec = EmbeddingCache() + cm = AsyncConnectionManager(base_dir=str(tmp_path)) + ec = EmbeddingCache(cm=cm) emb = await ec.embed_single("test") assert len(emb) == 384 + await cm.close_all() asyncio.run(t())