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"))