diff --git a/roboco/api/routes/coroner.py b/roboco/api/routes/coroner.py index 9aa1d2b2..ff628943 100644 --- a/roboco/api/routes/coroner.py +++ b/roboco/api/routes/coroner.py @@ -25,7 +25,7 @@ from roboco.api.schemas.coroner import ( ) from roboco.foundation.policy.content import markers from roboco.security import guard_deco -from roboco.services.coroner_service import get_coroner_service +from roboco.services.coroner_service import PLAYBOOK_KIND, get_coroner_service from roboco.services.task import get_task_service if TYPE_CHECKING: @@ -55,7 +55,16 @@ def _to_response(task: TaskTable) -> PostmortemResponse: process_change_kind=process_change.get("kind"), process_change_description=process_change.get("description"), playbook_id=postmortem.get("playbook_id"), - process_change_status=process_change.get("status", "proposed"), + # A playbook-kind change already drafted into the playbook queue at + # propose time — there is nothing to decide, but the stored status + # stays "proposed", which left the panel rendering approve/dismiss + # buttons that both verbs refuse forever. Derive the terminal status + # the panel's contract expects instead. + process_change_status=( + "not_applicable" + if process_change.get("kind") == PLAYBOOK_KIND + else process_change.get("status", "proposed") + ), process_change_reject_reason=process_change.get("reject_reason"), process_change_materialized_task_id=process_change.get("materialized_task_id"), ) diff --git a/roboco/services/coroner_service.py b/roboco/services/coroner_service.py index 64278bae..5eef9d0e 100644 --- a/roboco/services/coroner_service.py +++ b/roboco/services/coroner_service.py @@ -53,7 +53,7 @@ if TYPE_CHECKING: from roboco.db.tables import TaskTable -_PLAYBOOK_KIND = "playbook" +PLAYBOOK_KIND = "playbook" @dataclass(frozen=True) @@ -90,7 +90,7 @@ class CoronerService(BaseService): task, payload, process_change = await self._find(task_id) if task is None or payload is None or process_change is None: return None - if process_change["kind"] == _PLAYBOOK_KIND: + if process_change["kind"] == PLAYBOOK_KIND: return self._playbook_result() if process_change["status"] == "approved": return ProcessChangeResult( @@ -139,7 +139,7 @@ class CoronerService(BaseService): task, payload, process_change = await self._find(task_id) if task is None or payload is None or process_change is None: return None - if process_change["kind"] == _PLAYBOOK_KIND: + if process_change["kind"] == PLAYBOOK_KIND: return self._playbook_result() if process_change["status"] == "rejected": return ProcessChangeResult( diff --git a/tests/integration/test_coroner_routes.py b/tests/integration/test_coroner_routes.py index cbf0f5c5..04457abe 100644 --- a/tests/integration/test_coroner_routes.py +++ b/tests/integration/test_coroner_routes.py @@ -200,6 +200,28 @@ async def test_list_postmortems_returns_completed_autopsy( assert row["failed_stage"] == "awaiting_qa" +@pytest.mark.asyncio +async def test_list_postmortems_derives_not_applicable_for_playbook_kind( + db_session: AsyncSession, ceo_client: AsyncClient +) -> None: + """A playbook-kind process change reads as not_applicable even when the + stored marker has NO status key — legacy rows predate the propose-time + stamp, and the raw default ("proposed") left the panel rendering + approve/dismiss buttons both verbs refuse forever.""" + task = await _seed_completed_postmortem(db_session) + payload = markers.get_coroner_postmortem(task) + assert payload is not None + payload["process_change"] = { + "kind": "playbook", + "description": "run the named command end-to-end", + } + markers.set_coroner_postmortem(task, payload) + await db_session.flush() + resp = await ceo_client.get("/api/coroner/postmortems") + assert resp.status_code == HTTPStatus.OK + assert resp.json()[0]["process_change_status"] == "not_applicable" + + @pytest.mark.asyncio async def test_list_postmortems_forbidden_for_non_ceo( dev_client: AsyncClient,