diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 44480107..b8242f6c 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -277,6 +277,8 @@ jobs: ;; apps/api/src/services/ai_agent_log_controlled_writeback_dispatch.py) ;; + apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py) + ;; apps/api/src/services/ai_agent_autonomous_runtime_control.py) ;; apps/api/src/services/awooop_ansible_audit_service.py) @@ -381,6 +383,8 @@ jobs: ;; apps/api/tests/test_ai_agent_log_controlled_writeback_dispatch_api.py) ;; + apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py) + ;; apps/api/tests/test_ai_agent_autonomous_runtime_control.py) ;; apps/api/tests/test_awooop_truth_chain_service.py) @@ -572,6 +576,7 @@ jobs: src/services/ai_agent_log_controlled_writeback_plan_readback.py \ src/services/ai_agent_log_controlled_writeback_executor_readback.py \ src/services/ai_agent_log_controlled_writeback_dispatch.py \ + src/services/ai_agent_log_controlled_writeback_consumer_readback.py \ src/services/ai_agent_autonomous_runtime_control.py \ src/services/awooop_ansible_audit_service.py \ src/services/awooop_ansible_check_mode_service.py \ @@ -627,6 +632,7 @@ jobs: tests/test_ai_agent_log_controlled_writeback_plan_readback_api.py \ tests/test_ai_agent_log_controlled_writeback_executor_readback_api.py \ tests/test_ai_agent_log_controlled_writeback_dispatch_api.py \ + tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py \ tests/test_ai_agent_autonomous_runtime_control.py \ tests/test_awooop_truth_chain_service.py \ tests/test_shadow_auto_approve.py \ diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 255a3357..732fb96d 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -100,6 +100,9 @@ from src.services.ai_agent_learning_writeback_approval_package import ( from src.services.ai_agent_live_read_model_gate import ( load_latest_ai_agent_live_read_model_gate, ) +from src.services.ai_agent_log_controlled_writeback_consumer_readback import ( + load_latest_ai_agent_log_controlled_writeback_consumer_readback, +) from src.services.ai_agent_log_controlled_writeback_dispatch import ( dispatch_latest_ai_agent_log_controlled_writeback, ) @@ -381,12 +384,12 @@ from src.services.gitea_private_inventory_closeout_validation import ( from src.services.gitea_private_inventory_p0_scorecard import ( load_latest_gitea_private_inventory_p0_scorecard, ) -from src.services.gitea_workflow_runner_owner_attestation_request import ( - load_latest_gitea_workflow_runner_owner_attestation_request, -) from src.services.gitea_workflow_runner_health import ( load_latest_gitea_workflow_runner_health, ) +from src.services.gitea_workflow_runner_owner_attestation_request import ( + load_latest_gitea_workflow_runner_owner_attestation_request, +) from src.services.github_target_private_backup_evidence_gate import ( load_latest_github_target_controlled_execution_preflight, load_latest_github_target_private_backup_evidence_gate, @@ -2198,6 +2201,34 @@ async def post_agent_log_controlled_writeback_dispatch() -> dict[str, Any]: ) from exc +@router.get( + "/agent-log-controlled-writeback-consumer-readback", + response_model=dict[str, Any], + summary="取得 AI Agent LOG controlled writeback consumer readback", + description=( + "讀取 automation_operation_log 中的 LOG metadata-only dispatch receipts," + "並投影成 KM / RAG / PlayBook / MCP / verifier / AI Agent consumer " + "context binding。此端點不寫 KM、不寫 RAG index、不更新 PlayBook trust、" + "不呼叫 MCP tool、不發 Telegram、不觸發 workflow、不保存 raw log payload、" + "不讀 secret、不呼叫 GitHub。" + ), +) +async def get_agent_log_controlled_writeback_consumer_readback() -> dict[str, Any]: + """Read live metadata-only LOG controlled writeback consumer bindings.""" + try: + payload = await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + return redact_public_lan_topology(payload) + except (json.JSONDecodeError, ValueError) as exc: + logger.error( + "ai_agent_log_controlled_writeback_consumer_readback_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI Agent LOG controlled writeback consumer readback 無效", + ) from exc + + @router.get( "/agent-telegram-receipt-approval-package", response_model=dict[str, Any], 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 0ca66232..9f137a85 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_consumer_readback import ( + load_latest_ai_agent_log_controlled_writeback_consumer_readback, +) from src.services.ai_agent_log_controlled_writeback_dispatch import ( OPERATION_TYPE as LOG_CONTROLLED_WRITEBACK_DISPATCH_OPERATION_TYPE, ) @@ -269,6 +272,91 @@ def _load_log_controlled_writeback_executor_readback() -> dict[str, Any]: } +def _fallback_log_controlled_writeback_consumer_readback( + error_type: str | None = None, +) -> dict[str, Any]: + readback = { + "schema_version": "ai_agent_log_controlled_writeback_consumer_readback_v1", + "priority": "P1-LOG-KM-RAG-MCP-PLAYBOOK", + "scope": "ai_agent_log_controlled_writeback_consumer_readback", + "status": "blocked_waiting_controlled_writeback_consumer_receipts", + "readback": { + "workplan_id": "P1-LOG-CONTROLLED-WRITEBACK-CONSUMER-READBACK", + "workplan_title": ( + "LOG metadata ledger receipts consumable by KM / RAG / PlayBook / " + "MCP / verifier / AI Agent context" + ), + "source_operation_type": LOG_CONTROLLED_WRITEBACK_DISPATCH_OPERATION_TYPE, + "source_executor_route": "ai_agent_metadata_writeback_executor", + "safe_next_step": "repair_log_controlled_writeback_consumer_readback_then_retry", + }, + "controlled_consume": { + "mode": "blocked_waiting_consumer_readback", + "controlled_consume_allowed": False, + "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, + "runtime_target_write_performed": False, + }, + "consumer_bindings": [], + "target_rollups": [], + "rollups": { + "target_count": 6, + "dispatch_ledger_row_count": 0, + "consumer_binding_count": 0, + "ready_consumer_binding_count": 0, + "ready_target_count": 0, + "metadata_only_receipt_count": 0, + "post_apply_verifier_ref_count": 0, + "controlled_consumer_readback_ready": False, + "runtime_target_write_performed": False, + }, + "active_blockers": ["log_controlled_writeback_consumer_readback_unavailable"], + "operation_boundaries": { + "consumer_readback_only": True, + "metadata_ledger_read_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, + "telegram_send_performed": False, + "workflow_trigger_performed": False, + "raw_log_payload_persisted": False, + "secret_value_collection_allowed": False, + "github_api_used": False, + }, + } + if error_type: + readback["readback"]["error_type"] = error_type + return readback + + +async def _load_log_controlled_writeback_consumer_readback( + *, + project_id: str, +) -> dict[str, Any]: + """Attach LOG consumer bindings without writing KM/RAG/PlayBook/MCP targets.""" + + try: + return await load_latest_ai_agent_log_controlled_writeback_consumer_readback( + project_id=project_id, + ) + except Exception as exc: # pragma: no cover - keeps runtime control API visible + logger.warning( + "log_controlled_writeback_consumer_readback_failed", + project_id=project_id, + error_type=type(exc).__name__, + ) + return _fallback_log_controlled_writeback_consumer_readback( + error_type=type(exc).__name__, + ) + + def _trace_stage( *, stage_id: str, @@ -1625,6 +1713,7 @@ def _build_work_item_progress( trace_ledger: Mapping[str, Any], log_integration_taxonomy: Mapping[str, Any], log_controlled_writeback_executor: Mapping[str, Any], + log_controlled_writeback_consumer: Mapping[str, Any], agent_decision_wiring: Mapping[str, Any], learning_loop: Mapping[str, Any], alert_noise_reduction: Mapping[str, Any], @@ -1687,6 +1776,20 @@ def _build_work_item_progress( and log_executor_rollups.get("controlled_executor_dispatch_ready") is True and not log_executor_blockers ) + log_consumer_rollups = log_controlled_writeback_consumer.get("rollups") + if not isinstance(log_consumer_rollups, Mapping): + log_consumer_rollups = {} + log_consumer_blockers = log_controlled_writeback_consumer.get("active_blockers") + if not isinstance(log_consumer_blockers, list): + log_consumer_blockers = [] + log_consumer_ready = ( + log_controlled_writeback_consumer.get("schema_version") + == "ai_agent_log_controlled_writeback_consumer_readback_v1" + and log_controlled_writeback_consumer.get("status") + == "controlled_writeback_consumer_readback_ready" + and log_consumer_rollups.get("controlled_consumer_readback_ready") is True + and not log_consumer_blockers + ) ui_rollups = ui_productization.get("rollups") if not isinstance(ui_rollups, Mapping): ui_rollups = {} @@ -1799,11 +1902,24 @@ def _build_work_item_progress( ), "active_blocker_count": len(log_executor_blockers), }, + { + "work_item_id": "P1-F-log-controlled-writeback-consumer", + "priority": "P1-F", + "title": "LOG metadata receipts consumable by KM / RAG / MCP / PlayBook / AI Agent", + "status": "completed" if log_consumer_ready else "in_progress" if log_executor_ready else "pending", + "exit_criteria": "runtime-control exposes ready consumer bindings for all LOG metadata writeback targets", + "remaining_consumer_binding_count": max( + 0, + _int_value(log_consumer_rollups.get("target_count")) + - _int_value(log_consumer_rollups.get("ready_target_count")), + ), + "active_blocker_count": len(log_consumer_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 log_executor_ready else "pending", + "status": "completed" if p2a_completed else "in_progress" if log_consumer_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, }, @@ -2584,6 +2700,7 @@ def build_runtime_receipt_readback_from_rows( alert_operation_count_rows: Iterable[Mapping[str, Any] | Any] = (), alertmanager_event_count_rows: Iterable[Mapping[str, Any] | Any] = (), grouped_alert_event_count_rows: Iterable[Mapping[str, Any] | Any] = (), + log_controlled_writeback_consumer: Mapping[str, Any] | None = None, error_type: str | None = None, ) -> dict[str, Any]: """Build the live executor receipt readback from already-fetched rows.""" @@ -2709,10 +2826,15 @@ 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() + if not isinstance(log_controlled_writeback_consumer, Mapping): + log_controlled_writeback_consumer = ( + _fallback_log_controlled_writeback_consumer_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, + log_controlled_writeback_consumer=log_controlled_writeback_consumer, agent_decision_wiring=agent_decision_wiring, learning_loop=learning_loop, alert_noise_reduction=alert_noise_reduction, @@ -2840,6 +2962,7 @@ def build_runtime_receipt_readback_from_rows( "trace_ledger": trace_ledger, "log_integration_taxonomy": log_integration_taxonomy, "log_controlled_writeback_executor": log_controlled_writeback_executor, + "log_controlled_writeback_consumer": dict(log_controlled_writeback_consumer), "agent_decision_wiring": agent_decision_wiring, "learning_loop": learning_loop, "alert_noise_reduction": alert_noise_reduction, @@ -2876,6 +2999,15 @@ def _attach_runtime_receipt_readback( log_executor_blockers = log_executor.get("active_blockers") if not isinstance(log_executor_blockers, list): log_executor_blockers = [] + log_consumer = readback.get("log_controlled_writeback_consumer") + if not isinstance(log_consumer, Mapping): + log_consumer = {} + log_consumer_rollups = log_consumer.get("rollups") + if not isinstance(log_consumer_rollups, Mapping): + log_consumer_rollups = {} + log_consumer_blockers = log_consumer.get("active_blockers") + if not isinstance(log_consumer_blockers, list): + log_consumer_blockers = [] operation_counts = (readback.get("ansible_operations") or {}).get("counts") if not isinstance(operation_counts, Mapping): operation_counts = {} @@ -2992,6 +3124,48 @@ def _attach_runtime_receipt_readback( "live_log_controlled_writeback_recent_dispatch_count": _int_value( log_dispatch_summary.get("recent") ), + "live_log_controlled_writeback_consumer_binding_count": _int_value( + log_consumer_rollups.get("consumer_binding_count") + ), + "live_log_controlled_writeback_consumer_ready_binding_count": _int_value( + log_consumer_rollups.get("ready_consumer_binding_count") + ), + "live_log_controlled_writeback_consumer_ready_target_count": _int_value( + log_consumer_rollups.get("ready_target_count") + ), + "live_log_controlled_writeback_consumer_ready_count": ( + 1 + if log_consumer.get("status") + == "controlled_writeback_consumer_readback_ready" + else 0 + ), + "live_log_controlled_writeback_consumer_blocker_count": len( + log_consumer_blockers + ), + "live_log_controlled_writeback_consumer_metadata_only_count": _int_value( + log_consumer_rollups.get("metadata_only_receipt_count") + ), + "live_log_controlled_writeback_consumer_verifier_ref_count": _int_value( + log_consumer_rollups.get("post_apply_verifier_ref_count") + ), + "live_log_controlled_writeback_km_consumer_binding_count": _int_value( + log_consumer_rollups.get("km_consumer_binding_count") + ), + "live_log_controlled_writeback_rag_consumer_binding_count": _int_value( + log_consumer_rollups.get("rag_consumer_binding_count") + ), + "live_log_controlled_writeback_playbook_consumer_binding_count": _int_value( + log_consumer_rollups.get("playbook_consumer_binding_count") + ), + "live_log_controlled_writeback_mcp_consumer_binding_count": _int_value( + log_consumer_rollups.get("mcp_consumer_binding_count") + ), + "live_log_controlled_writeback_verifier_consumer_binding_count": _int_value( + log_consumer_rollups.get("verifier_consumer_binding_count") + ), + "live_log_controlled_writeback_ai_agent_consumer_binding_count": _int_value( + log_consumer_rollups.get("ai_agent_consumer_binding_count") + ), "live_agent_decision_wiring_stage_count": _int_value( ((readback.get("agent_decision_wiring") or {}).get("rollups") or {}).get( "stage_count" @@ -3483,6 +3657,11 @@ async def load_ai_agent_autonomous_runtime_receipt_readback( "grouped_alert_event_counts", _RUNTIME_GROUPED_ALERT_EVENT_COUNTS_SQL, ) + log_controlled_writeback_consumer = ( + await _load_log_controlled_writeback_consumer_readback( + project_id=project_id, + ) + ) except Exception as exc: logger.warning( "ai_agent_autonomous_runtime_receipt_readback_failed", @@ -3519,6 +3698,7 @@ async def load_ai_agent_autonomous_runtime_receipt_readback( alert_operation_count_rows=alert_operation_counts, alertmanager_event_count_rows=alertmanager_event_counts, grouped_alert_event_count_rows=grouped_alert_event_counts, + log_controlled_writeback_consumer=log_controlled_writeback_consumer, ) diff --git a/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py b/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py new file mode 100644 index 00000000..211279da --- /dev/null +++ b/apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py @@ -0,0 +1,315 @@ +"""AI Agent LOG controlled writeback consumer readback. + +Reads live metadata-only dispatch receipts from automation_operation_log and +turns them into KM / RAG / PlayBook / MCP / verifier / AI Agent consumer +bindings. This endpoint does not write those target systems, call MCP tools, +trigger workflows, or persist raw log payloads. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sqlalchemy import text + +from src.db.base import get_db_context +from src.services.ai_agent_log_controlled_writeback_dispatch import ( + EXECUTOR_ROUTE, + OPERATION_TYPE, +) + +SCHEMA_VERSION = "ai_agent_log_controlled_writeback_consumer_readback_v1" +DEFAULT_PROJECT_ID = "awoooi" +_TARGETS = ("km", "rag", "playbook", "mcp", "verifier", "ai_agent") +_CONSUMER_SURFACES = { + "km": "knowledge_memory_context", + "rag": "rag_index_candidate_context", + "playbook": "playbook_trust_candidate_context", + "mcp": "mcp_audit_feedback_context", + "verifier": "post_apply_verifier_feedback_context", + "ai_agent": "autonomous_runtime_decision_context", +} + + +async def load_latest_ai_agent_log_controlled_writeback_consumer_readback( + *, + project_id: str = DEFAULT_PROJECT_ID, +) -> dict[str, Any]: + """Return live consumer bindings for LOG controlled writeback receipts.""" + + async with get_db_context(project_id) as db: + result = await db.execute( + text(""" + SELECT + op_id::text AS op_id, + operation_type, + actor, + status, + created_at, + input ->> 'semantic_operation_type' AS semantic_operation_type, + input ->> 'ledger_operation_type' AS ledger_operation_type, + input ->> 'dispatch_receipt_id' AS dispatch_receipt_id, + input ->> 'batch_id' AS batch_id, + input ->> 'target' AS target, + input ->> 'target_surface' AS target_surface, + input ->> 'risk_tier' AS risk_tier, + input ->> 'project_id' AS project_id, + input ->> 'raw_payload_included' AS raw_payload_included, + output ->> 'next_action' AS next_action, + output ->> 'km_write_performed' AS km_write_performed, + output ->> 'rag_index_write_performed' AS rag_index_write_performed, + output ->> 'playbook_trust_write_performed' AS playbook_trust_write_performed, + output ->> 'mcp_tool_call_performed' AS mcp_tool_call_performed, + output ->> 'telegram_send_performed' AS telegram_send_performed, + output -> 'post_apply_verifier_refs' AS post_apply_verifier_refs + FROM automation_operation_log + WHERE coalesce(input ->> 'semantic_operation_type', operation_type) + = :operation_type + ORDER BY created_at DESC, op_id DESC + LIMIT 50 + """), + {"operation_type": OPERATION_TYPE}, + ) + rows = _result_rows(result) + bindings = [_consumer_binding(row) for row in rows] + active_blockers = _active_blockers(bindings) + + return { + "schema_version": SCHEMA_VERSION, + "priority": "P1-LOG-KM-RAG-MCP-PLAYBOOK", + "scope": "ai_agent_log_controlled_writeback_consumer_readback", + "status": ( + "controlled_writeback_consumer_readback_ready" + if not active_blockers + else "blocked_waiting_controlled_writeback_consumer_receipts" + ), + "readback": { + "workplan_id": "P1-LOG-CONTROLLED-WRITEBACK-CONSUMER-READBACK", + "workplan_title": ( + "LOG metadata ledger receipts consumable by KM / RAG / PlayBook / " + "MCP / verifier / AI Agent context" + ), + "source_operation_type": OPERATION_TYPE, + "source_executor_route": EXECUTOR_ROUTE, + "safe_next_step": ( + "consume_metadata_receipts_in_km_rag_playbook_agent_context_with_post_apply_verifier" + ), + }, + "controlled_consume": { + "mode": "controlled_consumer_readback_ready" + if not active_blockers + else "blocked_waiting_consumer_readback", + "controlled_consume_allowed": not active_blockers, + "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, + "runtime_target_write_performed": False, + }, + "consumer_bindings": bindings, + "target_rollups": _target_rollups(bindings), + "rollups": { + "target_count": len(_TARGETS), + "dispatch_ledger_row_count": len(rows), + "consumer_binding_count": len(bindings), + "ready_consumer_binding_count": sum( + 1 for binding in bindings if binding["status"] == "ready_for_consumer_context" + ), + "ready_target_count": sum( + 1 for item in _target_rollups(bindings) if item["ready_binding_count"] > 0 + ), + "metadata_only_receipt_count": sum( + 1 for binding in bindings if binding["raw_payload_included"] is False + ), + "post_apply_verifier_ref_count": sum( + len(binding["post_apply_verifier_refs"]) for binding in bindings + ), + "controlled_consumer_readback_ready": not active_blockers, + "km_consumer_binding_count": _target_count(bindings, "km"), + "rag_consumer_binding_count": _target_count(bindings, "rag"), + "playbook_consumer_binding_count": _target_count(bindings, "playbook"), + "mcp_consumer_binding_count": _target_count(bindings, "mcp"), + "verifier_consumer_binding_count": _target_count(bindings, "verifier"), + "ai_agent_consumer_binding_count": _target_count(bindings, "ai_agent"), + "runtime_target_write_performed": False, + }, + "active_blockers": active_blockers, + "operation_boundaries": { + "consumer_readback_only": True, + "metadata_ledger_read_performed": True, + "km_write_performed": False, + "rag_index_write_performed": False, + "playbook_trust_write_performed": False, + "mcp_tool_call_performed": False, + "agent_runtime_action_performed": False, + "telegram_send_performed": False, + "workflow_trigger_performed": False, + "raw_log_payload_persisted": False, + "secret_value_collection_allowed": False, + "github_api_used": False, + }, + } + + +def _consumer_binding(row: Mapping[str, Any]) -> dict[str, Any]: + target = str(row.get("target") or "") + verifier_refs = _strings(row.get("post_apply_verifier_refs")) + raw_payload_included = str(row.get("raw_payload_included") or "").lower() == "true" + return { + "consumer_binding_id": f"consumer::{row.get('dispatch_receipt_id') or row.get('op_id')}", + "dispatch_receipt_id": str(row.get("dispatch_receipt_id") or ""), + "ledger_op_id": str(row.get("op_id") or ""), + "target": target, + "target_surface": str(row.get("target_surface") or ""), + "consumer_surface": _CONSUMER_SURFACES.get(target, "unknown_consumer_surface"), + "risk_tier": str(row.get("risk_tier") or ""), + "status": ( + "ready_for_consumer_context" + if _binding_ready(row, raw_payload_included) + else "blocked_waiting_consumer_binding_controls" + ), + "project_id": str(row.get("project_id") or ""), + "executor_route": str(row.get("actor") or ""), + "semantic_operation_type": str(row.get("semantic_operation_type") or ""), + "ledger_operation_type": str(row.get("ledger_operation_type") or ""), + "ledger_status": str(row.get("status") or ""), + "raw_payload_included": raw_payload_included, + "next_action": str(row.get("next_action") or ""), + "post_apply_verifier_refs": verifier_refs, + "target_selector": { + "dispatch_receipt_id": str(row.get("dispatch_receipt_id") or ""), + "target": target, + "target_surface": str(row.get("target_surface") or ""), + "consumer_surface": _CONSUMER_SURFACES.get(target, "unknown_consumer_surface"), + }, + "source_of_truth_diff": { + "current_state": "metadata_dispatch_receipt_in_ledger", + "desired_state": "metadata_receipt_available_to_consumer_context", + "delta_kind": f"{target}_consumer_context_binding", + "raw_payload_included": raw_payload_included, + }, + "check_mode": { + "enabled": True, + "checks": [ + "dispatch_receipt_id_present", + "target_surface_present", + "metadata_only_raw_payload_absent", + "post_apply_verifier_refs_present", + "ledger_status_success", + ], + }, + "rollback": { + "required": True, + "rollback_ref": ( + "rollback://ai-agent-log-controlled-writeback-consumer/" + f"{row.get('dispatch_receipt_id') or row.get('op_id')}" + ), + "strategy": "ignore_consumer_binding_and_mark_receipt_superseded", + }, + "post_apply_verifier": { + "required": True, + "verifier_refs": verifier_refs, + }, + "target_write_performed": False, + } + + +def _binding_ready(row: Mapping[str, Any], raw_payload_included: bool) -> bool: + if str(row.get("semantic_operation_type") or "") != OPERATION_TYPE: + return False + if str(row.get("actor") or "") != EXECUTOR_ROUTE: + return False + if str(row.get("status") or "") != "success": + return False + if str(row.get("target") or "") not in _TARGETS: + return False + if not str(row.get("dispatch_receipt_id") or ""): + return False + if not str(row.get("target_surface") or ""): + return False + if raw_payload_included: + return False + return bool(_strings(row.get("post_apply_verifier_refs"))) + + +def _active_blockers(bindings: list[dict[str, Any]]) -> list[str]: + blockers: list[str] = [] + if not bindings: + blockers.append("log_controlled_writeback_dispatch_receipts_missing") + missing_targets = [target for target in _TARGETS if _target_count(bindings, target) == 0] + for target in missing_targets: + blockers.append(f"{target}_consumer_binding_missing") + for binding in bindings: + if binding["status"] != "ready_for_consumer_context": + blockers.append(f"{binding['target'] or 'unknown'}_consumer_binding_not_ready") + if binding["raw_payload_included"] is not False: + blockers.append(f"{binding['target'] or 'unknown'}_raw_payload_included") + return _unique(blockers) + + +def _target_rollups(bindings: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "target": target, + "consumer_surface": _CONSUMER_SURFACES[target], + "binding_count": _target_count(bindings, target), + "ready_binding_count": sum( + 1 + for binding in bindings + if binding["target"] == target + and binding["status"] == "ready_for_consumer_context" + ), + "metadata_only": all( + binding["raw_payload_included"] is False + for binding in bindings + if binding["target"] == target + ), + } + for target in _TARGETS + ] + + +def _target_count(bindings: list[dict[str, Any]], target: str) -> int: + return sum(1 for binding in bindings if binding["target"] == target) + + +def _result_rows(result: Any) -> list[dict[str, Any]]: + mappings = getattr(result, "mappings", None) + if callable(mappings): + return [dict(row) for row in mappings().all()] + all_rows = getattr(result, "all", None) + if callable(all_rows): + return [_row_mapping(row) for row in all_rows()] + return [_row_mapping(row) for row in result] + + +def _row_mapping(row: Any) -> dict[str, Any]: + if isinstance(row, Mapping): + return dict(row) + mapping = getattr(row, "_mapping", None) + if mapping is not None: + return dict(mapping) + return dict(row) + + +def _strings(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, tuple): + return [str(item) for item in value] + return [] + + +def _unique(values: list[str]) -> list[str]: + seen = set() + result = [] + for value in values: + if value in seen: + continue + seen.add(value) + result.append(value) + return result 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 ed81b74f..35b52acb 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -54,6 +54,101 @@ def _assert_log_controlled_writeback_executor(payload: dict): assert boundaries["github_api_used"] is False +def _log_controlled_writeback_consumer_readback() -> dict: + targets = ("km", "rag", "playbook", "mcp", "verifier", "ai_agent") + bindings = [ + { + "consumer_binding_id": f"consumer::receipt::{target}", + "dispatch_receipt_id": f"receipt::{target}", + "ledger_op_id": f"op::{target}", + "target": target, + "target_surface": f"{target}_metadata_feedback_binding", + "consumer_surface": f"{target}_consumer_context", + "status": "ready_for_consumer_context", + "raw_payload_included": False, + "post_apply_verifier_refs": [f"verifier://{target}"], + "target_write_performed": False, + } + for target in targets + ] + return { + "schema_version": "ai_agent_log_controlled_writeback_consumer_readback_v1", + "priority": "P1-LOG-KM-RAG-MCP-PLAYBOOK", + "scope": "ai_agent_log_controlled_writeback_consumer_readback", + "status": "controlled_writeback_consumer_readback_ready", + "readback": { + "workplan_id": "P1-LOG-CONTROLLED-WRITEBACK-CONSUMER-READBACK", + "source_operation_type": "log_controlled_writeback_dispatched", + "source_executor_route": "ai_agent_metadata_writeback_executor", + }, + "controlled_consume": { + "controlled_consume_allowed": True, + "runtime_target_write_performed": False, + }, + "consumer_bindings": bindings, + "target_rollups": [ + { + "target": target, + "consumer_surface": f"{target}_consumer_context", + "binding_count": 1, + "ready_binding_count": 1, + "metadata_only": True, + } + for target in targets + ], + "rollups": { + "target_count": 6, + "dispatch_ledger_row_count": 6, + "consumer_binding_count": 6, + "ready_consumer_binding_count": 6, + "ready_target_count": 6, + "metadata_only_receipt_count": 6, + "post_apply_verifier_ref_count": 6, + "controlled_consumer_readback_ready": True, + "km_consumer_binding_count": 1, + "rag_consumer_binding_count": 1, + "playbook_consumer_binding_count": 1, + "mcp_consumer_binding_count": 1, + "verifier_consumer_binding_count": 1, + "ai_agent_consumer_binding_count": 1, + "runtime_target_write_performed": False, + }, + "active_blockers": [], + "operation_boundaries": { + "consumer_readback_only": True, + "metadata_ledger_read_performed": True, + "km_write_performed": False, + "rag_index_write_performed": False, + "playbook_trust_write_performed": False, + "mcp_tool_call_performed": False, + "agent_runtime_action_performed": False, + "telegram_send_performed": False, + "workflow_trigger_performed": False, + "raw_log_payload_persisted": False, + "secret_value_collection_allowed": False, + "github_api_used": False, + }, + } + + +def _assert_log_controlled_writeback_consumer(payload: dict): + assert payload["schema_version"] == ( + "ai_agent_log_controlled_writeback_consumer_readback_v1" + ) + assert payload["status"] == "controlled_writeback_consumer_readback_ready" + assert payload["active_blockers"] == [] + assert payload["controlled_consume"]["controlled_consume_allowed"] is True + assert payload["controlled_consume"]["runtime_target_write_performed"] is False + assert payload["rollups"]["consumer_binding_count"] == 6 + assert payload["rollups"]["ready_consumer_binding_count"] == 6 + assert payload["rollups"]["ready_target_count"] == 6 + assert payload["rollups"]["controlled_consumer_readback_ready"] is True + assert payload["operation_boundaries"]["consumer_readback_only"] is True + assert payload["operation_boundaries"]["mcp_tool_call_performed"] is False + assert payload["operation_boundaries"]["raw_log_payload_persisted"] is False + assert payload["operation_boundaries"]["github_api_used"] is False + + def test_runtime_receipt_auxiliary_sql_keeps_source_family_counts_schema_safe(): from src.services.ai_agent_autonomous_runtime_control import ( _RUNTIME_OPERATION_COUNTS_SQL, @@ -134,6 +229,11 @@ def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receip _assert_log_controlled_writeback_executor( data["runtime_receipt_readback"]["log_controlled_writeback_executor"] ) + consumer = data["runtime_receipt_readback"]["log_controlled_writeback_consumer"] + assert consumer["schema_version"] == ( + "ai_agent_log_controlled_writeback_consumer_readback_v1" + ) + assert consumer["status"] == "blocked_waiting_controlled_writeback_consumer_receipts" 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 @@ -141,6 +241,9 @@ def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receip assert data["rollups"]["live_log_controlled_writeback_next_action_queue_count"] == 6 assert data["rollups"]["live_log_controlled_writeback_dispatch_count"] == 0 assert data["rollups"]["live_log_controlled_writeback_recent_dispatch_count"] == 0 + assert data["rollups"]["live_log_controlled_writeback_consumer_binding_count"] == 0 + assert data["rollups"]["live_log_controlled_writeback_consumer_ready_count"] == 0 + assert data["rollups"]["live_log_controlled_writeback_consumer_blocker_count"] == 1 assert data["runtime_receipt_readback"]["learning_loop"]["status"] == "in_progress" assert ( data["runtime_receipt_readback"]["learning_loop"]["rollups"][ @@ -398,6 +501,7 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): grouped_alert_event_count_rows=[ {"status": "grouped_child_alert", "total": 4, "recent": 1}, ], + log_controlled_writeback_consumer=_log_controlled_writeback_consumer_readback(), ) assert readback["db_read_status"] == "ok" @@ -533,6 +637,9 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): _assert_log_controlled_writeback_executor( readback["log_controlled_writeback_executor"] ) + _assert_log_controlled_writeback_consumer( + readback["log_controlled_writeback_consumer"] + ) decision_wiring = readback["agent_decision_wiring"] assert decision_wiring["schema_version"] == "ai_agent_decision_wiring_readback_v1" assert decision_wiring["status"] == "completed" @@ -642,6 +749,7 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): "P1-C-learning-loop", "P1-D-alert-noise-reduction", "P1-E-log-controlled-writeback-executor", + "P1-F-log-controlled-writeback-consumer", "P2-A-ui-ux-productization", "P2-B-multi-product-expansion", ] @@ -656,13 +764,16 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): 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_ui_surface_count"] == 0 + assert progress["ordered_items"][10]["remaining_consumer_binding_count"] == 0 + assert progress["ordered_items"][10]["active_blocker_count"] == 0 assert progress["ordered_items"][11]["status"] == "completed" - assert progress["ordered_items"][11]["remaining_product_scope_count"] == 0 + assert progress["ordered_items"][11]["remaining_ui_surface_count"] == 0 + assert progress["ordered_items"][12]["status"] == "completed" + assert progress["ordered_items"][12]["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"] == 22 + assert progress["rollups"]["completed_count"] == 23 assert progress["rollups"]["pending_count"] == 0 @@ -830,6 +941,7 @@ def test_runtime_receipt_work_items_use_learning_receipts_without_latest_telegra grouped_alert_event_count_rows=[ {"status": "grouped_child_alert", "total": 4, "recent": 1}, ], + log_controlled_writeback_consumer=_log_controlled_writeback_consumer_readback(), ) assert readback["latest_flow_closure"]["closed"] is False @@ -848,10 +960,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["P1-F-log-controlled-writeback-consumer"] == "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"] == 22 + assert progress["rollups"]["completed_count"] == 23 assert progress["rollups"]["pending_count"] == 0 diff --git a/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py b/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py new file mode 100644 index 00000000..1b34888e --- /dev/null +++ b/apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1 import agents +from src.api.v1.agents import router +from src.services import ( + ai_agent_log_controlled_writeback_consumer_readback as consumer_module, +) +from src.services.ai_agent_log_controlled_writeback_consumer_readback import ( + load_latest_ai_agent_log_controlled_writeback_consumer_readback, +) + + +class _FakeMappingResult: + def __init__(self, rows: list[dict]): + self._rows = rows + + def mappings(self): + return self + + def all(self) -> list[dict]: + return self._rows + + +class _FakeDb: + def __init__(self, rows: list[dict]): + self.rows = rows + self.params: list[dict] = [] + + async def execute(self, _statement, params: dict): + self.params.append(params) + return _FakeMappingResult(self.rows) + + +class _FakeContext: + def __init__(self, db: _FakeDb): + self.db = db + + async def __aenter__(self) -> _FakeDb: + return self.db + + async def __aexit__(self, _exc_type, _exc, _tb) -> bool: + return False + + +def _ledger_rows() -> list[dict]: + return [ + { + "op_id": f"op-{target}", + "operation_type": "km_linked", + "actor": "ai_agent_metadata_writeback_executor", + "status": "success", + "created_at": "2026-06-30T01:40:00+08:00", + "semantic_operation_type": "log_controlled_writeback_dispatched", + "ledger_operation_type": "km_linked", + "dispatch_receipt_id": f"log_controlled_writeback_dispatched::{target}", + "batch_id": f"log-feedback-writeback::{target}", + "target": target, + "target_surface": f"{target}_metadata_feedback_binding", + "risk_tier": "medium" if target in {"km", "rag", "playbook"} else "low", + "project_id": "awoooi", + "raw_payload_included": "false", + "next_action": "consume_metadata_receipt_in_km_rag_playbook_agent_context", + "km_write_performed": "false", + "rag_index_write_performed": "false", + "playbook_trust_write_performed": "false", + "mcp_tool_call_performed": "false", + "telegram_send_performed": "false", + "post_apply_verifier_refs": [ + f"post-write-verifier://ai-agent-log-feedback/{target}/sample-a" + ], + } + for target in ("km", "rag", "playbook", "mcp", "verifier", "ai_agent") + ] + + +@pytest.mark.asyncio +async def test_log_controlled_writeback_consumer_loader_reads_live_ledger(monkeypatch): + fake_db = _FakeDb(_ledger_rows()) + monkeypatch.setattr( + consumer_module, + "get_db_context", + lambda project_id: _FakeContext(fake_db), + ) + + payload = await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + + _assert_consumer_readback(payload) + assert fake_db.params == [{"operation_type": "log_controlled_writeback_dispatched"}] + + +def test_log_controlled_writeback_consumer_endpoint_returns_readback(monkeypatch): + async def fake_loader(): + fake_db = _FakeDb(_ledger_rows()) + monkeypatch.setattr( + consumer_module, + "get_db_context", + lambda project_id: _FakeContext(fake_db), + ) + return await load_latest_ai_agent_log_controlled_writeback_consumer_readback() + + monkeypatch.setattr( + agents, + "load_latest_ai_agent_log_controlled_writeback_consumer_readback", + fake_loader, + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get( + "/api/v1/agents/agent-log-controlled-writeback-consumer-readback" + ) + + assert response.status_code == 200 + _assert_consumer_readback(response.json()) + + +def _assert_consumer_readback(payload: dict): + assert ( + payload["schema_version"] + == "ai_agent_log_controlled_writeback_consumer_readback_v1" + ) + assert payload["priority"] == "P1-LOG-KM-RAG-MCP-PLAYBOOK" + assert payload["status"] == "controlled_writeback_consumer_readback_ready" + assert payload["active_blockers"] == [] + assert payload["readback"]["source_operation_type"] == ( + "log_controlled_writeback_dispatched" + ) + assert payload["readback"]["source_executor_route"] == ( + "ai_agent_metadata_writeback_executor" + ) + assert payload["controlled_consume"]["controlled_consume_allowed"] is True + assert payload["controlled_consume"]["runtime_target_write_performed"] is False + assert payload["rollups"]["dispatch_ledger_row_count"] == 6 + assert payload["rollups"]["consumer_binding_count"] == 6 + assert payload["rollups"]["ready_consumer_binding_count"] == 6 + assert payload["rollups"]["ready_target_count"] == 6 + assert payload["rollups"]["metadata_only_receipt_count"] == 6 + assert payload["rollups"]["post_apply_verifier_ref_count"] == 6 + assert payload["rollups"]["controlled_consumer_readback_ready"] is True + assert payload["rollups"]["km_consumer_binding_count"] == 1 + assert payload["rollups"]["rag_consumer_binding_count"] == 1 + assert payload["rollups"]["playbook_consumer_binding_count"] == 1 + assert payload["rollups"]["mcp_consumer_binding_count"] == 1 + assert payload["rollups"]["verifier_consumer_binding_count"] == 1 + assert payload["rollups"]["ai_agent_consumer_binding_count"] == 1 + + target_rollups = {item["target"]: item for item in payload["target_rollups"]} + assert set(target_rollups) == {"km", "rag", "playbook", "mcp", "verifier", "ai_agent"} + for rollup in target_rollups.values(): + assert rollup["consumer_surface"] + assert rollup["binding_count"] == 1 + assert rollup["ready_binding_count"] == 1 + assert rollup["metadata_only"] is True + + bindings = payload["consumer_bindings"] + assert {binding["target"] for binding in bindings} == set(target_rollups) + for binding in bindings: + assert binding["status"] == "ready_for_consumer_context" + assert binding["executor_route"] == "ai_agent_metadata_writeback_executor" + assert binding["semantic_operation_type"] == "log_controlled_writeback_dispatched" + assert binding["ledger_status"] == "success" + assert binding["raw_payload_included"] is False + assert binding["target_write_performed"] is False + assert binding["check_mode"]["enabled"] is True + assert binding["rollback"]["required"] is True + assert binding["post_apply_verifier"]["required"] is True + assert binding["post_apply_verifier"]["verifier_refs"] + assert binding["source_of_truth_diff"]["raw_payload_included"] is False + + boundaries = payload["operation_boundaries"] + assert boundaries["consumer_readback_only"] is True + assert boundaries["metadata_ledger_read_performed"] is True + 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["telegram_send_performed"] is False + assert boundaries["workflow_trigger_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 diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 01a320d8..0c90dcca 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,17 @@ +## 2026-06-30 — 02:28 P1-LOG KM/RAG/PlayBook/MCP consumer readback + +**照主線完成的實作**: +- P0-006 仍是 current P0,但目前只剩 fresh all-host reboot window / approved reboot drill 這個事故級邊界;未重啟主機。 +- 新增 GET `/api/v1/agents/agent-log-controlled-writeback-consumer-readback`,直接讀 live `automation_operation_log` 的 `log_controlled_writeback_dispatched` metadata-only receipts,投影成 KM / RAG / PlayBook / MCP / verifier / AI Agent consumer bindings。 +- `/api/v1/agents/agent-autonomous-runtime-control` 已納入 `log_controlled_writeback_consumer`、P1-F work item 與 live rollups,可直接讀到 consumer binding、ready target、metadata-only、verifier ref 與 blocker count。 +- `.gitea/workflows/cd.yaml` controlled-runtime profile 已納入新 service / test,避免 LOG consumer readback 變更落到錯誤 runner lane。 + +**驗證**: +- Focused pytest:LOG consumer readback / autonomous runtime-control / CD profile `35 passed`。 +- `ruff`、`py_compile`、`git diff --check`、Gitea runner pressure guard、Gitea step env secret guard:通過。 + +**邊界**:未寫 KM / RAG / PlayBook / MCP target,未呼叫 MCP tool,未發 Telegram,未 workflow_dispatch,未操作 host / Docker / K8s / DB / firewall,未讀 secret / token / raw sessions / SQLite / `.env`,未使用 GitHub / `gh` / GitHub API。 + ## 2026-06-30 — 02:05 P1-102 Backup/DR config capture stale blocker closeout **照優先順序完成的實作**: diff --git a/ops/runner/test_cd_controlled_runtime_profile.py b/ops/runner/test_cd_controlled_runtime_profile.py index 491b1a58..4360308f 100644 --- a/ops/runner/test_cd_controlled_runtime_profile.py +++ b/ops/runner/test_cd_controlled_runtime_profile.py @@ -189,6 +189,18 @@ def test_ai_log_controlled_writeback_dispatch_stays_on_controlled_runtime_profil assert source in text +def test_ai_log_controlled_writeback_consumer_stays_on_controlled_runtime_profile() -> None: + text = _workflow_text() + expected_sources = [ + "apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py)", + "apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py)", + "src/services/ai_agent_log_controlled_writeback_consumer_readback.py", + "tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py", + ] + for source in expected_sources: + assert source in text + + def test_awooop_ansible_check_mode_stays_on_controlled_runtime_profile() -> None: text = _workflow_text() expected_sources = [