diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 8a975c49..6b905c67 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -95,6 +95,7 @@ class CallbackReplyItem(BaseModel): run_created_at: datetime | None = None callback_reply: dict[str, Any] awooop_status_chain: dict[str, Any] | None = None + persisted_awooop_status_chain: dict[str, Any] | None = None km_stale_completion_summary: dict[str, Any] | None = None persisted_km_stale_completion_summary: dict[str, Any] | None = None run_detail_href: str | None = None diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 43c78642..334aa41f 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -374,6 +374,8 @@ async def list_callback_replies( m.sent_at, m.triggered_by_state, m.source_envelope -> 'callback_reply' AS callback_reply, + m.source_envelope -> 'awooop_status_chain' + AS persisted_awooop_status_chain, m.source_envelope -> 'km_stale_completion_summary' AS persisted_km_stale_completion_summary, r.agent_id, @@ -1055,6 +1057,9 @@ def _callback_reply_event_item(row: Mapping[str, Any]) -> dict[str, Any]: "agent_id": row.get("agent_id"), "run_created_at": row.get("run_created_at"), "callback_reply": callback_reply, + "persisted_awooop_status_chain": _as_dict( + row.get("persisted_awooop_status_chain"), + ) or None, "persisted_km_stale_completion_summary": _as_dict( row.get("persisted_km_stale_completion_summary"), ) or None, diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index a2880535..a3e04ea6 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -825,6 +825,7 @@ def _callback_reply_source_envelope_extra( parse_mode: str | None = None, error: str | None = None, km_stale_completion_summary: dict[str, object] | None = None, + awooop_status_chain: dict[str, object] | None = None, ) -> dict[str, object] | None: """Build AwoooP metadata for Telegram detail/history callback replies.""" if not incident_id: @@ -856,6 +857,8 @@ def _callback_reply_source_envelope_extra( ) if km_snapshot: extra["km_stale_completion_summary"] = km_snapshot + if isinstance(awooop_status_chain, dict): + extra["awooop_status_chain"] = awooop_status_chain return extra @@ -909,6 +912,131 @@ def _callback_reply_km_stale_completion_snapshot( return snapshot +def _callback_reply_awooop_status_chain_snapshot( + *, + incident_id: str | None, + truth_chain: dict[str, object] | None = None, + remediation_history: dict[str, object] | None = None, +) -> dict[str, object] | None: + """Persist a compact AwoooP status-chain snapshot with callback evidence.""" + if not incident_id or (not truth_chain and not remediation_history): + return None + + truth_status = ( + truth_chain.get("truth_status") + if isinstance(truth_chain, dict) and isinstance(truth_chain.get("truth_status"), dict) + else {} + ) + quality = ( + truth_chain.get("automation_quality") + if isinstance(truth_chain, dict) and isinstance(truth_chain.get("automation_quality"), dict) + else {} + ) + facts = quality.get("facts") if isinstance(quality.get("facts"), dict) else {} + latest = _latest_remediation_history_item(remediation_history) + remediation_state = _remediation_evidence_state(remediation_history) or "missing" + remediation_total = ( + _safe_int(remediation_history.get("total")) + if isinstance(remediation_history, dict) + else 0 + ) + latest_route = "none" + if latest: + latest_route = ( + f"{latest.get('agent_id') or 'unknown_agent'}/" + f"{latest.get('tool_name') or 'current_state'}/" + f"{latest.get('required_scope') or 'unknown'}" + ) + + current_stage = str(truth_status.get("current_stage") or "unknown") + stage_status = str(truth_status.get("stage_status") or "unknown") + verdict = str(quality.get("verdict") or "unknown") + verification = ( + facts.get("verification_result") + or latest.get("verification_result_preview") + or "missing" + ) + auto_repair_records = _safe_int(facts.get("auto_repair_execution_records")) + operation_records = _safe_int(facts.get("automation_operation_records")) + gateway_total = _safe_int(facts.get("mcp_gateway_total")) + km_entries = _safe_int(facts.get("knowledge_entries")) + needs_human = bool(truth_status.get("needs_human")) + + if verdict == "auto_repaired_verified": + repair_state = "auto_repaired_verified" + next_step = "monitor_for_regression" + elif auto_repair_records > 0 or operation_records > 0: + repair_state = "executed_pending_verification" if verification == "missing" else "executed" + next_step = "verify_execution_result" + elif remediation_state == "read_only": + repair_state = "read_only_dry_run" + next_step = "approve_or_escalate_from_awooop" + elif remediation_state == "write_observed": + repair_state = "write_observed_manual_review" + next_step = "review_write_evidence" + elif remediation_state == "blocked": + repair_state = "blocked_manual_required" + next_step = "manual_investigation" + elif needs_human: + repair_state = "manual_required" + next_step = "manual_investigation" + else: + repair_state = "no_execution_evidence" + next_step = "collect_evidence_or_wait" + + if remediation_state in {"blocked", "fetch_failed"}: + needs_human = True + if ( + remediation_state == "write_observed" + and repair_state != "auto_repaired_verified" + ): + needs_human = True + + truth_blockers = ( + truth_status.get("blockers") if isinstance(truth_status.get("blockers"), list) else [] + ) + quality_blockers = ( + quality.get("blockers") if isinstance(quality.get("blockers"), list) else [] + ) + blockers = [ + str(item) + for item in [*truth_blockers, *quality_blockers] + if item + ] + + return { + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + "source_schema_version": "awooop_status_chain_v1", + "source": "telegram_callback_reply_snapshot", + "source_id": incident_id, + "incident_ids": [incident_id], + "current_stage": current_stage, + "stage_status": stage_status, + "verdict": verdict, + "repair_state": repair_state, + "verification": str(verification), + "needs_human": needs_human, + "next_step": next_step, + "blockers": blockers[:8], + "evidence": { + "auto_repair_records": auto_repair_records, + "operation_records": operation_records, + "mcp_gateway_total": gateway_total, + "knowledge_entries": km_entries, + "remediation_total": remediation_total, + "remediation_state": remediation_state, + "latest_route": latest_route, + "latest_mode": latest.get("mode"), + "latest_at": latest.get("created_at"), + "latest_preview": latest.get("verification_result_preview"), + }, + "writes": { + "incident": latest.get("writes_incident_state"), + "auto_repair": latest.get("writes_auto_repair_result"), + }, + } + + def _merge_outbound_source_envelope_extra( envelope: dict[str, object], extra: dict[str, object] | None, @@ -925,6 +1053,10 @@ def _merge_outbound_source_envelope_extra( if isinstance(km_stale_completion_summary, dict): envelope["km_stale_completion_summary"] = km_stale_completion_summary + awooop_status_chain = extra.get("awooop_status_chain") + if isinstance(awooop_status_chain, dict): + envelope["awooop_status_chain"] = awooop_status_chain + extra_refs = extra.get("source_refs") if isinstance(extra_refs, dict): source_refs = envelope.setdefault("source_refs", {}) @@ -6123,6 +6255,7 @@ class TelegramGateway: incident_id=incident_id, project_id=project_id, ) + truth_chain: dict[str, object] | None = None try: from src.services.awooop_truth_chain_service import fetch_truth_chain @@ -6156,6 +6289,11 @@ class TelegramGateway: lines += _format_km_stale_completion_lines(km_completion_summary) lines += _format_remediation_history_lines(remediation_history) + awooop_status_chain_snapshot = _callback_reply_awooop_status_chain_snapshot( + incident_id=incident_id, + truth_chain=truth_chain, + remediation_history=remediation_history, + ) await self._send_html_line_message( lines, failure_context="incident_detail", @@ -6163,6 +6301,7 @@ class TelegramGateway: incident_id=incident_id, callback_action="detail", km_stale_completion_summary=km_completion_summary, + awooop_status_chain=awooop_status_chain_snapshot, ) except Exception as e: @@ -6281,6 +6420,7 @@ class TelegramGateway: incident_id=incident_id, project_id=project_id, ) + truth_chain: dict[str, object] | None = None try: from src.services.awooop_truth_chain_service import fetch_truth_chain @@ -6309,6 +6449,11 @@ class TelegramGateway: lines += _format_km_stale_completion_lines(km_completion_summary) lines += _format_remediation_history_lines(remediation_history) + awooop_status_chain_snapshot = _callback_reply_awooop_status_chain_snapshot( + incident_id=incident_id, + truth_chain=truth_chain, + remediation_history=remediation_history, + ) await self._send_html_line_message( lines, failure_context="incident_history", @@ -6316,6 +6461,7 @@ class TelegramGateway: incident_id=incident_id, callback_action="history", km_stale_completion_summary=km_completion_summary, + awooop_status_chain=awooop_status_chain_snapshot, ) except Exception as e: @@ -6555,6 +6701,7 @@ class TelegramGateway: incident_id: str | None = None, callback_action: str | None = None, km_stale_completion_summary: dict[str, object] | None = None, + awooop_status_chain: dict[str, object] | None = None, ) -> None: """Send a multi-line HTML message without cutting Telegram tags in half.""" chunks = _telegram_html_chunks(lines) @@ -6575,6 +6722,7 @@ class TelegramGateway: callback_action=callback_action, parse_mode="HTML", km_stale_completion_summary=km_stale_completion_summary, + awooop_status_chain=awooop_status_chain, ) if source_extra: payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = source_extra @@ -6606,6 +6754,7 @@ class TelegramGateway: parse_mode="plain_text", error=str(exc), km_stale_completion_summary=km_stale_completion_summary, + awooop_status_chain=awooop_status_chain, ) if fallback_source_extra: fallback_payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = fallback_source_extra @@ -6639,6 +6788,7 @@ class TelegramGateway: parse_mode="plain_text", error=str(fallback_exc), km_stale_completion_summary=km_stale_completion_summary, + awooop_status_chain=awooop_status_chain, ) if rescue_source_extra: rescue_payload[_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] = rescue_source_extra diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 9bad23a7..848674af 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -375,6 +375,12 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None: "incident_id": "INC-20260513-79ED5E", "error": "HTTP error: 400", }, + "persisted_awooop_status_chain": { + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + "repair_state": "blocked_manual_required", + "needs_human": True, + "next_step": "manual_investigation", + }, "persisted_km_stale_completion_summary": { "schema_version": "km_stale_owner_review_callback_reply_snapshot_v1", "status": "no_related_owner_review", @@ -398,6 +404,9 @@ def test_callback_reply_event_item_surfaces_run_link_and_human_flag() -> None: assert item["persisted_km_stale_completion_summary"]["triage"]["ai_lead_agent"] == ( "Hermes" ) + assert item["persisted_awooop_status_chain"]["repair_state"] == ( + "blocked_manual_required" + ) def test_list_callback_replies_response_preserves_callback_evidence() -> None: @@ -434,6 +443,30 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: "repair_state": "read_only_dry_run", "needs_human": True, }, + "persisted_awooop_status_chain": { + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + "source_schema_version": "awooop_status_chain_v1", + "source": "telegram_callback_reply_snapshot", + "source_id": "INC-20260513-79ED5E", + "incident_ids": ["INC-20260513-79ED5E"], + "current_stage": "approval_required", + "stage_status": "waiting", + "verdict": "approval_required", + "repair_state": "read_only_dry_run", + "verification": "missing", + "needs_human": True, + "next_step": "approve_or_escalate_from_awooop", + "evidence": { + "auto_repair_records": 0, + "operation_records": 0, + "mcp_gateway_total": 1, + "knowledge_entries": 0, + }, + "writes": { + "incident": False, + "auto_repair": False, + }, + }, "km_stale_completion_summary": { "schema_version": ( "km_stale_owner_review_completion_callback_summary_v1" @@ -497,6 +530,9 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: assert dumped["items"][0]["awooop_status_chain"]["repair_state"] == ( "read_only_dry_run" ) + assert dumped["items"][0]["persisted_awooop_status_chain"]["next_step"] == ( + "approve_or_escalate_from_awooop" + ) assert dumped["items"][0]["km_stale_completion_summary"]["ready_count"] == 3 assert dumped["items"][0]["km_stale_completion_summary"]["related_total"] == 1 assert dumped["items"][0]["persisted_km_stale_completion_summary"]["triage"][ diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 16cbde6b..511652f9 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -177,6 +177,58 @@ def test_awooop_status_chain_lines_show_read_only_manual_gate() -> None: assert "pending_human_approval" in joined +def test_callback_reply_awooop_status_chain_snapshot_marks_manual_gate() -> None: + """Callback evidence 要保存當下 AwoooP 狀態鏈,不只保存 live query 結果。""" + snapshot = telegram_gateway_module._callback_reply_awooop_status_chain_snapshot( + incident_id="INC-20260514-F85F21", + truth_chain={ + "truth_status": { + "current_stage": "approval_required", + "stage_status": "waiting", + "needs_human": True, + "blockers": ["pending_human_approval"], + }, + "automation_quality": { + "verdict": "approval_required", + "facts": { + "auto_repair_execution_records": 0, + "automation_operation_records": 0, + "verification_result": "missing", + "mcp_gateway_total": 1, + "knowledge_entries": 0, + }, + "blockers": [], + }, + }, + remediation_history={ + "total": 1, + "items": [ + { + "agent_id": "investigator", + "tool_name": "ssh_diagnose", + "required_scope": "read", + "safety_level": "read_only", + "verification_result_preview": "degraded", + "writes_incident_state": False, + "writes_auto_repair_result": False, + } + ], + }, + ) + + assert snapshot is not None + assert snapshot["schema_version"] == ( + "awooop_status_chain_callback_reply_snapshot_v1" + ) + assert snapshot["repair_state"] == "read_only_dry_run" + assert snapshot["needs_human"] is True + assert snapshot["next_step"] == "approve_or_escalate_from_awooop" + assert snapshot["evidence"]["mcp_gateway_total"] == 1 + assert snapshot["evidence"]["latest_route"] == "investigator/ssh_diagnose/read" + assert snapshot["writes"]["incident"] is False + assert snapshot["blockers"] == ["pending_human_approval"] + + def test_km_stale_completion_lines_show_owner_review_queue_state() -> None: """詳情/歷史要顯示 KM owner-review completion queue 是否卡住或可處理。""" lines = telegram_gateway_module._format_km_stale_completion_lines({ @@ -329,6 +381,15 @@ async def test_send_request_strips_awooop_callback_metadata_before_telegram_api( "status": "callback_reply_sent", "incident_id": "INC-20260513-79ED5E", }, + "awooop_status_chain": { + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + "repair_state": "read_only_dry_run", + "needs_human": True, + "evidence": { + "auto_repair_records": 0, + "operation_records": 0, + }, + }, "km_stale_completion_summary": { "schema_version": ( "km_stale_owner_review_callback_reply_snapshot_v1" @@ -349,6 +410,9 @@ async def test_send_request_strips_awooop_callback_metadata_before_telegram_api( assert captured["mirror"]["source_envelope_extra"]["callback_reply"]["status"] == ( "callback_reply_sent" ) + assert captured["mirror"]["source_envelope_extra"]["awooop_status_chain"][ + "repair_state" + ] == "read_only_dry_run" assert captured["mirror"]["source_envelope_extra"][ "km_stale_completion_summary" ]["ready_count"] == 2 @@ -407,6 +471,29 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch) reply_markup=reply_markup, incident_id="INC-20260514-F85F21", callback_action="history", + awooop_status_chain={ + "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", + "source": "telegram_callback_reply_snapshot", + "source_id": "INC-20260514-F85F21", + "incident_ids": ["INC-20260514-F85F21"], + "current_stage": "approval_required", + "stage_status": "waiting", + "verdict": "approval_required", + "repair_state": "read_only_dry_run", + "verification": "missing", + "needs_human": True, + "next_step": "approve_or_escalate_from_awooop", + "evidence": { + "auto_repair_records": 0, + "operation_records": 0, + "mcp_gateway_total": 1, + "knowledge_entries": 0, + }, + "writes": { + "incident": False, + "auto_repair": False, + }, + }, km_stale_completion_summary={ "schema_version": "km_stale_owner_review_completion_callback_summary_v1", "project_id": "awoooi", @@ -443,6 +530,10 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch) assert first_extra["status"] == "callback_reply_sent" assert first_extra["action"] == "history" assert first_extra["parse_mode"] == "HTML" + assert first_source_extra["awooop_status_chain"]["repair_state"] == ( + "read_only_dry_run" + ) + assert first_source_extra["awooop_status_chain"]["needs_human"] is True assert first_source_extra["km_stale_completion_summary"]["ready_count"] == 3 assert first_source_extra["km_stale_completion_summary"]["triage"]["flow_stage"] == ( "callback_observed_owner_review_link_missing" @@ -450,6 +541,9 @@ async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch) assert fallback_extra["status"] == "callback_reply_fallback_sent" assert fallback_extra["incident_id"] == "INC-20260514-F85F21" assert fallback_extra["parse_mode"] == "plain_text" + assert fallback_source_extra["awooop_status_chain"]["next_step"] == ( + "approve_or_escalate_from_awooop" + ) assert fallback_source_extra["km_stale_completion_summary"]["triage"][ "ai_lead_agent" ] == "Hermes" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 86e3b496..fcd3b0a9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2796,6 +2796,7 @@ "providerMessage": "Message: {messageId}", "previewEmpty": "No preview", "openRun": "Open Run", + "awooopSnapshotTitle": "Callback-time AwoooP Status Chain", "kmCompletion": { "title": "KM Owner Review", "status": "Status: {status}", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 351685d4..c080cbb4 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2797,6 +2797,7 @@ "providerMessage": "Message:{messageId}", "previewEmpty": "無摘要", "openRun": "開啟 Run", + "awooopSnapshotTitle": "Callback 當下 AwoooP 狀態鏈", "kmCompletion": { "title": "KM Owner Review", "status": "狀態:{status}", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index e380482c..ecebd49b 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -370,6 +370,7 @@ interface CallbackReplyEvent { agent_id?: string | null; run_detail_href?: string | null; awooop_status_chain?: AwoooPStatusChain | null; + persisted_awooop_status_chain?: AwoooPStatusChain | null; km_stale_completion_summary?: KmStaleCompletionSummary | null; persisted_km_stale_completion_summary?: KmStaleCompletionSummary | null; } @@ -1732,6 +1733,21 @@ function CallbackReplyEvidencePanel({ compact className="mt-3" /> + {event.persisted_awooop_status_chain ? ( +
+ {t("awooopSnapshotTitle")} +
+