From afa82ceec26749fd33c689c65ef7090c7cd150ed Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:17:03 +0300 Subject: [PATCH] feat(#532): add process.list + process.kill manager screen (#556) Co-authored-by: M57 --- .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../com/m57/hermescontrol/ScreenRegistry.kt | 7 + .../hermescontrol/data/model/ProcessInfo.kt | 50 ++++ .../data/session/ActiveSessionHolder.kt | 28 ++ .../m57/hermescontrol/data/ws/WsMethods.kt | 8 + .../hermescontrol/ui/chat/ChatViewModel.kt | 9 + .../ui/process/ProcessesScreen.kt | 254 ++++++++++++++++++ .../ui/process/ProcessesViewModel.kt | 168 ++++++++++++ app/src/main/res/values/strings.xml | 12 + .../ui/process/ProcessesViewModelTest.kt | 123 +++++++++ 10 files changed, 661 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ProcessInfo.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index a9ec0109..401abd8f 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -48,3 +48,5 @@ import kotlinx.serialization.Serializable @Serializable data object SystemScreen : NavKey @Serializable data object KanbanScreen : NavKey + +@Serializable data object ProcessesScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 58b0cf97..2d92c6f7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -36,6 +36,7 @@ import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent +import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent @@ -166,6 +167,12 @@ object ScreenRegistry { Icons.Filled.HistoryEdu, DrawerSection.INSPECT, ) { sessionId, openDrawer -> LogsScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + ProcessesScreen, + R.string.screen_processes, + Icons.Filled.Memory, + DrawerSection.INSPECT, + ) { sessionId, openDrawer -> ProcessesScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( KanbanScreen, R.string.screen_kanban, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ProcessInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ProcessInfo.kt new file mode 100644 index 00000000..d4a757af --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ProcessInfo.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.data.model + +/** + * A single background process owned by a session, as returned by the gateway + * `process.list` RPC. + * + * Field names match the gateway JSON exactly (Gson deserializes by name, + * snake_case). See `tui_gateway/server.py::_session_processes` and + * `tools/process_registry.list_sessions` for the canonical shape. + * + * @property sessionId Stable process id used by `process.kill` as `process_id`. + * @property command First 200 chars of the spawned command line. + * @property cwd Working directory the process was launched in. + * @property pid OS process id (0 / null for detached or not-yet-spawned). + * @property startedAt ISO-8601 local timestamp string from the backend. + * @property uptimeSeconds Seconds since spawn (backend-computed). + * @property status `"running"` or `"exited"`. + * @property outputPreview Last 200 chars of buffered output. + * @property outputTail Last 4000 chars of buffered output (richer preview). + * @property exitCode Present only when [status] == `"exited"`. + * @property sessionScoped True when surfaced only because it shares the gateway + * session (not the current task) — a possibly-forgotten long-lived process. + */ +data class ProcessInfo( + val sessionId: String, + val command: String? = null, + val cwd: String? = null, + val pid: Int? = null, + val startedAt: String? = null, + val uptimeSeconds: Int? = null, + val status: String? = null, + val outputPreview: String? = null, + val outputTail: String? = null, + val exitCode: Int? = null, + val sessionScoped: Boolean = false, +) { + val isRunning: Boolean get() = status != "exited" + + /** A short, human-readable title derived from the command line. */ + val title: String + get() = + (command ?: "") + .lineSequence() + .firstOrNull() + ?.trim() + .orEmpty() + .ifEmpty { "background process" } + + companion object +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt b/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt new file mode 100644 index 00000000..e65397e1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.data.session + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * App-wide mirror of the active chat session id. + * + * The desktop `process.list` / `process.kill` RPCs are session-scoped — the + * gateway returns `4001 "session not found"` without a valid `session_id`, and + * mobile has no global "current session" holder (ChatViewModel keeps it + * privately). This singleton holds the last-known active session id so + * session-scoped drawer screens (e.g. the Processes screen, issue #532) can + * issue those RPCs. ChatViewModel writes here on every switch/resume; it is a + * best-effort mirror and may be null when no chat session has been opened yet. + */ +object ActiveSessionHolder { + private val _activeSessionId = MutableStateFlow(null) + + /** The currently active chat session id, or null if none is known yet. */ + val activeSessionId: StateFlow = _activeSessionId.asStateFlow() + + /** Update the active session id. Pass null to clear (e.g. on logout). */ + fun set(sessionId: String?) { + _activeSessionId.value = sessionId + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 5d690984..2a094592 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -32,4 +32,12 @@ object WsMethods { /** Stage a non-image file (data URL) for agent access. */ const val FILE_ATTACH = "file.attach" + + // ── Background process manager (issue #532) ────────────────────────── + + /** List background processes owned by the active session. */ + const val PROCESS_LIST = "process.list" + + /** Kill a single background process (scoped to the active session). */ + const val PROCESS_KILL = "process.kill" } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 6c58030c..75165d4c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient @@ -414,6 +415,10 @@ class ChatViewModel( chatTitle = "Hermes", ) } + // Mirror the active session id app-wide so session-scoped + // drawer screens (e.g. Processes, issue #532) can issue + // session-scoped RPCs. See ActiveSessionHolder. + ActiveSessionHolder.set(sessionId) _streamingState.update { StreamingState() } addSystemMessage("Session created", persist = true) loadSessions() @@ -456,6 +461,8 @@ class ChatViewModel( currentSessionId = sessionId, ) } + // Mirror the active session id app-wide (issue #532). + ActiveSessionHolder.set(sessionId) addSystemMessage("Session resumed") } @@ -891,6 +898,8 @@ class ChatViewModel( isAgentTyping = false, ) } + // Mirror the active session id app-wide (issue #532). + ActiveSessionHolder.set(sessionId) _streamingState.update { StreamingState() } viewModelScope.launch { // Step 1: Load cached messages first — instant display diff --git a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesScreen.kt new file mode 100644 index 00000000..a857b2bd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesScreen.kt @@ -0,0 +1,254 @@ +package com.m57.hermescontrol.ui.process + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ProcessInfo +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing + +@Composable +fun ProcessesScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: ProcessesViewModel = viewModel { ProcessesViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + var killTarget by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + if (state.sessionId != null) viewModel.load() + } + + ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + + HermesScaffold( + title = { Text(stringResource(R.string.screen_processes)) }, + navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, + isRefreshing = state.isLoading, + onRefresh = { viewModel.load() }, + modifier = modifier, + ) { + when { + state.sessionId == null -> + EmptyState( + title = stringResource(R.string.processes_no_session_title), + subtitle = stringResource(R.string.processes_no_session_desc), + icon = Icons.Filled.Memory, + ) + + state.isLoading && state.processes.isEmpty() -> + LoadingState() + + state.errorMessage != null -> + ErrorState( + message = state.errorMessage ?: stringResource(R.string.error_unknown), + onRetry = { viewModel.load() }, + ) + + state.processes.isEmpty() -> + EmptyState( + title = stringResource(R.string.processes_empty_title), + subtitle = stringResource(R.string.processes_empty_desc), + icon = Icons.Filled.Memory, + ) + + else -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + items(state.processes, key = { it.sessionId }) { process -> + ProcessCard( + process = process, + isKilling = state.killingId == process.sessionId, + onKill = { killTarget = process }, + ) + } + } + } + } + + killTarget?.let { process -> + AlertDialog( + onDismissRequest = { killTarget = null }, + title = { Text(stringResource(R.string.processes_kill_title)) }, + text = { + Text( + stringResource( + R.string.processes_kill_desc, + process.title, + ), + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.kill(process.sessionId) + killTarget = null + }, + ) { Text(stringResource(R.string.processes_kill_confirm)) } + }, + dismissButton = { + TextButton(onClick = { killTarget = null }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun ProcessCard( + process: ProcessInfo, + isKilling: Boolean, + onKill: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = process.statusIcon(), + contentDescription = null, + tint = process.statusColor(statusColors), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = process.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = process.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (process.isRunning) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.processes_running_hint), + style = MaterialTheme.typography.labelSmall, + color = statusColors.success, + ) + } + } + if (process.isRunning) { + Spacer(modifier = Modifier.width(8.dp)) + if (isKilling) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + IconButton( + onClick = onKill, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.processes_kill_cd), + tint = statusColors.error, + ) + } + } + } + } + } +} + +private val ProcessInfo.subtitle: String + get() { + val parts = mutableListOf() + pid?.let { parts += "pid $it" } + uptimeSeconds?.let { parts += formatUptime(it) } + if (!isRunning) { + exitCode?.let { parts += "exit $it" } + } + cwd?.let { parts += it } + return parts.joinToString(" · ") + } + +private fun formatUptime(seconds: Int): String { + val m = seconds / 60 + val s = seconds % 60 + return when { + m <= 0 -> "${s}s" + m < 60 -> "${m}m ${s}s" + else -> { + val h = m / 60 + "${h}h ${m % 60}m" + } + } +} + +private fun ProcessInfo.statusIcon(): ImageVector = if (isRunning) Icons.Filled.PlayArrow else Icons.Filled.Memory + +@Composable +private fun ProcessInfo.statusColor(statusColors: com.m57.hermescontrol.theme.HermesStatusColors): Color = + if (isRunning) statusColors.success else statusColors.info 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 new file mode 100644 index 00000000..28c786f2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt @@ -0,0 +1,168 @@ +package com.m57.hermescontrol.ui.process + +import androidx.lifecycle.ViewModel +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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ProcessesUiState( + val isLoading: Boolean = false, + val processes: List = emptyList(), + val errorMessage: String? = null, + val toastMessage: String? = null, + /** The process id currently being killed (drives the in-row spinner). */ + val killingId: String? = null, + /** The active session id the list is scoped to (null = no chat session yet). */ + val sessionId: String? = null, +) + +class ProcessesViewModel : ViewModel() { + private val _uiState = MutableStateFlow(ProcessesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Keep the session id in sync and auto-load whenever it becomes known. + viewModelScope.launch { + ActiveSessionHolder.activeSessionId.collect { sid -> + _uiState.update { it.copy(sessionId = sid) } + if (sid != null) { + load() + } else { + _uiState.update { + it.copy( + processes = emptyList(), + errorMessage = null, + ) + } + } + } + } + } + + /** Pull the active session's process snapshot from the gateway. */ + fun load() { + val sessionId = + ActiveSessionHolder.activeSessionId.value + ?: run { + _uiState.update { + it.copy( + isLoading = false, + processes = emptyList(), + errorMessage = "Open a chat session first — process list is session-scoped.", + ) + } + return + } + + _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) + _uiState.update { + it.copy( + isLoading = false, + processes = processes, + errorMessage = null, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Could not load processes: ${e.message ?: e.javaClass.simpleName}", + ) + } + } + } + } + + /** Kill a single process by id, then refresh the list. */ + fun kill(processId: String) { + val sessionId = + ActiveSessionHolder.activeSessionId.value + ?: run { + _uiState.update { it.copy(toastMessage = "No active session — cannot kill.") } + return + } + if (_uiState.value.killingId != null) return // one kill at a time + + _uiState.update { it.copy(killingId = processId) } + viewModelScope.launch { + try { + HermesWsClient + .request( + WsMethods.PROCESS_KILL, + mapOf( + "process_id" to processId, + "session_id" to sessionId, + ), + ).await() + _uiState.update { it.copy(killingId = null, toastMessage = "Process killed") } + load() + } catch (e: Exception) { + _uiState.update { + it.copy( + killingId = null, + toastMessage = "Kill failed: ${e.message ?: e.javaClass.simpleName}", + ) + } + } + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } + + /** + * Parse the `process.list` RPC result (`{ "processes": [ {…} ] }`) into + * [ProcessInfo]s. The result is a `kotlinx` `toAny()` map (numbers arrive as + * Double/Long/Int, booleans as Boolean) — see [HermesWsClient]. + */ + private fun parseProcessList(result: Any?): List { + val map = result as? Map<*, *> ?: return emptyList() + val rawList = map["processes"] as? List<*> ?: return emptyList() + return rawList.mapNotNull { entry -> + val proc = entry as? Map<*, *> ?: return@mapNotNull null + ProcessInfo.fromMap(proc) + } + } +} + +private fun Map<*, *>.str(key: String): String? = (this[key] as? String)?.takeIf { it.isNotBlank() } + +private fun Map<*, *>.intOrNull(key: String): Int? = (this[key] as? Number)?.toInt() + +private fun Map<*, *>.bool(key: String): Boolean = (this[key] as? Boolean) ?: false + +/** Build a [ProcessInfo] from a raw `process.list` entry map. */ +fun ProcessInfo.Companion.fromMap(raw: Map<*, *>): ProcessInfo? { + val m = raw.mapKeys { it.key.toString() } + val sessionId = m.str("session_id") ?: return null + return ProcessInfo( + sessionId = sessionId, + command = m.str("command"), + cwd = m.str("cwd"), + pid = m.intOrNull("pid"), + startedAt = m.str("started_at"), + uptimeSeconds = m.intOrNull("uptime_seconds"), + status = m.str("status"), + outputPreview = m.str("output_preview"), + outputTail = m.str("output_tail"), + exitCode = m.intOrNull("exit_code"), + sessionScoped = m.bool("session_scoped"), + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5d0b51e..1397d54e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,6 +71,7 @@ Kanban Achievements History + Processes Hermes @@ -840,4 +841,15 @@ Loading… Fetching model providers… Loading channels… + + + No running processes + Background processes for this session will appear here. + No active chat session + Open a chat session first — the process list is scoped to a session. + Running + Kill process? + Stop \u201c%1$s\u201d? This cannot be undone. + Kill + Kill process \ No newline at end of file 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 new file mode 100644 index 00000000..19455158 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt @@ -0,0 +1,123 @@ +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 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 org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ProcessesViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(HermesWsClient) + ActiveSessionHolder.set("sess-1") + } + + @After + fun tearDown() { + unmockkAll() + ActiveSessionHolder.set(null) + Dispatchers.resetMain() + } + + @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, + ), + ), + ) + + every { + HermesWsClient.request(WsMethods.PROCESS_LIST, any()) + } returns CompletableDeferred(raw) + + val vm = ProcessesViewModel() + vm.load() + + val procs = vm.uiState.value.processes + assertEquals(2, procs.size) + + val running = procs[0] + assertEquals("p1", running.sessionId) + assertTrue(running.isRunning) + assertEquals(1234, running.pid) + assertEquals("python train.py --epochs 10", running.command) + assertEquals("python train.py --epochs 10", running.title) + + val exited = procs[1] + assertEquals("p2", exited.sessionId) + assertFalse(exited.isRunning) + assertEquals(0, exited.exitCode) + assertEquals("echo done", exited.title) + } + + @Test + fun `kill issues process kill with session-scoped params then refreshes`() { + var listCalls = 0 + val killParamsSlot = slot>() + + every { + HermesWsClient.request(WsMethods.PROCESS_LIST, any()) + } answers { + listCalls++ + CompletableDeferred(mapOf("processes" to emptyList())) + } + every { + HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot)) + } answers { + CompletableDeferred(mapOf("killed" to true)) + } + + val vm = ProcessesViewModel() + vm.kill("p1") + + val killParams = 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 + assertTrue(listCalls >= 2) + assertNull(vm.uiState.value.killingId) + } + + @Test + fun `fromMap returns null when session_id missing`() { + val parsed = ProcessInfo.fromMap(mapOf("command" to "ls")) + assertNull(parsed) + } +}