diff --git a/apps/api/src/services/adr100_remediation_service.py b/apps/api/src/services/adr100_remediation_service.py index 82cf88339..a98094f58 100644 --- a/apps/api/src/services/adr100_remediation_service.py +++ b/apps/api/src/services/adr100_remediation_service.py @@ -65,6 +65,7 @@ class Adr100RemediationService: slo_service: Adr100SloStatusService | None = None, incident_repository: _IncidentRepository | None = None, auto_repair_service: AutoRepairService | None = None, + playbook_service: Any | None = None, verifier: PostExecutionVerifier | None = None, approval_service: Any | None = None, timeline_service: Any | None = None, @@ -74,6 +75,7 @@ class Adr100RemediationService: self._slo_service = slo_service or get_adr100_slo_status_service() self._incident_repository = incident_repository or IncidentDBRepository() self._auto_repair_service = auto_repair_service or AutoRepairService() + self._playbook_service = playbook_service self._verifier = verifier or get_post_execution_verifier() self._approval_service = approval_service self._timeline_service = timeline_service @@ -323,6 +325,7 @@ class Adr100RemediationService: }) post_state = await self._collect_current_state(incident) + replay_gate = await self._build_replay_gate(item, incident) action_taken = f"dry_run_replay:{item.get('playbook_id') or 'unknown'}" result = _assess_recovery(None, post_state, action_taken) @@ -335,12 +338,125 @@ class Adr100RemediationService: extra={ "diagnostic_command_preview": diagnostic_command, "mcp_route": route, + "replay_gate": replay_gate, "promql": _promql_for_incident(incident), }, ) payload["history"] = await self._record_dry_run_history(item, payload) return payload + async def _build_replay_gate( + self, + item: dict[str, Any], + incident: Incident, + ) -> dict[str, Any]: + playbook_id = str(item.get("playbook_id") or "") + base: dict[str, Any] = { + "schema_version": "adr100_replay_gate_v1", + "playbook_id": playbook_id or None, + "playbook_loaded": False, + "status": "blocked_missing_playbook_id", + "execution_authorized": False, + "repair_executed": False, + "writes_incident_state": False, + "writes_auto_repair_result": False, + "can_runtime_replay": False, + "mutating_step_count": 0, + "supported_write_route_count": 0, + "unsupported_step_count": 0, + "approval_required_count": 0, + "next_step": "attach_playbook_to_work_item", + "steps": [], + } + if not playbook_id: + return base + + playbook_svc = self._playbook_service + if playbook_svc is None: + from src.services.playbook_service import get_playbook_service + + playbook_svc = get_playbook_service() + + playbook = await playbook_svc.get_by_id(playbook_id) + if playbook is None: + return { + **base, + "status": "blocked_playbook_not_found", + "next_step": "reload_playbook_catalog_or_author_supported_executor", + } + + steps: list[dict[str, Any]] = [] + mutating_count = 0 + supported_write_count = 0 + unsupported_count = 0 + approval_required_count = 0 + + for step in playbook.repair_steps: + command = str(getattr(step, "command", "") or "") + action_type = _step_action_type(step) + write_route = None + if action_type == "ssh_command": + write_route = self._auto_repair_service.preview_write_ssh_mcp_route( + incident, + command, + ) + read_route = self._auto_repair_service.preview_read_only_ssh_mcp_route( + incident, + command, + ) if action_type == "ssh_command" else None + is_mutating = bool(write_route) or _step_looks_mutating(step) + requires_approval = bool(getattr(step, "requires_approval", False)) + if is_mutating: + mutating_count += 1 + if write_route: + supported_write_count += 1 + else: + unsupported_count += 1 + if requires_approval: + approval_required_count += 1 + + steps.append({ + "step_number": getattr(step, "step_number", None), + "action_type": action_type, + "risk_level": _step_risk_level(step), + "requires_approval": requires_approval, + "is_mutating": is_mutating, + "write_route": write_route, + "read_route": read_route, + "supported": bool(write_route) or not is_mutating, + "command_preview": _compact_command(command), + }) + + if mutating_count == 0: + status = "blocked_observe_only_playbook" + next_step = "author_mutating_repair_step" + can_runtime_replay = False + elif unsupported_count > 0: + status = "blocked_unsupported_write_route" + next_step = "author_supported_executor_step" + can_runtime_replay = False + elif approval_required_count > 0: + status = "approval_required" + next_step = "request_runtime_replay_approval" + can_runtime_replay = False + else: + status = "runtime_replay_ready" + next_step = "queue_runtime_replay_with_gate5_projection" + can_runtime_replay = True + + return { + **base, + "playbook_loaded": True, + "status": status, + "can_runtime_replay": can_runtime_replay, + "mutating_step_count": mutating_count, + "supported_write_route_count": supported_write_count, + "unsupported_step_count": unsupported_count, + "approval_required_count": approval_required_count, + "next_step": next_step, + "steps": steps, + } + async def _dry_run_ticket_proposal( self, item: dict[str, Any], @@ -842,6 +958,48 @@ def _summarize_post_state(post_state: dict[str, Any]) -> dict[str, Any]: } +def _step_action_type(step: Any) -> str: + action_type = getattr(step, "action_type", None) + return str(getattr(action_type, "value", action_type) or "unknown") + + +def _step_risk_level(step: Any) -> str: + risk_level = getattr(step, "risk_level", None) + return str(getattr(risk_level, "value", risk_level) or "unknown").lower() + + +def _step_looks_mutating(step: Any) -> bool: + command = str(getattr(step, "command", "") or "").strip().lower() + action_type = _step_action_type(step) + if not command or action_type == "manual": + return False + if action_type == "script": + return True + if action_type == "kubectl": + return not command.startswith(( + "kubectl get ", + "kubectl describe ", + "kubectl logs ", + "kubectl top ", + "kubectl explain ", + )) + if action_type == "ssh_command": + return any(token in command for token in ( + "docker restart", + "docker start", + "docker stop", + "systemctl restart", + "systemctl start", + "certbot renew", + )) + return False + + +def _compact_command(command: str) -> str: + compact = " ".join(str(command or "").split()) + return compact[:240] + + def _history_context(item: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: return { "schema_version": "adr100_remediation_dry_run_history_v1", @@ -862,6 +1020,7 @@ def _history_context(item: dict[str, Any], payload: dict[str, Any]) -> dict[str, "verification_result_preview": payload.get("verification_result_preview"), "post_state_summary": payload.get("post_state_summary"), "mcp_route": payload.get("mcp_route"), + "replay_gate": payload.get("replay_gate"), "checks": payload.get("checks"), } @@ -938,6 +1097,7 @@ def _history_item(record: Any, context: dict[str, Any]) -> dict[str, Any]: route = context.get("mcp_route") or {} post_state = context.get("post_state_summary") or {} approval = context.get("approval") or {} + replay_gate = context.get("replay_gate") or {} return { "id": str(getattr(record, "id", "")), "incident_id": getattr(record, "incident_id", None), @@ -971,6 +1131,9 @@ def _history_item(record: Any, context: dict[str, Any]) -> dict[str, Any]: "deduplicated": context.get("deduplicated"), "fingerprint": context.get("fingerprint"), "ticket_preview": context.get("ticket_preview"), + "replay_gate": replay_gate or None, + "replay_gate_status": replay_gate.get("status"), + "replay_gate_next_step": replay_gate.get("next_step"), "plan": context.get("plan"), "checks": context.get("checks") or [], } @@ -993,6 +1156,8 @@ def _summarize_history_by_work_item(items: list[dict[str, Any]]) -> list[dict[st "latest_agent_id": item.get("agent_id"), "latest_tool_name": item.get("tool_name"), "required_scope": item.get("required_scope"), + "latest_replay_gate_status": item.get("replay_gate_status"), + "latest_replay_gate_next_step": item.get("replay_gate_next_step"), } summary[key]["count"] += 1 return list(summary.values()) diff --git a/apps/api/src/services/auto_repair_service.py b/apps/api/src/services/auto_repair_service.py index 35dfa9bc0..e6fa6a318 100644 --- a/apps/api/src/services/auto_repair_service.py +++ b/apps/api/src/services/auto_repair_service.py @@ -1237,6 +1237,30 @@ class AutoRepairService: "flywheel_node": "execute", } + def preview_write_ssh_mcp_route( + self, + incident: Incident, + command: str, + ) -> dict[str, Any] | None: + """Preview whether a legacy SSH repair can use the write MCP Gateway. + + This mirrors the executor's narrow Docker restart routing without + projecting a Gate 5 approval or mutating any runtime state. It lets + ADR-100 Work Items show whether ``ready_for_replay`` is actually ready + for a governed write executor. + """ + + route = self._route_legacy_ssh_write_command_to_mcp(incident, command) + if route is None: + return None + return { + "tool_name": route.tool_name, + "params": route.params, + "agent_id": "auto_repair_executor", + "required_scope": route.required_scope, + "flywheel_node": "execute", + } + def _resolve_ssh_host_for_incident(self, incident: Incident, command: str) -> str: """Resolve ``{host}``, short host labels, and exporter instance ports.""" diff --git a/apps/api/tests/test_adr100_remediation_service.py b/apps/api/tests/test_adr100_remediation_service.py index 7741cdb2f..910116d13 100644 --- a/apps/api/tests/test_adr100_remediation_service.py +++ b/apps/api/tests/test_adr100_remediation_service.py @@ -11,7 +11,12 @@ from fastapi.testclient import TestClient from src.api.v1.ai_slo import router from src.models.approval import ApprovalRequest, ApprovalStatus, RiskLevel from src.models.incident import Incident, IncidentStatus, Severity, Signal -from src.models.playbook import Playbook +from src.models.playbook import ( + ActionType, + Playbook, + RepairStep, + RiskLevel as PlaybookRiskLevel, +) from src.services.adr100_remediation_service import ( Adr100RemediationService, RemediationNotFoundError, @@ -152,6 +157,16 @@ class _NoopPlaybookService: return True +class _FakePlaybookService(_NoopPlaybookService): + def __init__(self, playbook: Playbook | None) -> None: + self.playbook = playbook + + async def get_by_id(self, playbook_id: str) -> Playbook | None: + if self.playbook and self.playbook.playbook_id == playbook_id: + return self.playbook + return None + + async def _no_cooldown(*_args, **_kwargs) -> tuple[bool, str]: # noqa: ANN002, ANN003 return True, "test" @@ -201,6 +216,7 @@ def _service( item: dict[str, Any], incident: Incident | None = None, state: dict[str, Any] | None = None, + playbook_service: Any | None = None, approval_service: Any | None = None, timeline_service: Any | None = None, alert_operation_log_repository: Any | None = None, @@ -213,6 +229,7 @@ def _service( playbook_service=_NoopPlaybookService(), cooldown_checker=_no_cooldown, ), + playbook_service=playbook_service, verifier=_FakeVerifier(state or {"k8s_get_pod_status": {"phase": "Running"}}), approval_service=approval_service, timeline_service=timeline_service, @@ -221,6 +238,24 @@ def _service( ) +def _runtime_replay_playbook() -> Playbook: + return Playbook( + playbook_id="PB-1", + name="Docker container restart", + description="Replay Docker restart through governed MCP write route.", + repair_steps=[ + RepairStep( + step_number=1, + action_type=ActionType.SSH_COMMAND, + command="ssh {host} 'docker restart {container}'", + expected_result="container restarted", + requires_approval=False, + risk_level=PlaybookRiskLevel.MEDIUM, + ) + ], + ) + + @pytest.mark.asyncio async def test_preview_marks_replay_work_item_read_only(): svc = _service(item=_queue_item()) @@ -407,6 +442,41 @@ async def test_dry_run_replay_validates_supported_executor_route(): assert result["mcp_route"]["params"]["host"] == "192.168.0.110" assert result["mcp_route"]["params"]["container_name"] == "momo-scheduler" assert result["diagnostic_command_preview"].startswith("ssh 110") + assert result["replay_gate"]["status"] == "blocked_playbook_not_found" + assert result["replay_gate"]["can_runtime_replay"] is False + + +@pytest.mark.asyncio +async def test_dry_run_replay_surfaces_runtime_write_gate_without_executing(): + alert_repo = _FakeAlertOperationLogRepository() + svc = _service( + item=_queue_item(), + playbook_service=_FakePlaybookService(_runtime_replay_playbook()), + alert_operation_log_repository=alert_repo, + record_history=True, + ) + + result = await svc.dry_run("verification:INC-20260514-TEST01:are-1") + + assert result["allowed"] is True + assert result["mode"] == "replay" + assert result["writes_incident_state"] is False + assert result["writes_auto_repair_result"] is False + gate = result["replay_gate"] + assert gate["schema_version"] == "adr100_replay_gate_v1" + assert gate["status"] == "runtime_replay_ready" + assert gate["can_runtime_replay"] is True + assert gate["execution_authorized"] is False + assert gate["repair_executed"] is False + assert gate["mutating_step_count"] == 1 + assert gate["supported_write_route_count"] == 1 + assert gate["unsupported_step_count"] == 0 + assert gate["next_step"] == "queue_runtime_replay_with_gate5_projection" + assert gate["steps"][0]["write_route"]["tool_name"] == "ssh_docker_restart" + assert gate["steps"][0]["write_route"]["required_scope"] == "write" + assert gate["steps"][0]["write_route"]["params"]["host"] == "192.168.0.110" + assert gate["steps"][0]["write_route"]["params"]["container_name"] == "momo-scheduler" + assert alert_repo.calls[0]["context"]["replay_gate"]["status"] == "runtime_replay_ready" @pytest.mark.asyncio diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d8c0e2627..e1ae54bc8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2993,6 +2993,13 @@ "approvalWrite": "已建立審批紀錄", "deduplicated": "已收斂既有審批", "approval": "審批 {id} / {status} / {risk}" + }, + "replayGate": { + "title": "Replay Gate", + "status": "狀態={value}", + "next": "下一步={value}", + "routes": "write route={write} / unsupported={unsupported}", + "auth": "authorized={authorized} / executed={executed}" } }, "callbackTraceRecoveryActions": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index d8c0e2627..e1ae54bc8 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2993,6 +2993,13 @@ "approvalWrite": "已建立審批紀錄", "deduplicated": "已收斂既有審批", "approval": "審批 {id} / {status} / {risk}" + }, + "replayGate": { + "title": "Replay Gate", + "status": "狀態={value}", + "next": "下一步={value}", + "routes": "write route={write} / unsupported={unsupported}", + "auth": "authorized={authorized} / executed={executed}" } }, "callbackTraceRecoveryActions": { diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index bf6c93045..779d42753 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -217,6 +217,17 @@ type RecurrenceWorkItemActionResult = { labels?: string[] | null; body_preview?: string | null; } | null; + replay_gate?: { + status?: string | null; + next_step?: string | null; + can_runtime_replay?: boolean | null; + execution_authorized?: boolean | null; + repair_executed?: boolean | null; + mutating_step_count?: number | null; + supported_write_route_count?: number | null; + unsupported_step_count?: number | null; + approval_required_count?: number | null; + } | null; approval_id?: string | null; approval?: { id?: string | null; @@ -2765,6 +2776,7 @@ function Adr100RemediationQueuePanel({ const result = state?.result ?? null; const ticketPreview = result?.ticket_preview ?? null; const approval = result?.approval ?? null; + const replayGate = result?.replay_gate ?? null; const canCreateApproval = item.remediation_status === "needs_playbook_ticket" || item.remediation_action === "promote_diagnostic_to_repair_playbook"; return ( @@ -2876,6 +2888,23 @@ function Adr100RemediationQueuePanel({

) : null} + {replayGate ? ( +
+

{t("replayGate.title")}

+
+ {t("replayGate.status", { value: replayGate.status ?? "--" })} + {t("replayGate.next", { value: replayGate.next_step ?? "--" })} + {t("replayGate.routes", { + write: replayGate.supported_write_route_count ?? 0, + unsupported: replayGate.unsupported_step_count ?? 0, + })} + {t("replayGate.auth", { + authorized: String(replayGate.execution_authorized ?? false), + executed: String(replayGate.repair_executed ?? false), + })} +
+
+ ) : null} ) : null} diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index c85c4f012..2d53769d8 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,62 @@ +## 2026-06-02|ADR-100 ready-for-replay Replay Gate 可視化 + +**背景**: + +- Production `/api/v1/ai/slo` 目前仍有 ADR-100 補救工作 `8` 筆,其中 `ready_for_replay` 有 `2` 筆:`INC-20260601-1B3388` / `GiteaDown` 與 `INC-20260601-B51DFD` / `DockerContainerUnhealthy`。 +- 既有 Work Items 只能做 read-only dry-run,operator 看得到 `mcp:ssh_diagnose`,但看不到下一步是否已具備 write executor、是否要 approval、是否真的會寫 `auto_repair_executions`。 +- 本階段不直接執行 runtime repair、不更新 incident state、不回填舊 auto-repair result,避免把 replay preflight 誤宣稱為 `verified_success`。 + +**Production 現況驗證(變更前)**: + +- `verification:INC-20260601-1B3388:7d73ac5f-6224-4fa4-8ec8-96622b51f739` dry-run: + - `mode=replay`、`allowed=true`、`executed=true`、`verification_result_preview=degraded` + - `mcp_route=auto_repair_executor/ssh_diagnose/read` + - `writes_incident_state=false`、`writes_auto_repair_result=false` + - history recorded:`alert_operation_id=df52f951-a68c-49e5-844d-72c64ec80999`、`timeline_event_id=583e25be-a9b8-469a-b171-a92d822bef6b` +- `verification:INC-20260601-B51DFD:c9635db3-ec54-405f-a909-7e6371775676` dry-run: + - `mode=replay`、`allowed=true`、`executed=true`、`verification_result_preview=failed` + - `mcp_route=auto_repair_executor/ssh_diagnose/read` + - current-state tools:`prometheus_query`、`prometheus_query_range`、`ssh_diagnose`、`ssh_get_container_status`、`ssh_get_top_processes` + - history recorded:`alert_operation_id=ad847006-d2d2-4d0a-b68c-0c13e09d4927`、`timeline_event_id=882030aa-7b00-45e8-96b5-9d16a5e51640` + +**本次調整**: + +- `apps/api/src/services/auto_repair_service.py`: + - 新增 `preview_write_ssh_mcp_route()`,只預覽 legacy SSH repair step 是否可走 `ssh_docker_restart/write` MCP Gateway。 + - 不投影 Gate 5 approval、不呼叫 MCP、不執行 Docker restart、不寫 DB。 +- `apps/api/src/services/adr100_remediation_service.py`: + - replay dry-run 新增 `replay_gate`,讀取 PlayBook repair steps 並判斷: + - `runtime_replay_ready` + - `approval_required` + - `blocked_playbook_not_found` + - `blocked_observe_only_playbook` + - `blocked_unsupported_write_route` + - `replay_gate` 會寫入 dry-run history context,讓 AwoooP history/API/frontend 可追同一個 gate 判斷。 + - payload 明確保留 `execution_authorized=false`、`repair_executed=false`、`writes_incident_state=false`、`writes_auto_repair_result=false`。 +- `apps/web/src/app/[locale]/awooop/work-items/page.tsx`: + - ADR-100 補救工作結果新增 `Replay Gate` 區塊,顯示 status、next step、write route/unsupported 數量,以及 authorized/executed 狀態。 +- `apps/web/messages/zh-TW.json` / `en.json`: + - 新增 `replayGate` 文案;英文語系維持繁中文案鏡像。 + +**驗證**: + +- `python3 -m py_compile apps/api/src/services/adr100_remediation_service.py apps/api/src/services/auto_repair_service.py apps/api/tests/test_adr100_remediation_service.py` +- `DATABASE_URL=postgresql://test:test@localhost:5432/test PYTHONPATH=apps/api /Users/ogt/.pyenv/shims/pytest apps/api/tests/test_adr100_remediation_service.py -q` → `13 passed` +- `DATABASE_URL=postgresql://test:test@localhost:5432/test PYTHONPATH=apps/api /Users/ogt/.pyenv/shims/pytest apps/api/tests/test_auto_repair_service.py -q` → `29 passed` +- `python3 -m json.tool apps/web/messages/zh-TW.json` / `apps/web/messages/en.json` / `cmp -s` +- `pnpm install --offline --frozen-lockfile`(clean worktree dependency setup only) +- `pnpm --dir apps/web exec tsc --noEmit --tsBuildInfoFile /tmp/awoooi-ready-replay-gate.tsbuildinfo` +- `NEXT_PUBLIC_API_URL=https://awoooi.wooo.work NEXT_PRIVATE_BUILD_WORKER_COUNT=1 pnpm --dir apps/web run build` +- `git diff --check` +- `python3 scripts/security/security-mirror-progress-guard.py --root .` → `SECURITY_MIRROR_PROGRESS_GUARD_OK` + +**目前整體進度(本階段完成後)**: + +- ADR-100 非成功驗證補救工作項:約 `95%`;`ready_for_replay` 已從只看 read-only dry-run,提升到可判斷 write replay gate / blocked reason / next step。 +- Approval / execution 誠實度:約 `95%`;前後端會明確顯示 replay 尚未 authorized/executed,也不寫 incident 或 auto-repair result。 +- 真正 verified auto-repair 成功樣本:仍約 `3-4%`;本階段沒有執行 runtime repair,不能上修這項。 +- 完整 AI 自動化飛輪總進度:維持 `61%`;下一個上調條件是 `ready_for_replay` 進入受控 write execution,並產生 production `verification_result=success` + KM / learning 回寫。 + ## 2026-06-01|IwoooS S4.9 補件草稿詳情層落地 **背景**: