feat(awooop): preview recurrence repair work items
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m11s
CD Pipeline / build-and-deploy (push) Successful in 3m33s
CD Pipeline / post-deploy-checks (push) Successful in 1m32s

This commit is contained in:
Your Name
2026-05-18 21:42:20 +08:00
parent 51660ecbb1
commit d1ebcdac10
6 changed files with 925 additions and 4 deletions

View File

@@ -10,13 +10,17 @@ from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from src.services.channel_event_dossier_service import (
RecurrenceWorkItemMode,
RecurrenceWorkItemNotFoundError,
fetch_channel_event_dossier,
fetch_channel_event_dossier_coverage,
fetch_channel_event_dossier_recurrence,
fetch_recurrence_work_item_dry_run,
fetch_recurrence_work_item_preview,
)
from src.services.platform_operator_service import list_recent_channel_events
@@ -170,6 +174,16 @@ class ChannelEventRecurrenceResponse(BaseModel):
items: list[ChannelEventRecurrenceItem]
class RecurrenceWorkItemDryRunRequest(BaseModel):
"""AwoooP recurrence work item dry-run request."""
project_id: str | None = Field(default=None, min_length=1)
work_item_id: str = Field(min_length=1)
mode: RecurrenceWorkItemMode = "auto"
provider: str | None = Field(default=None, min_length=1)
limit: int = Field(default=300, ge=1, le=300)
@router.get(
"/events/dossier",
response_model=ChannelEventDossierResponse,
@@ -241,6 +255,64 @@ async def get_event_dossier_recurrence(
)
@router.get(
"/events/dossier/recurrence/work-item/preview",
summary="預覽重複告警工作項的安全處理計畫",
description=(
"依 recurrence read model 找出指定 work_item返回下一步、pre-flight checks "
"與 read-only / no-write 保證;不修改 incident、auto-repair 或 ticket 狀態。"
),
)
async def preview_event_recurrence_work_item(
work_item_id: str = Query(..., min_length=1, description="recurrence work_item_id"),
project_id: str | None = Query(None, description="租戶 ID可選"),
provider: str | None = Query(
None, description="provider可選如 alertmanager / sentry / signoz"
),
mode: RecurrenceWorkItemMode = Query("auto", description="預覽模式"),
limit: int = Query(300, ge=1, le=300, description="最多納入統計筆數"),
) -> dict[str, Any]:
try:
return await fetch_recurrence_work_item_preview(
project_id=project_id,
work_item_id=work_item_id,
mode=mode,
provider=provider,
limit=limit,
)
except RecurrenceWorkItemNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="recurrence_work_item_not_found",
) from exc
@router.post(
"/events/dossier/recurrence/work-item/dry-run",
summary="乾跑重複告警工作項的安全處理流程",
description=(
"依 recurrence read model 產生 dry-run 結果並寫入 pre-flight history"
"但不修改 incident、auto-repair 或 ticket 狀態。"
),
)
async def dry_run_event_recurrence_work_item(
request: RecurrenceWorkItemDryRunRequest,
) -> dict[str, Any]:
try:
return await fetch_recurrence_work_item_dry_run(
project_id=request.project_id,
work_item_id=request.work_item_id,
mode=request.mode,
provider=request.provider,
limit=request.limit,
)
except RecurrenceWorkItemNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="recurrence_work_item_not_found",
) from exc
@router.get(
"/events/recent",
response_model=RecentEventsResponse,

View File

@@ -8,19 +8,27 @@ automation state.
from __future__ import annotations
import re
from typing import Any
from typing import Any, Literal
from uuid import UUID
import structlog
from fastapi import HTTPException, status
from sqlalchemy import text
from src.db.base import get_db_context
logger = structlog.get_logger(__name__)
_MAX_DOSSIER_EVENTS = 50
_MAX_COVERAGE_EVENTS = 200
_MAX_RECURRENCE_EVENTS = 300
_MAX_REPAIR_INCIDENTS = 200
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
RecurrenceWorkItemMode = Literal["auto", "ticket", "reverify", "approval_review", "observe"]
class RecurrenceWorkItemNotFoundError(LookupError):
"""Requested recurrence work item is not in the current read model."""
def _as_dict(value: Any) -> dict[str, Any]:
@@ -390,6 +398,386 @@ def _attach_work_item_summary(
}
def _recurrence_work_item_target(item: dict[str, Any]) -> dict[str, Any]:
return {
"recurrence_key": item.get("recurrence_key"),
"provider": item.get("provider"),
"alertname": item.get("alertname"),
"severity": item.get("severity"),
"namespace": item.get("namespace"),
"target_resource": item.get("target_resource"),
"fingerprint": item.get("fingerprint"),
"latest_event_id": item.get("latest_event_id"),
"latest_provider_event_id": item.get("latest_provider_event_id"),
"latest_run_id": item.get("latest_run_id"),
"latest_run_state": item.get("latest_run_state"),
"latest_agent_id": item.get("latest_agent_id"),
"latest_incident_id": item.get("latest_incident_id"),
}
def _selected_recurrence_mode(
work_item: dict[str, Any],
requested_mode: RecurrenceWorkItemMode,
) -> str:
if requested_mode != "auto":
return requested_mode
next_step = str(work_item.get("next_step") or "")
if next_step == "create_repair_ticket":
return "ticket"
if next_step in {
"run_post_verification",
"triage_failed_repair",
"review_repair_record",
"triage_missing_repair_record",
}:
return "reverify"
if next_step == "review_approval":
return "approval_review"
return "observe"
def _recurrence_work_item_checks(
item: dict[str, Any],
work_item: dict[str, Any],
) -> list[dict[str, Any]]:
repair_summary = _as_dict(item.get("repair_summary"))
source_ref_total = int(item.get("source_ref_total") or 0)
return [
{
"name": "work_item_open",
"passed": work_item.get("status") == "open",
"detail": str(work_item.get("status") or "unknown"),
},
{
"name": "incident_linked",
"passed": bool(work_item.get("incident_id")),
"detail": str(work_item.get("incident_id") or "missing incident_id"),
},
{
"name": "known_next_step",
"passed": str(work_item.get("next_step") or "none") != "none",
"detail": str(work_item.get("next_step") or "none"),
},
{
"name": "source_refs_present",
"passed": source_ref_total > 0,
"detail": str(source_ref_total),
},
{
"name": "no_destructive_writes",
"passed": True,
"detail": "preview_and_dry_run_only",
},
{
"name": "repair_status_visible",
"passed": bool(repair_summary.get("status")),
"detail": str(repair_summary.get("status") or "unknown"),
},
]
def _recurrence_plan(
item: dict[str, Any],
work_item: dict[str, Any],
mode: str,
) -> dict[str, Any]:
route_by_mode = {
"ticket": {
"step": "prepare_repair_ticket_preview",
"flywheel_node": "work_item_to_ticket",
"agent_id": "awooop_recurrence_coordinator",
},
"reverify": {
"step": "collect_read_model_and_prepare_reverification",
"flywheel_node": "verify",
"agent_id": "post_execution_verifier",
},
"approval_review": {
"step": "route_to_approval_review",
"flywheel_node": "approval",
"agent_id": "awooop_approval_coordinator",
},
"observe": {
"step": "observe_until_run_or_repair_state_changes",
"flywheel_node": "observe",
"agent_id": "awooop_recurrence_coordinator",
},
}
route = route_by_mode.get(mode, route_by_mode["observe"])
return {
**route,
"required_scope": "read",
"writes": [],
"target_action": work_item.get("next_step"),
"reason": work_item.get("reason"),
"target": _recurrence_work_item_target(item),
}
def _ticket_preview(item: dict[str, Any], work_item: dict[str, Any]) -> dict[str, Any]:
alertname = str(item.get("alertname") or item.get("provider") or "recurrence")
incident_id = str(work_item.get("incident_id") or item.get("latest_incident_id") or "")
kind = str(work_item.get("kind") or "recurrence")
title = f"[AwoooP] {alertname} recurrence work item: {incident_id or 'unlinked'}"
labels = ["awooop", "recurrence", kind]
body_lines = [
f"Incident: {incident_id or '--'}",
f"Alert: {alertname}",
f"Namespace/Target: {item.get('namespace') or '--'} / {item.get('target_resource') or '--'}",
f"Occurrences: {item.get('occurrence_total') or 0}",
f"Duplicates: {item.get('duplicate_total') or 0}",
f"Latest run: {item.get('latest_run_id') or '--'} ({item.get('latest_run_state') or '--'})",
f"Repair status: {_as_dict(item.get('repair_summary')).get('status') or '--'}",
f"Next step: {work_item.get('next_step') or '--'}",
"Writes: none in preview/dry-run; ticket creation requires a later explicit apply path.",
]
return {
"would_create": False,
"title": title[:180],
"labels": labels,
"body_preview": "\n".join(body_lines)[:1000],
}
def _recurrence_current_state_summary(
item: dict[str, Any],
work_item: dict[str, Any],
) -> dict[str, Any]:
repair_summary = _as_dict(item.get("repair_summary"))
return {
"work_item_status": work_item.get("status"),
"work_item_kind": work_item.get("kind"),
"work_item_next_step": work_item.get("next_step"),
"work_item_reason": work_item.get("reason"),
"occurrence_total": int(item.get("occurrence_total") or 0),
"duplicate_total": int(item.get("duplicate_total") or 0),
"linked_run_total": int(item.get("linked_run_total") or 0),
"run_state_counts": item.get("run_state_counts") or {},
"latest_run_state": item.get("latest_run_state"),
"latest_run_id": item.get("latest_run_id"),
"repair_status": repair_summary.get("status"),
"latest_auto_repair_id": repair_summary.get("latest_auto_repair_id"),
"latest_verification_result": repair_summary.get("latest_verification_result"),
"auto_repair_total": int(repair_summary.get("auto_repair_total") or 0),
"source_ref_total": int(item.get("source_ref_total") or 0),
"sentry_ref_total": int(item.get("sentry_ref_total") or 0),
"signoz_ref_total": int(item.get("signoz_ref_total") or 0),
"alert_ref_total": int(item.get("alert_ref_total") or 0),
}
def _verification_result_preview(mode: str, allowed: bool) -> str:
if not allowed:
return "blocked"
return {
"ticket": "ticket_preview_ready",
"reverify": "reverify_preview_ready",
"approval_review": "approval_review_required",
"observe": "observe_only",
}.get(mode, "observe_only")
def _find_recurrence_work_item(
recurrence: dict[str, Any],
work_item_id: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
for item in recurrence.get("items") or []:
work_item = _as_dict(item.get("work_item"))
if work_item.get("work_item_id") == work_item_id:
return item, work_item
raise RecurrenceWorkItemNotFoundError(work_item_id)
def build_recurrence_work_item_preview(
recurrence: dict[str, Any],
*,
work_item_id: str,
mode: RecurrenceWorkItemMode = "auto",
) -> dict[str, Any]:
"""Build a read-only plan for a recurrence work item."""
item, work_item = _find_recurrence_work_item(recurrence, work_item_id)
selected_mode = _selected_recurrence_mode(work_item, mode)
checks = _recurrence_work_item_checks(item, work_item)
allowed = all(check["passed"] for check in checks)
return {
"schema_version": "awooop_recurrence_work_item_preview_v1",
"source": "channel_event_dossier.recurrence",
"project_id": recurrence.get("project_id"),
"work_item_id": work_item.get("work_item_id"),
"incident_id": work_item.get("incident_id"),
"auto_repair_id": work_item.get("auto_repair_id"),
"mode": selected_mode,
"requested_mode": mode,
"allowed": allowed,
"safety_level": "read_only",
"writes_incident_state": False,
"writes_auto_repair_result": False,
"writes_ticket": False,
"checks": checks,
"plan": _recurrence_plan(item, work_item, selected_mode),
}
def build_recurrence_work_item_dry_run(
recurrence: dict[str, Any],
*,
work_item_id: str,
mode: RecurrenceWorkItemMode = "auto",
) -> dict[str, Any]:
"""Build a read-only dry-run result for a recurrence work item."""
item, work_item = _find_recurrence_work_item(recurrence, work_item_id)
selected_mode = _selected_recurrence_mode(work_item, mode)
checks = _recurrence_work_item_checks(item, work_item)
allowed = all(check["passed"] for check in checks)
payload = {
"schema_version": "awooop_recurrence_work_item_dry_run_v1",
"source": "channel_event_dossier.recurrence",
"project_id": recurrence.get("project_id"),
"work_item_id": work_item.get("work_item_id"),
"incident_id": work_item.get("incident_id"),
"auto_repair_id": work_item.get("auto_repair_id"),
"mode": selected_mode,
"requested_mode": mode,
"allowed": allowed,
"executed": allowed,
"safety_level": "read_only",
"writes_incident_state": False,
"writes_auto_repair_result": False,
"writes_ticket": False,
"checks": checks,
"verification_result_preview": _verification_result_preview(
selected_mode,
allowed,
),
"current_state_summary": _recurrence_current_state_summary(item, work_item),
"ticket_preview": _ticket_preview(item, work_item),
"plan": _recurrence_plan(item, work_item, selected_mode),
"read_model_route": {
"agent_id": "awooop_recurrence_coordinator",
"tool_name": "channel_event_dossier.recurrence",
"required_scope": "read",
"is_shadow": True,
"flywheel_node": "work_item",
},
"next_step": work_item.get("next_step"),
}
if not allowed:
payload["executed"] = False
return payload
def _recurrence_history_context(payload: dict[str, Any]) -> dict[str, Any]:
return {
"schema_version": "awooop_recurrence_work_item_dry_run_history_v1",
"source": payload.get("source"),
"project_id": payload.get("project_id"),
"work_item_id": payload.get("work_item_id"),
"incident_id": payload.get("incident_id"),
"auto_repair_id": payload.get("auto_repair_id"),
"mode": payload.get("mode"),
"requested_mode": payload.get("requested_mode"),
"allowed": payload.get("allowed"),
"executed": payload.get("executed"),
"safety_level": payload.get("safety_level"),
"writes_incident_state": payload.get("writes_incident_state"),
"writes_auto_repair_result": payload.get("writes_auto_repair_result"),
"writes_ticket": payload.get("writes_ticket"),
"verification_result_preview": payload.get("verification_result_preview"),
"current_state_summary": payload.get("current_state_summary"),
"ticket_preview": payload.get("ticket_preview"),
"read_model_route": payload.get("read_model_route"),
"checks": payload.get("checks"),
"next_step": payload.get("next_step"),
}
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:
return {"recorded": False, "reason": "missing_incident_id"}
history: dict[str, Any] = {
"recorded": False,
"alert_operation_id": None,
"timeline_event_id": None,
}
context = _recurrence_history_context(payload)
allowed = bool(payload.get("allowed"))
try:
from src.repositories.alert_operation_log_repository import (
get_alert_operation_log_repository,
)
record = await get_alert_operation_log_repository().append(
"PRE_FLIGHT_PASSED" if allowed else "PRE_FLIGHT_FAILED",
incident_id=incident_id,
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],
success=allowed,
context=context,
)
if record is not None:
history["alert_operation_id"] = getattr(record, "id", None)
except Exception as exc:
logger.warning(
"awooop_recurrence_work_item_alert_operation_history_failed",
incident_id=incident_id,
error=str(exc),
)
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),
)
history["recorded"] = bool(
history.get("alert_operation_id") or history.get("timeline_event_id")
)
if not history["recorded"]:
history["reason"] = "history_sink_unavailable"
return history
def _recurrence_history_description(context: dict[str, Any]) -> str:
state = context.get("current_state_summary") or {}
route = context.get("read_model_route") or {}
return (
f"mode={context.get('mode')} "
f"preview={context.get('verification_result_preview')} "
f"occurrences={state.get('occurrence_total')} "
f"repair_status={state.get('repair_status')} "
f"route={route.get('agent_id')}/{route.get('tool_name')} "
f"writes_incident={context.get('writes_incident_state')} "
f"writes_auto_repair={context.get('writes_auto_repair_result')} "
f"writes_ticket={context.get('writes_ticket')}"
)[:500]
def build_dossier_coverage(
rows: list[dict[str, Any]],
*,
@@ -813,3 +1201,49 @@ async def fetch_channel_event_dossier_recurrence(
limit=safe_limit,
repair_summaries_by_incident=repair_summaries,
)
async def fetch_recurrence_work_item_preview(
*,
project_id: str | None,
work_item_id: str,
mode: RecurrenceWorkItemMode = "auto",
provider: str | None = None,
limit: int = _MAX_RECURRENCE_EVENTS,
) -> dict[str, Any]:
"""Fetch a read-only preview for a recurrence work item."""
recurrence = await fetch_channel_event_dossier_recurrence(
project_id=project_id,
provider=provider,
limit=limit,
)
return build_recurrence_work_item_preview(
recurrence,
work_item_id=work_item_id,
mode=mode,
)
async def fetch_recurrence_work_item_dry_run(
*,
project_id: str | None,
work_item_id: str,
mode: RecurrenceWorkItemMode = "auto",
provider: str | None = None,
limit: int = _MAX_RECURRENCE_EVENTS,
) -> dict[str, Any]:
"""Fetch and record a safe read-only dry-run for a recurrence work item."""
recurrence = await fetch_channel_event_dossier_recurrence(
project_id=project_id,
provider=provider,
limit=limit,
)
payload = build_recurrence_work_item_dry_run(
recurrence,
work_item_id=work_item_id,
mode=mode,
)
payload["history"] = await _record_recurrence_work_item_dry_run_history(payload)
return payload

View File

@@ -8,9 +8,12 @@ from fastapi import HTTPException
from src.api.v1.platform.events import ChannelEventRecurrenceResponse
from src.services import channel_event_dossier_service
from src.services.channel_event_dossier_service import (
RecurrenceWorkItemNotFoundError,
build_dossier_coverage,
build_dossier_event,
build_dossier_recurrence,
build_recurrence_work_item_dry_run,
build_recurrence_work_item_preview,
fetch_channel_event_dossier,
fetch_channel_event_dossier_coverage,
fetch_channel_event_dossier_recurrence,
@@ -345,6 +348,133 @@ def test_build_dossier_recurrence_opens_work_item_for_completed_run_without_repa
}
def test_build_recurrence_work_item_preview_selects_ticket_mode() -> None:
recurrence = build_dossier_recurrence(
[
{
"event_id": "event-1",
"project_id": "awoooi",
"channel_type": "internal",
"provider_event_id": "alertmanager:received:1",
"content_hash": "a" * 64,
"content_preview": "Docker container unhealthy",
"content_redacted": "Docker container unhealthy",
"redaction_version": "audit_sink_v1",
"source_envelope": {
"provider": "alertmanager",
"source_refs": {
"alert_ids": ["alert-1"],
"incident_ids": ["INC-20260517-F25B4A"],
"fingerprints": ["fp-container-unhealthy"],
},
"log_correlation": {
"alertname": "DockerContainerUnhealthy",
"severity": "warning",
"namespace": "momo",
"target_resource": "bitan-pharmacy-bitan-1",
"fingerprint": "fp-container-unhealthy",
},
},
"is_duplicate": True,
"provider_ts": None,
"received_at": "2026-05-17T23:47:00",
"run_id": UUID("33333333-3333-4333-8333-333333333333"),
"run_state": "completed",
"run_agent_id": "openclaw",
}
],
project_id="awoooi",
limit=20,
)
preview = build_recurrence_work_item_preview(
recurrence,
work_item_id="incident:INC-20260517-F25B4A",
)
assert preview["schema_version"] == "awooop_recurrence_work_item_preview_v1"
assert preview["source"] == "channel_event_dossier.recurrence"
assert preview["mode"] == "ticket"
assert preview["allowed"] is True
assert preview["writes_incident_state"] is False
assert preview["writes_auto_repair_result"] is False
assert preview["writes_ticket"] is False
assert preview["plan"]["step"] == "prepare_repair_ticket_preview"
assert preview["plan"]["target"]["alertname"] == "DockerContainerUnhealthy"
def test_build_recurrence_work_item_dry_run_returns_ticket_preview_without_writes() -> None:
recurrence = build_dossier_recurrence(
[
{
"event_id": "event-1",
"project_id": "awoooi",
"channel_type": "internal",
"provider_event_id": "alertmanager:received:1",
"content_hash": "a" * 64,
"content_preview": "Docker container unhealthy",
"content_redacted": "Docker container unhealthy",
"redaction_version": "audit_sink_v1",
"source_envelope": {
"provider": "alertmanager",
"source_refs": {
"alert_ids": ["alert-1"],
"incident_ids": ["INC-20260517-F25B4A"],
"fingerprints": ["fp-container-unhealthy"],
},
"log_correlation": {
"alertname": "DockerContainerUnhealthy",
"severity": "warning",
"namespace": "momo",
"target_resource": "bitan-pharmacy-bitan-1",
"fingerprint": "fp-container-unhealthy",
},
},
"is_duplicate": True,
"provider_ts": None,
"received_at": "2026-05-17T23:47:00",
"run_id": UUID("33333333-3333-4333-8333-333333333333"),
"run_state": "completed",
"run_agent_id": "openclaw",
}
],
project_id="awoooi",
limit=20,
)
dry_run = build_recurrence_work_item_dry_run(
recurrence,
work_item_id="incident:INC-20260517-F25B4A",
)
assert dry_run["schema_version"] == "awooop_recurrence_work_item_dry_run_v1"
assert dry_run["mode"] == "ticket"
assert dry_run["allowed"] is True
assert dry_run["executed"] is True
assert dry_run["writes_incident_state"] is False
assert dry_run["writes_auto_repair_result"] is False
assert dry_run["writes_ticket"] is False
assert dry_run["verification_result_preview"] == "ticket_preview_ready"
assert dry_run["ticket_preview"]["would_create"] is False
assert "DockerContainerUnhealthy" in dry_run["ticket_preview"]["title"]
assert dry_run["current_state_summary"]["repair_status"] == "run_completed_no_repair"
assert dry_run["read_model_route"]["required_scope"] == "read"
def test_build_recurrence_work_item_preview_raises_for_missing_item() -> None:
recurrence = build_dossier_recurrence(
[],
project_id="awoooi",
limit=20,
)
with pytest.raises(RecurrenceWorkItemNotFoundError):
build_recurrence_work_item_preview(
recurrence,
work_item_id="incident:INC-20260517-MISSING",
)
def test_recurrence_response_model_preserves_repair_work_item_fields() -> None:
response = ChannelEventRecurrenceResponse.model_validate(
{

View File

@@ -1841,6 +1841,36 @@
"nextStep": "Next: {step}",
"openRun": "Open Run",
"openRuns": "Back to Runs",
"actions": {
"preview": "Preview",
"previewing": "Previewing",
"dryRun": "Dry-run",
"dryRunning": "Dry-running",
"failed": "The safe preview / dry-run API did not respond, so the next step cannot be claimed.",
"allowed": "Safety gate passed",
"blocked": "Safety gate blocked",
"mode": "Mode: {mode}",
"previewResult": "Result: {result}",
"writes": "Writes: incident={incident}; autoRepair={autoRepair}; ticket={ticket}",
"history": "Dry-run stored: {recorded}",
"ticket": "Ticket preview: {title}",
"modes": {
"auto": "Auto select",
"ticket": "Ticket preview",
"reverify": "Reverify",
"approval_review": "Approval review",
"observe": "Observe",
"unknown": "Unknown"
},
"previews": {
"ticket_preview_ready": "Ticket preview ready",
"reverify_preview_ready": "Reverify preview ready",
"approval_review_required": "Approval review required",
"observe_only": "Observe only",
"blocked": "Blocked",
"unknown": "Unknown"
}
},
"statuses": {
"auto_repair_verified": "Verified repair",
"auto_repair_succeeded_unverified": "Repair needs verification",

View File

@@ -1842,6 +1842,36 @@
"nextStep": "下一步:{step}",
"openRun": "開啟 Run",
"openRuns": "回 Run 監控",
"actions": {
"preview": "預覽",
"previewing": "預覽中",
"dryRun": "乾跑",
"dryRunning": "乾跑中",
"failed": "安全預覽 / 乾跑 API 未回應,不能判定下一步。",
"allowed": "安全閘門通過",
"blocked": "安全閘門阻塞",
"mode": "模式:{mode}",
"previewResult": "結果:{result}",
"writes": "寫入incident={incident}autoRepair={autoRepair}ticket={ticket}",
"history": "試跑入庫:{recorded}",
"ticket": "Ticket 預覽:{title}",
"modes": {
"auto": "自動選擇",
"ticket": "Ticket 預覽",
"reverify": "重新驗證",
"approval_review": "審批檢查",
"observe": "觀察",
"unknown": "未知"
},
"previews": {
"ticket_preview_ready": "Ticket 預覽已就緒",
"reverify_preview_ready": "重新驗證預覽已就緒",
"approval_review_required": "需進審批檢查",
"observe_only": "僅觀察",
"blocked": "已阻塞",
"unknown": "未知"
}
},
"statuses": {
"auto_repair_verified": "已驗證修復",
"auto_repair_succeeded_unverified": "修復待驗證",

View File

@@ -104,6 +104,60 @@ type RecurrenceResponse = {
items: RecurrenceItem[];
};
type RecurrenceWorkItemActionResult = {
schema_version?: string;
work_item_id?: string | null;
incident_id?: string | null;
mode?: string | null;
requested_mode?: string | null;
allowed?: boolean | null;
executed?: boolean | null;
safety_level?: string | null;
writes_incident_state?: boolean | null;
writes_auto_repair_result?: boolean | null;
writes_ticket?: boolean | null;
verification_result_preview?: string | null;
next_step?: string | null;
checks?: Array<{ name?: string | null; passed?: boolean | null; detail?: string | null }>;
current_state_summary?: {
repair_status?: string | null;
occurrence_total?: number | null;
duplicate_total?: number | null;
linked_run_total?: number | null;
} | null;
ticket_preview?: {
would_create?: boolean | null;
title?: string | null;
labels?: string[] | null;
body_preview?: string | null;
} | null;
plan?: {
step?: string | null;
flywheel_node?: string | null;
agent_id?: string | null;
required_scope?: string | null;
target_action?: string | null;
} | null;
read_model_route?: {
agent_id?: string | null;
tool_name?: string | null;
required_scope?: string | null;
flywheel_node?: string | null;
} | null;
history?: {
recorded?: boolean | null;
reason?: string | null;
alert_operation_id?: string | null;
timeline_event_id?: string | null;
} | null;
};
type RecurrenceWorkItemActionState = {
loading?: "preview" | "dryRun" | null;
result?: RecurrenceWorkItemActionResult | null;
error?: string | null;
};
type SloResponse = {
adr100?: {
verification_coverage?: {
@@ -194,6 +248,30 @@ async function fetchJson<T>(url: string, timeoutMs = 8000): Promise<T | null> {
}
}
async function postJson<T>(
url: string,
body: Record<string, unknown>,
timeoutMs = 8000
): Promise<T | null> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
} finally {
window.clearTimeout(timeout);
}
}
function hasGateFailure(summary: AutomationQualitySummary | null, gate: string) {
return Boolean(summary?.gate_failures?.some((row) => row.gate === gate && row.total > 0));
}
@@ -227,6 +305,32 @@ function recurrenceRepairStatusKey(status?: string | null) {
return "unknown";
}
function recurrenceActionModeKey(mode?: string | null) {
if (
mode === "auto" ||
mode === "ticket" ||
mode === "reverify" ||
mode === "approval_review" ||
mode === "observe"
) {
return mode;
}
return "unknown";
}
function recurrencePreviewKey(preview?: string | null) {
if (
preview === "ticket_preview_ready" ||
preview === "reverify_preview_ready" ||
preview === "approval_review_required" ||
preview === "observe_only" ||
preview === "blocked"
) {
return preview;
}
return "unknown";
}
function buildWorkItems(
telemetry: Telemetry,
t: ReturnType<typeof useTranslations>
@@ -538,6 +642,7 @@ function RecurrenceWorkQueuePanel({
projectId: string;
}) {
const t = useTranslations("awooop.workItems.recurrence");
const [actionState, setActionState] = useState<Record<string, RecurrenceWorkItemActionState>>({});
const openItems = recurrenceOpenItems(recurrence);
const focusedItem = focusedWorkItemId
? openItems.find((item) => item.work_item?.work_item_id === focusedWorkItemId)
@@ -546,6 +651,42 @@ function RecurrenceWorkQueuePanel({
? [focusedItem, ...openItems.filter((item) => item !== focusedItem).slice(0, 5)]
: openItems.slice(0, 6);
const summary = recurrence?.summary;
const runWorkItemAction = useCallback(async (
workItemId: string,
action: "preview" | "dryRun"
) => {
setActionState((current) => ({
...current,
[workItemId]: { ...current[workItemId], loading: action, error: null },
}));
const encodedProjectId = encodeURIComponent(projectId);
const encodedWorkItemId = encodeURIComponent(workItemId);
const result = action === "preview"
? await fetchJson<RecurrenceWorkItemActionResult>(
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/preview?project_id=${encodedProjectId}&work_item_id=${encodedWorkItemId}`,
12000
)
: await postJson<RecurrenceWorkItemActionResult>(
`${API_BASE}/api/v1/platform/events/dossier/recurrence/work-item/dry-run`,
{
project_id: projectId,
work_item_id: workItemId,
mode: "auto",
limit: 300,
},
15000
);
setActionState((current) => ({
...current,
[workItemId]: {
loading: null,
result,
error: result ? null : t("actions.failed"),
},
}));
}, [projectId, t]);
return (
<section className="border border-[#e0ddd4] bg-white">
@@ -582,17 +723,23 @@ function RecurrenceWorkQueuePanel({
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-3">
{visibleItems.map((item) => {
const workItem = item.work_item;
const workItemId = workItem?.work_item_id ?? "";
const isFocused = Boolean(
focusedWorkItemId && workItem?.work_item_id === focusedWorkItemId
focusedWorkItemId && workItemId === focusedWorkItemId
);
const repairStatusKey = recurrenceRepairStatusKey(item.repair_summary?.status);
const runHref = item.latest_run_id
? `/awooop/runs/${item.latest_run_id}?project_id=${encodeURIComponent(projectId)}`
: null;
const currentAction = workItemId ? actionState[workItemId] : null;
const actionResult = currentAction?.result;
const actionAllowed = actionResult?.allowed === true;
const actionModeKey = recurrenceActionModeKey(actionResult?.mode);
const previewKey = recurrencePreviewKey(actionResult?.verification_result_preview);
return (
<article
key={workItem?.work_item_id ?? item.recurrence_key}
key={workItemId || item.recurrence_key}
className={cn(
"bg-white px-4 py-3",
isFocused && "outline outline-2 outline-[#d97757]"
@@ -631,6 +778,32 @@ function RecurrenceWorkQueuePanel({
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{workItemId ? (
<>
<button
type="button"
onClick={() => runWorkItemAction(workItemId, "preview")}
disabled={currentAction?.loading === "preview"}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{currentAction?.loading === "preview"
? t("actions.previewing")
: t("actions.preview")}
</button>
<button
type="button"
onClick={() => runWorkItemAction(workItemId, "dryRun")}
disabled={currentAction?.loading === "dryRun"}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d97757] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<Gauge className="h-3.5 w-3.5" aria-hidden="true" />
{currentAction?.loading === "dryRun"
? t("actions.dryRunning")
: t("actions.dryRun")}
</button>
</>
) : null}
{runHref ? (
<Link
href={runHref as never}
@@ -648,6 +821,58 @@ function RecurrenceWorkQueuePanel({
{t("openRuns")}
</Link>
</div>
{currentAction?.error ? (
<div className="mt-3 border border-[#e2a29b] bg-[#fff0ef] px-3 py-2 text-xs leading-5 text-[#9f2f25]">
{currentAction.error}
</div>
) : null}
{actionResult ? (
<div
className={cn(
"mt-3 border px-3 py-2 text-xs leading-5",
actionAllowed
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold">
{actionAllowed ? t("actions.allowed") : t("actions.blocked")}
</span>
<span className="font-mono">
{t("actions.mode", {
mode: t(`actions.modes.${actionModeKey}` as never),
})}
</span>
</div>
<div className="mt-1 grid gap-1 text-[#5f5b52]">
<p>
{t("actions.previewResult", {
result: t(`actions.previews.${previewKey}` as never),
})}
</p>
<p>
{t("actions.writes", {
incident: String(actionResult.writes_incident_state ?? false),
autoRepair: String(actionResult.writes_auto_repair_result ?? false),
ticket: String(actionResult.writes_ticket ?? false),
})}
</p>
<p>
{t("actions.history", {
recorded: String(actionResult.history?.recorded ?? false),
})}
</p>
{actionResult.ticket_preview?.title ? (
<p className="truncate">
{t("actions.ticket", {
title: actionResult.ticket_preview.title,
})}
</p>
) : null}
</div>
</div>
) : null}
</article>
);
})}