Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
## Critical 修復 (C1-C5) - C1: git rm --cached 03-secrets.yaml(CHANGE_ME 模板不再追蹤) - C2: git rm --cached awoooi.db + .gitignore 加 *.db(SQLite HARD_RULES 違規) - C3: sentry-tunnel SENTRY_HOST 改為 process.env fallback - C4: config.py DATABASE_URL 移除 changeme default,改為必填 - C5: run_migration.py 改為 os.environ["DATABASE_URL"] ## Major 修復 (M1-M4) - M1: auto_repair /execute 加 CSRF 保護 + AutoRepairPanel.tsx 同步 - M2: drift /rollback /adopt 加 CSRF 保護(/internal/scan 保持無 CSRF) - M3: terminal /intent 加 CSRF 保護 + terminal.store.ts 同步 - M4: live-dashboard HOST_IPS + host-grid VIP 改為 env var ## 其他 - 新增 apps/web/.env.example(6 個 env var 說明) - K8s deployment-web 補入 3 個新 env var - 整合測試:新增 aider_event_repository + ai_router_feedback 真實 DB 測試 - test_terminal.py CSRF dependency override 修復 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
628 lines
17 KiB
TypeScript
628 lines
17 KiB
TypeScript
/**
|
|
* AWOOOI Terminal Store
|
|
* ==========================
|
|
* Phase 19 - Omni-Terminal SSE State Machine
|
|
*
|
|
* 核心功能:
|
|
* 1. 7-State SSE 連線狀態機
|
|
* 2. 混合模式 SSE (POST intent + GET stream)
|
|
* 3. 指數退避重連
|
|
* 4. Last-Event-ID 斷點續傳
|
|
*
|
|
* @see ADR-031 Omni-Terminal SSE Architecture
|
|
* @see lib/constants/sse-states.ts
|
|
*
|
|
* @author Claude Code (首席架構師)
|
|
* @version 2.0.0
|
|
* @date 2026-03-28 (台北時間)
|
|
*/
|
|
|
|
import { create } from 'zustand'
|
|
import {
|
|
type SSEConnectionState,
|
|
type TerminalSession,
|
|
type TerminalEventType,
|
|
type SSEEventData,
|
|
isValidTransition,
|
|
getReconnectDelay,
|
|
shouldRetry,
|
|
createSession,
|
|
getStateInfo,
|
|
} from '@/lib/constants/sse-states'
|
|
import {
|
|
trackIntentSubmit,
|
|
trackIntentComplete,
|
|
trackSSEConnection,
|
|
setTerminalContext,
|
|
clearTerminalContext,
|
|
} from '@/lib/telemetry'
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export type MessageRole = 'system' | 'user' | 'assistant'
|
|
export type MessageType = 'text' | 'tool_call' | 'thought' | 'render_ui'
|
|
|
|
export interface TerminalMessage {
|
|
id: string
|
|
role: MessageRole
|
|
type: MessageType
|
|
content: string
|
|
payload?: Record<string, unknown>
|
|
timestamp: Date
|
|
}
|
|
|
|
/** Ghost Payload - 空間感知上下文 */
|
|
export interface SpatialContext {
|
|
/** 當前頁面路由 */
|
|
currentPage: string
|
|
/** 聚焦的實體 ID (incident/approval) */
|
|
focusedEntityId?: string
|
|
}
|
|
|
|
/** Intent 請求回應 (snake_case from backend) */
|
|
interface IntentResponse {
|
|
session_id: string
|
|
stream_url: string
|
|
created_at: string
|
|
}
|
|
|
|
// =============================================================================
|
|
// Store State
|
|
// =============================================================================
|
|
|
|
interface TerminalState {
|
|
// UI 狀態
|
|
isOpen: boolean
|
|
|
|
// SSE 連線狀態機
|
|
connectionState: SSEConnectionState
|
|
session: TerminalSession | null
|
|
reconnectAttempt: number
|
|
|
|
// 訊息
|
|
messages: TerminalMessage[]
|
|
|
|
// 內部引用
|
|
_eventSource: EventSource | null
|
|
_abortController: AbortController | null
|
|
|
|
// Actions - UI
|
|
toggleTerminal: () => void
|
|
openTerminal: () => void
|
|
closeTerminal: () => void
|
|
|
|
// Actions - SSE 狀態機
|
|
transitionTo: (newState: SSEConnectionState) => void
|
|
connect: () => Promise<void>
|
|
disconnect: () => void
|
|
|
|
// Actions - 訊息
|
|
sendIntent: (text: string, context?: SpatialContext) => Promise<void>
|
|
appendMessage: (message: Omit<TerminalMessage, 'id' | 'timestamp'>) => void
|
|
updateLastMessage: (contentChunk: string) => void
|
|
clearMessages: () => void
|
|
|
|
// Actions - 內部
|
|
_subscribeToStream: (sessionId: string) => Promise<void>
|
|
_handleSSEEvent: (event: MessageEvent) => void
|
|
_handleSSEError: (error: Event) => void
|
|
_reconnect: () => void
|
|
|
|
// Selectors (computed)
|
|
getStateInfo: () => ReturnType<typeof getStateInfo>
|
|
}
|
|
|
|
// =============================================================================
|
|
// API Base URL
|
|
// =============================================================================
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || ''
|
|
|
|
// =============================================================================
|
|
// Store Implementation
|
|
// =============================================================================
|
|
|
|
export const useTerminalStore = create<TerminalState>((set, get) => ({
|
|
// ---------------------------------------------------------------------------
|
|
// Initial State
|
|
// ---------------------------------------------------------------------------
|
|
isOpen: false,
|
|
connectionState: 'disconnected',
|
|
session: null,
|
|
reconnectAttempt: 0,
|
|
messages: [
|
|
{
|
|
id: 'sys-init',
|
|
role: 'system',
|
|
type: 'text',
|
|
content: 'AWOOOI OS [Version 2.0.0]\n(c) WOOO Corporation. All rights reserved.',
|
|
timestamp: new Date(),
|
|
},
|
|
{
|
|
id: 'sys-ready',
|
|
role: 'system',
|
|
type: 'text',
|
|
content: 'System ready. Press ⌘J to toggle terminal.',
|
|
timestamp: new Date(),
|
|
},
|
|
],
|
|
_eventSource: null,
|
|
_abortController: null,
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UI Actions
|
|
// ---------------------------------------------------------------------------
|
|
toggleTerminal: () => set((state) => ({ isOpen: !state.isOpen })),
|
|
openTerminal: () => set({ isOpen: true }),
|
|
closeTerminal: () => {
|
|
get().disconnect()
|
|
set({ isOpen: false })
|
|
},
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE State Machine
|
|
// ---------------------------------------------------------------------------
|
|
transitionTo: (newState: SSEConnectionState) => {
|
|
const currentState = get().connectionState
|
|
|
|
// 驗證狀態轉換
|
|
if (!isValidTransition(currentState, newState)) {
|
|
console.warn(
|
|
`[Terminal] Invalid state transition: ${currentState} → ${newState}`
|
|
)
|
|
return
|
|
}
|
|
|
|
console.log(`[Terminal] State: ${currentState} → ${newState}`)
|
|
set({ connectionState: newState })
|
|
|
|
// 錯誤狀態自動觸發重連
|
|
if (newState === 'error') {
|
|
const { reconnectAttempt } = get()
|
|
if (shouldRetry(reconnectAttempt)) {
|
|
const delay = getReconnectDelay(reconnectAttempt)
|
|
console.log(`[Terminal] Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`)
|
|
setTimeout(() => get()._reconnect(), delay)
|
|
}
|
|
}
|
|
},
|
|
|
|
connect: async () => {
|
|
// Note: In Hybrid SSE mode, connect() is for reconnection scenarios only
|
|
// The actual connection happens in _subscribeToStream() after POST /intent
|
|
const state = get()
|
|
|
|
// 已連接則跳過
|
|
if (state.connectionState === 'connected' || state.connectionState === 'streaming') {
|
|
return
|
|
}
|
|
|
|
// 沒有有效 session 則無法連接
|
|
if (!state.session?.sessionId) {
|
|
console.warn('[Terminal] No valid session for reconnection')
|
|
return
|
|
}
|
|
|
|
// 嘗試重新訂閱現有 session
|
|
await get()._subscribeToStream(state.session.sessionId)
|
|
},
|
|
|
|
/**
|
|
* 訂閱 SSE 串流 (內部方法)
|
|
* ADR-031: Hybrid SSE 模式 - POST intent 後才訂閱
|
|
*/
|
|
_subscribeToStream: async (sessionId: string) => {
|
|
const state = get()
|
|
|
|
// 開始訂閱
|
|
state.transitionTo('subscribing')
|
|
|
|
// 追蹤 SSE 連線開始 (Phase 19.O)
|
|
trackSSEConnection({
|
|
state: 'connecting',
|
|
sessionId,
|
|
})
|
|
|
|
try {
|
|
const streamUrl = `${API_BASE_URL}/api/v1/terminal/stream/${sessionId}`
|
|
const lastEventId = state.session?.lastEventId
|
|
|
|
const eventSource = new EventSource(
|
|
lastEventId ? `${streamUrl}?last_event_id=${lastEventId}` : streamUrl
|
|
)
|
|
|
|
// 設定事件處理器
|
|
eventSource.onopen = () => {
|
|
console.log('[Terminal] SSE connected to', sessionId)
|
|
get().transitionTo('connected')
|
|
// 進入串流狀態 (正在處理)
|
|
get().transitionTo('streaming')
|
|
|
|
// 追蹤 SSE 連線成功 (Phase 19.O)
|
|
trackSSEConnection({
|
|
state: 'connected',
|
|
sessionId,
|
|
})
|
|
}
|
|
|
|
eventSource.onmessage = (event) => {
|
|
get()._handleSSEEvent(event)
|
|
}
|
|
|
|
eventSource.onerror = (error) => {
|
|
get()._handleSSEError(error)
|
|
}
|
|
|
|
// 監聽特定事件類型
|
|
const eventTypes: TerminalEventType[] = [
|
|
'terminal_thought',
|
|
'terminal_tool_call',
|
|
'terminal_render_ui',
|
|
'terminal_action_request',
|
|
'terminal_action_result',
|
|
'terminal_complete',
|
|
'terminal_error',
|
|
'terminal_heartbeat',
|
|
'connected',
|
|
'heartbeat',
|
|
]
|
|
|
|
eventTypes.forEach((type) => {
|
|
eventSource.addEventListener(type, (event) => {
|
|
get()._handleSSEEvent(event as MessageEvent)
|
|
})
|
|
})
|
|
|
|
set({ _eventSource: eventSource })
|
|
} catch (error) {
|
|
console.error('[Terminal] SSE subscription error:', error)
|
|
get().transitionTo('error')
|
|
}
|
|
},
|
|
|
|
disconnect: () => {
|
|
const { _eventSource, _abortController } = get()
|
|
|
|
if (_eventSource) {
|
|
_eventSource.close()
|
|
}
|
|
|
|
if (_abortController) {
|
|
_abortController.abort()
|
|
}
|
|
|
|
set({
|
|
connectionState: 'disconnected',
|
|
_eventSource: null,
|
|
_abortController: null,
|
|
session: null,
|
|
reconnectAttempt: 0,
|
|
})
|
|
|
|
console.log('[Terminal] Disconnected')
|
|
},
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Message Actions
|
|
// ---------------------------------------------------------------------------
|
|
sendIntent: async (text: string, context?: SpatialContext) => {
|
|
if (!text.trim()) return
|
|
|
|
const state = get()
|
|
const intentStartTime = Date.now()
|
|
|
|
// 樂觀更新 UI - 顯示使用者輸入
|
|
state.appendMessage({
|
|
role: 'user',
|
|
type: 'text',
|
|
content: text,
|
|
})
|
|
|
|
// 關閉現有 SSE 連線 (每次 intent 是新的 session)
|
|
if (state._eventSource) {
|
|
state._eventSource.close()
|
|
set({ _eventSource: null })
|
|
}
|
|
|
|
// 進入連接狀態
|
|
state.transitionTo('connecting')
|
|
|
|
// 追蹤 Intent 提交 (Phase 19.O 可觀測性)
|
|
trackIntentSubmit({
|
|
intentType: 'user_input',
|
|
inputLength: text.length,
|
|
})
|
|
|
|
try {
|
|
const abortController = new AbortController()
|
|
set({ _abortController: abortController })
|
|
|
|
// Step 1: POST intent 到後端建立 session
|
|
// Phase 20: CSRF Protection — 先取得 token 再提交
|
|
const csrfRes = await fetch(`${API_BASE_URL}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' })
|
|
if (!csrfRes.ok) throw new Error(`CSRF fetch failed: ${csrfRes.status}`)
|
|
const csrfData = await csrfRes.json()
|
|
const csrfHeaders: Record<string, string> = csrfData.token ? { 'X-CSRF-Token': String(csrfData.token) } : {}
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/terminal/intent`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...csrfHeaders,
|
|
},
|
|
body: JSON.stringify({
|
|
intent: text,
|
|
context: {
|
|
current_page: context?.currentPage ??
|
|
(typeof window !== 'undefined' ? window.location.pathname : '/'),
|
|
focused_entity_id: context?.focusedEntityId,
|
|
},
|
|
}),
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Intent failed: ${response.status}`)
|
|
}
|
|
|
|
const data: IntentResponse = await response.json()
|
|
|
|
// Step 2: 建立 session 並訂閱 SSE
|
|
set({
|
|
session: createSession(data.session_id),
|
|
reconnectAttempt: 0,
|
|
})
|
|
|
|
// 設定 Sentry 上下文 (Phase 19.O)
|
|
setTerminalContext(data.session_id, 'user_input')
|
|
|
|
// Step 3: 訂閱 SSE 串流
|
|
await get()._subscribeToStream(data.session_id)
|
|
|
|
// 追蹤 Intent 完成 (Phase 19.O)
|
|
trackIntentComplete({
|
|
intentType: 'user_input',
|
|
inputLength: text.length,
|
|
sessionId: data.session_id,
|
|
duration: Date.now() - intentStartTime,
|
|
success: true,
|
|
})
|
|
} catch (error) {
|
|
if ((error as Error).name === 'AbortError') {
|
|
console.log('[Terminal] Intent aborted')
|
|
set({ connectionState: 'disconnected' })
|
|
clearTerminalContext()
|
|
return
|
|
}
|
|
|
|
console.error('[Terminal] Intent error:', error)
|
|
get().appendMessage({
|
|
role: 'system',
|
|
type: 'text',
|
|
content: `Error: ${(error as Error).message}`,
|
|
})
|
|
|
|
// 追蹤 Intent 失敗 (Phase 19.O)
|
|
trackIntentComplete({
|
|
intentType: 'user_input',
|
|
inputLength: text.length,
|
|
duration: Date.now() - intentStartTime,
|
|
success: false,
|
|
})
|
|
|
|
// 進入錯誤狀態 (會觸發自動重連)
|
|
set({ connectionState: 'error' })
|
|
}
|
|
},
|
|
|
|
appendMessage: (msg) =>
|
|
set((state) => ({
|
|
messages: [
|
|
...state.messages,
|
|
{
|
|
...msg,
|
|
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
timestamp: new Date(),
|
|
},
|
|
],
|
|
})),
|
|
|
|
updateLastMessage: (contentChunk: string) =>
|
|
set((state) => {
|
|
const messages = [...state.messages]
|
|
if (messages.length === 0) return state
|
|
|
|
const lastMsg = messages[messages.length - 1]
|
|
if (lastMsg.role === 'assistant') {
|
|
lastMsg.content += contentChunk
|
|
return { messages }
|
|
}
|
|
return state
|
|
}),
|
|
|
|
clearMessages: () =>
|
|
set({
|
|
messages: [
|
|
{
|
|
id: 'sys-cleared',
|
|
role: 'system',
|
|
type: 'text',
|
|
content: 'Terminal cleared.',
|
|
timestamp: new Date(),
|
|
},
|
|
],
|
|
}),
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal Handlers
|
|
// ---------------------------------------------------------------------------
|
|
_handleSSEEvent: (event: MessageEvent) => {
|
|
const state = get()
|
|
|
|
try {
|
|
const data: SSEEventData = JSON.parse(event.data)
|
|
|
|
// 更新 lastEventId
|
|
if (data.id && state.session) {
|
|
set((s) => ({
|
|
session: s.session ? { ...s.session, lastEventId: data.id! } : null,
|
|
}))
|
|
}
|
|
|
|
// 根據事件類型處理
|
|
switch (data.type) {
|
|
case 'terminal_thought':
|
|
state.appendMessage({
|
|
role: 'assistant',
|
|
type: 'thought',
|
|
content: (data.data.msg as string) || '',
|
|
payload: data.data,
|
|
})
|
|
break
|
|
|
|
case 'terminal_tool_call':
|
|
state.appendMessage({
|
|
role: 'assistant',
|
|
type: 'tool_call',
|
|
content: `[Tool] ${data.data.tool}`,
|
|
payload: data.data,
|
|
})
|
|
break
|
|
|
|
case 'terminal_render_ui':
|
|
state.appendMessage({
|
|
role: 'assistant',
|
|
type: 'render_ui',
|
|
content: '',
|
|
payload: data.data,
|
|
})
|
|
break
|
|
|
|
case 'terminal_action_request':
|
|
state.appendMessage({
|
|
role: 'assistant',
|
|
type: 'render_ui',
|
|
content: '',
|
|
payload: {
|
|
component: 'ApprovalCard',
|
|
props: data.data,
|
|
},
|
|
})
|
|
break
|
|
|
|
case 'terminal_complete':
|
|
// 串流完成,回到 connected 狀態
|
|
if (state.connectionState === 'streaming') {
|
|
state.transitionTo('connected')
|
|
}
|
|
break
|
|
|
|
case 'terminal_error':
|
|
state.appendMessage({
|
|
role: 'system',
|
|
type: 'text',
|
|
content: `Error: ${data.data.message || 'Unknown error'}`,
|
|
})
|
|
state.transitionTo('error')
|
|
break
|
|
|
|
case 'terminal_heartbeat':
|
|
case 'heartbeat':
|
|
// 心跳,更新活動時間
|
|
if (state.session) {
|
|
set((s) => ({
|
|
session: s.session
|
|
? { ...s.session, lastActivityAt: new Date() }
|
|
: null,
|
|
}))
|
|
}
|
|
break
|
|
|
|
default:
|
|
console.log('[Terminal] Unknown event type:', data.type)
|
|
}
|
|
} catch (error) {
|
|
console.error('[Terminal] Failed to parse SSE event:', error)
|
|
}
|
|
},
|
|
|
|
_handleSSEError: (error: Event) => {
|
|
const state = get()
|
|
console.error('[Terminal] SSE error:', error)
|
|
|
|
// 追蹤 SSE 錯誤 (Phase 19.O)
|
|
trackSSEConnection({
|
|
state: 'error',
|
|
sessionId: state.session?.sessionId,
|
|
error: 'SSE connection error',
|
|
retryCount: state.reconnectAttempt,
|
|
})
|
|
|
|
// 關閉現有連接
|
|
if (state._eventSource) {
|
|
state._eventSource.close()
|
|
set({ _eventSource: null })
|
|
}
|
|
|
|
// 進入錯誤狀態 (會自動觸發重連)
|
|
state.transitionTo('error')
|
|
},
|
|
|
|
_reconnect: () => {
|
|
const state = get()
|
|
|
|
// 更新重連計數
|
|
set((s) => ({ reconnectAttempt: s.reconnectAttempt + 1 }))
|
|
|
|
// 追蹤 SSE 重連 (Phase 19.O)
|
|
trackSSEConnection({
|
|
state: 'reconnecting',
|
|
sessionId: state.session?.sessionId,
|
|
retryCount: state.reconnectAttempt + 1,
|
|
})
|
|
|
|
// 進入重連狀態
|
|
state.transitionTo('reconnecting')
|
|
|
|
// 嘗試重新連接
|
|
state.connect()
|
|
},
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Selectors
|
|
// ---------------------------------------------------------------------------
|
|
getStateInfo: () => getStateInfo(get().connectionState),
|
|
}))
|
|
|
|
// =============================================================================
|
|
// Selector Hooks (Optimized Re-renders)
|
|
// =============================================================================
|
|
|
|
/** 取得連線狀態 */
|
|
export const useConnectionState = () =>
|
|
useTerminalStore((s) => s.connectionState)
|
|
|
|
/** 取得狀態資訊 (label, color, etc.) */
|
|
export const useConnectionStateInfo = () =>
|
|
useTerminalStore((s) => getStateInfo(s.connectionState))
|
|
|
|
/** 是否已連接 (可發送訊息) */
|
|
export const useIsConnected = () =>
|
|
useTerminalStore((s) => {
|
|
const info = getStateInfo(s.connectionState)
|
|
return info.isConnected
|
|
})
|
|
|
|
/** 是否正在串流 */
|
|
export const useIsStreaming = () =>
|
|
useTerminalStore((s) => s.connectionState === 'streaming')
|
|
|
|
/** Terminal 是否開啟 */
|
|
export const useIsTerminalOpen = () =>
|
|
useTerminalStore((s) => s.isOpen)
|
|
|
|
/** 訊息列表 */
|
|
export const useTerminalMessages = () =>
|
|
useTerminalStore((s) => s.messages)
|