# ADR-UI-04: Approval Queue UI

**狀態**：Accepted
**日期**：2026-05-03（台北）
**決策者**：統帥
**範圍**：M7 Approval Queue + M8 Approval Decision 的詳細設計
**關聯**：ADR-UI-01（架構）、ADR-114（WAITING_APPROVAL state）、ADR-116（approval_token）

---

## M7 — Approval Queue

### 頁面設計

**路由**：`/awooop/approvals`

**即時更新（SSE）— 重要：approval 過期倒數**

```
┌────────────────────────────────────────────────────────────────────────────┐
│ Approval Queue                                         🔵 3 pending        │
├──────────────┬────────────────────────────┬────────────────┬──────────────┤
│ Run ID       │ Request Summary            │ Risk Level     │ Expires in   │
├──────────────┼────────────────────────────┼────────────────┼──────────────┤
│ abc123...    │ k8s_exec_pod(api, restart) │ 🔴 HIGH        │ 12:34 ⏱️    │
│ def456...    │ k8s_scale_deployment(3→1)  │ 🟡 MEDIUM      │ 06:12 ⏱️    │
│ ghi789...    │ pg_mutate(DELETE incidents) │ 🔴 HIGH        │ 02:01 ⚠️    │
└──────────────┴────────────────────────────┴────────────────┴──────────────┘
```

**倒數計時顏色**：
- > 10 分鐘：灰色
- 5-10 分鐘：⏱️ 橙色
- < 5 分鐘：⚠️ 紅色閃爍
- 過期：❌ 已過期（不可再 approve）

**倒數計時邏輯**（前端計算，不依賴 SSE 更新）：

```typescript
function TokenCountdown({ expiresAt }: { expiresAt: string }) {
  const [remaining, setRemaining] = useState(() =>
    Math.max(0, differenceInSeconds(new Date(expiresAt), new Date()))
  )

  useEffect(() => {
    const interval = setInterval(() => {
      setRemaining(prev => Math.max(0, prev - 1))
    }, 1000)
    return () => clearInterval(interval)
  }, [])

  const minutes = Math.floor(remaining / 60)
  const seconds = remaining % 60
  const isUrgent = remaining < 300  // < 5分鐘

  return (
    <span className={isUrgent ? "text-red-500 animate-pulse" : "text-orange-400"}>
      {minutes}:{seconds.toString().padStart(2, "0")}
    </span>
  )
}
```

**Telegram 通知連動**：
- 新的 WAITING_APPROVAL run → 同時發 Telegram 通知給有 `approver` role 的 principal
- 通知格式（ADR-116 + approval_token）：
  ```
  [APPROVAL REQUEST] Run abc123
  Operation: k8s_exec_pod(api, restart)
  Risk: HIGH | Expires: 15min
  [Approve] [Reject]  ← Telegram inline buttons
  ```
- Telegram approve/reject → 走 callback → Phase 8 resume API

---

## M8 — Approval Decision

### 頁面設計

**路由**：`/awooop/approvals/[run_id]`

**頁面佈局**：

```
┌─────────────────────────────────────────────────────────────────────────┐
│ Approval Request                                    Expires: 12:34 ⏱️  │
├─────────────────────────────────────────────────────────────────────────┤
│ Run: abc12345-...                                                       │
│ Agent: openclaw-sre | Project: awoooi                                   │
│ Subject: awoooi:telegram:123456789 (陳統帥)                             │
│ Trigger: "請幫我把 api deployment 縮到 1 個 replica"                   │
├─────────────────────────────────────────────────────────────────────────┤
│ REQUESTED OPERATION                                                     │
│ ──────────────────────────────────────────────────────────────────────  │
│ Tool: k8s_scale_deployment                                              │
│ Risk Level: 🔴 HIGH                                                     │
│ Parameters:                                                             │
│   namespace: awoooi-prod                                                │
│   deployment: api                                                       │
│   replicas: 1  (current: 3)                                             │
│                                                                         │
│ Compensation available: ✅ (scale back to 3 if rejected/rolled back)   │
├─────────────────────────────────────────────────────────────────────────┤
│ SAGA Context（前面做了什麼）                                             │
│ ──────────────────────────────────────────────────────────────────────  │
│ Step 1 ✅ LLM decision: scale api to 1 (reason: SLO budget exceeded)   │
│ Step 2 ⏳ WAITING: scale_deployment approval                            │
├─────────────────────────────────────────────────────────────────────────┤
│ APPROVAL DECISION                                                       │
│                                                                         │
│ Comment (optional): ____________________________________                │
│                                                                         │
│ [✅ Approve]  [❌ Reject]                                               │
└─────────────────────────────────────────────────────────────────────────┘
```

### Approve / Reject 流程

```typescript
async function handleApprovalDecision(
  runId: string,
  decision: "approve" | "reject",
  comment: string
) {
  // 1. 取得 approval_token（Platform API 生成 HS256 token）
  const { approval_token } = await fetchApprovalToken(runId)

  // 2. 送出決策
  const response = await fetch(`/v1/platform/runs/${runId}/resume`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      approval_token,        // ADR-116 D4
      decision,
      comment,
      approver_subject_id: currentUser.platform_subject_id
    })
  })

  if (!response.ok) {
    const error = await response.json()
    if (error.code === "E-APPROVAL-002") {
      // approval_token 過期 → 提示用戶刷新頁面
      toast.error("Approval token has expired. Please refresh.")
    } else if (error.code === "E-APPROVAL-003") {
      // jti 已使用（重複提交）
      toast.error("This approval has already been submitted.")
    }
    return
  }

  // 3. 成功 → 跳回 Approval Queue
  router.push("/awooop/approvals")
  toast.success(`Run ${decision === "approve" ? "approved" : "rejected"} successfully.`)
}
```

### 錯誤處理

| 錯誤碼 | UI 行為 |
|--------|---------|
| E-APPROVAL-001 | 「此 run 不再需要 approval」→ 跳回 Queue |
| E-APPROVAL-002 | 「Token 已過期，請刷新」→ 重新取 token |
| E-APPROVAL-003 | 「已送出過 approval，請勿重複提交」→ 禁用按鈕 |
| E-APPROVAL-004 | 「您沒有 approver 權限」→ 顯示錯誤，跳回首頁 |

### Token 取得機制

```typescript
// GET /v1/platform/runs/{run_id}/approval-token
// → 返回 HS256 approval_token（只對目前 session 的用戶有效）

async function fetchApprovalToken(runId: string): Promise<{ approval_token: string }> {
  const response = await fetch(`/v1/platform/runs/${runId}/approval-token`)
  if (!response.ok) throw new Error("Failed to fetch approval token")
  return response.json()
}
```

**注意**：approval_token 在 API 端生成後，每次頁面載入都重新取得（15min TTL，不快取）。

---

## 後果

### Benefits
- 倒數計時讓 approver 清楚知道緊迫程度（防止 approval 超時）
- SAGA Context 顯示：approver 可以看到 run 到目前為止做了什麼，做出知情決策
- Compensation 顯示：approver 知道 reject 後會自動回滾什麼操作
- Telegram 按鈕連動：approver 可以在 Telegram 直接 approve/reject，不需要開網頁

### Costs
- 倒數計時 setInterval（每頁面一個，不影響效能）
- approval_token 每次頁面載入都重新取得（一次 API call，可接受）

### Risks
- 倒數計時與 server 時鐘有偏差（前端計算 vs server exp claim）
- 緩解：頁面載入時以 server 返回的 `expires_at` 為準，不用 `Date.now()`

---

## 驗收標準

- [ ] M7：WAITING_APPROVAL run 即時顯示 + 倒數計時（Phase 5）
- [ ] M7：< 5 分鐘 → 紅色閃爍（視覺測試）
- [ ] M8：SAGA Context 正確顯示前序步驟（Phase 5）
- [ ] M8：Approve → run 恢復 RESUMED 狀態（整合測試）
- [ ] M8：Reject → SAGA 補償觸發（整合測試）
- [ ] M8：E-APPROVAL-002（token 過期）→ 錯誤提示正確（整合測試）
- [ ] Telegram inline button approve → Phase 8 resume API（整合測試）

## 關聯

- ADR-UI-01（Operator Console 路由架構）
- ADR-UI-03（M6 Run Detail，SAGA 步驟資料共用）
- ADR-114（WAITING_APPROVAL state，lease 不 renew）
- ADR-116（approval_token HS256，M8 核心）
- ADR-119（SAGA compensation，reject 觸發）
