Read paths recomputed per-case pass/connectivity independently — report generation, the logs endpoint, and the frontend each derived it, and the frontend's every(passed) recompute ignored the engine's authoritative verdict. Extract resolve_case_verdicts: a single pure seam that prefers stored case_outcomes verbatim and approximates only for legacy runs. The logs endpoint now surfaces case_verdicts so the frontend reads instead of recomputing.
283 lines
8.0 KiB
TypeScript
283 lines
8.0 KiB
TypeScript
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
|
import { runsApi, type CaseSnapshot, type Run } from '../api'
|
|
import {
|
|
initialSessionState,
|
|
sessionReducer,
|
|
type WsEvent,
|
|
} from './sessionReducer'
|
|
|
|
// WebSocket reconnect config
|
|
const WS_MAX_RETRIES = 5
|
|
const WS_BASE_DELAY_MS = 1000 // 1s, 2s, 4s, 8s, 16s (capped at 30s)
|
|
const WS_MAX_DELAY_MS = 30000
|
|
|
|
export interface TurnState {
|
|
roundIndex: number
|
|
message: string
|
|
reply?: string
|
|
latency_ms?: number | null
|
|
pending?: boolean
|
|
error?: string
|
|
}
|
|
|
|
export interface CaseError {
|
|
round?: number
|
|
message: string
|
|
}
|
|
|
|
export interface RuleResultState {
|
|
rule_type: string
|
|
passed: boolean
|
|
score?: number | null
|
|
reason?: string | null
|
|
}
|
|
|
|
export interface CaseState {
|
|
caseId: string
|
|
status: 'waiting' | 'running' | 'done'
|
|
turns: TurnState[]
|
|
ruleResults: RuleResultState[]
|
|
generatedMessages?: string[]
|
|
errors: CaseError[]
|
|
isDynamic?: boolean
|
|
passed?: boolean
|
|
}
|
|
|
|
export interface RunProgress {
|
|
done: number
|
|
total: number
|
|
currentIndex: number
|
|
}
|
|
|
|
export interface RunError {
|
|
code?: string
|
|
message: string
|
|
}
|
|
|
|
export interface RunSession {
|
|
run: Run | null
|
|
cases: CaseState[]
|
|
progress: RunProgress | null
|
|
scenarioSnapshot: Record<string, CaseSnapshot>
|
|
isLive: boolean
|
|
completed: boolean
|
|
finalStatus: 'completed' | 'failed' | 'cancelled' | null
|
|
errorInfo: RunError | null
|
|
select: (run: Run | null, opts?: { live?: boolean }) => void
|
|
cancel: () => Promise<void>
|
|
refreshRun: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* Manages the lifecycle of one evaluation run: WebSocket events, historical
|
|
* log loading, and polling. State transitions are driven by a pure reducer
|
|
* (see `sessionReducer.ts`) so the logic is testable and free of the
|
|
* stale-closure hazards that bit the previous useState-based implementation.
|
|
*/
|
|
export function useRunSession(): RunSession {
|
|
// Run object is managed separately because it's driven by REST, not by
|
|
// the WebSocket event stream.
|
|
const [run, setRun] = useState<Run | null>(null)
|
|
const [state, dispatch] = useReducer(sessionReducer, initialSessionState)
|
|
|
|
const wsRef = useRef<WebSocket | null>(null)
|
|
const pollRef = useRef<number | null>(null)
|
|
const selectedIdRef = useRef<string | null>(null)
|
|
const reconnectTimerRef = useRef<number | null>(null)
|
|
const reconnectCountRef = useRef<number>(0)
|
|
|
|
const clearPolling = () => {
|
|
if (pollRef.current) {
|
|
window.clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
}
|
|
}
|
|
|
|
const clearReconnect = () => {
|
|
if (reconnectTimerRef.current) {
|
|
window.clearTimeout(reconnectTimerRef.current)
|
|
reconnectTimerRef.current = null
|
|
}
|
|
}
|
|
|
|
const closeWs = () => {
|
|
clearReconnect()
|
|
if (wsRef.current) {
|
|
try { wsRef.current.close() } catch { /* noop */ }
|
|
wsRef.current = null
|
|
}
|
|
}
|
|
|
|
// Side effect of `completed` transitioning to true: close the WS and
|
|
// refresh the Run object so the UI shows the final status/summary.
|
|
useEffect(() => {
|
|
if (!state.completed) return
|
|
closeWs()
|
|
const id = selectedIdRef.current
|
|
if (!id) return
|
|
runsApi.get(id).then((res) => setRun(res.data)).catch(() => { /* noop */ })
|
|
}, [state.completed])
|
|
|
|
const loadHistoricalLogs = useCallback(async (runId: string) => {
|
|
try {
|
|
const res = await runsApi.logs(runId)
|
|
const data = res.data
|
|
const snapshot = data.scenario_snapshot ?? {}
|
|
|
|
const caseMap = new Map<string, CaseState>()
|
|
const ensure = (caseId: string): CaseState => {
|
|
let cs = caseMap.get(caseId)
|
|
if (!cs) {
|
|
const snap = snapshot[caseId]
|
|
cs = {
|
|
caseId,
|
|
status: 'done',
|
|
turns: [],
|
|
ruleResults: [],
|
|
errors: [],
|
|
isDynamic: snap?.type === 'dynamic',
|
|
}
|
|
caseMap.set(caseId, cs)
|
|
}
|
|
return cs
|
|
}
|
|
|
|
for (const t of data.turns) {
|
|
const cs = ensure(t.case_id)
|
|
cs.turns.push({
|
|
roundIndex: t.round_index,
|
|
message: t.sent_text,
|
|
reply: t.reply_text,
|
|
latency_ms: t.latency_ms,
|
|
})
|
|
}
|
|
for (const r of data.results) {
|
|
const cs = ensure(r.case_id)
|
|
cs.ruleResults.push({
|
|
rule_type: r.rule_type,
|
|
passed: r.passed,
|
|
score: r.score,
|
|
reason: r.reason,
|
|
})
|
|
}
|
|
const verdicts = data.case_verdicts ?? {}
|
|
for (const cs of caseMap.values()) {
|
|
cs.turns.sort((a, b) => a.roundIndex - b.roundIndex)
|
|
cs.passed = verdicts[cs.caseId]?.passed ?? false
|
|
}
|
|
const cases = Array.from(caseMap.values())
|
|
const total = cases.length
|
|
|
|
dispatch({ type: 'LOAD_HISTORY', cases, progress: { done: total, total, currentIndex: total }, snapshot })
|
|
} catch { /* noop */ }
|
|
}, [])
|
|
|
|
const select = useCallback((r: Run | null, opts?: { live?: boolean }) => {
|
|
closeWs()
|
|
clearPolling()
|
|
dispatch({ type: 'RESET' })
|
|
setRun(r)
|
|
selectedIdRef.current = r?.id ?? null
|
|
reconnectCountRef.current = 0
|
|
|
|
if (!r) return
|
|
|
|
const live = opts?.live ?? (r.status === 'running' || r.status === 'pending')
|
|
dispatch({ type: 'SET_LIVE', live })
|
|
|
|
if (live) {
|
|
connectWs(r.id)
|
|
} else {
|
|
loadHistoricalLogs(r.id)
|
|
dispatch({ type: 'FINALIZE_FROM_RUN', status: r.status, summary: r.summary })
|
|
}
|
|
}, [loadHistoricalLogs]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
function connectWs(runId: string) {
|
|
if (selectedIdRef.current !== runId) return
|
|
|
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
const ws = new WebSocket(`${proto}://${window.location.host}/ws/runs/${runId}`)
|
|
wsRef.current = ws
|
|
|
|
ws.onmessage = (msg) => {
|
|
try {
|
|
const ev = JSON.parse(msg.data) as WsEvent
|
|
dispatch({ type: 'WS_EVENT', event: ev })
|
|
} catch { /* noop */ }
|
|
}
|
|
|
|
ws.onclose = (e) => {
|
|
if (selectedIdRef.current !== runId) return
|
|
|
|
// Normal close (code 1000) or run already completed → finalize
|
|
if (e.code === 1000 || e.wasClean) {
|
|
dispatch({ type: 'SET_LIVE', live: false })
|
|
runsApi.get(runId).then((res) => {
|
|
setRun(res.data)
|
|
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
|
|
}).catch(() => { /* noop */ })
|
|
return
|
|
}
|
|
|
|
// Abnormal close → attempt exponential backoff reconnect
|
|
const retries = reconnectCountRef.current
|
|
if (retries >= WS_MAX_RETRIES) {
|
|
dispatch({ type: 'SET_LIVE', live: false })
|
|
runsApi.get(runId).then((res) => {
|
|
setRun(res.data)
|
|
dispatch({ type: 'FINALIZE_FROM_RUN', status: res.data.status, summary: res.data.summary })
|
|
}).catch(() => { /* noop */ })
|
|
return
|
|
}
|
|
|
|
const delay = Math.min(WS_BASE_DELAY_MS * Math.pow(2, retries), WS_MAX_DELAY_MS)
|
|
reconnectCountRef.current = retries + 1
|
|
reconnectTimerRef.current = window.setTimeout(() => connectWs(runId), delay)
|
|
}
|
|
}
|
|
|
|
const cancel = useCallback(async () => {
|
|
if (!run) return
|
|
await runsApi.cancel(run.id)
|
|
}, [run])
|
|
|
|
const refreshRun = useCallback(async () => {
|
|
if (!run) return
|
|
try {
|
|
const res = await runsApi.get(run.id)
|
|
setRun(res.data)
|
|
} catch { /* noop */ }
|
|
}, [run])
|
|
|
|
// Poll during live runs to keep the Run object fresh (status/summary/completed_at)
|
|
useEffect(() => {
|
|
if (!state.isLive || !run) {
|
|
clearPolling()
|
|
return
|
|
}
|
|
pollRef.current = window.setInterval(() => {
|
|
runsApi.get(run.id).then((res) => {
|
|
if (selectedIdRef.current === run.id) setRun(res.data)
|
|
}).catch(() => { /* noop */ })
|
|
}, 3000)
|
|
return () => clearPolling()
|
|
}, [state.isLive, run?.id])
|
|
|
|
useEffect(() => () => { closeWs(); clearPolling(); clearReconnect() }, [])
|
|
|
|
return {
|
|
run,
|
|
cases: state.cases,
|
|
progress: state.progress,
|
|
scenarioSnapshot: state.scenarioSnapshot,
|
|
isLive: state.isLive,
|
|
completed: state.completed,
|
|
finalStatus: state.finalStatus,
|
|
errorInfo: state.errorInfo,
|
|
select,
|
|
cancel,
|
|
refreshRun,
|
|
}
|
|
}
|