From 64b34828a76711379eb0c481903ca51f0990bc5d Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 19 May 2026 09:13:58 +0800
Subject: [PATCH] feat(drift): record remediation evidence
---
apps/api/src/api/v1/drift.py | 47 +++
.../drift_fingerprint_state_service.py | 347 +++++++++++++++++-
.../test_drift_fingerprint_state_service.py | 47 +++
apps/web/messages/en.json | 35 +-
apps/web/messages/zh-TW.json | 35 +-
.../app/[locale]/awooop/work-items/page.tsx | 100 +++++
apps/web/src/components/panels/DriftPanel.tsx | 36 ++
7 files changed, 641 insertions(+), 6 deletions(-)
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.title")}
+
+
+
+
+ {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 ?? "--",
+ })}
+
+
+
+ {state.latest_remediation ? (
+
+
+ {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 ?? '--',
+ })}
+
+
+ ) : null}
)
}