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"),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user