feat(api): Phase 19.4 Terminal Service 真實 API 整合

整合真實後端服務,移除 Mock 數據:

_handle_approval_action:
- 使用 ApprovalDBService.get_pending_approvals()
- 顯示待簽核清單摘要 (最多 5 個)
- 渲染第一個待簽核項目的 ApprovalCard

_handle_status_query:
- 使用 K8s API 查詢 Pod 狀態
- 統計 Running/Ready/Total Pods
- 顯示問題 Pods (非 Running 或 NotReady)
- 查詢 Deployment 健康狀態

測試覆蓋:
- 6 個新增 API 整合測試
- 總計 60 個測試通過

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-30 01:17:03 +08:00
parent 288ba7593e
commit 19fff8339d
2 changed files with 421 additions and 35 deletions

View File

@@ -28,6 +28,7 @@ from src.models.terminal import (
TerminalSession,
TerminalSessionStatus,
)
from src.services.approval_db import get_approval_service
from src.services.openclaw import get_openclaw
logger = get_logger("awoooi.terminal")
@@ -489,30 +490,83 @@ class TerminalService:
self,
publisher,
topic: str,
session_id: str,
_session_id: str, # Reserved for session-specific approval tracking
_intent: str, # Reserved for intent-specific approval filtering
) -> None:
"""處理簽核操作"""
"""
處理簽核操作
Phase 19.4: 整合真實 Approval API
@author Claude Code
@date 2026-03-30 (台北時間)
"""
await self._publish_thought(publisher, topic, "Executor", "檢查待簽核項目...")
await self._publish_tool_call(publisher, topic, "Approval-Query", "executing")
await asyncio.sleep(0.5)
# TODO: Phase 19.4 - 整合真實 Approval API
await self._publish_tool_call(
publisher, topic, "Approval-Query", "completed",
result={"pending_count": 2}
)
await asyncio.sleep(0.3)
try:
approval_service = get_approval_service()
pending_approvals = await approval_service.get_pending_approvals()
# 渲染 ApprovalCard
await self._publish_render_ui(
publisher, topic, "ApprovalCard",
{
"approvalId": f"APR-{session_id[:8].upper()}",
"riskLevel": "MEDIUM",
"kubectl": "kubectl rollout restart deployment/awoooi-api -n awoooi",
}
)
await self._publish_tool_call(
publisher, topic, "Approval-Query", "completed",
result={"pending_count": len(pending_approvals)}
)
await asyncio.sleep(0.3)
if not pending_approvals:
await self._publish_thought(
publisher, topic, "Executor",
"目前沒有待簽核的項目。"
)
return
# 顯示待簽核清單摘要
summary_lines = [f"發現 {len(pending_approvals)} 個待簽核項目:"]
for i, approval in enumerate(pending_approvals[:5], 1): # 最多顯示 5 個
risk = approval.risk_level.value.upper() if approval.risk_level else "UNKNOWN"
summary_lines.append(
f" {i}. [{risk}] {approval.action[:50]}"
)
if len(pending_approvals) > 5:
summary_lines.append(f" ... 還有 {len(pending_approvals) - 5}")
await self._publish_thought(
publisher, topic, "Executor",
"\n".join(summary_lines)
)
# 渲染第一個待簽核項目的 ApprovalCard
first_approval = pending_approvals[0]
risk_level = first_approval.risk_level.value.upper() if first_approval.risk_level else "MEDIUM"
# 從 metadata 取得 kubectl 指令 (如果有)
kubectl_cmd = "kubectl get pods -n awoooi"
if first_approval.metadata:
kubectl_cmd = first_approval.metadata.get("kubectl_command", kubectl_cmd)
await self._publish_render_ui(
publisher, topic, "ApprovalCard",
{
"approvalId": str(first_approval.id)[:8].upper(),
"riskLevel": risk_level,
"kubectl": kubectl_cmd,
"description": first_approval.description[:100] if first_approval.description else "",
"requiredSignatures": first_approval.required_signatures,
"currentSignatures": first_approval.current_signatures,
}
)
except Exception as e:
logger.error("approval_query_failed", error=str(e))
await self._publish_tool_call(
publisher, topic, "Approval-Query", "failed",
result={"error": str(e)[:100]}
)
await self._publish_thought(
publisher, topic, "System",
f"查詢簽核項目失敗: {str(e)[:50]}"
)
async def _handle_metrics_query(
self,
@@ -584,27 +638,110 @@ class TerminalService:
_session_id: str, # Reserved for session-specific caching
_intent: str, # Reserved for intent-specific status filtering
) -> None:
"""處理狀態查詢"""
"""
處理狀態查詢
Phase 19.4: 整合真實 K8s API
@author Claude Code
@date 2026-03-30 (台北時間)
"""
await self._publish_thought(publisher, topic, "Monitor", "檢查系統狀態...")
await self._publish_tool_call(publisher, topic, "K8s-Status", "executing")
await asyncio.sleep(0.5)
# TODO: Phase 19.4 - 整合真實 K8s API
await self._publish_tool_call(
publisher, topic, "K8s-Status", "completed",
result={
"pods_running": 11,
"pods_total": 12,
"deployments_healthy": 5,
}
)
await asyncio.sleep(0.3)
try:
# 使用 K8s Diagnostics Service 的 client
from src.services.k8s_diagnostics import _get_k8s_client
await self._publish_thought(
publisher, topic, "Monitor",
"系統狀態: 11/12 Pods 運行中5 個 Deployment 健康。\n"
"1 個 Pod 正在重啟中 (awoooi-worker-xxx)。"
)
client = await _get_k8s_client()
if not client:
raise RuntimeError("K8s client initialization failed")
v1 = client.CoreV1Api()
apps_v1 = client.AppsV1Api()
# 查詢 awoooi namespace 的 Pods
namespace = "awoooi"
pods = await v1.list_namespaced_pod(namespace=namespace)
# 統計 Pod 狀態
pods_total = len(pods.items)
pods_running = sum(
1 for p in pods.items
if p.status.phase == "Running"
)
pods_ready = sum(
1 for p in pods.items
if p.status.phase == "Running"
and all(
c.ready for c in (p.status.container_statuses or [])
)
)
# 找出有問題的 Pods
problem_pods = []
for pod in pods.items:
if pod.status.phase != "Running":
problem_pods.append(f"{pod.metadata.name}: {pod.status.phase}")
elif pod.status.container_statuses:
for cs in pod.status.container_statuses:
if not cs.ready:
reason = ""
if cs.state and cs.state.waiting:
reason = cs.state.waiting.reason or ""
problem_pods.append(f"{pod.metadata.name}: {reason or 'NotReady'}")
break
# 查詢 Deployments
deployments = await apps_v1.list_namespaced_deployment(namespace=namespace)
deployments_total = len(deployments.items)
deployments_healthy = sum(
1 for d in deployments.items
if d.status.ready_replicas == d.spec.replicas
)
await self._publish_tool_call(
publisher, topic, "K8s-Status", "completed",
result={
"pods_running": pods_running,
"pods_ready": pods_ready,
"pods_total": pods_total,
"deployments_healthy": deployments_healthy,
"deployments_total": deployments_total,
}
)
await asyncio.sleep(0.3)
# 組合狀態訊息
status_lines = [
f"系統狀態: {pods_ready}/{pods_total} Pods 就緒",
f"Deployments: {deployments_healthy}/{deployments_total} 健康",
]
if problem_pods:
status_lines.append(f"\n問題 Pods ({len(problem_pods)}):")
for pp in problem_pods[:3]: # 最多顯示 3 個
status_lines.append(f" - {pp}")
if len(problem_pods) > 3:
status_lines.append(f" ... 還有 {len(problem_pods) - 3}")
else:
status_lines.append("\n所有 Pods 運行正常。")
await self._publish_thought(
publisher, topic, "Monitor",
"\n".join(status_lines)
)
except Exception as e:
logger.error("k8s_status_query_failed", error=str(e))
await self._publish_tool_call(
publisher, topic, "K8s-Status", "failed",
result={"error": str(e)[:100]}
)
await self._publish_thought(
publisher, topic, "System",
f"K8s 狀態查詢失敗: {str(e)[:50]}\n"
"請確認 K8s 連線配置。"
)
async def _handle_general_chat(
self,

View File

@@ -15,12 +15,15 @@ Omni-Terminal SSE 架構測試
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from src.models.terminal import (
SpatialContext,
TerminalIntentRequest,
TerminalSessionStatus,
)
from src.models.approval import ApprovalRequest, ApprovalStatus, RiskLevel
from src.services.terminal_service import (
IntentType,
TerminalService,
@@ -222,3 +225,249 @@ def test_intent_type_coverage():
missing = all_types - tested_types
assert not missing, f"Missing test cases for IntentType: {missing}"
# =============================================================================
# Phase 19.4: API Integration Tests
# =============================================================================
# @author Claude Code
# @date 2026-03-30 (台北時間)
# =============================================================================
@pytest.fixture
def mock_publisher():
"""模擬 SSE Publisher"""
publisher = AsyncMock()
publisher.publish = AsyncMock()
return publisher
@pytest.fixture
def sample_approval():
"""測試用 Approval"""
return ApprovalRequest(
id=uuid4(),
action="kubectl rollout restart deployment/awoooi-api -n awoooi",
description="重啟 API 服務以套用新設定",
status=ApprovalStatus.PENDING,
risk_level=RiskLevel.MEDIUM,
required_signatures=1,
current_signatures=0,
signatures=[],
requested_by="system",
)
class TestHandleApprovalAction:
"""_handle_approval_action Phase 19.4 測試"""
@pytest.mark.asyncio
async def test_approval_action_with_pending(self, mock_publisher, sample_approval):
"""測試有待簽核項目時的處理"""
service = TerminalService()
mock_approval_service = MagicMock()
mock_approval_service.get_pending_approvals = AsyncMock(
return_value=[sample_approval]
)
with patch(
"src.services.terminal_service.get_approval_service",
return_value=mock_approval_service,
):
await service._handle_approval_action(
mock_publisher,
"test_topic",
"test_session",
"approval",
)
# 驗證 publish 被呼叫多次 (thought + tool_call + render_ui)
assert mock_publisher.publish.call_count >= 3
@pytest.mark.asyncio
async def test_approval_action_no_pending(self, mock_publisher):
"""測試沒有待簽核項目時的處理"""
service = TerminalService()
mock_approval_service = MagicMock()
mock_approval_service.get_pending_approvals = AsyncMock(return_value=[])
with patch(
"src.services.terminal_service.get_approval_service",
return_value=mock_approval_service,
):
await service._handle_approval_action(
mock_publisher,
"test_topic",
"test_session",
"approval",
)
# 驗證有發送 "沒有待簽核項目" 的訊息
calls = mock_publisher.publish.call_args_list
found_no_pending = False
for call in calls:
event = call[0][0]
if hasattr(event, 'data') and isinstance(event.data, dict):
msg = event.data.get('msg', '')
if '沒有待簽核' in msg:
found_no_pending = True
break
assert found_no_pending, "Should mention no pending approvals"
@pytest.mark.asyncio
async def test_approval_action_error_handling(self, mock_publisher):
"""測試查詢失敗時的錯誤處理"""
service = TerminalService()
mock_approval_service = MagicMock()
mock_approval_service.get_pending_approvals = AsyncMock(
side_effect=Exception("Database error")
)
with patch(
"src.services.terminal_service.get_approval_service",
return_value=mock_approval_service,
):
# 不應拋出例外
await service._handle_approval_action(
mock_publisher,
"test_topic",
"test_session",
"approval",
)
# 驗證有發送錯誤訊息
assert mock_publisher.publish.call_count >= 2
class TestHandleStatusQuery:
"""_handle_status_query Phase 19.4 測試"""
@pytest.mark.asyncio
async def test_status_query_success(self, mock_publisher):
"""測試 K8s 狀態查詢成功"""
service = TerminalService()
# 模擬 K8s client
mock_pod = MagicMock()
mock_pod.metadata.name = "awoooi-api-xxx"
mock_pod.status.phase = "Running"
mock_pod.status.container_statuses = [MagicMock(ready=True)]
mock_pods_list = MagicMock()
mock_pods_list.items = [mock_pod]
mock_deployment = MagicMock()
mock_deployment.status.ready_replicas = 1
mock_deployment.spec.replicas = 1
mock_deployments_list = MagicMock()
mock_deployments_list.items = [mock_deployment]
mock_v1 = MagicMock()
mock_v1.list_namespaced_pod = AsyncMock(return_value=mock_pods_list)
mock_apps_v1 = MagicMock()
mock_apps_v1.list_namespaced_deployment = AsyncMock(
return_value=mock_deployments_list
)
mock_client = MagicMock()
mock_client.CoreV1Api.return_value = mock_v1
mock_client.AppsV1Api.return_value = mock_apps_v1
with patch(
"src.services.k8s_diagnostics._get_k8s_client",
new=AsyncMock(return_value=mock_client),
):
await service._handle_status_query(
mock_publisher,
"test_topic",
"test_session",
"status",
)
# 驗證成功查詢
assert mock_publisher.publish.call_count >= 2
@pytest.mark.asyncio
async def test_status_query_with_problem_pods(self, mock_publisher):
"""測試有問題 Pods 時的狀態查詢"""
service = TerminalService()
# 一個正常 Pod一個有問題的 Pod
mock_pod_ok = MagicMock()
mock_pod_ok.metadata.name = "awoooi-api-ok"
mock_pod_ok.status.phase = "Running"
mock_pod_ok.status.container_statuses = [MagicMock(ready=True)]
mock_pod_bad = MagicMock()
mock_pod_bad.metadata.name = "awoooi-worker-bad"
mock_pod_bad.status.phase = "Pending"
mock_pod_bad.status.container_statuses = []
mock_pods_list = MagicMock()
mock_pods_list.items = [mock_pod_ok, mock_pod_bad]
mock_deployments_list = MagicMock()
mock_deployments_list.items = []
mock_v1 = MagicMock()
mock_v1.list_namespaced_pod = AsyncMock(return_value=mock_pods_list)
mock_apps_v1 = MagicMock()
mock_apps_v1.list_namespaced_deployment = AsyncMock(
return_value=mock_deployments_list
)
mock_client = MagicMock()
mock_client.CoreV1Api.return_value = mock_v1
mock_client.AppsV1Api.return_value = mock_apps_v1
with patch(
"src.services.k8s_diagnostics._get_k8s_client",
new=AsyncMock(return_value=mock_client),
):
await service._handle_status_query(
mock_publisher,
"test_topic",
"test_session",
"status",
)
# 驗證有提到問題 Pods
calls = mock_publisher.publish.call_args_list
found_problem_pod = False
for call in calls:
event = call[0][0]
if hasattr(event, 'data') and isinstance(event.data, dict):
msg = event.data.get('msg', '')
if '問題 Pods' in msg or 'Pending' in msg:
found_problem_pod = True
break
assert found_problem_pod, "Should mention problem pods"
@pytest.mark.asyncio
async def test_status_query_k8s_unavailable(self, mock_publisher):
"""測試 K8s 連線失敗時的處理"""
service = TerminalService()
with patch(
"src.services.k8s_diagnostics._get_k8s_client",
new=AsyncMock(return_value=None),
):
# 不應拋出例外
await service._handle_status_query(
mock_publisher,
"test_topic",
"test_session",
"status",
)
# 驗證有發送錯誤訊息
assert mock_publisher.publish.call_count >= 2