226 lines
6.7 KiB
Python
226 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)
|
||
|