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

@@ -0,0 +1,36 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Fan-out hub for gateway change events ([WsEvent.ChangeEvent]).
*
* [HermesWsClient] forwards the change events it parses into this hub; screens
* subscribe here instead of touching the [HermesWsClient] singleton directly.
*
* Why a separate hub: [HermesWsClient] is an Android-touching object whose real
* static init must never run inside unit tests (it poisons every later
* `mockkObject(HermesWsClient)` in the JVM). This hub is pure Kotlin — safe to
* subscribe to from a ViewModel's `init` in any environment. When the client is
* mocked in tests, nothing forwards into the hub and subscribers simply sit
* idle, which is also the correct behavior for backends without
* `change_events` (issue #784).
*/
object ChangeEventHub {
private val _events =
MutableSharedFlow<WsEvent.ChangeEvent>(
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Collect this from ViewModels to react to gateway change events. */
val events: SharedFlow<WsEvent.ChangeEvent> = _events.asSharedFlow()
/** Non-suspending emit — never blocks the WS event loop. */
fun emit(event: WsEvent.ChangeEvent) {
_events.tryEmit(event)
}
}
@@ -0,0 +1,20 @@
package com.m57.hermescontrol.data.ws
/**
* Change-event vocabulary for [WsEvent.ChangeEvent].
*
* The gateway advertises `change_events: true` in the `gateway.ready`
* handshake and broadcasts these when its on-disk signatures move
* (`tui_gateway/server.py` `_CHANGE_WATCHES`), so clients can demote
* blind polling to a slow backstop. Backends without the feature never
* broadcast — consumers simply stay quiet.
*
* `pet.changed` is deliberately absent: mobile has no pet feature, so it
* is neither parsed nor consumed.
*/
object ChangeEvents {
const val CRON = "cron.changed"
const val SESSIONS = "sessions.changed"
const val PLATFORMS = "platforms.changed"
const val PAIRING = "pairing.changed"
}
@@ -151,6 +151,13 @@ object EventParser {
WsEvent.BackgroundComplete(payload)
}
// Change events (issue #784): gateway watches on-disk signatures
// and broadcasts these so screens can refresh on change. pet.changed
// intentionally absent — mobile has no pet feature.
"cron.changed", "sessions.changed", "platforms.changed", "pairing.changed" -> {
WsEvent.ChangeEvent(eventType, payload)
}
"review.summary" -> {
val text = (payload?.get("text") as? String)?.trim() ?: ""
WsEvent.ReviewSummary(text, sessionId)
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
@@ -237,6 +238,14 @@ object HermesWsClient {
}
}
}
// Forward parsed change events (issue #784) to the hub so screens can
// refresh on change without touching this singleton — its real static
// init must never run inside unit tests (see ChangeEventHub).
wsScope.launch {
events
.filterIsInstance<WsEvent.ChangeEvent>()
.collect { ChangeEventHub.emit(it) }
}
}
// ── Connection helpers ────────────────────────────────────────────────
@@ -216,6 +216,22 @@ sealed class WsEvent {
val message: String?,
) : WsEvent()
// ── Gateway change events ───────────────────────────────────────────
/**
* Backend "something changed" broadcast. The gateway advertises
* `change_events: true` in the `gateway.ready` handshake and emits one of
* these when its on-disk signatures move (see [ChangeEvents] for the
* vocabulary), so screens can refresh on change instead of blind-polling.
* Backends without the feature simply never broadcast — consumers stay
* quiet. `pet.changed` exists on the backend but is intentionally NOT
* parsed: mobile has no pet feature.
*/
data class ChangeEvent(
val type: String,
val data: Map<String, Any?>? = null,
) : WsEvent()
// ── Background job completion ──────────────────────────────────────
/**