feat(awooop): surface status chain on work queues
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m30s
CD Pipeline / build-and-deploy (push) Successful in 3m29s
CD Pipeline / post-deploy-checks (push) Successful in 1m15s

This commit is contained in:
Your Name
2026-05-19 10:42:44 +08:00
parent a0f41658db
commit aa330339b8
6 changed files with 185 additions and 8 deletions

View File

@@ -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,

View File

@@ -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}

View File

@@ -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") == (

View File

@@ -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}
/>
<AwoooPStatusChainPanel chain={detail?.awooop_status_chain} />
<ApprovalRemediationEvidence
history={detail?.remediation_history}
locale={locale}

View File

@@ -21,6 +21,10 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing";
import {
AwoooPStatusChainPanel,
type AwoooPStatusChain,
} from "@/components/awooop/status-chain";
// =============================================================================
// Types
@@ -57,6 +61,7 @@ interface Approval {
created_at: string;
timeout_at: string | null;
remediation_summary?: RemediationSummary | null;
awooop_status_chain?: AwoooPStatusChain | null;
}
// =============================================================================
@@ -294,6 +299,9 @@ function ApprovalRow({ approval }: { approval: Approval }) {
<td className="px-4 py-3">
<RemediationEvidenceCell summary={approval.remediation_summary} />
</td>
<td className="min-w-[280px] px-4 py-3">
<AwoooPStatusChainPanel chain={approval.awooop_status_chain} compact />
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground font-mono">
{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<Approval[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -554,6 +563,9 @@ export default function ApprovalsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{tEvidence("column")}
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{tStatusChain("title")}
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
@@ -566,7 +578,7 @@ export default function ApprovalsPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 7 }).map((_, j) => (
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>

View File

@@ -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<string | null | undefined>) {
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<Date | null>(null);
@@ -1418,6 +1451,21 @@ export default function AwoooPWorkItemsPage() {
fetchJson<DriftFingerprintState>(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<AwoooPStatusChain>(
`${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}
/>
<AwoooPStatusChainPanel chain={telemetry.statusChain} />
<RecurrenceWorkQueuePanel
recurrence={telemetry.eventRecurrence}
focusedWorkItemId={focusedWorkItemId}