mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""The PLAYBOOKS RAG index write must not commit independently of — and BEFORE —
|
|||
|
|
the playbook status transaction.
|
||
|
|
|
||
|
|
``approve()`` / ``reject()`` flush the status only; the index/unindex is a
|
||
|
|
separate post-commit step the caller runs after the status commits (skipped if
|
||
|
|
the commit fails), so the RAG corpus never diverges from the DB row.
|
||
|
|
"""
|
||
|
|
|
||
|
|
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
|
||
|
|
# approve/reject act on a DRAFT (their legitimate starting state — F109
|
||
|
|
# guards both to DRAFT-only). The index/unindex ordering assertions below
|
||
|
|
# are independent of the starting status.
|
||
|
|
pb.status = "draft"
|
||
|
|
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 divergence)."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||
|
|
session = AsyncMock()
|
||
|
|
session.flush = AsyncMock()
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
monkeypatch.setattr(svc, "_get_or_raise", AsyncMock(return_value=_playbook_mock()))
|
||
|
|
|
||
|
|
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)
|
||
|
|
monkeypatch.setattr(svc, "_get_or_raise", AsyncMock(return_value=_playbook_mock()))
|
||
|
|
|
||
|
|
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(),
|
||
|
|
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"
|