diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c86fc91a..a3783e09 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -11558,6 +11558,26 @@ "nextAction": "下一步", "reason": "原因" }, + "executorHandoff": { + "title": "Executor handoff readiness", + "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。", + "runtime": { + "open": "runtime gate open", + "closed": "runtime gate closed" + }, + "metrics": { + "readiness": "可交接度", + "ready": "已備妥", + "blocked": "卡點", + "status": "狀態" + }, + "nextAction": "下一步", + "blocker": "阻擋原因", + "missingTitle": "缺少的 owner review / 安全路由欄位", + "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準", + "openWorkItem": "開啟 owner review", + "openRuns": "追蹤 Runs" + }, "evidence": { "executor": "執行器", "ansible": "Ansible", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index c86fc91a..a3783e09 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -11558,6 +11558,26 @@ "nextAction": "下一步", "reason": "原因" }, + "executorHandoff": { + "title": "Executor handoff readiness", + "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。", + "runtime": { + "open": "runtime gate open", + "closed": "runtime gate closed" + }, + "metrics": { + "readiness": "可交接度", + "ready": "已備妥", + "blocked": "卡點", + "status": "狀態" + }, + "nextAction": "下一步", + "blocker": "阻擋原因", + "missingTitle": "缺少的 owner review / 安全路由欄位", + "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準", + "openWorkItem": "開啟 owner review", + "openRuns": "追蹤 Runs" + }, "evidence": { "executor": "執行器", "ansible": "Ansible", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 547bf3a4..2089705c 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -633,6 +633,159 @@ function Gate5ProjectionBadge() { ); } +function handoffWorkItemHref( + projectId: string, + incidentId: string, + chain: AwoooPStatusChain | null +) { + const promotion = chain?.repair_candidate_promotion; + const href = promotion?.work_item_url + ?? promotion?.contract?.source_work_item_url + ?? null; + if (href && href.startsWith("/")) return href; + + const workItemId = promotion?.work_item_id + ?? promotion?.contract?.source_work_item_id + ?? chain?.automation_handoff?.work_item_id + ?? ""; + const params = new URLSearchParams({ project_id: projectId, incident_id: incidentId }); + if (workItemId) params.set("work_item_id", workItemId); + return `/awooop/work-items?${params.toString()}`; +} + +function ExecutorHandoffReadinessCard({ + projectId, + incidentId, + chain, +}: { + projectId: string; + incidentId: string; + chain: AwoooPStatusChain | null; +}) { + const t = useTranslations("awooop.approvals.incidentFocus.executorHandoff"); + const promotion = chain?.repair_candidate_promotion; + const contract = promotion?.contract; + const closure = chain?.automation_handoff?.closure_readiness; + const runtimeAllowed = Boolean( + promotion?.runtime_execution_authorized + || contract?.runtime_execution_authorized + || closure?.runtime_execution_authorized + || chain?.automation_handoff?.runtime_execution_authorized + ); + const ready = Number(contract?.ready_count ?? closure?.ready_count ?? 0); + const total = Number(contract?.total_count ?? closure?.total_count ?? 0); + const blocked = Number(contract?.blocked_count ?? closure?.blocked_count ?? 0); + const readinessPercent = total > 0 ? Math.min(100, Math.round((ready / total) * 100)) : 0; + const status = promotion?.status ?? contract?.status ?? closure?.status ?? chain?.automation_handoff?.status ?? "not_available"; + const nextAction = chain?.automation_handoff?.next_action + ?? closure?.next_action + ?? promotion?.summary + ?? chain?.operator_outcome?.next_action + ?? chain?.next_step + ?? "--"; + const blocker = closure?.blocked_reason + ?? promotion?.reason + ?? chain?.operator_outcome?.human_action_reason + ?? chain?.blockers?.[0] + ?? "--"; + const blockedFields = [ + ...(contract?.blocked_fields ?? []), + ...(closure?.required_owner_fields ?? []), + ].filter((field, index, fields) => field && fields.indexOf(field) === index).slice(0, 8); + const workItemHref = handoffWorkItemHref(projectId, incidentId, chain); + const runsHref = `/awooop/runs?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(incidentId)}`; + + return ( +
{t("subtitle")}
+{label}
++ {value} +
+{t("nextAction")}
+{nextAction}
+{t("blocker")}
+{blocker}
+{t("missingTitle")}
+{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}
+