feat(awooop): 顯示修復候選升級合約
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user