# ADR-001: 前端狀態管理採用 Zustand

> **狀態**: Accepted
> **日期**: 2026-03-20 (Gate 0 驗證完成)
> **決策者**: 統帥 (CTO + CPO)
> **關聯**: [docs/adr/ADR-004-state-management.md](../docs/adr/ADR-004-state-management.md)

---

## 摘要

AWOOOI 前端全面採用 **Zustand** 作為狀態管理工具，特別針對：
- **Approval Multi-Sig 狀態機** - HITL 審批流程
- **SSE 即時串流** - Dashboard 主機監控

---

## 背景

### 問題陳述

AWOOOI 需要處理高頻率的狀態更新：

| 場景 | 更新頻率 | 狀態類型 |
|------|---------|---------|
| Dashboard SSE | 每秒 | 4 主機 CPU/Memory |
| Approval 簽核 | 事件驅動 | Multi-Sig 狀態機 |
| OpenClaw 思考 | 串流 | AI 輸出 Token |

傳統的 Redux 在這種場景下過於笨重 (7KB + 大量 boilerplate)。

---

## 決策

**採用 Zustand 作為唯一全域狀態管理工具**

### 核心實作

#### 1. Dashboard Store (SSE 整合)

```typescript
// stores/dashboard.store.ts
import { create } from 'zustand'

interface DashboardState {
  hosts: HostStatus[]
  connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error'
  lastUpdate: Date | null

  // Actions
  connect: (apiUrl: string) => void
  disconnect: () => void
  updateHosts: (hosts: HostStatus[]) => void
}

export const useDashboardStore = create<DashboardState>((set, get) => ({
  hosts: [],
  connectionStatus: 'disconnected',
  lastUpdate: null,

  connect: (apiUrl) => {
    set({ connectionStatus: 'connecting' })

    const eventSource = new EventSource(`${apiUrl}/api/v1/dashboard/stream`)

    eventSource.onopen = () => {
      set({ connectionStatus: 'connected' })
    }

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data)
      set({
        hosts: data.hosts,
        lastUpdate: new Date()
      })
    }

    eventSource.onerror = () => {
      set({ connectionStatus: 'error' })
    }
  },

  disconnect: () => {
    set({ connectionStatus: 'disconnected' })
  },

  updateHosts: (hosts) => set({ hosts, lastUpdate: new Date() })
}))

// Selector hooks for fine-grained subscriptions
export const useHosts = () => useDashboardStore((s) => s.hosts)
export const useConnectionStatus = () => useDashboardStore((s) => s.connectionStatus)
```

#### 2. Approval Store (Multi-Sig 狀態機)

```typescript
// stores/approval.store.ts
import { create } from 'zustand'

type SigningStatus = 'idle' | 'signing' | 'success' | 'error'

interface ApprovalState {
  pendingApprovals: Approval[]
  selectedApproval: Approval | null
  signingStatus: SigningStatus

  // Actions
  fetchApprovals: () => Promise<void>
  signApproval: (id: string, userId: string, role: string) => Promise<void>
  rejectApproval: (id: string, reason: string) => Promise<void>
}

export const useApprovalStore = create<ApprovalState>((set, get) => ({
  pendingApprovals: [],
  selectedApproval: null,
  signingStatus: 'idle',

  fetchApprovals: async () => {
    const response = await fetch('/api/v1/approvals?status=pending')
    const data = await response.json()
    set({ pendingApprovals: data.items })
  },

  signApproval: async (id, userId, role) => {
    set({ signingStatus: 'signing' })

    try {
      const response = await fetch(`/api/v1/approvals/${id}/approve`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ user_id: userId, user_role: role })
      })

      if (response.status === 409) {
        // TOCTOU Conflict or Duplicate Signature
        throw new Error('Conflict')
      }

      const result = await response.json()

      // Update local state
      if (!result.needs_more) {
        // Remove from pending if fully approved
        set((s) => ({
          pendingApprovals: s.pendingApprovals.filter(a => a.id !== id),
          signingStatus: 'success'
        }))
      } else {
        set({ signingStatus: 'success' })
      }

    } catch (error) {
      set({ signingStatus: 'error' })
      throw error
    }
  },

  rejectApproval: async (id, reason) => {
    await fetch(`/api/v1/approvals/${id}/reject`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ reason })
    })

    set((s) => ({
      pendingApprovals: s.pendingApprovals.filter(a => a.id !== id)
    }))
  }
}))
```

---

## 狀態機設計

### Approval 生命週期

```
                    ┌─────────────────────┐
                    │       pending       │
                    └──────────┬──────────┘
                               │
           ┌───────────────────┼───────────────────┐
           │                   │                   │
           ▼                   ▼                   ▼
    ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
    │   approved   │   │   rejected   │   │    voided    │
    │ (簽章達閾值) │   │  (使用者拒絕) │   │ (TOCTOU衝突) │
    └──────────────┘   └──────────────┘   └──────────────┘
```

### 風險矩陣 (簽章閾值)

| Risk Level | 簽章數 | 條件 |
|------------|--------|------|
| low        | 0      | 自動執行 |
| medium     | 1      | admin/devops |
| high       | 2      | 任二管理員 |
| critical   | 2      | 含 CTO 或 CISO |

---

## 理由

### 為什麼選擇 Zustand

| 特性 | Redux | Zustand | 優勢 |
|------|-------|---------|------|
| Bundle Size | ~7KB | ~1KB | **-86%** |
| Boilerplate | 高 | 極低 | 開發效率 |
| SSE 整合 | 需 middleware | 原生 | 簡單直接 |
| TypeScript | 需額外設定 | 開箱即用 | DX 優異 |
| Re-render | 需 selector | 內建 | 效能優化 |

### 為什麼不選擇 Redux

1. **過度工程** - AWOOOI 不需要 Redux 的 time-travel debugging
2. **Boilerplate** - 每個 action 需要 type/reducer/action creator
3. **Bundle** - 7KB 對於輕量 SaaS 是負擔
4. **SSE 整合** - 需要額外的 middleware 如 redux-saga

### 為什麼不選擇 Context API

1. **Re-render 問題** - Provider 下所有元件都會重繪
2. **不適合高頻更新** - SSE 每秒更新會造成效能問題
3. **缺乏 selector** - 無法細粒度訂閱

---

## SSE 企業級模式

### Buffer + Debounce

```typescript
// 避免每個 SSE 事件都觸發 re-render
const bufferRef = useRef<HostStatus[]>([])

eventSource.onmessage = (event) => {
  bufferRef.current.push(JSON.parse(event.data))
}

// 每 500ms 批次更新
setInterval(() => {
  if (bufferRef.current.length > 0) {
    set({ hosts: bufferRef.current })
    bufferRef.current = []
  }
}, 500)
```

### AbortController 清理

```typescript
useEffect(() => {
  const controller = new AbortController()

  connect(apiUrl)

  return () => {
    controller.abort()
    disconnect()
  }
}, [])
```

### 指數退避重連

```typescript
const reconnect = (attempt: number) => {
  const delay = Math.min(1000 * Math.pow(2, attempt), 30000)
  setTimeout(() => connect(), delay)
}
```

---

## 驗證結果 (Gate 0)

| 測試項目 | 結果 |
|---------|------|
| Dashboard SSE 連線 | ✅ 穩定 |
| 4 主機即時更新 | ✅ <100ms 延遲 |
| Approval 簽核流程 | ✅ Multi-Sig 運作 |
| TOCTOU 防護 | ✅ 409 正確處理 |
| 記憶體洩漏 | ✅ 無 (AbortController) |

---

## 後果

### 優點

- **極度輕量** - 不增加 bundle 負擔
- **高頻更新** - 完美處理 SSE 串流
- **簡單 API** - 降低學習曲線
- **TypeScript** - 完整型別推導

### 缺點

- **生態較小** - 相比 Redux 社群資源較少
- **DevTools** - 功能不如 Redux DevTools 強大

### 風險緩解

| 風險 | 緩解措施 |
|------|---------|
| Store 肥大化 | 強制 Slice Pattern |
| 狀態同步錯誤 | 搭配 TanStack Query |

---

## 參考資料

- [Zustand 官方文檔](https://zustand-demo.pmnd.rs/)
- [API 契約](../docs/api/approvals-contract.yaml)
- [docs/adr/ADR-004](../docs/adr/ADR-004-state-management.md) - 詳細版本

---

*Gate 0 里程碑 - 2026-03-20*
