feat(kanban): add KanbanSyncCoordinator and harden WebSocket event decoding
This commit is contained in:
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. */
|
||||
|
||||
Reference in new issue
Block a user