fix(awooop): make learning receipt backfill idempotent
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 19s
CD Pipeline / build-and-deploy (push) Successful in 5m13s
CD Pipeline / post-deploy-checks (push) Successful in 59s
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 19s
CD Pipeline / build-and-deploy (push) Successful in 5m13s
CD Pipeline / post-deploy-checks (push) Successful in 59s
This commit is contained in:
@@ -528,7 +528,7 @@ def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckMo
|
||||
input_payload = _json_loads(row.get("input"))
|
||||
output_payload = _json_loads(row.get("output"))
|
||||
dry_run_result = _json_loads(row.get("dry_run_result"))
|
||||
catalog_id = str(input_payload.get("catalog_id") or "")
|
||||
catalog_id = str(input_payload.get("catalog_id") or row.get("catalog_id") or "")
|
||||
if not catalog_id:
|
||||
return None
|
||||
catalog_item = get_ansible_catalog_item(catalog_id) or {}
|
||||
@@ -541,12 +541,14 @@ def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckMo
|
||||
apply_playbook_path = str(
|
||||
input_payload.get("apply_playbook_path")
|
||||
or input_payload.get("playbook_path")
|
||||
or row.get("playbook_path")
|
||||
or catalog_item.get("playbook_path")
|
||||
or ""
|
||||
)
|
||||
check_mode_playbook_path = str(
|
||||
input_payload.get("check_mode_playbook_path")
|
||||
or input_payload.get("playbook_path")
|
||||
or row.get("playbook_path")
|
||||
or apply_playbook_path
|
||||
)
|
||||
if not apply_playbook_path:
|
||||
@@ -563,7 +565,12 @@ def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckMo
|
||||
playbook_path=check_mode_playbook_path,
|
||||
apply_playbook_path=apply_playbook_path,
|
||||
inventory_hosts=tuple(str(host) for host in inventory_hosts),
|
||||
risk_level=str(input_payload.get("risk_level") or catalog_item.get("risk_level") or ""),
|
||||
risk_level=str(
|
||||
input_payload.get("risk_level")
|
||||
or row.get("risk_level")
|
||||
or catalog_item.get("risk_level")
|
||||
or ""
|
||||
),
|
||||
input_payload=input_payload,
|
||||
)
|
||||
result = AnsibleRunResult(
|
||||
@@ -866,37 +873,54 @@ async def _record_post_apply_verifier_and_learning(
|
||||
from src.repositories.knowledge_repository import KnowledgeDBRepository
|
||||
|
||||
async with get_db_context(project_id) as db:
|
||||
repo = KnowledgeDBRepository(db)
|
||||
await repo.create(
|
||||
KnowledgeEntryCreate(
|
||||
title=f"AI 自動修復沉澱:{claim.incident_id}",
|
||||
content=(
|
||||
"AI Agent 已完成 Ansible controlled apply 並寫入驗證摘要。\n\n"
|
||||
f"- Incident: {claim.incident_id}\n"
|
||||
f"- Catalog: {claim.catalog_id}\n"
|
||||
f"- PlayBook: {claim.apply_playbook_path}\n"
|
||||
f"- Apply operation: {apply_op_id}\n"
|
||||
f"- Verification result: {verification_result}\n"
|
||||
f"- Return code: {result.returncode}\n"
|
||||
f"- Next step: {post_state['next_required_step']}\n"
|
||||
),
|
||||
entry_type=EntryType.INCIDENT_CASE,
|
||||
category="AI自動化/Ansible受控修復",
|
||||
tags=[
|
||||
"ai_auto_repair",
|
||||
"ansible_controlled_apply",
|
||||
verification_result,
|
||||
str(claim.catalog_id or ""),
|
||||
],
|
||||
source=EntrySource.AI_EXTRACTED,
|
||||
status=EntryStatus.REVIEW,
|
||||
related_incident_id=claim.incident_id,
|
||||
related_playbook_id=str(claim.catalog_id or "")[:36] or None,
|
||||
path_type=_post_apply_km_path_type(apply_op_id),
|
||||
created_by="ai_agent_ansible_worker",
|
||||
)
|
||||
path_type = _post_apply_km_path_type(apply_op_id)
|
||||
existing = await db.execute(
|
||||
text("""
|
||||
SELECT id
|
||||
FROM knowledge_entries
|
||||
WHERE related_incident_id = :incident_id
|
||||
AND path_type = :path_type
|
||||
LIMIT 1
|
||||
"""),
|
||||
{
|
||||
"incident_id": claim.incident_id,
|
||||
"path_type": path_type,
|
||||
},
|
||||
)
|
||||
status["learning"] = True
|
||||
if existing.scalar() is not None:
|
||||
status["learning"] = True
|
||||
else:
|
||||
repo = KnowledgeDBRepository(db)
|
||||
await repo.create(
|
||||
KnowledgeEntryCreate(
|
||||
title=f"AI 自動修復沉澱:{claim.incident_id}",
|
||||
content=(
|
||||
"AI Agent 已完成 Ansible controlled apply 並寫入驗證摘要。\n\n"
|
||||
f"- Incident: {claim.incident_id}\n"
|
||||
f"- Catalog: {claim.catalog_id}\n"
|
||||
f"- PlayBook: {claim.apply_playbook_path}\n"
|
||||
f"- Apply operation: {apply_op_id}\n"
|
||||
f"- Verification result: {verification_result}\n"
|
||||
f"- Return code: {result.returncode}\n"
|
||||
f"- Next step: {post_state['next_required_step']}\n"
|
||||
),
|
||||
entry_type=EntryType.INCIDENT_CASE,
|
||||
category="AI自動化/Ansible受控修復",
|
||||
tags=[
|
||||
"ai_auto_repair",
|
||||
"ansible_controlled_apply",
|
||||
verification_result,
|
||||
str(claim.catalog_id or ""),
|
||||
],
|
||||
source=EntrySource.AI_EXTRACTED,
|
||||
status=EntryStatus.REVIEW,
|
||||
related_incident_id=claim.incident_id,
|
||||
related_playbook_id=str(claim.catalog_id or "")[:36] or None,
|
||||
path_type=path_type,
|
||||
created_by="ai_agent_ansible_worker",
|
||||
)
|
||||
)
|
||||
status["learning"] = True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ansible_post_apply_learning_writeback_failed",
|
||||
@@ -983,6 +1007,9 @@ async def backfill_missing_auto_repair_execution_receipts_once(
|
||||
apply.error,
|
||||
apply.duration_ms,
|
||||
apply.status,
|
||||
apply.catalog_id,
|
||||
apply.playbook_path,
|
||||
apply.risk_level,
|
||||
apply.created_at
|
||||
FROM automation_operation_log apply
|
||||
WHERE apply.operation_type = 'ansible_apply_executed'
|
||||
|
||||
@@ -1590,12 +1590,42 @@ def test_ansible_apply_operation_row_can_backfill_auto_repair_receipt() -> None:
|
||||
assert result.duration_ms == 456
|
||||
|
||||
|
||||
def test_ansible_apply_operation_row_reconstructs_from_columns_when_input_is_sparse() -> None:
|
||||
reconstructed = _claim_from_apply_operation_row({
|
||||
"op_id": "apply-op-2",
|
||||
"parent_op_id": "check-op-2",
|
||||
"incident_id": "INC-20260629-231F8E",
|
||||
"status": "success",
|
||||
"catalog_id": "ansible:188-momo-backup-user",
|
||||
"playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml",
|
||||
"risk_level": "low",
|
||||
"input": {
|
||||
"incident_id": "INC-20260629-231F8E",
|
||||
"source_candidate_op_id": "candidate-op-2",
|
||||
"check_mode_op_id": "check-op-2",
|
||||
},
|
||||
"output": {"returncode": 0, "stdout_tail": "ok"},
|
||||
"dry_run_result": {"apply_executed": True},
|
||||
"duration_ms": 7727,
|
||||
})
|
||||
|
||||
assert reconstructed is not None
|
||||
claim, result = reconstructed
|
||||
assert claim.catalog_id == "ansible:188-momo-backup-user"
|
||||
assert claim.inventory_hosts == ("host_188",)
|
||||
assert claim.apply_playbook_path == "infra/ansible/playbooks/188-momo-backup-user.yml"
|
||||
assert claim.risk_level == "low"
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def test_ansible_apply_receipt_backfill_queries_existing_apply_rows() -> None:
|
||||
source = inspect.getsource(backfill_missing_auto_repair_execution_receipts_once)
|
||||
|
||||
assert "operation_type = 'ansible_apply_executed'" in source
|
||||
assert "auto_repair_executions existing" in source
|
||||
assert "executed_steps::text LIKE" in source
|
||||
assert "apply.catalog_id" in source
|
||||
assert "apply.playbook_path" in source
|
||||
|
||||
|
||||
def test_ansible_auto_repair_receipt_insert_casts_asyncpg_parameters() -> None:
|
||||
@@ -1628,6 +1658,19 @@ def test_ansible_learning_writeback_receipt_records_learning_service_call() -> N
|
||||
assert "stores_secret_values" in source
|
||||
|
||||
|
||||
def test_ansible_post_apply_km_writeback_is_idempotent_for_learning_backfill() -> None:
|
||||
from src.services.awooop_ansible_check_mode_service import (
|
||||
_record_post_apply_verifier_and_learning,
|
||||
)
|
||||
|
||||
source = inspect.getsource(_record_post_apply_verifier_and_learning)
|
||||
|
||||
assert "SELECT id" in source
|
||||
assert "FROM knowledge_entries" in source
|
||||
assert "path_type = :path_type" in source
|
||||
assert "KnowledgeDBRepository" in source
|
||||
|
||||
|
||||
def test_ansible_live_controlled_apply_sends_telegram_receipt_but_backfill_does_not() -> None:
|
||||
live_source = inspect.getsource(run_controlled_apply_for_claim)
|
||||
backfill_source = inspect.getsource(backfill_missing_auto_repair_execution_receipts_once)
|
||||
|
||||
Reference in New Issue
Block a user