feat(awooop): surface status chain on work queues
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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") == (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user