CUB-125: address Grimm review — tests, type fixes, error state circuit breaker
- Add missing 'offline' to AgentStatus union type (types/index.ts) - Add max-retry circuit breaker to useSSE; error state is now reachable - Wire typed SSE payloads (SSEPayloadMap discriminated union) into useRealtimeSync - Add Vitest + 20 unit tests: useSSE lifecycle, back-off, circuit breaker, event parsing, cleanup; useRealtimeSync event-to-invalidation mapping - Rebase on dev to remove stale CUB-119 legacy-deletion commit and align CI workflow (dev already consolidated into single dev.yml) - Tests: npm test → 20/20 pass; Build: npm run build → 0 errors
This commit is contained in:
@@ -18,12 +18,18 @@ export interface UseSSEOptions {
|
||||
onMessage?: (msg: SSEMessage) => void
|
||||
/** Called when connection opens or reconnects */
|
||||
onOpen?: () => void
|
||||
/** Called on unrecoverable error */
|
||||
/** Called on every connection error (both transient and terminal) */
|
||||
onError?: (err: Event) => void
|
||||
/** Base delay in ms before the first reconnect attempt (default 1 000) */
|
||||
reconnectBaseMs?: number
|
||||
/** Maximum reconnect delay in ms (default 30 000) */
|
||||
reconnectMaxMs?: number
|
||||
/**
|
||||
* Maximum number of consecutive reconnect attempts before giving up.
|
||||
* When the limit is reached, status transitions to 'error'.
|
||||
* Default undefined (unlimited).
|
||||
*/
|
||||
reconnectLimit?: number
|
||||
/** Set false to disable auto-connect (useful in tests) */
|
||||
enabled?: boolean
|
||||
}
|
||||
@@ -35,9 +41,14 @@ const SSE_EVENTS = ['agent.status', 'agent.task', 'agent.progress', 'fleet.updat
|
||||
*
|
||||
* Handles:
|
||||
* - Initial connection on mount
|
||||
* - Exponential back-off reconnection on drop
|
||||
* - Exponential back-off reconnection on drop (1s → 2s → 4s … capped at reconnectMaxMs)
|
||||
* - Circuit-breaker: after reconnectLimit consecutive failures, transitions to 'error'
|
||||
* - Cleanup on unmount
|
||||
* - All four event types: agent.status, agent.task, agent.progress, fleet.update
|
||||
* - All five event types: agent.status, agent.task, agent.progress, fleet.update, connected
|
||||
*
|
||||
* The 'connected' SSE event is an application-level handshake sent by the backend
|
||||
* after the transport opens. This is distinct from onOpen, which fires at the
|
||||
* transport level when the EventSource HTTP connection is established.
|
||||
*/
|
||||
export function useSSE({
|
||||
url = '/api/events',
|
||||
@@ -46,6 +57,7 @@ export function useSSE({
|
||||
onError,
|
||||
reconnectBaseMs = 1_000,
|
||||
reconnectMaxMs = 30_000,
|
||||
reconnectLimit,
|
||||
enabled = true,
|
||||
}: UseSSEOptions = {}): { status: SSEStatus } {
|
||||
const [status, setStatus] = useState<SSEStatus>('connecting')
|
||||
@@ -100,12 +112,20 @@ export function useSSE({
|
||||
|
||||
onErrorRef.current?.(evt)
|
||||
|
||||
reconnectAttemptRef.current += 1
|
||||
|
||||
// Circuit-breaker: give up after reconnectLimit consecutive failures
|
||||
if (reconnectLimit !== undefined && reconnectAttemptRef.current > reconnectLimit) {
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
// Exponential back-off: 1s, 2s, 4s … capped at reconnectMaxMs
|
||||
// Note: attempt is 1-based here (already incremented), so we use attempt-1 for the exponent
|
||||
const delay = Math.min(
|
||||
reconnectBaseMs * 2 ** reconnectAttemptRef.current,
|
||||
reconnectBaseMs * 2 ** (reconnectAttemptRef.current - 1),
|
||||
reconnectMaxMs,
|
||||
)
|
||||
reconnectAttemptRef.current += 1
|
||||
setStatus('reconnecting')
|
||||
|
||||
clearReconnectTimer()
|
||||
@@ -128,7 +148,8 @@ export function useSSE({
|
||||
})
|
||||
}
|
||||
|
||||
// Catch-all for unnamed events (type == 'message')
|
||||
// Catch-all for unnamed events (type == 'message').
|
||||
// Won't fire for the named events registered via addEventListener above.
|
||||
es.onmessage = (evt: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
let data: unknown = evt.data
|
||||
@@ -139,7 +160,7 @@ export function useSSE({
|
||||
}
|
||||
onMessageRef.current?.({ type: 'message', data })
|
||||
}
|
||||
}, [url, enabled, reconnectBaseMs, reconnectMaxMs, clearReconnectTimer])
|
||||
}, [url, enabled, reconnectBaseMs, reconnectMaxMs, reconnectLimit, clearReconnectTimer])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
Reference in New Issue
Block a user