## 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>
220 lines
10 KiB
Markdown
220 lines
10 KiB
Markdown
# 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 觸發)
|