feat(agent): bind context and timeline receipts
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 1m12s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 1m12s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -817,6 +817,59 @@ def build_ansible_pre_apply_runtime_stage_receipts(
|
||||
]
|
||||
|
||||
|
||||
def build_ansible_context_runtime_stage_receipts(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
evidence: dict[str, Any],
|
||||
*,
|
||||
derived_from_durable_chain: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Project public-safe pre-decision evidence into the current run."""
|
||||
|
||||
evidence_id = str(evidence.get("id") or "")
|
||||
if not evidence_id:
|
||||
return []
|
||||
|
||||
receipts: list[dict[str, Any]] = []
|
||||
mcp_health = _json_loads(evidence.get("mcp_health"))
|
||||
sensors_attempted = max(0, _int_from_value(evidence.get("sensors_attempted"), default=0))
|
||||
sensors_succeeded = max(0, _int_from_value(evidence.get("sensors_succeeded"), default=0))
|
||||
if mcp_health or sensors_attempted > 0:
|
||||
receipts.append(
|
||||
_runtime_stage_receipt(
|
||||
claim,
|
||||
stage_id="mcp_context",
|
||||
evidence_ref=f"incident_evidence:{evidence_id}:mcp_health",
|
||||
detail={
|
||||
"evidence_id": evidence_id,
|
||||
"tool_status_count": len(mcp_health),
|
||||
"sensors_attempted": sensors_attempted,
|
||||
"sensors_succeeded": sensors_succeeded,
|
||||
"context_collected_before_candidate": True,
|
||||
},
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
)
|
||||
)
|
||||
|
||||
recent_logs = evidence.get("recent_logs")
|
||||
if isinstance(recent_logs, str) and recent_logs.strip():
|
||||
receipts.append(
|
||||
_runtime_stage_receipt(
|
||||
claim,
|
||||
stage_id="service_log_evidence",
|
||||
evidence_ref=f"incident_evidence:{evidence_id}:recent_logs",
|
||||
detail={
|
||||
"evidence_id": evidence_id,
|
||||
"sanitized_character_count": len(recent_logs),
|
||||
"content_in_receipt": False,
|
||||
"redaction_contract": "incident_evidence.recent_logs_sanitized",
|
||||
"context_collected_before_candidate": True,
|
||||
},
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
)
|
||||
)
|
||||
return receipts
|
||||
|
||||
|
||||
def build_ansible_post_apply_runtime_stage_receipts(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
@@ -859,6 +912,165 @@ def build_ansible_post_apply_runtime_stage_receipts(
|
||||
return receipts
|
||||
|
||||
|
||||
def build_ansible_timeline_runtime_stage_receipt(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
*,
|
||||
apply_op_id: str,
|
||||
timeline_event_id: str,
|
||||
derived_from_durable_chain: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return _runtime_stage_receipt(
|
||||
claim,
|
||||
stage_id="timeline_projection",
|
||||
evidence_ref=f"timeline_events:{timeline_event_id}",
|
||||
detail={
|
||||
"timeline_event_id": timeline_event_id,
|
||||
"apply_op_id": apply_op_id,
|
||||
"returncode": result.returncode,
|
||||
"projection_status": "success" if result.returncode == 0 else "error",
|
||||
},
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
)
|
||||
|
||||
|
||||
async def _load_pre_decision_context_runtime_stage_receipts(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
*,
|
||||
project_id: str,
|
||||
derived_from_durable_chain: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Load only evidence collected before the root candidate was created."""
|
||||
|
||||
try:
|
||||
async with get_db_context(project_id) as db:
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
evidence.id,
|
||||
evidence.recent_logs,
|
||||
evidence.mcp_health,
|
||||
evidence.sensors_attempted,
|
||||
evidence.sensors_succeeded,
|
||||
evidence.collected_at
|
||||
FROM incident_evidence evidence
|
||||
JOIN automation_operation_log candidate
|
||||
ON candidate.op_id = CAST(:candidate_op_id AS uuid)
|
||||
WHERE evidence.incident_id = CAST(:incident_id AS varchar(30))
|
||||
AND evidence.collected_at <= candidate.created_at
|
||||
AND evidence.post_execution_state IS NULL
|
||||
AND (
|
||||
NULLIF(evidence.recent_logs, '') IS NOT NULL
|
||||
OR evidence.mcp_health IS NOT NULL
|
||||
)
|
||||
ORDER BY evidence.collected_at DESC
|
||||
LIMIT 10
|
||||
"""),
|
||||
{
|
||||
"candidate_op_id": claim.source_candidate_op_id,
|
||||
"incident_id": claim.incident_id,
|
||||
},
|
||||
)
|
||||
evidence_rows = [dict(row) for row in result.mappings().all()]
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ansible_pre_decision_context_receipt_read_failed",
|
||||
incident_id=claim.incident_id,
|
||||
candidate_op_id=claim.source_candidate_op_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
by_stage: dict[str, dict[str, Any]] = {}
|
||||
for evidence in evidence_rows:
|
||||
for receipt in build_ansible_context_runtime_stage_receipts(
|
||||
claim,
|
||||
evidence,
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
):
|
||||
by_stage.setdefault(str(receipt["stage_id"]), receipt)
|
||||
return list(by_stage.values())
|
||||
|
||||
|
||||
async def _record_timeline_projection_receipt(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
*,
|
||||
apply_op_id: str,
|
||||
project_id: str,
|
||||
derived_from_durable_chain: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist one idempotent operator timeline event and return its receipt."""
|
||||
|
||||
automation_run_id = _automation_run_id_for_claim(claim)
|
||||
description = (
|
||||
f"automation_run_id={automation_run_id};apply_op_id={apply_op_id};"
|
||||
f"catalog_id={claim.catalog_id};returncode={result.returncode}"
|
||||
)
|
||||
try:
|
||||
async with get_db_context(project_id) as db:
|
||||
row = await db.execute(
|
||||
text("""
|
||||
WITH existing AS (
|
||||
SELECT id
|
||||
FROM timeline_events
|
||||
WHERE incident_id = :incident_id
|
||||
AND actor = 'ansible_controlled_apply_worker'
|
||||
AND description = :description
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
), inserted AS (
|
||||
INSERT INTO timeline_events (
|
||||
incident_id, event_type, status, title, description,
|
||||
actor, actor_role, risk_level, created_at
|
||||
)
|
||||
SELECT
|
||||
:incident_id,
|
||||
'exec',
|
||||
:status,
|
||||
:title,
|
||||
:description,
|
||||
'ansible_controlled_apply_worker',
|
||||
'ai_agent',
|
||||
:risk_level,
|
||||
NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT id FROM inserted
|
||||
UNION ALL
|
||||
SELECT id FROM existing
|
||||
LIMIT 1
|
||||
"""),
|
||||
{
|
||||
"incident_id": claim.incident_id,
|
||||
"status": "success" if result.returncode == 0 else "error",
|
||||
"title": f"AI controlled apply: {claim.catalog_id}"[:500],
|
||||
"description": description,
|
||||
"risk_level": str(claim.risk_level or "")[:20] or None,
|
||||
},
|
||||
)
|
||||
timeline_event_id = str(row.scalar() or "")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ansible_timeline_projection_write_failed",
|
||||
incident_id=claim.incident_id,
|
||||
apply_op_id=apply_op_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return None
|
||||
|
||||
if not timeline_event_id:
|
||||
return None
|
||||
return build_ansible_timeline_runtime_stage_receipt(
|
||||
claim,
|
||||
result,
|
||||
apply_op_id=apply_op_id,
|
||||
timeline_event_id=timeline_event_id,
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
)
|
||||
|
||||
|
||||
async def _record_runtime_stage_receipts(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
@@ -867,8 +1079,15 @@ async def _record_runtime_stage_receipts(
|
||||
verifier_ready: bool,
|
||||
project_id: str,
|
||||
derived_from_durable_chain: bool = False,
|
||||
extra_receipts: tuple[dict[str, Any], ...] = (),
|
||||
) -> bool:
|
||||
context_receipts = await _load_pre_decision_context_runtime_stage_receipts(
|
||||
claim,
|
||||
project_id=project_id,
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
)
|
||||
receipts = [
|
||||
*context_receipts,
|
||||
*build_ansible_pre_apply_runtime_stage_receipts(
|
||||
claim,
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
@@ -880,6 +1099,7 @@ async def _record_runtime_stage_receipts(
|
||||
verifier_ready=verifier_ready,
|
||||
derived_from_durable_chain=derived_from_durable_chain,
|
||||
),
|
||||
*extra_receipts,
|
||||
]
|
||||
try:
|
||||
async with get_db_context(project_id) as db:
|
||||
@@ -1328,6 +1548,7 @@ async def backfill_missing_auto_repair_execution_receipts_once(
|
||||
"verification_written": 0,
|
||||
"learning_written": 0,
|
||||
"trust_learning_written": 0,
|
||||
"timeline_projection_written": 0,
|
||||
"runtime_stage_receipts_written": 0,
|
||||
"skipped": 0,
|
||||
"error": None,
|
||||
@@ -1393,7 +1614,16 @@ async def backfill_missing_auto_repair_execution_receipts_once(
|
||||
WHERE learning.operation_type = 'ansible_learning_writeback_recorded'
|
||||
AND learning.parent_op_id = apply.op_id
|
||||
)
|
||||
OR NOT (coalesce(apply.input, '{}'::jsonb) ? 'runtime_stage_receipts')
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(
|
||||
coalesce(
|
||||
apply.input -> 'runtime_stage_receipts',
|
||||
'[]'::jsonb
|
||||
)
|
||||
) AS receipt(value)
|
||||
WHERE receipt.value ->> 'stage_id' = 'timeline_projection'
|
||||
)
|
||||
)
|
||||
ORDER BY apply.created_at DESC
|
||||
LIMIT :limit
|
||||
@@ -1429,6 +1659,15 @@ async def backfill_missing_auto_repair_execution_receipts_once(
|
||||
stats["learning_written"] += 1
|
||||
if writeback.get("trust_learning"):
|
||||
stats["trust_learning_written"] += 1
|
||||
timeline_receipt = await _record_timeline_projection_receipt(
|
||||
claim,
|
||||
result,
|
||||
apply_op_id=str(row.get("op_id") or ""),
|
||||
project_id=project_id,
|
||||
derived_from_durable_chain=True,
|
||||
)
|
||||
if timeline_receipt is not None:
|
||||
stats["timeline_projection_written"] += 1
|
||||
if await _record_runtime_stage_receipts(
|
||||
claim,
|
||||
result,
|
||||
@@ -1438,6 +1677,7 @@ async def backfill_missing_auto_repair_execution_receipts_once(
|
||||
),
|
||||
project_id=project_id,
|
||||
derived_from_durable_chain=True,
|
||||
extra_receipts=(timeline_receipt,) if timeline_receipt else (),
|
||||
):
|
||||
stats["runtime_stage_receipts_written"] += 1
|
||||
except Exception as exc:
|
||||
@@ -2206,12 +2446,19 @@ async def run_controlled_apply_for_claim(
|
||||
apply_op_id=apply_op_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
timeline_receipt = await _record_timeline_projection_receipt(
|
||||
claim,
|
||||
result,
|
||||
apply_op_id=apply_op_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
runtime_stage_receipts_written = await _record_runtime_stage_receipts(
|
||||
claim,
|
||||
result,
|
||||
apply_op_id=apply_op_id,
|
||||
verifier_ready=bool(writeback.get("verification")),
|
||||
project_id=project_id,
|
||||
extra_receipts=(timeline_receipt,) if timeline_receipt else (),
|
||||
)
|
||||
telegram_receipt_sent = await _send_controlled_apply_telegram_receipt(
|
||||
claim,
|
||||
@@ -2233,6 +2480,7 @@ async def run_controlled_apply_for_claim(
|
||||
auto_repair_receipt_written=receipt_written,
|
||||
post_apply_verification_written=writeback.get("verification"),
|
||||
post_apply_learning_written=writeback.get("learning"),
|
||||
timeline_projection_written=timeline_receipt is not None,
|
||||
runtime_stage_receipts_written=runtime_stage_receipts_written,
|
||||
telegram_receipt_sent=telegram_receipt_sent,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user