* 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:
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 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user