diff --git a/apps/api/src/services/channel_event_dossier_service.py b/apps/api/src/services/channel_event_dossier_service.py
index d855f324..293be57c 100644
--- a/apps/api/src/services/channel_event_dossier_service.py
+++ b/apps/api/src/services/channel_event_dossier_service.py
@@ -7,6 +7,7 @@ automation state.
from __future__ import annotations
+import re
from typing import Any
from uuid import UUID
@@ -18,6 +19,8 @@ from src.db.base import get_db_context
_MAX_DOSSIER_EVENTS = 50
_MAX_COVERAGE_EVENTS = 200
_MAX_RECURRENCE_EVENTS = 300
+_MAX_REPAIR_INCIDENTS = 200
+_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
def _as_dict(value: Any) -> dict[str, Any]:
@@ -41,6 +44,39 @@ def _ref_count(source_refs: dict[str, Any], key: str) -> int:
return 1 if value else 0
+def _append_unique(values: list[str], candidate: Any) -> None:
+ text_value = str(candidate or "").strip()
+ if text_value and text_value not in values:
+ values.append(text_value)
+
+
+def _append_incident_ids_from_text(values: list[str], text_value: Any) -> None:
+ if not text_value:
+ return
+ for incident_id in _INCIDENT_ID_RE.findall(str(text_value)):
+ _append_unique(values, incident_id)
+
+
+def _append_incident_ids_from_refs(
+ values: list[str], source_refs: dict[str, Any]
+) -> None:
+ incident_ids = source_refs.get("incident_ids")
+ if isinstance(incident_ids, list):
+ for incident_id in incident_ids:
+ _append_unique(values, incident_id)
+ else:
+ _append_unique(values, incident_ids)
+
+
+def _event_incident_ids(event: dict[str, Any]) -> list[str]:
+ incident_ids: list[str] = []
+ _append_incident_ids_from_refs(incident_ids, _as_dict(event.get("source_refs")))
+ _append_incident_ids_from_text(incident_ids, event.get("content_preview"))
+ _append_incident_ids_from_text(incident_ids, event.get("content_redacted"))
+ _append_incident_ids_from_text(incident_ids, event.get("provider_event_id"))
+ return incident_ids
+
+
def _recurrence_key(event: dict[str, Any]) -> str:
fingerprint = str(event.get("fingerprint") or "").strip()
if fingerprint:
@@ -61,15 +97,18 @@ def build_dossier_recurrence(
*,
project_id: str,
limit: int,
+ repair_summaries_by_incident: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Group recent source events into recurrence buckets with linked run state."""
groups: dict[str, dict[str, Any]] = {}
+ repair_summaries = repair_summaries_by_incident or {}
for row in rows:
event = build_dossier_event(row)
key = _recurrence_key(event)
source_ref_count = int(event.get("source_ref_count") or 0)
source_refs = _as_dict(event.get("source_refs"))
+ incident_ids = _event_incident_ids(event)
run_id = row.get("run_id")
run_state = row.get("run_state")
received_at = event.get("received_at")
@@ -90,6 +129,8 @@ def build_dossier_recurrence(
"latest_run_id": run_id,
"latest_run_state": run_state,
"latest_agent_id": row.get("run_agent_id"),
+ "latest_incident_id": incident_ids[0] if incident_ids else None,
+ "incident_ids": [],
"occurrence_total": 0,
"duplicate_total": 0,
"linked_run_total": 0,
@@ -112,6 +153,9 @@ def build_dossier_recurrence(
if event.get("is_duplicate"):
group["duplicate_total"] += 1
+ for incident_id in incident_ids:
+ _append_unique(group["incident_ids"], incident_id)
+
group["sentry_ref_total"] += _ref_count(source_refs, "sentry_issue_ids")
group["signoz_ref_total"] += _ref_count(source_refs, "signoz_alerts")
group["alert_ref_total"] += _ref_count(source_refs, "alert_ids")
@@ -136,19 +180,27 @@ def build_dossier_recurrence(
or str(received_at) > str(group.get("latest_received_at"))
):
group["latest_received_at"] = received_at
+ group["latest_incident_id"] = (
+ incident_ids[0] if incident_ids else group.get("latest_incident_id")
+ )
items = []
linked_run_total = 0
for group in groups.values():
run_ids = group.pop("_run_ids")
group["linked_run_total"] = len(run_ids)
+ _attach_work_item_summary(group, repair_summaries)
linked_run_total += len(run_ids)
items.append(group)
items.sort(key=lambda item: str(item.get("latest_received_at") or ""), reverse=True)
items.sort(key=lambda item: int(item.get("occurrence_total") or 0), reverse=True)
latest_received_at = max(
- (item.get("latest_received_at") for item in items if item.get("latest_received_at")),
+ (
+ item.get("latest_received_at")
+ for item in items
+ if item.get("latest_received_at")
+ ),
default=None,
)
@@ -161,15 +213,133 @@ def build_dossier_recurrence(
"recurrent_group_total": sum(
1 for item in items if int(item.get("occurrence_total") or 0) > 1
),
- "duplicate_event_total": sum(int(item.get("duplicate_total") or 0) for item in items),
+ "duplicate_event_total": sum(
+ int(item.get("duplicate_total") or 0) for item in items
+ ),
"linked_run_total": linked_run_total,
"unlinked_event_total": sum(1 for row in rows if not row.get("run_id")),
+ "auto_repair_linked_total": sum(
+ 1
+ for item in items
+ if _as_dict(item.get("repair_summary")).get("latest_auto_repair_id")
+ ),
+ "verified_repair_group_total": sum(
+ 1
+ for item in items
+ if _as_dict(item.get("repair_summary")).get("status")
+ == "auto_repair_verified"
+ ),
+ "open_work_item_group_total": sum(
+ 1
+ for item in items
+ if _as_dict(item.get("work_item")).get("status") == "open"
+ ),
+ "manual_gate_group_total": sum(
+ 1
+ for item in items
+ if _as_dict(item.get("repair_summary")).get("status") == "manual_gate"
+ ),
"latest_received_at": latest_received_at,
},
"items": items,
}
+def _repair_status(
+ *,
+ incident_id: str | None,
+ latest_run_state: str | None,
+ repair_summary: dict[str, Any] | None,
+) -> str:
+ if not incident_id:
+ return "no_incident_link"
+ if repair_summary:
+ latest_success = repair_summary.get("latest_success")
+ verification = str(
+ repair_summary.get("latest_verification_result") or ""
+ ).lower()
+ if latest_success is True and verification == "success":
+ return "auto_repair_verified"
+ if latest_success is True:
+ return "auto_repair_succeeded_unverified"
+ if latest_success is False:
+ return "auto_repair_failed"
+ return "auto_repair_recorded"
+ if latest_run_state == "waiting_approval":
+ return "manual_gate"
+ if latest_run_state in {"pending", "running", "waiting_tool"}:
+ return "investigating"
+ if latest_run_state == "completed":
+ return "run_completed_no_repair"
+ return "no_repair_record"
+
+
+def _work_item_status(repair_status: str) -> str:
+ if repair_status in {"no_incident_link", "run_completed_no_repair"}:
+ return "none"
+ if repair_status == "auto_repair_verified":
+ return "closed"
+ return "open"
+
+
+def _attach_work_item_summary(
+ group: dict[str, Any],
+ repair_summaries_by_incident: dict[str, dict[str, Any]],
+) -> None:
+ incident_ids = [
+ str(incident_id) for incident_id in group.get("incident_ids", []) if incident_id
+ ]
+ latest_incident_id = str(group.get("latest_incident_id") or "") or (
+ incident_ids[0] if incident_ids else None
+ )
+ repair_summary = (
+ repair_summaries_by_incident.get(latest_incident_id)
+ if latest_incident_id
+ else None
+ )
+ status_value = _repair_status(
+ incident_id=latest_incident_id,
+ latest_run_state=group.get("latest_run_state"),
+ repair_summary=repair_summary,
+ )
+ if repair_summary:
+ repair_payload = dict(repair_summary)
+ repair_payload["status"] = status_value
+ else:
+ repair_payload = {
+ "schema_version": "awooop_recurrence_repair_summary_v1",
+ "status": status_value,
+ "incident_id": latest_incident_id,
+ "latest_auto_repair_id": None,
+ "latest_verification_result": None,
+ "auto_repair_total": 0,
+ "success_total": 0,
+ "failed_total": 0,
+ }
+
+ work_status = _work_item_status(status_value)
+ auto_repair_id = repair_payload.get("latest_auto_repair_id")
+ work_item_id = None
+ if latest_incident_id and work_status != "none":
+ work_item_id = (
+ f"verification:{latest_incident_id}:{auto_repair_id}"
+ if auto_repair_id
+ else f"incident:{latest_incident_id}"
+ )
+
+ group["latest_incident_id"] = latest_incident_id
+ group["repair_summary"] = repair_payload
+ group["work_item"] = {
+ "schema_version": "awooop_recurrence_work_item_link_v1",
+ "work_item_id": work_item_id,
+ "incident_id": latest_incident_id,
+ "auto_repair_id": auto_repair_id,
+ "status": work_status,
+ "kind": "verification" if auto_repair_id else "incident_followup",
+ "needs_human": work_status == "open",
+ }
+
+
def build_dossier_coverage(
rows: list[dict[str, Any]],
*,
@@ -229,9 +399,9 @@ def build_dossier_coverage(
provider_item["sentry_ref_total"] += event_sentry_refs
provider_item["signoz_ref_total"] += event_signoz_refs
provider_item["alert_ref_total"] += event_alert_refs
- provider_item["latest_received_at"] = (
- provider_item["latest_received_at"] or event.get("received_at")
- )
+ provider_item["latest_received_at"] = provider_item[
+ "latest_received_at"
+ ] or event.get("received_at")
duplicate_total = sum(1 for event in events if event.get("is_duplicate"))
redacted_total = sum(1 for event in events if event.get("has_redacted_content"))
@@ -258,7 +428,10 @@ def build_dossier_coverage(
},
"providers": sorted(
provider_map.values(),
- key=lambda item: (-int(item.get("total") or 0), str(item.get("provider") or "")),
+ key=lambda item: (
+ -int(item.get("total") or 0),
+ str(item.get("provider") or ""),
+ ),
),
}
@@ -299,6 +472,104 @@ def build_dossier_event(row: dict[str, Any]) -> dict[str, Any]:
}
+def _collect_incident_ids_from_rows(rows: list[dict[str, Any]]) -> list[str]:
+ incident_ids: list[str] = []
+ for row in rows:
+ event = build_dossier_event(row)
+ for incident_id in _event_incident_ids(event):
+ _append_unique(incident_ids, incident_id)
+ return incident_ids
+
+
+async def _fetch_auto_repair_summaries_by_incident(
+ db: Any,
+ incident_ids: list[str],
+) -> dict[str, dict[str, Any]]:
+ """Fetch latest auto-repair and verifier evidence for recurrence groups."""
+ visible_incident_ids = incident_ids[:_MAX_REPAIR_INCIDENTS]
+ if not visible_incident_ids:
+ return {}
+
+ placeholders: list[str] = []
+ params: dict[str, Any] = {}
+ for index, incident_id in enumerate(visible_incident_ids):
+ key = f"incident_id_{index}"
+ placeholders.append(f":{key}")
+ params[key] = incident_id
+
+ result = await db.execute(
+ text(
+ f"""
+ WITH ranked AS (
+ SELECT
+ are.id AS latest_auto_repair_id,
+ are.incident_id,
+ are.playbook_id AS latest_playbook_id,
+ are.playbook_name AS latest_playbook_name,
+ are.success AS latest_success,
+ left(coalesce(are.error_message, ''), 240) AS latest_error_message_preview,
+ are.triggered_by AS latest_triggered_by,
+ are.risk_level AS latest_risk_level,
+ are.execution_time_ms AS latest_execution_time_ms,
+ are.created_at AS latest_auto_repair_at,
+ latest_evidence.verification_result AS latest_verification_result,
+ latest_evidence.collected_at AS latest_verification_at,
+ COUNT(*) OVER (PARTITION BY are.incident_id) AS auto_repair_total,
+ COUNT(*) FILTER (WHERE are.success IS TRUE)
+ OVER (PARTITION BY are.incident_id) AS success_total,
+ COUNT(*) FILTER (WHERE are.success IS FALSE)
+ OVER (PARTITION BY are.incident_id) AS failed_total,
+ ROW_NUMBER() OVER (
+ PARTITION BY are.incident_id
+ ORDER BY are.created_at DESC
+ ) AS rn
+ FROM auto_repair_executions are
+ LEFT JOIN LATERAL (
+ SELECT
+ ev.verification_result,
+ ev.collected_at
+ FROM incident_evidence ev
+ WHERE ev.incident_id = are.incident_id
+ AND ev.verification_result IS NOT NULL
+ ORDER BY ev.collected_at DESC
+ LIMIT 1
+ ) latest_evidence ON TRUE
+ WHERE are.incident_id IN ({", ".join(placeholders)})
+ )
+ SELECT
+ latest_auto_repair_id,
+ incident_id,
+ latest_playbook_id,
+ latest_playbook_name,
+ latest_success,
+ latest_error_message_preview,
+ latest_triggered_by,
+ latest_risk_level,
+ latest_execution_time_ms,
+ latest_auto_repair_at,
+ latest_verification_result,
+ latest_verification_at,
+ auto_repair_total,
+ success_total,
+ failed_total
+ FROM ranked
+ WHERE rn = 1
+ """
+ ),
+ params,
+ )
+
+ summaries: dict[str, dict[str, Any]] = {}
+ for row in result.mappings().all():
+ item = dict(row)
+ incident_id = str(item.get("incident_id") or "")
+ if not incident_id:
+ continue
+ item["schema_version"] = "awooop_recurrence_repair_summary_v1"
+ summaries[incident_id] = item
+ return summaries
+
+
async def fetch_channel_event_dossier(
*,
project_id: str | None,
@@ -329,7 +600,8 @@ async def fetch_channel_event_dossier(
async with get_db_context(effective_project_id) as db:
result = await db.execute(
- text(f"""
+ text(
+ f"""
SELECT
event_id,
project_id,
@@ -347,7 +619,8 @@ async def fetch_channel_event_dossier(
WHERE {" AND ".join(where_clauses)}
ORDER BY received_at ASC
LIMIT :limit
- """),
+ """
+ ),
params,
)
rows = [dict(row) for row in result.mappings().all()]
@@ -364,7 +637,9 @@ async def fetch_channel_event_dossier(
"source_count": len(events),
"duplicate_total": duplicate_total,
"redacted_total": redacted_total,
- "source_ref_total": sum(int(event.get("source_ref_count") or 0) for event in events),
+ "source_ref_total": sum(
+ int(event.get("source_ref_count") or 0) for event in events
+ ),
},
}
@@ -392,7 +667,8 @@ async def fetch_channel_event_dossier_coverage(
async with get_db_context(effective_project_id) as db:
result = await db.execute(
- text(f"""
+ text(
+ f"""
SELECT
event_id,
project_id,
@@ -410,7 +686,8 @@ async def fetch_channel_event_dossier_coverage(
WHERE {" AND ".join(where_clauses)}
ORDER BY received_at DESC
LIMIT :limit
- """),
+ """
+ ),
params,
)
rows = [dict(row) for row in result.mappings().all()]
@@ -445,7 +722,8 @@ async def fetch_channel_event_dossier_recurrence(
async with get_db_context(effective_project_id) as db:
result = await db.execute(
- text(f"""
+ text(
+ f"""
SELECT
e.event_id,
e.project_id,
@@ -469,13 +747,19 @@ async def fetch_channel_event_dossier_recurrence(
WHERE {" AND ".join(where_clauses)}
ORDER BY e.received_at DESC
LIMIT :limit
- """),
+ """
+ ),
params,
)
rows = [dict(row) for row in result.mappings().all()]
+ repair_summaries = await _fetch_auto_repair_summaries_by_incident(
+ db,
+ _collect_incident_ids_from_rows(rows),
+ )
return build_dossier_recurrence(
rows,
project_id=effective_project_id,
limit=safe_limit,
+ repair_summaries_by_incident=repair_summaries,
)
diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py
index c8506bf3..b571586e 100644
--- a/apps/api/tests/test_channel_event_dossier_service.py
+++ b/apps/api/tests/test_channel_event_dossier_service.py
@@ -1,13 +1,14 @@
from __future__ import annotations
+from uuid import UUID
+
import pytest
from fastapi import HTTPException
-from uuid import UUID
from src.services import channel_event_dossier_service
from src.services.channel_event_dossier_service import (
- build_dossier_event,
build_dossier_coverage,
+ build_dossier_event,
build_dossier_recurrence,
fetch_channel_event_dossier,
fetch_channel_event_dossier_coverage,
@@ -16,38 +17,40 @@ from src.services.channel_event_dossier_service import (
def test_build_dossier_event_summarizes_source_envelope() -> None:
- event = build_dossier_event({
- "event_id": "event-1",
- "project_id": "awoooi",
- "channel_type": "internal",
- "provider_event_id": "sentry:received:issue-1",
- "content_hash": "h" * 64,
- "content_preview": "Sentry issue",
- "content_redacted": "Sentry issue redacted",
- "redaction_version": "audit_sink_v1",
- "source_envelope": {
- "provider": "sentry",
- "stage": "received",
- "source_url": "https://sentry.example.invalid/issues/issue-1",
- "content_sha256": "a" * 64,
- "content_length": 42,
- "source_refs": {
- "event_ids": ["issue-1"],
- "sentry_issue_ids": ["issue-1", "sentry:received:issue-1"],
- "fingerprints": ["sentry-issue-1"],
+ event = build_dossier_event(
+ {
+ "event_id": "event-1",
+ "project_id": "awoooi",
+ "channel_type": "internal",
+ "provider_event_id": "sentry:received:issue-1",
+ "content_hash": "h" * 64,
+ "content_preview": "Sentry issue",
+ "content_redacted": "Sentry issue redacted",
+ "redaction_version": "audit_sink_v1",
+ "source_envelope": {
+ "provider": "sentry",
+ "stage": "received",
+ "source_url": "https://sentry.example.invalid/issues/issue-1",
+ "content_sha256": "a" * 64,
+ "content_length": 42,
+ "source_refs": {
+ "event_ids": ["issue-1"],
+ "sentry_issue_ids": ["issue-1", "sentry:received:issue-1"],
+ "fingerprints": ["sentry-issue-1"],
+ },
+ "log_correlation": {
+ "alertname": "Sentry Issue",
+ "severity": "error",
+ "namespace": "sentry",
+ "target_resource": "frontend",
+ "fingerprint": "sentry-issue-1",
+ },
},
- "log_correlation": {
- "alertname": "Sentry Issue",
- "severity": "error",
- "namespace": "sentry",
- "target_resource": "frontend",
- "fingerprint": "sentry-issue-1",
- },
- },
- "is_duplicate": False,
- "provider_ts": None,
- "received_at": "2026-05-13T13:46:00",
- })
+ "is_duplicate": False,
+ "provider_ts": None,
+ "received_at": "2026-05-13T13:46:00",
+ }
+ )
assert event["provider"] == "sentry"
assert event["stage"] == "received"
@@ -153,6 +156,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
"provider": "alertmanager",
"source_refs": {
"alert_ids": ["alert-2"],
+ "incident_ids": ["INC-20260513-ABCD"],
"fingerprints": ["fp-host-disk"],
},
"log_correlation": {
@@ -183,6 +187,7 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
"provider": "alertmanager",
"source_refs": {
"alert_ids": ["alert-1"],
+ "incident_ids": ["INC-20260513-ABCD"],
"fingerprints": ["fp-host-disk"],
},
"log_correlation": {
@@ -224,6 +229,26 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
],
project_id="awoooi",
limit=100,
+ repair_summaries_by_incident={
+ "INC-20260513-ABCD": {
+ "schema_version": "awooop_recurrence_repair_summary_v1",
+ "incident_id": "INC-20260513-ABCD",
+ "latest_auto_repair_id": "repair-1",
+ "latest_playbook_id": "playbook-1",
+ "latest_playbook_name": "Restart workload",
+ "latest_success": False,
+ "latest_error_message_preview": "verifier failed",
+ "latest_triggered_by": "auto_repair",
+ "latest_risk_level": "low",
+ "latest_execution_time_ms": 1200,
+ "latest_auto_repair_at": "2026-05-13T13:48:00",
+ "latest_verification_result": "failed",
+ "latest_verification_at": "2026-05-13T13:49:00",
+ "auto_repair_total": 1,
+ "success_total": 0,
+ "failed_total": 1,
+ },
+ },
)
assert recurrence["summary"]["source_event_total"] == 3
@@ -232,6 +257,9 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
assert recurrence["summary"]["duplicate_event_total"] == 1
assert recurrence["summary"]["linked_run_total"] == 2
assert recurrence["summary"]["unlinked_event_total"] == 1
+ assert recurrence["summary"]["auto_repair_linked_total"] == 1
+ assert recurrence["summary"]["open_work_item_group_total"] == 1
+ assert recurrence["summary"]["verified_repair_group_total"] == 0
host_group = recurrence["items"][0]
assert host_group["recurrence_key"] == "fingerprint:fp-host-disk"
@@ -239,8 +267,21 @@ def test_build_dossier_recurrence_groups_events_and_run_state() -> None:
assert host_group["duplicate_total"] == 1
assert host_group["linked_run_total"] == 2
assert host_group["latest_run_state"] == "waiting_approval"
+ assert host_group["latest_incident_id"] == "INC-20260513-ABCD"
+ assert host_group["incident_ids"] == ["INC-20260513-ABCD"]
assert host_group["run_state_counts"] == {"waiting_approval": 1, "completed": 1}
assert host_group["alert_ref_total"] == 2
+ assert host_group["repair_summary"]["status"] == "auto_repair_failed"
+ assert host_group["repair_summary"]["latest_auto_repair_id"] == "repair-1"
+ assert host_group["work_item"] == {
+ "schema_version": "awooop_recurrence_work_item_link_v1",
+ "work_item_id": "verification:INC-20260513-ABCD:repair-1",
+ "incident_id": "INC-20260513-ABCD",
+ "auto_repair_id": "repair-1",
+ "status": "open",
+ "kind": "verification",
+ "needs_human": True,
+ }
@pytest.mark.asyncio
@@ -306,7 +347,9 @@ async def test_fetch_channel_event_dossier_uses_typed_run_filter(monkeypatch) ->
@pytest.mark.asyncio
-async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(monkeypatch) -> None:
+async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(
+ monkeypatch,
+) -> None:
captured: dict[str, object] = {}
class FakeMappings:
@@ -353,7 +396,9 @@ async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(m
@pytest.mark.asyncio
-async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter(monkeypatch) -> None:
+async def test_fetch_channel_event_dossier_recurrence_uses_joined_typed_filter(
+ monkeypatch,
+) -> None:
captured: dict[str, object] = {}
class FakeMappings:
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 3edcb605..3ad442b1 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1911,13 +1911,17 @@
"groups": "Link groups",
"recurrent": "Recurring groups",
"duplicates": "Duplicate events",
- "linkedRuns": "Linked Runs"
+ "linkedRuns": "Linked Runs",
+ "autoRepair": "Auto repair",
+ "openWorkItems": "Open work items"
},
"details": {
"sourceEvents": "{count} source events",
"latest": "Latest {time}",
"unlinked": "{count} items not linked to a Run",
- "limit": "Latest {count} item window"
+ "limit": "Latest {count} item window",
+ "verifiedRepair": "{count} verified repair groups",
+ "manualGates": "{count} manual gates"
},
"states": {
"pending": "Pending",
@@ -1930,13 +1934,28 @@
"timeout": "Timed out",
"unlinked": "Not linked"
},
+ "repairStatuses": {
+ "no_incident_link": "No Incident link",
+ "auto_repair_verified": "Verified repair",
+ "auto_repair_succeeded_unverified": "Repair needs verification",
+ "auto_repair_failed": "Repair failed",
+ "auto_repair_recorded": "Repair recorded",
+ "manual_gate": "Manual gate needed",
+ "investigating": "Investigating",
+ "run_completed_no_repair": "Run completed without repair",
+ "no_repair_record": "No repair record"
+ },
"item": {
"latest": "Latest {time}",
"duplicates": "Duplicates {count}",
"refs": "Refs {count}",
"linkedRuns": "Runs {count}",
+ "incident": "Incident {incidentId}",
+ "repair": "Repair {status}",
"openRun": "Open Run",
- "noRun": "No Run yet"
+ "noRun": "No Run yet",
+ "openWorkItem": "Open work item",
+ "noWorkItem": "No open work item"
}
},
"callbackReply": {
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index be1d55ab..c8f2f7a5 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -1912,13 +1912,17 @@
"groups": "關聯群組",
"recurrent": "重複群組",
"duplicates": "重複事件",
- "linkedRuns": "已連 Run"
+ "linkedRuns": "已連 Run",
+ "autoRepair": "自動修復",
+ "openWorkItems": "待處理項"
},
"details": {
"sourceEvents": "{count} 筆來源事件",
"latest": "最新 {time}",
"unlinked": "{count} 筆尚未連 Run",
- "limit": "最近 {count} 筆視窗"
+ "limit": "最近 {count} 筆視窗",
+ "verifiedRepair": "{count} 組已驗證修復",
+ "manualGates": "{count} 組人工閘門"
},
"states": {
"pending": "待執行",
@@ -1931,13 +1935,28 @@
"timeout": "已超時",
"unlinked": "尚未連 Run"
},
+ "repairStatuses": {
+ "no_incident_link": "尚未連 Incident",
+ "auto_repair_verified": "已驗證修復",
+ "auto_repair_succeeded_unverified": "修復待驗證",
+ "auto_repair_failed": "修復失敗",
+ "auto_repair_recorded": "修復已記錄",
+ "manual_gate": "需人工閘門",
+ "investigating": "調查中",
+ "run_completed_no_repair": "Run 完成無修復",
+ "no_repair_record": "無修復記錄"
+ },
"item": {
"latest": "最新 {time}",
"duplicates": "重複 {count}",
"refs": "Refs {count}",
"linkedRuns": "Run {count}",
+ "incident": "Incident {incidentId}",
+ "repair": "修復 {status}",
"openRun": "開啟 Run",
- "noRun": "尚無 Run"
+ "noRun": "尚無 Run",
+ "openWorkItem": "開啟工作項",
+ "noWorkItem": "無待處理項"
}
},
"callbackReply": {
diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx
index e603805f..27325242 100644
--- a/apps/web/src/app/[locale]/awooop/runs/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx
@@ -55,6 +55,16 @@ type RemediationStatus =
| "write_observed"
| "blocked"
| "observed";
+type RecurrenceRepairStatus =
+ | "no_incident_link"
+ | "auto_repair_verified"
+ | "auto_repair_succeeded_unverified"
+ | "auto_repair_failed"
+ | "auto_repair_recorded"
+ | "manual_gate"
+ | "investigating"
+ | "run_completed_no_repair"
+ | "no_repair_record";
interface RemediationSummary {
schema_version?: string;
@@ -183,9 +193,43 @@ interface EventRecurrenceSummary {
duplicate_event_total: number;
linked_run_total: number;
unlinked_event_total: number;
+ auto_repair_linked_total?: number;
+ verified_repair_group_total?: number;
+ open_work_item_group_total?: number;
+ manual_gate_group_total?: number;
latest_received_at?: string | null;
}
+interface EventRecurrenceRepairSummary {
+ schema_version?: string;
+ status?: RecurrenceRepairStatus | string;
+ incident_id?: string | null;
+ latest_auto_repair_id?: string | null;
+ latest_playbook_id?: string | null;
+ latest_playbook_name?: string | null;
+ latest_success?: boolean | null;
+ latest_error_message_preview?: string | null;
+ latest_triggered_by?: string | null;
+ latest_risk_level?: string | null;
+ latest_execution_time_ms?: number | null;
+ latest_auto_repair_at?: string | null;
+ latest_verification_result?: string | null;
+ latest_verification_at?: string | null;
+ auto_repair_total?: number;
+ success_total?: number;
+ failed_total?: number;
+}
+
+interface EventRecurrenceWorkItem {
+ schema_version?: string;
+ work_item_id?: string | null;
+ incident_id?: string | null;
+ auto_repair_id?: string | null;
+ status?: "open" | "closed" | "none" | string;
+ kind?: string | null;
+ needs_human?: boolean;
+}
+
interface EventRecurrenceItem {
recurrence_key: string;
provider?: string | null;
@@ -200,6 +244,10 @@ interface EventRecurrenceItem {
latest_run_id?: string | null;
latest_run_state?: RunState | string | null;
latest_agent_id?: string | null;
+ latest_incident_id?: string | null;
+ incident_ids?: string[];
+ repair_summary?: EventRecurrenceRepairSummary | null;
+ work_item?: EventRecurrenceWorkItem | null;
occurrence_total: number;
duplicate_total: number;
linked_run_total: number;
@@ -944,6 +992,23 @@ function recurrenceStateLabelKey(state?: string | null) {
return "states.unlinked";
}
+function recurrenceRepairStatusLabelKey(status?: string | null) {
+ if (
+ status === "no_incident_link" ||
+ status === "auto_repair_verified" ||
+ status === "auto_repair_succeeded_unverified" ||
+ status === "auto_repair_failed" ||
+ status === "auto_repair_recorded" ||
+ status === "manual_gate" ||
+ status === "investigating" ||
+ status === "run_completed_no_repair" ||
+ status === "no_repair_record"
+ ) {
+ return `repairStatuses.${status}`;
+ }
+ return "repairStatuses.no_repair_record";
+}
+
function EventRecurrencePanel({
recurrence,
error,
@@ -987,6 +1052,20 @@ function EventRecurrencePanel({
detail: t("details.limit", { count: recurrence?.limit ?? 0 }),
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
},
+ {
+ label: t("metrics.autoRepair"),
+ value: summary?.auto_repair_linked_total ?? 0,
+ detail: t("details.verifiedRepair", { count: summary?.verified_repair_group_total ?? 0 }),
+ className: "border-[#b9a6d9] bg-[#f5f0ff] text-[#51358f]",
+ },
+ {
+ label: t("metrics.openWorkItems"),
+ value: summary?.open_work_item_group_total ?? 0,
+ detail: t("details.manualGates", { count: summary?.manual_gate_group_total ?? 0 }),
+ className: (summary?.open_work_item_group_total ?? 0) > 0
+ ? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
+ : "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
+ },
];
return (
@@ -1010,7 +1089,7 @@ function EventRecurrencePanel({
) : (
<>
-
+
{metrics.map((item) => (
@@ -1049,9 +1128,16 @@ function EventRecurrencePanel({
: "--";
const stateKey = recurrenceStateLabelKey(item.latest_run_state);
const stateLabel = t(stateKey as never);
+ const repair = item.repair_summary;
+ const workItem = item.work_item;
+ const repairKey = recurrenceRepairStatusLabelKey(repair?.status);
+ const repairLabel = t(repairKey as never);
const runHref = item.latest_run_id
? `/awooop/runs/${item.latest_run_id}?project_id=${encodeURIComponent(recurrence?.project_id ?? "awoooi")}`
: null;
+ const workHref = workItem?.work_item_id
+ ? `/awooop/work-items?project_id=${encodeURIComponent(recurrence?.project_id ?? "awoooi")}&work_item_id=${encodeURIComponent(workItem.work_item_id)}${workItem.incident_id ? `&incident_id=${encodeURIComponent(workItem.incident_id)}` : ""}`
+ : null;
return (
@@ -1073,11 +1159,25 @@ function EventRecurrencePanel({
{t("item.duplicates", { count: item.duplicate_total })}
{t("item.refs", { count: item.source_ref_total })}
{t("item.linkedRuns", { count: item.linked_run_total })}
+ {t("item.incident", { incidentId: item.latest_incident_id || "--" })}
+ {t("item.repair", { status: repairLabel })}
{stateLabel}
+
+ {repairLabel}
+
{runHref ? (
{t("item.noRun")}
)}
+ {workHref ? (
+
+
+ {t("item.openWorkItem")}
+
+ ) : (
+ {t("item.noWorkItem")}
+ )}
);