diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index c6cc70513..206579f72 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -38,7 +38,7 @@ from src.db.awooop_models import ( AwoooPRunStepJournal, ) from src.db.base import get_db_context -from src.db.models import IncidentRecord, MCPAuditLog +from src.db.models import ApprovalRecord, IncidentRecord, MCPAuditLog from src.services.audit_sink import write_audit from src.services.awooop_ansible_audit_service import summarize_ansible_execution from src.services.awooop_approval_token import issue_approval_token, record_approval @@ -5584,6 +5584,7 @@ def _build_awooop_status_chain( source_id: str | None = None, fetch_error: str | None = None, source_correlation: dict[str, Any] | None = None, + repair_candidate_promotion: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the shared read-only status chain used by Telegram and Operator UI.""" truth_status = ( @@ -5762,9 +5763,112 @@ def _build_awooop_status_chain( "mcp": mcp_section, "execution": execution_section, "source_refs": source_section, + "repair_candidate_promotion": repair_candidate_promotion + or _empty_repair_candidate_promotion_projection(), } +def _empty_repair_candidate_promotion_projection() -> dict[str, Any]: + return { + "schema_version": "repair_candidate_promotion_projection_v1", + "status": "not_available", + "source": "approval_metadata", + "available": False, + "reason": "repair_candidate_promotion_contract_not_found", + "contract": None, + "summary": "", + "runtime_execution_authorized": False, + "runtime_write_allowed": False, + } + + +def _repair_candidate_promotion_summary(contract: Mapping[str, Any]) -> str: + route = str(contract.get("route_id") or "--") + ready = _safe_int(contract.get("ready_count")) + total = _safe_int(contract.get("total_count")) + blocked = _safe_int(contract.get("blocked_count")) + status_value = str(contract.get("status") or "unknown") + return ( + f"route={route}; promotion={ready}/{total}; " + f"blocked={blocked}; status={status_value}; runtime=false" + ) + + +def _repair_candidate_projection_from_metadata( + metadata: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(metadata, Mapping): + return None + package = metadata.get("repair_candidate_draft_package") + package = package if isinstance(package, Mapping) else {} + contract = package.get("candidate_promotion_contract") or metadata.get( + "repair_candidate_promotion_contract" + ) + if not isinstance(contract, Mapping): + return None + contract_dict = dict(contract) + summary = str(metadata.get("repair_candidate_promotion_summary") or "").strip() + if not summary: + summary = _repair_candidate_promotion_summary(contract_dict) + work_item = package.get("awooop_work_item") + if not isinstance(work_item, Mapping): + work_item = {} + return { + "schema_version": "repair_candidate_promotion_projection_v1", + "status": str(contract_dict.get("status") or "unknown"), + "source": "approval_metadata", + "available": True, + "approval_id": str(metadata.get("approval_id") or ""), + "work_item_id": str(work_item.get("work_item_id") or ""), + "work_item_url": str( + work_item.get("work_item_url") + or work_item.get("work_item_href") + or "" + ), + "contract": contract_dict, + "summary": summary, + "runtime_execution_authorized": bool( + contract_dict.get("runtime_execution_authorized") is True + ), + "runtime_write_allowed": bool(contract_dict.get("runtime_write_allowed") is True), + } + + +async def _fetch_repair_candidate_promotion_projection( + *, + incident_ids: list[str], + project_id: str, +) -> dict[str, Any]: + """Read the latest repair-candidate promotion contract for status-chain UI. + + The projection is read-only: it surfaces existing ApprovalRecord metadata + so Work Items can show why a draft is blocked before runtime execution. + """ + + normalized_ids = [incident_id for incident_id in incident_ids if incident_id] + if not normalized_ids: + return _empty_repair_candidate_promotion_projection() + + async with get_db_context(project_id or "awoooi") as db: + stmt = ( + select(ApprovalRecord) + .where(ApprovalRecord.incident_id.in_(normalized_ids)) + .where(ApprovalRecord.extra_metadata.is_not(None)) + .order_by(ApprovalRecord.created_at.desc()) + .limit(20) + ) + result = await db.execute(stmt) + rows = list(result.scalars().all()) + + for row in rows: + metadata = dict(row.extra_metadata or {}) + metadata["approval_id"] = str(row.id) + projection = _repair_candidate_projection_from_metadata(metadata) + if projection is not None: + return projection + return _empty_repair_candidate_promotion_projection() + + async def _fetch_awooop_status_chain( *, incident_ids: list[str], @@ -5808,6 +5912,24 @@ async def _fetch_awooop_status_chain( missing_reason="source_correlation_fetch_failed", ) + try: + repair_candidate_promotion = await _fetch_repair_candidate_promotion_projection( + incident_ids=incident_ids, + project_id=project_id or "awoooi", + ) + except Exception as exc: + logger.warning( + "operator_repair_candidate_promotion_projection_fetch_failed", + incident_ids=incident_ids, + project_id=project_id, + error=str(exc), + ) + repair_candidate_promotion = { + **_empty_repair_candidate_promotion_projection(), + "status": "fetch_failed", + "reason": "repair_candidate_promotion_projection_fetch_failed", + } + return _build_awooop_status_chain( incident_ids=incident_ids, truth_chain=truth_chain, @@ -5815,6 +5937,7 @@ async def _fetch_awooop_status_chain( source_id=source_id, fetch_error=fetch_error, source_correlation=source_correlation, + repair_candidate_promotion=repair_candidate_promotion, ) diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 88a06c389..f1a7cb6d9 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -46,6 +46,7 @@ from src.services.platform_operator_service import ( _recent_event_source_summary, _remediation_summary_matches_incident_id, _remediation_summary_matches_status, + _repair_candidate_projection_from_metadata, _remediation_timeline_summary, _run_callback_reply_summary, _run_remediation_list_summary, @@ -2245,6 +2246,69 @@ def test_awooop_status_chain_does_not_treat_audit_ops_as_repair() -> None: assert chain["evidence"]["auto_repair_records"] == 0 +def test_awooop_status_chain_surfaces_repair_candidate_promotion_projection() -> None: + projection = _repair_candidate_projection_from_metadata({ + "approval_id": "997e0d13-9b90-46f5-89ec-fcf7079429d8", + "repair_candidate_promotion_summary": "", + "repair_candidate_draft_package": { + "awooop_work_item": { + "work_item_id": ( + "repair-candidate-draft:awoooi:INC-20260626-F0C9A7:" + "promote_diagnostic_to_repair_playbook" + ), + "work_item_url": ( + "https://awoooi.wooo.work/zh-TW/awooop/work-items?" + "project_id=awoooi&incident_id=INC-20260626-F0C9A7" + ), + }, + "candidate_promotion_contract": { + "schema_version": "repair_candidate_promotion_contract_v1", + "status": "owner_review_ready_runtime_blocked", + "route_id": "host_service_route_after_owner_review", + "ready_count": 6, + "total_count": 11, + "blocked_count": 5, + "blocked_fields": ["owner_review", "maintenance_window"], + "runtime_execution_authorized": False, + "runtime_write_allowed": False, + }, + }, + }) + + chain = _build_awooop_status_chain( + incident_ids=["INC-20260626-F0C9A7"], + source_id="INC-20260626-F0C9A7", + truth_chain={ + "truth_status": { + "current_stage": "rule_or_degraded", + "stage_status": "manual_required", + "needs_human": True, + "blockers": ["playbook_observe_only"], + }, + "automation_quality": { + "verdict": "observed_not_executed", + "facts": { + "auto_repair_execution_records": 0, + "automation_operation_records": 0, + "verification_result": "missing", + "mcp_gateway_total": 8, + "knowledge_entries": 0, + }, + }, + }, + repair_candidate_promotion=projection, + ) + + promotion = chain["repair_candidate_promotion"] + assert promotion["available"] is True + assert promotion["status"] == "owner_review_ready_runtime_blocked" + assert promotion["contract"]["ready_count"] == 6 + assert promotion["contract"]["blocked_count"] == 5 + assert promotion["runtime_execution_authorized"] is False + assert promotion["runtime_write_allowed"] is False + assert "promotion=6/11" in promotion["summary"] + + def test_awooop_status_chain_surfaces_rejected_approval_outcome() -> None: chain = _build_awooop_status_chain( incident_ids=["INC-REJECTED"], diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 617e7ade4..9ea34f372 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9636,6 +9636,36 @@ "lane": "處置 lane", "effect": "決策效果" }, + "promotion": { + "title": "候選升級合約", + "subtitle": "把草案轉成可審查 Gate:哪些已備、哪些缺 owner / 維護窗口 / verifier,一眼可判讀。", + "progress": "已備 {ready}/{total} · 卡點 {blocked}", + "missingBadge": "尚未回補", + "runtimeClosed": "runtime gate=0", + "blockedTitle": "卡點欄位", + "noBlockedFields": "無卡點欄位", + "missingTitle": "尚未取得升級合約", + "missingDetail": "這筆事件尚未回補 repair_candidate_promotion_contract;原因:{reason}。請重診或等待下一次同類告警產生新草案。", + "metrics": { + "readiness": "升級準備度", + "route": "安全路由", + "status": "合約狀態", + "runtime": "執行邊界" + }, + "fields": { + "target_selector": "Target selector", + "mcp_evidence_refs": "MCP 證據", + "route_id": "Route", + "repair_command_template": "修復模板", + "rollback_command_template": "Rollback", + "verifier_plan": "Verifier", + "owner_review": "Owner review", + "maintenance_window": "維護窗口", + "blast_radius": "影響範圍", + "km_writeback_owner": "KM owner", + "playbook_trust_owner": "PlayBook trust owner" + } + }, "flow": { "ingest": { "title": "告警接收", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 617e7ade4..9ea34f372 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9636,6 +9636,36 @@ "lane": "處置 lane", "effect": "決策效果" }, + "promotion": { + "title": "候選升級合約", + "subtitle": "把草案轉成可審查 Gate:哪些已備、哪些缺 owner / 維護窗口 / verifier,一眼可判讀。", + "progress": "已備 {ready}/{total} · 卡點 {blocked}", + "missingBadge": "尚未回補", + "runtimeClosed": "runtime gate=0", + "blockedTitle": "卡點欄位", + "noBlockedFields": "無卡點欄位", + "missingTitle": "尚未取得升級合約", + "missingDetail": "這筆事件尚未回補 repair_candidate_promotion_contract;原因:{reason}。請重診或等待下一次同類告警產生新草案。", + "metrics": { + "readiness": "升級準備度", + "route": "安全路由", + "status": "合約狀態", + "runtime": "執行邊界" + }, + "fields": { + "target_selector": "Target selector", + "mcp_evidence_refs": "MCP 證據", + "route_id": "Route", + "repair_command_template": "修復模板", + "rollback_command_template": "Rollback", + "verifier_plan": "Verifier", + "owner_review": "Owner review", + "maintenance_window": "維護窗口", + "blast_radius": "影響範圍", + "km_writeback_owner": "KM owner", + "playbook_trust_owner": "PlayBook trust owner" + } + }, "flow": { "ingest": { "title": "告警接收", 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 868386a56..b395caaaf 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1102,6 +1102,25 @@ function laneToneFromCounts(ready: number, total: number, blocked: number): Work return "watching"; } +function promotionValueText(value: unknown): string { + if (Array.isArray(value)) { + return value.map((item) => String(item)).filter(Boolean).slice(0, 3).join(" / ") || "--"; + } + if (value && typeof value === "object") { + return Object.values(value as Record) + .map((item) => String(item)) + .filter(Boolean) + .slice(0, 3) + .join(" / ") || "--"; + } + return String(value ?? "--"); +} + +function promotionStatusTone(status: string | null | undefined): WorkStatus { + if (String(status ?? "").toLowerCase() === "ready") return "live"; + return "blocked"; +} + function buildAutomationBlockerLanes( telemetry: Telemetry, focusedDraft: RepairCandidateDraftFocus | null @@ -1444,6 +1463,13 @@ const REPAIR_CANDIDATE_DRAFT_BLOCKED_OPERATIONS = [ "generic_fallback_repair", ] as const; +const REPAIR_CANDIDATE_PROMOTION_FOCUS_FIELDS = [ + "route_id", + "repair_command_template", + "rollback_command_template", + "verifier_plan", +] as const; + const statusConfig: Record = { live: { className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", @@ -4122,6 +4148,53 @@ function RepairCandidateDraftPanel({ }) { const t = useTranslations("awooop.workItems.repairCandidateDraft"); if (!draft) return null; + const promotion = chain?.repair_candidate_promotion; + const promotionContract = promotion?.contract ?? null; + const promotionAvailable = Boolean(promotion?.available && promotionContract); + const promotionReady = toCount(promotionContract?.ready_count); + const promotionTotal = toCount(promotionContract?.total_count); + const promotionBlocked = toCount(promotionContract?.blocked_count); + const promotionPercent = promotionTotal > 0 + ? Math.min(100, Math.round((promotionReady / promotionTotal) * 100)) + : 0; + const promotionFields = promotionContract?.fields ?? []; + const promotionFieldMap = new Map( + promotionFields + .filter((field) => field.field) + .map((field) => [String(field.field), field]) + ); + const promotionLabels: Record = { + target_selector: t("promotion.fields.target_selector"), + mcp_evidence_refs: t("promotion.fields.mcp_evidence_refs"), + route_id: t("promotion.fields.route_id"), + repair_command_template: t("promotion.fields.repair_command_template"), + rollback_command_template: t("promotion.fields.rollback_command_template"), + verifier_plan: t("promotion.fields.verifier_plan"), + owner_review: t("promotion.fields.owner_review"), + maintenance_window: t("promotion.fields.maintenance_window"), + blast_radius: t("promotion.fields.blast_radius"), + km_writeback_owner: t("promotion.fields.km_writeback_owner"), + playbook_trust_owner: t("promotion.fields.playbook_trust_owner"), + }; + const promotionFocusRows = REPAIR_CANDIDATE_PROMOTION_FOCUS_FIELDS.map((field) => { + const row = promotionFieldMap.get(field); + return { + field, + label: promotionLabels[field] ?? row?.label ?? field, + status: row?.status ?? "blocked", + value: row?.value, + source: row?.source ?? "--", + }; + }); + const promotionBlockedFields = ( + promotionContract?.blocked_fields?.length + ? promotionContract.blocked_fields + : promotionFields + .filter((field) => String(field.status ?? "").toLowerCase() !== "ready") + .map((field) => field.field) + ) + .filter((field): field is string => Boolean(field)) + .slice(0, 8); const runsHref = draft.incidentId ? `/awooop/runs?project_id=${encodeURIComponent(draft.projectId)}&incident_id=${encodeURIComponent(draft.incidentId)}` @@ -4195,6 +4268,116 @@ function RepairCandidateDraftPanel({ ))} +
+
+
+
+ + {promotionAvailable + ? t("promotion.progress", { + ready: promotionReady, + total: promotionTotal, + blocked: promotionBlocked, + }) + : t("promotion.missingBadge")} + +
+ + {promotionAvailable ? ( +
+
+
+

{t("promotion.metrics.readiness")}

+

{promotionPercent}%

+
+
+

{t("promotion.metrics.route")}

+

+ {promotionContract?.route_id ?? "--"} +

+
+
+

{t("promotion.metrics.status")}

+

+ {promotion?.status ?? promotionContract?.status ?? "--"} +

+
+
+

{t("promotion.metrics.runtime")}

+

+ {t("promotion.runtimeClosed")} +

+
+
+ +
+ + +
+ {promotionFocusRows.map((row) => { + const tone = promotionStatusTone(row.status); + const Icon = statusConfig[tone].icon; + return ( +
+
+
+

{row.label}

+

+ {promotionValueText(row.value)} +

+
+ + +
+
+ ); + })} +
+ +
+

{t("promotion.blockedTitle")}

+
+ {promotionBlockedFields.map((field) => ( + + {promotionLabels[field] ?? field} + + ))} + {promotionBlockedFields.length === 0 ? ( + + {t("promotion.noBlockedFields")} + + ) : null} +
+
+
+ ) : ( +
+
+

{t("promotion.missingTitle")}

+

+ {t("promotion.missingDetail", { + reason: promotion?.reason ?? "repair_candidate_promotion_contract_not_found", + })} +

+
+
+ )} +
+