from __future__ import annotations import json import pytest from src.services.host_runaway_aiops_loop_readiness import ( load_latest_host_runaway_aiops_loop_readiness, ) def test_load_latest_host_runaway_aiops_loop_readiness_reads_newest_file(tmp_path): older = _snapshot(generated_at="2026-06-17T00:00:00+08:00") newer = _snapshot(generated_at="2026-06-18T15:08:00+08:00") (tmp_path / "host_runaway_aiops_loop_readiness_2026-06-17.json").write_text( json.dumps(older), encoding="utf-8", ) _write_snapshot(tmp_path, newer) loaded = load_latest_host_runaway_aiops_loop_readiness(tmp_path) assert loaded["generated_at"] == "2026-06-18T15:08:00+08:00" assert loaded["program_status"]["current_task_id"] == "P3-009" assert loaded["program_status"]["next_task_id"] == "P3-010" assert loaded["rollups"]["loop_stage_count"] == 6 assert loaded["rollups"]["alert_lane_count"] == 2 assert loaded["rollups"]["asset_writeback_contract_count"] == 5 assert loaded["live_readback"]["host_label"] == "110" assert loaded["live_readback"]["deploy_marker"] == "2d278568" assert loaded["activation_boundaries"]["runtime_remediation_enabled"] is False def test_host_runaway_aiops_loop_readiness_requires_read_only_mode(tmp_path): snapshot = _snapshot() snapshot["program_status"]["read_only_mode"] = False _write_snapshot(tmp_path, snapshot) with pytest.raises(ValueError, match="read_only_mode"): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def test_host_runaway_aiops_loop_readiness_blocks_process_termination(tmp_path): snapshot = _snapshot() snapshot["activation_boundaries"]["process_termination_authorized"] = True _write_snapshot(tmp_path, snapshot) with pytest.raises(ValueError, match="false activation boundaries"): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def test_host_runaway_aiops_loop_readiness_blocks_host_write_rollup(tmp_path): snapshot = _snapshot() snapshot["rollups"]["host_write_count"] = 1 _write_snapshot(tmp_path, snapshot) with pytest.raises(ValueError, match="zero rollup fields"): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def test_host_runaway_aiops_loop_readiness_requires_alert_lane_contract(tmp_path): snapshot = _snapshot() snapshot["alert_lanes"][0]["runtime_write_gate"] = 1 _write_snapshot(tmp_path, snapshot) with pytest.raises(ValueError, match="runtime_write_gate"): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def test_host_runaway_aiops_loop_readiness_requires_asset_writeback_fields(tmp_path): snapshot = _snapshot() snapshot["asset_writeback_contract"][0]["required_fields"] = [] _write_snapshot(tmp_path, snapshot) with pytest.raises(ValueError, match="required_fields"): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def test_host_runaway_aiops_loop_readiness_rejects_private_terms(tmp_path): snapshot = _snapshot() snapshot["loop_stages"][0]["next_action"] = "不要顯示工作視窗內容" _write_snapshot(tmp_path, snapshot) with pytest.raises(ValueError, match="forbidden public terms"): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def test_host_runaway_aiops_loop_readiness_fails_when_missing(tmp_path): with pytest.raises(FileNotFoundError): load_latest_host_runaway_aiops_loop_readiness(tmp_path) def _write_snapshot(tmp_path, payload: dict) -> None: (tmp_path / "host_runaway_aiops_loop_readiness_2026-06-18.json").write_text( json.dumps(payload), encoding="utf-8", ) def _snapshot(*, generated_at: str = "2026-06-18T15:08:00+08:00") -> dict: return { "schema_version": "host_runaway_aiops_loop_readiness_v1", "generated_at": generated_at, "program_status": { "overall_completion_percent": 100, "current_priority": "P3", "current_task_id": "P3-009", "next_task_id": "P3-010", "read_only_mode": True, "runtime_authority": "host_runaway_aiops_loop_readiness_only_no_host_write", "status_note": "只讀 readiness。", }, "source_refs": [ "scripts/ops/host-runaway-process-exporter.py", "scripts/ops/host-runaway-process-remediation.py", "ops/monitoring/alerts-unified.yml", "apps/api/src/services/telegram_gateway.py", "docs/runbooks/HOST-RUNAWAY-PROCESS-AIOPS-PLAYBOOK.md", "docs/runbooks/FULL-STACK-COLD-START-SOP.md", "docs/LOGBOOK.md", ], "rollups": { "loop_stage_count": 6, "alert_lane_count": 2, "asset_writeback_contract_count": 5, "source_ref_count": 7, "live_readback_metric_count": 8, "blocked_runtime_action_count": 12, "runtime_remediation_authorized_count": 0, "telegram_send_count": 0, "gateway_queue_write_count": 0, "bot_api_call_count": 0, "host_write_count": 0, "process_termination_count": 0, "docker_restart_count": 0, "systemd_restart_count": 0, "nginx_reload_count": 0, "firewall_change_count": 0, "kubectl_action_count": 0, "production_write_count": 0, }, "loop_stages": [ _stage("read_only_host_textfile_exporter"), _stage("prometheus_alert_rules"), _stage("telegram_ai_event_packet"), _stage("playbook_contract"), _stage("km_verifier_writeback_contract"), _stage("gated_remediation_helper"), ], "alert_lanes": [ { "alertname": "HostOrphanBrowserSmokeHighCpu", "lane_id": "orphan_browser_smoke_runaway_process", "classification": "host_resource_runaway_process", "action_policy": "triage_packet_then_dry_run_then_gated_sigterm", "dry_run_allowed": True, "apply_allowed_without_owner_gate": False, "runtime_write_gate": 0, "next_action": "prepare packet", }, { "alertname": "HostCiRunnerLoadSaturation", "lane_id": "ci_runner_load_saturation", "classification": "host_resource_capacity", "action_policy": "capacity_triage_no_process_remediation", "dry_run_allowed": False, "apply_allowed_without_owner_gate": False, "runtime_write_gate": 0, "next_action": "capacity triage", }, ], "asset_writeback_contract": [ _asset("knowledge_base_incident_summary"), _asset("playbook_trust_evidence"), _asset("awooop_work_item_truth_chain"), _asset("verifier_post_check"), _asset("recurrence_guard"), ], "live_readback": { "host_label": "110", "monitor_up": 1, "orphan_browser_group_count": 0, "active_ci_container_count": 2, "load5_per_core_upper_observed": 0.81, "swap_used_ratio_upper_observed": 1.0, "remediation_authorized_count": 0, "alerts_firing_count": 0, "deploy_marker": "2d278568", "runtime_revision": "f358a0f6c3e614e407dedb6eee89bf10b2bc8173", "argocd_sync": "Synced", "argocd_health": "Healthy", "production_route_count": 3, "forbidden_public_hit_count": 0, }, "remediation_gate": { "dry_run_required": True, "owner_approval_required": True, "maintenance_window_required": True, "evidence_ref_required": True, "post_check_required": True, "allowed_signal_after_gate": "SIGTERM", "process_termination_authorized": False, "disallowed_actions": [ "SIGKILL", "docker restart", "systemctl restart", "nginx reload", "firewall change", "kubectl action", "secret read", "production write", ], }, "activation_boundaries": { "read_only_readback_allowed": True, "ai_triage_packet_allowed": True, "dry_run_generation_allowed": True, "runtime_remediation_enabled": False, "process_termination_authorized": False, "telegram_send_enabled": False, "gateway_queue_write_enabled": False, "bot_api_call_enabled": False, "host_write_enabled": False, "docker_restart_enabled": False, "systemd_restart_enabled": False, "nginx_reload_enabled": False, "firewall_change_enabled": False, "kubectl_action_enabled": False, "production_write_enabled": False, "secret_read_enabled": False, }, "next_steps": [ { "step_id": "real_alert_receipt_fixture", "description": "verify packet", "runtime_write_allowed": False, } ], } def _stage(stage_id: str) -> dict: return { "stage_id": stage_id, "display_name": stage_id, "owner_agent": "openclaw", "status": "ready", "completion_percent": 100, "evidence_refs": ["docs/LOGBOOK.md"], "next_action": "keep readback visible", "blocked_runtime_actions": ["host_write"], } def _asset(asset_id: str) -> dict: return { "asset_id": asset_id, "display_name": asset_id, "required_on_real_incident": True, "live_write_enabled": False, "required_fields": ["incident_id"], }