Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
cf893585b1
commit
afa82ceec2
10 files changed
+661
No files matched your search
@@ -48,3 +48,5 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object SystemScreen : NavKey
|
||||
|
||||
@Serializable data object KanbanScreen : NavKey
|
||||
|
||||
@Serializable data object ProcessesScreen : NavKey
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String?>(null)
|
||||
|
||||
/** The currently active chat session id, or null if none is known yet. */
|
||||
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
|
||||
|
||||
/** Update the active session id. Pass null to clear (e.g. on logout). */
|
||||
fun set(sessionId: String?) {
|
||||
_activeSessionId.value = sessionId
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProcessInfo?>(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<String>()
|
||||
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
|
||||
@@ -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<ProcessInfo> = 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<ProcessesUiState> = _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<ProcessInfo> {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
<string name="screen_kanban">Kanban</string>
|
||||
<string name="screen_achievements">Achievements</string>
|
||||
<string name="screen_history">History</string>
|
||||
<string name="screen_processes">Processes</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notif_title">Hermes</string>
|
||||
@@ -840,4 +841,15 @@
|
||||
<string name="loading_state_subtitle_default">Loading…</string>
|
||||
<string name="loading_state_subtitle_models">Fetching model providers…</string>
|
||||
<string name="loading_state_subtitle_channels">Loading channels…</string>
|
||||
|
||||
<!-- Processes Screen (issue #532) -->
|
||||
<string name="processes_empty_title">No running processes</string>
|
||||
<string name="processes_empty_desc">Background processes for this session will appear here.</string>
|
||||
<string name="processes_no_session_title">No active chat session</string>
|
||||
<string name="processes_no_session_desc">Open a chat session first — the process list is scoped to a session.</string>
|
||||
<string name="processes_running_hint">Running</string>
|
||||
<string name="processes_kill_title">Kill process?</string>
|
||||
<string name="processes_kill_desc">Stop \u201c%1$s\u201d? This cannot be undone.</string>
|
||||
<string name="processes_kill_confirm">Kill</string>
|
||||
<string name="processes_kill_cd">Kill process</string>
|
||||
</resources>
|
||||
@@ -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<Map<String, Any>>()
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.PROCESS_LIST, any())
|
||||
} answers {
|
||||
listCalls++
|
||||
CompletableDeferred(mapOf("processes" to emptyList<Any>()))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user