feat(web): add approvals decision handoff rail
This commit is contained in:
@@ -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 投影",
|
||||
|
||||
@@ -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 投影",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user