refactor(#1375): type process list and kill RPC params (#1390)

This commit is contained in:
m57
2026-09-29 22:36:35 +03:00
committed by GitHub
parent 27ce528005
commit 9a09974081
6 changed files with 113 additions and 56 deletions
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProcessKillParams(
@SerialName("session_id") val sessionId: String,
@SerialName("process_id") val processId: String,
)
@@ -347,6 +347,22 @@ object RpcMethods {
JsonElement.serializer(), JsonElement.serializer(),
) )
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROCESS_LIST: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.PROCESS_LIST,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROCESS_KILL: RpcMethod<ProcessKillParams, JsonElement> =
RpcMethod(
WsMethods.PROCESS_KILL,
ProcessKillParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */ /** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = val all: List<RpcMethod<*, *>> =
listOf( listOf(
@@ -392,5 +408,7 @@ object RpcMethods {
CONNECTORS_OPERATION_WAKE, CONNECTORS_OPERATION_WAKE,
SUBAGENT_LIST, SUBAGENT_LIST,
SUBAGENT_TAIL, SUBAGENT_TAIL,
PROCESS_LIST,
PROCESS_KILL,
) )
} }
@@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ProcessKillParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import com.m57.hermescontrol.data.ws.toAny
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -67,13 +70,8 @@ class ProcessesViewModel :
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val result = val result = HermesWsClient.call(RpcMethods.PROCESS_LIST, SessionIdParams(sessionId))
HermesWsClient val processes = parseProcessList(result.toAny())
.request(
WsMethods.PROCESS_LIST,
mapOf("session_id" to sessionId),
).await()
val processes = parseProcessList(result)
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@@ -105,14 +103,7 @@ class ProcessesViewModel :
_uiState.update { it.copy(killingId = processId) } _uiState.update { it.copy(killingId = processId) }
viewModelScope.launch { viewModelScope.launch {
try { try {
HermesWsClient HermesWsClient.call(RpcMethods.PROCESS_KILL, ProcessKillParams(sessionId, processId))
.request(
WsMethods.PROCESS_KILL,
mapOf(
"process_id" to processId,
"session_id" to sessionId,
),
).await()
_uiState.update { it.copy(killingId = null, toastMessage = "Process killed") } _uiState.update { it.copy(killingId = null, toastMessage = "Process killed") }
load() load()
} catch (e: Exception) { } catch (e: Exception) {
@@ -342,8 +342,6 @@ class RawRpcParamsRatchetTest {
"ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::FILE_ATTACH",
"ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES",
"ui/chat/ChatViewModel.kt::SLASH_EXEC", "ui/chat/ChatViewModel.kt::SLASH_EXEC",
"ui/process/ProcessesViewModel.kt::PROCESS_KILL",
"ui/process/ProcessesViewModel.kt::PROCESS_LIST",
) )
} }
} }
@@ -0,0 +1,34 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ProcessTypesTest {
@Test
fun killEncodesExactlySessionAndProcessIds() {
val encoded =
OkHttpProvider.json.encodeToJsonElement(
ProcessKillParams.serializer(),
ProcessKillParams(sessionId = "s1", processId = "p1"),
)
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("process_id", "p1")
},
encoded,
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all)
assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all)
assertEquals("process.list", RpcMethods.PROCESS_LIST.name)
assertEquals("process.kill", RpcMethods.PROCESS_KILL.name)
}
}
@@ -3,17 +3,23 @@ package com.m57.hermescontrol.ui.process
import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ProcessKillParams
import io.mockk.every import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import io.mockk.coEvery
import io.mockk.mockkObject import io.mockk.mockkObject
import io.mockk.slot import io.mockk.slot
import io.mockk.unmockkAll import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -42,34 +48,40 @@ class ProcessesViewModelTest {
@Test @Test
fun `process list parses running and exited entries`() { fun `process list parses running and exited entries`() {
val raw = val raw: JsonElement =
mapOf( buildJsonObject {
"processes" to put(
listOf( "processes",
mapOf( buildJsonArray {
"session_id" to "p1", add(
"command" to "python train.py --epochs 10", buildJsonObject {
"pid" to 1234, put("session_id", "p1")
"status" to "running", put("command", "python train.py --epochs 10")
"uptime_seconds" to 95, put("pid", 1234)
"cwd" to "/work", put("status", "running")
), put("uptime_seconds", 95)
mapOf( put("cwd", "/work")
"session_id" to "p2", },
"command" to "echo done", )
"status" to "exited", add(
"exit_code" to 0, buildJsonObject {
), put("session_id", "p2")
), put("command", "echo done")
) put("status", "exited")
put("exit_code", 0)
},
)
},
)
}
every { val listParams = slot<SessionIdParams>()
HermesWsClient.request(WsMethods.PROCESS_LIST, any()) coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, capture(listParams), any(), any()) } returns raw
} returns CompletableDeferred(raw)
val vm = ProcessesViewModel() val vm = ProcessesViewModel()
vm.load() vm.load()
assertEquals(SessionIdParams("sess-1"), listParams.captured)
val procs = vm.uiState.value.processes val procs = vm.uiState.value.processes
assertEquals(2, procs.size) assertEquals(2, procs.size)
@@ -90,26 +102,20 @@ class ProcessesViewModelTest {
@Test @Test
fun `kill issues process kill with session-scoped params then refreshes`() { fun `kill issues process kill with session-scoped params then refreshes`() {
var listCalls = 0 var listCalls = 0
val killParamsSlot = slot<Map<String, Any>>() val killParamsSlot = slot<ProcessKillParams>()
every { coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, any(), any(), any()) } answers {
HermesWsClient.request(WsMethods.PROCESS_LIST, any())
} answers {
listCalls++ listCalls++
CompletableDeferred(mapOf("processes" to emptyList<Any>())) buildJsonObject { put("processes", JsonArray(emptyList())) }
} }
every { coEvery { HermesWsClient.call(RpcMethods.PROCESS_KILL, capture(killParamsSlot), any(), any()) } answers {
HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot)) buildJsonObject { put("killed", true) }
} answers {
CompletableDeferred(mapOf("killed" to true))
} }
val vm = ProcessesViewModel() val vm = ProcessesViewModel()
vm.kill("p1") vm.kill("p1")
val killParams = killParamsSlot.captured assertEquals(ProcessKillParams(sessionId = "sess-1", processId = "p1"), killParamsSlot.captured)
assertEquals("p1", killParams["process_id"])
assertEquals("sess-1", killParams["session_id"])
// list is called once on init (session emitted) + once after kill refresh // list is called once on init (session emitted) + once after kill refresh
assertTrue(listCalls >= 2) assertTrue(listCalls >= 2)
assertNull(vm.uiState.value.killingId) assertNull(vm.uiState.value.killingId)