feat(awooop): 顯示修復候選升級合約
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m44s
CD Pipeline / build-and-deploy (push) Successful in 4m50s
CD Pipeline / post-deploy-checks (push) Successful in 1m40s

This commit is contained in:
Your Name
2026-06-26 12:03:58 +08:00
parent 4b18a3d8c0
commit 0fec19c707
6 changed files with 477 additions and 1 deletions

View File

@@ -1102,6 +1102,25 @@ function laneToneFromCounts(ready: number, total: number, blocked: number): Work
return "watching";
}
function promotionValueText(value: unknown): string {
if (Array.isArray(value)) {
return value.map((item) => String(item)).filter(Boolean).slice(0, 3).join(" / ") || "--";
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>)
.map((item) => String(item))
.filter(Boolean)
.slice(0, 3)
.join(" / ") || "--";
}
return String(value ?? "--");
}
function promotionStatusTone(status: string | null | undefined): WorkStatus {
if (String(status ?? "").toLowerCase() === "ready") return "live";
return "blocked";
}
function buildAutomationBlockerLanes(
telemetry: Telemetry,
focusedDraft: RepairCandidateDraftFocus | null
@@ -1444,6 +1463,13 @@ const REPAIR_CANDIDATE_DRAFT_BLOCKED_OPERATIONS = [
"generic_fallback_repair",
] as const;
const REPAIR_CANDIDATE_PROMOTION_FOCUS_FIELDS = [
"route_id",
"repair_command_template",
"rollback_command_template",
"verifier_plan",
] as const;
const statusConfig: Record<WorkStatus, { className: string; icon: typeof Activity }> = {
live: {
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
@@ -4122,6 +4148,53 @@ function RepairCandidateDraftPanel({
}) {
const t = useTranslations("awooop.workItems.repairCandidateDraft");
if (!draft) return null;
const promotion = chain?.repair_candidate_promotion;
const promotionContract = promotion?.contract ?? null;
const promotionAvailable = Boolean(promotion?.available && promotionContract);
const promotionReady = toCount(promotionContract?.ready_count);
const promotionTotal = toCount(promotionContract?.total_count);
const promotionBlocked = toCount(promotionContract?.blocked_count);
const promotionPercent = promotionTotal > 0
? Math.min(100, Math.round((promotionReady / promotionTotal) * 100))
: 0;
const promotionFields = promotionContract?.fields ?? [];
const promotionFieldMap = new Map(
promotionFields
.filter((field) => field.field)
.map((field) => [String(field.field), field])
);
const promotionLabels: Record<string, string> = {
target_selector: t("promotion.fields.target_selector"),
mcp_evidence_refs: t("promotion.fields.mcp_evidence_refs"),
route_id: t("promotion.fields.route_id"),
repair_command_template: t("promotion.fields.repair_command_template"),
rollback_command_template: t("promotion.fields.rollback_command_template"),
verifier_plan: t("promotion.fields.verifier_plan"),
owner_review: t("promotion.fields.owner_review"),
maintenance_window: t("promotion.fields.maintenance_window"),
blast_radius: t("promotion.fields.blast_radius"),
km_writeback_owner: t("promotion.fields.km_writeback_owner"),
playbook_trust_owner: t("promotion.fields.playbook_trust_owner"),
};
const promotionFocusRows = REPAIR_CANDIDATE_PROMOTION_FOCUS_FIELDS.map((field) => {
const row = promotionFieldMap.get(field);
return {
field,
label: promotionLabels[field] ?? row?.label ?? field,
status: row?.status ?? "blocked",
value: row?.value,
source: row?.source ?? "--",
};
});
const promotionBlockedFields = (
promotionContract?.blocked_fields?.length
? promotionContract.blocked_fields
: promotionFields
.filter((field) => String(field.status ?? "").toLowerCase() !== "ready")
.map((field) => field.field)
)
.filter((field): field is string => Boolean(field))
.slice(0, 8);
const runsHref = draft.incidentId
? `/awooop/runs?project_id=${encodeURIComponent(draft.projectId)}&incident_id=${encodeURIComponent(draft.incidentId)}`
@@ -4195,6 +4268,116 @@ function RepairCandidateDraftPanel({
))}
</div>
<div className="mt-4 border border-[#e0ddd4] bg-[#faf9f3]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-white px-3 py-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<div>
<h4 className="text-sm font-semibold text-[#141413]">{t("promotion.title")}</h4>
<p className="mt-1 text-[11px] leading-4 text-[#77736a]">
{t("promotion.subtitle")}
</p>
</div>
</div>
<span className="border border-[#d9b36f] bg-[#fff7e8] px-2 py-1 font-mono text-xs font-semibold text-[#8a5a08]">
{promotionAvailable
? t("promotion.progress", {
ready: promotionReady,
total: promotionTotal,
blocked: promotionBlocked,
})
: t("promotion.missingBadge")}
</span>
</div>
{promotionAvailable ? (
<div className="p-3">
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("promotion.metrics.readiness")}</p>
<p className="mt-1 font-mono text-2xl font-semibold text-[#141413]">{promotionPercent}%</p>
</div>
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("promotion.metrics.route")}</p>
<p className="mt-1 break-all font-mono text-xs font-semibold text-[#141413]">
{promotionContract?.route_id ?? "--"}
</p>
</div>
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("promotion.metrics.status")}</p>
<p className="mt-1 break-words font-mono text-xs font-semibold text-[#141413]">
{promotion?.status ?? promotionContract?.status ?? "--"}
</p>
</div>
<div className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("promotion.metrics.runtime")}</p>
<p className="mt-1 font-mono text-xs font-semibold text-[#9f2f25]">
{t("promotion.runtimeClosed")}
</p>
</div>
</div>
<div className="mt-3 h-2 border border-[#d8d3c7] bg-white">
<div
className="h-full bg-[#d97757]"
style={{ width: `${promotionPercent}%` }}
aria-hidden="true"
/>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-4">
{promotionFocusRows.map((row) => {
const tone = promotionStatusTone(row.status);
const Icon = statusConfig[tone].icon;
return (
<div key={row.field} className="min-w-0 border border-[#e0ddd4] bg-white px-3 py-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#141413]">{row.label}</p>
<p className="mt-1 break-all font-mono text-[11px] leading-4 text-[#5f5b52]">
{promotionValueText(row.value)}
</p>
</div>
<span className={cn("inline-flex shrink-0 items-center gap-1 border px-1.5 py-0.5 font-mono text-[10px] font-semibold", statusConfig[tone].className)}>
<Icon className="h-3 w-3" aria-hidden="true" />
{row.status}
</span>
</div>
</div>
);
})}
</div>
<div className="mt-3 border border-[#e2a29b] bg-[#fff0ef] px-3 py-2">
<p className="text-xs font-semibold text-[#9f2f25]">{t("promotion.blockedTitle")}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{promotionBlockedFields.map((field) => (
<span key={field} className="border border-[#e2a29b] bg-white px-2 py-0.5 font-mono text-[10px] font-semibold text-[#9f2f25]">
{promotionLabels[field] ?? field}
</span>
))}
{promotionBlockedFields.length === 0 ? (
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5 font-mono text-[10px] font-semibold text-[#5f5b52]">
{t("promotion.noBlockedFields")}
</span>
) : null}
</div>
</div>
</div>
) : (
<div className="p-3">
<div className="border border-[#d9b36f] bg-[#fff7e8] px-3 py-2">
<p className="text-xs font-semibold text-[#8a5a08]">{t("promotion.missingTitle")}</p>
<p className="mt-1 text-[11px] leading-5 text-[#5f5b52]">
{t("promotion.missingDetail", {
reason: promotion?.reason ?? "repair_candidate_promotion_contract_not_found",
})}
</p>
</div>
</div>
)}
</div>
<div className="mt-4 border border-[#e0ddd4] bg-[#faf9f3] p-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-brand-accent" aria-hidden="true" />

View File

@@ -314,6 +314,52 @@ export interface AwoooPStatusChain {
owner_review_checklist?: string[];
forbidden_actions?: string[];
} | null;
repair_candidate_promotion?: {
schema_version?: string | null;
status?: string | null;
source?: string | null;
available?: boolean | null;
reason?: string | null;
approval_id?: string | null;
work_item_id?: string | null;
work_item_url?: string | null;
summary?: string | null;
runtime_execution_authorized?: boolean | null;
runtime_write_allowed?: boolean | null;
contract?: {
schema_version?: string | null;
status?: string | null;
lane?: string | null;
incident_id?: string | null;
project_id?: string | null;
source_work_item_id?: string | null;
source_work_item_url?: string | null;
route_id?: string | null;
repair_command_template?: string | null;
rollback_command_template?: string | null;
verifier_plan_template?: string[];
ready_count?: number | null;
total_count?: number | null;
blocked_count?: number | null;
ready_fields?: string[];
blocked_fields?: string[];
fields?: Array<{
field?: string | null;
label?: string | null;
status?: string | null;
source?: string | null;
value?: unknown;
runtime_execution_authorized?: boolean | null;
}>;
blockers?: string[];
runtime_write_allowed?: boolean | null;
runtime_execution_authorized?: boolean | null;
approval_required_before_execution?: boolean | null;
owner_review_required?: boolean | null;
forbidden_until_promoted?: string[];
next_steps?: string[];
} | null;
} | null;
source_refs?: {
inbound_total?: number | null;
outbound_total?: number | null;