S-01: generate_alert_fingerprint() 移至 alert_analyzer_service (Router→Service) S-02: 移除廢棄 USE_NEW_ENGINE config (Phase R 已完成歷史使命) S-03: github_webhook.py linter 清理 (Field unused + delivery_id noqa) S-04: Pydantic v2 遷移 - approval/incident models (class Config → ConfigDict) S-05: Skill 09 v1.1 更新 (USE_NEW_ENGINE 廢棄說明) 測試: 393 passed, 零失敗 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
10 KiB
ADR-031: Omni-Terminal SSE 架構
狀態: Accepted 日期: 2026-03-27 決策者: 首席架構師 (Claude Code) 審核者: 統帥 (ogt)
背景
AWOOOI Phase 19 計畫將平台從「傳統監控儀表板」轉型為「AI 代理人協作空間 (Agentic Workspace)」。核心組件 Omni-Terminal 需要即時雙向通訊能力,支援:
- AI 思考軌跡串流 - 展示 OpenClaw 分析過程
- 動態 UI 生成 (GenUI) - 根據 AI 回應動態掛載組件
- 核鑰授權請求 - 高風險操作的確認流程
- 斷線恢復 - 確保通訊穩定性
現有架構約束
apps/api/src/core/sse.py已有EventPublisher實作apps/api/src/api/v1/agents.py使用混合 SSE 模式- 前端
terminal.store.ts目前使用 Mock 實作 - 必須符合 leWOOOgo 積木化原則
決策
1. 採用混合 SSE 模式 (POST Intent + GET Stream)
┌─────────┐ POST /terminal/intent ┌─────────┐
│ Browser │ ────────────────────────────► │ API │
│ (Web) │ ◄──── { session_id: "xxx" } ─ │ Server │
└─────────┘ └─────────┘
│
│ GET /terminal/stream/{session_id}
▼
┌─────────┐ SSE (EventSource) ┌─────────┐
│ Browser │ ◄─────────────────────────── │ API │
│ (Web) │ │ Server │
└─────────┘ └─────────┘
2. API 端點設計
| 端點 | 方法 | 說明 |
|---|---|---|
/api/v1/terminal/intent |
POST | 提交意圖,返回 session_id |
/api/v1/terminal/stream/{session_id} |
GET | SSE 訂閱 (EventSource 原生) |
/api/v1/terminal/abort/{session_id} |
POST | 中斷執行 |
/api/v1/terminal/status/{session_id} |
GET | 查詢 Session 狀態 |
3. SSE 事件類型
class TerminalEventType(str, Enum):
# 思考軌跡
TERMINAL_THOUGHT = "terminal_thought"
TERMINAL_TOOL_CALL = "terminal_tool_call"
# GenUI
TERMINAL_RENDER_UI = "terminal_render_ui"
# 授權
TERMINAL_ACTION_REQUEST = "terminal_action_request"
TERMINAL_ACTION_RESULT = "terminal_action_result"
# 控制
TERMINAL_COMPLETE = "terminal_complete"
TERMINAL_ERROR = "terminal_error"
TERMINAL_HEARTBEAT = "terminal_heartbeat"
4. 前端 SSE 狀態機 (7 狀態)
type SSEConnectionState =
| 'disconnected' // 未連接
| 'connecting' // 正在連接
| 'subscribing' // 訂閱中
| 'connected' // 已連接 (空閒)
| 'streaming' // 串流中 (AI 回應)
| 'reconnecting' // 重連中
| 'error' // 錯誤
// 合法狀態轉換
const VALID_TRANSITIONS = {
disconnected: ['connecting'],
connecting: ['subscribing', 'error', 'disconnected'],
subscribing: ['connected', 'error'],
connected: ['streaming', 'reconnecting', 'disconnected'],
streaming: ['connected', 'error'],
reconnecting: ['connecting', 'error', 'disconnected'],
error: ['reconnecting', 'disconnected'],
}
5. Session 儲存策略
- 儲存位置: Redis (與 Multi-Sig 共用實例)
- TTL: 5 分鐘 (無活動自動過期)
- Key 格式:
terminal:session:{session_id} - 資料結構:
class TerminalSession(BaseModel):
session_id: str
user_id: str
intent: str
context: SpatialContext
status: Literal["pending", "processing", "completed", "aborted", "error"]
created_at: datetime
last_event_id: int = 0
6. 重連策略
- 指數退避: 1s → 2s → 4s → 8s → 16s (最大)
- Last-Event-ID: 支援斷點續傳
- 最大重試: 5 次
- 重連後: 自動恢復訂閱
7. 分層架構
apps/api/src/
├── models/
│ └── terminal.py # Pydantic 模型
├── services/
│ ├── terminal_service.py # ITerminalService + 實作
│ └── terminal_session.py # Session 管理 (Redis)
├── api/v1/
│ └── terminal.py # 純路由 (無業務邏輯)
└── core/
└── sse.py # 新增 TerminalEventType
理由
為什麼選擇混合模式而非純 POST SSE?
| 考量 | POST SSE | 混合模式 |
|---|---|---|
| 瀏覽器支援 | 需 fetch-event-source |
原生 EventSource |
| 與現有架構一致性 | 不一致 | ✅ 與 agents.py 一致 |
| 中斷/恢復能力 | 複雜 | ✅ 原生支援 Last-Event-ID |
| 偵錯友善度 | 低 | ✅ 可直接 curl |
為什麼選擇 Redis 而非記憶體?
| 考量 | 記憶體 | Redis |
|---|---|---|
| Pod 重啟 | 丟失 | ✅ 保留 |
| 水平擴展 | 黏性 Session | ✅ 無狀態 |
| 複雜度 | 低 | 中 |
| K3s 環境 | 不適用 | ✅ 必要 |
為什麼需要 7 狀態機?
現有 terminal.store.ts 使用布林標記 (isConnecting, isConnected),無法表達:
- 訂閱中 vs 已連接
- 串流中 vs 空閒
- 重連中的不同階段
狀態機提供:
- 明確的狀態轉換規則
- 防止非法狀態 (如「未連接但串流中」)
- 便於偵錯和可觀測性
後果
優點
- 與現有架構一致 - 複用 EventPublisher、與 agents.py 模式相同
- 原生瀏覽器支援 - 不需額外套件
- 斷點續傳 - Last-Event-ID 支援
- K3s 友善 - Redis Session 支援無狀態部署
- 可觀測 - 狀態機提供清晰的狀態追蹤
缺點
- 兩次請求 - POST + GET 比單一 POST SSE 多一次 roundtrip
- Redis 依賴 - 增加基礎設施依賴
- Session 管理 - 需處理 TTL、清理等
風險
| 風險 | 緩解策略 |
|---|---|
| SSE 被 Nginx 緩衝 | 確認 X-Accel-Buffering: no Header |
| Redis 連線失敗 | Fallback 到記憶體模式 + 告警 |
| Session 過期競爭 | 串流中延長 TTL |
| 前端狀態不一致 | 狀態機強制轉換驗證 |
實作指引
後端 (Phase 19.1)
# api/v1/terminal.py - Router 層
@router.post("/intent")
async def handle_intent(
request: TerminalRequest,
service: ITerminalService = Depends(get_terminal_service)
) -> IntentResponse:
session_id = await service.process_intent(request)
return IntentResponse(session_id=session_id, stream_url=f"/terminal/stream/{session_id}")
@router.get("/stream/{session_id}")
async def stream_response(
session_id: str,
service: ITerminalService = Depends(get_terminal_service)
) -> StreamingResponse:
async def generate():
publisher = await get_publisher()
client = await publisher.subscribe(topics=[f"terminal:{session_id}"])
async for event_str in publisher.stream(client):
yield event_str
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}
)
前端 (Phase 19.2)
// stores/terminal.store.ts
const useTerminalStore = create<TerminalState>()((set, get) => ({
connectionState: 'disconnected' as SSEConnectionState,
async sendIntent(intent: string, context: SpatialContext) {
const { transitionTo } = get()
transitionTo('connecting')
// Step 1: POST intent
const { session_id, stream_url } = await fetch('/api/v1/terminal/intent', {
method: 'POST',
body: JSON.stringify({ intent, context }),
}).then(r => r.json())
transitionTo('subscribing')
// Step 2: GET stream
const es = new EventSource(stream_url)
es.onopen = () => transitionTo('connected')
es.onmessage = (e) => {
transitionTo('streaming')
// Handle event...
}
es.onerror = () => transitionTo('error')
},
}))
實作紀錄
更新日期: 2026-03-31 (Phase 19.6 完成) 更新者: Claude Code (首席架構師) 首席架構師審查: Phase 19 審查 47/50 (SSE 狀態機 10/10 ⭐)
已完成項目
| 項目 | 檔案 | 狀態 |
|---|---|---|
| 後端 Router | apps/api/src/api/v1/terminal.py |
✅ |
| 後端 Service | apps/api/src/services/terminal_service.py |
✅ |
| 後端 Models | apps/api/src/models/terminal.py |
✅ |
| 前端 Store | apps/web/src/stores/terminal.store.ts |
✅ |
| 前端 UI | apps/web/src/components/terminal/OmniTerminal.tsx |
✅ |
| Telemetry | apps/web/src/lib/telemetry/terminal-telemetry.ts |
✅ |
| 單元測試 (Service) | apps/api/tests/test_terminal_service.py |
✅ (54 項通過) |
| 單元測試 (Router) | apps/api/tests/test_terminal.py |
✅ (Phase 19.6) |
| 單元測試 (GenUI) | apps/web/src/components/genui/__tests__/registry.test.ts |
✅ (Phase 19.6, Vitest) |
| E2E 測試 | apps/web/tests/e2e/terminal.spec.ts |
✅ (Phase 19.6, Playwright) |
P0-P2 修復紀錄
| 優先級 | 修復項目 | 說明 |
|---|---|---|
| P0 | Singleton → FastAPI Depends | get_terminal_service() 依賴注入 |
| P2 | Slow Query 監控 | 5s 警告 / 10s 嚴重 + Sentry 告警 |
Phase 19.6 測試補全 (2026-03-31)
| 測試類型 | 檔案 | 覆蓋範圍 |
|---|---|---|
| Router 層 | test_terminal.py |
4 端點 + 422/404 錯誤案例 + Session 流程 |
| GenUI Zod | registry.test.ts |
7 組件 Schema + validateProps 錯誤碼分類 |
| E2E API | terminal.spec.ts |
POST/GET/Abort 完整流程 + 404/422 驗證 |
驗證結果
# Python 測試
cd apps/api && python -m pytest tests/test_terminal_service.py tests/test_terminal.py -v
# Service: 54 passed; Router: ~18 passed
# GenUI Zod 單元測試 (需先 pnpm install 安裝 vitest)
cd apps/web && pnpm vitest run
# E2E 測試
cd apps/web && pnpm playwright test tests/e2e/terminal.spec.ts
# 意圖分類覆蓋
# - 42 個分類測試案例
# - 9 種 IntentType 全覆蓋
參考
- ADR-004 State Management - Zustand 狀態管理
- ADR-006 AI Fallback Strategy - AI 備援順序
- agents.py - 現有 SSE 實作參考
- core/sse.py - EventPublisher 實作
- Phase 19 工作規格書
- 會議紀錄