From b5deca91dfa40d13d9e89f6d702c7541203f6d5e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 21 May 2026 09:08:46 +0800 Subject: [PATCH] fix(awooop): record source review dry-run audit --- .../services/channel_event_dossier_service.py | 100 ++++++++++-------- .../test_channel_event_dossier_service.py | 76 +++++++++++++ docs/LOGBOOK.md | 6 +- 3 files changed, 138 insertions(+), 44 deletions(-) diff --git a/apps/api/src/services/channel_event_dossier_service.py b/apps/api/src/services/channel_event_dossier_service.py index 982439f2..8cf7856d 100644 --- a/apps/api/src/services/channel_event_dossier_service.py +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -869,11 +869,20 @@ def _recurrence_handoff_context(payload: dict[str, Any]) -> dict[str, Any]: } +def _is_source_correlation_review_payload(payload: dict[str, Any]) -> bool: + state = _as_dict(payload.get("current_state_summary")) + return ( + state.get("work_item_kind") == "source_correlation_review" + or payload.get("next_step") == "review_provider_source_match" + ) + + async def _record_recurrence_work_item_dry_run_history( payload: dict[str, Any], ) -> dict[str, Any]: incident_id = str(payload.get("incident_id") or "") - if not incident_id: + source_review = _is_source_correlation_review_payload(payload) + if not incident_id and not source_review: return {"recorded": False, "reason": "missing_incident_id"} history: dict[str, Any] = { @@ -891,7 +900,7 @@ async def _record_recurrence_work_item_dry_run_history( record = await get_alert_operation_log_repository().append( "PRE_FLIGHT_PASSED" if allowed else "PRE_FLIGHT_FAILED", - incident_id=incident_id, + incident_id=incident_id or None, auto_repair_id=str(payload.get("auto_repair_id") or "") or None, actor="awooop_recurrence_work_item_service", action_detail=f"recurrence_work_item_dry_run:{payload.get('mode')}"[:200], @@ -907,26 +916,29 @@ async def _record_recurrence_work_item_dry_run_history( error=str(exc), ) - try: - from src.services.approval_db import get_timeline_service + if incident_id: + try: + from src.services.approval_db import get_timeline_service - event = await get_timeline_service().add_event( - event_type="verifier", - status="success" if allowed else "warning", - title="AwoooP recurrence work item dry-run", - description=_recurrence_history_description(context), - actor="awooop_recurrence_work_item_service", - actor_role=str(payload.get("mode") or "dry_run"), - incident_id=incident_id, - ) - if event: - history["timeline_event_id"] = event.get("id") - except Exception as exc: - logger.warning( - "awooop_recurrence_work_item_timeline_history_failed", - incident_id=incident_id, - error=str(exc), - ) + event = await get_timeline_service().add_event( + event_type="verifier", + status="success" if allowed else "warning", + title="AwoooP recurrence work item dry-run", + description=_recurrence_history_description(context), + actor="awooop_recurrence_work_item_service", + actor_role=str(payload.get("mode") or "dry_run"), + incident_id=incident_id, + ) + if event: + history["timeline_event_id"] = event.get("id") + except Exception as exc: + logger.warning( + "awooop_recurrence_work_item_timeline_history_failed", + incident_id=incident_id, + error=str(exc), + ) + else: + history["timeline_reason"] = "source_review_not_incident_scoped" history["recorded"] = bool( history.get("alert_operation_id") or history.get("timeline_event_id") @@ -940,7 +952,8 @@ async def _record_recurrence_work_item_handoff_history( payload: dict[str, Any], ) -> dict[str, Any]: incident_id = str(payload.get("incident_id") or "") - if not incident_id: + source_review = _is_source_correlation_review_payload(payload) + if not incident_id and not source_review: return {"recorded": False, "reason": "missing_incident_id"} history: dict[str, Any] = { @@ -958,7 +971,7 @@ async def _record_recurrence_work_item_handoff_history( record = await get_alert_operation_log_repository().append( "ESCALATED", - incident_id=incident_id, + incident_id=incident_id or None, auto_repair_id=str(payload.get("auto_repair_id") or "") or None, actor="awooop_recurrence_work_item_service", action_detail=( @@ -976,26 +989,29 @@ async def _record_recurrence_work_item_handoff_history( error=str(exc), ) - try: - from src.services.approval_db import get_timeline_service + if incident_id: + try: + from src.services.approval_db import get_timeline_service - event = await get_timeline_service().add_event( - event_type="human", - status="warning" if allowed else "error", - title="AwoooP recurrence work item handoff", - description=_recurrence_handoff_history_description(context), - actor="awooop_recurrence_work_item_service", - actor_role=str(payload.get("handoff_kind") or "handoff"), - incident_id=incident_id, - ) - if event: - history["timeline_event_id"] = event.get("id") - except Exception as exc: - logger.warning( - "awooop_recurrence_work_item_handoff_timeline_history_failed", - incident_id=incident_id, - error=str(exc), - ) + event = await get_timeline_service().add_event( + event_type="human", + status="warning" if allowed else "error", + title="AwoooP recurrence work item handoff", + description=_recurrence_handoff_history_description(context), + actor="awooop_recurrence_work_item_service", + actor_role=str(payload.get("handoff_kind") or "handoff"), + incident_id=incident_id, + ) + if event: + history["timeline_event_id"] = event.get("id") + except Exception as exc: + logger.warning( + "awooop_recurrence_work_item_handoff_timeline_history_failed", + incident_id=incident_id, + error=str(exc), + ) + else: + history["timeline_reason"] = "source_review_not_incident_scoped" history["recorded"] = bool( history.get("alert_operation_id") or history.get("timeline_event_id") diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py index 6e74de29..32de9e2c 100644 --- a/apps/api/tests/test_channel_event_dossier_service.py +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -555,6 +555,82 @@ def test_build_recurrence_work_item_dry_run_returns_ticket_preview_without_write assert dry_run["read_model_route"]["required_scope"] == "read" +@pytest.mark.asyncio +async def test_source_correlation_dry_run_history_records_without_incident( + monkeypatch, +) -> None: + appended: list[dict[str, object]] = [] + + class FakeRecord: + id = "alert-op-1" + + class FakeRepo: + async def append(self, event_type: str, **kwargs): # noqa: ANN001 + appended.append({"event_type": event_type, **kwargs}) + return FakeRecord() + + from src.repositories import alert_operation_log_repository + + monkeypatch.setattr( + alert_operation_log_repository, + "get_alert_operation_log_repository", + lambda: FakeRepo(), + ) + + history = ( + await channel_event_dossier_service._record_recurrence_work_item_dry_run_history( + { + "source": "channel_event_dossier.recurrence", + "project_id": "awoooi", + "work_item_id": ( + "source-evidence:sentry:upstream_canary:" + "awoooi-canary-codex-t115-production" + ), + "incident_id": None, + "auto_repair_id": None, + "mode": "observe", + "requested_mode": "auto", + "allowed": True, + "executed": True, + "safety_level": "read_only", + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_ticket": False, + "verification_result_preview": "observe_only", + "current_state_summary": { + "work_item_kind": "source_correlation_review", + "work_item_next_step": "review_provider_source_match", + "repair_status": "source_correlation_review", + "latest_stage": "upstream_canary", + "latest_provider_event_id": ( + "sentry:upstream_canary:" + "awoooi-canary-codex-t115-production" + ), + }, + "ticket_preview": {"would_create": False}, + "read_model_route": { + "agent_id": "awooop_recurrence_coordinator", + "tool_name": "channel_event_dossier.recurrence", + "required_scope": "read", + }, + "checks": [], + "next_step": "review_provider_source_match", + } + ) + ) + + assert history == { + "recorded": True, + "alert_operation_id": "alert-op-1", + "timeline_event_id": None, + "timeline_reason": "source_review_not_incident_scoped", + } + assert appended[0]["event_type"] == "PRE_FLIGHT_PASSED" + assert appended[0]["incident_id"] is None + assert appended[0]["actor"] == "awooop_recurrence_work_item_service" + assert appended[0]["context"]["work_item_id"].startswith("source-evidence:") + + def test_build_recurrence_work_item_handoff_records_ticket_proposal_contract() -> None: recurrence = build_dossier_recurrence( [ diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index f23bd1ea..eb21f745 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -13,6 +13,7 @@ - `next_step=review_provider_source_match` - `reason=provider_native_evidence_unlinked` - 不寫入 Incident / AutoRepair / Ticket,只提供 preview / dry-run / handoff read model。 +- Source review dry-run / handoff 沒有 Incident 時仍會寫入 `alert_operation_log`(`incident_id=null`),避免 operator 看到 `missing_incident_id` 誤判為沒有 DB audit;timeline 仍只在有 Incident 時寫入。 - `/api/v1/platform/events/dossier/recurrence` summary 新增 `source_correlation_review_group_total`。 - AwoooP Runs 前端「重複告警關聯」新增「來源待審」指標,卡片顯示事件 stage,讓 operator 可看見 provider-native evidence 已進 AwoooP 但仍需配對審核。 - AwoooP Work Items 同步顯示 source review count、stage、provider event id、Sentry / SignOz refs,避免從 Runs 點進工作項後掉成 unknown。 @@ -23,7 +24,7 @@ python -m py_compile apps/api/src/services/channel_event_dossier_service.py apps/api/src/api/v1/platform/events.py -> pass DATABASE_URL=postgresql+asyncpg://test:test@localhost/test pytest -q tests/test_channel_event_dossier_service.py - -> 14 passed + -> 15 passed pnpm --dir apps/web exec tsc --noEmit -> pass NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build @@ -45,7 +46,8 @@ python -m ruff check src/services/channel_event_dossier_service.py src/api/v1/pl - AwoooP 告警可觀測鏈:99.985% → 99.988%。 - 前端 AI 自動化管理介面同步:99.99%(Runs / Work Items recurrence panel 已同步來源待審)。 - 完整 AI 自動化管理產品化:99.65% → 99.68%。 -- 剩餘:推 Gitea main、等待 CI/CD、production API / frontend 驗證。 +- 第一輪部署:Gitea Actions 1952 Code Review success;1951 CD success;production recurrence schema 已出現 `source_correlation_review_group_total` / `latest_stage` / `stage_counts`。 +- 剩餘:source review audit 小修再推 Gitea main,等待第二輪 CI/CD、production dry-run history 驗證。 ## 2026-05-20|T115 Provider-native upstream canary 接入