From aa330339b8fcaa1964f569ddffae09b147227ca2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 10:42:44 +0800 Subject: [PATCH] feat(awooop): surface status chain on work queues --- apps/api/src/api/v1/platform/operator_runs.py | 25 ++++++++ .../src/services/platform_operator_service.py | 57 +++++++++++++++++-- .../test_awooop_operator_timeline_labels.py | 37 ++++++++++++ .../awooop/approvals/[run_id]/page.tsx | 7 +++ .../app/[locale]/awooop/approvals/page.tsx | 14 ++++- .../app/[locale]/awooop/work-items/page.tsx | 53 ++++++++++++++++- 6 files changed, 185 insertions(+), 8 deletions(-) diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index af1cbc04..85a03fbf 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -25,6 +25,9 @@ from src.core.awooop_operator_auth import ( from src.services.platform_operator_service import ( decide_approval as decide_approval_svc, ) +from src.services.platform_operator_service import ( + get_awooop_status_chain as get_awooop_status_chain_svc, +) from src.services.platform_operator_service import ( get_run_detail as get_run_detail_svc, ) @@ -103,6 +106,7 @@ class ApprovalItem(BaseModel): created_at: datetime timeout_at: datetime | None remediation_summary: dict[str, Any] | None = None + awooop_status_chain: dict[str, Any] | None = None class ListApprovalsResponse(BaseModel): @@ -209,6 +213,27 @@ async def get_run_detail( return await get_run_detail_svc(run_id=run_id, project_id=project_id) +@router.get( + "/status-chain", + summary="查詢 AwoooP 狀態鏈", + description=( + "依 incident_id 查詢 truth-chain + ADR-100 history 合併後的只讀狀態鏈," + "供 Work Items、Approvals、Monitoring 等操作頁面共用。" + ), +) +async def get_awooop_status_chain( + project_id: str | None = Query(None, description="租戶 ID(可選)"), + incident_id: list[str] | None = Query( + None, + description="Incident ID,可重複傳入以合併同一工作項的多個事件", + ), +) -> dict[str, Any]: + return await get_awooop_status_chain_svc( + project_id=project_id, + incident_ids=incident_id or [], + ) + + @router.get( "/approvals", response_model=ListApprovalsResponse, diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index a8ef05ad..2bb02ad7 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -1148,6 +1148,34 @@ async def _fetch_awooop_status_chain( ) +async def get_awooop_status_chain( + *, + project_id: str | None, + incident_ids: list[str], +) -> dict[str, Any]: + """Return the shared AwoooP status chain for UI surfaces without writing state.""" + normalized_incident_ids: list[str] = [] + for incident_id in incident_ids: + safe_incident_id = str(incident_id or "").strip() + if not safe_incident_id: + continue + _validate_incident_id_filter(safe_incident_id) + _append_unique(normalized_incident_ids, safe_incident_id) + + if not normalized_incident_ids: + return _build_awooop_status_chain(incident_ids=[], source_id=None) + + remediation_history = await _fetch_run_remediation_history( + normalized_incident_ids, + limit=5, + ) + return await _fetch_awooop_status_chain( + incident_ids=normalized_incident_ids, + project_id=project_id or "awoooi", + remediation_history=remediation_history, + ) + + def _validate_remediation_status_filter(value: str | None) -> None: if value is None: return @@ -1913,17 +1941,34 @@ async def list_approvals( ] total = len(rows) - items = [ - { + status_chain_cache: dict[tuple[str, tuple[str, ...]], dict[str, Any]] = {} + items = [] + for r in rows: + summary = remediation_summaries.get(r.run_id) + summary_incident_ids = summary.get("incident_ids") if isinstance(summary, dict) else [] + incident_ids = [ + str(incident_id) + for incident_id in summary_incident_ids + if isinstance(incident_id, str) and incident_id + ] + cache_key = (r.project_id, tuple(incident_ids)) + status_chain = status_chain_cache.get(cache_key) + if status_chain is None: + status_chain = await get_awooop_status_chain( + project_id=r.project_id, + incident_ids=incident_ids, + ) + status_chain_cache[cache_key] = status_chain + + items.append({ "run_id": r.run_id, "project_id": r.project_id, "agent_id": r.agent_id, "created_at": r.created_at, "timeout_at": r.timeout_at, - "remediation_summary": remediation_summaries.get(r.run_id), - } - for r in rows - ] + "remediation_summary": summary, + "awooop_status_chain": status_chain, + }) return {"approvals": items, "total": total, "items": items} diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 6282c36d..ca5b3ca2 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -7,6 +7,7 @@ import pytest from fastapi import HTTPException from src.api.v1.platform.operator_runs import ( + ListApprovalsResponse, ListCallbackRepliesResponse, ListRunsResponse, ) @@ -374,6 +375,42 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None: assert dumped["items"][0]["run_detail_href"].endswith("project_id=awoooi") +def test_list_approvals_response_preserves_status_chain() -> None: + run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38") + response = ListApprovalsResponse.model_validate({ + "items": [ + { + "run_id": run_id, + "project_id": "awoooi", + "agent_id": "hermes-approval-router", + "created_at": datetime(2026, 5, 18, 7, 30, 0), + "timeout_at": datetime(2026, 5, 18, 7, 45, 0), + "remediation_summary": { + "status": "read_only_dry_run", + "incident_ids": ["INC-20260513-79ED5E"], + }, + "awooop_status_chain": { + "schema_version": "awooop_status_chain_v1", + "source_id": "INC-20260513-79ED5E", + "repair_state": "read_only_dry_run", + "needs_human": True, + "next_step": "approve_or_escalate_from_awooop", + }, + } + ], + "total": 1, + }) + + dumped = response.model_dump(mode="json") + assert dumped["items"][0]["remediation_summary"]["status"] == ( + "read_only_dry_run" + ) + assert dumped["items"][0]["awooop_status_chain"]["source_id"] == ( + "INC-20260513-79ED5E" + ) + assert dumped["items"][0]["awooop_status_chain"]["needs_human"] is True + + def test_callback_reply_action_filter_normalizes_safe_actions() -> None: assert _validate_callback_reply_action_filter(" History ") == "history" assert _validate_callback_reply_action_filter("incident:detail-2") == ( diff --git a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx index c45ed602..96a9a65f 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx @@ -22,6 +22,10 @@ import { import { Link, useRouter } from "@/i18n/routing"; import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header"; +import { + AwoooPStatusChainPanel, + type AwoooPStatusChain, +} from "@/components/awooop/status-chain"; import { cn } from "@/lib/utils"; interface RunDetail { @@ -63,6 +67,7 @@ interface RunRemediationHistory { interface RunDetailResponse { run: RunDetail; remediation_history?: RunRemediationHistory; + awooop_status_chain?: AwoooPStatusChain | null; } const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; @@ -480,6 +485,8 @@ export default function ApprovalDecisionPage({ writesAutoRepairResult={latestRemediation?.writes_auto_repair_result} /> + + + + + {formattedDate} @@ -312,6 +320,7 @@ function ApprovalRow({ approval }: { approval: Approval }) { export default function ApprovalsPage() { const tEvidence = useTranslations("awooop.listEvidence"); + const tStatusChain = useTranslations("awooop.statusChain"); const [approvals, setApprovals] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -554,6 +563,9 @@ export default function ApprovalsPage() { {tEvidence("column")} + + {tStatusChain("title")} + 建立時間 @@ -566,7 +578,7 @@ export default function ApprovalsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 7 }).map((_, j) => ( + {Array.from({ length: 8 }).map((_, j) => (
diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 130539c0..cfcca891 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -27,6 +27,10 @@ import { import { Link } from "@/i18n/routing"; import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header"; +import { + AwoooPStatusChainPanel, + type AwoooPStatusChain, +} from "@/components/awooop/status-chain"; import { cn } from "@/lib/utils"; type WorkStatus = "live" | "in_progress" | "blocked" | "watching"; @@ -278,6 +282,7 @@ type Telemetry = { slo: SloResponse | null; remediationHistory: RemediationHistoryResponse | null; driftFingerprintState: DriftFingerprintState | null; + statusChain: AwoooPStatusChain | null; }; type WorkItem = { @@ -371,6 +376,33 @@ function recurrenceOpenItems(recurrence: RecurrenceResponse | null) { return (recurrence?.items ?? []).filter((item) => item.work_item?.status === "open"); } +function firstIncidentId(...candidates: Array) { + return candidates.find((candidate) => Boolean(candidate?.trim()))?.trim() ?? null; +} + +function selectStatusChainIncidentId( + focusedIncidentId: string | null, + remediationHistory: RemediationHistoryResponse | null, + recurrence: RecurrenceResponse | null +) { + const latestRemediationIncident = remediationHistory?.items?.find( + (item) => Boolean(item.incident_id?.trim()) + )?.incident_id; + const latestOpenRecurrence = recurrenceOpenItems(recurrence)[0] ?? null; + const latestRecurrenceIncident = recurrence?.items?.find( + (item) => Boolean(item.latest_incident_id?.trim() || item.work_item?.incident_id?.trim()) + ); + + return firstIncidentId( + focusedIncidentId, + latestRemediationIncident, + latestOpenRecurrence?.latest_incident_id, + latestOpenRecurrence?.work_item?.incident_id, + latestRecurrenceIncident?.latest_incident_id, + latestRecurrenceIncident?.work_item?.incident_id + ); +} + function recurrenceRepairStatusKey(status?: string | null) { if ( status === "auto_repair_verified" || @@ -1382,6 +1414,7 @@ export default function AwoooPWorkItemsPage() { slo: null, remediationHistory: null, driftFingerprintState: null, + statusChain: null, }); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); @@ -1418,6 +1451,21 @@ export default function AwoooPWorkItemsPage() { fetchJson(driftFingerprintUrl, 12000), ]); + const statusChainIncidentId = selectStatusChainIncidentId( + focusedIncidentId, + remediationHistory, + eventRecurrence + ); + let statusChain: AwoooPStatusChain | null = null; + if (statusChainIncidentId) { + const statusChainParams = new URLSearchParams({ project_id: projectId }); + statusChainParams.append("incident_id", statusChainIncidentId); + statusChain = await fetchJson( + `${API_BASE}/api/v1/platform/status-chain?${statusChainParams.toString()}`, + 12000 + ); + } + setTelemetry({ quality, governanceEvents, @@ -1427,10 +1475,11 @@ export default function AwoooPWorkItemsPage() { slo, remediationHistory, driftFingerprintState, + statusChain, }); setLastUpdated(new Date()); setLoading(false); - }, [projectId]); + }, [focusedIncidentId, projectId]); useEffect(() => { fetchTelemetry(); @@ -1533,6 +1582,8 @@ export default function AwoooPWorkItemsPage() { writesAutoRepairResult={latestRemediationHistory?.writes_auto_repair_result} /> + +