From bc89940564d63431010e838a2e37a9ea0fd07049 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 15 May 2026 02:31:46 +0800 Subject: [PATCH] feat(awooop): link remediation evidence to run timeline --- .../src/services/platform_operator_service.py | 190 ++++++++++++++++++ .../test_awooop_operator_timeline_labels.py | 58 +++++- apps/web/messages/en.json | 35 ++++ apps/web/messages/zh-TW.json | 35 ++++ .../awooop/approvals/[run_id]/page.tsx | 124 +++++++++++- .../[locale]/awooop/runs/[run_id]/page.tsx | 142 +++++++++++++ docs/LOGBOOK.md | 33 +++ 7 files changed, 610 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index f852e77b2..e8ab8830a 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -8,6 +8,7 @@ ADR-106(AwoooP Agent Platform) from __future__ import annotations +import re import uuid from datetime import UTC, datetime from typing import Any @@ -40,6 +41,8 @@ _MAX_PER_PAGE = 200 _MAX_EVENTS = 100 _MAX_TIMELINE_ITEMS = 100 _MAX_STEP_SUMMARY_CHARS = 128 +_REMEDIATION_HISTORY_LIMIT = 20 +_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") # ============================================================================= # Tenants @@ -252,6 +255,165 @@ def _mcp_gateway_summary_row(row: AwoooPMcpGatewayAudit) -> dict[str, Any]: } +def _as_dict(value: Any) -> dict[str, Any]: + """Return dict payloads defensively; DB JSON fields may be null or stale.""" + return value if isinstance(value, dict) else {} + + +def _append_unique(values: list[str], candidate: Any) -> None: + """Append non-empty string once while preserving discovery order.""" + 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: + """Extract incident ids from legacy text payloads.""" + 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_source_envelope(values: list[str], envelope: Any) -> None: + """Extract incident ids from AwoooP channel event source_refs.""" + source_refs = _as_dict(_as_dict(envelope).get("source_refs")) + 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 _collect_run_incident_ids( + *, + run: AwoooPRunState, + inbound_events: list[AwoooPConversationEvent], + outbound_messages: list[AwoooPOutboundMessage], +) -> list[str]: + """Collect incident ids that tie a Run back to legacy incident evidence.""" + incident_ids: list[str] = [] + _append_incident_ids_from_text(incident_ids, run.trigger_ref) + _append_incident_ids_from_text(incident_ids, run.error_detail) + + for event in inbound_events: + _append_incident_ids_from_source_envelope(incident_ids, event.source_envelope) + _append_incident_ids_from_text(incident_ids, event.content_preview) + _append_incident_ids_from_text(incident_ids, event.content_redacted) + + for message in outbound_messages: + _append_incident_ids_from_text(incident_ids, message.content_preview) + _append_incident_ids_from_text(incident_ids, message.send_error) + + return incident_ids + + +def _route_label_from_remediation(item: dict[str, Any]) -> str: + """Render remediation MCP route consistently with Telegram / Work Items.""" + return "/".join( + str(part) + for part in ( + item.get("agent_id"), + item.get("tool_name"), + item.get("required_scope"), + ) + if part + ) or "--" + + +def _remediation_timeline_status(item: dict[str, Any]) -> str: + if item.get("success") is False or item.get("allowed") is False: + return "failed" + if item.get("verification_result_preview") == "success": + return "success" + return "warning" + + +def _remediation_timeline_summary(item: dict[str, Any]) -> str: + return ( + f"incident={item.get('incident_id') or '--'} " + f"mode={item.get('mode') or '--'} " + f"preview={item.get('verification_result_preview') or '--'} " + f"route={_route_label_from_remediation(item)} " + f"writes_incident={item.get('writes_incident_state')} " + f"writes_auto_repair={item.get('writes_auto_repair_result')}" + )[:500] + + +def _summarize_run_remediation_by_work_item( + items: list[dict[str, Any]], +) -> list[dict[str, Any]]: + summary: dict[str, dict[str, Any]] = {} + for item in items: + key = str(item.get("work_item_id") or item.get("incident_id") or item.get("id")) + if key not in summary: + summary[key] = { + "work_item_id": item.get("work_item_id"), + "incident_id": item.get("incident_id"), + "count": 0, + "latest_at": item.get("created_at"), + "latest_preview": item.get("verification_result_preview"), + "latest_mode": item.get("mode"), + "latest_route": _route_label_from_remediation(item), + } + summary[key]["count"] += 1 + return list(summary.values()) + + +async def _fetch_run_remediation_history( + incident_ids: list[str], + *, + limit: int = _REMEDIATION_HISTORY_LIMIT, +) -> dict[str, Any]: + """Fetch durable ADR-100 remediation dry-run evidence linked to run incidents.""" + if not incident_ids: + return { + "schema_version": "awooop_run_remediation_evidence_v1", + "source": "alert_operation_log", + "incident_ids": [], + "total": 0, + "limit": limit, + "items": [], + "by_work_item": [], + "errors": [], + } + + from src.services.adr100_remediation_service import Adr100RemediationService + + service = Adr100RemediationService(record_history=False) + items: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + for incident_id in incident_ids: + try: + history = await service.history(limit=limit, incident_id=incident_id) + items.extend( + item + for item in history.get("items", []) + if isinstance(item, dict) + ) + except Exception as exc: + logger.warning( + "run_remediation_history_fetch_failed", + incident_id=incident_id, + error=str(exc), + ) + errors.append({"incident_id": incident_id, "error": str(exc)}) + + items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True) + visible_items = items[:limit] + return { + "schema_version": "awooop_run_remediation_evidence_v1", + "source": "alert_operation_log", + "incident_ids": incident_ids, + "total": len(items), + "limit": limit, + "items": visible_items, + "by_work_item": _summarize_run_remediation_by_work_item(visible_items), + "errors": errors, + } + + async def get_run_detail( run_id: str, project_id: str | None = None, @@ -406,6 +568,12 @@ async def get_run_detail( mcp_gateway_summary = _summarize_gateway_mcp([ _mcp_gateway_summary_row(row) for row in mcp_calls ]) + incident_ids = _collect_run_incident_ids( + run=run, + inbound_events=inbound_events, + outbound_messages=outbound_messages, + ) + remediation_history = await _fetch_run_remediation_history(incident_ids) timeline: list[dict[str, Any]] = [ _timeline_item( @@ -480,6 +648,26 @@ async def get_run_detail( }, ) ) + for item in remediation_history.get("items", []): + if not isinstance(item, dict): + continue + timeline.append( + _timeline_item( + ts=item.get("created_at"), + kind="remediation", + title="ADR-100 補救試跑", + status=_remediation_timeline_status(item), + summary=_remediation_timeline_summary(item), + metadata={ + "incident_id": item.get("incident_id"), + "work_item_id": item.get("work_item_id"), + "mcp_route": _route_label_from_remediation(item), + "writes_incident_state": item.get("writes_incident_state"), + "writes_auto_repair_result": item.get("writes_auto_repair_result"), + "history_source": "alert_operation_log", + }, + ) + ) for row in outbound_messages: timeline.append( _timeline_item( @@ -522,12 +710,14 @@ async def get_run_detail( "outbound_messages": outbound_items, "mcp_calls": mcp_items, "mcp_gateway": mcp_gateway_summary, + "remediation_history": remediation_history, "timeline": timeline, "counts": { "steps": len(step_items), "inbound_events": len(inbound_items), "outbound_messages": len(outbound_items), "mcp_calls": len(mcp_items), + "remediation_history": remediation_history.get("total", 0), "timeline": len(timeline), }, } diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index a14eea548..ac78d9c1d 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1,4 +1,10 @@ -from src.services.platform_operator_service import _outbound_timeline_title +from types import SimpleNamespace + +from src.services.platform_operator_service import ( + _collect_run_incident_ids, + _outbound_timeline_title, + _remediation_timeline_summary, +) def test_outbound_timeline_title_labels_runbook_review() -> None: @@ -45,3 +51,53 @@ def test_outbound_timeline_title_falls_back_to_human_label() -> None: title = _outbound_timeline_title("telegram", "interim", "正在調用 MCP 工具") assert title == "TELEGRAM:漸進式狀態回饋" + + +def test_collect_run_incident_ids_reads_source_refs_and_legacy_text() -> None: + run = SimpleNamespace( + trigger_ref="not-an-incident", + error_detail=None, + ) + inbound_events = [ + SimpleNamespace( + source_envelope={ + "source_refs": { + "incident_ids": ["INC-20260514-F85F21", "INC-20260514-F85F21"], + } + }, + content_preview="Alertmanager inbound converged", + content_redacted=None, + ) + ] + outbound_messages = [ + SimpleNamespace( + content_preview="詳情:INC-20260513-79ED5E", + send_error=None, + ) + ] + + incident_ids = _collect_run_incident_ids( + run=run, + inbound_events=inbound_events, + outbound_messages=outbound_messages, + ) + + assert incident_ids == ["INC-20260514-F85F21", "INC-20260513-79ED5E"] + + +def test_remediation_timeline_summary_surfaces_route_and_write_flags() -> None: + summary = _remediation_timeline_summary({ + "incident_id": "INC-20260514-F85F21", + "mode": "replay", + "verification_result_preview": "degraded", + "agent_id": "auto_repair_executor", + "tool_name": "ssh_diagnose", + "required_scope": "read", + "writes_incident_state": False, + "writes_auto_repair_result": False, + }) + + assert "incident=INC-20260514-F85F21" in summary + assert "route=auto_repair_executor/ssh_diagnose/read" in summary + assert "writes_incident=False" in summary + assert "writes_auto_repair=False" in summary diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 3a3c70016..d2bae95f8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1845,6 +1845,24 @@ "legacyBridge": "Legacy bridge" } }, + "remediation": { + "title": "Remediation Dry-run Evidence", + "empty": "This run is not linked to ADR-100 remediation dry-run history yet.", + "latest": "Latest dry-run", + "route": "MCP Route", + "preview": "Mode {mode}; preview {preview}", + "writes": "Writes: incident={incident}; autoRepair={autoRepair}", + "status": { + "linked": "Linked to remediation history", + "empty": "No remediation history" + }, + "metrics": { + "incidents": "Incident", + "dryRuns": "Dry-run", + "tools": "Tools", + "writes": "Write flags" + } + }, "dossier": { "title": "Source Event Dossier", "empty": "This run is not linked to replayable inbound source events yet.", @@ -1926,6 +1944,7 @@ "shadow": "Shadow", "success": "Success", "timeout": "Timed out", + "warning": "Warning", "waitingApproval": "Waiting approval" } }, @@ -1950,6 +1969,22 @@ "title": "This run is not waiting for human approval", "detail": "Current state is {state}. This page will not show approve / reject; return to Run Timeline for the latest state." }, + "remediation": { + "title": "Remediation Dry-run Evidence", + "empty": "This run is not linked to remediation dry-run history yet; check the Run Timeline source dossier and MCP Gateway before approval.", + "latest": "Latest dry-run", + "preview": "Mode {mode}; preview {preview}", + "writes": "Writes: incident={incident}; autoRepair={autoRepair}", + "status": { + "linked": "Linked to remediation history", + "empty": "No remediation history" + }, + "metrics": { + "incidents": "Incident", + "dryRuns": "Dry-run", + "tools": "Tools" + } + }, "details": { "title": "Run Details", "runId": "Run ID", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 7c3770e62..581ddbc07 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1846,6 +1846,24 @@ "legacyBridge": "Legacy bridge" } }, + "remediation": { + "title": "補救試跑證據", + "empty": "此 Run 尚未連到 ADR-100 補救試跑歷史。", + "latest": "最新試跑", + "route": "MCP 路由", + "preview": "模式 {mode};預覽 {preview}", + "writes": "寫入:incident={incident};autoRepair={autoRepair}", + "status": { + "linked": "已連到補救歷史", + "empty": "尚無補救歷史" + }, + "metrics": { + "incidents": "Incident", + "dryRuns": "Dry-run", + "tools": "Tools", + "writes": "寫入旗標" + } + }, "dossier": { "title": "來源事件卷宗", "empty": "此 Run 尚未連到可回放的入站來源事件。", @@ -1927,6 +1945,7 @@ "shadow": "Shadow", "success": "成功", "timeout": "已超時", + "warning": "警告", "waitingApproval": "等待審批" } }, @@ -1951,6 +1970,22 @@ "title": "此 Run 目前不在人工審批狀態", "detail": "目前狀態為 {state}。此頁不會顯示 approve / reject,請回 Run Timeline 檢查最新狀態。" }, + "remediation": { + "title": "補救試跑證據", + "empty": "此 Run 尚未連到補救試跑歷史;核准前仍需回 Run Timeline 檢查來源卷宗與 MCP Gateway。", + "latest": "最新試跑", + "preview": "模式 {mode};預覽 {preview}", + "writes": "寫入:incident={incident};autoRepair={autoRepair}", + "status": { + "linked": "已連到補救歷史", + "empty": "尚無補救歷史" + }, + "metrics": { + "incidents": "Incident", + "dryRuns": "Dry-run", + "tools": "Tools" + } + }, "details": { "title": "Run 詳情", "runId": "Run ID", diff --git a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx index 72e9997d0..c170f65c2 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx @@ -40,8 +40,28 @@ interface RunDetail { timeout_at?: string | null; } +interface RemediationHistoryItem { + incident_id?: string | null; + mode?: string | null; + verification_result_preview?: string | null; + tool_count?: number | null; + agent_id?: string | null; + tool_name?: string | null; + required_scope?: string | null; + writes_incident_state?: boolean | null; + writes_auto_repair_result?: boolean | null; + created_at?: string | null; +} + +interface RunRemediationHistory { + incident_ids?: string[]; + total?: number; + items?: RemediationHistoryItem[]; +} + interface RunDetailResponse { run: RunDetail; + remediation_history?: RunRemediationHistory; } const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; @@ -71,6 +91,17 @@ function runTimelineHref(runId: string, projectId?: string | null) { return `/awooop/runs/${runId}${query}` as never; } +function remediationRoute(item?: RemediationHistoryItem | null) { + if (!item) return "--"; + return [item.agent_id, item.tool_name, item.required_scope].filter(Boolean).join("/") || "--"; +} + +function booleanLabel(value: boolean | null | undefined, emptyLabel: string) { + if (value === true) return "true"; + if (value === false) return "false"; + return emptyLabel; +} + function DetailRow({ label, value, @@ -90,6 +121,80 @@ function DetailRow({ ); } +function ApprovalRemediationEvidence({ + history, + locale, + emptyLabel, +}: { + history?: RunRemediationHistory; + locale: string; + emptyLabel: string; +}) { + const t = useTranslations("awooop.approvalDecision.remediation"); + const total = history?.total ?? 0; + const latest = history?.items?.[0] ?? null; + const incidentIds = history?.incident_ids ?? []; + const hasRecords = total > 0; + + return ( +
+
+
+
+ + {hasRecords ? t("status.linked") : t("status.empty")} + +
+
+ {[ + { label: t("metrics.incidents"), value: incidentIds.length || emptyLabel }, + { label: t("metrics.dryRuns"), value: total }, + { label: t("metrics.tools"), value: latest?.tool_count ?? emptyLabel }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value} +

+
+ ))} +
+ {latest ? ( +
+

+ {t("latest")}{" "} + {latest.incident_id ?? emptyLabel}{" "} + {t("preview", { + mode: latest.mode ?? emptyLabel, + preview: latest.verification_result_preview ?? emptyLabel, + })} +

+

+ {remediationRoute(latest)} · {formatDate(latest.created_at, locale, emptyLabel)} +

+

+ {t("writes", { + incident: booleanLabel(latest.writes_incident_state, emptyLabel), + autoRepair: booleanLabel(latest.writes_auto_repair_result, emptyLabel), + })} +

+
+ ) : ( +
{t("empty")}
+ )} +
+ ); +} + function DecisionDialog({ decision, runId, @@ -204,7 +309,7 @@ export default function ApprovalDecisionPage({ const searchParams = useSearchParams(); const projectId = searchParams.get("project_id") ?? ""; - const [run, setRun] = useState(null); + const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [actionLoading, setActionLoading] = useState(false); @@ -213,8 +318,8 @@ export default function ApprovalDecisionPage({ const [dialogDecision, setDialogDecision] = useState<"approve" | "reject" | null>(null); const timelineHref = useMemo( - () => runTimelineHref(run_id, run?.project_id || projectId || undefined), - [projectId, run?.project_id, run_id] + () => runTimelineHref(run_id, detail?.run?.project_id || projectId || undefined), + [detail?.run?.project_id, projectId, run_id] ); const fetchRun = useCallback(async () => { @@ -226,7 +331,7 @@ export default function ApprovalDecisionPage({ const res = await fetch(`${API_BASE}/api/v1/platform/runs/${run_id}/detail${suffix}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: RunDetailResponse = await res.json(); - setRun(data.run); + setDetail(data); } catch (err) { setError(err instanceof Error ? err.message : t("errors.loadFailed")); } finally { @@ -240,7 +345,7 @@ export default function ApprovalDecisionPage({ }, [fetchRun]); const handleDecision = async (decision: "approve" | "reject", reason?: string) => { - if (!run?.project_id) { + if (!detail?.run?.project_id) { setActionError(t("errors.missingProject")); setDialogDecision(null); return; @@ -252,7 +357,7 @@ export default function ApprovalDecisionPage({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - project_id: run.project_id, + project_id: detail.run.project_id, decision, reason: reason ?? null, }), @@ -269,6 +374,7 @@ export default function ApprovalDecisionPage({ } }; + const run = detail?.run; const isWaitingApproval = run?.state === "waiting_approval"; const stateClass = STATE_STYLE[run?.state ?? ""] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]"; @@ -362,6 +468,12 @@ export default function ApprovalDecisionPage({ )} + +

{t("details.title")}

diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index 24206b6bc..1b8ca5bc7 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -127,15 +127,54 @@ interface McpGatewaySummary { by_scope: McpGatewayBucket[]; } +interface RemediationHistoryItem { + id?: string; + incident_id?: string | null; + work_item_id?: string | null; + mode?: string | null; + safety_level?: string | null; + verification_result_preview?: string | null; + tool_count?: number | null; + tools?: string[]; + agent_id?: string | null; + tool_name?: string | null; + required_scope?: string | null; + writes_incident_state?: boolean | null; + writes_auto_repair_result?: boolean | null; + created_at?: string | null; + checks?: Array<{ name?: string; passed?: boolean; detail?: string }>; +} + +interface RunRemediationHistory { + schema_version?: string; + source?: string; + incident_ids?: string[]; + total?: number; + limit?: number; + items?: RemediationHistoryItem[]; + by_work_item?: Array<{ + work_item_id?: string | null; + incident_id?: string | null; + count?: number; + latest_at?: string | null; + latest_preview?: string | null; + latest_mode?: string | null; + latest_route?: string | null; + }>; + errors?: Array<{ incident_id?: string; error?: string }>; +} + interface RunDetailResponse { run: RunDetail; timeline: TimelineItem[]; mcp_gateway?: McpGatewaySummary; + remediation_history?: RunRemediationHistory; counts: { steps: number; inbound_events: number; outbound_messages: number; mcp_calls: number; + remediation_history?: number; timeline: number; }; } @@ -157,6 +196,7 @@ const STATUS_STYLE: Record = { blocked: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", cancelled: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", timeout: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + warning: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", }; const STATUS_TRANSLATION_KEYS: Record = { @@ -208,6 +248,7 @@ function itemIcon(kind: string) { if (kind === "outbound") return Send; if (kind === "step") return Wrench; if (kind === "mcp") return ShieldAlert; + if (kind === "remediation") return SearchCheck; return Route; } @@ -227,6 +268,17 @@ function shortDigest(value?: string | null) { return value.length > 16 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value; } +function remediationRoute(item?: RemediationHistoryItem | null) { + if (!item) return "--"; + return [item.agent_id, item.tool_name, item.required_scope].filter(Boolean).join("/") || "--"; +} + +function booleanLabel(value: boolean | null | undefined, emptyLabel: string) { + if (value === true) return "true"; + if (value === false) return "false"; + return emptyLabel; +} + function RunActionPanel({ run, counts, @@ -567,6 +619,90 @@ function McpGatewayPanel({ ); } +function RemediationEvidencePanel({ + history, + locale, + emptyLabel, +}: { + history?: RunRemediationHistory; + locale: string; + emptyLabel: string; +}) { + const t = useTranslations("awooop.runDetail.remediation"); + const total = history?.total ?? 0; + const latest = history?.items?.[0] ?? null; + const hasRecords = total > 0; + const incidentIds = history?.incident_ids ?? []; + const route = remediationRoute(latest); + const metrics = [ + { label: t("metrics.incidents"), value: incidentIds.length || emptyLabel }, + { label: t("metrics.dryRuns"), value: total }, + { label: t("metrics.tools"), value: latest?.tool_count ?? emptyLabel }, + { + label: t("metrics.writes"), + value: latest + ? `${booleanLabel(latest.writes_incident_state, emptyLabel)}/${booleanLabel( + latest.writes_auto_repair_result, + emptyLabel + )}` + : emptyLabel, + }, + ]; + + return ( +
+
+
+
+ + {hasRecords ? t("status.linked") : t("status.empty")} + +
+
+ {metrics.map((item) => ( +
+

{item.label}

+

+ {item.value} +

+
+ ))} +
+ {latest ? ( +
+
+

{t("latest")}

+

+ {latest.incident_id ?? emptyLabel}{" "} + {t("preview", { + mode: latest.mode ?? emptyLabel, + preview: latest.verification_result_preview ?? emptyLabel, + })} +

+

+ {formatTime(latest.created_at, locale, emptyLabel)} +

+
+
+

{t("route")}

+

{route}

+

+ {t("writes", { + incident: booleanLabel(latest.writes_incident_state, emptyLabel), + autoRepair: booleanLabel(latest.writes_auto_repair_result, emptyLabel), + })} +

+
+
+ ) : ( +
{t("empty")}
+ )} +
+ ); +} + function TimelineRow({ item, locale, @@ -765,6 +901,12 @@ export default function RunDetailPage({ statusLabel={statusLabel} /> + +
diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 32a6c331e..6abfd224d 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,36 @@ +## 2026-05-14 | T30 Run Timeline / Approval Flow 接上補救試跑 evidence + +**背景**:T29 已修掉 Telegram 詳情/歷史 HTML 400,並讓 Work Items 顯示 remediation history / MCP route / write flags。但 AwoooP Run Timeline 與 Approval Decision 仍只顯示 Run / Step / MCP / Outbound 本體,沒有把 `alert_operation_log` 內的 ADR-100 dry-run history 依 incident 關聯納入同一條處置脈絡。這會讓 operator 在 Telegram、Work Items、Run Timeline、Approval Flow 間看到不同粒度的事實。 + +**修正**: +- `platform_operator_service.get_run_detail()` 新增 read-only remediation evidence 聚合: + - 從 Run 的 inbound `source_envelope.source_refs.incident_ids`、legacy text payload、outbound preview 萃取 incident id。 + - 透過既有 `Adr100RemediationService.history()` 讀 `alert_operation_log` 的 `adr100_remediation_dry_run_history_v1`,不新增資料表、不改 incident / approval / auto-repair 狀態。 + - response 新增 `remediation_history`,含 `incident_ids / total / items / by_work_item / errors`。 + - Run `timeline` 會新增 `kind=remediation` 的 `ADR-100 補救試跑` 事件,metadata 顯示 `incident_id / work_item_id / mcp_route / writes_* / history_source`。 +- `/awooop/runs/[run_id]` 新增「補救試跑證據」panel,顯示 linked incident 數、dry-run 次數、tools、write flags、latest preview、MCP route。 +- `/awooop/approvals/[run_id]` 在核准/拒絕前顯示同一段 remediation evidence,讓人工審批不必跳頁猜 AI 是否已試跑、是否只讀。 +- `zh-TW` / `en` i18n 補齊 Run Detail / Approval Decision remediation 文案與 `warning` status label。 + +**本地驗證**: +- `python -m py_compile src/services/platform_operator_service.py tests/test_awooop_operator_timeline_labels.py`:pass。 +- `ruff check --select F,E9 src/services/platform_operator_service.py tests/test_awooop_operator_timeline_labels.py`:pass。 +- `DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test python -m pytest tests/test_awooop_operator_timeline_labels.py -q`:7 passed。 +- i18n JSON parse:pass。 +- `pnpm --filter @awoooi/web typecheck`:pass。 +- `pnpm --dir apps/web exec next lint --file 'src/app/[locale]/awooop/runs/[run_id]/page.tsx' --file 'src/app/[locale]/awooop/approvals/[run_id]/page.tsx'`:pass。 +- `NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --filter @awoooi/web build`:pass;既有 Sentry 建議警告與部分靜態頁 timeout retry warning 出現,但 build exit 0。 + +**推版與 production 驗證**: +- Pending:commit / Gitea main / CI/CD / production smoke。 + +**目前整體進度**: +- Alertmanager 低風險自動修復主線:約 98%。 +- 完整 AI 自動化管理產品化:約 95%。 +- 告警詳情/歷史可追溯:約 95%。 +- 前端 AI 自動化管理介面同步:約 86%。 +- T30 把補救試跑 evidence 拉進 Run Timeline / Approval Flow。下一段應把同一條狀態機再收斂到 Run list / Approvals list 的摘要欄,讓列表層就能看出「AI 已試跑 / 只讀 / 需人工」。 + ## 2026-05-14 | T29 Telegram 詳情/歷史避免 HTML 截斷 400,Work Items 顯示補救試跑證據 **背景**:Telegram 截圖中 `AwoooPAutoRepairCanaryT16` 的「詳情」已能顯示 truth-chain 與處理歷程,但「歷史」回 `HTTP error: 400`。production API 直接查 `/api/v1/incidents/INC-20260513-79ED5E/timeline`、`/api/v1/ai/slo/remediation/history?incident_id=...`、`/api/v1/alert-operation-logs?incident_id=...` 均可取得資料,根因不是 DB 缺資料,而是 Telegram `send_notification()` 以 `text[:500]` 截斷 HTML,可能把 `` / `` tag 切壞,Telegram parse mode 回 400,最後被誤報成「無法取得歷史統計」。