feat(sessions): show live status for active sessions in History (#1101) (#1104)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 14:45:33 +03:00
1 parent 52e0104cd2
commit 7e8af8db01
16 files changed
+1485 -12

No files matched your search

@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* High-level live status for a session in History.
*/
enum class SessionLiveStatus {
WORKING,
WAITING,
}
/**
* RPC response from `session.active_list`.
*/
@Serializable
data class ActiveSessionsResponse(
val sessions: List<ActiveSessionItem> = emptyList(),
)
/**
* Individual item in `session.active_list`.
*/
@Serializable
data class ActiveSessionItem(
val id: String? = null,
@SerialName("session_key")
val sessionKey: String? = null,
val status: String? = null,
@SerialName("last_active")
val lastActive: Double? = null,
)
/**
* Parsed authoritative snapshot of live sessions.
*/
data class LiveSessionSnapshot(
val statusByStoredId: Map<String, SessionLiveStatus> = emptyMap(),
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
)
@@ -0,0 +1,169 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.SessionLiveStatus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.serializer
/**
* Interface for polling and observing live session statuses.
*/
interface SessionLiveStatusSource {
suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot?
val events: Flow<WsEvent>
val connectionStatus: StateFlow<ConnectionStatus>
}
/**
* Decodes untyped RPC responses from `session.active_list` into a [LiveSessionSnapshot].
*/
object SessionLiveStatusDecoder {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
fun decodeSnapshot(rawResult: Any?): LiveSessionSnapshot? {
if (rawResult == null) return null
val response =
try {
decodeResponse(rawResult)
} catch (_: Exception) {
return null
} ?: return null
val statusByStoredId = mutableMapOf<String, SessionLiveStatus>()
val storedIdByRuntimeId = mutableMapOf<String, String>()
for (item in response.sessions) {
val runtimeId = item.id?.trim()
val storedId = item.sessionKey?.trim()
if (runtimeId.isNullOrEmpty() || storedId.isNullOrEmpty()) {
continue
}
storedIdByRuntimeId[runtimeId] = storedId
when (item.status?.trim()?.lowercase()) {
"working" -> {
statusByStoredId[storedId] = SessionLiveStatus.WORKING
}
"waiting" -> {
statusByStoredId[storedId] = SessionLiveStatus.WAITING
}
else -> {
// idle, starting, unknown -> no live indicator
}
}
}
return LiveSessionSnapshot(
statusByStoredId = statusByStoredId,
storedIdByRuntimeId = storedIdByRuntimeId,
)
}
private fun decodeResponse(result: Any): ActiveSessionsResponse? {
val element: JsonElement =
when (result) {
is JsonElement -> {
result
}
is Map<*, *> -> {
anyToJsonElement(result)
}
is List<*> -> {
JsonArray(result.map { anyToJsonElement(it) })
}
else -> {
val str = result.toString()
try {
json.parseToJsonElement(str)
} catch (_: Exception) {
return null
}
}
}
if (element !is JsonObject || !element.containsKey("sessions")) {
return null
}
return json.decodeFromJsonElement(serializer<ActiveSessionsResponse>(), element)
}
@Suppress("UNCHECKED_CAST")
internal fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
)
}
is List<*> -> {
JsonArray(value.map { anyToJsonElement(it) })
}
is String -> {
JsonPrimitive(value)
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
}
/**
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
*/
class HermesSessionLiveStatusSource(
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
},
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
) : SessionLiveStatusSource {
override val events: Flow<WsEvent> by lazy { eventsProvider() }
override val connectionStatus: StateFlow<ConnectionStatus> by lazy { connectionStatusProvider() }
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
try {
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
SessionLiveStatusDecoder.decodeSnapshot(raw)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}