From 9a0997408185cae048a609b7870d4fa8a521deea Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:36:35 +0300 Subject: [PATCH] refactor(#1375): type process list and kill RPC params (#1390) --- .../data/ws/contract/ProcessKillParams.kt | 10 +++ .../data/ws/contract/RpcMethod.kt | 18 ++++ .../ui/process/ProcessesViewModel.kt | 23 ++---- .../data/ws/RawRpcParamsRatchetTest.kt | 2 - .../data/ws/contract/ProcessTypesTest.kt | 34 ++++++++ .../ui/process/ProcessesViewModelTest.kt | 82 ++++++++++--------- 6 files changed, 113 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt new file mode 100644 index 00000000..0d4412df --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index fdf91765..249962e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -347,6 +347,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROCESS_LIST: RpcMethod = + RpcMethod( + WsMethods.PROCESS_LIST, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROCESS_KILL: RpcMethod = + RpcMethod( + WsMethods.PROCESS_KILL, + ProcessKillParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -392,5 +408,7 @@ object RpcMethods { CONNECTORS_OPERATION_WAKE, SUBAGENT_LIST, SUBAGENT_TAIL, + PROCESS_LIST, + PROCESS_KILL, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt index 6a67378b..c9047f35 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.session.ActiveSessionHolder 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,13 +70,8 @@ class ProcessesViewModel : _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { try { - val result = - HermesWsClient - .request( - WsMethods.PROCESS_LIST, - mapOf("session_id" to sessionId), - ).await() - val processes = parseProcessList(result) + val result = HermesWsClient.call(RpcMethods.PROCESS_LIST, SessionIdParams(sessionId)) + val processes = parseProcessList(result.toAny()) _uiState.update { it.copy( isLoading = false, @@ -105,14 +103,7 @@ class ProcessesViewModel : _uiState.update { it.copy(killingId = processId) } viewModelScope.launch { try { - HermesWsClient - .request( - WsMethods.PROCESS_KILL, - mapOf( - "process_id" to processId, - "session_id" to sessionId, - ), - ).await() + HermesWsClient.call(RpcMethods.PROCESS_KILL, ProcessKillParams(sessionId, processId)) _uiState.update { it.copy(killingId = null, toastMessage = "Process killed") } load() } catch (e: Exception) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 3831b09f..c105d60e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -342,8 +342,6 @@ class RawRpcParamsRatchetTest { "ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", "ui/chat/ChatViewModel.kt::SLASH_EXEC", - "ui/process/ProcessesViewModel.kt::PROCESS_KILL", - "ui/process/ProcessesViewModel.kt::PROCESS_LIST", ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt new file mode 100644 index 00000000..02544cad --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt index 19455158..fbd39b22 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt @@ -3,17 +3,23 @@ package com.m57.hermescontrol.ui.process import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods -import io.mockk.every +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 io.mockk.coEvery import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain 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.Assert.assertEquals import org.junit.Assert.assertFalse @@ -42,34 +48,40 @@ class ProcessesViewModelTest { @Test fun `process list parses running and exited entries`() { - val raw = - mapOf( - "processes" to - listOf( - mapOf( - "session_id" to "p1", - "command" to "python train.py --epochs 10", - "pid" to 1234, - "status" to "running", - "uptime_seconds" to 95, - "cwd" to "/work", - ), - mapOf( - "session_id" to "p2", - "command" to "echo done", - "status" to "exited", - "exit_code" to 0, - ), - ), - ) + val raw: JsonElement = + buildJsonObject { + put( + "processes", + buildJsonArray { + add( + buildJsonObject { + put("session_id", "p1") + put("command", "python train.py --epochs 10") + put("pid", 1234) + put("status", "running") + put("uptime_seconds", 95) + put("cwd", "/work") + }, + ) + add( + buildJsonObject { + put("session_id", "p2") + put("command", "echo done") + put("status", "exited") + put("exit_code", 0) + }, + ) + }, + ) + } - every { - HermesWsClient.request(WsMethods.PROCESS_LIST, any()) - } returns CompletableDeferred(raw) + val listParams = slot() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, capture(listParams), any(), any()) } returns raw val vm = ProcessesViewModel() vm.load() + assertEquals(SessionIdParams("sess-1"), listParams.captured) val procs = vm.uiState.value.processes assertEquals(2, procs.size) @@ -90,26 +102,20 @@ class ProcessesViewModelTest { @Test fun `kill issues process kill with session-scoped params then refreshes`() { var listCalls = 0 - val killParamsSlot = slot>() + val killParamsSlot = slot() - every { - HermesWsClient.request(WsMethods.PROCESS_LIST, any()) - } answers { + coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, any(), any(), any()) } answers { listCalls++ - CompletableDeferred(mapOf("processes" to emptyList())) + buildJsonObject { put("processes", JsonArray(emptyList())) } } - every { - HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot)) - } answers { - CompletableDeferred(mapOf("killed" to true)) + coEvery { HermesWsClient.call(RpcMethods.PROCESS_KILL, capture(killParamsSlot), any(), any()) } answers { + buildJsonObject { put("killed", true) } } val vm = ProcessesViewModel() vm.kill("p1") - val killParams = killParamsSlot.captured - assertEquals("p1", killParams["process_id"]) - assertEquals("sess-1", killParams["session_id"]) + assertEquals(ProcessKillParams(sessionId = "sess-1", processId = "p1"), killParamsSlot.captured) // list is called once on init (session emitted) + once after kill refresh assertTrue(listCalls >= 2) assertNull(vm.uiState.value.killingId)