From e22b8e7ab2d00055daaae478ccdba2538d5ecb2b Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 5 May 2026 11:00:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(awooop):=20Operator=20Console=20API=20+=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=88leWOOOgo=20=E7=A9=8D=E6=9C=A8?= =?UTF-8?q?=E5=8C=96=E4=BF=AE=E5=BE=A9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端: - 新增 platform_operator_service.py(DB 存取集中 Service 層) - Router 層移除 Depends(get_db),改呼叫 Service 函數 - tenants/contracts/operator_runs 三個 Router 符合 leWOOOgo 規範 - __init__.py 整合四個 platform router 前端: - apps/web/src/app/[locale]/awooop/ 完整建立(7 個頁面) - layout.tsx:四分頁導覽(tenants/contracts/runs/approvals) - 全部使用 @/i18n/routing(Link/usePathname/useRouter)避免 i18n 路徑問題 - approvals page:10s 自動刷新、timeout 倒數、緊急紅色高亮 ADR-106/107/112/114/115/116 Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/api/v1/platform/__init__.py | 22 +- apps/api/src/api/v1/platform/contracts.py | 53 ++ apps/api/src/api/v1/platform/operator_runs.py | 136 +++++ apps/api/src/api/v1/platform/tenants.py | 47 ++ .../src/services/platform_operator_service.py | 293 +++++++++++ .../awooop/approvals/[run_id]/page.tsx | 495 ++++++++++++++++++ .../app/[locale]/awooop/approvals/page.tsx | 300 +++++++++++ .../app/[locale]/awooop/contracts/page.tsx | 281 ++++++++++ apps/web/src/app/[locale]/awooop/layout.tsx | 106 ++++ apps/web/src/app/[locale]/awooop/page.tsx | 9 + .../web/src/app/[locale]/awooop/runs/page.tsx | 458 ++++++++++++++++ .../src/app/[locale]/awooop/tenants/page.tsx | 269 ++++++++++ 12 files changed, 2467 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/api/v1/platform/contracts.py create mode 100644 apps/api/src/api/v1/platform/operator_runs.py create mode 100644 apps/api/src/api/v1/platform/tenants.py create mode 100644 apps/api/src/services/platform_operator_service.py create mode 100644 apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx create mode 100644 apps/web/src/app/[locale]/awooop/approvals/page.tsx create mode 100644 apps/web/src/app/[locale]/awooop/contracts/page.tsx create mode 100644 apps/web/src/app/[locale]/awooop/layout.tsx create mode 100644 apps/web/src/app/[locale]/awooop/page.tsx create mode 100644 apps/web/src/app/[locale]/awooop/runs/page.tsx create mode 100644 apps/web/src/app/[locale]/awooop/tenants/page.tsx diff --git a/apps/api/src/api/v1/platform/__init__.py b/apps/api/src/api/v1/platform/__init__.py index b5d0d0e4..065a8c38 100644 --- a/apps/api/src/api/v1/platform/__init__.py +++ b/apps/api/src/api/v1/platform/__init__.py @@ -1,4 +1,22 @@ -"""AwoooP Platform API — Phase 4 Shadow Mode Shell""" -from src.api.v1.platform.runs import router +""" +AwoooP Platform API — Operator Console Router 彙整 +=================================================== +Phase 4 Shadow Mode + Phase 8 Operator Console +ADR-106/ADR-107/ADR-114/ADR-115/ADR-116 +2026-05-05 ogt + Claude Sonnet 4.6(新增 Operator Console 四 router) +""" + +from fastapi import APIRouter + +from src.api.v1.platform.contracts import router as contracts_router +from src.api.v1.platform.operator_runs import router as operator_runs_router +from src.api.v1.platform.runs import router as runs_router +from src.api.v1.platform.tenants import router as tenants_router + +router = APIRouter() +router.include_router(runs_router) +router.include_router(tenants_router) +router.include_router(contracts_router) +router.include_router(operator_runs_router) __all__ = ["router"] diff --git a/apps/api/src/api/v1/platform/contracts.py b/apps/api/src/api/v1/platform/contracts.py new file mode 100644 index 00000000..22110206 --- /dev/null +++ b/apps/api/src/api/v1/platform/contracts.py @@ -0,0 +1,53 @@ +""" +AwoooP Operator Console — Contracts List API +============================================= +ADR-106(AwoooP Agent Platform),ADR-107/ADR-112(Contract Revision) +2026-05-05 ogt + Claude Sonnet 4.6 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Query +from pydantic import BaseModel + +from src.services.platform_operator_service import list_contracts as list_contracts_svc + +router = APIRouter() + + +class ContractItem(BaseModel): + revision_id: UUID + contract_id: str + contract_family: str + lifecycle_status: str + body_hash: str + version_major: int + version_minor: int + created_at: datetime + project_id: str + + +class ListContractsResponse(BaseModel): + contracts: list[ContractItem] + total: int + + +@router.get( + "/contracts", + response_model=ListContractsResponse, + summary="列出合約 Revisions", + description=( + "返回 awooop_contract_revisions,支援 project_id / lifecycle_status filter。\n\n" + "- 按 created_at DESC 排序,最多 200 筆\n" + "- ADR-107/ADR-112:append-only revision 表,只查不寫" + ), +) +async def list_contracts( + project_id: str | None = Query(None, description="租戶 ID(可選)"), + lifecycle_status: str | None = Query(None, description="lifecycle status filter(draft/published/active/revoked)"), +) -> dict[str, Any]: + return await list_contracts_svc(project_id=project_id, lifecycle_status=lifecycle_status) diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py new file mode 100644 index 00000000..43586bd6 --- /dev/null +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -0,0 +1,136 @@ +""" +AwoooP Operator Console — Runs List & Approval API +==================================================== + GET /runs/list — 列出 runs(可 filter) + GET /approvals — 列出待審核 runs(state=waiting_approval) + POST /approvals/{run_id}/decide — 核准或拒絕 run +ADR-106(AwoooP Agent Platform),ADR-114(Run State Machine),ADR-116(Gate 5 Approval) +2026-05-05 ogt + Claude Sonnet 4.6 +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field + +from src.services.platform_operator_service import ( + decide_approval as decide_approval_svc, + list_approvals as list_approvals_svc, + list_runs as list_runs_svc, +) + +router = APIRouter() + +_DEFAULT_PER_PAGE = 50 +_MAX_PER_PAGE = 200 + + +class RunItem(BaseModel): + run_id: UUID + project_id: str + agent_id: str + state: str + is_shadow: bool + cost_usd: Decimal + step_count: int + created_at: datetime + timeout_at: datetime | None + + +class ListRunsResponse(BaseModel): + runs: list[RunItem] + total: int + page: int + per_page: int + + +class ApprovalItem(BaseModel): + run_id: UUID + project_id: str + agent_id: str + created_at: datetime + timeout_at: datetime | None + + +class ListApprovalsResponse(BaseModel): + items: list[ApprovalItem] + total: int + + +class DecideApprovalRequest(BaseModel): + project_id: str = Field(..., description="租戶 ID") + decision: Literal["approve", "reject"] = Field(..., description="核准或拒絕") + approver_id: str = Field(..., description="審核人 ID(platform_subject_id 或 operator email)") + reason: str | None = Field(None, description="決策原因(可選)") + + +class DecideApprovalResponse(BaseModel): + run_id: str + decision: str + new_state: str + approval_token_jti: str | None + + +@router.get( + "/runs/list", + response_model=ListRunsResponse, + summary="列出 Runs", + description=( + "返回 awooop_run_state 記錄,支援 project_id / state filter 與分頁。\n\n" + "- 按 created_at DESC 排序\n" + "- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突" + ), +) +async def list_runs( + project_id: str | None = Query(None, description="租戶 ID(可選)"), + state: str | None = Query(None, description="Run 狀態 filter(可選)"), + page: int = Query(1, ge=1, description="頁碼,從 1 開始"), + per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"), +) -> dict[str, Any]: + return await list_runs_svc( + project_id=project_id, state=state, page=page, per_page=per_page + ) + + +@router.get( + "/approvals", + response_model=ListApprovalsResponse, + summary="列出待審核 Runs", + description=( + "返回 state=waiting_approval 的 runs,即需要人工審核的任務清單。\n\n" + "ADR-116 Gate 5:人工審核關卡" + ), +) +async def list_approvals( + project_id: str | None = Query(None, description="租戶 ID(可選)"), +) -> dict[str, Any]: + return await list_approvals_svc(project_id=project_id) + + +@router.post( + "/approvals/{run_id}/decide", + response_model=DecideApprovalResponse, + summary="核准或拒絕 Run", + description=( + "對 waiting_approval 狀態的 run 做出審核決定。\n\n" + "- approve:發行 approval token → record_approval → run 轉為 running\n" + "- reject:直接 transition → cancelled\n\n" + "ADR-116 Gate 5:Operator Console 人工審核" + ), +) +async def decide_approval( + run_id: str, + body: DecideApprovalRequest, +) -> dict[str, Any]: + return await decide_approval_svc( + run_id=run_id, + project_id=body.project_id, + decision=body.decision, + approver_id=body.approver_id, + reason=body.reason, + ) diff --git a/apps/api/src/api/v1/platform/tenants.py b/apps/api/src/api/v1/platform/tenants.py new file mode 100644 index 00000000..c44c3213 --- /dev/null +++ b/apps/api/src/api/v1/platform/tenants.py @@ -0,0 +1,47 @@ +""" +AwoooP Operator Console — Tenants List API +========================================== +ADR-106(AwoooP Agent Platform),ADR-115(Tenant Onboarding) +2026-05-05 ogt + Claude Sonnet 4.6 +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from pydantic import BaseModel + +from src.services.platform_operator_service import list_tenants as list_tenants_svc + +router = APIRouter() + + +class TenantItem(BaseModel): + project_id: str + display_name: str + migration_mode: str + budget_limit_usd: Decimal | None + is_active: bool + created_at: datetime + + +class ListTenantsResponse(BaseModel): + tenants: list[TenantItem] + total: int + + +@router.get( + "/tenants", + response_model=ListTenantsResponse, + summary="列出所有租戶", + description=( + "返回所有 awooop_projects 記錄(含已停用)。\n\n" + "ADR-106/ADR-115:Operator Console 使用,不依 RLS 過濾。" + ), +) +async def list_tenants() -> dict[str, Any]: + return await list_tenants_svc() diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py new file mode 100644 index 00000000..f91c9031 --- /dev/null +++ b/apps/api/src/services/platform_operator_service.py @@ -0,0 +1,293 @@ +""" +AwoooP Operator Console — Platform Operator Service +==================================================== +leWOOOgo 積木化:DB 存取集中在 Service 層,Router 不直接引用 get_db。 +ADR-106(AwoooP Agent Platform) +2026-05-05 ogt + Claude Sonnet 4.6 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +import structlog +from fastapi import HTTPException, status +from sqlalchemy import func, select + +from src.db.awooop_models import ( + AwoooPContractRevision, + AwoooPProject, + AwoooPRunState, +) +from src.db.base import get_db_context +from src.services.audit_sink import write_audit +from src.services.awooop_approval_token import issue_approval_token, record_approval +from src.services.run_state_machine import transition + +logger = structlog.get_logger(__name__) + +_MAX_CONTRACTS = 200 +_DEFAULT_PER_PAGE = 50 +_MAX_PER_PAGE = 200 + +# ============================================================================= +# Tenants +# ============================================================================= + +async def list_tenants() -> dict[str, Any]: + """列出所有 AwoooP 租戶(Operator Console,不依 RLS 過濾)。""" + async with get_db_context("awoooi") as db: + result = await db.execute( + select(AwoooPProject).order_by(AwoooPProject.created_at.asc()) + ) + rows = list(result.scalars().all()) + + tenants = [ + { + "project_id": r.project_id, + "display_name": r.display_name, + "migration_mode": r.migration_mode, + "budget_limit_usd": r.budget_limit_usd, + "is_active": r.is_active, + "created_at": r.created_at, + } + for r in rows + ] + return {"tenants": tenants, "total": len(tenants)} + + +# ============================================================================= +# Contracts +# ============================================================================= + +async def list_contracts( + project_id: str | None, + lifecycle_status: str | None, +) -> dict[str, Any]: + """列出合約 revisions(可 filter by project_id / lifecycle_status)。""" + async with get_db_context("awoooi") as db: + stmt = select(AwoooPContractRevision).order_by( + AwoooPContractRevision.created_at.desc() + ) + if project_id is not None: + stmt = stmt.where(AwoooPContractRevision.project_id == project_id) + if lifecycle_status is not None: + stmt = stmt.where( + AwoooPContractRevision.lifecycle_status == lifecycle_status + ) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar_one() + + stmt = stmt.limit(_MAX_CONTRACTS) + result = await db.execute(stmt) + rows = list(result.scalars().all()) + + contracts = [ + { + "revision_id": r.revision_id, + "contract_id": r.contract_id, + "contract_family": r.contract_family, + "lifecycle_status": r.lifecycle_status, + "body_hash": r.body_hash, + "version_major": r.version_major, + "version_minor": r.version_minor, + "created_at": r.created_at, + "project_id": r.project_id, + } + for r in rows + ] + return {"contracts": contracts, "total": total} + + +# ============================================================================= +# Runs +# ============================================================================= + +async def list_runs( + project_id: str | None, + state: str | None, + page: int, + per_page: int, +) -> dict[str, Any]: + """列出 runs,支援 project_id、state filter 與分頁。""" + async with get_db_context("awoooi") as db: + stmt = select(AwoooPRunState).order_by(AwoooPRunState.created_at.desc()) + if project_id is not None: + stmt = stmt.where(AwoooPRunState.project_id == project_id) + if state is not None: + stmt = stmt.where(AwoooPRunState.state == state) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar_one() + + offset = (page - 1) * per_page + stmt = stmt.offset(offset).limit(per_page) + result = await db.execute(stmt) + rows = list(result.scalars().all()) + + runs = [ + { + "run_id": r.run_id, + "project_id": r.project_id, + "agent_id": r.agent_id, + "state": r.state, + "is_shadow": r.is_shadow, + "cost_usd": r.cost_usd, + "step_count": r.step_count, + "created_at": r.created_at, + "timeout_at": r.timeout_at, + } + for r in rows + ] + return {"runs": runs, "total": total, "page": page, "per_page": per_page} + + +# ============================================================================= +# Approvals +# ============================================================================= + +async def list_approvals(project_id: str | None) -> dict[str, Any]: + """列出所有 waiting_approval 狀態的 runs。""" + async with get_db_context("awoooi") as db: + stmt = ( + select(AwoooPRunState) + .where(AwoooPRunState.state == "waiting_approval") + .order_by(AwoooPRunState.created_at.asc()) + ) + if project_id is not None: + stmt = stmt.where(AwoooPRunState.project_id == project_id) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar_one() + + result = await db.execute(stmt) + rows = list(result.scalars().all()) + + items = [ + { + "run_id": r.run_id, + "project_id": r.project_id, + "agent_id": r.agent_id, + "created_at": r.created_at, + "timeout_at": r.timeout_at, + } + for r in rows + ] + return {"approvals": items, "total": total, "items": items} + + +async def decide_approval( + run_id: str, + project_id: str, + decision: str, + approver_id: str, + reason: str | None, +) -> dict[str, Any]: + """核准或拒絕一個待審核的 run(ADR-116 Gate 5)。""" + try: + run_uuid = uuid.UUID(run_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"run_id 格式錯誤: {exc}", + ) from exc + + async with get_db_context(project_id) as db: + result = await db.execute( + select(AwoooPRunState).where( + AwoooPRunState.run_id == run_uuid, + AwoooPRunState.project_id == project_id, + ) + ) + run = result.scalar_one_or_none() + + if run is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"run {run_id!r} 不存在或非此 project 所有", + ) + if run.state != "waiting_approval": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"run {run_id!r} 目前狀態為 {run.state!r},無法審核(需為 waiting_approval)", + ) + + approval_token_jti: str | None = None + new_state: str + + if decision == "approve": + token = issue_approval_token( + project_id=project_id, + run_id=run_id, + tool_name="operator_console_approve", + approver_id=approver_id, + ) + try: + await record_approval( + project_id=project_id, + run_id=run_id, + tool_name="operator_console_approve", + approver_id=approver_id, + token=token, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"核准記錄失敗: {exc}", + ) from exc + + await transition(run_uuid, project_id, "running") + new_state = "running" + + import base64 + import json as _json + try: + p_b64 = token.split(".")[1] + padding = 4 - len(p_b64) % 4 + if padding != 4: + p_b64 += "=" * padding + payload = _json.loads(base64.urlsafe_b64decode(p_b64)) + approval_token_jti = payload.get("jti") + except Exception: + approval_token_jti = None + + else: + await transition( + run_uuid, + project_id, + "cancelled", + error_code="E-APPR-REJECTED", + error_detail=f"operator 拒絕: approver={approver_id!r}, reason={reason!r}", + ) + new_state = "cancelled" + + try: + await write_audit( + project_id=project_id, + action=f"run.approval.{decision}", + resource_type="run", + resource_id=run_id, + details={ + "approver_id": approver_id, + "decision": decision, + "reason": reason, + "new_state": new_state, + }, + ) + except Exception as exc: + logger.warning("approval_audit_write_failed", run_id=run_id, error=str(exc)) + + return { + "run_id": run_id, + "decision": decision, + "new_state": new_state, + "approval_token_jti": approval_token_jti, + } diff --git a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx new file mode 100644 index 00000000..83c8d51a --- /dev/null +++ b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx @@ -0,0 +1,495 @@ +// ============================================================================= +// WOOO AIOps - AwoooP M8 審批決策頁面 +// ============================================================================= +// 顯示 Run 詳情,Approve / Reject 各需 Dialog 確認 +// Reject 需填寫原因 + +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter, Link } from "@/i18n/routing"; +import { + ShieldCheck, + AlertCircle, + CheckCircle, + XCircle, + ArrowLeft, + RefreshCw, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ============================================================================= +// Types +// ============================================================================= + +interface RunDetail { + run_id: string; + project_id: string; + agent_id: string; + state: string; + created_at: string; + timeout_at?: string | null; +} + +// ============================================================================= +// 常數 +// ============================================================================= + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; + +// ============================================================================= +// Sub Components +// ============================================================================= + +function DetailRow({ + label, + value, +}: { + label: string; + value: string | React.ReactNode; +}) { + return ( +
+ + {label} + + {value} +
+ ); +} + +// Approve 確認 Dialog +function ApproveDialog({ + runId, + onConfirm, + onCancel, + loading, +}: { + runId: string; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +}) { + return ( +
+
+ {/* Header */} +
+
+
+
+

+ 確認核准 +

+
+ +
+ + {/* Body */} +
+

確定要核准此 Run?

+
+ + Run ID:{" "} + {runId.slice(0, 8)} + +
+

+ 核准後,Run 將繼續執行後續步驟。此操作無法撤銷。 +

+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + +// Reject 確認 Dialog(需填原因) +function RejectDialog({ + runId, + onConfirm, + onCancel, + loading, +}: { + runId: string; + onConfirm: (reason: string) => void; + onCancel: () => void; + loading: boolean; +}) { + const [reason, setReason] = useState(""); + + return ( +
+
+ {/* Header */} +
+
+
+
+

+ 確認拒絕 +

+
+ +
+ + {/* Body */} +
+
+ + Run ID:{" "} + {runId.slice(0, 8)} + +
+ +
+ +