feat(web): add approvals decision handoff rail
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 2m1s
CD Pipeline / build-and-deploy (push) Successful in 3m45s
CD Pipeline / post-deploy-checks (push) Successful in 1m56s

This commit is contained in:
Your Name
2026-06-25 18:38:55 +08:00
parent 856fbcddb9
commit 01a8e9d3e5
3 changed files with 498 additions and 0 deletions

View File

@@ -10567,6 +10567,88 @@
"expired": "已逾時",
"expiredDetail": "不得再自動恢復"
},
"decisionRail": {
"eyebrow": "審批決策 Rail",
"title": "卡住的批准與人工接手判讀",
"subtitle": "先把狀態鏈、MCP 證據、Gate 5 投影與 Legacy HITL 收斂成一個判讀面板;使用者先看卡點與下一個安全入口,再下鑽完整表格。",
"boundary": "此面板只做審批判讀與安全導覽;不送 Telegram、不套用 PlayBook、不執行 Ansible、不重啟服務、不切換供應者也不代表 runtime gate 已開。",
"conclusion": {
"blocked": "有卡點需接手",
"degraded": "資料載入需復核",
"watching": "等待人工決策",
"clear": "目前無待審"
},
"status": {
"loading": "資料刷新",
"runtimeGate": "runtime gate",
"yes": "讀取中",
"no": "穩定"
},
"flow": {
"request": {
"title": "請求",
"detail": "AwoooP 與 Legacy 待決策總量"
},
"evidence": {
"title": "證據",
"detail": "MCP 或 read-only dry-run 已接上"
},
"decision": {
"title": "決策",
"detail": "需要人工或已逾時的審批"
},
"handoff": {
"title": "接手",
"detail": "Gate 5、Legacy 與工作項接手"
},
"verifier": {
"title": "驗證",
"detail": "失敗、降級或需 rollback 的結果"
}
},
"cards": {
"stuck": {
"title": "阻塞與人工閘門",
"detail": "找出 learning_recorded、execution_failed、manual fix 或逾時的審批。",
"cta": "查看卡點",
"meta": {
"needsHuman": "需要人工",
"executionFailed": "執行失敗 / 降級",
"learningRecorded": "卡在學習紀錄"
}
},
"evidence": {
"title": "AI 證據可用度",
"detail": "分開 MCP、唯讀試跑與仍缺證據的列避免把心跳誤判為已處理。",
"cta": "查看證據",
"meta": {
"mcp": "MCP 已觀測",
"dryRun": "唯讀試跑",
"missing": "仍缺證據"
}
},
"handoff": {
"title": "接手包與工作項",
"detail": "把 Gate 5 投影、Legacy HITL 與人工接手導回 Work Items。",
"cta": "查看工作項",
"meta": {
"gate5": "Gate 5 投影",
"legacy": "Legacy HITL",
"manual": "人工接手"
}
},
"guardrail": {
"title": "安全閘門仍關閉",
"detail": "批准頁不等於執行頁;所有高風險動作仍需獨立 owner 與 verifier。",
"cta": "查看治理",
"meta": {
"runtimeGate": "runtime gate",
"unsafeActions": "危險操作入口",
"providerSwitch": "供應者切換"
}
}
}
},
"badges": {
"humanGate": "人工閘門",
"gate5Projection": "Gate 5 投影",

View File

@@ -10567,6 +10567,88 @@
"expired": "已逾時",
"expiredDetail": "不得再自動恢復"
},
"decisionRail": {
"eyebrow": "審批決策 Rail",
"title": "卡住的批准與人工接手判讀",
"subtitle": "先把狀態鏈、MCP 證據、Gate 5 投影與 Legacy HITL 收斂成一個判讀面板;使用者先看卡點與下一個安全入口,再下鑽完整表格。",
"boundary": "此面板只做審批判讀與安全導覽;不送 Telegram、不套用 PlayBook、不執行 Ansible、不重啟服務、不切換供應者也不代表 runtime gate 已開。",
"conclusion": {
"blocked": "有卡點需接手",
"degraded": "資料載入需復核",
"watching": "等待人工決策",
"clear": "目前無待審"
},
"status": {
"loading": "資料刷新",
"runtimeGate": "runtime gate",
"yes": "讀取中",
"no": "穩定"
},
"flow": {
"request": {
"title": "請求",
"detail": "AwoooP 與 Legacy 待決策總量"
},
"evidence": {
"title": "證據",
"detail": "MCP 或 read-only dry-run 已接上"
},
"decision": {
"title": "決策",
"detail": "需要人工或已逾時的審批"
},
"handoff": {
"title": "接手",
"detail": "Gate 5、Legacy 與工作項接手"
},
"verifier": {
"title": "驗證",
"detail": "失敗、降級或需 rollback 的結果"
}
},
"cards": {
"stuck": {
"title": "阻塞與人工閘門",
"detail": "找出 learning_recorded、execution_failed、manual fix 或逾時的審批。",
"cta": "查看卡點",
"meta": {
"needsHuman": "需要人工",
"executionFailed": "執行失敗 / 降級",
"learningRecorded": "卡在學習紀錄"
}
},
"evidence": {
"title": "AI 證據可用度",
"detail": "分開 MCP、唯讀試跑與仍缺證據的列避免把心跳誤判為已處理。",
"cta": "查看證據",
"meta": {
"mcp": "MCP 已觀測",
"dryRun": "唯讀試跑",
"missing": "仍缺證據"
}
},
"handoff": {
"title": "接手包與工作項",
"detail": "把 Gate 5 投影、Legacy HITL 與人工接手導回 Work Items。",
"cta": "查看工作項",
"meta": {
"gate5": "Gate 5 投影",
"legacy": "Legacy HITL",
"manual": "人工接手"
}
},
"guardrail": {
"title": "安全閘門仍關閉",
"detail": "批准頁不等於執行頁;所有高風險動作仍需獨立 owner 與 verifier。",
"cta": "查看治理",
"meta": {
"runtimeGate": "runtime gate",
"unsafeActions": "危險操作入口",
"providerSwitch": "供應者切換"
}
}
}
},
"badges": {
"humanGate": "人工閘門",
"gate5Projection": "Gate 5 投影",

View File

@@ -50,6 +50,19 @@ type RemediationStatus =
| "blocked"
| "observed";
type ApprovalDecisionRailTone = "ok" | "warning" | "blocked" | "watching";
type ApprovalDecisionRailCardKey = "stuck" | "evidence" | "handoff" | "guardrail";
interface ApprovalDecisionRailCard {
key: ApprovalDecisionRailCardKey;
value: number;
tone: ApprovalDecisionRailTone;
icon: typeof ShieldCheck;
href: string;
meta: Record<string, number>;
}
interface RemediationSummary {
incident_ids?: string[];
total?: number;
@@ -250,6 +263,87 @@ function normalizeRemediationStatus(summary?: RemediationSummary | null): Remedi
return "no_evidence";
}
function lowerValue(value: unknown): string {
return String(value ?? "").toLowerCase();
}
function chainValues(approval: Approval): string[] {
const chain = approval.awooop_status_chain;
const outcome = chain?.operator_outcome;
const executionResult = outcome?.execution_result;
return [
chain?.current_stage,
chain?.stage_status,
chain?.verdict,
chain?.repair_state,
chain?.verification,
chain?.next_step,
outcome?.state,
outcome?.severity,
outcome?.summary_zh,
outcome?.human_action_reason,
outcome?.next_action,
executionResult?.approval_status,
executionResult?.completion_status,
executionResult?.command_status,
executionResult?.repair_status,
executionResult?.failure_status,
executionResult?.summary_zh,
]
.filter((value): value is string => Boolean(value))
.map(lowerValue);
}
function approvalNeedsHuman(approval: Approval): boolean {
const chain = approval.awooop_status_chain;
const outcome = chain?.operator_outcome;
return Boolean(
chain?.needs_human ||
outcome?.needs_human ||
outcome?.human_action_required ||
approval.remediation_summary?.human_gate_open
);
}
function approvalHasExecutionFailure(approval: Approval): boolean {
const values = chainValues(approval);
return values.some((value) =>
["execution_failed", "manual_fix_or_rollback", "failed", "degraded", "error", "timeout"].some(
(needle) => value.includes(needle)
)
);
}
function approvalHasLearningRecorded(approval: Approval): boolean {
return chainValues(approval).some((value) => value.includes("learning_recorded"));
}
function approvalIsExpired(approval: Approval): boolean {
const remainingMs = getRemainingMs(approval.timeout_at);
return remainingMs !== null && remainingMs <= 0;
}
function approvalIsGate5Projection(approval: Approval): boolean {
return approval.trigger_type === "adr100_runtime_replay_gate5";
}
function approvalHref(approval: Approval): string {
return `/awooop/approvals/${approval.run_id}`;
}
function uniqueApprovalCount(groups: Approval[][]): number {
const ids = new Set<string>();
groups.forEach((group) => group.forEach((approval) => ids.add(approval.run_id)));
return ids.size;
}
function firstApproval(groups: Approval[][]): Approval | null {
for (const group of groups) {
if (group.length > 0) return group[0];
}
return null;
}
function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) {
const t = useTranslations("awooop.listEvidence");
const status = normalizeRemediationStatus(summary);
@@ -539,6 +633,237 @@ function Gate5ProjectionBadge() {
);
}
function approvalDecisionRailToneClass(tone: ApprovalDecisionRailTone) {
if (tone === "ok") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
if (tone === "warning") return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
return "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
}
function ApprovalDecisionRail({
approvals,
legacyApprovals,
projectId,
loading,
error,
legacyError,
}: {
approvals: Approval[];
legacyApprovals: LegacyApproval[];
projectId: string;
loading: boolean;
error: string | null;
legacyError: string | null;
}) {
const t = useTranslations("awooop.approvals.decisionRail");
const projectQuery = encodeURIComponent(projectId);
const needsHuman = approvals.filter(approvalNeedsHuman);
const executionFailed = approvals.filter(approvalHasExecutionFailure);
const learningRecorded = approvals.filter(approvalHasLearningRecorded);
const expired = approvals.filter(approvalIsExpired);
const gate5 = approvals.filter(approvalIsGate5Projection);
const mcpObserved = approvals.filter(
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "mcp_observed"
);
const readOnly = approvals.filter(
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "read_only_dry_run"
);
const noEvidence = approvals.filter(
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "no_evidence"
);
const firstStuck = firstApproval([needsHuman, executionFailed, learningRecorded, expired]);
const stuckCount = uniqueApprovalCount([needsHuman, executionFailed, learningRecorded, expired]);
const evidenceCount = uniqueApprovalCount([mcpObserved, readOnly, noEvidence]);
const handoffCount = uniqueApprovalCount([needsHuman, gate5]) + legacyApprovals.length;
const hasLoadIssue = Boolean(error || legacyError);
const conclusionKey = stuckCount > 0
? "blocked"
: hasLoadIssue
? "degraded"
: approvals.length + legacyApprovals.length > 0
? "watching"
: "clear";
const cards: ApprovalDecisionRailCard[] = [
{
key: "stuck",
value: stuckCount,
tone: stuckCount > 0 ? "blocked" : "ok",
icon: TriangleAlert,
href: firstStuck ? approvalHref(firstStuck) : `/awooop/approvals?project_id=${projectQuery}`,
meta: {
needsHuman: needsHuman.length,
executionFailed: executionFailed.length,
learningRecorded: learningRecorded.length,
},
},
{
key: "evidence",
value: evidenceCount,
tone: noEvidence.length > 0 ? "warning" : evidenceCount > 0 ? "watching" : "ok",
icon: SearchCheck,
href: `/awooop/runs?project_id=${projectQuery}`,
meta: {
mcp: mcpObserved.length,
dryRun: readOnly.length,
missing: noEvidence.length,
},
},
{
key: "handoff",
value: handoffCount,
tone: handoffCount > 0 ? "warning" : "ok",
icon: GitBranch,
href: `/awooop/work-items?project_id=${projectQuery}`,
meta: {
gate5: gate5.length,
legacy: legacyApprovals.length,
manual: needsHuman.length,
},
},
{
key: "guardrail",
value: 0,
tone: "ok",
icon: ShieldCheck,
href: "/governance?tab=automation-inventory",
meta: {
runtimeGate: 0,
unsafeActions: 0,
providerSwitch: 0,
},
},
];
const flow = [
{ key: "request", value: approvals.length + legacyApprovals.length },
{ key: "evidence", value: mcpObserved.length + readOnly.length },
{ key: "decision", value: needsHuman.length + expired.length },
{ key: "handoff", value: gate5.length + legacyApprovals.length },
{ key: "verifier", value: executionFailed.length },
];
return (
<section
data-testid="awooop-approvals-decision-rail"
className="border border-[#d8d3c7] bg-white"
>
<div className="grid gap-px bg-[#d8d3c7] xl:grid-cols-[1.05fr_0.95fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#d97757]">
{t("eyebrow")}
</p>
<h3 className="mt-2 text-xl font-semibold text-[#141413]">
{t("title")}
</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#5f5b52]">
{t("subtitle")}
</p>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-2 border px-3 py-1.5 text-xs font-semibold",
conclusionKey === "blocked"
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: conclusionKey === "degraded"
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
)}
>
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{t(`conclusion.${conclusionKey}`)}
</span>
</div>
<div className="mt-4 grid gap-px overflow-hidden border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-5">
{flow.map((item, index) => (
<div key={item.key} className="min-w-0 bg-[#faf9f3] p-3">
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs font-semibold text-[#77736a]">
{String(index + 1).padStart(2, "0")}
</span>
<span className="font-mono text-lg font-semibold text-[#141413]">
{item.value}
</span>
</div>
<p className="mt-2 text-sm font-semibold text-[#141413]">
{t(`flow.${item.key}.title`)}
</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">
{t(`flow.${item.key}.detail`)}
</p>
</div>
))}
</div>
</div>
<div className="min-w-0 bg-[#faf9f3] p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="border border-[#e0ddd4] bg-white p-3">
<p className="text-xs font-semibold text-[#77736a]">{t("status.loading")}</p>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
{loading ? t("status.yes") : t("status.no")}
</p>
</div>
<div className="border border-[#e0ddd4] bg-white p-3">
<p className="text-xs font-semibold text-[#77736a]">{t("status.runtimeGate")}</p>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">0</p>
</div>
</div>
<p className="mt-3 border border-[#d9b36f] bg-white px-3 py-2 text-xs leading-5 text-[#8a5a08]">
{t("boundary")}
</p>
</div>
</div>
<div className="grid gap-px bg-[#d8d3c7] md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => {
const Icon = card.icon;
const toneClass = approvalDecisionRailToneClass(card.tone);
return (
<Link
key={card.key}
href={card.href}
className="group min-w-0 bg-white p-4 transition hover:bg-[#faf9f3]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[#141413]">
{t(`cards.${card.key}.title`)}
</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">
{t(`cards.${card.key}.detail`)}
</p>
</div>
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center border", toneClass)}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<span className="font-mono text-3xl font-semibold text-[#141413]">
{card.value}
</span>
<span className="inline-flex items-center gap-1 text-xs font-semibold text-[#d97757]">
{t(`cards.${card.key}.cta`)}
<ArrowRight className="h-3.5 w-3.5 transition group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
<div className="mt-4 grid gap-2 text-xs leading-5 text-[#5f5b52]">
{Object.entries(card.meta).map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-3 border-t border-[#eee9dd] pt-2">
<span className="min-w-0 break-words">{t(`cards.${card.key}.meta.${key}`)}</span>
<span className="font-mono font-semibold text-[#141413]">{value}</span>
</div>
))}
</div>
</Link>
);
})}
</div>
</section>
);
}
function ApprovalRow({ approval }: { approval: Approval }) {
const formattedDate = approval.created_at
? new Date(approval.created_at).toLocaleDateString("zh-TW", {
@@ -1439,6 +1764,15 @@ export default function ApprovalsPage() {
})}
</section>
<ApprovalDecisionRail
approvals={approvals}
legacyApprovals={legacyApprovals}
projectId={projectId}
loading={loading}
error={error}
legacyError={legacyError}
/>
{queryIncidentId ? (
<FocusedIncidentApprovalPanel
projectId={projectId}