feat(awooop): surface remediation evidence in run lists
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m9s
CD Pipeline / build-and-deploy (push) Successful in 4m1s
CD Pipeline / post-deploy-checks (push) Successful in 1m54s

This commit is contained in:
Your Name
2026-05-17 20:26:03 +08:00
parent 27c2a3d980
commit 0592402779
7 changed files with 674 additions and 12 deletions

View File

@@ -51,6 +51,7 @@ class RunItem(BaseModel):
step_count: int
created_at: datetime
timeout_at: datetime | None
remediation_summary: dict[str, Any] | None = None
class ListRunsResponse(BaseModel):
@@ -66,6 +67,7 @@ class ApprovalItem(BaseModel):
agent_id: str
created_at: datetime
timeout_at: datetime | None
remediation_summary: dict[str, Any] | None = None
class ListApprovalsResponse(BaseModel):

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import re
import uuid
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -40,6 +41,7 @@ _DEFAULT_PER_PAGE = 50
_MAX_PER_PAGE = 200
_MAX_EVENTS = 100
_MAX_TIMELINE_ITEMS = 100
_MAX_LIST_CONTEXT_ROWS = 500
_MAX_STEP_SUMMARY_CHARS = 128
_REMEDIATION_HISTORY_LIMIT = 20
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
@@ -151,6 +153,14 @@ async def list_runs(
result = await db.execute(stmt)
rows = list(result.scalars().all())
inbound_by_run, outbound_by_run = await _load_run_message_context(db, rows)
remediation_summaries = await _build_run_remediation_summaries(
runs=rows,
inbound_by_run=inbound_by_run,
outbound_by_run=outbound_by_run,
)
runs = [
{
"run_id": r.run_id,
@@ -162,6 +172,7 @@ async def list_runs(
"step_count": r.step_count,
"created_at": r.created_at,
"timeout_at": r.timeout_at,
"remediation_summary": remediation_summaries.get(r.run_id),
}
for r in rows
]
@@ -309,6 +320,67 @@ def _collect_run_incident_ids(
return incident_ids
async def _load_run_message_context(
db: Any,
runs: list[AwoooPRunState],
) -> tuple[
dict[UUID, list[AwoooPConversationEvent]],
dict[UUID, list[AwoooPOutboundMessage]],
]:
"""Load list-page sidecar events needed to link runs back to incidents."""
if not runs:
return {}, {}
run_ids = [run.run_id for run in runs]
run_ids_set = set(run_ids)
trigger_refs = [str(run.trigger_ref) for run in runs if run.trigger_ref]
trigger_ref_to_run = {
str(run.trigger_ref): run.run_id
for run in runs
if run.trigger_ref
}
trigger_event_ids: list[UUID] = []
for trigger_ref in trigger_refs:
try:
trigger_event_ids.append(uuid.UUID(trigger_ref))
except ValueError:
continue
inbound_filters = [AwoooPConversationEvent.run_id.in_(run_ids)]
if trigger_refs:
inbound_filters.append(AwoooPConversationEvent.provider_event_id.in_(trigger_refs))
if trigger_event_ids:
inbound_filters.append(AwoooPConversationEvent.event_id.in_(trigger_event_ids))
inbound_result = await db.execute(
select(AwoooPConversationEvent)
.where(sa_or(*inbound_filters))
.order_by(AwoooPConversationEvent.received_at.desc())
.limit(_MAX_LIST_CONTEXT_ROWS)
)
inbound_by_run: dict[UUID, list[AwoooPConversationEvent]] = defaultdict(list)
for event in inbound_result.scalars().all():
target_run_id = event.run_id if event.run_id in run_ids_set else None
if target_run_id is None:
target_run_id = trigger_ref_to_run.get(str(event.provider_event_id))
if target_run_id is None:
target_run_id = trigger_ref_to_run.get(str(event.event_id))
if target_run_id is not None:
inbound_by_run[target_run_id].append(event)
outbound_result = await db.execute(
select(AwoooPOutboundMessage)
.where(AwoooPOutboundMessage.run_id.in_(run_ids))
.order_by(AwoooPOutboundMessage.queued_at.desc())
.limit(_MAX_LIST_CONTEXT_ROWS)
)
outbound_by_run: dict[UUID, list[AwoooPOutboundMessage]] = defaultdict(list)
for message in outbound_result.scalars().all():
outbound_by_run[message.run_id].append(message)
return dict(inbound_by_run), dict(outbound_by_run)
def _route_label_from_remediation(item: dict[str, Any]) -> str:
"""Render remediation MCP route consistently with Telegram / Work Items."""
return "/".join(
@@ -341,6 +413,132 @@ def _remediation_timeline_summary(item: dict[str, Any]) -> str:
)[:500]
def _run_remediation_list_summary(
*,
run: AwoooPRunState,
incident_ids: list[str],
items: list[dict[str, Any]],
errors: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Summarize durable ADR-100 dry-run evidence for list-level UX."""
sorted_items = sorted(
(item for item in items if isinstance(item, dict)),
key=lambda item: str(item.get("created_at") or ""),
reverse=True,
)
latest = sorted_items[0] if sorted_items else {}
writes_incident = latest.get("writes_incident_state")
writes_auto_repair = latest.get("writes_auto_repair_result")
route = _route_label_from_remediation(latest) if latest else "--"
write_observed = writes_incident is True or writes_auto_repair is True
is_read_only = (
bool(latest)
and latest.get("required_scope") == "read"
and writes_incident is False
and writes_auto_repair is False
)
if not sorted_items:
status_value = "no_evidence"
elif latest.get("success") is False or latest.get("allowed") is False:
status_value = "blocked"
elif write_observed:
status_value = "write_observed"
elif is_read_only:
status_value = "read_only_dry_run"
else:
status_value = "observed"
return {
"schema_version": "awooop_run_remediation_summary_v1",
"source": "alert_operation_log",
"incident_ids": incident_ids,
"total": len(sorted_items),
"status": status_value,
"has_dry_run": bool(sorted_items),
"is_read_only": is_read_only,
"human_gate_open": run.state == "waiting_approval",
"latest_at": latest.get("created_at"),
"latest_preview": latest.get("verification_result_preview"),
"latest_mode": latest.get("mode"),
"latest_route": route,
"latest_agent_id": latest.get("agent_id"),
"latest_tool_name": latest.get("tool_name"),
"latest_required_scope": latest.get("required_scope"),
"writes_incident_state": writes_incident,
"writes_auto_repair_result": writes_auto_repair,
"errors": errors or [],
}
async def _build_run_remediation_summaries(
*,
runs: list[AwoooPRunState],
inbound_by_run: dict[UUID, list[AwoooPConversationEvent]],
outbound_by_run: dict[UUID, list[AwoooPOutboundMessage]],
) -> dict[UUID, dict[str, Any]]:
"""Build remediation summaries for list endpoints without writing state."""
if not runs:
return {}
incident_ids_by_run: dict[UUID, list[str]] = {}
all_incident_ids: list[str] = []
for run in runs:
incident_ids = _collect_run_incident_ids(
run=run,
inbound_events=inbound_by_run.get(run.run_id, []),
outbound_messages=outbound_by_run.get(run.run_id, []),
)
incident_ids_by_run[run.run_id] = incident_ids
for incident_id in incident_ids:
_append_unique(all_incident_ids, incident_id)
histories_by_incident: dict[str, list[dict[str, Any]]] = {}
errors_by_incident: dict[str, dict[str, str]] = {}
if all_incident_ids:
from src.services.adr100_remediation_service import Adr100RemediationService
service = Adr100RemediationService(record_history=False)
for incident_id in all_incident_ids:
try:
history = await service.history(
limit=_REMEDIATION_HISTORY_LIMIT,
incident_id=incident_id,
)
histories_by_incident[incident_id] = [
item
for item in history.get("items", [])
if isinstance(item, dict)
]
except Exception as exc:
logger.warning(
"run_list_remediation_history_fetch_failed",
incident_id=incident_id,
error=str(exc),
)
errors_by_incident[incident_id] = {
"incident_id": incident_id,
"error": str(exc),
}
summaries: dict[UUID, dict[str, Any]] = {}
for run in runs:
incident_ids = incident_ids_by_run.get(run.run_id, [])
items: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
for incident_id in incident_ids:
items.extend(histories_by_incident.get(incident_id, []))
if incident_id in errors_by_incident:
errors.append(errors_by_incident[incident_id])
summaries[run.run_id] = _run_remediation_list_summary(
run=run,
incident_ids=incident_ids,
items=items,
errors=errors,
)
return summaries
def _timeline_sort_key(item: dict[str, Any], fallback_ts: Any) -> str:
"""Normalize mixed DB datetime / ISO string timestamps for timeline sorting."""
value = item.get("ts") or fallback_ts
@@ -816,6 +1014,14 @@ async def list_approvals(
result = await db.execute(stmt)
rows = list(result.scalars().all())
inbound_by_run, outbound_by_run = await _load_run_message_context(db, rows)
remediation_summaries = await _build_run_remediation_summaries(
runs=rows,
inbound_by_run=inbound_by_run,
outbound_by_run=outbound_by_run,
)
items = [
{
"run_id": r.run_id,
@@ -823,6 +1029,7 @@ async def list_approvals(
"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
]

View File

@@ -4,6 +4,7 @@ from types import SimpleNamespace
from src.services.platform_operator_service import (
_collect_run_incident_ids,
_outbound_timeline_title,
_run_remediation_list_summary,
_remediation_timeline_summary,
_timeline_sort_key,
)
@@ -105,6 +106,58 @@ def test_remediation_timeline_summary_surfaces_route_and_write_flags() -> None:
assert "writes_auto_repair=False" in summary
def test_run_remediation_list_summary_marks_read_only_dry_run() -> None:
run = SimpleNamespace(state="waiting_approval")
summary = _run_remediation_list_summary(
run=run,
incident_ids=["INC-20260514-F85F21"],
items=[
{
"created_at": "2026-05-14T23:04:00+00:00",
"incident_id": "INC-20260514-F85F21",
"mode": "replay",
"verification_result_preview": "degraded",
"agent_id": "auto_repair_executor",
"tool_name": "ssh_diagnose",
"required_scope": "read",
"writes_incident_state": False,
"writes_auto_repair_result": False,
}
],
)
assert summary["status"] == "read_only_dry_run"
assert summary["has_dry_run"] is True
assert summary["is_read_only"] is True
assert summary["human_gate_open"] is True
assert summary["latest_route"] == "auto_repair_executor/ssh_diagnose/read"
def test_run_remediation_list_summary_flags_write_observed() -> None:
run = SimpleNamespace(state="completed")
summary = _run_remediation_list_summary(
run=run,
incident_ids=["INC-20260514-F85F21"],
items=[
{
"created_at": "2026-05-14T23:05:00+00:00",
"incident_id": "INC-20260514-F85F21",
"agent_id": "auto_repair_executor",
"tool_name": "state_update",
"required_scope": "write",
"writes_incident_state": True,
"writes_auto_repair_result": False,
}
],
)
assert summary["status"] == "write_observed"
assert summary["is_read_only"] is False
assert summary["writes_incident_state"] is True
def test_timeline_sort_key_normalizes_datetime_and_iso_string() -> None:
fallback = datetime(2026, 5, 14, 10, 0, 0)
keys = [

View File

@@ -1796,6 +1796,39 @@
"timelineMissing": "Quality summary still reports a Timeline / audit gap"
}
},
"listEvidence": {
"column": "AI Evidence",
"count": "{count} dry-runs",
"route": "MCP: {route}",
"emptyShort": "No remediation dry-run linked",
"manualGate": "Next: human approval",
"statuses": {
"noEvidence": "No dry-run yet",
"readOnlyDryRun": "AI dry-run: read-only",
"writeObserved": "Write flag observed",
"blocked": "Dry-run blocked",
"observed": "Evidence linked"
},
"details": {
"noEvidence": "This row is not linked to ADR-100 remediation dry-run records in alert_operation_log yet.",
"readOnlyDryRun": "AI has run the remediation dry-run and the latest record did not write incident or auto-repair state.",
"writeObserved": "The latest remediation record contains write flags; verify the state-change source before approval.",
"blocked": "The remediation dry-run failed or was blocked by a gate; human review is required.",
"observed": "This row is linked to remediation history; open Run Timeline for the full evidence."
},
"summary": {
"readOnly": "Read-only dry-run",
"readOnlyDetail": "Latest evidence shows AI trialed the action without writing state",
"manualGate": "Human gate",
"manualGateDetail": "AI is stopped at the approval gate and needs approve / reject",
"writeObserved": "Write flags",
"writeObservedDetail": "Verify whether this is the expected auto-repair result",
"noEvidence": "Missing evidence",
"noEvidenceDetail": "The list row is not linked to ADR-100 dry-run history yet",
"approvalReadOnlyDetail": "Read-only remediation evidence is visible before approval",
"approvalNoEvidenceDetail": "Approval still lacks remediation dry-run evidence; inspect Run Timeline"
}
},
"runDetail": {
"back": "Back to Run Monitor",
"title": "Run Disposition Timeline",

View File

@@ -1797,6 +1797,39 @@
"timelineMissing": "品質總覽仍指出 Timeline / 稽核記錄缺口"
}
},
"listEvidence": {
"column": "AI 證據",
"count": "試跑 {count} 次",
"route": "MCP{route}",
"emptyShort": "尚未連到補救試跑",
"manualGate": "下一步:人工審批",
"statuses": {
"noEvidence": "尚無試跑",
"readOnlyDryRun": "AI 已試跑:只讀",
"writeObserved": "有寫入旗標",
"blocked": "試跑受阻",
"observed": "有補救證據"
},
"details": {
"noEvidence": "此列尚未從 alert_operation_log 連到 ADR-100 補救試跑。",
"readOnlyDryRun": "AI 已走補救試跑,且最新紀錄沒有寫入 incident 或 auto-repair 狀態。",
"writeObserved": "最新補救紀錄含寫入旗標,審批前需確認狀態變更來源。",
"blocked": "補救試跑未通過或被 gate 阻擋,需人工確認卡點。",
"observed": "此列已連到補救歷史,請進入 Run Timeline 查看完整證據。"
},
"summary": {
"readOnly": "只讀試跑",
"readOnlyDetail": "最新證據顯示 AI 已試跑且未寫狀態",
"manualGate": "人工閘門",
"manualGateDetail": "AI 已停在 approval gate需 approve / reject",
"writeObserved": "寫入旗標",
"writeObservedDetail": "需確認是否為預期自動修復結果",
"noEvidence": "缺補救證據",
"noEvidenceDetail": "列表尚未連到 ADR-100 dry-run history",
"approvalReadOnlyDetail": "審批前已有只讀補救證據可回看",
"approvalNoEvidenceDetail": "審批前仍缺補救試跑證據,需進 Run Timeline 檢查"
}
},
"runDetail": {
"back": "返回 Run 監控",
"title": "Run 處置脈絡",

View File

@@ -6,6 +6,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import {
ShieldCheck,
RefreshCw,
@@ -13,6 +14,7 @@ import {
Clock,
ArrowRight,
ListChecks,
SearchCheck,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -22,12 +24,31 @@ import { Link } from "@/i18n/routing";
// Types
// =============================================================================
type RemediationStatus =
| "no_evidence"
| "read_only_dry_run"
| "write_observed"
| "blocked"
| "observed";
interface RemediationSummary {
incident_ids?: string[];
total?: number;
status?: RemediationStatus | string;
human_gate_open?: boolean;
latest_route?: string | null;
latest_preview?: string | null;
writes_incident_state?: boolean | null;
writes_auto_repair_result?: boolean | null;
}
interface Approval {
run_id: string;
project_id: string;
agent_id: string;
created_at: string;
timeout_at: string | null;
remediation_summary?: RemediationSummary | null;
}
// =============================================================================
@@ -63,6 +84,97 @@ function formatRemaining(ms: number): string {
// Sub Components
// =============================================================================
const REMEDIATION_STATUS_CONFIG: Record<
RemediationStatus,
{
labelKey: string;
detailKey: string;
icon: typeof ShieldCheck;
className: string;
}
> = {
no_evidence: {
labelKey: "statuses.noEvidence",
detailKey: "details.noEvidence",
icon: AlertCircle,
className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
},
read_only_dry_run: {
labelKey: "statuses.readOnlyDryRun",
detailKey: "details.readOnlyDryRun",
icon: SearchCheck,
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
},
write_observed: {
labelKey: "statuses.writeObserved",
detailKey: "details.writeObserved",
icon: TriangleAlert,
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
},
blocked: {
labelKey: "statuses.blocked",
detailKey: "details.blocked",
icon: TriangleAlert,
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
observed: {
labelKey: "statuses.observed",
detailKey: "details.observed",
icon: ListChecks,
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
},
};
function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus {
const statusValue = summary?.status;
if (
statusValue === "read_only_dry_run" ||
statusValue === "write_observed" ||
statusValue === "blocked" ||
statusValue === "observed"
) {
return statusValue;
}
return "no_evidence";
}
function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) {
const t = useTranslations("awooop.listEvidence");
const status = normalizeRemediationStatus(summary);
const config = REMEDIATION_STATUS_CONFIG[status];
const Icon = config.icon;
const total = summary?.total ?? 0;
const route = summary?.latest_route && summary.latest_route !== "--"
? summary.latest_route
: null;
return (
<div className="flex min-w-[230px] flex-col gap-1">
<span
className={cn(
"inline-flex w-fit items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
config.className
)}
title={t(config.detailKey)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(config.labelKey)}
</span>
{total > 0 ? (
<span className="text-xs leading-5 text-[#5f5b52]">
{t("count", { count: total })}
{route ? ` · ${t("route", { route })}` : ""}
</span>
) : (
<span className="text-xs leading-5 text-[#77736a]">{t("emptyShort")}</span>
)}
<span className="text-xs font-semibold text-[#8a5a08]">
{t("manualGate")}
</span>
</div>
);
}
function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) {
const [remaining, setRemaining] = useState<number | null>(
getRemainingMs(timeoutAt)
@@ -151,6 +263,9 @@ function ApprovalRow({ approval }: { approval: Approval }) {
<td className="px-4 py-3">
<DecisionPostureBadge />
</td>
<td className="px-4 py-3">
<RemediationEvidenceCell summary={approval.remediation_summary} />
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground font-mono">
{formattedDate}
@@ -168,10 +283,11 @@ function ApprovalRow({ approval }: { approval: Approval }) {
// =============================================================================
export default function ApprovalsPage() {
const tEvidence = useTranslations("awooop.listEvidence");
const [approvals, setApprovals] = useState<Approval[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchApprovals = useCallback(async () => {
@@ -211,6 +327,12 @@ export default function ApprovalsPage() {
const ms = getRemainingMs(a.timeout_at);
return ms !== null && ms <= 0;
}).length;
const readOnlyEvidenceCount = approvals.filter(
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "read_only_dry_run"
).length;
const noEvidenceCount = approvals.filter(
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "no_evidence"
).length;
const queueSummary = useMemo(
() => [
{
@@ -235,14 +357,21 @@ export default function ApprovalsPage() {
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
{
label: "操作來源",
value: "Run",
detail: "審批必須回到 Operator Run",
icon: ListChecks,
label: tEvidence("summary.readOnly"),
value: readOnlyEvidenceCount,
detail: tEvidence("summary.approvalReadOnlyDetail"),
icon: SearchCheck,
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
},
{
label: tEvidence("summary.noEvidence"),
value: noEvidenceCount,
detail: tEvidence("summary.approvalNoEvidenceDetail"),
icon: AlertCircle,
className: "border-[#d8d3c7] bg-white text-[#5f5b52]",
},
],
[approvals.length, criticalCount, expiredCount]
[approvals.length, criticalCount, expiredCount, noEvidenceCount, readOnlyEvidenceCount, tEvidence]
);
return (
@@ -263,7 +392,9 @@ export default function ApprovalsPage() {
<p className="text-xs text-muted-foreground">
{loading
? "載入中..."
: `${approvals.length} 筆待審 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
: `${approvals.length} 筆待審 · 上次更新 ${
lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--"
}`}
</p>
</div>
</div>
@@ -281,7 +412,7 @@ export default function ApprovalsPage() {
</div>
</div>
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-5">
{queueSummary.map((item) => {
const Icon = item.icon;
return (
@@ -347,6 +478,9 @@ export default function ApprovalsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Lane
</th>
<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">
</th>
@@ -359,7 +493,7 @@ export default function ApprovalsPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 6 }).map((_, j) => (
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>

View File

@@ -6,6 +6,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import {
Activity,
@@ -39,6 +40,32 @@ type RunState =
| "timeout";
type RunLane = "intake" | "diagnosis" | "approval" | "execution" | "done" | "manual";
type RemediationStatus =
| "no_evidence"
| "read_only_dry_run"
| "write_observed"
| "blocked"
| "observed";
interface RemediationSummary {
schema_version?: string;
source?: string;
incident_ids?: string[];
total?: number;
status?: RemediationStatus | string;
has_dry_run?: boolean;
is_read_only?: boolean;
human_gate_open?: boolean;
latest_at?: string | null;
latest_preview?: string | null;
latest_mode?: string | null;
latest_route?: string | null;
latest_agent_id?: string | null;
latest_tool_name?: string | null;
latest_required_scope?: string | null;
writes_incident_state?: boolean | null;
writes_auto_repair_result?: boolean | null;
}
interface Run {
run_id: string;
@@ -49,6 +76,7 @@ interface Run {
cost_usd: number | string;
step_count: number;
created_at: string;
remediation_summary?: RemediationSummary | null;
}
interface Tenant {
@@ -191,6 +219,47 @@ const LANE_CONFIG: Record<
},
};
const REMEDIATION_STATUS_CONFIG: Record<
RemediationStatus,
{
labelKey: string;
detailKey: string;
icon: typeof Activity;
className: string;
}
> = {
no_evidence: {
labelKey: "statuses.noEvidence",
detailKey: "details.noEvidence",
icon: AlertCircle,
className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
},
read_only_dry_run: {
labelKey: "statuses.readOnlyDryRun",
detailKey: "details.readOnlyDryRun",
icon: SearchCheck,
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
},
write_observed: {
labelKey: "statuses.writeObserved",
detailKey: "details.writeObserved",
icon: TriangleAlert,
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
},
blocked: {
labelKey: "statuses.blocked",
detailKey: "details.blocked",
icon: TriangleAlert,
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
observed: {
labelKey: "statuses.observed",
detailKey: "details.observed",
icon: ListChecks,
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
},
};
function getRunLane(state: RunState): RunLane {
if (state === "pending") return "intake";
if (state === "waiting_tool") return "diagnosis";
@@ -250,6 +319,58 @@ function RunLaneBadge({ state }: { state: RunState }) {
);
}
function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus {
const statusValue = summary?.status;
if (
statusValue === "read_only_dry_run" ||
statusValue === "write_observed" ||
statusValue === "blocked" ||
statusValue === "observed"
) {
return statusValue;
}
return "no_evidence";
}
function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) {
const t = useTranslations("awooop.listEvidence");
const status = normalizeRemediationStatus(summary);
const config = REMEDIATION_STATUS_CONFIG[status];
const Icon = config.icon;
const total = summary?.total ?? 0;
const route = summary?.latest_route && summary.latest_route !== "--"
? summary.latest_route
: null;
return (
<div className="flex min-w-[230px] flex-col gap-1">
<span
className={cn(
"inline-flex w-fit items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
config.className
)}
title={t(config.detailKey)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(config.labelKey)}
</span>
{total > 0 ? (
<span className="text-xs leading-5 text-[#5f5b52]">
{t("count", { count: total })}
{route ? ` · ${t("route", { route })}` : ""}
</span>
) : (
<span className="text-xs leading-5 text-[#77736a]">{t("emptyShort")}</span>
)}
{summary?.human_gate_open && (
<span className="text-xs font-semibold text-[#8a5a08]">
{t("manualGate")}
</span>
)}
</div>
);
}
function RunRow({ run }: { run: Run }) {
const formattedDate = run.created_at
? new Date(run.created_at).toLocaleDateString("zh-TW", {
@@ -288,6 +409,9 @@ function RunRow({ run }: { run: Run }) {
<td className="px-4 py-3">
<RunLaneBadge state={run.state} />
</td>
<td className="px-4 py-3">
<RemediationEvidenceCell summary={run.remediation_summary} />
</td>
<td className="px-4 py-3">
<ShadowBadge isShadow={run.is_shadow} />
</td>
@@ -377,6 +501,7 @@ function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) {
// =============================================================================
export default function RunsPage() {
const tEvidence = useTranslations("awooop.listEvidence");
const [runs, setRuns] = useState<Run[]>([]);
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -386,7 +511,7 @@ export default function RunsPage() {
const [projectFilter, setProjectFilter] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [page, setPage] = useState(1);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 取得租戶清單
@@ -471,6 +596,20 @@ export default function RunsPage() {
});
return counts;
}, [runs]);
const evidenceSummary = useMemo(() => {
return {
readOnly: runs.filter(
(run) => normalizeRemediationStatus(run.remediation_summary) === "read_only_dry_run"
).length,
writeObserved: runs.filter(
(run) => normalizeRemediationStatus(run.remediation_summary) === "write_observed"
).length,
noEvidence: runs.filter(
(run) => normalizeRemediationStatus(run.remediation_summary) === "no_evidence"
).length,
manualGate: runs.filter((run) => run.remediation_summary?.human_gate_open).length,
};
}, [runs]);
return (
<div className="space-y-6">
@@ -483,7 +622,9 @@ export default function RunsPage() {
<p className="text-xs text-muted-foreground">
{loading
? "載入中..."
: `${total} 筆 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
: `${total} 筆 · 上次更新 ${
lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--"
}`}
</p>
</div>
</div>
@@ -522,6 +663,62 @@ export default function RunsPage() {
})}
</section>
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
{[
{
label: tEvidence("summary.readOnly"),
value: evidenceSummary.readOnly,
detail: tEvidence("summary.readOnlyDetail"),
icon: SearchCheck,
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
},
{
label: tEvidence("summary.manualGate"),
value: evidenceSummary.manualGate,
detail: tEvidence("summary.manualGateDetail"),
icon: ShieldCheck,
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
},
{
label: tEvidence("summary.writeObserved"),
value: evidenceSummary.writeObserved,
detail: tEvidence("summary.writeObservedDetail"),
icon: TriangleAlert,
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
{
label: tEvidence("summary.noEvidence"),
value: evidenceSummary.noEvidence,
detail: tEvidence("summary.noEvidenceDetail"),
icon: AlertCircle,
className: "border-[#d8d3c7] bg-white text-[#5f5b52]",
},
].map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value}
</div>
</div>
<span
className={cn(
"flex h-8 w-8 items-center justify-center border",
item.className
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
</div>
);
})}
</section>
<GroupedAlertEventsPanel events={groupedEvents} />
{/* Filters */}
@@ -598,6 +795,9 @@ export default function RunsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Lane
</th>
<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">
Shadow
</th>
@@ -613,7 +813,7 @@ export default function RunsPage() {
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 8 }).map((_, j) => (
{Array.from({ length: 9 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>