feat(kanban): add KanbanSyncCoordinator and harden WebSocket event decoding

This commit is contained in:
M57 committed 2026-09-14 22:51:20 +03:00
1 parent 6fd697f2b0
commit da03500b30
4 files changed
+273

No files matched your search

@@ -0,0 +1,125 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface KanbanSyncStatus {
data object Live : KanbanSyncStatus
data class Polling(
val lastSyncEpoch: Long = System.currentTimeMillis(),
) : KanbanSyncStatus
data object AuthFailed : KanbanSyncStatus
data object Disconnected : KanbanSyncStatus
}
class KanbanSyncCoordinator(
private val repository: KanbanRepository,
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val reloadDebounceMs: Long = 250L,
private val pollIntervalMs: Long = 60_000L,
private val nudgeDebounceMs: Long = 400L,
) {
private var eventsClient: KanbanEventsClient? = null
private var activeBoard: String? = null
private var reloadJob: Job? = null
private var pollJob: Job? = null
private var nudgeJob: Job? = null
private val _syncStatus = MutableStateFlow<KanbanSyncStatus>(KanbanSyncStatus.Disconnected)
val syncStatus: StateFlow<KanbanSyncStatus> = _syncStatus.asStateFlow()
fun connect(
scope: CoroutineScope,
board: String,
onReload: () -> Unit,
) {
if (activeBoard == board && _syncStatus.value == KanbanSyncStatus.Live) return
activeBoard = board
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
client.connect(
scope = scope,
board = board,
onEvents = {
reloadJob?.cancel()
reloadJob =
scope.launch {
delay(reloadDebounceMs)
onReload()
}
},
onStatus = { status ->
when (status) {
KanbanLiveStatus.CONNECTED -> {
_syncStatus.value = KanbanSyncStatus.Live
}
KanbanLiveStatus.DISCONNECTED -> {
_syncStatus.value = KanbanSyncStatus.Polling()
}
KanbanLiveStatus.AUTH_FAILED -> {
_syncStatus.value = KanbanSyncStatus.AuthFailed
}
}
},
)
}
fun startVisiblePolling(
scope: CoroutineScope,
board: String,
onPoll: () -> Unit,
) {
pollJob?.cancel()
pollJob =
scope.launch {
while (true) {
delay(pollIntervalMs)
if (_syncStatus.value !is KanbanSyncStatus.Live) {
onPoll()
_syncStatus.value = KanbanSyncStatus.Polling(System.currentTimeMillis())
}
}
}
}
fun stopVisiblePolling() {
pollJob?.cancel()
pollJob = null
}
fun scheduleNudge(
scope: CoroutineScope,
board: String,
) {
nudgeJob?.cancel()
nudgeJob =
scope.launch {
delay(nudgeDebounceMs)
repository.nudgeDispatcher(board)
}
}
fun disconnect() {
activeBoard = null
reloadJob?.cancel()
reloadJob = null
pollJob?.cancel()
pollJob = null
nudgeJob?.cancel()
nudgeJob = null
eventsClient?.disconnect()
_syncStatus.value = KanbanSyncStatus.Disconnected
}
}
@@ -8,11 +8,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
@@ -31,11 +39,37 @@ enum class KanbanLiveStatus {
AUTH_FAILED,
}
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
object StringOrLongSerializer : KSerializer<String?> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringOrLong", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
value: String?,
) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeString(value)
}
}
override fun deserialize(decoder: Decoder): String? {
val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
val element = jsonDecoder.decodeJsonElement()
if (element is JsonPrimitive) {
return element.content
}
return null
}
}
/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */
@Serializable
data class KanbanEvent(
val id: Long,
@SerialName("task_id") val taskId: String? = null,
@Serializable(with = StringOrLongSerializer::class)
@SerialName("run_id") val runId: String? = null,
val kind: String,
/** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */