mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F057] playbook: index/unindex as a post-commit step so the RAG corpus never leads the status transaction
This commit is contained in:
@@ -53,14 +53,17 @@ async def approve_playbook(
|
|||||||
"""Approve a draft playbook → approved (and indexed into the KB)."""
|
"""Approve a draft playbook → approved (and indexed into the KB)."""
|
||||||
_require_curator(agent)
|
_require_curator(agent)
|
||||||
try:
|
try:
|
||||||
playbook = await get_playbook_service(db).approve(
|
svc = get_playbook_service(db)
|
||||||
playbook_id, approver_id=agent.agent_id
|
playbook = await svc.approve(playbook_id, approver_id=agent.agent_id)
|
||||||
)
|
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
|
||||||
) from exc
|
) from exc
|
||||||
|
# Commit the status change BEFORE indexing: the RAG index write runs through
|
||||||
|
# its own auto-committing connection, so indexing before commit would durably
|
||||||
|
# land an approved playbook in the corpus even if this commit rolled back.
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await svc.index_approved(playbook)
|
||||||
return Playbook.model_validate(playbook)
|
return Playbook.model_validate(playbook)
|
||||||
|
|
||||||
|
|
||||||
@@ -74,12 +77,15 @@ async def reject_playbook(
|
|||||||
"""Reject a playbook → archived, with the Auditor's reason."""
|
"""Reject a playbook → archived, with the Auditor's reason."""
|
||||||
_require_curator(agent)
|
_require_curator(agent)
|
||||||
try:
|
try:
|
||||||
playbook = await get_playbook_service(db).reject(
|
svc = get_playbook_service(db)
|
||||||
|
playbook = await svc.reject(
|
||||||
playbook_id, approver_id=agent.agent_id, reason=body.reason
|
playbook_id, approver_id=agent.agent_id, reason=body.reason
|
||||||
)
|
)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
|
||||||
) from exc
|
) from exc
|
||||||
|
# Commit the status change BEFORE de-indexing (see approve_playbook).
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
await svc.unindex_playbook(playbook)
|
||||||
return Playbook.model_validate(playbook)
|
return Playbook.model_validate(playbook)
|
||||||
|
|||||||
@@ -793,6 +793,18 @@ class ContentActions:
|
|||||||
status = "playbook_archived"
|
status = "playbook_archived"
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
return Envelope.not_found(message=f"playbook {playbook_id} not found")
|
return Envelope.not_found(message=f"playbook {playbook_id} not found")
|
||||||
|
# Commit the status change BEFORE touching the RAG index: the index write
|
||||||
|
# runs through its own auto-committing connection, so indexing before the
|
||||||
|
# status commit would durably land (or drop) a playbook in the corpus even
|
||||||
|
# if this transaction rolled back — a divergence agents surface in
|
||||||
|
# briefings. ``get_db`` commits the session again after the route returns
|
||||||
|
# (a no-op on the now-clean transaction); this explicit commit is what
|
||||||
|
# gates the index.
|
||||||
|
await self.task.session.commit()
|
||||||
|
if action == "approve":
|
||||||
|
await svc.index_approved(playbook)
|
||||||
|
else:
|
||||||
|
await svc.unindex_playbook(playbook)
|
||||||
return Envelope.ok(
|
return Envelope.ok(
|
||||||
status=status,
|
status=status,
|
||||||
task_id=None,
|
task_id=None,
|
||||||
|
|||||||
@@ -68,21 +68,30 @@ class PlaybookService(BaseService):
|
|||||||
return playbook
|
return playbook
|
||||||
|
|
||||||
async def approve(self, playbook_id: UUID, approver_id: UUID) -> PlaybookTable:
|
async def approve(self, playbook_id: UUID, approver_id: UUID) -> PlaybookTable:
|
||||||
"""Auditor approves a draft: draft -> approved, stamped."""
|
"""Auditor approves a draft: draft -> approved, stamped.
|
||||||
|
|
||||||
|
Flushes the status change ONLY — the RAG index write (``index_approved``)
|
||||||
|
is a SEPARATE step the caller runs AFTER committing the status. The vector
|
||||||
|
store writes through its own auto-committing pool connection, so indexing
|
||||||
|
inline (before the status commit) would durably land an approved playbook
|
||||||
|
in the corpus even if the status transaction rolled back — a divergence
|
||||||
|
agents then surfaced in briefings.
|
||||||
|
"""
|
||||||
playbook = await self._get_or_raise(playbook_id)
|
playbook = await self._get_or_raise(playbook_id)
|
||||||
playbook.status = PlaybookStatus.APPROVED.value
|
playbook.status = PlaybookStatus.APPROVED.value
|
||||||
playbook.approved_by = approver_id
|
playbook.approved_by = approver_id
|
||||||
playbook.approved_at = datetime.now(UTC)
|
playbook.approved_at = datetime.now(UTC)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await self._index_approved(playbook)
|
|
||||||
self.log.info("Playbook approved", playbook_id=str(playbook_id))
|
self.log.info("Playbook approved", playbook_id=str(playbook_id))
|
||||||
return playbook
|
return playbook
|
||||||
|
|
||||||
async def _index_approved(self, playbook: PlaybookTable) -> None:
|
async def index_approved(self, playbook: PlaybookTable) -> None:
|
||||||
"""Embed an approved playbook into the PLAYBOOKS RAG index (best-effort).
|
"""Embed an approved playbook into the PLAYBOOKS RAG index (best-effort).
|
||||||
|
|
||||||
Gated on ``org_memory_enabled`` so the feature is fully inert when off;
|
Post-commit step: the caller commits the ``draft -> approved`` status
|
||||||
a failure (e.g. the embedder is down) never blocks the approval.
|
change FIRST, then runs this so the index never leads the status
|
||||||
|
transaction. Gated on ``org_memory_enabled`` so the feature is fully inert
|
||||||
|
when off; a failure (e.g. the embedder is down) never blocks the approval.
|
||||||
"""
|
"""
|
||||||
if not settings.org_memory_enabled:
|
if not settings.org_memory_enabled:
|
||||||
return
|
return
|
||||||
@@ -114,17 +123,20 @@ class PlaybookService(BaseService):
|
|||||||
async def reject(
|
async def reject(
|
||||||
self, playbook_id: UUID, approver_id: UUID, reason: str
|
self, playbook_id: UUID, approver_id: UUID, reason: str
|
||||||
) -> PlaybookTable:
|
) -> PlaybookTable:
|
||||||
"""Auditor rejects a playbook: -> archived (reason recorded in the log)."""
|
"""Auditor rejects a playbook: -> archived (reason recorded in the log).
|
||||||
|
|
||||||
|
Flushes the status change ONLY — ``unindex_playbook`` is a separate
|
||||||
|
post-commit step (see ``approve`` for the ordering rationale).
|
||||||
|
"""
|
||||||
playbook = await self._get_or_raise(playbook_id)
|
playbook = await self._get_or_raise(playbook_id)
|
||||||
playbook.status = PlaybookStatus.ARCHIVED.value
|
playbook.status = PlaybookStatus.ARCHIVED.value
|
||||||
playbook.approved_by = approver_id
|
playbook.approved_by = approver_id
|
||||||
playbook.approved_at = datetime.now(UTC)
|
playbook.approved_at = datetime.now(UTC)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await self._unindex_playbook(playbook)
|
|
||||||
self.log.info("Playbook rejected", playbook_id=str(playbook_id), reason=reason)
|
self.log.info("Playbook rejected", playbook_id=str(playbook_id), reason=reason)
|
||||||
return playbook
|
return playbook
|
||||||
|
|
||||||
async def _unindex_playbook(self, playbook: PlaybookTable) -> None:
|
async def unindex_playbook(self, playbook: PlaybookTable) -> None:
|
||||||
"""De-index a playbook from the PLAYBOOKS RAG index (best-effort).
|
"""De-index a playbook from the PLAYBOOKS RAG index (best-effort).
|
||||||
|
|
||||||
The mirror of :meth:`_index_approved`: a rejected/archived playbook that
|
The mirror of :meth:`_index_approved`: a rejected/archived playbook that
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def _actions(role: str) -> ContentActions:
|
|||||||
agent = MagicMock()
|
agent = MagicMock()
|
||||||
agent.role = role
|
agent.role = role
|
||||||
task.agent_for = AsyncMock(return_value=agent)
|
task.agent_for = AsyncMock(return_value=agent)
|
||||||
task.session = MagicMock()
|
task.session = AsyncMock()
|
||||||
deps = ContentActionsDeps(
|
deps = ContentActionsDeps(
|
||||||
task=task,
|
task=task,
|
||||||
git=MagicMock(),
|
git=MagicMock(),
|
||||||
@@ -107,13 +107,17 @@ async def test_approve_playbook_for_auditor(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
approved.status = "approved"
|
approved.status = "approved"
|
||||||
svc = MagicMock()
|
svc = MagicMock()
|
||||||
svc.approve = AsyncMock(return_value=approved)
|
svc.approve = AsyncMock(return_value=approved)
|
||||||
|
svc.index_approved = AsyncMock()
|
||||||
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
|
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
|
||||||
env = await _actions("auditor").approve_playbook(
|
actions = _actions("auditor")
|
||||||
agent_id=uuid4(), playbook_id=uuid4()
|
env = await actions.approve_playbook(agent_id=uuid4(), playbook_id=uuid4())
|
||||||
)
|
|
||||||
assert env.error is None
|
assert env.error is None
|
||||||
assert env.status == "playbook_approved"
|
assert env.status == "playbook_approved"
|
||||||
svc.approve.assert_awaited_once()
|
svc.approve.assert_awaited_once()
|
||||||
|
# F057: the status commit gates the index — commit then index, never index
|
||||||
|
# before commit (the index write auto-commits on its own connection).
|
||||||
|
actions.task.session.commit.assert_awaited_once()
|
||||||
|
svc.index_approved.assert_awaited_once_with(approved)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -125,9 +129,14 @@ async def test_reject_playbook_archives_for_auditor(
|
|||||||
archived.status = "archived"
|
archived.status = "archived"
|
||||||
svc = MagicMock()
|
svc = MagicMock()
|
||||||
svc.reject = AsyncMock(return_value=archived)
|
svc.reject = AsyncMock(return_value=archived)
|
||||||
|
svc.unindex_playbook = AsyncMock()
|
||||||
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
|
monkeypatch.setattr("roboco.services.playbook.get_playbook_service", lambda _s: svc)
|
||||||
env = await _actions("auditor").reject_playbook(
|
actions = _actions("auditor")
|
||||||
|
env = await actions.reject_playbook(
|
||||||
agent_id=uuid4(), playbook_id=uuid4(), reason="duplicate"
|
agent_id=uuid4(), playbook_id=uuid4(), reason="duplicate"
|
||||||
)
|
)
|
||||||
assert env.status == "playbook_archived"
|
assert env.status == "playbook_archived"
|
||||||
svc.reject.assert_awaited_once()
|
svc.reject.assert_awaited_once()
|
||||||
|
# F057: de-index is the post-commit step (commit gates it).
|
||||||
|
actions.task.session.commit.assert_awaited_once()
|
||||||
|
svc.unindex_playbook.assert_awaited_once_with(archived)
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""F057: the PLAYBOOKS RAG index write must not commit independently of — and
|
||||||
|
BEFORE — the playbook status transaction.
|
||||||
|
|
||||||
|
``approve()`` / ``reject()`` used to call ``_index_approved`` / ``_unindex_playbook``
|
||||||
|
inline, AFTER ``flush()`` but BEFORE the caller's ``commit()``. The vector store
|
||||||
|
writes chunks via its OWN pool connection (vector_store.py:211-237), which
|
||||||
|
auto-commits immediately and independently of the SQLAlchemy session
|
||||||
|
transaction. So a status-commit failure (or a crash between the index write and
|
||||||
|
the commit) left the RAG corpus with an approved/archived playbook whose DB row
|
||||||
|
was still DRAFT/APPROVED — a divergence agents then surfaced in briefings.
|
||||||
|
|
||||||
|
The fix: ``approve()`` / ``reject()`` flush the status ONLY; the index/unindex
|
||||||
|
is a separate post-commit step (``index_approved`` / ``unindex_playbook``) the
|
||||||
|
caller runs AFTER the status transaction commits. Both entry points — the panel
|
||||||
|
route (playbooks.py) and the Auditor gateway verb (content_actions.
|
||||||
|
_curate_playbook) — commit-then-index, and skip the index if the commit fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.api.routes.playbooks import approve_playbook, reject_playbook
|
||||||
|
from roboco.config import settings
|
||||||
|
from roboco.models import AgentRole
|
||||||
|
from roboco.models.permissions import AgentContext
|
||||||
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
||||||
|
from roboco.services.playbook import PlaybookService
|
||||||
|
|
||||||
|
_PID = uuid4()
|
||||||
|
_APPROVER = uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
def _playbook_mock() -> Any:
|
||||||
|
pb = MagicMock(name="playbook")
|
||||||
|
pb.id = _PID
|
||||||
|
pb.status = "approved"
|
||||||
|
pb.title = "T"
|
||||||
|
pb.problem = "P"
|
||||||
|
pb.procedure = "Pr"
|
||||||
|
pb.tags = []
|
||||||
|
pb.team = "backend"
|
||||||
|
pb.scope = "team"
|
||||||
|
return pb
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service-level: approve/reject flush ONLY; index/unindex are separate steps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_does_not_index_before_commit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""``approve()`` flushes the status change but must NOT write to the RAG
|
||||||
|
index — that writes through its own auto-committing connection, so it would
|
||||||
|
durably land before the caller commits the status (the F057 divergence)."""
|
||||||
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||||||
|
session = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
svc = PlaybookService(session)
|
||||||
|
svc._get_or_raise = AsyncMock(return_value=_playbook_mock()) # type: ignore[assignment]
|
||||||
|
|
||||||
|
optimal = MagicMock()
|
||||||
|
optimal.index_playbook = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.optimal.get_optimal_service",
|
||||||
|
AsyncMock(return_value=optimal),
|
||||||
|
)
|
||||||
|
|
||||||
|
await svc.approve(_PID, approver_id=_APPROVER)
|
||||||
|
|
||||||
|
optimal.index_playbook.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_approved_is_a_separate_post_commit_step(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""``index_approved`` is the public post-commit index step the caller runs
|
||||||
|
AFTER committing the status — it invokes the optimal index."""
|
||||||
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||||||
|
svc = PlaybookService(AsyncMock())
|
||||||
|
optimal = MagicMock()
|
||||||
|
optimal.index_playbook = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.optimal.get_optimal_service",
|
||||||
|
AsyncMock(return_value=optimal),
|
||||||
|
)
|
||||||
|
|
||||||
|
await svc.index_approved(_playbook_mock())
|
||||||
|
|
||||||
|
optimal.index_playbook.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_does_not_unindex_before_commit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""``reject()`` flushes the status change but must NOT de-index from the RAG
|
||||||
|
index inline — same ordering gap as approve (the de-index auto-commits before
|
||||||
|
the status commit, dropping an approved playbook from briefings while its row
|
||||||
|
is still APPROVED on rollback)."""
|
||||||
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||||||
|
session = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
svc = PlaybookService(session)
|
||||||
|
svc._get_or_raise = AsyncMock(return_value=_playbook_mock()) # type: ignore[assignment]
|
||||||
|
|
||||||
|
optimal = MagicMock()
|
||||||
|
optimal.unindex_playbook = AsyncMock()
|
||||||
|
optimal.deindex_playbook = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.optimal.get_optimal_service",
|
||||||
|
AsyncMock(return_value=optimal),
|
||||||
|
)
|
||||||
|
|
||||||
|
await svc.reject(_PID, approver_id=_APPROVER, reason="nope")
|
||||||
|
|
||||||
|
# No de-index call of any name originated from reject() before commit.
|
||||||
|
optimal.unindex_playbook.assert_not_awaited()
|
||||||
|
optimal.deindex_playbook.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unindex_playbook_is_a_separate_post_commit_step(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""``unindex_playbook`` is the public post-commit de-index step."""
|
||||||
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||||||
|
svc = PlaybookService(AsyncMock())
|
||||||
|
optimal = MagicMock()
|
||||||
|
optimal.unindex_playbook = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.optimal.get_optimal_service",
|
||||||
|
AsyncMock(return_value=optimal),
|
||||||
|
)
|
||||||
|
|
||||||
|
await svc.unindex_playbook(_playbook_mock())
|
||||||
|
|
||||||
|
optimal.unindex_playbook.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route-level (panel): commit-then-index, skip index on commit failure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _route_service_mock(
|
||||||
|
order: list[str], *, commit_fails: bool = False
|
||||||
|
) -> tuple[Any, Any]:
|
||||||
|
def _approve(*_a: Any, **_k: Any) -> Any:
|
||||||
|
order.append("approve")
|
||||||
|
return _playbook_mock()
|
||||||
|
|
||||||
|
def _reject(*_a: Any, **_k: Any) -> Any:
|
||||||
|
order.append("reject")
|
||||||
|
return _playbook_mock()
|
||||||
|
|
||||||
|
svc = MagicMock()
|
||||||
|
svc.approve = AsyncMock(side_effect=_approve)
|
||||||
|
svc.reject = AsyncMock(side_effect=_reject)
|
||||||
|
svc.index_approved = AsyncMock(side_effect=lambda *_a, **_k: order.append("index"))
|
||||||
|
svc.unindex_playbook = AsyncMock(
|
||||||
|
side_effect=lambda *_a, **_k: order.append("unindex")
|
||||||
|
)
|
||||||
|
db = AsyncMock()
|
||||||
|
if commit_fails:
|
||||||
|
db.commit = AsyncMock(side_effect=RuntimeError("commit failed"))
|
||||||
|
else:
|
||||||
|
db.commit = AsyncMock(side_effect=lambda: order.append("commit"))
|
||||||
|
return svc, db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_route_commits_before_indexing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
svc, db = _route_service_mock(order)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.api.routes.playbooks.get_playbook_service", lambda _db: svc
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.api.routes.playbooks.Playbook.model_validate", lambda obj: obj
|
||||||
|
)
|
||||||
|
agent = AgentContext(agent_id=_APPROVER, role=AgentRole.AUDITOR)
|
||||||
|
|
||||||
|
await approve_playbook(_PID, db, agent)
|
||||||
|
|
||||||
|
assert order == ["approve", "commit", "index"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_route_skips_index_when_commit_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
svc, db = _route_service_mock(order, commit_fails=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.api.routes.playbooks.get_playbook_service", lambda _db: svc
|
||||||
|
)
|
||||||
|
agent = AgentContext(agent_id=_APPROVER, role=AgentRole.AUDITOR)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await approve_playbook(_PID, db, agent)
|
||||||
|
|
||||||
|
svc.index_approved.assert_not_awaited()
|
||||||
|
assert order == ["approve"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_route_commits_before_unindexing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
svc, db = _route_service_mock(order)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.api.routes.playbooks.get_playbook_service", lambda _db: svc
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.api.routes.playbooks.Playbook.model_validate", lambda obj: obj
|
||||||
|
)
|
||||||
|
agent = AgentContext(agent_id=_APPROVER, role=AgentRole.AUDITOR)
|
||||||
|
|
||||||
|
await reject_playbook(_PID, MagicMock(reason="nope"), db, agent)
|
||||||
|
|
||||||
|
assert order == ["reject", "commit", "unindex"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gateway-level (Auditor verb): commit-then-index, skip index on commit failure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_actions(
|
||||||
|
order: list[str], *, commit_fails: bool = False
|
||||||
|
) -> tuple[ContentActions, MagicMock]:
|
||||||
|
task = MagicMock()
|
||||||
|
task.session = AsyncMock()
|
||||||
|
if commit_fails:
|
||||||
|
task.session.commit = AsyncMock(side_effect=RuntimeError("commit failed"))
|
||||||
|
else:
|
||||||
|
task.session.commit = AsyncMock(side_effect=lambda: order.append("commit"))
|
||||||
|
task.agent_for = AsyncMock(return_value=MagicMock(role="auditor"))
|
||||||
|
deps = ContentActionsDeps(
|
||||||
|
task=task,
|
||||||
|
git=MagicMock(),
|
||||||
|
messaging=MagicMock(),
|
||||||
|
a2a=MagicMock(),
|
||||||
|
journal=MagicMock(),
|
||||||
|
workspace=MagicMock(),
|
||||||
|
notifications=MagicMock(),
|
||||||
|
)
|
||||||
|
return ContentActions(deps), task
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_svc_mock(order: list[str]) -> MagicMock:
|
||||||
|
def _approve(*_a: Any, **_k: Any) -> Any:
|
||||||
|
order.append("approve")
|
||||||
|
return _playbook_mock()
|
||||||
|
|
||||||
|
def _reject(*_a: Any, **_k: Any) -> Any:
|
||||||
|
order.append("reject")
|
||||||
|
return _playbook_mock()
|
||||||
|
|
||||||
|
svc = MagicMock()
|
||||||
|
svc.approve = AsyncMock(side_effect=_approve)
|
||||||
|
svc.reject = AsyncMock(side_effect=_reject)
|
||||||
|
svc.index_approved = AsyncMock(side_effect=lambda *_a, **_k: order.append("index"))
|
||||||
|
svc.unindex_playbook = AsyncMock(
|
||||||
|
side_effect=lambda *_a, **_k: order.append("unindex")
|
||||||
|
)
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_approve_commits_before_indexing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
actions, _task = _gateway_actions(order)
|
||||||
|
svc = _gateway_svc_mock(order)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.playbook.get_playbook_service",
|
||||||
|
lambda _session: svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
env = await actions.approve_playbook(agent_id=_APPROVER, playbook_id=_PID)
|
||||||
|
|
||||||
|
assert order == ["approve", "commit", "index"]
|
||||||
|
assert env.status == "playbook_approved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_approve_skips_index_when_commit_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
actions, _task = _gateway_actions(order, commit_fails=True)
|
||||||
|
svc = _gateway_svc_mock(order)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.playbook.get_playbook_service",
|
||||||
|
lambda _session: svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await actions.approve_playbook(agent_id=_APPROVER, playbook_id=_PID)
|
||||||
|
|
||||||
|
svc.index_approved.assert_not_awaited()
|
||||||
|
assert order == ["approve"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_reject_commits_before_unindexing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
order: list[str] = []
|
||||||
|
actions, _task = _gateway_actions(order)
|
||||||
|
svc = _gateway_svc_mock(order)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"roboco.services.playbook.get_playbook_service",
|
||||||
|
lambda _session: svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
env = await actions.reject_playbook(
|
||||||
|
agent_id=_APPROVER, playbook_id=_PID, reason="nope"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order == ["reject", "commit", "unindex"]
|
||||||
|
assert env.status == "playbook_archived"
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
"""F011 — PlaybookService.reject must de-index the playbook.
|
"""PlaybookService.reject must flush the status change WITHOUT de-indexing
|
||||||
|
inline, and ``unindex_playbook`` is the separate post-commit de-index step.
|
||||||
|
|
||||||
A rejected/archived playbook that was previously approved stays in the
|
A rejected/archived playbook that was previously approved must stop surfacing in
|
||||||
PLAYBOOKS RAG index (no de-index on reject/archive), so it keeps surfacing
|
agent briefings, so a reject drops its chunks + tracking row. Originally (F011)
|
||||||
in agent briefings as a stale, no-longer-canonical procedure. The fix
|
``reject`` called an ``_unindex_playbook`` helper inline. F057 split that out:
|
||||||
mirrors the index-on-approve path: ``reject`` calls an
|
the RAG index write runs through its own auto-committing pool connection, so
|
||||||
``_unindex_playbook`` helper (gated on ``org_memory_enabled``, best-effort)
|
de-indexing inline (before the caller commits the status) would drop a playbook
|
||||||
that removes the playbook's chunks + tracking row.
|
from the corpus even if the status transaction rolled back — a divergence. So
|
||||||
|
``reject`` now flushes the status ONLY; ``unindex_playbook`` is a separate
|
||||||
|
public step the caller runs AFTER committing. Both helpers stay gated on
|
||||||
|
``org_memory_enabled`` (inert when the loop is off) and best-effort.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -32,26 +36,28 @@ def _mock_playbook(playbook_id, *, status=PlaybookStatus.APPROVED.value):
|
|||||||
return pb
|
return pb
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def _session_with(pb) -> MagicMock:
|
||||||
async def test_reject_deindexes_approved_playbook(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""Rejecting an approved (indexed) playbook removes it from the index."""
|
|
||||||
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
|
||||||
playbook_id = uuid4()
|
|
||||||
pb = _mock_playbook(playbook_id, status=PlaybookStatus.APPROVED.value)
|
|
||||||
|
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
# _get_or_raise returns the playbook; flush is a no-op.
|
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.scalar_one_or_none.return_value = pb
|
result.scalar_one_or_none.return_value = pb
|
||||||
session.execute = AsyncMock(return_value=result)
|
session.execute = AsyncMock(return_value=result)
|
||||||
session.flush = AsyncMock()
|
session.flush = AsyncMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_archives_but_does_not_deindex_inline(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""``reject`` flushes the ARCHIVED status but must NOT touch the RAG index —
|
||||||
|
the de-index is a separate post-commit step (F057 ordering)."""
|
||||||
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||||||
|
playbook_id = uuid4()
|
||||||
|
pb = _mock_playbook(playbook_id, status=PlaybookStatus.APPROVED.value)
|
||||||
|
svc = PlaybookService(_session_with(pb))
|
||||||
|
|
||||||
optimal = MagicMock()
|
optimal = MagicMock()
|
||||||
optimal.unindex_playbook = AsyncMock(return_value=None)
|
optimal.unindex_playbook = AsyncMock(return_value=None)
|
||||||
|
|
||||||
svc = PlaybookService(session)
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"roboco.services.optimal.get_optimal_service",
|
"roboco.services.optimal.get_optimal_service",
|
||||||
@@ -61,68 +67,52 @@ async def test_reject_deindexes_approved_playbook(
|
|||||||
out = await svc.reject(playbook_id, approver_id=uuid4(), reason="stale")
|
out = await svc.reject(playbook_id, approver_id=uuid4(), reason="stale")
|
||||||
|
|
||||||
assert out.status == PlaybookStatus.ARCHIVED.value
|
assert out.status == PlaybookStatus.ARCHIVED.value
|
||||||
optimal.unindex_playbook.assert_awaited_once_with(str(playbook_id))
|
optimal.unindex_playbook.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reject_of_unindexed_draft_does_not_error(
|
async def test_unindex_playbook_deindexes_approved_playbook(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A draft (never indexed) reject still de-indexes (idempotent no-op) and
|
"""``unindex_playbook`` (the post-commit step) removes an approved playbook
|
||||||
never errors the curation."""
|
from the index."""
|
||||||
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||||||
playbook_id = uuid4()
|
playbook_id = uuid4()
|
||||||
pb = _mock_playbook(playbook_id, status=PlaybookStatus.DRAFT.value)
|
pb = _mock_playbook(playbook_id, status=PlaybookStatus.APPROVED.value)
|
||||||
|
svc = PlaybookService(_session_with(pb))
|
||||||
session = MagicMock()
|
|
||||||
result = MagicMock()
|
|
||||||
result.scalar_one_or_none.return_value = pb
|
|
||||||
session.execute = AsyncMock(return_value=result)
|
|
||||||
session.flush = AsyncMock()
|
|
||||||
|
|
||||||
optimal = MagicMock()
|
optimal = MagicMock()
|
||||||
optimal.unindex_playbook = AsyncMock(return_value=None)
|
optimal.unindex_playbook = AsyncMock(return_value=None)
|
||||||
|
|
||||||
svc = PlaybookService(session)
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"roboco.services.optimal.get_optimal_service",
|
"roboco.services.optimal.get_optimal_service",
|
||||||
AsyncMock(return_value=optimal),
|
AsyncMock(return_value=optimal),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
out = await svc.reject(playbook_id, approver_id=uuid4(), reason="nope")
|
await svc.unindex_playbook(pb)
|
||||||
|
|
||||||
assert out.status == PlaybookStatus.ARCHIVED.value
|
|
||||||
# De-index is still called (idempotent — the store no-ops on an absent
|
|
||||||
# source), so a draft reject mirrors the approved reject path uniformly.
|
|
||||||
optimal.unindex_playbook.assert_awaited_once_with(str(playbook_id))
|
optimal.unindex_playbook.assert_awaited_once_with(str(playbook_id))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reject_skips_deindex_when_org_memory_disabled(
|
async def test_unindex_playbook_skips_when_org_memory_disabled(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""org_memory_enabled=False ⇒ the index is inert; reject must not touch it."""
|
"""org_memory_enabled=False ⇒ the index is inert; ``unindex_playbook`` must
|
||||||
|
not touch it."""
|
||||||
monkeypatch.setattr(settings, "org_memory_enabled", False)
|
monkeypatch.setattr(settings, "org_memory_enabled", False)
|
||||||
playbook_id = uuid4()
|
playbook_id = uuid4()
|
||||||
pb = _mock_playbook(playbook_id)
|
pb = _mock_playbook(playbook_id)
|
||||||
|
svc = PlaybookService(_session_with(pb))
|
||||||
session = MagicMock()
|
|
||||||
result = MagicMock()
|
|
||||||
result.scalar_one_or_none.return_value = pb
|
|
||||||
session.execute = AsyncMock(return_value=result)
|
|
||||||
session.flush = AsyncMock()
|
|
||||||
|
|
||||||
optimal = MagicMock()
|
optimal = MagicMock()
|
||||||
optimal.unindex_playbook = AsyncMock(return_value=None)
|
optimal.unindex_playbook = AsyncMock(return_value=None)
|
||||||
|
|
||||||
svc = PlaybookService(session)
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"roboco.services.optimal.get_optimal_service",
|
"roboco.services.optimal.get_optimal_service",
|
||||||
AsyncMock(return_value=optimal),
|
AsyncMock(return_value=optimal),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await svc.reject(playbook_id, approver_id=uuid4(), reason="nope")
|
await svc.unindex_playbook(pb)
|
||||||
|
|
||||||
optimal.unindex_playbook.assert_not_awaited()
|
optimal.unindex_playbook.assert_not_awaited()
|
||||||
|
|||||||
Reference in New Issue
Block a user