diff --git a/apps/api/migrations/sprint5r_telegram_message_id.sql b/apps/api/migrations/sprint5r_telegram_message_id.sql new file mode 100644 index 00000000..cc4a3d43 --- /dev/null +++ b/apps/api/migrations/sprint5r_telegram_message_id.sql @@ -0,0 +1,10 @@ +-- Sprint 5R: 批准執行閉環修復 — 新增 Telegram 訊息持久化欄位 +-- 2026-04-09 Claude Sonnet 4.6: C1 架構 Review 修復 +-- 用途: 批准卡片發送後記錄 message_id/chat_id,供後續 editMessageReplyMarkup 移除按鈕 + +ALTER TABLE approval_records + ADD COLUMN IF NOT EXISTS telegram_message_id INTEGER, + ADD COLUMN IF NOT EXISTS telegram_chat_id INTEGER; + +COMMENT ON COLUMN approval_records.telegram_message_id IS 'Telegram message_id of approval card, used to remove inline keyboard after decision'; +COMMENT ON COLUMN approval_records.telegram_chat_id IS 'Telegram chat_id where approval card was sent'; diff --git a/apps/api/src/db/base.py b/apps/api/src/db/base.py index 5ada0444..f7e16f11 100644 --- a/apps/api/src/db/base.py +++ b/apps/api/src/db/base.py @@ -164,6 +164,16 @@ async def init_db() -> None: """) ) + # 2026-04-09 Claude Sonnet 4.6: Sprint 5R C1 修復 — 批准執行閉環 Telegram 訊息持久化欄位 + # create_all 不做 ALTER,需手動補欄位 + await conn.execute( + text(""" + ALTER TABLE approval_records + ADD COLUMN IF NOT EXISTS telegram_message_id INTEGER, + ADD COLUMN IF NOT EXISTS telegram_chat_id INTEGER; + """) + ) + async def close_db() -> None: """ diff --git a/apps/api/src/services/openclaw.py b/apps/api/src/services/openclaw.py index d3176469..71849f37 100644 --- a/apps/api/src/services/openclaw.py +++ b/apps/api/src/services/openclaw.py @@ -71,6 +71,53 @@ CONFIDENCE_THRESHOLD_COLLAB = 0.70 # 低於此閾值自動標記為 COLLAB LLMAnalysisResult = OpenClawDecision +# ============================================================================= +# kubectl_command 回填 helper +# 2026-04-09 Claude Sonnet 4.6: I2 架構Review修復 — 補齊所有 tool 類型,消除兩處重複邏輯 (M3) +# ============================================================================= + +def _backfill_kubectl_command(proposal: dict, tools: list) -> None: + """將 AI tool call 結果回填為可執行的 kubectl_command。 + proposal["kubectl_command"] 若已有值則不覆蓋(LLM 直接填的優先)。 + """ + if not tools or proposal.get("kubectl_command"): + return + _t = tools[0] + _tool_name = _t.get("tool", "") + _args = _t.get("args", {}) + _ns = _args.get("namespace", proposal.get("namespace", "awoooi-prod")) + + if _tool_name == "restart_deployment": + _deploy = _args.get("deployment_name", proposal.get("target_resource", "")) + if _deploy: + proposal["kubectl_command"] = f"kubectl rollout restart deployment/{_deploy} -n {_ns}" + elif _tool_name == "delete_pod": + _pod = _args.get("pod_name", "") + if _pod: + proposal["kubectl_command"] = f"kubectl delete pod {_pod} -n {_ns}" + elif _tool_name == "scale_deployment": + _deploy = _args.get("deployment_name", "") + _replicas = _args.get("replicas", 2) + if _deploy: + proposal["kubectl_command"] = f"kubectl scale deployment/{_deploy} --replicas={_replicas} -n {_ns}" + elif _tool_name == "delete_deployment": + _deploy = _args.get("deployment_name", "") + if _deploy: + proposal["kubectl_command"] = f"kubectl delete deployment/{_deploy} -n {_ns}" + elif _tool_name == "drain_node": + _node = _args.get("node_name", "") + if _node: + proposal["kubectl_command"] = f"kubectl drain {_node} --ignore-daemonsets --delete-emptydir-data" + elif _tool_name == "cordon_node": + _node = _args.get("node_name", "") + if _node: + proposal["kubectl_command"] = f"kubectl cordon {_node}" + elif _tool_name == "delete_service": + _svc = _args.get("service_name", "") + if _svc: + proposal["kubectl_command"] = f"kubectl delete service/{_svc} -n {_ns}" + + # ============================================================================= # OpenClaw Service # ============================================================================= @@ -1559,25 +1606,7 @@ Focus on: # 2026-04-09 Claude Sonnet 4.6: 將 Nemotron tool call 回填為 kubectl_command # 根本問題修復:approval_records.action 需要可執行指令才能被 parse_operation_from_action 解析 - _tools = proposal["nemotron_tools"] - if _tools: - _t = _tools[0] - _tool_name = _t.get("tool", "") - _args = _t.get("args", {}) - _ns = _args.get("namespace", proposal.get("namespace", "awoooi-prod")) - if _tool_name == "restart_deployment": - _deploy = _args.get("deployment_name", proposal.get("target_resource", "")) - if _deploy: - proposal["kubectl_command"] = f"kubectl rollout restart deployment/{_deploy} -n {_ns}" - elif _tool_name == "delete_pod": - _pod = _args.get("pod_name", "") - if _pod: - proposal["kubectl_command"] = f"kubectl delete pod {_pod} -n {_ns}" - elif _tool_name == "scale_deployment": - _deploy = _args.get("deployment_name", "") - _replicas = _args.get("replicas", 2) - if _deploy: - proposal["kubectl_command"] = f"kubectl scale deployment/{_deploy} --replicas={_replicas} -n {_ns}" + _backfill_kubectl_command(proposal, proposal["nemotron_tools"]) logger.info( "nemotron_collaboration_complete", @@ -1628,25 +1657,7 @@ Focus on: proposal["nemotron_tool_backend"] = "Gemini 雲端" # 2026-04-09 Claude Sonnet 4.6: Gemini fallback 同樣回填 kubectl_command - _fb_tools = proposal["nemotron_tools"] - if _fb_tools: - _t = _fb_tools[0] - _tool_name = _t.get("tool", "") - _args = _t.get("args", {}) - _ns = _args.get("namespace", proposal.get("namespace", "awoooi-prod")) - if _tool_name == "restart_deployment": - _deploy = _args.get("deployment_name", proposal.get("target_resource", "")) - if _deploy: - proposal["kubectl_command"] = f"kubectl rollout restart deployment/{_deploy} -n {_ns}" - elif _tool_name == "delete_pod": - _pod = _args.get("pod_name", "") - if _pod: - proposal["kubectl_command"] = f"kubectl delete pod {_pod} -n {_ns}" - elif _tool_name == "scale_deployment": - _deploy = _args.get("deployment_name", "") - _replicas = _args.get("replicas", 2) - if _deploy: - proposal["kubectl_command"] = f"kubectl scale deployment/{_deploy} --replicas={_replicas} -n {_ns}" + _backfill_kubectl_command(proposal, proposal["nemotron_tools"]) return proposal, provider, True diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 7275b22c..2932af75 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -3795,8 +3795,9 @@ class TelegramGateway: "reply_markup": {"inline_keyboard": info_buttons}, }, ) - except Exception: - pass + except Exception as _e: + # 2026-04-09 Claude Sonnet 4.6: I3 架構Review修復 — 加 warning 防止靜默失敗 + logger.warning("notify_approval_edit_keyboard_failed", incident_id=incident_id, error=str(_e)) try: # 2. 在原訊息下回覆狀態 @@ -3810,14 +3811,14 @@ class TelegramGateway: }, ) return - except Exception: - pass + except Exception as _e: + logger.warning("notify_approval_reply_failed", incident_id=incident_id, error=str(_e)) # fallback: 發新通知 try: await self.send_notification(status_line, parse_mode="HTML") - except Exception: - pass + except Exception as _e: + logger.warning("notify_approval_fallback_failed", incident_id=incident_id, error=str(_e)) async def _execute_approval_action( self, diff --git a/apps/web/src/components/panels/CompliancePanel.tsx b/apps/web/src/components/panels/CompliancePanel.tsx index 0d1a6ff5..7333fa77 100644 --- a/apps/web/src/components/panels/CompliancePanel.tsx +++ b/apps/web/src/components/panels/CompliancePanel.tsx @@ -41,6 +41,7 @@ export function CompliancePanel() { if (s) setSummary(s) if (r) setRepairStats(r) }) + .catch(() => setError('load_failed')) .finally(() => setLoading(false)) }, []) diff --git a/apps/web/src/components/panels/SecurityPanel.tsx b/apps/web/src/components/panels/SecurityPanel.tsx index b1fe7cb8..ee8469bd 100644 --- a/apps/web/src/components/panels/SecurityPanel.tsx +++ b/apps/web/src/components/panels/SecurityPanel.tsx @@ -48,6 +48,7 @@ export function SecurityPanel() { if (statsData) setStats(statsData) setIssues(listData?.issues ?? []) }) + .catch(() => setError('load_failed')) .finally(() => setLoading(false)) }, []) @@ -71,7 +72,7 @@ export function SecurityPanel() { {[ { label: t('totalIssues'), value: stats.total_issues, color: '#141413' }, { label: t('criticalIssues'), value: stats.critical_count, color: stats.critical_count > 0 ? '#cc2200' : '#22C55E' }, - { label: 'Unresolved', value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' }, + { label: t('unresolvedIssues'), value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' }, { label: t('errorRate'), value: stats.error_count_24h != null ? `${stats.error_count_24h}/24h` : '—', color: '#141413' }, ].map(card => (
diff --git a/apps/web/src/components/shared/pending-approvals-card.tsx b/apps/web/src/components/shared/pending-approvals-card.tsx index 674b446f..bcc55cf7 100644 --- a/apps/web/src/components/shared/pending-approvals-card.tsx +++ b/apps/web/src/components/shared/pending-approvals-card.tsx @@ -35,6 +35,7 @@ export function PendingApprovalsCard() { const locale = useLocale() const [approvals, setApprovals] = useState([]) const [actionError, setActionError] = useState(null) + const [actioningId, setActioningId] = useState(null) useEffect(() => { fetch(`${API_BASE}/api/v1/approvals/pending`) @@ -78,24 +79,30 @@ export function PendingApprovalsCard() {
+ style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#22C55E', color: '#fff', opacity: actioningId === ap.id ? 0.6 : 1 }} + >{actioningId === ap.id ? '...' : t('approve')}