aps-agent/server/gateway/plan_api.py

225 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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