# ============================================================ # Plan 治理 API(moduleId: gateway-plan-api, 可重生 ✅) # P1:仅追加治理草稿/版本,不写 APS 世界状态。 # ============================================================ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from typing import Literal from pydantic import BaseModel, ConfigDict, Field, JsonValue from server.agent_core.plan_runtime import ( PlanConflictError, PlanLayer, PlanTransitionError, PlanNode, PlanNotFoundError, PlanParentError, PlanStatus, PlanStore, PlanStoreError, get_plan_store, ) router = APIRouter(prefix="/api/plans", tags=["plan-governance"]) class PlanCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") planId: str | None = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") layer: PlanLayer parentId: str | None = None inputs: JsonValue payload: JsonValue status: PlanStatus = "DRAFT" evidenceRefs: list[str] = Field(default_factory=list) class PlanRegenerateRequest(BaseModel): model_config = ConfigDict(extra="forbid") inputs: JsonValue expectedInputsHash: str = Field(pattern=r"^[0-9a-f]{64}$") payload: JsonValue status: PlanStatus = "DRAFT" evidenceRefs: list[str] | None = None class PlanNodeAtEnvelope(BaseModel): planId: str version: int plan: PlanNode replayable: bool class PlanTransitionRequest(BaseModel): model_config = ConfigDict(extra="forbid") action: Literal["approve", "reject"] = "approve" note: str = "" class PlanReplayRequest(BaseModel): model_config = ConfigDict(extra="forbid") inputs: JsonValue class PlanEnvelope(BaseModel): plan: PlanNode class PlanVersionsEnvelope(BaseModel): planId: str versions: list[PlanNode] def resolve_plan_store() -> PlanStore: return get_plan_store() def _raise_plan_error(exc: PlanStoreError) -> None: if isinstance(exc, PlanNotFoundError): status_code = status.HTTP_404_NOT_FOUND elif isinstance(exc, PlanConflictError): status_code = status.HTTP_409_CONFLICT elif isinstance(exc, PlanParentError): status_code = status.HTTP_422_UNPROCESSABLE_ENTITY elif isinstance(exc, PlanTransitionError): status_code = status.HTTP_409_CONFLICT else: status_code = status.HTTP_500_INTERNAL_SERVER_ERROR raise HTTPException( status_code=status_code, detail={"code": exc.code, "message": str(exc)}, ) from exc @router.post("", response_model=PlanEnvelope, status_code=status.HTTP_201_CREATED) def create_plan( request: PlanCreateRequest, store: PlanStore = Depends(resolve_plan_store), ) -> PlanEnvelope: try: node = store.create( plan_id=request.planId, layer=request.layer, parent_id=request.parentId, inputs=request.inputs, payload=request.payload, status=request.status, evidence_refs=request.evidenceRefs, created_by="USER", ) except PlanStoreError as exc: _raise_plan_error(exc) return PlanEnvelope(plan=node) @router.get("/{plan_id}", response_model=PlanEnvelope) def get_plan(plan_id: str, store: PlanStore = Depends(resolve_plan_store)) -> PlanEnvelope: try: node = store.latest(plan_id) except PlanStoreError as exc: _raise_plan_error(exc) return PlanEnvelope(plan=node) @router.get("/{plan_id}/versions", response_model=PlanVersionsEnvelope) def get_plan_versions( plan_id: str, store: PlanStore = Depends(resolve_plan_store), ) -> PlanVersionsEnvelope: try: versions = store.versions(plan_id) except PlanStoreError as exc: _raise_plan_error(exc) return PlanVersionsEnvelope(planId=plan_id, versions=versions) @router.post( "/{plan_id}/regenerate", response_model=PlanEnvelope, status_code=status.HTTP_201_CREATED, ) def regenerate_plan( plan_id: str, request: PlanRegenerateRequest, store: PlanStore = Depends(resolve_plan_store), ) -> PlanEnvelope: try: node = store.regenerate( plan_id, inputs=request.inputs, expected_inputs_hash=request.expectedInputsHash, payload=request.payload, status=request.status, evidence_refs=request.evidenceRefs, created_by="USER", ) except PlanStoreError as exc: _raise_plan_error(exc) return PlanEnvelope(plan=node) @router.get("/{plan_id}/versions/{version}", response_model=PlanNodeAtEnvelope) def get_plan_at_version( plan_id: str, version: int, store: PlanStore = Depends(resolve_plan_store), ) -> PlanNodeAtEnvelope: """时间线回放:取指定版本的不可变节点(矩阵 110 行)。""" try: node = store.node_at(plan_id, version) except PlanStoreError as exc: _raise_plan_error(exc) return PlanNodeAtEnvelope(planId=plan_id, version=version, plan=node, replayable=True) @router.post("/{plan_id}/versions/{version}/replay-verify", response_model=PlanNodeAtEnvelope) def replay_verify_plan( plan_id: str, version: int, request: PlanReplayRequest, store: PlanStore = Depends(resolve_plan_store), ) -> PlanNodeAtEnvelope: """L2/L3 动作级重放校验:给定输入与目标版本 inputsHash 一致才可复算重放。""" try: node = store.replay_verify(plan_id, version, request.inputs) except PlanStoreError as exc: _raise_plan_error(exc) return PlanNodeAtEnvelope(planId=plan_id, version=version, plan=node, replayable=True) @router.post("/{plan_id}/approve", response_model=PlanEnvelope) def approve_plan( plan_id: str, request: PlanTransitionRequest, store: PlanStore = Depends(resolve_plan_store), ) -> PlanEnvelope: """批准 Plan(矩阵 110 行:批准审计,追加 APPROVED 状态版本)。""" try: node = store.transition_status(plan_id, new_status="APPROVED", created_by="USER", note=request.note) except PlanStoreError as exc: _raise_plan_error(exc) return PlanEnvelope(plan=node) @router.post("/{plan_id}/reject", response_model=PlanEnvelope) def reject_plan( plan_id: str, request: PlanTransitionRequest, store: PlanStore = Depends(resolve_plan_store), ) -> PlanEnvelope: """驳回 Plan(矩阵 110 行:驳回审计,追加 FAILED 状态版本)。""" try: node = store.transition_status(plan_id, new_status="FAILED", created_by="USER", note=request.note) except PlanStoreError as exc: _raise_plan_error(exc) return PlanEnvelope(plan=node)