Files
awoooi/architecture/ADR-001-state-management.md
OG T 604e38cf07 docs: Phase 14 紅區治理 + Skills 01/03 更新
- CLAUDE.md: 紅區治理章節
- Skills 01/03: 版本更新
- ADR/Architecture: 標準化

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 09:55:47 +08:00

323 lines
8.1 KiB
Markdown

# 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*