PMFlow/backend/app/api/pmflow_os_api/__init__.py

1158 lines
46 KiB
Python

from __future__ import annotations
import json
from datetime import date as Date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.database import get_db
from ...core.response import error_response, success_response
from ...dependencies import get_current_user
from ...models import Project, User, UserProject
from ...schemas.pmflow_os import (
PMFlowAIFeedbackCreateRequest,
PMFlowArtifactReviewActionRequest,
PMFlowArtifactReworkPackageRequest,
PMFlowCapacityOverridePayload,
PMFlowChangeItemDeferRequest,
PMFlowChangeItemEditRequest,
PMFlowChangeSetCommitRequest,
PMFlowExecutionHandoffConsumeRequest,
PMFlowExecutionHandoffRequest,
PMFlowExecutionAcceptanceDecisionRequest,
PMFlowExecutionRuntimeCredentialCreateRequest,
PMFlowExecutionMemoryWritebackRequest,
PMFlowExecutionProgressEventRequest,
PMFlowRuntimeExecutionPackageRequest,
PMFlowRuntimeExecutionResultRequest,
PMFlowExtractionCreateRequest,
PMFlowIterationRecommendationChangeSetDraftRequest,
PMFlowIterationRecommendationGroupChangeSetDraftRequest,
ProjectCopilotAskRequest,
PMFlowReleaseFeedbackIngestRequest,
PMFlowReleaseValidationRunCreateRequest,
PMFlowReviewDecisionRequest,
PMFlowThreadMessageCreateRequest,
)
from ...services.auth import has_any_role
from ...services.adaptive_capacity_service import AdaptiveCapacityService
from ...services.pmflow_ai_feedback_service import PMFlowAIFeedbackService
from ...services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
from ...services.pmflow_os_projection_service import PMFlowOSProjectionService
from ...services.project_copilot_service import ProjectCopilotService
from ...services.team_execution_capability_service import TeamExecutionCapabilityService
router = APIRouter(prefix="/pmflow-os", tags=["pmflow-os"])
def _review_error(exc: PMFlowChangeReviewError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
async def _ensure_project_member_or_manager(
db: AsyncSession,
*,
tenant_id: int,
project_id: int,
current_user: User,
) -> None:
project_result = await db.execute(
select(Project.id).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id)))
)
if project_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="project_not_found")
if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
return
member_result = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == int(tenant_id),
UserProject.project_id == int(project_id),
UserProject.user_id == int(current_user.id),
)
)
)
if member_result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="forbidden")
def _ensure_capacity_override_manager(current_user: User) -> None:
if not has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
raise HTTPException(status_code=403, detail="forbidden")
def _parse_capacity_date(value: str | None) -> Date | None:
if not value:
return None
try:
return Date.fromisoformat(str(value)[:10])
except ValueError as exc:
raise HTTPException(status_code=422, detail="invalid_date") from exc
@router.get("/projects/{project_id}/truth-state")
async def get_pmflow_os_truth_state(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_truth_state(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.get("/projects/{project_id}/project-memory/overview")
async def get_pmflow_os_project_memory_overview(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_project_memory_overview(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.get("/projects/{project_id}/project-memory/items")
async def get_pmflow_os_project_memory_items(
project_id: int,
category: str | None = Query(default=None),
stage: str | None = Query(default=None),
source_type: str | None = Query(default=None),
status: str | None = Query(default=None),
query: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_project_memory_items(
project_id=int(project_id),
category=category,
stage=stage,
source_type=source_type,
status=status,
query=query,
limit=int(limit),
offset=int(offset),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.get("/projects/{project_id}/project-memory/items/{item_id}")
async def get_pmflow_os_project_memory_item(
project_id: int,
item_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_project_memory_item(project_id=int(project_id), item_id=item_id)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
if str(exc) == "project_memory_item_not_found":
raise HTTPException(status_code=404, detail="project_memory_item_not_found") from exc
raise
return success_response(payload)
@router.get("/projects/{project_id}/project-memory/assets")
async def get_pmflow_os_project_memory_assets(
project_id: int,
asset_type: str | None = Query(default=None),
source_type: str | None = Query(default=None),
status: str | None = Query(default=None),
query: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_project_memory_assets(
project_id=int(project_id),
asset_type=asset_type,
source_type=source_type,
status=status,
query=query,
limit=int(limit),
offset=int(offset),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.get("/projects/{project_id}/project-memory/context-packs")
async def get_pmflow_os_project_memory_context_packs(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_project_memory_context_packs(project_id=int(project_id))
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.get("/projects/{project_id}/project-memory/writebacks")
async def get_pmflow_os_project_memory_writebacks(
project_id: int,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_project_memory_writebacks(project_id=int(project_id), limit=int(limit), offset=int(offset))
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.post("/projects/{project_id}/ai-feedback")
async def create_pmflow_os_ai_feedback(
project_id: int,
body: PMFlowAIFeedbackCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowAIFeedbackService(db, tenant_id=tenant_id)
payload = await service.create_or_update(project_id=int(project_id), body=body, user_id=int(current_user.id))
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/project-copilot/ask")
async def ask_pmflow_os_project_copilot(
project_id: int,
body: ProjectCopilotAskRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = ProjectCopilotService(db, tenant_id=tenant_id)
try:
payload = await service.ask(
project_id=int(project_id),
current_user_id=int(current_user.id),
body=body,
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/threads/{thread_id}/messages")
async def create_pmflow_os_thread_message(
project_id: int,
thread_id: str,
body: PMFlowThreadMessageCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
try:
payload = await service.create_thread_message(
project_id=int(project_id),
thread_id=thread_id,
body=body,
user_id=int(current_user.id),
)
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/projects/{project_id}/extractions")
async def create_pmflow_os_extraction(
project_id: int,
body: PMFlowExtractionCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
try:
payload = await service.create_extraction(project_id=int(project_id), body=body, user_id=int(current_user.id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.get("/projects/{project_id}/change-sets/{change_set_id}")
async def get_pmflow_os_change_set(
project_id: int,
change_set_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
try:
payload = await service.get_change_set_detail(project_id=int(project_id), change_set_id=int(change_set_id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-items/{change_item_id}/accept")
async def accept_pmflow_os_change_item(
change_item_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_item_project_id(change_item_id=int(change_item_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.accept_item(change_item_id=int(change_item_id), user_id=int(current_user.id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-items/{change_item_id}/edit")
async def edit_pmflow_os_change_item(
change_item_id: int,
body: PMFlowChangeItemEditRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_item_project_id(change_item_id=int(change_item_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.edit_item(change_item_id=int(change_item_id), body=body, user_id=int(current_user.id))
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-items/{change_item_id}/defer")
async def defer_pmflow_os_change_item(
change_item_id: int,
body: PMFlowChangeItemDeferRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_item_project_id(change_item_id=int(change_item_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.defer_item(
change_item_id=int(change_item_id),
user_id=int(current_user.id),
reason=body.reason if body is not None else None,
)
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-sets/{change_set_id}/commit")
async def commit_pmflow_os_change_set(
change_set_id: int,
body: PMFlowChangeSetCommitRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await service.get_change_set_project_id(change_set_id=int(change_set_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "changeset_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
payload = await service.commit_change_set(
change_set_id=int(change_set_id),
body=body or PMFlowChangeSetCommitRequest(),
user_id=int(current_user.id),
)
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(payload)
@router.post("/change-sets/{change_set_id}/review-decisions")
async def create_pmflow_os_review_decision(
change_set_id: int,
body: PMFlowReviewDecisionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
review_service = PMFlowChangeReviewService(db, tenant_id=tenant_id)
project_id = await review_service.get_change_set_project_id(change_set_id=int(change_set_id))
if project_id is None:
return JSONResponse(status_code=404, content=error_response(404, "changeset_not_found"))
await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user)
try:
review_payload = await review_service.record_review_decision(
change_set_id=int(change_set_id),
body=body,
user_id=int(current_user.id),
)
truth_state = await PMFlowOSProjectionService(db, tenant_id=tenant_id).get_truth_state(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except PMFlowChangeReviewError as exc:
return _review_error(exc)
return success_response(
{
"changeSetDetail": review_payload["changeSetDetail"],
"truthState": truth_state.model_dump(mode="json"),
"warnings": review_payload.get("warnings", []),
}
)
@router.get("/projects/{project_id}/execution-summary")
async def get_pmflow_os_execution_summary(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.get_execution_summary(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.get("/projects/{project_id}/execution-capabilities")
async def get_pmflow_os_execution_capabilities(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = TeamExecutionCapabilityService(db, tenant_id=tenant_id)
try:
payload = await service.get_snapshot(
project_id=int(project_id),
current_user_id=int(current_user.id),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.get("/projects/{project_id}/capacity-plan")
async def get_pmflow_os_capacity_plan(
project_id: int,
plan_date: str | None = Query(default=None, alias="date"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
try:
payload = await service.get_plan(
project_id=int(project_id),
current_user_id=int(current_user.id),
plan_date=_parse_capacity_date(plan_date),
)
except ValueError as exc:
if str(exc) == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/capacity-overrides/simulate")
async def simulate_pmflow_os_capacity_override(
project_id: int,
body: PMFlowCapacityOverridePayload,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
_ensure_capacity_override_manager(current_user)
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
try:
payload = await service.simulate_override(
project_id=int(project_id),
current_user_id=int(current_user.id),
payload=body,
)
except ValueError as exc:
if str(exc) == "capacity_member_not_found":
raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/capacity-overrides")
async def create_pmflow_os_capacity_override(
project_id: int,
body: PMFlowCapacityOverridePayload,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
_ensure_capacity_override_manager(current_user)
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
try:
payload = await service.apply_override(
project_id=int(project_id),
current_user_id=int(current_user.id),
payload=body,
)
except ValueError as exc:
if str(exc) == "capacity_member_not_found":
raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.get("/projects/{project_id}/capacity-overrides")
async def list_pmflow_os_capacity_overrides(
project_id: int,
plan_date: str | None = Query(default=None, alias="date"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = AdaptiveCapacityService(db, tenant_id=tenant_id)
payload = await service.list_overrides(project_id=int(project_id), plan_date=_parse_capacity_date(plan_date))
return success_response([item.model_dump(mode="json") for item in payload])
@router.post("/projects/{project_id}/execution-handoffs")
async def create_pmflow_os_execution_handoff(
project_id: int,
body: PMFlowExecutionHandoffRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_execution_handoff(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-progress-events")
async def create_pmflow_os_execution_progress_event(
project_id: int,
body: PMFlowExecutionProgressEventRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.record_execution_progress_event(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-artifact-review-actions")
async def create_pmflow_os_execution_artifact_review_action(
project_id: int,
body: PMFlowArtifactReviewActionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.record_execution_artifact_review_action(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "invalid_artifact_review_item_ids":
return JSONResponse(status_code=422, content=error_response(422, "invalid_artifact_review_item_ids"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-artifact-rework-packages")
async def create_pmflow_os_execution_artifact_rework_package(
project_id: int,
body: PMFlowArtifactReworkPackageRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_execution_artifact_rework_package(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "invalid_artifact_review_item_ids":
return JSONResponse(status_code=422, content=error_response(422, "invalid_artifact_review_item_ids"))
if code == "artifact_review_empty":
return JSONResponse(status_code=409, content=error_response(409, "artifact_review_empty"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-handoffs/{prompt_package_id}/runtime-credential")
async def create_pmflow_os_execution_runtime_credential(
project_id: int,
prompt_package_id: int,
body: PMFlowExecutionRuntimeCredentialCreateRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_execution_runtime_credential(
project_id=int(project_id),
prompt_package_id=int(prompt_package_id),
body=body or PMFlowExecutionRuntimeCredentialCreateRequest(),
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code in {"prompt_package_not_found", "execution_handoff_not_found", "task_not_found"}:
return JSONResponse(status_code=404, content=error_response(404, code))
if code in {"repo_binding_not_found", "runtime_target_invalid"}:
return JSONResponse(status_code=422, content=error_response(422, code))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/runtime/execution-results")
async def create_pmflow_os_runtime_execution_result(
request: Request,
db: AsyncSession = Depends(get_db),
):
raw_body = await request.body()
key_id = request.headers.get("X-PMFlow-Key-Id") or ""
timestamp = request.headers.get("X-PMFlow-Timestamp") or ""
signature = request.headers.get("X-PMFlow-Signature") or ""
delivery_id = request.headers.get("X-PMFlow-Delivery-Id") or ""
try:
raw_payload = json.loads(raw_body.decode("utf-8"))
body = PMFlowRuntimeExecutionResultRequest.model_validate(raw_payload)
except (json.JSONDecodeError, UnicodeDecodeError, ValidationError):
return JSONResponse(status_code=422, content=error_response(422, "runtime_payload_invalid"))
service = PMFlowOSProjectionService(db, tenant_id=0)
try:
payload = await service.record_runtime_execution_result(
body=body,
key_id=key_id,
timestamp=timestamp,
signature=signature,
delivery_id=delivery_id,
raw_body=raw_body,
)
except ValueError as exc:
code = str(exc)
if code in {"runtime_credential_missing", "task_not_found", "prompt_package_not_found"}:
return JSONResponse(status_code=404, content=error_response(404, code))
if code in {
"runtime_credential_expired",
"runtime_credential_revoked",
"runtime_credential_invalid",
"runtime_signature_invalid",
"runtime_timestamp_invalid",
"runtime_source_not_allowed",
"runtime_handoff_mismatch",
}:
return JSONResponse(status_code=401, content=error_response(401, code))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "project_not_found":
return JSONResponse(status_code=404, content=error_response(404, "project_not_found"))
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/runtime/execution-packages")
async def create_pmflow_os_runtime_execution_package(
request: Request,
db: AsyncSession = Depends(get_db),
):
raw_body = await request.body()
key_id = request.headers.get("X-PMFlow-Key-Id") or ""
timestamp = request.headers.get("X-PMFlow-Timestamp") or ""
signature = request.headers.get("X-PMFlow-Signature") or ""
delivery_id = request.headers.get("X-PMFlow-Delivery-Id") or ""
try:
raw_payload = json.loads(raw_body.decode("utf-8"))
body = PMFlowRuntimeExecutionPackageRequest.model_validate(raw_payload)
except (json.JSONDecodeError, UnicodeDecodeError, ValidationError):
return JSONResponse(status_code=422, content=error_response(422, "runtime_package_payload_invalid"))
service = PMFlowOSProjectionService(db, tenant_id=0)
try:
payload = await service.record_runtime_execution_package_pull(
body=body,
key_id=key_id,
timestamp=timestamp,
signature=signature,
delivery_id=delivery_id,
raw_body=raw_body,
)
except ValueError as exc:
code = str(exc)
if code in {"runtime_credential_missing", "task_not_found", "prompt_package_not_found"}:
return JSONResponse(status_code=404, content=error_response(404, code))
if code in {
"runtime_credential_expired",
"runtime_credential_revoked",
"runtime_credential_invalid",
"runtime_signature_invalid",
"runtime_timestamp_invalid",
"runtime_source_not_allowed",
"runtime_handoff_mismatch",
}:
return JSONResponse(status_code=401, content=error_response(401, code))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "project_not_found":
return JSONResponse(status_code=404, content=error_response(404, "project_not_found"))
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-acceptance-decisions")
async def create_pmflow_os_execution_acceptance_decision(
project_id: int,
body: PMFlowExecutionAcceptanceDecisionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.record_execution_acceptance_decision(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "acceptance_test_evidence_required":
return JSONResponse(status_code=409, content=error_response(409, "acceptance_test_evidence_required"))
if code == "technical_review_required":
return JSONResponse(status_code=409, content=error_response(409, "technical_review_required"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))
@router.post("/projects/{project_id}/execution-memory-writebacks")
async def create_pmflow_os_execution_memory_writeback(
project_id: int,
body: PMFlowExecutionMemoryWritebackRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.record_execution_memory_writeback(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code in {
"execution_acceptance_required",
"execution_evidence_required",
"writeback_already_exists",
"unresolved_blocker_exists",
}:
return JSONResponse(status_code=409, content=error_response(409, code))
if code == "invalid_progress_event_ids":
return JSONResponse(status_code=422, content=error_response(422, "invalid_progress_event_ids"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.post("/projects/{project_id}/release-validation-runs")
async def create_pmflow_os_release_validation_run(
project_id: int,
body: PMFlowReleaseValidationRunCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_release_validation_run(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "release_validation_evidence_empty":
return JSONResponse(status_code=409, content=error_response(409, "release_validation_evidence_empty"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.post("/projects/{project_id}/release-feedback-ingests")
async def create_pmflow_os_release_feedback_ingest(
project_id: int,
body: PMFlowReleaseFeedbackIngestRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_release_feedback_ingest(
project_id=int(project_id),
body=body,
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code in {"feedback_content_empty", "反馈内容不能为空"}:
return JSONResponse(status_code=400, content=error_response(400, "feedback_content_empty"))
if code == "charter_not_found":
return JSONResponse(status_code=404, content=error_response(404, "charter_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.post("/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts")
async def create_pmflow_os_iteration_recommendation_change_set_draft(
project_id: int,
recommendation_id: int,
body: PMFlowIterationRecommendationChangeSetDraftRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_iteration_recommendation_change_set_draft(
project_id=int(project_id),
recommendation_id=int(recommendation_id),
body=body or PMFlowIterationRecommendationChangeSetDraftRequest(),
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "iteration_recommendation_not_found":
return JSONResponse(status_code=404, content=error_response(404, "iteration_recommendation_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.post("/projects/{project_id}/iteration-recommendation-groups/{group_key}/change-set-drafts")
async def create_pmflow_os_iteration_recommendation_group_change_set_draft(
project_id: int,
group_key: str,
body: PMFlowIterationRecommendationGroupChangeSetDraftRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.create_iteration_recommendation_group_change_set_draft(
project_id=int(project_id),
group_key=group_key,
body=body or PMFlowIterationRecommendationGroupChangeSetDraftRequest(),
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code == "iteration_recommendation_group_not_found":
return JSONResponse(status_code=404, content=error_response(404, "iteration_recommendation_group_not_found"))
if code == "iteration_recommendation_not_found":
return JSONResponse(status_code=404, content=error_response(404, "iteration_recommendation_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload)
@router.post("/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume")
async def consume_pmflow_os_execution_handoff(
project_id: int,
prompt_package_id: int,
body: PMFlowExecutionHandoffConsumeRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tenant_id = int(current_user.tenant_id)
await _ensure_project_member_or_manager(
db,
tenant_id=tenant_id,
project_id=int(project_id),
current_user=current_user,
)
service = PMFlowOSProjectionService(db, tenant_id=tenant_id)
try:
payload = await service.consume_execution_handoff(
project_id=int(project_id),
prompt_package_id=int(prompt_package_id),
body=body or PMFlowExecutionHandoffConsumeRequest(),
current_user_id=int(current_user.id),
)
except ValueError as exc:
code = str(exc)
if code in {"prompt_package_not_found", "execution_handoff_not_found"}:
return JSONResponse(status_code=404, content=error_response(404, code))
if code == "task_not_found":
return JSONResponse(status_code=404, content=error_response(404, "task_not_found"))
if code == "repo_binding_not_found":
return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found"))
if code == "project_not_found":
raise HTTPException(status_code=404, detail="project_not_found") from exc
raise
return success_response(payload.model_dump(mode="json"))