diff --git a/apps/api/src/api/v1/drift.py b/apps/api/src/api/v1/drift.py index 7563b72f..aa973bbe 100644 --- a/apps/api/src/api/v1/drift.py +++ b/apps/api/src/api/v1/drift.py @@ -57,6 +57,28 @@ class DriftFingerprintHandoffRequest(BaseModel): note: str | None = Field(default=None, max_length=500) +class DriftFingerprintRemediationRequest(BaseModel): + """Record-only remediation request for a stable drift fingerprint.""" + + report_id: str | None = Field(default=None, min_length=1) + namespace: str | None = Field(default="awoooi-prod", min_length=1) + remediation_kind: Literal[ + "live_env_rollback", + "git_adopted", + "git_rollback", + "zero_diff_pr_cleanup", + "manual_noop", + ] = "live_env_rollback" + remediation_status: Literal[ + "executed_unverified", + "verified_no_drift", + "verification_failed", + ] | None = None + verification_report_id: str | None = Field(default=None, min_length=1) + note: str | None = Field(default=None, max_length=1000) + commands_summary: list[str] = Field(default_factory=list, max_length=12) + + @router.post("/scan", response_model=DriftScanResponse, summary="觸發漂移掃描") async def trigger_drift_scan( request: DriftScanRequest, @@ -160,6 +182,31 @@ async def record_drift_fingerprint_handoff( raise HTTPException(status_code=404, detail="drift_report_not_found") from exc +@router.post("/fingerprints/remediation", summary="記錄 Config Drift fingerprint 修復") +async def record_drift_fingerprint_remediation( + request: DriftFingerprintRemediationRequest, +) -> dict: + """ + 記錄 stable fingerprint 已完成的修復 / 驗證證據。 + + 安全邊界:只寫 alert_operation_log / timeline_events,不修改 drift 狀態、 + incident 狀態、自動修復結果,不建立外部 ticket,也不執行 kubectl。 + """ + svc = get_drift_fingerprint_state_service() + try: + return await svc.record_remediation( + report_id=request.report_id, + namespace=request.namespace, + remediation_kind=request.remediation_kind, + remediation_status=request.remediation_status, + verification_report_id=request.verification_report_id, + note=request.note, + commands_summary=request.commands_summary, + ) + except DriftFingerprintStateNotFoundError as exc: + raise HTTPException(status_code=404, detail="drift_report_not_found") from exc + + @router.post("/reports/{report_id}/rollback", summary="覆蓋回 Git 狀態") async def rollback_drift(report_id: str, _csrf_token: CSRFToken) -> dict: # Phase 20: CSRF Protection (驗證用,不需要使用值) """ diff --git a/apps/api/src/services/drift_fingerprint_state_service.py b/apps/api/src/services/drift_fingerprint_state_service.py index de99bcd1..231a4e40 100644 --- a/apps/api/src/services/drift_fingerprint_state_service.py +++ b/apps/api/src/services/drift_fingerprint_state_service.py @@ -2,9 +2,9 @@ The drift scanner creates a new report_id on every scan, so the operator-facing state must be keyed by the stable drift fingerprint instead of the volatile -report id. This service is intentionally conservative: it can record a human -handoff breadcrumb, but it does not adopt, merge, roll back, or mutate incident -state. +report id. This service is intentionally conservative: it can record human +handoff and remediation breadcrumbs, but it does not adopt, merge, roll back, +or mutate incident state. """ from __future__ import annotations @@ -27,6 +27,7 @@ logger = structlog.get_logger(__name__) SCHEMA_VERSION = "drift_fingerprint_state_v1" HANDOFF_SCHEMA_VERSION = "drift_fingerprint_handoff_history_v1" +REMEDIATION_SCHEMA_VERSION = "drift_fingerprint_remediation_history_v1" SERVICE_ACTOR = "drift_fingerprint_state_service" DEFAULT_NAMESPACE = "awoooi-prod" @@ -36,6 +37,20 @@ DriftFingerprintHandoffKind = Literal[ "zero_diff_pr_cleanup", ] +DriftFingerprintRemediationKind = Literal[ + "live_env_rollback", + "git_adopted", + "git_rollback", + "zero_diff_pr_cleanup", + "manual_noop", +] + +DriftFingerprintRemediationStatus = Literal[ + "executed_unverified", + "verified_no_drift", + "verification_failed", +] + class DriftFingerprintStateNotFoundError(Exception): """Raised when no drift report can be found for the requested selector.""" @@ -87,8 +102,19 @@ def _derive_fsm_state( repeat_state: dict[str, Any], open_pr: dict[str, Any] | None, latest_handoff: dict[str, Any] | None, + latest_remediation: dict[str, Any] | None, ) -> str: status = str(_enum_value(report.status)) + remediation_status = (latest_remediation or {}).get("remediation_status") + if remediation_status == "verified_no_drift": + if report.high_count == 0 and report.medium_count == 0 and report.info_count == 0: + return "no_drift_verified" + return "remediated_verified" + if remediation_status == "executed_unverified": + return "remediation_executed_unverified" + if remediation_status == "verification_failed": + return "remediation_verification_failed" + if status == "adopted": return "adopted_unverified" if status == "rolled_back": @@ -113,6 +139,12 @@ def _derive_fsm_state( def _next_step_for_state(state: str, open_pr: dict[str, Any] | None) -> str: + if state in {"no_drift_verified", "remediated_verified"}: + return "monitor_for_recurrence" + if state == "remediation_executed_unverified": + return "run_verification_scan_then_record_result" + if state == "remediation_verification_failed": + return "open_manual_investigation_with_failed_verification" if state == "pr_open_zero_diff": return "close_zero_diff_pr_and_prepare_real_yaml_patch" if state == "pr_open_waiting_review": @@ -138,6 +170,7 @@ def build_drift_fingerprint_state( *, open_pr: dict[str, Any] | None = None, latest_handoff: dict[str, Any] | None = None, + latest_remediation: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the operator-facing Config Drift fingerprint state payload.""" @@ -145,7 +178,13 @@ def build_drift_fingerprint_state( report.namespace, report.items, ) - fsm_state = _derive_fsm_state(report, repeat_state, open_pr, latest_handoff) + fsm_state = _derive_fsm_state( + report, + repeat_state, + open_pr, + latest_handoff, + latest_remediation, + ) next_step = _next_step_for_state(fsm_state, open_pr) return { @@ -169,6 +208,7 @@ def build_drift_fingerprint_state( "next_step": next_step, "open_pr": open_pr, "latest_handoff": latest_handoff, + "latest_remediation": latest_remediation, "strict_fingerprint": repeat_state.get("strict_fingerprint"), "p0_escalation": { "suppresses_repeated_p0": True, @@ -188,6 +228,7 @@ def build_drift_fingerprint_state( "writes_auto_repair_result": False, "writes_drift_status": False, "writes_ticket": False, + "writes_remediation_record": False, "creates_external_ticket": False, } @@ -230,6 +271,71 @@ def _handoff_description(context: dict[str, Any]) -> str: )[:500] +def _is_no_drift_report(report: DriftReport | None) -> bool: + if report is None: + return False + return report.high_count == 0 and report.medium_count == 0 and report.info_count == 0 + + +def _remediation_context( + state: dict[str, Any], + *, + remediation_kind: DriftFingerprintRemediationKind, + remediation_status: DriftFingerprintRemediationStatus, + verification_report: DriftReport | None, + note: str | None, + commands_summary: list[str] | None, +) -> dict[str, Any]: + verification_summary = None + if verification_report is not None: + verification_summary = { + "report_id": verification_report.report_id, + "status": str(_enum_value(verification_report.status)), + "summary": _report_summary(verification_report), + "high_count": verification_report.high_count, + "medium_count": verification_report.medium_count, + "info_count": verification_report.info_count, + "scanned_at": _iso(verification_report.scanned_at), + "is_no_drift": _is_no_drift_report(verification_report), + } + + return { + "schema_version": REMEDIATION_SCHEMA_VERSION, + "fingerprint": state.get("fingerprint"), + "namespace": state.get("namespace"), + "remediated_report_id": state.get("latest_report_id"), + "verification_report_id": getattr(verification_report, "report_id", None), + "verification_summary": verification_summary, + "remediation_kind": remediation_kind, + "remediation_status": remediation_status, + "note": note, + "commands_summary": [ + str(command)[:180] for command in (commands_summary or [])[:12] + ], + "fsm_state_before": state.get("fsm_state"), + "next_step_before": state.get("next_step"), + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_drift_status": False, + "writes_ticket": False, + "writes_remediation_record": True, + "creates_external_ticket": False, + "recorded_at": now_taipei().isoformat(), + } + + +def _remediation_description(context: dict[str, Any]) -> str: + verification = context.get("verification_summary") or {} + return ( + f"fingerprint={context.get('fingerprint')} " + f"kind={context.get('remediation_kind')} " + f"status={context.get('remediation_status')} " + f"remediated_report={context.get('remediated_report_id')} " + f"verification_report={context.get('verification_report_id') or '--'} " + f"verification={verification.get('summary') or '--'}" + )[:500] + + class DriftFingerprintStateService: """Read and record the state of a stable Config Drift fingerprint.""" @@ -249,11 +355,22 @@ class DriftFingerprintStateService: alternate_fingerprints=_repeat_state_fingerprint_aliases(repeat_state), report_ids=_repeat_state_report_ids(repeat_state), ) + latest_remediation = await self._fetch_latest_remediation( + fingerprint, + alternate_fingerprints=_repeat_state_fingerprint_aliases(repeat_state), + report_ids={ + report.report_id, + *_repeat_state_report_ids(repeat_state), + }, + namespace=report.namespace, + allow_namespace_fallback=_is_no_drift_report(report), + ) return build_drift_fingerprint_state( report, repeat_state, open_pr=open_pr, latest_handoff=latest_handoff, + latest_remediation=latest_remediation, ) async def record_handoff( @@ -357,6 +474,132 @@ class DriftFingerprintStateService: "history": history, } + async def record_remediation( + self, + *, + report_id: str | None = None, + namespace: str | None = None, + remediation_kind: DriftFingerprintRemediationKind = "live_env_rollback", + remediation_status: DriftFingerprintRemediationStatus | None = None, + verification_report_id: str | None = None, + note: str | None = None, + commands_summary: list[str] | None = None, + ) -> dict[str, Any]: + state = await self.get_state(report_id=report_id, namespace=namespace) + verification_report = await self._load_verification_report( + report_id=verification_report_id, + namespace=state.get("namespace") or namespace, + ) + resolved_status: DriftFingerprintRemediationStatus = ( + remediation_status + or ( + "verified_no_drift" + if _is_no_drift_report(verification_report) + else "executed_unverified" + ) + ) + context = _remediation_context( + state, + remediation_kind=remediation_kind, + remediation_status=resolved_status, + verification_report=verification_report, + note=note, + commands_summary=commands_summary, + ) + + history = { + "recorded": False, + "alert_operation_id": None, + "timeline_event_id": None, + "reason": None, + } + incident_id = str(state.get("latest_report_id") or "") + success = resolved_status != "verification_failed" + + try: + from src.repositories.alert_operation_log_repository import ( + get_alert_operation_log_repository, + ) + + record = await get_alert_operation_log_repository().append( + "CHANGE_APPLIED", + incident_id=incident_id or None, + actor=SERVICE_ACTOR, + action_detail=( + f"drift_fingerprint_remediation:{remediation_kind}" + )[:200], + success=success, + context=context, + ) + if record is not None: + history["alert_operation_id"] = getattr(record, "id", None) + except Exception as exc: + logger.warning( + "drift_fingerprint_remediation_aol_failed", + report_id=incident_id, + error=str(exc), + ) + + try: + from src.services.approval_db import get_timeline_service + + event = await get_timeline_service().add_event( + event_type="exec", + status="success" if success else "error", + title="AwoooP drift remediation verified", + description=_remediation_description(context), + actor=SERVICE_ACTOR, + actor_role=remediation_kind, + incident_id=incident_id or None, + ) + if event: + history["timeline_event_id"] = event.get("id") + except Exception as exc: + logger.warning( + "drift_fingerprint_remediation_timeline_failed", + report_id=incident_id, + error=str(exc), + ) + + history["recorded"] = bool( + history.get("alert_operation_id") or history.get("timeline_event_id") + ) + if not history["recorded"]: + history["reason"] = "history_sink_unavailable" + + latest_remediation = { + "remediation_kind": remediation_kind, + "remediation_status": ( + resolved_status if history["recorded"] else "record_failed" + ), + "verification_report_id": context.get("verification_report_id"), + "verification_summary": context.get("verification_summary"), + "note": note, + "commands_summary": context.get("commands_summary"), + "created_at": context.get("recorded_at"), + "source": "current_request", + } + updated_state = build_drift_fingerprint_state( + await self._load_report( + report_id=str(state.get("latest_report_id") or ""), + namespace=state.get("namespace"), + ), + { + **(state.get("repeat_state") or {}), + "fingerprint": state.get("fingerprint"), + }, + open_pr=state.get("open_pr"), + latest_handoff=state.get("latest_handoff"), + latest_remediation=latest_remediation, + ) + + return { + **updated_state, + "remediation_kind": remediation_kind, + "remediation_status": latest_remediation["remediation_status"], + "history": history, + } + async def _load_report( self, *, @@ -376,6 +619,22 @@ class DriftFingerprintStateService: return report raise DriftFingerprintStateNotFoundError(desired_namespace) + async def _load_verification_report( + self, + *, + report_id: str | None, + namespace: str | None, + ) -> DriftReport | None: + repo = get_drift_repository() + if report_id: + return await repo.get(report_id) + + desired_namespace = namespace or DEFAULT_NAMESPACE + for report in await repo.list_recent(limit=50): + if report.namespace == desired_namespace and _is_no_drift_report(report): + return report + return None + async def _fetch_latest_handoff( self, fingerprint: str, @@ -439,6 +698,86 @@ class DriftFingerprintStateService: "note": context.get("note"), } + async def _fetch_latest_remediation( + self, + fingerprint: str, + *, + alternate_fingerprints: set[str] | None = None, + report_ids: set[str] | None = None, + namespace: str | None = None, + allow_namespace_fallback: bool = False, + ) -> dict[str, Any] | None: + fingerprints = {fingerprint, *(alternate_fingerprints or set())} + fingerprints = {value for value in fingerprints if value} + report_ids = {value for value in (report_ids or set()) if value} + try: + async with get_db_context() as db: + result = await db.execute( + text( + """ + SELECT id, action_detail, success, context, created_at + FROM alert_operation_log + WHERE actor = :actor + AND action_detail LIKE 'drift_fingerprint_remediation:%' + ORDER BY created_at DESC + LIMIT 100 + """ + ), + {"actor": SERVICE_ACTOR}, + ) + rows = result.mappings().all() + except Exception as exc: + logger.warning( + "drift_fingerprint_remediation_lookup_failed", + fingerprint=fingerprint, + error=str(exc), + ) + return { + "lookup_error": str(exc)[:160], + "remediation_status": "lookup_failed", + } + + namespace_fallback = None + row = None + for candidate in rows: + context = candidate.get("context") or {} + if not isinstance(context, dict): + continue + if context.get("fingerprint") in fingerprints: + row = candidate + break + if context.get("remediated_report_id") in report_ids: + row = candidate + break + if context.get("verification_report_id") in report_ids: + row = candidate + break + if ( + allow_namespace_fallback + and namespace + and context.get("namespace") == namespace + and namespace_fallback is None + ): + namespace_fallback = candidate + + row = row or namespace_fallback + if row is None: + return None + context = row.get("context") or {} + return { + "alert_operation_id": row.get("id"), + "action_detail": row.get("action_detail"), + "success": row.get("success"), + "created_at": _iso(row.get("created_at")), + "remediation_kind": context.get("remediation_kind"), + "remediation_status": context.get("remediation_status") or "recorded", + "remediated_report_id": context.get("remediated_report_id"), + "verification_report_id": context.get("verification_report_id"), + "verification_summary": context.get("verification_summary"), + "note": context.get("note"), + "commands_summary": context.get("commands_summary"), + } + async def _lookup_open_pr(self, report: DriftReport) -> dict[str, Any] | None: settings = get_settings() api_url = settings.GITEA_API_URL.rstrip("/") diff --git a/apps/api/tests/test_drift_fingerprint_state_service.py b/apps/api/tests/test_drift_fingerprint_state_service.py index fec857fb..ddc7627e 100644 --- a/apps/api/tests/test_drift_fingerprint_state_service.py +++ b/apps/api/tests/test_drift_fingerprint_state_service.py @@ -27,6 +27,18 @@ def _report(report_id: str = "drift-1", status: DriftStatus = DriftStatus.PENDIN ) +def _no_drift_report(report_id: str = "no-drift-1") -> DriftReport: + return DriftReport( + report_id=report_id, + namespace="awoooi-prod", + high_count=0, + medium_count=0, + info_count=0, + status=DriftStatus.IGNORED, + items=[], + ) + + def test_build_state_marks_repeated_pending_human() -> None: current = _report("drift-3") repeat = build_drift_repeat_state( @@ -81,3 +93,38 @@ def test_build_state_marks_handoff_recorded() -> None: assert state["fsm_state"] == "handoff_recorded" assert state["next_step"] == "operator_review_handoff_and_execute_manual_plan" + + +def test_build_state_marks_verified_no_drift_remediation() -> None: + report = _no_drift_report("no-drift-2") + repeat = build_drift_repeat_state(report, []) + state = build_drift_fingerprint_state( + report, + repeat, + latest_remediation={ + "remediation_kind": "live_env_rollback", + "remediation_status": "verified_no_drift", + "verification_report_id": "no-drift-2", + }, + ) + + assert state["fsm_state"] == "no_drift_verified" + assert state["next_step"] == "monitor_for_recurrence" + assert state["latest_remediation"]["remediation_status"] == "verified_no_drift" + assert state["writes_remediation_record"] is False + + +def test_build_state_marks_remediation_executed_unverified() -> None: + report = _report("drift-6") + repeat = build_drift_repeat_state(report, []) + state = build_drift_fingerprint_state( + report, + repeat, + latest_remediation={ + "remediation_kind": "live_env_rollback", + "remediation_status": "executed_unverified", + }, + ) + + assert state["fsm_state"] == "remediation_executed_unverified" + assert state["next_step"] == "run_verification_scan_then_record_result" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a4a514cc..0b9f7694 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1069,7 +1069,11 @@ "next": "Next: {step}", "writes": "Writes: drift={drift}; incident={incident}; repair={repair}; ticket={ticket}", "pr": "PR: {pr}; zeroDiff={zeroDiff}", - "p0Dedup": "P0 dedupe {hours}h" + "p0Dedup": "P0 dedupe {hours}h", + "remediation": "Remediation: {status}; verification report: {report}", + "remediationKind": "Remediation kind: {kind}", + "remediationVerification": "Verification: {summary}", + "remediationNote": "Note: {note}" } }, "neuralCommand": { @@ -1814,6 +1818,7 @@ "driftFingerprintId": "Fingerprint: {fingerprint}; Report: {report}", "driftFingerprintPr": "PR: {pr}; zeroDiff={zeroDiff}", "driftFingerprintNext": "Next: {step}", + "driftFingerprintRemediation": "Remediation: {kind} / {status}; verification report: {report}", "driftFingerprintEmpty": "No Config Drift fingerprint state yet", "remediationQueue": "Remediation work: {total}; AI-ready: {ready}; human: {human}", "telegramCallbacks": "Telegram callback lookup and history summary are being repaired", @@ -1866,6 +1871,10 @@ "pr_open_waiting_review": "PR waiting review", "pr_merged_unverified": "PR merged, unverified", "handoff_recorded": "Handoff recorded", + "no_drift_verified": "No drift, verified", + "remediated_verified": "Remediated, verified", + "remediation_executed_unverified": "Remediated, unverified", + "remediation_verification_failed": "Remediation verification failed", "adopted_unverified": "Adopted, unverified", "rolled_back": "Rolled back", "acknowledged": "Acknowledged", @@ -1877,6 +1886,8 @@ "review_pr_then_merge_or_reject": "Review PR, then merge or reject", "verify_git_baseline_then_mark_adopted": "Verify Git baseline, then mark adopted", "operator_review_handoff_and_execute_manual_plan": "Operator reviews handoff and executes manual plan", + "run_verification_scan_then_record_result": "Run verification scan, then record the result", + "open_manual_investigation_with_failed_verification": "Open manual investigation with the failed verification", "verify_k8s_matches_git_baseline": "Verify K8s matches Git baseline", "confirm_no_repeat_after_rollback": "Confirm no repeat after rollback", "monitor_for_recurrence": "Monitor for recurrence", @@ -1893,6 +1904,28 @@ "handoff": { "latest": "Latest handoff: {status}" }, + "remediation": { + "title": "Remediation / Verification", + "latest": "Latest remediation: {kind} / {status}", + "verification": "Verification report: {report}; {summary}", + "note": "Note: {note}" + }, + "remediationKinds": { + "live_env_rollback": "Live env rollback", + "git_adopted": "Git adopted", + "git_rollback": "Git rollback", + "zero_diff_pr_cleanup": "Zero-diff PR cleanup", + "manual_noop": "Manual no-op", + "unknown": "Unknown" + }, + "remediationStatuses": { + "executed_unverified": "Executed, unverified", + "verified_no_drift": "Verified no drift", + "verification_failed": "Verification failed", + "record_failed": "Record failed", + "lookup_failed": "Lookup failed", + "unknown": "No record yet" + }, "actions": { "record": "Record handoff", "recording": "Recording", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index b67da355..f5b56cb6 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1070,7 +1070,11 @@ "next": "下一步:{step}", "writes": "寫入:drift={drift};incident={incident};repair={repair};ticket={ticket}", "pr": "PR:{pr};zeroDiff={zeroDiff}", - "p0Dedup": "P0 去重 {hours}h" + "p0Dedup": "P0 去重 {hours}h", + "remediation": "修復:{status};驗證 Report:{report}", + "remediationKind": "修復方式:{kind}", + "remediationVerification": "驗證結果:{summary}", + "remediationNote": "備註:{note}" } }, "neuralCommand": { @@ -1815,6 +1819,7 @@ "driftFingerprintId": "Fingerprint:{fingerprint};Report:{report}", "driftFingerprintPr": "PR:{pr};zeroDiff={zeroDiff}", "driftFingerprintNext": "下一步:{step}", + "driftFingerprintRemediation": "修復:{kind} / {status};驗證 Report:{report}", "driftFingerprintEmpty": "尚無 Config Drift fingerprint 狀態", "remediationQueue": "補救工作:{total};AI 可接手:{ready};人工:{human}", "telegramCallbacks": "目前修補 Telegram callback 查詢鏈與歷史摘要", @@ -1867,6 +1872,10 @@ "pr_open_waiting_review": "PR 等待 review", "pr_merged_unverified": "PR 已 merge 待驗證", "handoff_recorded": "交接已記錄", + "no_drift_verified": "無漂移且已驗證", + "remediated_verified": "已修復且已驗證", + "remediation_executed_unverified": "已修復待驗證", + "remediation_verification_failed": "修復驗證失敗", "adopted_unverified": "已採納待驗證", "rolled_back": "已回滾", "acknowledged": "已知悉", @@ -1878,6 +1887,8 @@ "review_pr_then_merge_or_reject": "review PR 後 merge 或 reject", "verify_git_baseline_then_mark_adopted": "驗證 Git baseline 後標記採納", "operator_review_handoff_and_execute_manual_plan": "Operator review 交接並執行人工方案", + "run_verification_scan_then_record_result": "執行驗證掃描並記錄結果", + "open_manual_investigation_with_failed_verification": "建立人工調查並附上失敗驗證", "verify_k8s_matches_git_baseline": "驗證 K8s 與 Git baseline 一致", "confirm_no_repeat_after_rollback": "確認回滾後不再重複", "monitor_for_recurrence": "監控是否復發", @@ -1894,6 +1905,28 @@ "handoff": { "latest": "最近交接:{status}" }, + "remediation": { + "title": "修復 / 驗證", + "latest": "最近修復:{kind} / {status}", + "verification": "驗證 Report:{report};{summary}", + "note": "備註:{note}" + }, + "remediationKinds": { + "live_env_rollback": "線上 env 回滾", + "git_adopted": "Git 採納", + "git_rollback": "Git 回滾", + "zero_diff_pr_cleanup": "零 diff PR 清理", + "manual_noop": "人工確認無需動作", + "unknown": "未知" + }, + "remediationStatuses": { + "executed_unverified": "已執行待驗證", + "verified_no_drift": "已驗證無漂移", + "verification_failed": "驗證失敗", + "record_failed": "入庫失敗", + "lookup_failed": "查詢失敗", + "unknown": "尚無記錄" + }, "actions": { "record": "記錄交接", "recording": "記錄中", 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 4d54b4bb..130539c0 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -229,6 +229,24 @@ type DriftFingerprintState = { created_at?: string | null; lookup_error?: string | null; } | null; + latest_remediation?: { + remediation_kind?: string | null; + remediation_status?: string | null; + remediated_report_id?: string | null; + verification_report_id?: string | null; + verification_summary?: { + report_id?: string | null; + status?: string | null; + summary?: string | null; + high_count?: number | null; + medium_count?: number | null; + info_count?: number | null; + is_no_drift?: boolean | null; + } | null; + note?: string | null; + created_at?: string | null; + lookup_error?: string | null; + } | null; p0_escalation?: { suppresses_repeated_p0?: boolean | null; dedup_window_hours?: number | null; @@ -422,6 +440,10 @@ function driftFsmStateKey(state?: string | null) { state === "pr_open_waiting_review" || state === "pr_merged_unverified" || state === "handoff_recorded" || + state === "no_drift_verified" || + state === "remediated_verified" || + state === "remediation_executed_unverified" || + state === "remediation_verification_failed" || state === "adopted_unverified" || state === "rolled_back" || state === "acknowledged" || @@ -438,6 +460,8 @@ function driftNextStepKey(step?: string | null) { step === "review_pr_then_merge_or_reject" || step === "verify_git_baseline_then_mark_adopted" || step === "operator_review_handoff_and_execute_manual_plan" || + step === "run_verification_scan_then_record_result" || + step === "open_manual_investigation_with_failed_verification" || step === "verify_k8s_matches_git_baseline" || step === "confirm_no_repeat_after_rollback" || step === "monitor_for_recurrence" || @@ -449,9 +473,38 @@ function driftNextStepKey(step?: string | null) { return "unknown"; } +function driftRemediationKindKey(kind?: string | null) { + if ( + kind === "live_env_rollback" || + kind === "git_adopted" || + kind === "git_rollback" || + kind === "zero_diff_pr_cleanup" || + kind === "manual_noop" + ) { + return kind; + } + return "unknown"; +} + +function driftRemediationStatusKey(status?: string | null) { + if ( + status === "executed_unverified" || + status === "verified_no_drift" || + status === "verification_failed" || + status === "record_failed" || + status === "lookup_failed" + ) { + return status; + } + return "unknown"; +} + function driftStatusForWorkItem(state: DriftFingerprintState | null): WorkStatus { const fsm = state?.fsm_state; if (!state) return "blocked"; + if (fsm === "no_drift_verified" || fsm === "remediated_verified") return "live"; + if (fsm === "remediation_executed_unverified") return "in_progress"; + if (fsm === "remediation_verification_failed") return "blocked"; if (fsm === "adopted_unverified" || fsm === "pr_merged_unverified") return "in_progress"; if (fsm === "rolled_back" || fsm === "acknowledged" || fsm === "ignored") return "watching"; if (fsm === "pr_open_waiting_review" || fsm === "handoff_recorded") return "in_progress"; @@ -482,6 +535,12 @@ function buildWorkItems( const driftState = telemetry.driftFingerprintState; const driftFsmKey = driftFsmStateKey(driftState?.fsm_state); const driftNextKey = driftNextStepKey(driftState?.next_step); + const driftRemediationKind = driftRemediationKindKey( + driftState?.latest_remediation?.remediation_kind + ); + const driftRemediationStatus = driftRemediationStatusKey( + driftState?.latest_remediation?.remediation_status + ); const governanceEventsUnavailable = telemetry.governanceEvents === null; const governanceQueueMissing = telemetry.governanceQueue?.table_pending === true; const governanceDispatchBlocked = @@ -575,6 +634,13 @@ function buildWorkItems( t("evidence.driftFingerprintNext", { step: t(`driftFingerprint.nextSteps.${driftNextKey}` as never), }), + t("evidence.driftFingerprintRemediation", { + kind: t(`driftFingerprint.remediationKinds.${driftRemediationKind}` as never), + status: t( + `driftFingerprint.remediationStatuses.${driftRemediationStatus}` as never + ), + report: driftState.latest_remediation?.verification_report_id ?? "--", + }), ] : [t("evidence.driftFingerprintEmpty")], href: "/drift", @@ -1104,6 +1170,12 @@ function DriftFingerprintPanel({ }); const fsmKey = driftFsmStateKey(state?.fsm_state); const nextKey = driftNextStepKey(state?.next_step); + const remediationKindKey = driftRemediationKindKey( + state?.latest_remediation?.remediation_kind + ); + const remediationStatusKey = driftRemediationStatusKey( + state?.latest_remediation?.remediation_status + ); const handoffKind = state?.open_pr?.is_zero_diff ? "zero_diff_pr_cleanup" : "open_pr_review"; @@ -1222,6 +1294,34 @@ function DriftFingerprintPanel({ })}
++ {t("remediation.latest", { + kind: t(`remediationKinds.${remediationKindKey}` as never), + status: t(`remediationStatuses.${remediationStatusKey}` as never), + })} +
++ {t("remediation.verification", { + report: state.latest_remediation?.verification_report_id ?? "--", + summary: + state.latest_remediation?.verification_summary?.summary ?? "--", + })} +
++ {t("remediation.note", { + note: state.latest_remediation?.note ?? "--", + })} +
++ {t('fingerprintState.remediationKind', { + kind: state.latest_remediation.remediation_kind ?? '--', + })} +
++ {t('fingerprintState.remediationVerification', { + summary: state.latest_remediation.verification_summary?.summary ?? '--', + })} +
++ {t('fingerprintState.remediationNote', { + note: state.latest_remediation.note ?? '--', + })} +
+