feat(awooop): Operator Console API + 前端(leWOOOgo 積木化修復)
All checks were successful
Code Review / ai-code-review (push) Successful in 42s
All checks were successful
Code Review / ai-code-review (push) Successful in 42s
後端: - 新增 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
53
apps/api/src/api/v1/platform/contracts.py
Normal file
53
apps/api/src/api/v1/platform/contracts.py
Normal file
@@ -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)
|
||||
136
apps/api/src/api/v1/platform/operator_runs.py
Normal file
136
apps/api/src/api/v1/platform/operator_runs.py
Normal file
@@ -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,
|
||||
)
|
||||
47
apps/api/src/api/v1/platform/tenants.py
Normal file
47
apps/api/src/api/v1/platform/tenants.py
Normal file
@@ -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()
|
||||
293
apps/api/src/services/platform_operator_service.py
Normal file
293
apps/api/src/services/platform_operator_service.py
Normal file
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user