feat(ws): consume gateway change events (issue #784) (#837)

* feat(ws): consume gateway change events (cron/sessions/platforms/pairing) for targeted refresh (issue #784)

* test(chat): complete catch-all request stub — kill frozen-collector capture race (slash-focus flake, 4645a2b CI)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 13:48:17 +03:00
1 parent 7d60057156
commit f34bfa7cf8
15 files changed
+320 -4

No files matched your search

@@ -9,7 +9,9 @@ import com.m57.hermescontrol.data.model.TelegramOnboardingStartRequest
import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -60,6 +62,24 @@ class ChannelsViewModel :
private var launchJob: Job? = null
init {
// Issue #784: gateway broadcasts platforms.changed — refresh silently
// (no spinner) instead of waiting for a manual pull.
refreshOnChange(
eventType = ChangeEvents.PLATFORMS,
apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
onSuccess = { data ->
_uiState.update {
it.copy(
platforms = data.platforms.orEmpty(),
gatewayStartCommand = data.gatewayStartCommand,
envPath = data.envPath,
)
}
},
)
}
fun loadPlatforms() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
@@ -130,6 +130,9 @@ object ChatWsEventReducer {
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
// Change events (issue #784) are consumed by their screens' ViewModels
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
}
// ── GatewayReady ──────────────────────────────────────────────────
@@ -3,7 +3,9 @@ package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ChangeEventHub
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
interface ToastHost {
@@ -31,3 +33,41 @@ inline fun <T> ViewModel.safeLaunchLoad(
}
}
}
/**
* Collect a gateway change event ([com.m57.hermescontrol.data.ws.ChangeEvents])
* from [ChangeEventHub] and run a **silent** refresh: no loading spinner, no
* error surface — stale data stays in place on failure. At most one request
* runs at a time; events arriving mid-flight are skipped, so a
* `sessions.changed` burst during a long streaming turn (the gateway floors
* those at 2s) cannot pile up requests.
*
* Call from `init`. Backends without `change_events` never broadcast, and in
* unit tests the hub is never fed (HermesWsClient is mocked or uninitialized),
* so this is a no-op there — exactly the "slow backstop" contract of issue
* #784.
*/
inline fun <T> ViewModel.refreshOnChange(
eventType: String,
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onSuccess: (T) -> Unit,
): Job {
var refreshInFlight = false
return viewModelScope.launch {
ChangeEventHub.events
.filter { it.type == eventType }
.collect { _ ->
if (refreshInFlight) return@collect
refreshInFlight = true
try {
// No Dispatchers.IO hop — see safeLaunchLoad.
val result = apiCall()
if (result is NetworkResult.Success) {
onSuccess(result.data)
}
} finally {
refreshInFlight = false
}
}
}
}
@@ -11,8 +11,10 @@ import com.m57.hermescontrol.data.model.UpdateCronJobRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -83,6 +85,16 @@ class CronJobsViewModel :
private var loadJob: Job? = null
init {
// Issue #784: gateway broadcasts cron.changed — refresh the list
// silently on change instead of waiting for a manual pull.
refreshOnChange(
eventType = ChangeEvents.CRON,
apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
onSuccess = { data -> _uiState.update { it.copy(jobs = data.orEmpty()) } },
)
}
fun loadCronJobs() {
loadJob =
safeLaunchLoad(
@@ -8,7 +8,9 @@ import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -36,6 +38,16 @@ class PairingViewModel :
private var launchJob: Job? = null
init {
// Issue #784: gateway broadcasts pairing.changed — refresh silently
// (no spinner) instead of waiting for a manual pull.
refreshOnChange(
eventType = ChangeEvents.PAIRING,
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
onSuccess = { data -> _uiState.update { it.copy(pairing = data) } },
)
}
fun loadPairing() {
launchJob =
safeLaunchLoad(
@@ -10,7 +10,9 @@ import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.refreshOnChange
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -62,6 +64,31 @@ class SessionsViewModel :
private var loadJob: Job? = null
private var statsJob: Job? = null
init {
// Issue #784: gateway broadcasts sessions.changed — refresh the list
// silently (no spinner, no selection reset) instead of blind polling.
refreshOnChange(
eventType = ChangeEvents.SESSIONS,
apiCall = {
safeApiCall {
ApiClient.hermesApi.getSessions(
limit = PAGE_SIZE,
offset = 0,
order = "recent",
)
}
},
onSuccess = { data ->
_uiState.update {
it.copy(
sessions = data.sessions.orEmpty(),
total = data.total,
)
}
},
)
}
/** Page size sent to the server — matches the default the gateway uses. */
private companion object {
const val PAGE_SIZE = 20