Files
awoooi/apps/api/src/services/platform_operator_service.py
Your Name a2c4b3d47e
Some checks failed
Code Review / ai-code-review (push) Has been cancelled
CD Pipeline / tests (push) Successful in 2m22s
CD Pipeline / build-and-deploy (push) Successful in 3m54s
CD Pipeline / post-deploy-checks (push) Successful in 1m17s
fix(awooop): align console with flywheel execution metrics
2026-05-06 00:46:08 +08:00

307 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AwoooP Operator Console — Platform Operator Service
====================================================
leWOOOgo 積木化DB 存取集中在 Service 層Router 不直接引用 get_db。
ADR-106AwoooP Agent Platform
2026-05-05 ogt + Claude Sonnet 4.6
"""
from __future__ import annotations
import uuid
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,
run_id: str | None = None,
) -> dict[str, Any]:
"""列出 waiting_approval runs可依 project_id 或 run_id 篩選。"""
run_uuid: UUID | None = None
if run_id:
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("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)
if run_uuid is not None:
stmt = stmt.where(AwoooPRunState.run_id == run_uuid)
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]:
"""核准或拒絕一個待審核的 runADR-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,
}