diff --git a/.agents/skills/07-tool-integration-expert.md b/.agents/skills/07-tool-integration-expert.md new file mode 100644 index 00000000..bfa06561 --- /dev/null +++ b/.agents/skills/07-tool-integration-expert.md @@ -0,0 +1,374 @@ +# Skill 07: Tool Integration Expert +# MCP Tool 整合專家 + +> **管轄範圍**: MCP Bridge, 外部系統連接, RAG 向量化 +> **觸發條件**: 修改 `plugins/mcp/`, `services/*_tool.py`, 向量資料庫 + +--- + +## 文件資訊 + +| 欄位 | 值 | +|------|-----| +| **版本** | v1.1 | +| **建立日期** | 2026-03-25 23:30 (台北) | +| **建立者** | Claude Code | +| **最後修改** | 2026-03-26 14:20 (台北) | +| **修改者** | Claude Code | + +### 變更紀錄 + +| 版本 | 日期 | 執行者 | 變更內容 | +|------|------|--------|----------| +| v1.1 | 2026-03-26 14:20 | Claude Code | 更新 MCP Tool 狀態 (#79/#80/#81 已完成) | +| v1.0 | 2026-03-25 23:30 | Claude Code | 初始建立 - Phase 13.2 Tool 整合專家 | + +--- + +## 核心職責 + +Phase 13.2 Tool 實作 (P0 最優先): +- #79 SignOz MCP Tool (Trace/Logs/Metrics 查詢) +- #80 Kubernetes MCP Tool (真實 kubectl 執行) +- #81 PostgreSQL MCP Tool (歷史模式分析) +- #84 維運手冊 RAG Tool (Markdown 向量化) + +--- + +## MCP Tool 狀態總覽 + +| Tool | 狀態 | 位置 | 工作項 | +|------|------|------|--------| +| **Kubernetes** | ✅ 真實 | `mcp_bridge.py` | #80 ✅ | +| **Database** | ✅ 真實 | `mcp_bridge.py` | #81 ✅ | +| **SignOz** | ✅ 真實 | `mcp_bridge.py` | #79 ✅ | +| Filesystem | 🟡 Mock | `mcp_bridge.py` | #82 | +| Grafana | ❌ 缺失 | - | #83 | +| 維運手冊 RAG | ❌ 缺失 | - | #84 | + +### 已完成 Tool 功能 + +**SignOz MCP (#79)**: +- `gold_metrics`: RPS, Error Rate, P99 Latency, AI Success Rate +- `trace_url`: 生成 Trace 查詢 URL +- `system_metrics`: 系統層級指標 + +**PostgreSQL MCP (#81)**: +- `list_approvals`: 依狀態/incident 過濾 +- `get_approval`: 取得單筆詳情 +- `list_incidents`: 列出活躍事件 +- `list_timeline`: 時間線事件 + +**Kubernetes MCP (#80)**: +- `kubectl_get`: 整合真實 ActionExecutor +- `kubectl_restart`: Pod/Deployment 重啟 + +--- + +## SignOz API 整合 + +### 端點 + +``` +ClickHouse HTTP API: http://192.168.0.188:8123 +SignOz Query API: http://192.168.0.188:3301/api/v3 +``` + +### 查詢範例 + +```python +async def query_signoz_traces( + service_name: str, + start_ns: int, + end_ns: int, +) -> list[dict]: + """查詢 SignOz Traces + + Args: + service_name: 服務名稱 + start_ns: 起始時間 (nanoseconds) + end_ns: 結束時間 (nanoseconds) + + Warning: + SignOz API 有速率限制,避免高頻查詢 + """ + query = f""" + SELECT timestamp, traceID, serviceName, name, durationNano + FROM signoz_traces.distributed_signoz_index_v3 + WHERE serviceName = '{service_name}' + AND timestamp >= toDateTime64({start_ns}/1e9, 9) + AND timestamp <= toDateTime64({end_ns}/1e9, 9) + ORDER BY timestamp DESC + LIMIT 100 + """ + # 調用 ClickHouse HTTP API... +``` + +### SignOz MCP Tool 介面 + +```python +class SignOzMCPTool: + """SignOz 監控查詢 MCP Tool""" + + async def query_traces( + self, + service_name: str, + start_time: datetime, + end_time: datetime, + limit: int = 100, + ) -> MCPToolResult: + """查詢服務 Traces""" + + async def query_logs( + self, + service_name: str, + level: str | None = None, + keyword: str | None = None, + ) -> MCPToolResult: + """查詢服務 Logs""" + + async def query_metrics( + self, + metric_name: str, + aggregation: str = "avg", + ) -> MCPToolResult: + """查詢 Metrics""" +``` + +--- + +## Kubernetes MCP 實作 + +### 從 Mock 升級為真實執行 + +```python +# ✅ 正確: 真實 kubectl 執行 +async def kubectl_execute( + action: str, + resource: str, + namespace: str, + name: str | None = None, +) -> MCPToolResult: + """執行 kubectl 命令 + + Warning: + 所有命令必須帶 -n namespace + 禁止操作 kube-system, default + """ + # 驗證 namespace 白名單 + if namespace not in ALLOWED_NAMESPACES: + return MCPToolResult(success=False, error="Namespace not allowed") + + cmd = ["kubectl", action, resource, "-n", namespace] + if name: + cmd.append(name) + + result = await asyncio.create_subprocess_exec(*cmd, ...) +``` + +### Namespace 白名單 + +```python +ALLOWED_NAMESPACES = ["awoooi-prod", "awoooi-dev"] + +# ❌ 絕對禁止 +FORBIDDEN_NAMESPACES = ["kube-system", "default", "kube-public"] +``` + +### 危險操作攔截 + +```python +FORBIDDEN_KUBECTL_COMMANDS = [ + "delete namespace", + "delete -A", + "delete --all", + "drain", + "cordon", + "taint", +] +``` + +--- + +## PostgreSQL MCP Tool + +### 查詢歷史模式分析 + +```python +class PostgresMCPTool: + """PostgreSQL 歷史查詢 MCP Tool""" + + async def query_incident_patterns( + self, + alert_name: str, + days: int = 30, + ) -> MCPToolResult: + """查詢告警歷史模式 + + 用途: 分析相同告警的歷史處理方式,提取 Playbook + """ + query = """ + SELECT + alert_name, + COUNT(*) as occurrence_count, + AVG(resolution_time_seconds) as avg_resolution_time, + mode() WITHIN GROUP (ORDER BY resolution_action) as common_action + FROM incident_records + WHERE alert_name = :alert_name + AND created_at > NOW() - INTERVAL ':days days' + GROUP BY alert_name + """ + + async def query_approval_success_rate( + self, + action_type: str, + ) -> MCPToolResult: + """查詢特定操作的審批成功率 + + 用途: 為 Trust Engine 提供歷史數據 + """ +``` + +--- + +## 維運手冊 RAG Tool + +### 向量化流程 + +``` +docs/*.md → 分段 (chunk) → 嵌入 (embedding) → 存入 Vector DB +``` + +### 配置 + +```python +RAG_CONFIG = { + "chunk_size": 500, # 每段字數 + "chunk_overlap": 50, # 重疊字數 + "embedding_model": "bge-m3", # 多語言嵌入 + "vector_db": "redis", # Redis Stack Vector + "index_name": "idx:runbooks", +} +``` + +### 查詢流程 + +```python +async def search_runbook(query: str, top_k: int = 5) -> list[str]: + """搜尋維運手冊相關段落 + + Args: + query: 搜尋關鍵字 (自然語言) + top_k: 返回最相關的 N 個段落 + + Returns: + 相關段落列表 + """ + # 1. 嵌入查詢 + query_embedding = await embed(query) + + # 2. 向量搜尋 + results = await redis_client.ft_search( + "idx:runbooks", + f"*=>[KNN {top_k} @embedding $vec AS score]", + query_params={"vec": query_embedding}, + ) + + return [r.content for r in results] +``` + +### 文檔索引清單 + +```python +RUNBOOK_SOURCES = [ + "docs/operations/*.md", + "docs/troubleshooting/*.md", + "docs/adr/*.md", + ".agents/skills/*.md", +] +``` + +--- + +## Tool 實作鐵律 + +### 1. Privacy Shield 必須套用 + +```python +# ✅ 正確: 所有 Tool 調用經過脫敏 +async def call_tool(params: dict, redaction_mapping: dict): + # Rehydration 還原敏感資料 + params = rehydrator.unredact(params, redaction_mapping) + + # 驗證無殘留標籤 + is_clean, remaining = rehydrator.validate_no_labels(params) + if not is_clean: + raise SecurityError(f"Unrehydrated labels: {remaining}") + +# ❌ 禁止: 直接調用外部系統 +await kubectl_execute(raw_params) # 可能包含 [IP_1] 標籤 +``` + +### 2. 錯誤隔離 + +```python +# ✅ 正確: Tool 失敗不影響主流程 +try: + result = await signoz_tool.query_traces(...) +except SignOzConnectionError: + logger.warning("signoz_unavailable") + return MCPToolResult(success=False, error="SignOz unavailable") + +# ❌ 禁止: 讓 Tool 錯誤傳播 +result = await signoz_tool.query_traces(...) # 可能拋出異常 +``` + +### 3. 超時設定 + +| Tool | 建議超時 | +|------|---------| +| SignOz Query | 30s | +| Kubernetes Get | 10s | +| Kubernetes Delete | 30s | +| PostgreSQL Query | 15s | +| RAG Search | 5s | + +### 4. 審計日誌 + +```python +# 所有 Tool 調用必須記錄 +await audit_log.record( + tool_name="kubernetes", + action="delete_pod", + params={"namespace": "awoooi-prod", "name": "api-xxx"}, + result=result, + user_id=user_id, + timestamp=now_taipei(), +) +``` + +--- + +## Tool 封裝與模組化關係 + +> **統帥澄清 (2026-03-25)**: Tool 封裝 ≠ 模組化,兩者是不同層次 + +``` +Tool 封裝 → 放在 ACTION 積木內 → 遵循模組化原則開發 +``` + +| 維度 | leWOOOgo 模組化 | Tool 封裝 | +|------|-----------------|-----------| +| 層次 | 軟體架構層 | 系統整合層 | +| 範圍 | 六大積木 | 外部系統連接 | +| 關係 | 是 Tool 的**實作基礎** | 是模組化的**應用場景** | + +--- + +## 參考文檔 + +- `apps/api/src/plugins/mcp/mcp_bridge.py`: MCP Bridge 核心 +- `memory/feedback_tool_vs_modular.md`: Tool 與模組化關係 +- `memory/project_phase13_enterprise_aiops.md`: Phase 13 規劃 +- ADR-001: MCP Protocol 採用 +- Phase 13.2: Tool 實作工作項目 (#79-84) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 00000000..48ddf05c --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,169 @@ +/** + * Dependency Cruiser Configuration - Phase 14.2 + * ============================================== + * + * ADR-014: 依賴治理規則 + * + * Layer Model: + * - Layer 0: Pages (app/) - 可引用所有 + * - Layer 1: Features (agent/approval/incident/dashboard) - 禁止互相引用 + * - Layer 2: Shared (shared/layout) - 禁止下行引用 Layer 1 + * - Layer 3: Primitives (ui/lib/stores/hooks) - 純工具層 + * + * @see docs/adr/ADR-014-dependency-governance.md + */ + +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + // ========================================================================= + // Layer 1: Feature Isolation (禁止跨 feature 互相引用) + // ========================================================================= + { + name: "feature-isolation-agent", + comment: "agent 元件禁止引用其他 feature (approval/incident/dashboard)", + severity: "error", + from: { path: "apps/web/src/components/agent" }, + to: { path: "apps/web/src/components/(approval|incident|dashboard)" } + }, + { + name: "feature-isolation-approval", + comment: "approval 元件禁止引用其他 feature (agent/incident/dashboard)", + severity: "error", + from: { path: "apps/web/src/components/approval" }, + to: { path: "apps/web/src/components/(agent|incident|dashboard)" } + }, + { + name: "feature-isolation-incident", + comment: "incident 元件禁止引用其他 feature (agent/approval/dashboard)", + severity: "error", + from: { path: "apps/web/src/components/incident" }, + to: { path: "apps/web/src/components/(agent|approval|dashboard)" } + }, + { + name: "feature-isolation-dashboard", + comment: "dashboard 元件禁止引用其他 feature (agent/approval/incident)", + severity: "error", + from: { path: "apps/web/src/components/dashboard" }, + to: { path: "apps/web/src/components/(agent|approval|incident)" } + }, + + // ========================================================================= + // Layer 2: Shared Isolation (禁止 shared/ui 下行引用 feature) + // ========================================================================= + { + name: "shared-no-feature-import", + comment: "shared 元件禁止引用 feature 層 (agent/approval/incident/dashboard)", + severity: "error", + from: { path: "apps/web/src/components/shared" }, + to: { path: "apps/web/src/components/(agent|approval|incident|dashboard)" } + }, + { + name: "ui-no-feature-import", + comment: "ui 元件禁止引用 feature 層", + severity: "error", + from: { path: "apps/web/src/components/ui" }, + to: { path: "apps/web/src/components/(agent|approval|incident|dashboard|shared)" } + }, + { + name: "layout-no-feature-import", + comment: "layout 元件禁止引用 feature 層", + severity: "error", + from: { path: "apps/web/src/components/layout" }, + to: { path: "apps/web/src/components/(agent|approval|incident|dashboard)" } + }, + + // ========================================================================= + // Components → App 禁止反向引用 + // ========================================================================= + { + name: "components-no-app-import", + comment: "components 禁止引用 app 路由層", + severity: "error", + from: { path: "apps/web/src/components" }, + to: { path: "apps/web/src/app" } + }, + + // ========================================================================= + // 禁止循環依賴 + // ========================================================================= + { + name: "no-circular", + comment: "禁止循環依賴", + severity: "error", + from: {}, + to: { + circular: true + } + }, + + // ========================================================================= + // Hooks/Stores/Lib 不應引用 Components (純工具層) + // ========================================================================= + { + name: "hooks-no-component-import", + comment: "hooks 禁止引用 components (純工具層)", + severity: "warn", + from: { path: "apps/web/src/hooks" }, + to: { path: "apps/web/src/components" } + }, + { + name: "stores-no-component-import", + comment: "stores 禁止引用 components (純工具層)", + severity: "warn", + from: { path: "apps/web/src/stores" }, + to: { path: "apps/web/src/components" } + }, + { + name: "lib-no-component-import", + comment: "lib 禁止引用 components (純工具層)", + severity: "warn", + from: { path: "apps/web/src/lib" }, + to: { path: "apps/web/src/components" } + } + ], + + options: { + doNotFollow: { + path: "node_modules" + }, + + exclude: { + path: [ + "node_modules", + "\\.next", + "\\.turbo", + "dist", + "coverage", + "__tests__", + "\\.test\\.", + "\\.spec\\." + ] + }, + + includeOnly: { + path: "apps/web/src" + }, + + tsPreCompilationDeps: true, + + tsConfig: { + fileName: "apps/web/tsconfig.json" + }, + + enhancedResolveOptions: { + exportsFields: ["exports"], + conditionNames: ["import", "require", "node", "default"], + mainFields: ["main", "types"] + }, + + reporterOptions: { + dot: { + collapsePattern: "node_modules/(@[^/]+/[^/]+|[^/]+)" + }, + text: { + highlightFocused: true + } + } + } +}; diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 8cb07007..365e2a01 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -157,6 +157,11 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: else: logger.info("telegram_polling_disabled", reason="OpenClaw 是唯一 Polling 實例") + # ADR-015: MCP Provider 註冊 (DI 模式) + from src.plugins.mcp.providers import register_all_providers + register_all_providers() + logger.info("mcp_providers_registered") + # Phase 6.5: Telegram 心跳監控 (防止沉默盲點) # - 每 30 分鐘發送心跳,證明告警鏈路正常 # - 超過 2 小時沒訊息則告警 diff --git a/apps/api/src/plugins/mcp/interfaces.py b/apps/api/src/plugins/mcp/interfaces.py new file mode 100644 index 00000000..501fca75 --- /dev/null +++ b/apps/api/src/plugins/mcp/interfaces.py @@ -0,0 +1,173 @@ +""" +MCP Tool Provider Interfaces - ADR-015 模組化架構 +================================================= + +定義 MCP Tool Provider 的抽象介面,確保: +1. Interface 先行 (Contract-First) +2. 模組間透過 Public API 溝通 +3. 可測試性 (易於 Mock) + +@see docs/adr/ADR-015-mcp-modular-architecture.md +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from src.utils.timezone import now_taipei + + +# ============================================================================= +# Data Classes (DTO) +# ============================================================================= + + +@dataclass +class MCPTool: + """MCP 工具定義""" + + name: str + description: str + input_schema: dict[str, Any] + server_name: str + + +@dataclass +class MCPToolResult: + """ + 工具執行結果 + + 符合 ActionResult 介面,可直接用於 HITL 審核流程 + """ + + success: bool + execution_id: str + output: Any | None = None + error: str | None = None + duration: float = 0.0 + timestamp: datetime = field(default_factory=now_taipei) + + def to_dict(self) -> dict: + return { + "success": self.success, + "executionId": self.execution_id, + "output": self.output, + "error": self.error, + "duration": self.duration, + "timestamp": self.timestamp.isoformat(), + } + + +# ============================================================================= +# Abstract Base Classes +# ============================================================================= + + +class MCPToolProvider(ABC): + """ + MCP Tool Provider 抽象介面 + + 所有 MCP Tool 實作必須繼承此類別,確保: + - 統一的工具列表格式 + - 統一的執行介面 + - 統一的結果格式 + + Usage: + class K8sProvider(MCPToolProvider): + @property + def name(self) -> str: + return "kubernetes" + + async def list_tools(self) -> list[MCPTool]: + return [MCPTool(name="kubectl_get", ...)] + + async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult: + if tool_name == "kubectl_get": + return await self._kubectl_get(parameters) + """ + + @property + @abstractmethod + def name(self) -> str: + """ + Provider 名稱 + + 必須唯一,用於 ProviderRegistry 註冊 + 例如: 'kubernetes', 'signoz', 'database' + """ + pass + + @property + def enabled(self) -> bool: + """ + 是否啟用 + + 可覆寫此方法根據環境變數決定是否啟用 + """ + return True + + @abstractmethod + async def list_tools(self) -> list[MCPTool]: + """ + 列出可用工具 + + Returns: + list[MCPTool]: 工具定義列表 + """ + pass + + @abstractmethod + async def execute( + self, + tool_name: str, + parameters: dict[str, Any], + ) -> MCPToolResult: + """ + 執行工具 + + Args: + tool_name: 工具名稱 (必須在 list_tools() 中定義) + parameters: 工具參數 (已經過 Rehydration 還原) + + Returns: + MCPToolResult: 執行結果 + + Raises: + ValueError: 未知的工具名稱 + """ + pass + + async def health_check(self) -> bool: + """ + 健康檢查 + + 可覆寫此方法檢查 Provider 依賴的外部服務是否可用 + """ + return True + + +class RehydrationProvider(ABC): + """ + Rehydration Provider 抽象介面 + + 負責將 Privacy Shield 脫敏標籤還原為真實值 + """ + + @abstractmethod + def unredact( + self, + data: Any, + mapping: dict[str, str], + ) -> Any: + """ + 還原脫敏資料 + + Args: + data: 可能包含脫敏標籤的資料 + mapping: 原始值 → 標籤 的映射表 + + Returns: + 還原後的資料 + """ + pass diff --git a/apps/api/src/plugins/mcp/mcp_bridge.py b/apps/api/src/plugins/mcp/mcp_bridge.py index 5dc81668..cc08ed00 100644 --- a/apps/api/src/plugins/mcp/mcp_bridge.py +++ b/apps/api/src/plugins/mcp/mcp_bridge.py @@ -561,9 +561,25 @@ class MCPBridge: """ HTTP 方式執行工具 - Phase 13.2: 整合真實 K8s Executor (不再是 Mock) + ADR-015 重構: 透過 ProviderRegistry 委派執行 + 不再直接 import services,符合 leWOOOgo 積木化原則 """ # ============================================= + # ADR-015: 使用 Provider Registry (DI 模式) + # ============================================= + from src.plugins.mcp.registry import get_provider + + provider = get_provider(server.name) + if provider: + result = await provider.execute(tool_name, parameters) + if result.success: + return result.output + else: + return {"error": result.error} + + # ============================================= + # Fallback: 舊邏輯 (逐步遷移後刪除) + # ============================================= # Kubernetes: 使用真實 ActionExecutor # ============================================= if server.name == "kubernetes": @@ -859,7 +875,8 @@ class MCPBridge: ) -> Any: """STDIO 方式執行工具 (Mock 實作)""" # Phase 3: Mock 執行 - logger.info(f"[MOCK] STDIO call to {server.endpoint}: {tool_name}({parameters})") + # ⛔ 禁止 logging parameters!(ADR-015 Code Review 修復) + logger.info(f"[MOCK] STDIO call to {server.endpoint}: {tool_name}") mock_responses = { "read_file": f"[Mock] Contents of {parameters.get('path')}", diff --git a/apps/api/src/plugins/mcp/providers/__init__.py b/apps/api/src/plugins/mcp/providers/__init__.py new file mode 100644 index 00000000..0c7554ca --- /dev/null +++ b/apps/api/src/plugins/mcp/providers/__init__.py @@ -0,0 +1,34 @@ +""" +MCP Tool Providers - ADR-015 模組化架構 +====================================== + +每個 Provider 負責一個領域的 MCP 工具: +- K8sProvider: Kubernetes 操作 (kubectl) +- SignOzProvider: 監控指標查詢 +- DatabaseProvider: 資料庫查詢 (Approval/Incident) + +@see docs/adr/ADR-015-mcp-modular-architecture.md +""" + +from src.plugins.mcp.providers.database_provider import DatabaseProvider +from src.plugins.mcp.providers.k8s_provider import K8sProvider +from src.plugins.mcp.providers.signoz_provider import SignOzProvider + +__all__ = [ + "K8sProvider", + "SignOzProvider", + "DatabaseProvider", +] + + +def register_all_providers() -> None: + """ + 註冊所有 Provider 到全域 Registry + + 應在 FastAPI lifespan 中呼叫 + """ + from src.plugins.mcp.registry import register_provider + + register_provider(K8sProvider()) + register_provider(SignOzProvider()) + register_provider(DatabaseProvider()) diff --git a/apps/api/src/plugins/mcp/providers/database_provider.py b/apps/api/src/plugins/mcp/providers/database_provider.py new file mode 100644 index 00000000..92b74084 --- /dev/null +++ b/apps/api/src/plugins/mcp/providers/database_provider.py @@ -0,0 +1,261 @@ +""" +Database MCP Tool Provider - ADR-015 模組化架構 +============================================== + +提供資料庫查詢工具: +- list_approvals: 列出 Approval 請求 +- get_approval: 取得單一 Approval 詳情 +- list_incidents: 列出活躍事件 +- list_timeline: 列出時間線事件 + +透過 DI 注入 Services,不直接 import。 + +@see docs/adr/ADR-015-mcp-modular-architecture.md +""" + +import uuid +from typing import Any +from uuid import UUID + +import structlog + +from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult + +logger = structlog.get_logger(__name__) + + +class DatabaseProvider(MCPToolProvider): + """ + Database MCP Tool Provider + + 封裝所有 Approval/Incident 資料庫查詢操作。 + """ + + def __init__(self) -> None: + self._approval_svc = None + self._incident_svc = None + self._timeline_svc = None + + @property + def name(self) -> str: + return "database" + + def _get_approval_service(self): + """Lazy load approval service""" + if self._approval_svc is None: + from src.services.approval_db import get_approval_service + self._approval_svc = get_approval_service() + return self._approval_svc + + def _get_incident_service(self): + """Lazy load incident service""" + if self._incident_svc is None: + from src.services.incident_service import get_incident_service + self._incident_svc = get_incident_service() + return self._incident_svc + + def _get_timeline_service(self): + """Lazy load timeline service""" + if self._timeline_svc is None: + from src.services.approval_db import get_timeline_service + self._timeline_svc = get_timeline_service() + return self._timeline_svc + + async def list_tools(self) -> list[MCPTool]: + return [ + MCPTool( + name="list_approvals", + description="List approval requests with optional status filter", + input_schema={ + "type": "object", + "properties": { + "status": {"type": "string", "description": "Filter by status: pending, approved, rejected, expired"}, + "limit": {"type": "integer", "description": "Max results (default: 20)"}, + }, + }, + server_name=self.name, + ), + MCPTool( + name="get_approval", + description="Get detailed information about a specific approval", + input_schema={ + "type": "object", + "properties": { + "approval_id": {"type": "string", "description": "Approval UUID"}, + }, + "required": ["approval_id"], + }, + server_name=self.name, + ), + MCPTool( + name="list_incidents", + description="List incidents with optional status filter", + input_schema={ + "type": "object", + "properties": { + "status": {"type": "string", "description": "Filter by status: active, resolved, escalated"}, + "limit": {"type": "integer", "description": "Max results (default: 20)"}, + }, + }, + server_name=self.name, + ), + MCPTool( + name="list_timeline", + description="List timeline events for audit trail", + input_schema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Max results (default: 50)"}, + }, + }, + server_name=self.name, + ), + ] + + async def execute( + self, + tool_name: str, + parameters: dict[str, Any], + ) -> MCPToolResult: + execution_id = str(uuid.uuid4())[:8] + + try: + if tool_name == "list_approvals": + output = await self._list_approvals(parameters) + elif tool_name == "get_approval": + output = await self._get_approval(parameters) + elif tool_name == "list_incidents": + output = await self._list_incidents(parameters) + elif tool_name == "list_timeline": + output = await self._list_timeline(parameters) + else: + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Unknown tool: {tool_name}", + ) + + return MCPToolResult( + success=True, + execution_id=execution_id, + output=output, + ) + + except Exception as e: + logger.exception("database_provider_error", tool=tool_name, error=str(e)) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=str(e), + ) + + async def _list_approvals(self, parameters: dict) -> dict: + from src.models.approval import ApprovalStatus + + approval_svc = self._get_approval_service() + status_str = parameters.get("status") + limit = parameters.get("limit", 20) + + status_filter = None + if status_str: + try: + status_filter = ApprovalStatus(status_str.lower()) + except ValueError: + return {"error": f"Invalid status: {status_str}. Valid: pending, approved, rejected, expired"} + + approvals = await approval_svc.get_all_approvals( + status=status_filter, + limit=limit, + ) + + return { + "count": len(approvals), + "approvals": [ + { + "id": str(a.id), + "action": a.action[:80] if a.action else "", + "status": a.status.value if hasattr(a.status, 'value') else str(a.status), + "risk_level": a.risk_level.value if hasattr(a.risk_level, 'value') else str(a.risk_level), + "signatures": f"{a.current_signatures}/{a.required_signatures}", + "created_at": a.created_at.isoformat() if a.created_at else None, + } + for a in approvals + ], + } + + async def _get_approval(self, parameters: dict) -> dict: + approval_id = parameters.get("approval_id") + if not approval_id: + return {"error": "Missing 'approval_id' parameter"} + + approval_svc = self._get_approval_service() + try: + approval = await approval_svc.get_approval_by_id(UUID(approval_id)) + except ValueError: + return {"error": f"Invalid UUID format: {approval_id}"} + + if not approval: + return {"error": f"Approval not found: {approval_id}"} + + return { + "id": str(approval.id), + "action": approval.action, + "description": approval.description, + "status": approval.status.value if hasattr(approval.status, 'value') else str(approval.status), + "risk_level": approval.risk_level.value if hasattr(approval.risk_level, 'value') else str(approval.risk_level), + "required_signatures": approval.required_signatures, + "current_signatures": approval.current_signatures, + "signatures": [ + {"signer": s.signer_name, "timestamp": s.timestamp.isoformat()} + for s in (approval.signatures or []) + ], + "created_at": approval.created_at.isoformat() if approval.created_at else None, + "resolved_at": approval.resolved_at.isoformat() if approval.resolved_at else None, + } + + async def _list_incidents(self, parameters: dict) -> dict: + incident_svc = self._get_incident_service() + status_filter = parameters.get("status") + limit = parameters.get("limit", 20) + + incidents = await incident_svc.get_active_incidents() + + # Status filter + if status_filter: + incidents = [i for i in incidents if i.status.value == status_filter.lower()] + + incidents = incidents[:limit] + + return { + "count": len(incidents), + "incidents": [ + { + "id": i.incident_id, + "severity": i.severity.value if hasattr(i.severity, 'value') else str(i.severity), + "status": i.status.value if hasattr(i.status, 'value') else str(i.status), + "affected_services": i.affected_services, + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in incidents + ], + } + + async def _list_timeline(self, parameters: dict) -> dict: + timeline_svc = self._get_timeline_service() + limit = parameters.get("limit", 50) + + events = await timeline_svc.get_events(limit=limit) + + return { + "count": len(events), + "events": events, + } + + async def health_check(self) -> bool: + """Check if database services are accessible""" + try: + # Try to get approval service + self._get_approval_service() + return True + except Exception: + return False diff --git a/apps/api/src/plugins/mcp/providers/k8s_provider.py b/apps/api/src/plugins/mcp/providers/k8s_provider.py new file mode 100644 index 00000000..2c43fa72 --- /dev/null +++ b/apps/api/src/plugins/mcp/providers/k8s_provider.py @@ -0,0 +1,231 @@ +""" +Kubernetes MCP Tool Provider - ADR-015 模組化架構 +================================================ + +提供 Kubernetes 操作工具: +- kubectl_get: 查詢資源 +- kubectl_delete: 刪除 Pod +- kubectl_scale: 調整副本數 +- kubectl_restart: 重啟 Deployment + +透過 DI 注入 ActionExecutor,不直接 import services。 + +@see docs/adr/ADR-015-mcp-modular-architecture.md +""" + +import uuid +from typing import Any + +import structlog + +from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult + +logger = structlog.get_logger(__name__) + + +class K8sProvider(MCPToolProvider): + """ + Kubernetes MCP Tool Provider + + 封裝所有 kubectl 操作,透過 ActionExecutor 執行。 + """ + + def __init__(self) -> None: + # Lazy import to avoid circular dependency + self._executor = None + + @property + def name(self) -> str: + return "kubernetes" + + def _get_executor(self): + """Lazy load executor to avoid import at module load time""" + if self._executor is None: + from src.services.executor import get_executor + self._executor = get_executor() + return self._executor + + async def list_tools(self) -> list[MCPTool]: + return [ + MCPTool( + name="kubectl_get", + description="Query Kubernetes resources (pods, deployments, services)", + input_schema={ + "type": "object", + "properties": { + "resource": {"type": "string", "description": "Resource type: pods, deployments, services"}, + "namespace": {"type": "string", "description": "Namespace (default: awoooi-prod)"}, + "name": {"type": "string", "description": "Resource name (optional)"}, + }, + "required": ["resource"], + }, + server_name=self.name, + ), + MCPTool( + name="kubectl_delete", + description="Delete a Pod (with dry-run validation)", + input_schema={ + "type": "object", + "properties": { + "resource": {"type": "string", "description": "Resource type: pod"}, + "name": {"type": "string", "description": "Pod name"}, + "namespace": {"type": "string", "description": "Namespace"}, + }, + "required": ["name"], + }, + server_name=self.name, + ), + MCPTool( + name="kubectl_scale", + description="Scale a Deployment to N replicas", + input_schema={ + "type": "object", + "properties": { + "deployment": {"type": "string", "description": "Deployment name"}, + "replicas": {"type": "integer", "description": "Target replica count"}, + "namespace": {"type": "string", "description": "Namespace"}, + }, + "required": ["deployment", "replicas"], + }, + server_name=self.name, + ), + MCPTool( + name="kubectl_restart", + description="Restart a Deployment (rollout restart)", + input_schema={ + "type": "object", + "properties": { + "deployment": {"type": "string", "description": "Deployment name"}, + "namespace": {"type": "string", "description": "Namespace"}, + }, + "required": ["deployment"], + }, + server_name=self.name, + ), + ] + + async def execute( + self, + tool_name: str, + parameters: dict[str, Any], + ) -> MCPToolResult: + execution_id = str(uuid.uuid4())[:8] + executor = self._get_executor() + + try: + if tool_name == "kubectl_get": + output = await self._kubectl_get(executor, parameters) + elif tool_name == "kubectl_delete": + output = await self._kubectl_delete(executor, parameters) + elif tool_name == "kubectl_scale": + output = await self._kubectl_scale(executor, parameters) + elif tool_name == "kubectl_restart": + output = await self._kubectl_restart(executor, parameters) + else: + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Unknown tool: {tool_name}", + ) + + return MCPToolResult( + success=True, + execution_id=execution_id, + output=output, + ) + + except Exception as e: + logger.exception("k8s_provider_error", tool=tool_name, error=str(e)) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=str(e), + ) + + async def _kubectl_get(self, executor, parameters: dict) -> dict: + namespace = parameters.get("namespace", "awoooi-prod") + resource = parameters.get("resource", "pods") + name = parameters.get("name", "") + + cmd = f"kubectl get {resource} {name} -n {namespace} -o json".strip() + result = await executor.execute_kubectl_command(cmd) + + if result.success and result.k8s_response: + return result.k8s_response.get("stdout", "") + return {"error": result.error} + + async def _kubectl_delete(self, executor, parameters: dict) -> dict: + namespace = parameters.get("namespace", "awoooi-prod") + resource = parameters.get("resource", "pod") + name = parameters.get("name", "") + + if not name: + return {"error": "Missing 'name' parameter"} + + # Dry-run validation + if resource == "pod": + dry_run = await executor.validate_pod_exists(name, namespace) + else: + dry_run = await executor.validate_deployment_exists(name, namespace) + + if not dry_run.passed: + return {"error": dry_run.message, "dry_run": False} + + # Execute deletion + if resource == "pod": + result = await executor.delete_pod(name, namespace) + else: + return {"error": "Direct deployment deletion not supported, use restart"} + + return { + "success": result.success, + "message": result.message, + "duration_ms": result.duration_ms, + } + + async def _kubectl_scale(self, executor, parameters: dict) -> dict: + namespace = parameters.get("namespace", "awoooi-prod") + deployment = parameters.get("deployment", "") + replicas = parameters.get("replicas", 1) + + if not deployment: + return {"error": "Missing 'deployment' parameter"} + + cmd = f"kubectl scale deployment/{deployment} --replicas={replicas} -n {namespace}" + result = await executor.execute_kubectl_command(cmd) + + return { + "success": result.success, + "scaled": result.success, + "replicas": replicas, + "message": result.message, + } + + async def _kubectl_restart(self, executor, parameters: dict) -> dict: + namespace = parameters.get("namespace", "awoooi-prod") + deployment = parameters.get("deployment", "") + + if not deployment: + return {"error": "Missing 'deployment' parameter"} + + dry_run = await executor.validate_deployment_exists(deployment, namespace) + if not dry_run.passed: + return {"error": dry_run.message, "dry_run": False} + + result = await executor.restart_deployment(deployment, namespace) + + return { + "success": result.success, + "restarted": result.success, + "message": result.message, + "duration_ms": result.duration_ms, + } + + async def health_check(self) -> bool: + """Check if kubectl is accessible""" + try: + executor = self._get_executor() + result = await executor.execute_kubectl_command("kubectl version --client") + return result.success + except Exception: + return False diff --git a/apps/api/src/plugins/mcp/providers/signoz_provider.py b/apps/api/src/plugins/mcp/providers/signoz_provider.py new file mode 100644 index 00000000..66043784 --- /dev/null +++ b/apps/api/src/plugins/mcp/providers/signoz_provider.py @@ -0,0 +1,194 @@ +""" +SignOz MCP Tool Provider - ADR-015 模組化架構 +============================================= + +提供 SignOz 監控查詢工具: +- gold_metrics: 取得 Gold Metrics (RPS, Error Rate, P99) +- trace_url: 生成 Trace 查詢 URL +- system_metrics: 取得系統指標 (CPU/Disk) + +透過 DI 注入 SignOzClient,不直接 import services。 + +@see docs/adr/ADR-015-mcp-modular-architecture.md +""" + +import uuid +from typing import Any + +import structlog + +from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult + +logger = structlog.get_logger(__name__) + + +class SignOzProvider(MCPToolProvider): + """ + SignOz MCP Tool Provider + + 封裝所有監控指標查詢操作。 + """ + + def __init__(self) -> None: + self._client = None + + @property + def name(self) -> str: + return "signoz" + + def _get_client(self): + """Lazy load SignOz client""" + if self._client is None: + from src.services.signoz_client import get_signoz_client + self._client = get_signoz_client() + return self._client + + async def list_tools(self) -> list[MCPTool]: + return [ + MCPTool( + name="gold_metrics", + description="Get service Gold Metrics: RPS, Error Rate, P50/P95/P99 Latency", + input_schema={ + "type": "object", + "properties": { + "service_name": {"type": "string", "description": "Service name (e.g., awoooi-api)"}, + "namespace": {"type": "string", "description": "Namespace (default: awoooi-prod)"}, + "time_window_minutes": {"type": "integer", "description": "Time window in minutes (default: 10)"}, + }, + "required": ["service_name"], + }, + server_name=self.name, + ), + MCPTool( + name="trace_url", + description="Generate SignOz trace URL for a service", + input_schema={ + "type": "object", + "properties": { + "service_name": {"type": "string", "description": "Service name"}, + "window_minutes": {"type": "integer", "description": "Time window (default: 5)"}, + }, + "required": ["service_name"], + }, + server_name=self.name, + ), + MCPTool( + name="system_metrics", + description="Get system metrics (CPU, Disk) for a host", + input_schema={ + "type": "object", + "properties": { + "host": {"type": "string", "description": "Host IP (default: 192.168.0.188)"}, + "time_window_minutes": {"type": "integer", "description": "Time window (default: 5)"}, + }, + }, + server_name=self.name, + ), + ] + + async def execute( + self, + tool_name: str, + parameters: dict[str, Any], + ) -> MCPToolResult: + execution_id = str(uuid.uuid4())[:8] + client = self._get_client() + + try: + if tool_name == "gold_metrics": + output = await self._gold_metrics(client, parameters) + elif tool_name == "trace_url": + output = self._trace_url(client, parameters) + elif tool_name == "system_metrics": + output = await self._system_metrics(client, parameters) + else: + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Unknown tool: {tool_name}", + ) + + return MCPToolResult( + success=True, + execution_id=execution_id, + output=output, + ) + + except Exception as e: + logger.exception("signoz_provider_error", tool=tool_name, error=str(e)) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=str(e), + ) + + async def _gold_metrics(self, client, parameters: dict) -> dict: + service_name = parameters.get("service_name", "") + if not service_name: + return {"error": "Missing 'service_name' parameter"} + + namespace = parameters.get("namespace", "awoooi-prod") + time_window = parameters.get("time_window_minutes", 10) + + metrics = await client.get_gold_metrics( + service_name=service_name, + namespace=namespace, + time_window_minutes=time_window, + ) + + return { + "service_name": metrics.service_name, + "namespace": metrics.namespace, + "rps": round(metrics.rps, 2), + "rps_trend": metrics.rps_trend, + "error_rate": round(metrics.error_rate, 2), + "error_count": metrics.error_count, + "total_requests": metrics.total_requests, + "p50_latency_ms": round(metrics.p50_latency_ms, 1), + "p95_latency_ms": round(metrics.p95_latency_ms, 1), + "p99_latency_ms": round(metrics.p99_latency_ms, 1), + "latency_trend": metrics.latency_trend, + "summary": metrics.to_summary(), + } + + def _trace_url(self, client, parameters: dict) -> dict: + service_name = parameters.get("service_name", "") + if not service_name: + return {"error": "Missing 'service_name' parameter"} + + window_minutes = parameters.get("window_minutes", 5) + url = client.generate_trace_url( + service_name=service_name, + window_minutes=window_minutes, + ) + + return { + "service_name": service_name, + "trace_url": url, + "window_minutes": window_minutes, + } + + async def _system_metrics(self, client, parameters: dict) -> dict: + host = parameters.get("host", "192.168.0.188") + time_window = parameters.get("time_window_minutes", 5) + + metrics = await client.get_system_metrics( + _host=host, + time_window_minutes=time_window, + ) + + return { + "host": host, + "cpu": metrics.get("cpu", {}), + "disk": metrics.get("disk", {}), + "time_range": metrics.get("time_range", {}), + } + + async def health_check(self) -> bool: + """Check if SignOz is accessible""" + try: + client = self._get_client() + # Try to get metrics for a test service + return True # SignOz client initialization is the check + except Exception: + return False diff --git a/apps/api/src/plugins/mcp/registry.py b/apps/api/src/plugins/mcp/registry.py new file mode 100644 index 00000000..43196e9a --- /dev/null +++ b/apps/api/src/plugins/mcp/registry.py @@ -0,0 +1,170 @@ +""" +MCP Provider Registry - ADR-015 模組化架構 +========================================== + +Provider 註冊中心,實現依賴注入 (DI) 模式: +1. 統一管理所有 MCP Tool Providers +2. 支援動態註冊/反註冊 +3. 支援健康檢查 + +@see docs/adr/ADR-015-mcp-modular-architecture.md +""" + +import structlog + +from src.plugins.mcp.interfaces import MCPToolProvider + +logger = structlog.get_logger(__name__) + + +class ProviderRegistry: + """ + MCP Tool Provider 註冊中心 + + 使用方式: + # 註冊 + registry = ProviderRegistry() + registry.register(K8sProvider()) + registry.register(SignOzProvider()) + + # 取得 + k8s = registry.get("kubernetes") + await k8s.execute("kubectl_get", {...}) + + # 列出所有 + for provider in registry.all(): + print(provider.name) + """ + + def __init__(self) -> None: + self._providers: dict[str, MCPToolProvider] = {} + + def register(self, provider: MCPToolProvider) -> None: + """ + 註冊 Provider + + Args: + provider: MCPToolProvider 實例 + + Raises: + ValueError: 如果 Provider 名稱已存在 + """ + if provider.name in self._providers: + raise ValueError(f"Provider '{provider.name}' already registered") + + self._providers[provider.name] = provider + logger.info( + "provider_registered", + name=provider.name, + enabled=provider.enabled, + ) + + def unregister(self, name: str) -> bool: + """ + 反註冊 Provider + + Args: + name: Provider 名稱 + + Returns: + bool: 是否成功反註冊 + """ + if name in self._providers: + del self._providers[name] + logger.info("provider_unregistered", name=name) + return True + return False + + def get(self, name: str) -> MCPToolProvider | None: + """ + 取得 Provider + + Args: + name: Provider 名稱 + + Returns: + MCPToolProvider | None: Provider 實例,若不存在則返回 None + """ + provider = self._providers.get(name) + if provider and not provider.enabled: + logger.warning("provider_disabled", name=name) + return None + return provider + + def all(self) -> list[MCPToolProvider]: + """ + 取得所有已啟用的 Providers + + Returns: + list[MCPToolProvider]: 已啟用的 Provider 列表 + """ + return [p for p in self._providers.values() if p.enabled] + + def names(self) -> list[str]: + """ + 取得所有已啟用的 Provider 名稱 + + Returns: + list[str]: Provider 名稱列表 + """ + return [p.name for p in self.all()] + + async def health_check_all(self) -> dict[str, bool]: + """ + 檢查所有 Provider 健康狀態 + + Returns: + dict[str, bool]: {provider_name: is_healthy} + """ + results = {} + for provider in self.all(): + try: + results[provider.name] = await provider.health_check() + except Exception as e: + logger.warning( + "provider_health_check_failed", + name=provider.name, + error=str(e), + ) + results[provider.name] = False + return results + + def __contains__(self, name: str) -> bool: + return name in self._providers + + def __len__(self) -> int: + return len(self._providers) + + +# ============================================================================= +# Global Registry Singleton +# ============================================================================= + +_registry: ProviderRegistry | None = None + + +def get_provider_registry() -> ProviderRegistry: + """ + 取得全域 Provider Registry + + Returns: + ProviderRegistry: 單例實例 + """ + global _registry + if _registry is None: + _registry = ProviderRegistry() + return _registry + + +def register_provider(provider: MCPToolProvider) -> None: + """ + 便捷函數: 註冊 Provider 到全域 Registry + """ + get_provider_registry().register(provider) + + +def get_provider(name: str) -> MCPToolProvider | None: + """ + 便捷函數: 從全域 Registry 取得 Provider + """ + return get_provider_registry().get(name) diff --git a/apps/web/src/components/approval/conversational-view.tsx b/apps/web/src/components/approval/conversational-view.tsx index b098891e..4beaea0d 100644 --- a/apps/web/src/components/approval/conversational-view.tsx +++ b/apps/web/src/components/approval/conversational-view.tsx @@ -196,7 +196,7 @@ export function ConversationalView({ diff --git a/docs/adr/ADR-015-mcp-modular-architecture.md b/docs/adr/ADR-015-mcp-modular-architecture.md new file mode 100644 index 00000000..2f5fc18a --- /dev/null +++ b/docs/adr/ADR-015-mcp-modular-architecture.md @@ -0,0 +1,139 @@ +# ADR-015: MCP 模組化架構重構 + +| 欄位 | 值 | +|------|-----| +| **狀態** | 已批准 | +| **決策日期** | 2026-03-26 | +| **決策者** | 統帥 + 首席架構師 | +| **觸發原因** | Code Review 發現嚴重模組化違規 | + +--- + +## 背景 + +Phase 13.2 實作 MCP Tool 整合時,為了快速交付,在 `mcp_bridge.py` 直接 import services 層,違反了 leWOOOgo 積木化原則。 + +### 違規清單 + +| 位置 | 違規 | +|------|------| +| mcp_bridge.py:570 | `from src.services.executor import get_executor` | +| mcp_bridge.py:655 | `from src.services.signoz_client import get_signoz_client` | +| mcp_bridge.py:734 | `from src.services.approval_db import ...` | +| mcp_bridge.py:738 | `from src.services.incident_service import get_incident_service` | + +### 違反的鐵律 + +1. **Interface 先行** - 無 ABC 定義 +2. **禁止跨模組非法引用** - plugins → services 直接 import +3. **模組間透過 Public API 溝通** - 直接呼叫具體實作 + +--- + +## 決策 + +### 架構重構 + +``` +apps/api/src/plugins/mcp/ +├── __init__.py +├── interfaces.py # MCPToolProvider ABC (新增) +├── mcp_bridge.py # 透過 DI 注入 providers (重構) +├── registry.py # Provider 註冊中心 (新增) +└── providers/ # 具體實作 (新增) + ├── __init__.py + ├── k8s_provider.py + ├── signoz_provider.py + └── database_provider.py +``` + +### Interface 定義 + +```python +from abc import ABC, abstractmethod +from typing import Any + +class MCPToolProvider(ABC): + """MCP Tool Provider 抽象介面""" + + @property + @abstractmethod + def name(self) -> str: + """Provider 名稱 (如 'kubernetes', 'signoz')""" + pass + + @abstractmethod + async def list_tools(self) -> list[dict]: + """列出可用工具""" + pass + + @abstractmethod + async def execute(self, tool_name: str, parameters: dict) -> Any: + """執行工具""" + pass +``` + +### DI 注入模式 + +```python +# registry.py +class ProviderRegistry: + _providers: dict[str, MCPToolProvider] = {} + + @classmethod + def register(cls, provider: MCPToolProvider) -> None: + cls._providers[provider.name] = provider + + @classmethod + def get(cls, name: str) -> MCPToolProvider | None: + return cls._providers.get(name) + +# mcp_bridge.py (重構後) +async def call_tool(self, server_name: str, tool_name: str, parameters: dict): + provider = ProviderRegistry.get(server_name) + if not provider: + raise ValueError(f"Unknown provider: {server_name}") + return await provider.execute(tool_name, parameters) +``` + +--- + +## 優點 + +1. **符合 leWOOOgo 積木化** - Interface 先行,DI 注入 +2. **可測試性** - 可輕鬆 Mock Provider +3. **可擴展性** - 新增 Provider 無需修改 mcp_bridge.py +4. **單一職責** - 每個 Provider 只負責一個領域 + +--- + +## 缺點 + +1. **重構工時** - 需要 2-3 天 +2. **檔案數增加** - 從 1 個變 6+ 個 +3. **學習曲線** - 新成員需了解 DI 模式 + +--- + +## 實作計畫 + +| 步驟 | 內容 | 工時 | +|------|------|------| +| 1 | 建立 interfaces.py | 30min | +| 2 | 建立 registry.py | 30min | +| 3 | 建立 providers/ 目錄 | 15min | +| 4 | 實作 k8s_provider.py | 1h | +| 5 | 實作 signoz_provider.py | 1h | +| 6 | 實作 database_provider.py | 1h | +| 7 | 重構 mcp_bridge.py | 2h | +| 8 | 更新測試 | 1h | +| 9 | Code Review | 1h | +| **總計** | | **8-9h** | + +--- + +## 相關文件 + +- [feedback_modular_architecture.md](../../memory/feedback_modular_architecture.md) +- [feedback_modular_core_spirit.md](../../memory/feedback_modular_core_spirit.md) +- [ADR-003: leWOOOgo 模組化架構](ADR-003-lewooogo-module-architecture.md) diff --git a/package.json b/package.json index 0d327a2b..d90e4755 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@types/node": "^20.11.0", + "dependency-cruiser": "^17.3.9", "prettier": "^3.2.0", "turbo": "^2.0.0", "typescript": "^5.3.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cf902fe..660e44db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/node': specifier: ^20.11.0 version: 20.19.37 + dependency-cruiser: + specifier: ^17.3.9 + version: 17.3.9 prettier: specifier: ^3.2.0 version: 3.8.1 @@ -138,7 +141,7 @@ importers: version: 8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.2))(jiti@1.21.7)(postcss@8.5.8)(typescript@5.9.3) vitest: specifier: ^1.2.0 - version: 1.6.1(@types/node@20.19.37) + version: 1.6.1(@types/node@20.19.37)(terser@5.46.1) packages/tsconfig: {} @@ -1723,11 +1726,18 @@ packages: peerDependencies: acorn: ^8.14.0 + acorn-jsx-walk@2.0.0: + resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -1980,6 +1990,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2109,6 +2123,11 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dependency-cruiser@17.3.9: + resolution: {integrity: sha512-LwaotlB9bZ8zhdFGGYf/g2oYkYj7YNxlqx1btL/XIYGob/aKRArsSwkLKo+ZrHiegsEArQVg4ZQ3NhAh8uk+hg==} + engines: {node: ^20.12||^22||>=24} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2155,6 +2174,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -2496,6 +2519,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -2560,6 +2587,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} @@ -2588,6 +2619,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2596,6 +2631,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + intl-messageformat@11.2.0: resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==} @@ -2658,6 +2697,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -2678,6 +2721,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -2793,6 +2840,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3229,6 +3280,10 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3288,6 +3343,10 @@ packages: react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -3300,6 +3359,10 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -3365,6 +3428,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3424,6 +3490,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3632,9 +3701,17 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3805,6 +3882,11 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + watskeburt@5.0.3: + resolution: {integrity: sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==} + engines: {node: ^20.12||^22.13||>=24.0} + hasBin: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5402,10 +5484,16 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-jsx-walk@2.0.0: {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 + acorn-loose@8.5.2: + dependencies: + acorn: 8.16.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 @@ -5680,6 +5768,8 @@ snapshots: color-name@1.1.4: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -5790,6 +5880,27 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dependency-cruiser@17.3.9: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + acorn-jsx-walk: 2.0.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + commander: 14.0.3 + enhanced-resolve: 5.20.0 + ignore: 7.0.5 + interpret: 3.1.1 + is-installed-globally: 1.0.0 + json5: 2.2.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rechoir: 0.8.0 + safe-regex: 2.1.1 + semver: 7.7.4 + tsconfig-paths-webpack-plugin: 4.2.0 + watskeburt: 5.0.3 + detect-libc@2.1.2: {} didyoumean@1.2.2: {} @@ -5826,6 +5937,11 @@ snapshots: emoji-regex@9.2.2: {} + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -6379,6 +6495,10 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -6440,6 +6560,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + immer@10.2.0: {} immer@11.1.4: {} @@ -6472,6 +6594,8 @@ snapshots: inherits@2.0.4: {} + ini@4.1.1: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6480,6 +6604,8 @@ snapshots: internmap@2.0.3: {} + interpret@3.1.1: {} + intl-messageformat@11.2.0: dependencies: '@formatjs/ecma402-abstract': 3.2.0 @@ -6554,6 +6680,11 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -6567,6 +6698,8 @@ snapshots: is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -6678,6 +6811,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -7076,6 +7211,11 @@ snapshots: progress@2.0.3: {} + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7141,6 +7281,10 @@ snapshots: - '@types/react' - redux + rechoir@0.8.0: + dependencies: + resolve: 1.22.11 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -7158,6 +7302,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regexp-tree@0.1.27: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -7259,6 +7405,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex@2.1.1: + dependencies: + regexp-tree: 0.1.27 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -7334,6 +7484,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} source-map-js@1.2.1: {} @@ -7557,6 +7709,13 @@ snapshots: ts-interface-checker@0.1.13: {} + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.20.1 + tapable: 2.3.2 + tsconfig-paths: 4.2.0 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -7564,6 +7723,12 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + tslib@2.8.1: {} tsup@8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.2))(jiti@1.21.7)(postcss@8.5.8)(typescript@5.9.3): @@ -7727,13 +7892,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@1.6.1(@types/node@20.19.37): + vite-node@1.6.1(@types/node@20.19.37)(terser@5.46.1): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21(@types/node@20.19.37) + vite: 5.4.21(@types/node@20.19.37)(terser@5.46.1) transitivePeerDependencies: - '@types/node' - less @@ -7745,7 +7910,7 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@20.19.37): + vite@5.4.21(@types/node@20.19.37)(terser@5.46.1): dependencies: esbuild: 0.21.5 postcss: 8.5.8 @@ -7753,8 +7918,9 @@ snapshots: optionalDependencies: '@types/node': 20.19.37 fsevents: 2.3.3 + terser: 5.46.1 - vitest@1.6.1(@types/node@20.19.37): + vitest@1.6.1(@types/node@20.19.37)(terser@5.46.1): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -7773,8 +7939,8 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21(@types/node@20.19.37) - vite-node: 1.6.1(@types/node@20.19.37) + vite: 5.4.21(@types/node@20.19.37)(terser@5.46.1) + vite-node: 1.6.1(@types/node@20.19.37)(terser@5.46.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.37 @@ -7793,6 +7959,8 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + watskeburt@5.0.3: {} + webidl-conversions@3.0.1: {} webpack-sources@3.3.4: {}