8 files changed
+517
-2
No files matched your search
@@ -0,0 +1,37 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CronRunHistoryResponse(
|
||||
val runs: List<CronRun> = emptyList(),
|
||||
val limit: Int = 20,
|
||||
)
|
||||
|
||||
/**
|
||||
* One Hermes Agent cron execution.
|
||||
*
|
||||
* The backend exposes cron runs using the same enriched row shape as
|
||||
* `/api/sessions`, then annotates each row with its owning profile and
|
||||
* active/archive state. Only [id] is required; older backend rows can omit any
|
||||
* of the enrichment fields without making the whole history unusable.
|
||||
*/
|
||||
@Serializable
|
||||
data class CronRun(
|
||||
val id: String,
|
||||
val source: String? = null,
|
||||
val model: String? = null,
|
||||
val title: String? = null,
|
||||
val started_at: Double? = null,
|
||||
val ended_at: Double? = null,
|
||||
val last_active: Double? = null,
|
||||
val is_active: Boolean = false,
|
||||
val archived: Boolean = false,
|
||||
val message_count: Int? = null,
|
||||
val tool_call_count: Int? = null,
|
||||
val input_tokens: Int? = null,
|
||||
val output_tokens: Int? = null,
|
||||
val preview: String? = null,
|
||||
val parent_session_id: String? = null,
|
||||
val profile: String? = null,
|
||||
)
|
||||
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
||||
import com.m57.hermescontrol.data.model.CredentialPoolResponse
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.CronRunHistoryResponse
|
||||
import com.m57.hermescontrol.data.model.CuratorResponse
|
||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
||||
@@ -339,6 +340,12 @@ interface HermesApiService : KanbanApiService {
|
||||
@GET("api/cron/jobs")
|
||||
suspend fun getCronJobs(): Response<List<CronJob>>
|
||||
|
||||
@GET("api/cron/jobs/{id}/runs")
|
||||
suspend fun getCronJobRuns(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = 20,
|
||||
): Response<CronRunHistoryResponse>
|
||||
|
||||
@POST("api/cron/jobs/{id}/pause")
|
||||
suspend fun pauseCronJob(
|
||||
@Path("id") id: String,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
@@ -32,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -49,6 +52,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -59,6 +63,7 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.CronRun
|
||||
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
@@ -75,6 +80,10 @@ import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||
import com.m57.hermescontrol.util.CronExpressionFormatter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun CronJobsScreen(
|
||||
@@ -224,6 +233,15 @@ fun CronJobsScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { viewModel.openRunHistory(job) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.History,
|
||||
contentDescription = stringResource(R.string.cron_action_history),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.openEditJobDialog(job.id) },
|
||||
) {
|
||||
@@ -341,6 +359,14 @@ fun CronJobsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.runHistoryState.isOpen) {
|
||||
CronRunHistoryDialog(
|
||||
state = state.runHistoryState,
|
||||
onRetry = viewModel::retryRunHistory,
|
||||
onDismiss = viewModel::closeRunHistory,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete Confirm Dialog ──
|
||||
state.deleteTarget?.let { job ->
|
||||
AlertDialog(
|
||||
@@ -361,6 +387,139 @@ fun CronJobsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CronRunHistoryDialog(
|
||||
state: CronRunHistoryState,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.cron_history_title, state.jobName),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
when {
|
||||
state.isLoading -> {
|
||||
LoadingState(
|
||||
modifier = Modifier.height(240.dp),
|
||||
subtitle = stringResource(R.string.cron_history_loading),
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage,
|
||||
onRetry = onRetry,
|
||||
modifier = Modifier.height(240.dp),
|
||||
)
|
||||
}
|
||||
|
||||
state.runs.isEmpty() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Filled.History,
|
||||
title = stringResource(R.string.cron_history_empty_title),
|
||||
subtitle = stringResource(R.string.cron_history_empty_desc),
|
||||
modifier = Modifier.height(240.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
items(state.runs, key = { it.id }) { run ->
|
||||
CronRunRow(run)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cron_history_close))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CronRunRow(run: CronRun) {
|
||||
val spacing = LocalSpacing.current
|
||||
val (statusText, statusType) =
|
||||
when {
|
||||
run.is_active -> stringResource(R.string.cron_history_status_active) to StatusBadgeType.SUCCESS
|
||||
run.archived -> stringResource(R.string.cron_history_status_archived) to StatusBadgeType.NEUTRAL
|
||||
run.ended_at != null -> stringResource(R.string.cron_history_status_completed) to StatusBadgeType.INFO
|
||||
else -> stringResource(R.string.cron_history_status_inactive) to StatusBadgeType.NEUTRAL
|
||||
}
|
||||
val startedAt = formatCronRunTime(run.started_at)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = spacing.xs),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = startedAt ?: run.id,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusBadge(text = statusText, status = statusType)
|
||||
}
|
||||
if (!run.profile.isNullOrBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.cron_history_profile, run.profile),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (run.message_count != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.cron_history_messages, run.message_count),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (!run.preview.isNullOrBlank()) {
|
||||
Text(
|
||||
text = run.preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCronRunTime(epochSeconds: Double?): String? {
|
||||
if (epochSeconds == null) return null
|
||||
return runCatching {
|
||||
Instant
|
||||
.ofEpochMilli((epochSeconds * 1000).toLong())
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CronJobEditorDialog(
|
||||
state: CronJobEditorState,
|
||||
|
||||
@@ -3,22 +3,22 @@ package com.m57.hermescontrol.ui.cron
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.CronRun
|
||||
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -32,6 +32,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
private const val CRON_RUN_HISTORY_LIMIT = 20
|
||||
|
||||
data class CronJobsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val jobs: List<CronJob> = emptyList(),
|
||||
@@ -41,6 +43,16 @@ data class CronJobsUiState(
|
||||
val deleteTarget: CronJob? = null,
|
||||
// Editor state
|
||||
val editorState: CronJobEditorState = CronJobEditorState(),
|
||||
val runHistoryState: CronRunHistoryState = CronRunHistoryState(),
|
||||
)
|
||||
|
||||
data class CronRunHistoryState(
|
||||
val isOpen: Boolean = false,
|
||||
val jobId: String? = null,
|
||||
val jobName: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val runs: List<CronRun> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class CronJobEditorState(
|
||||
@@ -100,10 +112,13 @@ class CronJobsViewModel :
|
||||
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var runHistoryJob: Job? = null
|
||||
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
private val jobsCache = SwrCache<String, List<CronJob>>()
|
||||
|
||||
fun clearScopeOwnedState() {
|
||||
runHistoryJob?.cancel()
|
||||
runHistoryJob = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
@@ -111,6 +126,7 @@ class CronJobsViewModel :
|
||||
errorMessage = null,
|
||||
deleteTarget = null,
|
||||
editorState = CronJobEditorState(),
|
||||
runHistoryState = CronRunHistoryState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +171,100 @@ class CronJobsViewModel :
|
||||
)
|
||||
}
|
||||
|
||||
fun openRunHistory(job: CronJob) {
|
||||
runHistoryJob?.cancel()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
runHistoryState =
|
||||
CronRunHistoryState(
|
||||
isOpen = true,
|
||||
jobId = job.id,
|
||||
jobName = job.name,
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
loadRunHistory(job.id)
|
||||
}
|
||||
|
||||
fun retryRunHistory() {
|
||||
val jobId = _uiState.value.runHistoryState.jobId ?: return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
runHistoryState =
|
||||
it.runHistoryState.copy(
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
loadRunHistory(jobId)
|
||||
}
|
||||
|
||||
fun closeRunHistory() {
|
||||
runHistoryJob?.cancel()
|
||||
runHistoryJob = null
|
||||
_uiState.update { it.copy(runHistoryState = CronRunHistoryState()) }
|
||||
}
|
||||
|
||||
private fun loadRunHistory(jobId: String) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
runHistoryJob =
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getCronJobRuns(
|
||||
id = jobId,
|
||||
limit = CRON_RUN_HISTORY_LIMIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val history = _uiState.value.runHistoryState
|
||||
if (!history.isOpen ||
|
||||
history.jobId != jobId ||
|
||||
(requestScope != null && currentScope != requestScope)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
runHistoryState =
|
||||
it.runHistoryState.copy(
|
||||
isLoading = false,
|
||||
runs = result.data.runs,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
val message =
|
||||
if (result.error is NetworkError.Http && result.error.code == 404) {
|
||||
"Run history is unavailable on this Hermes Agent version."
|
||||
} else {
|
||||
"Failed to load run history: ${result.error.message}"
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
runHistoryState =
|
||||
it.runHistoryState.copy(
|
||||
isLoading = false,
|
||||
runs = emptyList(),
|
||||
errorMessage = message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
|
||||
Reference in new issue
Block a user