From 01a8e9d3e59d905db65d6b16ffc38cb265524905 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Jun 2026 18:38:55 +0800 Subject: [PATCH] feat(web): add approvals decision handoff rail --- apps/web/messages/en.json | 82 +++++ apps/web/messages/zh-TW.json | 82 +++++ .../app/[locale]/awooop/approvals/page.tsx | 334 ++++++++++++++++++ 3 files changed, 498 insertions(+) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index de6e163c..93239d2f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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 投影", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index de6e163c..93239d2f 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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 投影", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 3bfa8a72..547bf3a4 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -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; +} + 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(); + 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 ( +
+
+
+
+
+

+ {t("eyebrow")} +

+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+ + +
+ +
+ {flow.map((item, index) => ( +
+
+ + {String(index + 1).padStart(2, "0")} + + + {item.value} + +
+

+ {t(`flow.${item.key}.title`)} +

+

+ {t(`flow.${item.key}.detail`)} +

+
+ ))} +
+
+ +
+
+
+

{t("status.loading")}

+

+ {loading ? t("status.yes") : t("status.no")} +

+
+
+

{t("status.runtimeGate")}

+

0

+
+
+

+ {t("boundary")} +

+
+
+ +
+ {cards.map((card) => { + const Icon = card.icon; + const toneClass = approvalDecisionRailToneClass(card.tone); + return ( + +
+
+

+ {t(`cards.${card.key}.title`)} +

+

+ {t(`cards.${card.key}.detail`)} +

+
+ + +
+
+ + {card.value} + + + {t(`cards.${card.key}.cta`)} + +
+
+ {Object.entries(card.meta).map(([key, value]) => ( +
+ {t(`cards.${card.key}.meta.${key}`)} + {value} +
+ ))} +
+ + ); + })} +
+
+ ); +} + 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() { })} + + {queryIncidentId ? (