diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 5e9b6df3..3a008873 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -225,9 +225,26 @@ jobs: fi DEPLOY + # [首席架構師] 新增 Playwright E2E Smoke Test 步驟 v1.0.0 2026-04-01 (台北時間) + # continue-on-error: true — smoke 失敗不阻塞部署,但結果會反映在 TG 通知 + - name: E2E Smoke Test + id: smoke + continue-on-error: true + run: | + cd apps/web + # 安裝 Playwright Chromium(CI 環境,含系統依賴) + npx playwright install chromium --with-deps + # 跑 smoke test,line reporter 方便 CI 日誌閱讀 + npx playwright test tests/e2e/smoke.spec.ts --reporter=line + echo "smoke_status=pass" >> $GITHUB_OUTPUT + env: + # Playwright 在 CI 環境使用已建置的 pnpm node_modules + CI: "true" + - name: Notify Health Check Success env: - TG_MSG: "✅ AWOOOI 部署完成\n├ 📝 ${{ steps.commit.outputs.message }}\n├ 🔖 ${{ steps.commit.outputs.short_sha }}\n├ ⏱️ 耗時: ${MINUTES}m ${SECONDS}s\n├ 📦 API: ✅ Web: ✅\n└ 🩺 Health: ✅" + SMOKE_RESULT: ${{ steps.smoke.outcome == 'success' && '✅' || '⚠️' }} + TG_MSG: "✅ AWOOOI 部署完成\n├ 📝 ${{ steps.commit.outputs.message }}\n├ 🔖 ${{ steps.commit.outputs.short_sha }}\n├ ⏱️ 耗時: ${MINUTES}m ${SECONDS}s\n├ 📦 API: ✅ Web: ✅\n├ 🩺 Health: ✅\n└ 🎭 Smoke: ${SMOKE_RESULT}" run: | END_TIME=$(date +%s) DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }})) diff --git a/apps/api/src/api/v1/webhooks.py b/apps/api/src/api/v1/webhooks.py index dd370afd..2fab1688 100644 --- a/apps/api/src/api/v1/webhooks.py +++ b/apps/api/src/api/v1/webhooks.py @@ -45,8 +45,9 @@ from src.models.approval import ( from src.models.incident import Incident, IncidentStatus, Severity, Signal # R4 #129 (2026-04-01 ogt): AlertPayload/AlertResponse 移至 models 層,AlertAnalyzer 移至 services 層 # ogt 更新 v1.1 2026-04-01 台北時間: generate_alert_fingerprint 移至 alert_analyzer_service (ADR-024) +# [首席架構師] 移除 generate_alert_fingerprint 直接 import,改用 AlertAnalyzer.generate_fingerprint v1.2 2026-04-01 Asia/Taipei from src.models.webhook import AlertPayload, AlertResponse -from src.services.alert_analyzer_service import AlertAnalyzer, generate_alert_fingerprint +from src.services.alert_analyzer_service import AlertAnalyzer from src.services.approval_db import get_approval_service # Phase 17 P0: Service 層 (消除 Router 直接存取 Redis) @@ -338,7 +339,7 @@ async def verify_webhook_signature( return True -# generate_alert_fingerprint 已移至 src/services/alert_analyzer_service.py (ogt v1.1 2026-04-01 台北時間) +# generate_alert_fingerprint 已封裝為 AlertAnalyzer.generate_fingerprint (首席架構師 v1.2 2026-04-01 Asia/Taipei) # 戰略 B: 滑動時間窗 (5 分鐘) DEBOUNCE_WINDOW_MINUTES = 5 @@ -550,7 +551,7 @@ async def receive_alert( # ========================================================================== # 戰略 B Step 1: 生成告警指紋 # ========================================================================== - fingerprint = generate_alert_fingerprint(alert) + fingerprint = AlertAnalyzer.generate_fingerprint(alert) logger.info( "webhook_alert_received", @@ -987,7 +988,7 @@ async def alertmanager_webhook( # ========================================================================== # 告警指紋 + 收斂 # ========================================================================== - fingerprint = generate_alert_fingerprint(normalized_alert) + fingerprint = AlertAnalyzer.generate_fingerprint(normalized_alert) logger.info( "alertmanager_normalized", diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index f487ca6e..ce85c12b 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -425,6 +425,15 @@ class Settings(BaseSettings): HttpUrl(v) return v + # ========================================================================== + # Phase 23 (ADR-048): Sentry Webhook → OpenClaw AI Triage + # Sentry Issue Alert Webhook 簽章驗證 (sentry-hook-signature header) + # ========================================================================== + SENTRY_WEBHOOK_SECRET: str = Field( + default="", + description="Sentry Webhook secret for HMAC-SHA256 signature verification", + ) + # ========================================================================== # Phase 13.1: GitHub Webhook → OpenClaw 整合 # GitHub PR/Push 事件自動觸發 AI 代碼審查 diff --git a/apps/api/src/models/approval.py b/apps/api/src/models/approval.py index e2328b60..ea63e87c 100644 --- a/apps/api/src/models/approval.py +++ b/apps/api/src/models/approval.py @@ -14,7 +14,7 @@ from datetime import UTC, datetime from enum import Enum from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field # ============================================================================= # Enums @@ -123,13 +123,7 @@ class Signature(BaseModel): description="Telegram 訊息 ID", ) - # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei - model_config = ConfigDict( - json_encoders={ - datetime: lambda v: v.isoformat(), - UUID: lambda v: str(v), - } - ) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei # ============================================================================= @@ -187,13 +181,7 @@ class ApprovalRequest(ApprovalRequestBase): """檢查某人是否已簽核""" return any(s.signer_id == signer_id for s in self.signatures) - # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei - model_config = ConfigDict( - json_encoders={ - datetime: lambda v: v.isoformat(), - UUID: lambda v: str(v), - } - ) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei # ============================================================================= diff --git a/apps/api/src/models/incident.py b/apps/api/src/models/incident.py index 2aab015f..8acfe69a 100644 --- a/apps/api/src/models/incident.py +++ b/apps/api/src/models/incident.py @@ -25,7 +25,7 @@ from enum import Enum from typing import Literal from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field # 復用現有模型 (避免重複定義) from src.models.approval import BlastRadius @@ -107,10 +107,7 @@ class Signal(BaseModel): description="告警指紋 Hash,用於去重與聚合", ) - # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei - model_config = ConfigDict( - json_encoders={datetime: lambda v: v.isoformat()} - ) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei # ============================================================================= @@ -181,10 +178,7 @@ class AIDecisionChain(BaseModel): inference_completed_at: datetime = Field(..., description="推論完成時間") latency_ms: int = Field(..., description="推論延遲 (毫秒)") - # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei - model_config = ConfigDict( - json_encoders={datetime: lambda v: v.isoformat()} - ) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei # ============================================================================= @@ -423,13 +417,7 @@ class Incident(BaseModel): description="是否已向量化到 Vector DB (Semantic Memory)", ) - # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei - model_config = ConfigDict( - json_encoders={ - datetime: lambda v: v.isoformat(), - UUID: lambda v: str(v), - } - ) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei # ============================================================================= @@ -489,7 +477,4 @@ class IncidentResponse(BaseModel): closed_at=incident.closed_at, ) - # Claude 遷移 Pydantic v1→v2 2026-04-01 Asia/Taipei - model_config = ConfigDict( - json_encoders={datetime: lambda v: v.isoformat()} - ) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei diff --git a/apps/api/src/services/alert_analyzer_service.py b/apps/api/src/services/alert_analyzer_service.py index afca0e4b..db319fea 100644 --- a/apps/api/src/services/alert_analyzer_service.py +++ b/apps/api/src/services/alert_analyzer_service.py @@ -32,34 +32,6 @@ from src.models.webhook import AlertPayload from src.utils.k8s_naming import normalize_resource_name -# ============================================================================= -# 戰略 B: 告警指紋生成 -# ogt 移至 Service 層 v1.1 2026-04-01 台北時間 (ADR-024 R4 #129) -# ============================================================================= - -def generate_alert_fingerprint(alert: AlertPayload) -> str: - """ - 生成告警唯一指紋 (SHA256 Hash) - - 指紋組成: namespace:deployment:alert_type:target_resource - - 同一個告警模式(相同位置、相同類型)會產生相同指紋, - 用於識別重複告警並進行聚合。 - """ - # 從 labels 取得 deployment,如果沒有則用 target_resource - deployment = "" - if alert.labels: - deployment = alert.labels.get("deployment", alert.labels.get("app", "")) - if not deployment: - deployment = alert.target_resource - - # 組合指紋來源 - fingerprint_source = f"{alert.namespace}:{deployment}:{alert.alert_type}:{alert.target_resource}" - - # SHA256 Hash - return hashlib.sha256(fingerprint_source.encode()).hexdigest()[:32] - - class AlertAnalyzer: """ 告警分析器 - AWOOOI 核心大腦 @@ -203,3 +175,27 @@ class AlertAnalyzer: dry_run_checks=dry_run_checks, requested_by="OpenClaw", ) + + # [首席架構師] 封裝 generate_alert_fingerprint 為 staticmethod v1.2 2026-04-01 Asia/Taipei + @staticmethod + def generate_fingerprint(alert: AlertPayload) -> str: + """ + 生成告警唯一指紋 (SHA256 Hash) + + 指紋組成: namespace:deployment:alert_type:target_resource + + 同一個告警模式(相同位置、相同類型)會產生相同指紋, + 用於識別重複告警並進行聚合。 + """ + # 從 labels 取得 deployment,如果沒有則用 target_resource + deployment = "" + if alert.labels: + deployment = alert.labels.get("deployment", alert.labels.get("app", "")) + if not deployment: + deployment = alert.target_resource + + # 組合指紋來源 + fingerprint_source = f"{alert.namespace}:{deployment}:{alert.alert_type}:{alert.target_resource}" + + # SHA256 Hash + return hashlib.sha256(fingerprint_source.encode()).hexdigest()[:32] diff --git a/apps/api/src/services/sentry_webhook_service.py b/apps/api/src/services/sentry_webhook_service.py new file mode 100644 index 00000000..3079ed55 --- /dev/null +++ b/apps/api/src/services/sentry_webhook_service.py @@ -0,0 +1,452 @@ +""" +Sentry Webhook Service +====================== +Phase 23 (ADR-048): Sentry → OpenClaw AI Triage 業務邏輯層 + +遵循 leWOOOgo 積木化原則: +- Service 層負責: 解析、分析、建立 Incident、組裝訊息 +- Router 層只做: 接收、驗證、呼叫 Service、回傳 +- 禁止 Router 層直接存取 Redis/DB + +版本: v1.0 +建立: 2026-04-01 (台北時區) +建立者: Claude Code (Phase 23 ADR-048) +""" + +import hashlib +import hmac + +import structlog + +from src.core.config import settings +from src.models.approval import ( + ApprovalRequestCreate, + BlastRadius, + DataImpact, + RiskLevel, +) +from src.services.approval_db import get_approval_service +from src.services.openclaw_http_service import get_openclaw_http_service + +logger = structlog.get_logger(__name__) + +# Sentry Level → Risk Level 映射 (ADR-048) +SENTRY_LEVEL_TO_RISK: dict[str, RiskLevel] = { + "fatal": RiskLevel.CRITICAL, + "error": RiskLevel.HIGH, + "warning": RiskLevel.MEDIUM, + "info": RiskLevel.LOW, +} + + +# ============================================================================= +# Data Models (純資料容器,不含業務邏輯) +# ============================================================================= + +class SentryIssueContext: + """ + 解析後的 Sentry Issue 上下文 + + 從 Sentry webhook payload 提取關鍵欄位, + 供後續 OpenClaw 分析與 Incident 建立使用。 + """ + + def __init__( + self, + issue_id: str, + title: str, + culprit: str, + level: str, + project: str, + first_seen: str | None, + count: int, + message: str | None, + platform: str | None, + tags: list, + stacktrace: list[dict], + ) -> None: + self.issue_id = issue_id + self.title = title + self.culprit = culprit + self.level = level + self.project = project + self.first_seen = first_seen + self.count = count + self.message = message + self.platform = platform + self.tags = tags + self.stacktrace = stacktrace + + def to_dict(self) -> dict: + return { + "issue_id": self.issue_id, + "title": self.title, + "culprit": self.culprit, + "level": self.level, + "project": self.project, + "first_seen": self.first_seen, + "count": self.count, + "message": self.message, + "platform": self.platform, + "tags": self.tags, + "stacktrace": self.stacktrace, + } + + +class AIDecision: + """ + OpenClaw AI 分析結果 + + 封裝 OpenClaw 對 Sentry Issue 的分析輸出, + 包含根因、影響範圍、修復建議、預防措施。 + """ + + def __init__( + self, + root_cause: str, + impact: str, + fix_suggestion: str, + prevention: str, + confidence: float, + analyzed_by: str, + ) -> None: + self.root_cause = root_cause + self.impact = impact + self.fix_suggestion = fix_suggestion + self.prevention = prevention + self.confidence = confidence + self.analyzed_by = analyzed_by + + +# ============================================================================= +# SentryWebhookService +# ============================================================================= + +class SentryWebhookService: + """ + Sentry Webhook 業務邏輯 Service + + 職責: + 1. parse_sentry_issue() - 解析 webhook payload → SentryIssueContext + 2. analyze_with_openclaw() - 呼叫 OpenClaw 分析 → AIDecision | None + 3. create_incident() - 建立 Approval (Incident) 記錄 + 4. build_telegram_message() - 組裝 Telegram 告警訊息 + + leWOOOgo 積木化原則: + - 禁止直接存取 Redis/DB,透過對應 Service 呼叫 + - 每個方法單一職責 + """ + + def parse_sentry_issue(self, payload: dict) -> SentryIssueContext | None: + """ + 解析 Sentry Issue Alert Payload → SentryIssueContext + + Args: + payload: Sentry webhook raw JSON dict + + Returns: + SentryIssueContext 若解析成功,否則 None + + Sentry Payload 結構: + { + "action": "triggered", + "data": { + "issue": { id, title, culprit, level, firstSeen, count, project }, + "event": { message, platform, tags, exception } + } + } + """ + try: + issue_data = payload.get("data", {}).get("issue", {}) + event_data = payload.get("data", {}).get("event", {}) + + issue_id = issue_data.get("id") + if not issue_id: + logger.warning("sentry_parse_missing_issue_id") + return None + + return SentryIssueContext( + issue_id=str(issue_id), + title=issue_data.get("title", "Unknown Error"), + culprit=issue_data.get("culprit", "unknown"), + level=issue_data.get("level", "error"), + project=issue_data.get("project", {}).get("slug", "unknown"), + first_seen=issue_data.get("firstSeen"), + count=int(issue_data.get("count", 1)), + message=event_data.get("message"), + platform=event_data.get("platform"), + tags=event_data.get("tags", []), + stacktrace=self._extract_stacktrace(event_data), + ) + + except Exception as e: + logger.exception("sentry_parse_failed", error=str(e)) + return None + + def _extract_stacktrace(self, event_data: dict) -> list[dict]: + """提取 Stack Trace 最後 5 個 frame""" + try: + values = event_data.get("exception", {}).get("values", []) + if not values: + return [] + frames = values[0].get("stacktrace", {}).get("frames", []) + return [ + { + "filename": f.get("filename"), + "function": f.get("function"), + "lineno": f.get("lineno"), + "context_line": f.get("context_line"), + } + for f in frames[-5:] + ] + except Exception: + return [] + + async def analyze_with_openclaw( + self, + issue: SentryIssueContext, + ) -> AIDecision | None: + """ + 透過 OpenClawHttpService 分析 Sentry Issue + + Args: + issue: 已解析的 SentryIssueContext + + Returns: + AIDecision 若分析成功,否則 None (降級:無 AI 分析仍繼續流程) + """ + try: + service = get_openclaw_http_service() + data = await service.analyze_error( + error_context=issue.to_dict(), + prefer_local=True, + timeout=60.0, + ) + if not data: + return None + + return AIDecision( + root_cause=data.get("root_cause", "無法判斷根本原因"), + impact=data.get("impact", "影響範圍未知"), + fix_suggestion=data.get("fix_suggestion", "請人工排查"), + prevention=data.get("prevention", "待補充"), + confidence=float(data.get("confidence", 0.0)), + analyzed_by=data.get("analyzed_by", "unknown"), + ) + + except Exception as e: + logger.exception("sentry_openclaw_analyze_failed", error=str(e)) + return None + + async def create_incident( + self, + issue: SentryIssueContext, + decision: AIDecision | None, + anomaly_frequency: dict | None = None, + ) -> str: + """ + 建立 Approval (Incident) 記錄 + + Args: + issue: Sentry Issue 上下文 + decision: AI 分析結果 (可為 None,降級處理) + anomaly_frequency: 頻率統計 dict (可為 None) + + Returns: + str: Approval ID + """ + import uuid + + try: + approval_service = get_approval_service() + + # 基礎風險等級 + risk_level = SENTRY_LEVEL_TO_RISK.get(issue.level, RiskLevel.MEDIUM) + + # 根據頻率升級 (ADR-037) + if anomaly_frequency: + escalation = anomaly_frequency.get("escalation_level") + if escalation == "PERMANENT_FIX": + risk_level = RiskLevel.CRITICAL + elif escalation == "ESCALATE" and risk_level != RiskLevel.CRITICAL: + risk_level = RiskLevel.HIGH + + metadata: dict = { + "source": "sentry", + "alert_type": f"sentry_{issue.level}", + "sentry_issue_id": issue.issue_id, + "sentry_project": issue.project, + "culprit": issue.culprit, + "error_count": issue.count, + "first_seen": issue.first_seen, + "stacktrace": issue.stacktrace, + "llm_provider": decision.analyzed_by if decision else "pending", + "llm_confidence": decision.confidence if decision else 0.0, + } + if anomaly_frequency: + metadata["anomaly_frequency"] = anomaly_frequency + + approval_request = ApprovalRequestCreate( + action=f"Sentry {issue.level.upper()} Alert: {issue.culprit}", + description=( + f"{issue.title}\n\n" + f"Root Cause: {decision.root_cause if decision else '待分析'}\n" + f"Suggestion: {decision.fix_suggestion if decision else '待 AI 分析'}" + ), + risk_level=risk_level, + blast_radius=BlastRadius( + affected_pods=1, + estimated_downtime="0", + related_services=[issue.project], + data_impact=DataImpact.READ_ONLY, + ), + dry_run_checks=[], + requested_by="sentry-webhook", + metadata=metadata, + ) + + approval = await approval_service.create_approval(request=approval_request) + approval_id = str(approval.id) + + logger.info( + "sentry_incident_created", + approval_id=approval_id, + issue_id=issue.issue_id, + risk_level=risk_level.value, + ) + return approval_id + + except Exception as e: + logger.exception("sentry_incident_creation_failed", error=str(e)) + return f"temp-{uuid.uuid4().hex[:8]}" + + def build_telegram_message( + self, + issue: SentryIssueContext, + decision: AIDecision | None, + approval_id: str, + anomaly_frequency: dict | None = None, + ) -> str: + """ + 組裝 Telegram 告警訊息 (純文字摘要) + + 格式符合 feedback_telegram_alert_format.md 規範。 + 實際傳送使用 TelegramGateway.send_approval_card(), + 此方法提供純文字版本供日誌記錄與測試。 + + Args: + issue: Sentry Issue 上下文 + decision: AI 分析結果 + approval_id: Approval ID + anomaly_frequency: 頻率統計 + + Returns: + str: 格式化訊息文字 + """ + level_emoji = {"fatal": "💀", "error": "❌", "warning": "⚠️"}.get(issue.level, "🐛") + freq_text = "" + if anomaly_frequency and anomaly_frequency.get("count_24h", 0) > 1: + freq_text = ( + f"\n📊 頻率: " + f"1h:{anomaly_frequency.get('count_1h', 0)} / " + f"24h:{anomaly_frequency.get('count_24h', 0)} / " + f"7d:{anomaly_frequency.get('count_7d', 0)}" + ) + + analysis_text = "" + if decision: + analysis_text = ( + f"\n───────────────────────────\n" + f"🧠 OpenClaw 分析 (信心: {decision.confidence:.0%}):\n" + f"「{decision.root_cause[:120]}」\n" + f"💡 建議: {decision.fix_suggestion[:100]}" + ) + + return ( + f"═══════════════════════════\n" + f"{level_emoji} SENTRY {issue.level.upper()} 告警\n" + f"═══════════════════════════\n" + f"📦 專案: {issue.project}\n" + f"📍 {issue.culprit}\n" + f"🔖 {issue.title[:100]}" + f"{freq_text}" + f"{analysis_text}\n" + f"───────────────────────────\n" + f"🆔 Approval: {approval_id}" + ) + + +# ============================================================================= +# Webhook Secret 驗證 (ADR-048) +# ============================================================================= + +class SentrySignatureError(Exception): + """Sentry Webhook 簽章驗證失敗""" + + +def verify_sentry_signature(body: bytes, signature_header: str | None) -> bool: + """ + 驗證 Sentry Webhook 請求的 HMAC-SHA256 簽章 + + Sentry 使用 Header: sentry-hook-signature (hmac-sha256 hex digest) + + Fail-Closed 安全策略 (對齊 GitHub Webhook ADR): + - 生產環境: Secret 未設定 → 拒絕 + - 開發環境: Secret 未設定 → 允許 (僅供本地測試) + + Args: + body: Request body bytes + signature_header: sentry-hook-signature header 值 + + Returns: + bool: 驗證通過 + + Raises: + SentrySignatureError: 驗證失敗 + """ + secret = settings.SENTRY_WEBHOOK_SECRET + + if not secret: + if settings.ENVIRONMENT == "prod": + logger.critical( + "sentry_webhook_secret_missing_in_production", + message="CRITICAL: SENTRY_WEBHOOK_SECRET missing in production!", + ) + raise SentrySignatureError( + "SENTRY_WEBHOOK_SECRET missing in production environment" + ) + # 開發環境允許跳過 + logger.warning( + "sentry_signature_skipped_dev_only", + reason="SENTRY_WEBHOOK_SECRET not configured (dev mode only)", + ) + return True + + if not signature_header: + raise SentrySignatureError("Missing sentry-hook-signature header") + + expected = hmac.new( + secret.encode(), + body, + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(signature_header, expected): + raise SentrySignatureError("Invalid sentry-hook-signature") + + return True + + +# ============================================================================= +# Singleton +# ============================================================================= + +_sentry_webhook_service: SentryWebhookService | None = None + + +def get_sentry_webhook_service() -> SentryWebhookService: + """取得 SentryWebhookService 實例 (Singleton)""" + global _sentry_webhook_service + if _sentry_webhook_service is None: + _sentry_webhook_service = SentryWebhookService() + return _sentry_webhook_service diff --git a/apps/web/tests/e2e/smoke.spec.ts b/apps/web/tests/e2e/smoke.spec.ts new file mode 100644 index 00000000..6dcf7dc3 --- /dev/null +++ b/apps/web/tests/e2e/smoke.spec.ts @@ -0,0 +1,163 @@ +/** + * AWOOOI Smoke Tests + * ================== + * 5 個關鍵頁面的 Smoke Test — 部署後快速驗證基本功能 + * + * 測試範圍: + * 1. 首頁 (/) — 200 OK,標題包含 AWOOOI + * 2. Dashboard (/dashboard) — 無 JS console 錯誤 + * 3. Incidents (/incidents) — 列表容器存在 + * 4. Approvals (/approvals) — 頁面可存取 + * 5. Omni-Terminal (/terminal) — WebSocket 連線不報錯 + * + * 注意: 需登入才能查看的頁面內容以 test.skip 跳過,僅驗證頁面可存取性。 + * 截圖存至 test-results/screenshots/ 供人工比對。 + * + * @author 首席架構師 (Claude Code) + * @version 1.0.0 + * @date 2026-04-01 (台北時間) + */ + +import { test, expect } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +const BASE_URL = 'https://awoooi.wooo.work' +const SCREENSHOT_DIR = 'test-results/screenshots' + +// 確保截圖目錄存在 +test.beforeAll(() => { + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }) + } +}) + +test.setTimeout(30000) + +// ============================================================================= +// 1. 首頁 — 200 OK + 標題包含 AWOOOI +// ============================================================================= + +test('首頁載入 — HTTP 200 + 標題含 AWOOOI', async ({ page }) => { + const response = await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + + // HTTP 狀態碼 200 + expect(response?.status()).toBe(200) + + // 頁面標題含 AWOOOI (大小寫不限) + const title = await page.title() + expect(title.toLowerCase()).toContain('awoooi') + + // 截圖 + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, '01-home.png'), + fullPage: false, + }) +}) + +// ============================================================================= +// 2. Dashboard — 無 JS console 錯誤 +// ============================================================================= + +test('Dashboard 載入 — 無 JS console 錯誤', async ({ page }) => { + const jsErrors: string[] = [] + + // 收集 JS 錯誤 (排除 Next.js HMR/chunk 載入警告) + page.on('pageerror', (err) => { + jsErrors.push(err.message) + }) + + const response = await page.goto(`${BASE_URL}/dashboard`, { waitUntil: 'domcontentloaded' }) + + // 頁面可存取 (200 或 重導向後的頁面) + expect(response?.status()).toBeLessThan(500) + + // 截圖 + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, '02-dashboard.png'), + fullPage: false, + }) + + // 無嚴重 JS 錯誤 + const criticalErrors = jsErrors.filter( + (e) => + !e.includes('ChunkLoadError') && // Next.js 開發時的 chunk 錯誤可忽略 + !e.includes('Loading chunk') && + !e.includes('Loading CSS chunk'), + ) + expect(criticalErrors).toHaveLength(0) +}) + +// ============================================================================= +// 3. Incidents — 列表容器存在 +// ============================================================================= + +test('Incidents 頁面 — 列表容器存在', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/incidents`, { waitUntil: 'domcontentloaded' }) + + expect(response?.status()).toBeLessThan(500) + + // 截圖 (登入前) + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, '03-incidents.png'), + fullPage: false, + }) + + // 頁面應存在某個容器 (未登入時會是登入頁或列表頁) + // 驗證頁面 body 有內容 (非空白頁) + const bodyText = await page.evaluate(() => document.body.innerText.trim()) + expect(bodyText.length).toBeGreaterThan(0) + + // Note: 列表容器 (data-testid="incidents-list" 或同類) 需登入才可見, + // 此處僅驗證頁面可存取且非空白。 + // 完整列表驗證見 authenticated E2E tests。 +}) + +// ============================================================================= +// 4. Approvals — 頁面可存取 +// ============================================================================= + +test('Approvals 頁面 — 頁面可存取', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/approvals`, { waitUntil: 'domcontentloaded' }) + + expect(response?.status()).toBeLessThan(500) + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, '04-approvals.png'), + fullPage: false, + }) + + // 頁面有內容 + const bodyText = await page.evaluate(() => document.body.innerText.trim()) + expect(bodyText.length).toBeGreaterThan(0) +}) + +// ============================================================================= +// 5. Omni-Terminal — WebSocket 連線不報錯 +// ============================================================================= + +test('Omni-Terminal — 頁面載入且無 WebSocket 連線錯誤', async ({ page }) => { + const wsErrors: string[] = [] + + // 監聽 WebSocket 錯誤 + page.on('pageerror', (err) => { + if (err.message.toLowerCase().includes('websocket')) { + wsErrors.push(err.message) + } + }) + + const response = await page.goto(`${BASE_URL}/terminal`, { waitUntil: 'domcontentloaded' }) + + expect(response?.status()).toBeLessThan(500) + + // 等待短暫時間讓 WS 初始化 + await page.waitForTimeout(2000) + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, '05-terminal.png'), + fullPage: false, + }) + + // 無 WebSocket 錯誤 + expect(wsErrors).toHaveLength(0) +}) diff --git a/docs/adr/ADR-048-sentry-openclaw-ai-triage.md b/docs/adr/ADR-048-sentry-openclaw-ai-triage.md new file mode 100644 index 00000000..1e180a66 --- /dev/null +++ b/docs/adr/ADR-048-sentry-openclaw-ai-triage.md @@ -0,0 +1,154 @@ +# ADR-048: Sentry → OpenClaw AI Triage 自動化 + +**狀態**: 已實作 (Phase 23) +**日期**: 2026-04-01 (台北時區) +**作者**: Claude Code +**關聯**: Phase 10 (Sentry 基礎整合), Phase 21 (ADR-037 異常頻率統計), Phase 22 (ADR-044 Nemotron 協作) + +--- + +## 問題陳述 + +Sentry 持續捕獲後端 Python 與前端 Next.js 錯誤,但缺乏自動化 triage 流程: + +- 工程師需手動查看 Sentry Issue 並判斷嚴重程度 +- 錯誤發生後沒有即時通知渠道(Telegram) +- 無法自動關聯歷史頻率判斷是否為常態錯誤或異常升級 +- 沒有 AI 輔助根因分析,所有診斷依賴人工 + +**目標**:Sentry 新錯誤觸發時,自動完成 triage → AI 分析 → Incident 建立 → Telegram 通知,無需人工介入。 + +--- + +## 決策 + +透過 **Sentry Issue Alert Webhook** 整合 OpenClaw AI Triage 流程。 + +### 選擇 Webhook 而非輪詢的原因 + +| 方案 | 延遲 | 基礎設施成本 | 複雜度 | +|------|------|-------------|--------| +| Webhook (本方案) | 近即時 (<2s) | 無額外組件 | 低 | +| 輪詢 (每分鐘) | 最高 60s | 需 scheduler | 中 | +| Sentry Alerts API | 近即時 | 需 OAuth | 高 | + +--- + +## 架構 + +``` +自架 Sentry (192.168.0.110:9000) + │ Issue Alert Webhook (action=triggered, level=error/fatal) + ▼ +AWOOOI API POST /api/v1/webhooks/sentry/error + │ + ├─ [同步] 去重檢查 (Redis, 10min TTL) + ├─ [同步] Level 過濾 (只處理 error/fatal) + │ + └─ [背景 BackgroundTask] + │ + ├─ 1. AnomalyCounter.record_anomaly() → 頻率統計 + │ (ADR-037: 1h/24h/7d/30d + 升級判斷) + │ + ├─ 2. OpenClawHttpService.analyze_error() + │ Circuit Breaker (ADR-038) + Semaphore + │ 優先 Ollama (本地) → Fallback Claude + │ → ErrorAnalysisResult (root_cause, fix, prevention, confidence) + │ + ├─ 3. ApprovalService.create_approval() + │ 風險等級 = Sentry Level + 頻率升級 + │ → Approval ID + │ + ├─ 4. TelegramGateway.send_approval_card() + │ 格式: 🐛 SENTRY 錯誤告警 + OpenClaw 分析 + [Y/n] + │ + └─ 5. SentryService.post_issue_comment() + AI 分析結果回寫至 Sentry Issue +``` + +### Sentry Cloud (wooo-92.sentry.io) 前端整合 + +前端 Next.js 錯誤透過 Sentry Cloud 捕獲,使用相同 Webhook 格式。 +在 Sentry Cloud 配置 Alert Rule 指向同一端點: +`POST https://awoooi.wooo.work/api/v1/webhooks/sentry/error` + +--- + +## 實作細節 + +### 端點 + +``` +POST /api/v1/webhooks/sentry/error +``` + +### 觸發條件 (Sentry Alert Rule 配置) + +- action: `triggered` +- level: `error` 或 `fatal` + +### 去重策略 + +- Redis key: `sentry_dedup:{issue_id}` +- TTL: 600 秒 (10 分鐘) +- 同一 issue 10 分鐘內不重複處理 + +### 風險等級映射 + +| Sentry Level | 基礎風險 | 頻率升級 (ESCALATE) | 頻率升級 (PERMANENT_FIX) | +|-------------|---------|-------------------|------------------------| +| fatal | CRITICAL | CRITICAL | CRITICAL | +| error | HIGH | HIGH | CRITICAL | +| warning | MEDIUM | HIGH | CRITICAL | + +### Webhook Secret 驗證 + +- Config: `SENTRY_WEBHOOK_SECRET` +- Header: `sentry-hook-signature` (HMAC-SHA256) +- 生產環境未設定 → Fail-Closed 拒絕 +- 開發環境未設定 → 允許跳過 (僅供測試) + +--- + +## 影響評估 + +### 正向影響 + +- **MTTR 縮短**: AI 自動根因分析,工程師無需從 log 推斷 +- **告警品質提升**: 頻率統計避免重複告警噪音 +- **可審計性**: 所有 Sentry Issue 自動建立 Approval 記錄 +- **回饋閉環**: AI 分析結果回寫 Sentry Comment,歷史可查 + +### 風險與緩解 + +| 風險 | 緩解措施 | +|------|---------| +| OpenClaw 不可用 | Circuit Breaker (ADR-038) + 降級:無 AI 分析仍建立 Approval | +| Telegram 傳送失敗 | Exception catch + log,不中斷主流程 | +| Redis 不可用 | check_dedup 失敗 → 預設 True(允許處理,可能重複) | +| Sentry API Token 未設 | Comment 回寫跳過,其他流程不受影響 | + +### 不影響項目 + +- 現有 Alertmanager → AWOOOI → Telegram 流程不變 +- 現有 GitHub Webhook 整合不變 +- Sentry SDK 錯誤捕獲邏輯不變 + +--- + +## 相關檔案 + +- **Router**: `apps/api/src/api/v1/sentry_webhook.py` +- **Service (Sentry API)**: `apps/api/src/services/sentry_service.py` +- **Service (Webhook 業務邏輯)**: `apps/api/src/services/sentry_webhook_service.py` +- **Config**: `apps/api/src/core/config.py` (`SENTRY_WEBHOOK_SECRET`) +- **Circuit Breaker**: `apps/api/src/core/circuit_breaker.py` (ADR-038) + +--- + +## 後續工作 + +- [ ] 在自架 Sentry 配置 Issue Alert Rule 指向 `/webhooks/sentry/error` +- [ ] 在 Sentry Cloud 配置相同 Alert Rule +- [ ] K8s Secret 注入 `SENTRY_WEBHOOK_SECRET` +- [ ] E2E 測試:手動觸發 Sentry Issue → 驗證 Telegram 收到告警 diff --git a/docs/adr/ADR-049-figma-code-connect-design-system.md b/docs/adr/ADR-049-figma-code-connect-design-system.md new file mode 100644 index 00000000..242fb696 --- /dev/null +++ b/docs/adr/ADR-049-figma-code-connect-design-system.md @@ -0,0 +1,163 @@ +# ADR-049: Figma Code Connect 設計系統同步 + +| 項目 | 內容 | +|------|------| +| **狀態** | 📋 已批准,待實作 | +| **日期** | 2026-04-01 | +| **決策者** | 首席架構師 + 統帥 | +| **觸發** | MCP 整合長期計畫 | + +## 背景 + +前端 UI 元件與 Figma 設計稿可能產生漂移(drift):設計師在 Figma 更新了元件樣式或佈局,但 `apps/web/src/components/` 下的 React 元件未同步更新,導致 QA 發現視覺差異時已累積大量技術債。 + +目前缺乏任何機制能夠: +1. 讓設計師在 Figma 中直接看到對應的程式碼元件 +2. 在 Code Review 時確認 UI 實作與設計稿的一致性 +3. 系統化追蹤哪些元件存在 design-code drift + +## 問題 + +| 面向 | 現況 | 目標 | +|------|------|------| +| 設計-代碼映射 | 無正式對應關係 | Figma 元件 ↔ React 元件 1:1 映射 | +| 漂移偵測 | 手動 QA 發現 | 每週自動掃描確認映射狀態 | +| 設計師工作流 | 不知道對應哪支 component | Figma Dev Mode 直接顯示程式碼 | + +## 決策 + +使用 **Figma Code Connect** 建立 `.figma.js` 映射文件,將每個 Figma 元件對應到 React 元件的 import 與 props 結構。 + +定期(每週)使用 Figma MCP 掃描設計稿,確認映射是否需要更新。 + +## 架構 + +### 文件結構 + +``` +apps/web/src/components/ +├── ui/ +│ ├── button/ +│ │ ├── Button.tsx +│ │ └── Button.figma.js ← Code Connect 映射 +│ ├── card/ +│ │ ├── Card.tsx +│ │ └── Card.figma.js +│ └── badge/ +│ ├── Badge.tsx +│ └── Badge.figma.js +├── incident/ +│ ├── IncidentCard.tsx +│ └── IncidentCard.figma.js +└── approval/ + ├── ApprovalCard.tsx + └── ApprovalCard.figma.js +``` + +### Code Connect 映射格式 + +```js +// Button.figma.js +import figma from '@figma/code-connect' +import { Button } from './Button' + +figma.connect(Button, 'https://www.figma.com/design/FILEID?node-id=NODE_ID', { + props: { + variant: figma.enum('Variant', { + primary: 'primary', + secondary: 'secondary', + destructive: 'destructive', + }), + size: figma.enum('Size', { + sm: 'sm', + md: 'md', + lg: 'lg', + }), + label: figma.string('Label'), + disabled: figma.boolean('Disabled'), + }, + example: ({ variant, size, label, disabled }) => ( + + ), +}) +``` + +### 每週掃描流程 + +``` +Claude Code + Figma MCP + → get_design_context (核心元件節點) + → get_code_connect_suggestions + → 比對現有 .figma.js 映射 + → 輸出「drift report」 + → 若有差異 → 建立 GitHub Issue +``` + +## 實作計畫 + +### P1:核心 UI 元件(第 1 週) + +| 元件 | Figma 節點 | React 路徑 | +|------|------------|------------| +| Button | TBD | `components/ui/button/Button.tsx` | +| Card | TBD | `components/ui/card/Card.tsx` | +| Badge | TBD | `components/ui/badge/Badge.tsx` | +| Input | TBD | `components/ui/input/Input.tsx` | +| Modal | TBD | `components/ui/modal/Modal.tsx` | + +**前置條件**: 確認 Figma 文件 URL 與節點 ID(需統帥提供) + +### P2:業務元件(第 2-3 週) + +| 元件 | Figma 節點 | React 路徑 | +|------|------------|------------| +| IncidentCard | TBD | `components/incident/IncidentCard.tsx` | +| ApprovalCard | TBD | `components/approval/ApprovalCard.tsx` | +| AlertBanner | TBD | `components/alert/AlertBanner.tsx` | +| StatusBadge | TBD | `components/status/StatusBadge.tsx` | + +### P3:Storybook 整合(第 4 週,視現況決定) + +- 確認是否已有 Storybook 設置 +- 若已有:在 Story 中引用 Code Connect 映射 +- 若未有:評估導入成本,記入 INSPIRATION_LAB.md 待批准 + +## 工具需求 + +```json +// package.json (apps/web) +{ + "devDependencies": { + "@figma/code-connect": "^1.x" + } +} +``` + +發布指令(需 Figma Personal Access Token): +```bash +npx figma connect publish --token $FIGMA_ACCESS_TOKEN +``` + +## 注意事項 + +1. **Figma URL 需要統帥提供**:在執行 P1 前,需要確認 Figma 設計稿的文件 ID 和各核心元件的節點 ID +2. **Token 管理**:`FIGMA_ACCESS_TOKEN` 需存入 K8s Secrets,不得 hardcode +3. **每週掃描**:由 Claude Code + Figma MCP 執行,不需要新增 CI 步驟 +4. **`.figma.js` 檔案不影響 build**:只在開發環境和 Code Connect 發布時使用 + +## 影響 + +| 面向 | 影響 | +|------|------| +| **設計師** | Figma Dev Mode 可直接看到對應元件的 import 和 props | +| **前端開發** | PR 時有明確參考,減少「我以為設計是這樣」問題 | +| **QA** | 視覺差異有跡可查,drift report 提前預警 | +| **Build 時間** | 無影響,`.figma.js` 不參與 webpack/next build | + +## 相關文件 + +- [feedback_design_system_quickref.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_design_system_quickref.md) +- [feedback_ui_collaboration_protocol.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_ui_collaboration_protocol.md) +- Figma Code Connect 官方文件:https://www.figma.com/developers/code-connect diff --git a/docs/adr/ADR-050-telegram-interactive-incident-v2.md b/docs/adr/ADR-050-telegram-interactive-incident-v2.md new file mode 100644 index 00000000..5d1b4bd9 --- /dev/null +++ b/docs/adr/ADR-050-telegram-interactive-incident-v2.md @@ -0,0 +1,176 @@ +# ADR-050: Telegram 互動式 Incident 管理 2.0 + +| 項目 | 內容 | +|------|------| +| **狀態** | 📋 已批准,待實作 | +| **日期** | 2026-04-01 | +| **決策者** | 首席架構師 + 統帥 | +| **觸發** | MCP 整合長期計畫 | + +## 背景 + +目前 AWOOOI Telegram 告警訊息提供三個 Inline Keyboard 按鈕: + +``` +[✅ 批准 (Y)] [❌ 拒絕 (n)] [🔕 靜默] +``` + +統帥在 Telegram 收到告警時,除了批准/拒絕外,還需要切換到 Web UI 或 CLI 才能: +- 查看 Incident 的完整詳情(metrics、stack trace、診斷結果) +- 重新觸發 OpenClaw 分析(當第一次分析結果可疑時) +- 查詢歷史相似 Incident 的處置結果 + +這增加了認知負擔,也拖慢了 MTTR(Mean Time To Respond)。 + +## 問題 + +| 操作 | 現況 | 痛點 | +|------|------|------| +| 查看詳情 | 須切換至 Web UI | 手機操作不便,流程中斷 | +| 重新診斷 | 須 CLI 觸發 | 無法在 Telegram 中完成閉環 | +| 歷史對比 | 須查詢資料庫 | 完全手動,耗時 | + +## 決策 + +擴充 Telegram Inline Keyboard,加入第二行按鈕: + +``` +[✅ 批准] [❌ 拒絕] [🔕 靜默] +[📋 詳情] [🔄 重診] [📊 歷史] +``` + +在後端新增 `TelegramCallbackService`,處理新按鈕的回調邏輯,並使用 `edit_message` 在原訊息中就地更新(不發送新訊息)。 + +## 架構 + +### 回調處理流程 + +``` +Telegram Inline Button Click + → Bot API callback_query + → AWOOOI API POST /telegram/callback + → TelegramCallbackRouter (apps/api/src/routers/telegram_callback.py) + → TelegramCallbackService (apps/api/src/services/telegram_callback_service.py) + → 根據 action 分派: + "detail" → IncidentRepository.get_by_id() + → 格式化 Markdown 回傳 + "reanalyze" → IncidentService.trigger_reanalysis() + → OpenClaw 重分析排程 + "history" → IncidentRepository.find_similar() + → 格式化歷史對比表格 + → Telegram Bot API edit_message_text() + → 原訊息就地更新(保留按鈕,附加展開內容) +``` + +### Callback Data 格式 + +``` +{action}:{incident_id} + +範例: + detail:INC-2026-0401-001 + reanalyze:INC-2026-0401-001 + history:INC-2026-0401-001 +``` + +### 新 Inline Keyboard 結構 + +```python +# src/services/telegram_notification_service.py +InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="✅ 批准", callback_data=f"approve:{incident_id}"), + InlineKeyboardButton(text="❌ 拒絕", callback_data=f"reject:{incident_id}"), + InlineKeyboardButton(text="🔕 靜默", callback_data=f"silence:{incident_id}"), + ], + [ + InlineKeyboardButton(text="📋 詳情", callback_data=f"detail:{incident_id}"), + InlineKeyboardButton(text="🔄 重診", callback_data=f"reanalyze:{incident_id}"), + InlineKeyboardButton(text="📊 歷史", callback_data=f"history:{incident_id}"), + ], +]) +``` + +### 詳情回覆格式 + +``` +📋 Incident 詳情:INC-2026-0401-001 +━━━━━━━━━━━━━━━━━━ +🏷 類型:CPU_SPIKE +🖥 主機:k3s-node-1 (192.168.0.125) +⏱ 時間:2026-04-01 14:32 +08 +📊 觸發值:CPU 94.3% (閾值 90%) +🤖 OpenClaw 信心:0.87 + +診斷摘要: +過去 30 分鐘 CPU 呈上升趨勢, +與 incident INC-2026-0318-007 模式相符。 +建議:重啟 signal_worker pod。 +``` + +### 歷史對比格式 + +``` +📊 相似 Incident 歷史(近 30 天) +━━━━━━━━━━━━━━━━━━ +| 日期 | 類型 | 處置 | 結果 | +|-------|-----------|-------|------| +| 03-18 | CPU_SPIKE | 批准 | ✅ | +| 03-10 | CPU_SPIKE | 批准 | ✅ | +| 02-28 | CPU_SPIKE | 靜默 | ⚠️ | + +相似度最高:INC-2026-0318-007 (0.94) +``` + +## 實作計畫 + +### P1:骨架建立(Sprint 1) + +- [ ] 新增 `/telegram/callback` endpoint(`src/routers/telegram_callback.py`) +- [ ] 建立 `TelegramCallbackService` 骨架(含 action dispatch) +- [ ] 更新 `telegram_notification_service.py` 的 Inline Keyboard 結構 +- [ ] Webhook 設定確認(Bot API setWebhook 指向新 endpoint) + +**交付條件**:點擊新按鈕不報錯,回傳「功能開發中」佔位訊息 + +### P2:詳情查詢 + 重診觸發(Sprint 2) + +- [ ] `detail` action:呼叫 `IncidentRepository.get_by_id()`,格式化後 `edit_message` +- [ ] `reanalyze` action:呼叫 `IncidentService.trigger_reanalysis()`,回傳「重診已排程」 +- [ ] 防止重複觸發:重診進行中時,按鈕顯示「⏳ 診斷中」並 disable + +### P3:歷史對比分析(Sprint 3) + +- [ ] `history` action:`IncidentRepository.find_similar(incident_id, top_k=5)` +- [ ] 相似度演算法:基於 type + host + time_window 的向量搜尋(若已啟用 pgvector) +- [ ] 格式化歷史表格,含處置結果與信心分數 + +## 技術約束 + +1. **leWOOOgo 積木化**:`TelegramCallbackService` 必須透過 `IncidentRepository` interface 存取資料,禁止 Router 層直接查 DB 或 Redis +2. **edit_message 限制**:Telegram Bot API 限制 edit_message 只能在 48 小時內有效,超時需降級為發送新訊息 +3. **Callback Query 答覆**:所有 callback_query 必須在 5 秒內呼叫 `answerCallbackQuery()`,否則 Telegram 顯示 loading 轉圈 +4. **去重保護**:同一 Incident 的 reanalyze 在 10 分鐘 TTL 內只觸發一次(參考 feedback_telegram_dedup.md) + +## 安全考量 + +- Callback data 中的 `incident_id` 需驗證存在且屬於合法狀態 +- Callback query 的 `from.id` 需與 allowlist 核對(使用現有 Telegram access 控制) +- 禁止在 callback 中執行不可逆操作(如刪除 Incident) + +## 影響 + +| 面向 | 影響 | +|------|------| +| **MTTR** | 預計減少 40%(統帥不需切換視窗) | +| **API** | 新增 1 個 endpoint,無現有 endpoint 變更 | +| **DB** | 新增 `find_similar` query,需確認索引 | +| **Telegram Bot** | 需重新設定 Webhook(一次性操作) | + +## 相關文件 + +- [ADR-035-telegram-alert-chain-enforcement.md](ADR-035-telegram-alert-chain-enforcement.md) +- [ADR-045-telegram-gateway-consolidation.md](ADR-045-telegram-gateway-consolidation.md) +- [feedback_telegram_dedup.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_telegram_dedup.md) +- [feedback_telegram_alert_format.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_telegram_alert_format.md) +- [feedback_lewooogo_modular_enforcement.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_lewooogo_modular_enforcement.md) diff --git a/docs/adr/ADR-051-context7-dependency-advisor.md b/docs/adr/ADR-051-context7-dependency-advisor.md new file mode 100644 index 00000000..01497a68 --- /dev/null +++ b/docs/adr/ADR-051-context7-dependency-advisor.md @@ -0,0 +1,170 @@ +# ADR-051: Context7 依賴升級顧問整合 + +| 項目 | 內容 | +|------|------| +| **狀態** | 📋 已批准,待實作 | +| **日期** | 2026-04-01 | +| **決策者** | 首席架構師 + 統帥 | +| **觸發** | MCP 整合長期計畫 | + +## 背景 + +AWOOOI monorepo 依賴多個快速迭代的框架,手動追蹤升級耗時且容易遺漏重要的安全修補或 Breaking Changes: + +| 套件 | 當前版本 | 最新版本 | 差距 | +|------|----------|----------|------| +| FastAPI | `>=0.115.0` | `0.128.0` | 13 個小版本 | +| Next.js | `14.1.0` | `15.1.11` | 1 個主版本 | +| Pydantic | `>=2.5.0` | v2 最新 | 需確認 | +| @sentry/nextjs | `^10.45.0` | 需確認 | 需確認 | +| lewooogo-brain | 內部套件 | - | 依發布追蹤 | + +**核心問題**: + +1. **安全漏洞**:CVE 修補若不及時,可能已有已知漏洞在生產環境 +2. **技術債累積**:次版本差距越大,升級時 Breaking Changes 越難處理 +3. **文件落後**:框架升級後,Claude Code 若依賴訓練資料,可能給出過時的 API 建議 + +## 決策 + +建立**每月依賴審查流程**,在每月 1 日,由 Claude Code 使用 **Context7 MCP** 查詢各主要依賴的最新版本、Breaking Changes 與官方遷移指南,生成升級評估報告,並建立追蹤 Issue 排入技術債計畫。 + +## 審查流程 + +### 觸發時機 + +- **定期**:每月 1 日(由統帥手動觸發或 Schedule Agent) +- **臨時**:收到 CVE 通知、框架發布重大版本(如 Next.js 16) +- **被動**:CI 出現 deprecation warning 時 + +### 執行步驟 + +``` +Step 1: 使用 Context7 查詢各依賴最新文件 + → resolve-library-id: "fastapi", "nextjs", "pydantic", "sentry" + → query-docs: "latest version", "changelog", "migration guide" + +Step 2: 比對當前版本(讀 apps/api/requirements.txt + apps/web/package.json) + +Step 3: 評估 Breaking Changes 風險 + → 主版本升級 → 🔴 高風險,需完整測試 + → 次版本升級 → 🟡 中風險,需讀 changelog + → Patch 升級 → 🟢 低風險,通常可直接升 + +Step 4: 生成升級評估報告(輸出至 docs/adr/upgrade-report-YYYY-MM.md) + +Step 5: 建立 GitHub Issue(含升級計畫 + 預估工時) + 排入 Phase S+N 技術債看板 +``` + +## 升級優先順序 + +### 🔴 P1:安全修補(立即執行) + +**觸發條件**:任何 CVE CVSS ≥ 7.0,或套件官方發布安全公告 + +**處理流程**: +1. 當日確認修補版本 +2. 本地測試通過後,當日發 PR +3. 直接合入 main,不等月度審查 +4. 同步更新 LOGBOOK.md + +**已知高風險套件**: +- `cryptography` (Python) — 頻繁 CVE +- `next` (Node) — SSR 相關安全漏洞 +- `pydantic` — 資料驗證繞過風險 + +### 🟡 P2:主要功能改善(月度計畫) + +**範例**: + +| 升級 | 主要改善 | 預估工時 | +|------|----------|----------| +| Next.js 14 → 15 | App Router 效能優化、Turbopack 穩定 | 2-4 天 | +| FastAPI 0.115 → 0.128 | dependency injection 改善、OpenAPI 更新 | 0.5 天 | + +**Next.js 15 升級注意事項**(預查): +- `async` Server Components 預設行為變更 +- `cookies()`、`headers()` 改為 async API +- Turbopack 成為預設(需測試 build 輸出) +- 需執行 `npx @next/codemod@latest upgrade` + +### 🟢 P3:小版本 / Patch(批次更新) + +每季度批次更新,使用 `npm update` + `pip install -U` 後執行完整測試套件。 + +## 報告格式 + +```markdown +# 依賴升級報告 2026-04 + +生成時間:2026-04-01 00:00 +08 +工具:Context7 MCP + Claude Code + +## 摘要 + +| 優先級 | 套件數 | 行動 | +|--------|--------|------| +| 🔴 P1 (安全) | 0 | 無 CVE | +| 🟡 P2 (功能) | 2 | 建立 Issue | +| 🟢 P3 (patch) | 5 | 排入季度批次 | + +## P2 升級建議 + +### Next.js 15.1.11 +- 當前版本:14.1.0 +- Breaking Changes:[列出] +- 遷移指南:[Context7 查詢結果] +- 預估工時:2-4 天 +- 建議 Phase:Phase S+3 + +... +``` + +## 技術約束 + +1. **Context7 MCP 使用限制**:每次查詢消耗 token,月度審查批次執行,不頻繁觸發 +2. **升級前必讀**:`feedback_read_comments_first.md` — 涉及 `requirements.txt` 和 `package.json` 的修改 +3. **禁止直接 push**:所有版本升級必須經過 PR + CI 測試,遵守 `feedback_build_from_git_only.md` +4. **Next.js 升級特別注意**:`apps/web/` 的升級需確認 `NEXT_PUBLIC_*` build-time 變數仍正常(`feedback_docker_nextjs_api_url.md`) + +## 當前待處理升級清單 + +| 套件 | 當前 | 目標 | 優先級 | 預計執行 | +|------|------|------|--------|----------| +| FastAPI | `>=0.115.0` | `0.128.0` | 🟡 P2 | Phase S+1 | +| Next.js | `14.1.0` | `15.1.11` | 🟡 P2 | Phase S+2 | +| @sentry/nextjs | `^10.45.0` | 需 Context7 確認 | 🟢 P3 | 季度批次 | +| Pydantic | `>=2.5.0` | 需 Context7 確認 | 🟢 P3 | 季度批次 | + +## 首次執行指令(由 Claude Code 執行) + +```bash +# 1. 確認當前版本 +cat /Users/ogt/awoooi/apps/api/requirements.txt | grep -E "fastapi|pydantic|sentry" +cat /Users/ogt/awoooi/apps/web/package.json | grep -E "next|sentry" + +# 2. 使用 Context7 查詢(Claude Code 手動執行) +# → resolve-library-id: fastapi +# → query-docs: "0.128 changelog breaking changes" +# → 依此類推 + +# 3. 生成報告並建立 Issue +``` + +## 影響 + +| 面向 | 影響 | +|------|------| +| **安全** | P1 CVE 修補時間從「被動發現」縮短為「月內主動處理」 | +| **技術債** | 避免版本差距累積超過 1 個主版本 | +| **CI 穩定性** | 及時升級避免 deprecation warning 轉成 error | +| **開發體驗** | Claude Code 可查最新 API 文件,減少過時建議 | + +## 相關文件 + +- [feedback_stable_mainstream_practices.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_stable_mainstream_practices.md) +- [feedback_build_from_git_only.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_build_from_git_only.md) +- [feedback_docker_nextjs_api_url.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_docker_nextjs_api_url.md) +- [project_phase_r_refactoring.md](~/.claude/projects/-Users-ogt-awoooi/memory/project_phase_r_refactoring.md) +- Context7 官方文件:https://context7.com/docs