aps-agent/server/gateway/plan_api.py

226 lines
6.7 KiB
Python
Raw Normal View History

# ============================================================
# 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)