diff --git a/apps/api/src/services/repair_candidate_service.py b/apps/api/src/services/repair_candidate_service.py index 317348616..d6de5fc6a 100644 --- a/apps/api/src/services/repair_candidate_service.py +++ b/apps/api/src/services/repair_candidate_service.py @@ -350,7 +350,13 @@ class RepairCandidateService: namespace=namespace, ) metadata["playbook_draft_required"] = True - metadata["repair_candidate_next_step"] = draft_package["next_step"] + metadata["repair_candidate_prefilled_draft_summary"] = self._draft_summary_for_operator( + draft_package.get("playbook_draft_template") or {} + ) + metadata["repair_candidate_next_step"] = self._next_step_with_draft_summary( + str(draft_package["next_step"]), + str(metadata["repair_candidate_prefilled_draft_summary"]), + ) metadata["repair_candidate_draft_package"] = draft_package metadata["fallback_action"] = fallback_action return RepairCandidateResult( @@ -487,6 +493,16 @@ class RepairCandidateService: evidence=evidence, playbook=playbook, ) + playbook_draft_template = self._build_playbook_draft_template( + coverage_gap=coverage_gap, + blockers=blockers, + lane=lane, + alertname=alertname, + target_resource=target_resource, + namespace=namespace, + evidence=evidence, + playbook=playbook, + ) incident_id = getattr(incident, "incident_id", "") if incident else "" project_id = str(getattr(incident, "project_id", "awoooi") or "awoooi") work_item: dict[str, Any] | None = None @@ -517,6 +533,7 @@ class RepairCandidateService: "next_step": next_step, "required_fields": required_fields, "coverage_gap": coverage_gap, + "playbook_draft_template": playbook_draft_template, "blocked_operations": blocked_operations, "target_href": f"/awooop/runs?{run_query}", "work_item_href": f"/awooop/work-items?{work_item_query}", @@ -542,12 +559,128 @@ class RepairCandidateService: "evidence_snapshot_id": evidence_ref, "required_fields": required_fields, "coverage_gap": coverage_gap, + "playbook_draft_template": playbook_draft_template, "blocked_operations": blocked_operations, } if work_item: package["awooop_work_item"] = work_item return package + def _draft_summary_for_operator(self, template: dict[str, Any]) -> str: + route = str(template.get("suggested_route") or "--") + repair = str(template.get("repair_command_template") or "--") + verifier = template.get("verifier_plan_template") or [] + verifier_head = "--" + if isinstance(verifier, list) and verifier: + verifier_head = str(verifier[0]) + return f"route={route}; repair_template={repair}; verifier={verifier_head}" + + def _next_step_with_draft_summary(self, next_step: str, draft_summary: str) -> str: + if not draft_summary or draft_summary == "route=--; repair_template=--; verifier=--": + return next_step + return f"{next_step} 草案已預填:{draft_summary}" + + def _build_playbook_draft_template( + self, + *, + coverage_gap: dict[str, Any], + blockers: list[str], + lane: str, + alertname: str, + target_resource: str, + namespace: str, + evidence: EvidenceSnapshot | None, + playbook: Playbook | None, + ) -> dict[str, Any]: + """Prefill a service-specific PlayBook draft without authorizing execution.""" + + target_kind = str(coverage_gap.get("target_kind") or "unknown") + target = (target_resource or "").strip() + ns = (namespace or "").strip() + command_template = "owner_supplied_service_specific_command_after_evidence_review" + rollback_template = "owner_supplied_rollback_or_safe_stop_after_evidence_review" + route = "owner_review_required" + verifier_plan = [ + "rerun_mcp_evidence_collection", + "confirm_fingerprint_recurrence_stops_or_decreases", + "write_execution_result_and_verifier_outcome", + "update_km_and_playbook_trust_after_owner_review", + ] + + if target_kind == "k8s_workload": + workload = target.replace("deployment/", "").replace("pod/", "").replace("svc/", "") + workload_ref = workload or "" + namespace_ref = ns or "" + command_template = ( + f"kubectl rollout restart deployment/{workload_ref} -n {namespace_ref}" + ) + rollback_template = ( + f"kubectl rollout undo deployment/{workload_ref} -n {namespace_ref}" + ) + route = "k8s_rollout_after_owner_review" + verifier_plan.extend([ + f"kubectl rollout status deployment/{workload_ref} -n {namespace_ref}", + f"kubectl get events -n {namespace_ref} --field-selector involvedObject.name={workload_ref}", + ]) + elif target_kind == "host_service": + service_ref = target or "" + command_template = f"systemctl restart {service_ref}" + rollback_template = f"systemctl status {service_ref}; journalctl -u {service_ref} -n 120" + route = "host_service_route_after_owner_review" + verifier_plan.extend([ + f"systemctl is-active {service_ref}", + f"journalctl -u {service_ref} -n 120 --no-pager", + ]) + elif target_kind == "service": + service_ref = target or "" + command_template = f"service-specific repair for {service_ref}" + rollback_template = f"service-specific rollback for {service_ref}" + route = "service_specific_route_after_owner_review" + verifier_plan.append(f"confirm service health for {service_ref}") + + owner_review_checklist = [ + "確認 alertname 與 target selector 沒有誤配", + "確認 MCP evidence refs 足以支持修復假設", + "確認 repair command 不是純診斷命令或通用兜底命令", + "確認 rollback command 能在失敗時安全收斂", + "確認 verifier plan 能判斷成功、失敗與是否升級人工", + "確認 blast radius、maintenance window 與 owner 已填寫", + "確認 runtime_execution_authorized 仍為 false,直到正式 approval gate 通過", + ] + + return { + "schema_version": "service_specific_playbook_draft_template_v1", + "status": "prefilled_owner_review_required", + "lane": lane, + "coverage_key": coverage_gap.get("coverage_key"), + "blocking_stage": coverage_gap.get("blocking_stage"), + "blockers": list(dict.fromkeys(blockers)), + "alert_selector": { + "alertname": alertname or None, + "namespace": ns or None, + "target_resource": target or None, + "target_kind": target_kind, + }, + "mcp_evidence_refs": coverage_gap.get("required_mcp_evidence_refs") or [], + "matched_playbook": { + "playbook_id": playbook.playbook_id if playbook else None, + "name": playbook.name if playbook else None, + "status": playbook.status.value if playbook else None, + "trust_score": float(playbook.trust_score) if playbook else None, + }, + "suggested_route": route, + "repair_command_template": command_template, + "rollback_command_template": rollback_template, + "verifier_plan_template": list(dict.fromkeys(verifier_plan)), + "owner_review_checklist": owner_review_checklist, + "evidence_snapshot_id": evidence.snapshot_id if evidence else None, + "template_is_executable": False, + "approval_required_before_execution": True, + "runtime_execution_authorized": False, + "writes_runtime_state": False, + "telegram_send_authorized": False, + } + def _build_coverage_gap( self, *, diff --git a/apps/api/tests/test_repair_candidate_service.py b/apps/api/tests/test_repair_candidate_service.py index 48ea11c53..4b3805e15 100644 --- a/apps/api/tests/test_repair_candidate_service.py +++ b/apps/api/tests/test_repair_candidate_service.py @@ -228,6 +228,11 @@ async def test_candidate_blocked_when_playbook_is_generic_fallback() -> None: "create_service_specific_repair_playbook" ) assert "建立專屬 PlayBook 草案" in result.metadata["repair_candidate_next_step"] + assert "草案已預填" in result.metadata["repair_candidate_next_step"] + assert "route=k8s_rollout_after_owner_review" in result.metadata["repair_candidate_next_step"] + assert "repair_template=kubectl rollout restart deployment/awoooi-api -n awoooi-prod" in ( + result.metadata["repair_candidate_next_step"] + ) assert "repair_command" in result.metadata["repair_candidate_draft_package"]["required_fields"] coverage_gap = result.metadata["repair_candidate_draft_package"]["coverage_gap"] assert coverage_gap["schema_version"] == "repair_candidate_coverage_gap_v1" @@ -238,6 +243,21 @@ async def test_candidate_blocked_when_playbook_is_generic_fallback() -> None: assert coverage_gap["runtime_execution_authorized"] is False assert "recurrence_fingerprint" in coverage_gap["required_mcp_evidence_refs"] assert "repair_steps.command_or_ansible_ref" in coverage_gap["playbook_template_fields"] + draft_template = result.metadata["repair_candidate_draft_package"]["playbook_draft_template"] + assert draft_template["schema_version"] == "service_specific_playbook_draft_template_v1" + assert draft_template["status"] == "prefilled_owner_review_required" + assert draft_template["coverage_key"] == "unknownalert:awoooi-api" + assert draft_template["suggested_route"] == "k8s_rollout_after_owner_review" + assert draft_template["repair_command_template"] == ( + "kubectl rollout restart deployment/awoooi-api -n awoooi-prod" + ) + assert draft_template["rollback_command_template"] == ( + "kubectl rollout undo deployment/awoooi-api -n awoooi-prod" + ) + assert draft_template["template_is_executable"] is False + assert draft_template["approval_required_before_execution"] is True + assert draft_template["runtime_execution_authorized"] is False + assert "確認 MCP evidence refs 足以支持修復假設" in draft_template["owner_review_checklist"] work_item = result.metadata["repair_candidate_draft_package"]["awooop_work_item"] assert work_item["schema_version"] == "awooop_repair_candidate_draft_work_item_v1" assert work_item["work_item_id"].startswith( @@ -248,6 +268,10 @@ async def test_candidate_blocked_when_playbook_is_generic_fallback() -> None: assert work_item["decision_effect"] == "none" assert work_item["writes_runtime_state"] is False assert work_item["coverage_gap"]["coverage_key"] == "unknownalert:awoooi-api" + assert work_item["playbook_draft_template"]["repair_command_template"] == ( + "kubectl rollout restart deployment/awoooi-api -n awoooi-prod" + ) + assert work_item["playbook_draft_template"]["template_is_executable"] is False assert "/awooop/work-items?" in work_item["work_item_href"] assert "https://awoooi.wooo.work/zh-TW/awooop/work-items?" in work_item["work_item_url"] @@ -295,6 +319,14 @@ async def test_candidate_blocked_observe_only_prompts_repair_playbook_draft() -> assert coverage_gap["matched_playbook_id"] == "PB-REPAIR-001" assert "systemd_or_container_status" in coverage_gap["required_mcp_evidence_refs"] assert "診斷命令保留為 MCP evidence collector" in result.metadata["repair_candidate_next_step"] + assert "route=host_service_route_after_owner_review" in ( + result.metadata["repair_candidate_next_step"] + ) + draft_template = result.metadata["repair_candidate_draft_package"]["playbook_draft_template"] + assert draft_template["suggested_route"] == "host_service_route_after_owner_review" + assert draft_template["repair_command_template"] == "systemctl restart node-exporter-188" + assert draft_template["template_is_executable"] is False + assert draft_template["runtime_execution_authorized"] is False work_item = result.metadata["repair_candidate_draft_package"]["awooop_work_item"] assert work_item["target_resource"] == "node-exporter-188" assert work_item["lane"] == "promote_diagnostic_to_repair_playbook"