diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py index 6818e1f9f..6e2b322f5 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -18,6 +18,9 @@ from sqlalchemy import text from src.core.config import settings from src.core.logging import get_logger from src.db.base import get_db_context +from src.services.ai_agent_log_controlled_writeback_executor_readback import ( + load_latest_ai_agent_log_controlled_writeback_executor_readback, +) from src.services.report_generation_service import ( DAILY_REPORT_HOUR_TAIPEI, MONTHLY_REPORT_DAY_TAIPEI, @@ -178,6 +181,90 @@ def _status_recent(summary: Mapping[str, Any] | None, *statuses: str) -> int: return sum(_int_value(by_status.get(status)) for status in statuses) +def _load_log_controlled_writeback_executor_readback() -> dict[str, Any]: + """Attach the LOG/KM/RAG/MCP/PlayBook executor queue without dispatching it.""" + + try: + return load_latest_ai_agent_log_controlled_writeback_executor_readback() + except Exception as exc: # pragma: no cover - keeps runtime control API visible + logger.warning( + "log_controlled_writeback_executor_readback_failed", + error_type=type(exc).__name__, + ) + return { + "schema_version": "ai_agent_log_controlled_writeback_executor_readback_v1", + "priority": "P1-LOG-KM-RAG-MCP-PLAYBOOK", + "scope": "ai_agent_log_controlled_writeback_executor", + "status": "blocked_waiting_log_controlled_writeback_executor_readback", + "readback": { + "workplan_id": "P1-LOG-CONTROLLED-WRITEBACK-EXECUTOR", + "workplan_title": "LOG feedback controlled writeback executor and AI Agent consumption readback", + "safe_next_step": "repair_log_controlled_writeback_executor_readback_then_retry", + "error_type": type(exc).__name__, + }, + "executor_policy": { + "executor_route": "ai_agent_metadata_writeback_executor", + "low_medium_high_controlled_apply_enabled": True, + "owner_review_required_for_low_medium_high": False, + "critical_break_glass_required": True, + "target_selector_required": True, + "source_of_truth_diff_required": True, + "check_mode_required": True, + "rollback_required": True, + "post_apply_verifier_required": True, + }, + "execution_batches": [], + "agent_consumption_context": { + "context_id": "ai-agent-log-controlled-writeback-consumption-v1", + "consumable_by": [ + "ai_agent_autonomous_runtime_control", + "awooop_work_items", + "alert_triage_loop", + "km_rag_playbook_learning_loop", + "mcp_audit_context_loop", + ], + "evidence_chain": [ + "/api/v1/agents/agent-log-intelligence-integration-readback", + "/api/v1/agents/agent-log-feedback-receipt-dry-run", + "/api/v1/agents/agent-log-post-write-verifier-dry-run", + "/api/v1/agents/agent-log-controlled-writeback-plan-readback", + ], + "next_action_queue": [], + "learning_feedback_targets": [], + "raw_payload_required": False, + }, + "rollups": { + "source_writeback_plan_count": 0, + "execution_batch_count": 0, + "ready_execution_batch_count": 0, + "target_count": 0, + "target_selector_count": 0, + "source_of_truth_diff_count": 0, + "check_mode_ready_count": 0, + "rollback_ready_count": 0, + "post_apply_verifier_ready_count": 0, + "controlled_executor_dispatch_ready": False, + "controlled_apply_enabled_by_policy": True, + "runtime_dispatch_performed": False, + }, + "active_blockers": ["log_controlled_writeback_executor_readback_unavailable"], + "operation_boundaries": { + "executor_readback_only": True, + "controlled_apply_enabled_by_policy": True, + "executor_dispatch_performed": False, + "km_write_performed": False, + "rag_index_write_performed": False, + "playbook_trust_write_performed": False, + "mcp_tool_call_performed": False, + "agent_runtime_action_performed": False, + "workflow_trigger_performed": False, + "raw_log_payload_persisted": False, + "secret_value_collection_allowed": False, + "github_api_used": False, + }, + } + + def _trace_stage( *, stage_id: str, @@ -1533,6 +1620,7 @@ def _build_work_item_progress( *, trace_ledger: Mapping[str, Any], log_integration_taxonomy: Mapping[str, Any], + log_controlled_writeback_executor: Mapping[str, Any], agent_decision_wiring: Mapping[str, Any], learning_loop: Mapping[str, Any], alert_noise_reduction: Mapping[str, Any], @@ -1581,6 +1669,20 @@ def _build_work_item_progress( and alert_noise_reduction.get("status") == "completed" and alert_noise_missing == 0 ) + log_executor_rollups = log_controlled_writeback_executor.get("rollups") + if not isinstance(log_executor_rollups, Mapping): + log_executor_rollups = {} + log_executor_blockers = log_controlled_writeback_executor.get("active_blockers") + if not isinstance(log_executor_blockers, list): + log_executor_blockers = [] + log_executor_ready = ( + log_controlled_writeback_executor.get("schema_version") + == "ai_agent_log_controlled_writeback_executor_readback_v1" + and log_controlled_writeback_executor.get("status") + == "controlled_writeback_executor_ready" + and log_executor_rollups.get("controlled_executor_dispatch_ready") is True + and not log_executor_blockers + ) ui_rollups = ui_productization.get("rollups") if not isinstance(ui_rollups, Mapping): ui_rollups = {} @@ -1680,11 +1782,24 @@ def _build_work_item_progress( "exit_criteria": "repeated alerts are clustered, deduped, routed to controlled automation, and no longer default to manual handling", "remaining_alert_noise_stage_count": alert_noise_missing, }, + { + "work_item_id": "P1-E-log-controlled-writeback-executor", + "priority": "P1-E", + "title": "LOG feedback executor queue for KM / RAG / MCP / PlayBook", + "status": "completed" if log_executor_ready else "in_progress" if p1d_completed else "pending", + "exit_criteria": "executor readback exposes ready batches, target selectors, source diffs, rollback, verifier, and next-action queue", + "remaining_executor_batch_count": max( + 0, + _int_value(log_executor_rollups.get("execution_batch_count")) + - _int_value(log_executor_rollups.get("ready_execution_batch_count")), + ), + "active_blocker_count": len(log_executor_blockers), + }, { "work_item_id": "P2-A-ui-ux-productization", "priority": "P2-A", "title": "Professional product UI replacing text-heavy surfaces", - "status": "completed" if p2a_completed else "in_progress" if p1d_completed else "pending", + "status": "completed" if p2a_completed else "in_progress" if log_executor_ready else "pending", "exit_criteria": "AI automation status is shown as dense dashboard controls, filters, counters, and action rails", "remaining_ui_surface_count": ui_surface_missing, }, @@ -2589,9 +2704,11 @@ def build_runtime_receipt_readback_from_rows( ) ui_productization = _build_ui_productization_readback() multi_product_taxonomy = _build_multi_product_taxonomy_contract(log_integration_taxonomy) + log_controlled_writeback_executor = _load_log_controlled_writeback_executor_readback() work_item_progress = _build_work_item_progress( trace_ledger=trace_ledger, log_integration_taxonomy=log_integration_taxonomy, + log_controlled_writeback_executor=log_controlled_writeback_executor, agent_decision_wiring=agent_decision_wiring, learning_loop=learning_loop, alert_noise_reduction=alert_noise_reduction, @@ -2718,6 +2835,7 @@ def build_runtime_receipt_readback_from_rows( "autonomous_execution_loop_ledger": loop_ledger, "trace_ledger": trace_ledger, "log_integration_taxonomy": log_integration_taxonomy, + "log_controlled_writeback_executor": log_controlled_writeback_executor, "agent_decision_wiring": agent_decision_wiring, "learning_loop": learning_loop, "alert_noise_reduction": alert_noise_reduction, @@ -2739,6 +2857,21 @@ def _attach_runtime_receipt_readback( ) -> dict[str, Any]: payload["runtime_receipt_readback"] = readback rollups = payload.setdefault("rollups", {}) + log_executor = readback.get("log_controlled_writeback_executor") + if not isinstance(log_executor, Mapping): + log_executor = {} + log_executor_rollups = log_executor.get("rollups") + if not isinstance(log_executor_rollups, Mapping): + log_executor_rollups = {} + log_executor_context = log_executor.get("agent_consumption_context") + if not isinstance(log_executor_context, Mapping): + log_executor_context = {} + log_executor_queue = log_executor_context.get("next_action_queue") + if not isinstance(log_executor_queue, list): + log_executor_queue = [] + log_executor_blockers = log_executor.get("active_blockers") + if not isinstance(log_executor_blockers, list): + log_executor_blockers = [] rollups.update({ "live_ansible_apply_executed_count": _int_value( readback.get("ansible_apply_executed", {}).get("total") @@ -2826,6 +2959,23 @@ def _attach_runtime_receipt_readback( "recent_classified_event_total" ) ), + "live_log_controlled_writeback_executor_batch_count": _int_value( + log_executor_rollups.get("execution_batch_count") + ), + "live_log_controlled_writeback_executor_ready_batch_count": _int_value( + log_executor_rollups.get("ready_execution_batch_count") + ), + "live_log_controlled_writeback_executor_ready_count": ( + 1 + if log_executor.get("status") == "controlled_writeback_executor_ready" + else 0 + ), + "live_log_controlled_writeback_executor_blocker_count": len( + log_executor_blockers + ), + "live_log_controlled_writeback_next_action_queue_count": len( + log_executor_queue + ), "live_agent_decision_wiring_stage_count": _int_value( ((readback.get("agent_decision_wiring") or {}).get("rollups") or {}).get( "stage_count" diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py index 880b1b28d..3ebb0f102 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -1,6 +1,6 @@ from src.services.ai_agent_autonomous_runtime_control import ( - _RUNTIME_ALERTMANAGER_EVENT_COUNTS_SQL, _RUNTIME_ALERT_OPERATION_COUNTS_SQL, + _RUNTIME_ALERTMANAGER_EVENT_COUNTS_SQL, _RUNTIME_GROUPED_ALERT_EVENT_COUNTS_SQL, _RUNTIME_PLAYBOOK_TRUST_COUNTS_FALLBACK_SQL, _RUNTIME_TIMELINE_COUNTS_SQL, @@ -10,6 +10,50 @@ from src.services.ai_agent_autonomous_runtime_control import ( ) +def _assert_log_controlled_writeback_executor(payload: dict): + assert payload["schema_version"] == "ai_agent_log_controlled_writeback_executor_readback_v1" + assert payload["priority"] == "P1-LOG-KM-RAG-MCP-PLAYBOOK" + assert payload["status"] == "controlled_writeback_executor_ready" + assert payload["active_blockers"] == [] + + policy = payload["executor_policy"] + assert policy["low_medium_high_controlled_apply_enabled"] is True + assert policy["owner_review_required_for_low_medium_high"] is False + assert policy["critical_break_glass_required"] is True + assert policy["target_selector_required"] is True + assert policy["source_of_truth_diff_required"] is True + assert policy["check_mode_required"] is True + assert policy["rollback_required"] is True + assert policy["post_apply_verifier_required"] is True + + assert payload["rollups"]["source_writeback_plan_count"] == 12 + assert payload["rollups"]["execution_batch_count"] == 6 + assert payload["rollups"]["ready_execution_batch_count"] == 6 + assert payload["rollups"]["target_selector_count"] == 12 + assert payload["rollups"]["source_of_truth_diff_count"] == 12 + assert payload["rollups"]["controlled_executor_dispatch_ready"] is True + assert payload["rollups"]["controlled_apply_enabled_by_policy"] is True + assert payload["rollups"]["runtime_dispatch_performed"] is False + + context = payload["agent_consumption_context"] + assert "ai_agent_autonomous_runtime_control" in context["consumable_by"] + assert "km_rag_playbook_learning_loop" in context["consumable_by"] + assert len(context["next_action_queue"]) == 6 + assert context["raw_payload_required"] is False + + boundaries = payload["operation_boundaries"] + assert boundaries["executor_readback_only"] is True + assert boundaries["executor_dispatch_performed"] is False + assert boundaries["km_write_performed"] is False + assert boundaries["rag_index_write_performed"] is False + assert boundaries["playbook_trust_write_performed"] is False + assert boundaries["mcp_tool_call_performed"] is False + assert boundaries["agent_runtime_action_performed"] is False + assert boundaries["raw_log_payload_persisted"] is False + assert boundaries["secret_value_collection_allowed"] is False + assert boundaries["github_api_used"] is False + + def test_runtime_receipt_auxiliary_sql_keeps_source_family_counts_schema_safe(): assert "GROUP BY coalesce(status, 'unknown')" in _RUNTIME_TIMELINE_COUNTS_SQL assert "FROM timeline_events" in _RUNTIME_TIMELINE_COUNTS_SQL @@ -76,6 +120,14 @@ def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receip assert data["rollups"]["direct_bot_api_allowed_count"] == 0 assert data["rollups"]["legacy_policy_overridden_count"] >= 4 assert data["runtime_receipt_readback"]["db_read_status"] == "not_queried" + _assert_log_controlled_writeback_executor( + data["runtime_receipt_readback"]["log_controlled_writeback_executor"] + ) + assert data["rollups"]["live_log_controlled_writeback_executor_batch_count"] == 6 + assert data["rollups"]["live_log_controlled_writeback_executor_ready_batch_count"] == 6 + assert data["rollups"]["live_log_controlled_writeback_executor_ready_count"] == 1 + assert data["rollups"]["live_log_controlled_writeback_executor_blocker_count"] == 0 + assert data["rollups"]["live_log_controlled_writeback_next_action_queue_count"] == 6 assert data["runtime_receipt_readback"]["learning_loop"]["status"] == "in_progress" assert ( data["runtime_receipt_readback"]["learning_loop"]["rollups"][ @@ -453,6 +505,9 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): }.issubset(set(taxonomy["required_label_dimensions"])) assert taxonomy["public_safety"]["raw_secret_collection_allowed"] is False assert taxonomy["public_safety"]["unredacted_payload_storage_allowed"] is False + _assert_log_controlled_writeback_executor( + readback["log_controlled_writeback_executor"] + ) decision_wiring = readback["agent_decision_wiring"] assert decision_wiring["schema_version"] == "ai_agent_decision_wiring_readback_v1" assert decision_wiring["status"] == "completed" @@ -561,6 +616,7 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): "P1-B-agent-decision-wiring", "P1-C-learning-loop", "P1-D-alert-noise-reduction", + "P1-E-log-controlled-writeback-executor", "P2-A-ui-ux-productization", "P2-B-multi-product-expansion", ] @@ -572,13 +628,16 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): assert progress["ordered_items"][8]["status"] == "completed" assert progress["ordered_items"][8]["remaining_alert_noise_stage_count"] == 0 assert progress["ordered_items"][9]["status"] == "completed" - assert progress["ordered_items"][9]["remaining_ui_surface_count"] == 0 + assert progress["ordered_items"][9]["remaining_executor_batch_count"] == 0 + assert progress["ordered_items"][9]["active_blocker_count"] == 0 assert progress["ordered_items"][10]["status"] == "completed" - assert progress["ordered_items"][10]["remaining_product_scope_count"] == 0 + assert progress["ordered_items"][10]["remaining_ui_surface_count"] == 0 + assert progress["ordered_items"][11]["status"] == "completed" + assert progress["ordered_items"][11]["remaining_product_scope_count"] == 0 assert progress["source_family_items"] assert {item["status"] for item in progress["source_family_items"]} == {"completed"} assert progress["rollups"]["source_family_work_item_count"] == 10 - assert progress["rollups"]["completed_count"] == 21 + assert progress["rollups"]["completed_count"] == 22 assert progress["rollups"]["pending_count"] == 0 @@ -763,10 +822,11 @@ def test_runtime_receipt_work_items_use_learning_receipts_without_latest_telegra } assert statuses["P1-C-learning-loop"] == "completed" assert statuses["P1-D-alert-noise-reduction"] == "completed" + assert statuses["P1-E-log-controlled-writeback-executor"] == "completed" assert statuses["P2-A-ui-ux-productization"] == "completed" assert statuses["P2-B-multi-product-expansion"] == "completed" assert {item["status"] for item in progress["source_family_items"]} == {"completed"} - assert progress["rollups"]["completed_count"] == 21 + assert progress["rollups"]["completed_count"] == 22 assert progress["rollups"]["pending_count"] == 0