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

10 KiB
Raw Permalink Blame History

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 更新):

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 流程

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 取得機制

// 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 觸發)