225 lines
6.7 KiB
Python
225 lines
6.7 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 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)
|