fix(coroner): playbook-kind postmortems stop rendering dead buttons

A kind=playbook process change drafts into the playbook queue at propose
time and both approve/reject refuse it - but legacy rows carry no marker
status, so the response defaulted to proposed and the panel rendered
approve/dismiss buttons that bounce forever. The list route now derives
not_applicable for playbook-kind changes regardless of stored status,
which the panel already renders as its Drafted-as-playbook badge.
This commit is contained in:
Renn F
2026-07-29 05:50:20 +02:00
parent 87332346ce
commit 1c3c313c1e
3 changed files with 36 additions and 5 deletions
+11 -2
View File
@@ -25,7 +25,7 @@ from roboco.api.schemas.coroner import (
) )
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco 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 from roboco.services.task import get_task_service
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -55,7 +55,16 @@ def _to_response(task: TaskTable) -> PostmortemResponse:
process_change_kind=process_change.get("kind"), process_change_kind=process_change.get("kind"),
process_change_description=process_change.get("description"), process_change_description=process_change.get("description"),
playbook_id=postmortem.get("playbook_id"), 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_reject_reason=process_change.get("reject_reason"),
process_change_materialized_task_id=process_change.get("materialized_task_id"), process_change_materialized_task_id=process_change.get("materialized_task_id"),
) )
+3 -3
View File
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
from roboco.db.tables import TaskTable from roboco.db.tables import TaskTable
_PLAYBOOK_KIND = "playbook" PLAYBOOK_KIND = "playbook"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -90,7 +90,7 @@ class CoronerService(BaseService):
task, payload, process_change = await self._find(task_id) task, payload, process_change = await self._find(task_id)
if task is None or payload is None or process_change is None: if task is None or payload is None or process_change is None:
return None return None
if process_change["kind"] == _PLAYBOOK_KIND: if process_change["kind"] == PLAYBOOK_KIND:
return self._playbook_result() return self._playbook_result()
if process_change["status"] == "approved": if process_change["status"] == "approved":
return ProcessChangeResult( return ProcessChangeResult(
@@ -139,7 +139,7 @@ class CoronerService(BaseService):
task, payload, process_change = await self._find(task_id) task, payload, process_change = await self._find(task_id)
if task is None or payload is None or process_change is None: if task is None or payload is None or process_change is None:
return None return None
if process_change["kind"] == _PLAYBOOK_KIND: if process_change["kind"] == PLAYBOOK_KIND:
return self._playbook_result() return self._playbook_result()
if process_change["status"] == "rejected": if process_change["status"] == "rejected":
return ProcessChangeResult( return ProcessChangeResult(
+22
View File
@@ -200,6 +200,28 @@ async def test_list_postmortems_returns_completed_autopsy(
assert row["failed_stage"] == "awaiting_qa" 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 @pytest.mark.asyncio
async def test_list_postmortems_forbidden_for_non_ceo( async def test_list_postmortems_forbidden_for_non_ceo(
dev_client: AsyncClient, dev_client: AsyncClient,