Files
awoooi/docs/adr/ADR-UI-04-approval-queue-ui.md
Your Name 13e51802fe feat(awooop): Phase 0 全 ADR + Phase 1 control plane schema(含 critic 四項修正)
## Phase 0(文件層,全部 Accepted)
- ADR-106/107:AwoooP 平台架構 + 儲存策略
- ADR-111~118:Bootstrap → RLS 七項核心 ADR
- ADR-119~124:SAGA → Singleton Decomposition 六項 ADR
- ADR-UI-01~04:Operator Console 四個 UI ADR

## Phase 1(DB schema + migration)
- awooop_phase1_control_plane_2026-05-04.sql:7 張新表 + trigger + RLS
  - Step 1:三角色(platform_admin/migration BYPASSRLS,awooop_app 受 RLS)
  - Step 13:GRANT awooop_app 最小權限(7 條)
  - Step 14:RLS fail-closed,移除 __platform__ 後門
- awooop_phase1_batch1_rls_2026-05-04.sql:高流量四表三步式 ADD COLUMN
- awooop_phase1_batch1_backfill.py:SKIP LOCKED 分批回填腳本
- awooop_models.py:7 個 SQLAlchemy 2.x models

## Critic 修正(4 Critical + 3 Major)
- C-1:ADD CONSTRAINT IF NOT EXISTS → DO 塊 + pg_constraint 查詢
- C-2:__mapper_args__ 字串 list → primary_key=True on mapped_column
- C-3:__platform__ RLS 後門 → 全移除,改用 BYPASSRLS role
- C-4:awooop_app role 從未建立 → Step 1 + 7 條 GRANT
- M-1:active_pointer_guard SECURITY DEFINER(FORCE RLS 跨租戶保護)
- M-2:pg_partman create_parent 加冪等防護
- M-3:immutability trigger 新增身份欄位保護(project_id/family/contract_id)

## Task 1.2 修補
- agent_loader.py:硬編碼 Mac 路徑 → AGENTS_DIR 環境變數
- Dockerfile:補 COPY .claude/agents/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 13:37:11 +08:00

220 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-UI-04: Approval Queue UI
**狀態**Accepted
**日期**2026-05-03台北
**決策者**:統帥
**範圍**M7 Approval Queue + M8 Approval Decision 的詳細設計
**關聯**ADR-UI-01架構、ADR-114WAITING_APPROVAL state、ADR-116approval_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_tokenPlatform 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()`
---
## 驗收標準
- [ ] M7WAITING_APPROVAL run 即時顯示 + 倒數計時Phase 5
- [ ] M7< 5 分鐘 → 紅色閃爍(視覺測試)
- [ ] M8SAGA Context 正確顯示前序步驟Phase 5
- [ ] M8Approve → run 恢復 RESUMED 狀態(整合測試)
- [ ] M8Reject → SAGA 補償觸發(整合測試)
- [ ] M8E-APPROVAL-002token 過期)→ 錯誤提示正確(整合測試)
- [ ] Telegram inline button approve → Phase 8 resume API整合測試
## 關聯
- ADR-UI-01Operator Console 路由架構)
- ADR-UI-03M6 Run DetailSAGA 步驟資料共用)
- ADR-114WAITING_APPROVAL statelease 不 renew
- ADR-116approval_token HS256M8 核心)
- ADR-119SAGA compensationreject 觸發)