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

8.1 KiB

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

狀態: Accepted 日期: 2026-03-20 (Gate 0 驗證完成) 決策者: 統帥 (CTO + CPO) 關聯: 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 整合)

// 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 狀態機)

// 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

// 避免每個 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 清理

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

  connect(apiUrl)

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

指數退避重連

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

參考資料


Gate 0 里程碑 - 2026-03-20