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

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