"""Data-integrity smoke scenarios. C3: deleting a journal entry de-indexes its RAG chunks. The full chain: a journal entry is indexed (chunks in ``chunks_journals`` + an ``indexed_documents`` tracking row), then ``OptimalService.unindex_journal_entry`` runs against a real pgvector store + the real tracking-row repository, and both the chunks and the tracking row are gone. This proves the C3 fix's cross-layer wiring end-to-end (no orphaned chunks bleeding into RAG answers). Deviations from the brief (noted): the e2e app does not mount the journal route, ``similar_memory`` queries LEARNINGS + PLAYBOOKS (not JOURNALS), and the JOURNALS plugin's ``initialize()`` requires the ollama embedder (not available in the smoke env). So this smoke drives the de-index path directly with a real ``VectorStore`` against the test DB wrapped in a minimal ``OptimalService`` — exercising the exact pgvector + tracking-row deletes the production path runs. The ``delete_entry`` call site is covered by ``tests/unit/services/test_journal.py``. H12: notification dedup must not drop a notification whose recipient set OVERLAPS but is NOT EQUAL to an existing unacked one's. The ``notify`` do-tool takes a single recipient and the notifications REST router is not mounted in the smoke harness, so this smoke drives ``NotificationService._create_notification`` directly against the e2e DB — exercising the real ``_duplicate_unacked_exists`` SQL predicate + real ``NotificationTable`` insert path (the production path). """ from __future__ import annotations import hashlib from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 import pytest from roboco.config import settings from roboco.db.tables import ( AgentTable, IndexedDocumentTable, NotificationTable, PlaybookTable, ) from roboco.models import NotificationPriority, NotificationType from roboco.models.base import AgentRole, AgentStatus, PlaybookStatus, Team from roboco.models.notification import CreateNotificationParams from roboco.models.optimal import IndexType from roboco.services.notification import NotificationService from roboco.services.optimal import OptimalService from roboco.services.optimal_brain.indexes.base import IngestResult from roboco.services.optimal_brain.vector_store import VectorStore from roboco.services.playbook import PlaybookService from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine if TYPE_CHECKING: from tests.e2e_smoke.harness import E2EStack _SMOKE_DIM = 4 # tiny embedding dim — the table is created fresh per run _EXPECTED_H12_ROWS = 2 # first (be-pm) + second (be-pm, main-pm) both persist def _chunk_dsn(db_url: str) -> str: """asyncpg-friendly DSN (postgres:// -> postgresql://).""" return db_url.replace("postgresql+asyncpg://", "postgresql://").replace( "postgres://", "postgresql://", 1 ) @pytest.mark.asyncio async def test_c3_deleted_journal_unindexed(e2e_stack: E2EStack) -> None: from roboco.db import base as db_base # Rebind the app's lazy engine to this test's loop (see H12 for the # rationale): unindex_journal_entry's tracking-row delete routes through # get_db_context, whose lazy engine was bound to the e2e stack's uvicorn # thread loop on import. db_base._DbHolder.engine = None db_base._DbHolder.session_factory = None entry_id = uuid4() source = f"roboco://journals/{entry_id}" source_hash = hashlib.sha256(source.encode()).hexdigest() # 1. Provision a real VectorStore for the JOURNALS index against the test # DB. ``initialize`` creates ``chunks_journals`` if absent. store = VectorStore( dsn=_chunk_dsn(e2e_stack.db_url), table_name="chunks_journals", vector_dimension=_SMOKE_DIM, pool_min_size=1, pool_max_size=2, ) await store.initialize() try: # Seed a chunk row for the journal source with a tiny zero vector. pool = store._require_pool() async with pool.acquire() as conn: await conn.execute( "INSERT INTO chunks_journals (content, source, embedding, metadata) " "VALUES ($1, $2, $3::vector, $4::jsonb)", "private reflection that must not leak post-delete", source, "[" + ",".join(("0.0",) * _SMOKE_DIM) + "]", "{}", ) # 2. Seed the tracking row via SQLAlchemy. engine = create_async_engine(e2e_stack.db_url, future=True) try: factory = async_sessionmaker(engine, expire_on_commit=False) async with factory() as session: row = IndexedDocumentTable( index_type=IndexType.JOURNALS.value, source=source, source_hash=source_hash, title="Journal: reflect", preview="private reflection that must not leak post-delete", chunk_count=1, extra_data={}, ) session.add(row) await session.commit() # 3. Build a minimal OptimalService whose JOURNALS plugin routes # delete_by_source to the real VectorStore. This mirrors how # unindex_playbook's else-branch calls plugin._require_store. svc = OptimalService() plugin_obj: Any = type("FakeJournalsPlugin", (), {})() object.__setattr__(plugin_obj, "_require_store", store) svc._plugins = {IndexType.JOURNALS: plugin_obj} svc._initialized = True # 4. Run the de-index. await svc.unindex_journal_entry(entry_id) # 5. Assert the chunk row is gone. async with pool.acquire() as conn: remaining = await conn.fetchval( "SELECT count(*) FROM chunks_journals WHERE source = $1", source, ) assert remaining == 0, "journal chunk rows survived de-index" # 6. Assert the tracking row is gone. async with factory() as session: rows = ( await session.execute( select(IndexedDocumentTable).where( IndexedDocumentTable.source_hash == source_hash, IndexedDocumentTable.index_type == IndexType.JOURNALS.value, ) ) ).all() assert rows == [], "indexed_documents tracking row survived de-index" finally: await engine.dispose() finally: await store.close() # --------------------------------------------------------------------------- # H12 — notification dedup must not drop a notification with an # overlapping-but-not-equal recipient set (main-pm learns) # --------------------------------------------------------------------------- def _seed_sender_agent() -> AgentTable: return AgentTable( name="cell-pm smoke", slug="cell-pm-smoke", role=AgentRole.CELL_PM, team=Team.BACKEND, status=AgentStatus.OFFLINE, model_config={}, system_prompt="", capabilities=[], permissions={}, metrics={}, ) async def _seed_sender(session: Any) -> UUID: from sqlalchemy import delete # Idempotent: clear any prior smoke sender so the unique slug holds. await session.execute(delete(AgentTable).where(AgentTable.slug == "cell-pm-smoke")) agent = _seed_sender_agent() session.add(agent) await session.flush() return UUID(str(agent.id)) async def _list_notifications(session: Any, sender_id: UUID) -> list[NotificationTable]: result = await session.execute( select(NotificationTable) .where(NotificationTable.from_agent == sender_id) .order_by(NotificationTable.timestamp) ) return list(result.scalars().all()) @pytest.mark.asyncio async def test_h12_dedup_does_not_drop_new_recipient(e2e_stack: E2EStack) -> None: """H12(a) end-to-end: a blocker sent to {be-pm, main-pm} after an unacked one to {be-pm} alone must STILL reach main-pm. Pre-fix the overlap predicate dropped the entire second notification for ALL recipients.""" from roboco.db import base as db_base from roboco.db.base import get_db_context # Prior smoke tests in the same session (C3) call get_db_context, binding # the app's lazy engine to their event loop. pytest-asyncio gives each # test its own loop, so reusing that engine raises "attached to a # different loop". Drop the holder so this test's first get_db_context # binds a fresh engine to THIS loop. db_base._DbHolder.engine = None db_base._DbHolder.session_factory = None # Seed the sender agent through the app's own DB context (same engine the # service uses), so the lazy engine binds once and stays alive for the # service calls below. async with get_db_context() as session: sender_id = await _seed_sender(session) await session.commit() be_pm = uuid4() main_pm = uuid4() params_first = CreateNotificationParams( notification_type=NotificationType.ALERT, priority=NotificationPriority.HIGH, from_agent="cell-pm-smoke", to_agents=[str(be_pm)], subject="Blocker on T1", body="first blocker — be-pm only", ) params_second = CreateNotificationParams( notification_type=NotificationType.ALERT, priority=NotificationPriority.HIGH, from_agent="cell-pm-smoke", to_agents=[str(be_pm), str(main_pm)], subject="Blocker on T1", body="second blocker — be-pm + main-pm", ) svc = NotificationService() await svc._create_notification(params_first) await svc._create_notification(params_second) async with get_db_context() as session: rows = await _list_notifications(session, sender_id) assert len(rows) == _EXPECTED_H12_ROWS, ( f"expected both notifications persisted (overlap≠equal must NOT " f"suppress); got {len(rows)} row(s)" ) first, second = rows assert set(first.to_agents) == {be_pm} assert set(second.to_agents) == {be_pm, main_pm} assert main_pm in second.to_agents, "main-pm was dropped by dedup overlap" # --------------------------------------------------------------------------- # M23 — startup reconcile re-indexes APPROVED-but-unindexed playbooks # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_m23_approved_unindexed_reconcile(e2e_stack: E2EStack) -> None: """M23 end-to-end: an APPROVED playbook left ``indexed_ok=False`` by a mid-approval Ollama outage is found by the startup reconcile, re-indexed through the real ``index_approved`` path, and stamped ``indexed_ok=True``/``indexed_at`` in the DB. Proves the cross-layer wiring (the reconcile query → the service → the durable flag) without a live embedder (the optimal service is stubbed).""" from roboco.db import base as db_base from roboco.db.base import get_db_context # Rebind the lazy engine to this test's loop (see H12 for the rationale). db_base._DbHolder.engine = None db_base._DbHolder.session_factory = None approver = uuid4() creator = uuid4() pb_id = uuid4() # 1. Seed an APPROVED playbook with indexed_ok=False (the outage state). async with get_db_context() as session: pb = PlaybookTable( id=pb_id, title="M23 reconcile smoke", slug=f"m23-reconcile-smoke-{pb_id.hex[:12]}", problem="An Ollama restart mid-approval left this row unindexed.", procedure="1. Re-index on startup.\n2. Stamp indexed_ok=True.", tags=["backend"], team="backend", scope="org", source_task_ids=[], status=PlaybookStatus.APPROVED.value, created_by=creator, approved_by=approver, indexed_ok=False, indexed_at=None, ) session.add(pb) await session.commit() # 2. Stub the optimal service so index_playbook reports success without a # live embedder. The reconcile calls get_optimal_service() internally. optimal = MagicMock() optimal.index_playbook = AsyncMock( return_value=IngestResult(doc_id=str(pb_id), chunk_count=2, success=True) ) with ( patch( "roboco.services.optimal.get_optimal_service", AsyncMock(return_value=optimal), ), patch.object(settings, "org_memory_enabled", True), ): async with get_db_context() as session: svc = PlaybookService(session) count = await svc.reconcile_unindexed_approved() assert count == 1, "reconcile should have re-indexed the one unindexed row" # 3. Assert the durable flag flipped in the DB. engine = create_async_engine(e2e_stack.db_url, future=True) try: factory = async_sessionmaker(engine, expire_on_commit=False) async with factory() as session: row = ( await session.execute( select(PlaybookTable).where(PlaybookTable.id == pb_id) ) ).scalar_one() assert row.indexed_ok is True, "indexed_ok not stamped after reconcile" assert row.indexed_at is not None, "indexed_at not stamped after reconcile" finally: await engine.dispose()