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 SystemScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object KanbanScreen : 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.model.ModelScreen as ModelScreenContent
|
||||||
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
|
||||||
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
|
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.profiles.ProfilesScreen as ProfilesScreenContent
|
||||||
import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent
|
import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
||||||
@@ -166,6 +167,12 @@ object ScreenRegistry {
|
|||||||
Icons.Filled.HistoryEdu,
|
Icons.Filled.HistoryEdu,
|
||||||
DrawerSection.INSPECT,
|
DrawerSection.INSPECT,
|
||||||
) { sessionId, openDrawer -> LogsScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> LogsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
|
ScreenDefinition(
|
||||||
|
ProcessesScreen,
|
||||||
|
R.string.screen_processes,
|
||||||
|
Icons.Filled.Memory,
|
||||||
|
DrawerSection.INSPECT,
|
||||||
|
) { sessionId, openDrawer -> ProcessesScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
KanbanScreen,
|
KanbanScreen,
|
||||||
R.string.screen_kanban,
|
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. */
|
/** Stage a non-image file (data URL) for agent access. */
|
||||||
const val FILE_ATTACH = "file.attach"
|
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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
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.CommandCatalog
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
@@ -414,6 +415,10 @@ class ChatViewModel(
|
|||||||
chatTitle = "Hermes",
|
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() }
|
_streamingState.update { StreamingState() }
|
||||||
addSystemMessage("Session created", persist = true)
|
addSystemMessage("Session created", persist = true)
|
||||||
loadSessions()
|
loadSessions()
|
||||||
@@ -456,6 +461,8 @@ class ChatViewModel(
|
|||||||
currentSessionId = sessionId,
|
currentSessionId = sessionId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Mirror the active session id app-wide (issue #532).
|
||||||
|
ActiveSessionHolder.set(sessionId)
|
||||||
addSystemMessage("Session resumed")
|
addSystemMessage("Session resumed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,6 +898,8 @@ class ChatViewModel(
|
|||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Mirror the active session id app-wide (issue #532).
|
||||||
|
ActiveSessionHolder.set(sessionId)
|
||||||
_streamingState.update { StreamingState() }
|
_streamingState.update { StreamingState() }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Step 1: Load cached messages first — instant display
|
// 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_kanban">Kanban</string>
|
||||||
<string name="screen_achievements">Achievements</string>
|
<string name="screen_achievements">Achievements</string>
|
||||||
<string name="screen_history">History</string>
|
<string name="screen_history">History</string>
|
||||||
|
<string name="screen_processes">Processes</string>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">Hermes</string>
|
||||||
@@ -840,4 +841,15 @@
|
|||||||
<string name="loading_state_subtitle_default">Loading…</string>
|
<string name="loading_state_subtitle_default">Loading…</string>
|
||||||
<string name="loading_state_subtitle_models">Fetching model providers…</string>
|
<string name="loading_state_subtitle_models">Fetching model providers…</string>
|
||||||
<string name="loading_state_subtitle_channels">Loading channels…</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>
|
</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