mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(board): materialize program items as Main-PM roots, make reports actionable (#711)
Two coupled gaps in the Board Program output path. Approved items were created unowned and in BACKLOG. Nothing dispatches BACKLOG, and once activated a cell PM claimed the parentless task as a root, where _cell_pm_complete resolves its merge target through resolve_parent_branch — which for a parentless task falls through to the project head rung. The result was a cell branch merging straight into the trunk, bypassing the Main-PM root, the root->master PR and the CEO gate (live: PRs #703 and #704 both targeted slave directly). All eight materializers now create a PENDING, main-pm-assigned root with team=Team.MAIN_PM, matching what approve_and_start does for an intake draft. The team is load-bearing, not cosmetic: _next_hint_pr_fail, _deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer label all key on it, and a cell-teamed root drops the 'do NOT re-submit the root' steer that exists because of PR #138's infinite pr_fail loop. The item's own cell survives as a delegation hint in the description, which is what the Main PM's briefing renders. Periscope, Sentinel and Coroner produced artifacts with no way to act on them — three panel surfaces carried explicit 'no approve/reject UI' comments while each item already held a machine-readable suggested action. They now have per-item approve and dismiss, modelled on the roadmap queue: idempotent per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check still fires, and every decision recorded through record_decision so it reaches the next cycle's prompt. Approving materializes through the same corrected Main-PM-owned path. Target project resolves to each engine's own existing anchor — RoboCo's project for Periscope and Sentinel, the incident's project for Coroner — and fails with a clean invalid_state naming what is unresolvable rather than guessing at a repo. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
"""Coroner (Board Program) engine API — read-only Postmortems list.
|
||||
"""Coroner (Board Program) engine API — the CEO reads filed postmortems and
|
||||
approves/dismisses each one's process change.
|
||||
|
||||
Unlike Pest Control/Roadmap there is nothing here for the CEO to approve or
|
||||
reject: a postmortem completes atomically the moment the Auditor calls
|
||||
``propose_postmortem`` (spec §4). This route just lists what Coroner has
|
||||
already found. CEO-only, mirroring every other Board Program surface.
|
||||
A postmortem completes atomically at ``propose_postmortem`` time — the
|
||||
EXPLORATION TASK has no per-item decision to wait on — but its single
|
||||
process change still carries its own proposed/approved/rejected status the
|
||||
CEO decides on afterward (unless kind="playbook", already routed into the
|
||||
playbook queue). Unlike Periscope/Sentinel there is no item id: a postmortem
|
||||
is one process change, not a list, so the action routes key on the task id
|
||||
alone. CEO-only, mirroring every other Board Program surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.coroner import PostmortemResponse
|
||||
from roboco.api.schemas.coroner import (
|
||||
PostmortemResponse,
|
||||
ProcessChangeActionResponse,
|
||||
ProcessChangeRejectRequest,
|
||||
)
|
||||
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.task import get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -24,7 +35,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="view the Coroner postmortems list")
|
||||
require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
|
||||
|
||||
|
||||
def _to_response(task: TaskTable) -> PostmortemResponse:
|
||||
@@ -44,6 +55,9 @@ 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"),
|
||||
process_change_reject_reason=process_change.get("reject_reason"),
|
||||
process_change_materialized_task_id=process_change.get("materialized_task_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -55,3 +69,63 @@ async def list_postmortems(
|
||||
_require_ceo(agent)
|
||||
tasks = await get_task_service(db).list_completed_coroner_postmortems()
|
||||
return [_to_response(t) for t in tasks]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postmortems/{task_id}/process-change/approve",
|
||||
response_model=ProcessChangeActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
async def approve_process_change(
|
||||
task_id: UUID,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> ProcessChangeActionResponse:
|
||||
"""Materialize the postmortem's process change as a Main-PM-owned root
|
||||
task (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_coroner_service(db).approve_process_change(
|
||||
task_id, created_by=agent.agent_id
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such Coroner postmortem",
|
||||
)
|
||||
await db.commit()
|
||||
return ProcessChangeActionResponse(
|
||||
status=result.status,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postmortems/{task_id}/process-change/reject",
|
||||
response_model=ProcessChangeActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
||||
async def reject_process_change(
|
||||
task_id: UUID,
|
||||
data: ProcessChangeRejectRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> ProcessChangeActionResponse:
|
||||
"""Dismiss the postmortem's process change with a reason (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_coroner_service(db).reject_process_change(task_id, data.reason)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such Coroner postmortem",
|
||||
)
|
||||
await db.commit()
|
||||
return ProcessChangeActionResponse(
|
||||
status=result.status,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user