feat(tui-03): add SnapshotPhaseReducer with buffering and dedup logic
Buffers live events during snapshot phase, drains on SnapshotComplete, deduplicates live events by per-session cursor, and resets on disconnect.
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
package com.correx.apps.tui.reducer
|
||||||
|
|
||||||
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
|
import com.correx.apps.tui.input.Action
|
||||||
|
import com.correx.apps.tui.state.TuiState
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
object SnapshotPhaseReducer {
|
||||||
|
private val log = LoggerFactory.getLogger(SnapshotPhaseReducer::class.java)
|
||||||
|
|
||||||
|
data class Result(val state: TuiState, val actionsToDispatch: List<Action>)
|
||||||
|
|
||||||
|
fun process(state: TuiState, action: Action): Result = when (action) {
|
||||||
|
is Action.Disconnected -> Result(
|
||||||
|
state.copy(snapshotPhase = true, pendingEvents = emptyList(), cursors = emptyMap()),
|
||||||
|
listOf(action),
|
||||||
|
)
|
||||||
|
is Action.ServerEventReceived -> handleServerEvent(state, action)
|
||||||
|
else -> Result(state, listOf(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleServerEvent(state: TuiState, action: Action.ServerEventReceived): Result {
|
||||||
|
val msg = action.message
|
||||||
|
return when {
|
||||||
|
msg is ServerMessage.SessionSnapshot -> {
|
||||||
|
val newCursors = state.cursors + (msg.sessionId.value to msg.lastSessionSequence)
|
||||||
|
Result(state.copy(cursors = newCursors), listOf(action))
|
||||||
|
}
|
||||||
|
msg is ServerMessage.SnapshotComplete -> {
|
||||||
|
val replayActions = state.pendingEvents.map { Action.ServerEventReceived(it) }
|
||||||
|
Result(
|
||||||
|
state.copy(snapshotPhase = false, pendingEvents = emptyList()),
|
||||||
|
replayActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.snapshotPhase -> {
|
||||||
|
Result(state.copy(pendingEvents = state.pendingEvents + msg), emptyList())
|
||||||
|
}
|
||||||
|
else -> dedupeLive(state, action, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dedupeLive(
|
||||||
|
state: TuiState,
|
||||||
|
action: Action.ServerEventReceived,
|
||||||
|
msg: ServerMessage,
|
||||||
|
): Result {
|
||||||
|
val sid = sessionIdOf(msg) ?: return Result(state, listOf(action))
|
||||||
|
val seq = sessionSequenceOf(msg) ?: return Result(state, listOf(action))
|
||||||
|
val cursor = state.cursors[sid] ?: 0L
|
||||||
|
return when {
|
||||||
|
seq <= cursor -> {
|
||||||
|
log.debug("dropping duplicate sessionSequence={} for sid={} (cursor={})", seq, sid, cursor)
|
||||||
|
Result(state, emptyList())
|
||||||
|
}
|
||||||
|
seq > cursor + 1 -> {
|
||||||
|
log.warn("gap detected for sid={}: cursor={} -> incoming seq={} (applying anyway)", sid, cursor, seq)
|
||||||
|
Result(state.copy(cursors = state.cursors + (sid to seq)), listOf(action))
|
||||||
|
}
|
||||||
|
else -> Result(state.copy(cursors = state.cursors + (sid to seq)), listOf(action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sessionIdOf(msg: ServerMessage): String? = when (msg) {
|
||||||
|
is ServerMessage.SessionStarted -> msg.sessionId.value
|
||||||
|
is ServerMessage.SessionPaused -> msg.sessionId.value
|
||||||
|
is ServerMessage.SessionCompleted -> msg.sessionId.value
|
||||||
|
is ServerMessage.SessionFailed -> msg.sessionId.value
|
||||||
|
is ServerMessage.StageStarted -> msg.sessionId.value
|
||||||
|
is ServerMessage.StageCompleted -> msg.sessionId.value
|
||||||
|
is ServerMessage.StageFailed -> msg.sessionId.value
|
||||||
|
is ServerMessage.InferenceStarted -> msg.sessionId.value
|
||||||
|
is ServerMessage.InferenceCompleted -> msg.sessionId.value
|
||||||
|
is ServerMessage.InferenceTimedOut -> msg.sessionId.value
|
||||||
|
is ServerMessage.ToolStarted -> msg.sessionId.value
|
||||||
|
is ServerMessage.ToolCompleted -> msg.sessionId.value
|
||||||
|
is ServerMessage.ToolFailed -> msg.sessionId.value
|
||||||
|
is ServerMessage.ToolRejected -> msg.sessionId.value
|
||||||
|
is ServerMessage.ApprovalRequired -> msg.sessionId.value
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sessionSequenceOf(msg: ServerMessage): Long? = when (msg) {
|
||||||
|
is ServerMessage.SessionMessage -> msg.sessionSequence
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.correx.apps.tui.reducer
|
||||||
|
|
||||||
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
|
import com.correx.apps.tui.input.Action
|
||||||
|
import com.correx.apps.tui.state.TuiState
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SnapshotPhaseReducerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `during snapshotPhase, three live ServerEventReceived actions are buffered and sessions unchanged`() {
|
||||||
|
var state = TuiState(snapshotPhase = true)
|
||||||
|
val allDispatched = mutableListOf<Action>()
|
||||||
|
|
||||||
|
repeat(3) {
|
||||||
|
val msg = ServerMessage.SessionStarted(
|
||||||
|
sessionId = SessionId("s1"),
|
||||||
|
workflowId = "wf",
|
||||||
|
sequence = 1L,
|
||||||
|
sessionSequence = 1L,
|
||||||
|
)
|
||||||
|
val result = SnapshotPhaseReducer.process(state, Action.ServerEventReceived(msg))
|
||||||
|
state = result.state
|
||||||
|
allDispatched += result.actionsToDispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(3, state.pendingEvents.size)
|
||||||
|
assertTrue(allDispatched.isEmpty())
|
||||||
|
assertTrue(state.sessions.sessions.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user