diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index a2fee4ffd..bf7051369 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -1257,9 +1257,9 @@ async def get_ai_automation_asset_capability_matrix() -> dict[str, Any]: response_model=dict[str, Any], summary="取得 AWOOOI 主線工作優先順序讀回", description=( - "讀取已提交的 AWOOOI P0/P1 主線工作順序快照;此端點只回傳" + "預設讀取已提交的 AWOOOI P0/P1 主線工作順序快照;此端點只回傳" "目前 active P0、已關閉 P0、下一步順序、禁止事項與 evidence refs。" - "它會讀取 StockPlatform public HTTPS health/freshness/ingestion live " + "live_overlays=true 時才會讀取 StockPlatform public HTTPS health/freshness/ingestion live " "readback、110 node_exporter controlled CD lane textfile metrics、" "188 Gitea repository bundle backup metrics," "以及 AI LOG controlled writeback executor / verifier / consumer receipts;" @@ -1268,9 +1268,17 @@ async def get_ai_automation_asset_capability_matrix() -> dict[str, Any]: "不讀 secret、不註冊 runner、不觸發 workflow、不修改 host / Docker / K8s / DB / firewall。" ), ) -async def get_awoooi_priority_work_order_readback() -> dict[str, Any]: +async def get_awoooi_priority_work_order_readback( + live_overlays: bool = False, +) -> dict[str, Any]: """回傳 AWOOOI 主線工作優先順序只讀快照。""" try: + if not live_overlays: + payload = await asyncio.to_thread( + load_latest_awoooi_priority_work_order_readback + ) + _mark_priority_work_order_live_overlay_delegated(payload) + return redact_public_lan_topology(payload) payload = await asyncio.wait_for( _build_awoooi_priority_work_order_readback_with_live_overlays(), timeout=_PRIORITY_WORK_ORDER_LIVE_READBACK_TIMEOUT_SECONDS, @@ -1372,6 +1380,7 @@ async def _build_awoooi_priority_work_order_readback_with_live_overlays() -> dic rollups = payload.setdefault("rollups", {}) mainline = payload.setdefault("mainline_execution_state", {}) summary["priority_work_order_live_overlay_status"] = "ready" + summary["priority_work_order_live_overlay_included"] = True summary["priority_work_order_live_overlay_timeout_seconds"] = ( _PRIORITY_WORK_ORDER_LIVE_READBACK_TIMEOUT_SECONDS ) @@ -1380,6 +1389,31 @@ async def _build_awoooi_priority_work_order_readback_with_live_overlays() -> dic return payload +def _mark_priority_work_order_live_overlay_delegated( + payload: dict[str, Any], +) -> None: + """Keep work-order reads cheap while dedicated panels own runtime truth.""" + + summary = payload.setdefault("summary", {}) + rollups = payload.setdefault("rollups", {}) + mainline = payload.setdefault("mainline_execution_state", {}) + summary["priority_work_order_live_overlay_status"] = ( + "delegated_to_dedicated_readbacks" + ) + summary["priority_work_order_live_overlay_included"] = False + summary["priority_work_order_runtime_truth_sources"] = [ + "/api/v1/agents/agent-autonomous-runtime-control", + "/api/v1/agents/agent-log-controlled-writeback-consumer-readback", + "/api/v1/agents/telegram-alert-learning-context-post-apply-verifier", + "/api/v1/agents/telegram-alert-monitoring-coverage-readback", + ] + rollups["priority_work_order_live_overlay_degraded"] = False + mainline["priority_work_order_live_overlay_degraded"] = False + mainline["priority_work_order_runtime_truth_source"] = ( + "dedicated_runtime_readbacks" + ) + + def _mark_priority_work_order_live_overlay_degraded( payload: dict[str, Any], *, @@ -1393,6 +1427,7 @@ def _mark_priority_work_order_live_overlay_degraded( if isinstance(active_blockers, list) and blocker not in active_blockers: active_blockers.append(blocker) summary["priority_work_order_live_overlay_status"] = "degraded_timeout" + summary["priority_work_order_live_overlay_included"] = False summary["priority_work_order_live_overlay_timeout_seconds"] = ( _PRIORITY_WORK_ORDER_LIVE_READBACK_TIMEOUT_SECONDS ) 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 d9f8884f0..88a155f57 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -64,8 +64,8 @@ _RUNTIME_RECEIPT_QUERY_BUDGET_SECONDS = 3.0 _RUNTIME_RECEIPT_SINGLE_QUERY_TIMEOUT_SECONDS = 0.75 _RUNTIME_RECEIPT_STATEMENT_TIMEOUT_MS = 700 _LOG_CONTROLLED_WRITEBACK_CONSUMER_TIMEOUT_SECONDS = 4.0 -_LOG_CONTROLLED_WRITEBACK_CONSUMER_READBACK_CACHE_TTL_SECONDS = 30.0 -_RUNTIME_RECEIPT_READBACK_CACHE_TTL_SECONDS = 20.0 +_LOG_CONTROLLED_WRITEBACK_CONSUMER_READBACK_CACHE_TTL_SECONDS = 120.0 +_RUNTIME_RECEIPT_READBACK_CACHE_TTL_SECONDS = 60.0 _RUNTIME_RECEIPT_READBACK_STALE_FALLBACK_TTL_SECONDS = 300.0 _AUXILIARY_RUNTIME_RECEIPT_QUERY_NAMES = { "alert_operation_counts", 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 index 501958458..5f3758950 100644 --- 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 @@ -54,7 +54,7 @@ _CONSUMER_DIRECT_CONNECT_TIMEOUT_SECONDS = 1.5 _CONSUMER_DIRECT_QUERY_TIMEOUT_SECONDS = 2.0 _CONSUMER_TELEGRAM_CONTEXT_TIMEOUT_SECONDS = 1.5 _CONSUMER_STATEMENT_TIMEOUT_MS = 1500 -_CONSUMER_READBACK_CACHE_TTL_SECONDS = 20.0 +_CONSUMER_READBACK_CACHE_TTL_SECONDS = 120.0 _consumer_readback_cache: dict[ tuple[int, int, str], tuple[float, dict[str, Any]] ] = {} diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index bffe61f9e..e2f6d7c12 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -39,7 +39,7 @@ _INBOUND_LOOKUP_BRANCH_LIMIT = int( ) _JSON_TEXT_FIELDS = {"gate_result", "source_envelope"} _QUALITY_SUMMARY_CACHE_TTL_SECONDS = int( - os.getenv("AWOOOP_QUALITY_SUMMARY_CACHE_TTL_SECONDS", "30") + os.getenv("AWOOOP_QUALITY_SUMMARY_CACHE_TTL_SECONDS", "120") ) _QUALITY_SUMMARY_OBSERVATIONS: dict[str, dict[str, Any]] = {} diff --git a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py index a2aaec103..9e5e54b55 100644 --- a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py +++ b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py @@ -2497,7 +2497,9 @@ def test_awoooi_priority_work_order_readback_endpoint_returns_snapshot( app.include_router(router, prefix="/api/v1") client = TestClient(app) - response = client.get("/api/v1/agents/awoooi-priority-work-order-readback") + response = client.get( + "/api/v1/agents/awoooi-priority-work-order-readback?live_overlays=true" + ) assert response.status_code == 200 data = response.json() @@ -2750,7 +2752,9 @@ def test_awoooi_priority_work_order_readback_endpoint_returns_static_fallback_on app.include_router(router, prefix="/api/v1") client = TestClient(app) - response = client.get("/api/v1/agents/awoooi-priority-work-order-readback") + response = client.get( + "/api/v1/agents/awoooi-priority-work-order-readback?live_overlays=true" + ) assert response.status_code == 200 data = response.json() @@ -2768,6 +2772,34 @@ def test_awoooi_priority_work_order_readback_endpoint_returns_static_fallback_on assert "priority_work_order_live_overlay_timeout" in data["active_blockers"] +def test_awoooi_priority_work_order_readback_defaults_to_source_backed_order( + monkeypatch: pytest.MonkeyPatch, +): + async def live_overlay_must_not_run() -> dict: + raise AssertionError("default priority readback must not launch live overlays") + + monkeypatch.setattr( + agents, + "_build_awoooi_priority_work_order_readback_with_live_overlays", + live_overlay_must_not_run, + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/awoooi-priority-work-order-readback") + + assert response.status_code == 200 + data = response.json() + assert data["summary"]["priority_work_order_live_overlay_status"] == ( + "delegated_to_dedicated_readbacks" + ) + assert data["summary"]["priority_work_order_live_overlay_included"] is False + assert data["mainline_execution_state"][ + "priority_work_order_runtime_truth_source" + ] == "dedicated_runtime_readbacks" + + def test_priority_live_overlay_resolves_ai_runtime_before_secondary_sources() -> None: source = inspect.getsource( agents._build_awoooi_priority_work_order_readback_with_live_overlays @@ -2816,7 +2848,9 @@ def test_awoooi_priority_work_order_readback_endpoint_redacts_ai_loop_queue( app.include_router(router, prefix="/api/v1") client = TestClient(app) - response = client.get("/api/v1/agents/awoooi-priority-work-order-readback") + response = client.get( + "/api/v1/agents/awoooi-priority-work-order-readback?live_overlays=true" + ) assert response.status_code == 200 assert "192.168.0.110" not in response.text diff --git a/apps/api/tests/test_awooop_runs_runtime_load_guard.py b/apps/api/tests/test_awooop_runs_runtime_load_guard.py new file mode 100644 index 000000000..ceddb8104 --- /dev/null +++ b/apps/api/tests/test_awooop_runs_runtime_load_guard.py @@ -0,0 +1,76 @@ +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _read(relative_path: str) -> str: + return (REPO_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_runs_page_uses_single_flight_completion_based_polling() -> None: + source = _read("apps/web/src/app/[locale]/awooop/runs/page.tsx") + + assert "const AUTO_REFRESH_INTERVAL = 90_000" in source + assert "fetchRunsInFlightRef.current" in source + assert "completed ? AUTO_REFRESH_INTERVAL : BUSY_RETRY_INTERVAL" in source + assert "setInterval(" not in source + assert 'qualityParams.set("limit", "8")' in source + + +def test_runs_page_keeps_last_good_readback_on_transient_failures() -> None: + source = _read("apps/web/src/app/[locale]/awooop/runs/page.tsx") + + assert "setAiRouteStatus(null)" not in source + assert "setDossierCoverage(null)" not in source + assert "setEventRecurrence(null)" not in source + assert "setAutomationQualitySummary(null)" not in source + + +def test_autonomous_receipt_panel_serializes_bounded_readbacks() -> None: + source = _read( + "apps/web/src/components/awooop/autonomous-runtime-receipt-panel.tsx" + ) + + assert "const AUTO_REFRESH_INTERVAL = 90_000" in source + assert "refreshInFlightRef.current" in source + assert "window.setTimeout(() => void poll(), AUTO_REFRESH_INTERVAL)" in source + assert "window.setInterval(" not in source + assert "if (priority) setPriorityPayload(priority);" in source + assert "if (consumer) setConsumerPayload(consumer);" in source + assert ( + "if (telegramVerifier) setTelegramVerifierPayload(telegramVerifier);" in source + ) + assert ( + "if (telegramCoverage) setTelegramCoveragePayload(telegramCoverage);" in source + ) + + +def test_quality_summary_cache_outlives_one_ui_poll_cycle() -> None: + source = _read("apps/api/src/services/awooop_truth_chain_service.py") + + assert 'os.getenv("AWOOOP_QUALITY_SUMMARY_CACHE_TTL_SECONDS", "120")' in source + + +def test_priority_work_order_defaults_to_source_backed_readback() -> None: + source = _read("apps/api/src/api/v1/agents.py") + + assert "live_overlays: bool = False" in source + assert "if not live_overlays:" in source + assert '"delegated_to_dedicated_readbacks"' in source + assert '"dedicated_runtime_readbacks"' in source + + +def test_runtime_readback_caches_outlive_one_ui_poll_cycle() -> None: + consumer_source = _read( + "apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py" + ) + runtime_source = _read( + "apps/api/src/services/ai_agent_autonomous_runtime_control.py" + ) + + assert "_CONSUMER_READBACK_CACHE_TTL_SECONDS = 120.0" in consumer_source + assert ( + "_LOG_CONTROLLED_WRITEBACK_CONSUMER_READBACK_CACHE_TTL_SECONDS = 120.0" + in runtime_source + ) + assert "_RUNTIME_RECEIPT_READBACK_CACHE_TTL_SECONDS = 60.0" in runtime_source diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 78992356d..58508e4c8 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -1,7 +1,7 @@ // ============================================================================= // WOOO AIOps - AwoooP M5 Run 監控頁面 // ============================================================================= -// 每 30 秒自動刷新,RUNNING 狀態顯示 pulse 動畫 +// 每次讀取完成後再等待 90 秒刷新,避免慢查詢重疊;RUNNING 狀態顯示 pulse 動畫 "use client"; @@ -791,7 +791,8 @@ type OwnerResponseValidationRunRef = { const API_BASE = getRuntimeApiBaseUrl(); const PER_PAGE = 50; -const AUTO_REFRESH_INTERVAL = 30_000; // 30 秒 +const AUTO_REFRESH_INTERVAL = 90_000; // 完成一輪後等待 90 秒 +const BUSY_RETRY_INTERVAL = 5_000; const INCIDENT_ID_FILTER_RE = /^INC-\d{8}-[A-Z0-9]{4,}$/; const WAZUH_ALERT_CARD_EVENT_TYPE = "wazuh_dashboard_api_readback_degraded"; const WAZUH_ALERT_CARD_LANE = "siem_observability_readback_degraded"; @@ -4751,7 +4752,8 @@ export default function RunsPage() { const [incidentFilter, setIncidentFilter] = useState(""); const [page, setPage] = useState(1); const [lastRefresh, setLastRefresh] = useState(null); - const intervalRef = useRef | null>(null); + const refreshTimeoutRef = useRef | null>(null); + const fetchRunsInFlightRef = useRef(false); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -4791,11 +4793,9 @@ export default function RunsPage() { setAiRouteStatus(routeStatusData); setAiRouteStatusError(null); } else { - setAiRouteStatus(null); setAiRouteStatusError(`HTTP ${routeStatusRes.status}`); } } catch (routeStatusError) { - setAiRouteStatus(null); setAiRouteStatusError( routeStatusError instanceof Error ? routeStatusError.message : "route status failed" ); @@ -4803,6 +4803,8 @@ export default function RunsPage() { }, []); const fetchRuns = useCallback(async (options?: { refresh?: boolean }) => { + if (fetchRunsInFlightRef.current) return false; + fetchRunsInFlightRef.current = true; try { setError(null); const params = new URLSearchParams(); @@ -4851,7 +4853,6 @@ export default function RunsPage() { setDossierCoverage(dossierCoverageData); setDossierCoverageError(null); } else { - setDossierCoverage(null); setDossierCoverageError(`HTTP ${dossierCoverageRes.status}`); } @@ -4866,7 +4867,6 @@ export default function RunsPage() { setEventRecurrence(recurrenceData); setEventRecurrenceError(null); } else { - setEventRecurrence(null); setEventRecurrenceError(`HTTP ${recurrenceRes.status}`); } @@ -4895,17 +4895,9 @@ export default function RunsPage() { setCallbackCacheInfo(callbackData.cache ?? null); setCallbackEventsError(null); } else { - setCallbackEvents([]); - setCallbackEventsTotal(0); - setCallbackAuditSummary(null); - setCallbackCacheInfo(null); setCallbackEventsError(`HTTP ${callbackRes.status}`); } } catch (callbackError) { - setCallbackEvents([]); - setCallbackEventsTotal(0); - setCallbackAuditSummary(null); - setCallbackCacheInfo(null); setCallbackEventsError( callbackError instanceof Error ? callbackError.message : "callback evidence failed" ); @@ -4933,15 +4925,9 @@ export default function RunsPage() { setAiAlertLearningRegistry(aiAlertData.learning_registry ?? null); setAiAlertCardError(null); } else { - setAiAlertCardItems([]); - setAiAlertCardSummary(null); - setAiAlertLearningRegistry(null); setAiAlertCardError(`端點尚未部署或暫時不可用(HTTP ${aiAlertRes.status})`); } } catch (aiAlertError) { - setAiAlertCardItems([]); - setAiAlertCardSummary(null); - setAiAlertLearningRegistry(null); setAiAlertCardError("無法讀回 AI 事件卡送達狀態"); } finally { setAiAlertCardLoading(false); @@ -4951,7 +4937,7 @@ export default function RunsPage() { const qualityParams = new URLSearchParams(); qualityParams.set("project_id", projectFilter || "awoooi"); qualityParams.set("hours", "24"); - qualityParams.set("limit", "30"); + qualityParams.set("limit", "8"); if (options?.refresh) { qualityParams.set("refresh", "true"); } @@ -4963,11 +4949,9 @@ export default function RunsPage() { setAutomationQualitySummary(qualityData); setAutomationQualityError(null); } else { - setAutomationQualitySummary(null); setAutomationQualityError(`HTTP ${qualityRes.status}`); } } catch (qualityError) { - setAutomationQualitySummary(null); setAutomationQualityError( qualityError instanceof Error ? qualityError.message : "automation quality failed" ); @@ -4980,24 +4964,29 @@ export default function RunsPage() { setAiAlertCardLoading(false); } finally { setLoading(false); + fetchRunsInFlightRef.current = false; } + return true; }, [projectFilter, statusFilter, evidenceFilter, callbackFilter, incidentFilter, page]); - // 初次載入 + // 單航班刷新:一輪完成後才排下一輪,避免慢查詢重疊。 useEffect(() => { + let cancelled = false; setLoading(true); - fetchAiRouteStatus(); - fetchRuns(); - }, [fetchAiRouteStatus, fetchRuns]); - - // 30 秒自動刷新 - useEffect(() => { - intervalRef.current = setInterval(() => { - fetchAiRouteStatus(); - fetchRuns(); - }, AUTO_REFRESH_INTERVAL); + const poll = async () => { + await fetchAiRouteStatus(); + const completed = await fetchRuns(); + if (!cancelled) { + refreshTimeoutRef.current = setTimeout( + () => void poll(), + completed ? AUTO_REFRESH_INTERVAL : BUSY_RETRY_INTERVAL + ); + } + }; + void poll(); return () => { - if (intervalRef.current) clearInterval(intervalRef.current); + cancelled = true; + if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); }; }, [fetchAiRouteStatus, fetchRuns]); @@ -5097,12 +5086,12 @@ export default function RunsPage() {
- 每 30 秒自動刷新 + 每輪完成後 90 秒刷新