From af9798a62e85e3876b471d7c9c4339dd78fb6aa4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 13 May 2026 09:22:51 +0800 Subject: [PATCH 1/2] feat(awooop): surface reconciliation in incident timeline --- apps/api/src/api/v1/incidents.py | 1 + .../services/awooop_truth_chain_service.py | 4 +- .../src/services/incident_timeline_service.py | 68 +++++++++++++++++++ apps/api/src/services/telegram_gateway.py | 18 +++++ .../tests/test_awooop_truth_chain_service.py | 6 +- .../tests/test_incident_timeline_service.py | 33 ++++++++- 6 files changed, 124 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/v1/incidents.py b/apps/api/src/api/v1/incidents.py index b7c7ec9a..e362079d 100644 --- a/apps/api/src/api/v1/incidents.py +++ b/apps/api/src/api/v1/incidents.py @@ -134,6 +134,7 @@ class IncidentTimelineResponse(BaseModel): timeline: list[IncidentTimelineStage] = Field(default_factory=list) events: list[IncidentTimelineEvent] = Field(default_factory=list) ascii_timeline: str + reconciliation: dict[str, Any] = Field(default_factory=dict) # ============================================================================= diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 3dab3145..0b223195 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -97,7 +97,7 @@ def _operation_ids(automation_ops: list[dict[str, Any]]) -> list[str]: return [str(row["op_id"]) for row in automation_ops if row.get("op_id")] -def _build_reconciliation( +def build_incident_reconciliation( *, incident: dict[str, Any] | None, approvals: list[dict[str, Any]], @@ -694,7 +694,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ legacy_mcp_total=legacy_mcp_summary["total"], outbound_visible_total=len(outbound_rows), ) - reconciliation = _build_reconciliation( + reconciliation = build_incident_reconciliation( incident=incident, approvals=approvals, evidence_rows=evidence_rows, diff --git a/apps/api/src/services/incident_timeline_service.py b/apps/api/src/services/incident_timeline_service.py index 975f496a..385a8cb9 100644 --- a/apps/api/src/services/incident_timeline_service.py +++ b/apps/api/src/services/incident_timeline_service.py @@ -27,6 +27,7 @@ from src.db.models import ( KnowledgeEntryRecord, TimelineEvent, ) +from src.services.awooop_truth_chain_service import build_incident_reconciliation logger = structlog.get_logger(__name__) @@ -222,6 +223,29 @@ def _automation_summary(row: Any) -> str | None: return row.error +def _reconciliation_event(reconciliation: dict[str, Any]) -> dict[str, Any] | None: + """Render truth-chain reconciliation into the operator timeline.""" + if not reconciliation.get("applicable"): + return None + status = str(reconciliation.get("consistency_status") or "unknown") + mismatches = reconciliation.get("mismatches") or [] + if status == "consistent" and not mismatches: + return None + + stage_status = "error" if status == "blocked" else "warning" + codes = [str(row.get("code")) for row in mismatches if row.get("code")] + description = "; ".join(codes) if codes else None + return _event( + stage="safe", + status=stage_status, + title=f"Lifecycle reconciliation: {status}", + description=description, + actor="truth_chain_reconciliation", + source_table="truth_chain", + data=reconciliation, + ) + + async def _fetch_automation_ops( db: Any, incident_id: str, @@ -365,6 +389,49 @@ async def fetch_incident_timeline(incident_id: str) -> dict[str, Any] | None: automation_ops = await _fetch_automation_ops(db, incident_id, approval_ids) events: list[dict[str, Any]] = [] + reconciliation = build_incident_reconciliation( + incident={ + "incident_id": incident.incident_id, + "status": _value(incident.status), + }, + approvals=[ + { + "id": str(approval.id), + "status": _value(approval.status), + "action": approval.action, + "resolved_at": _iso(approval.resolved_at), + } + for approval in sorted( + approvals, + key=lambda row: row.created_at or datetime.min, + reverse=True, + ) + ], + evidence_rows=[ + { + "sensors_attempted": evidence.sensors_attempted, + "sensors_succeeded": evidence.sensors_succeeded, + } + for evidence in evidence_records + ], + automation_ops=[ + { + "status": op.status, + "operation_type": op.operation_type, + "op_id": op.op_id, + } + for op in automation_ops + ], + timeline_events=[ + { + "event_type": event.event_type, + "status": event.status, + } + for event in raw_timeline + ], + ) + if reconciliation_event := _reconciliation_event(reconciliation): + events.append(reconciliation_event) alert_name = incident.alertname if not alert_name and incident.signals: @@ -639,6 +706,7 @@ async def fetch_incident_timeline(incident_id: str) -> dict[str, Any] | None: "timeline": stage_list, "events": events, "ascii_timeline": format_ascii_timeline(stage_list), + "reconciliation": reconciliation, } logger.info( "incident_timeline_fetched", diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index da509954..b9fe0b67 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -5045,6 +5045,24 @@ class TelegramGateway: "🧭 處理歷程", f"{html.escape(timeline['ascii_timeline'])}", ] + reconciliation = timeline.get("reconciliation") or {} + if reconciliation.get("consistency_status") in {"blocked", "degraded"}: + mismatch_codes = [ + str(row.get("code")) + for row in reconciliation.get("mismatches", []) + if row.get("code") + ] + lines += [ + "", + "🚦 ηœŸη›Έιˆη‹€ζ…‹", + f"η‹€ζ…‹: {html.escape(str(reconciliation.get('consistency_status')))}", + f"δΈ‹δΈ€ζ­₯: {html.escape(str(reconciliation.get('operator_next_state')))}", + ] + if mismatch_codes: + lines.append( + "ηŸ›η›Ύ: " + + html.escape(", ".join(mismatch_codes[:4])) + ) await self.send_notification("\n".join(lines)) diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 6828a563..e602c5b0 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -8,7 +8,7 @@ from src.services.awooop_ansible_audit_service import ( build_ansible_truth, ) from src.services.awooop_truth_chain_service import ( - _build_reconciliation, + build_incident_reconciliation, _clean_row, _truth_status, ) @@ -162,7 +162,7 @@ def test_drift_repeat_state_counts_matching_fingerprint_only() -> None: def test_reconciliation_blocks_open_incident_after_no_action_approval() -> None: - reconciliation = _build_reconciliation( + reconciliation = build_incident_reconciliation( incident={"incident_id": "INC-1", "status": "INVESTIGATING"}, approvals=[ { @@ -191,7 +191,7 @@ def test_reconciliation_blocks_open_incident_after_no_action_approval() -> None: def test_reconciliation_marks_consistent_resolved_execution() -> None: - reconciliation = _build_reconciliation( + reconciliation = build_incident_reconciliation( incident={"incident_id": "INC-2", "status": "RESOLVED"}, approvals=[ { diff --git a/apps/api/tests/test_incident_timeline_service.py b/apps/api/tests/test_incident_timeline_service.py index 496e8041..7d68bc14 100644 --- a/apps/api/tests/test_incident_timeline_service.py +++ b/apps/api/tests/test_incident_timeline_service.py @@ -1,4 +1,8 @@ -from src.services.incident_timeline_service import STAGE_DEFS, format_ascii_timeline +from src.services.incident_timeline_service import ( + STAGE_DEFS, + _reconciliation_event, + format_ascii_timeline, +) def _stages(status_by_stage: dict[str, str]) -> list[dict]: @@ -23,3 +27,30 @@ def test_format_ascii_timeline_skips_unrecorded_stages() -> None: def test_format_ascii_timeline_has_empty_fallback() -> None: assert format_ascii_timeline(_stages({})) == "webhook:skip > ai:skip > executor:skip" + + +def test_reconciliation_event_marks_safe_stage_failed() -> None: + event = _reconciliation_event({ + "applicable": True, + "consistency_status": "blocked", + "operator_next_state": "manual_required", + "mismatches": [ + {"code": "approval_approved_without_execution_record"}, + {"code": "evidence_all_sensors_failed"}, + ], + }) + + assert event is not None + assert event["stage"] == "safe" + assert event["status"] == "error" + assert event["title"] == "Lifecycle reconciliation: blocked" + assert "approval_approved_without_execution_record" in event["description"] + assert event["data"]["operator_next_state"] == "manual_required" + + +def test_reconciliation_event_omits_consistent_state() -> None: + assert _reconciliation_event({ + "applicable": True, + "consistency_status": "consistent", + "mismatches": [], + }) is None From c01012d7676cebaf097ae5f81e387dfb8dfc36f9 Mon Sep 17 00:00:00 2001 From: AWOOOI CD Date: Wed, 13 May 2026 09:29:04 +0800 Subject: [PATCH 2/2] chore(cd): deploy af9798a [skip ci] --- k8s/awoooi-prod/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index 1e8d8ac9..93bd80d2 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -40,7 +40,7 @@ resources: images: - name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api - newTag: 1003fa4246290bec2bec4cd04caae9b8221996d9 + newTag: af9798a62e85e3876b471d7c9c4339dd78fb6aa4 - name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web - newTag: 1003fa4246290bec2bec4cd04caae9b8221996d9 + newTag: af9798a62e85e3876b471d7c9c4339dd78fb6aa4