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.CredentialPoolResponse
|
||||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
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.CuratorResponse
|
||||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||||
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
||||||
@@ -339,6 +340,12 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@GET("api/cron/jobs")
|
@GET("api/cron/jobs")
|
||||||
suspend fun getCronJobs(): Response<List<CronJob>>
|
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")
|
@POST("api/cron/jobs/{id}/pause")
|
||||||
suspend fun pauseCronJob(
|
suspend fun pauseCronJob(
|
||||||
@Path("id") id: String,
|
@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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
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.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -49,6 +52,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
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.CronBlueprint
|
||||||
import com.m57.hermescontrol.data.model.CronBlueprintField
|
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
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.DeliveryTarget
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
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.listItemSpacing
|
||||||
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||||
import com.m57.hermescontrol.util.CronExpressionFormatter
|
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
|
@Composable
|
||||||
fun CronJobsScreen(
|
fun CronJobsScreen(
|
||||||
@@ -224,6 +233,15 @@ fun CronJobsScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
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(
|
IconButton(
|
||||||
onClick = { viewModel.openEditJobDialog(job.id) },
|
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 ──
|
// ── Delete Confirm Dialog ──
|
||||||
state.deleteTarget?.let { job ->
|
state.deleteTarget?.let { job ->
|
||||||
AlertDialog(
|
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
|
@Composable
|
||||||
fun CronJobEditorDialog(
|
fun CronJobEditorDialog(
|
||||||
state: CronJobEditorState,
|
state: CronJobEditorState,
|
||||||
|
|||||||
@@ -3,22 +3,22 @@ package com.m57.hermescontrol.ui.cron
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
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.local.SwrCache
|
||||||
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.model.CronBlueprint
|
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
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.DeliveryTarget
|
||||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -32,6 +32,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
private const val CRON_RUN_HISTORY_LIMIT = 20
|
||||||
|
|
||||||
data class CronJobsUiState(
|
data class CronJobsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val jobs: List<CronJob> = emptyList(),
|
val jobs: List<CronJob> = emptyList(),
|
||||||
@@ -41,6 +43,16 @@ data class CronJobsUiState(
|
|||||||
val deleteTarget: CronJob? = null,
|
val deleteTarget: CronJob? = null,
|
||||||
// Editor state
|
// Editor state
|
||||||
val editorState: CronJobEditorState = CronJobEditorState(),
|
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(
|
data class CronJobEditorState(
|
||||||
@@ -100,10 +112,13 @@ class CronJobsViewModel :
|
|||||||
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
|
private var runHistoryJob: Job? = null
|
||||||
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
private val jobsCache = SwrCache<String, List<CronJob>>()
|
private val jobsCache = SwrCache<String, List<CronJob>>()
|
||||||
|
|
||||||
fun clearScopeOwnedState() {
|
fun clearScopeOwnedState() {
|
||||||
|
runHistoryJob?.cancel()
|
||||||
|
runHistoryJob = null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -111,6 +126,7 @@ class CronJobsViewModel :
|
|||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
deleteTarget = null,
|
deleteTarget = null,
|
||||||
editorState = CronJobEditorState(),
|
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) {
|
fun pauseCronJob(id: String) {
|
||||||
val originalJobs = _uiState.value.jobs
|
val originalJobs = _uiState.value.jobs
|
||||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||||
|
|||||||
@@ -1148,6 +1148,7 @@
|
|||||||
<string name="cron_action_pause">Pause</string>
|
<string name="cron_action_pause">Pause</string>
|
||||||
<string name="cron_action_resume">Resume</string>
|
<string name="cron_action_resume">Resume</string>
|
||||||
<string name="cron_action_run">Run</string>
|
<string name="cron_action_run">Run</string>
|
||||||
|
<string name="cron_action_history">Run history</string>
|
||||||
<string name="cron_action_edit">Edit</string>
|
<string name="cron_action_edit">Edit</string>
|
||||||
<string name="cron_action_add">Add cron job</string>
|
<string name="cron_action_add">Add cron job</string>
|
||||||
<string name="cron_edit_title">Edit Cron Job</string>
|
<string name="cron_edit_title">Edit Cron Job</string>
|
||||||
@@ -1188,6 +1189,17 @@
|
|||||||
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
||||||
<string name="cron_delete_title">Delete job?</string>
|
<string name="cron_delete_title">Delete job?</string>
|
||||||
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
|
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
|
||||||
|
<string name="cron_history_title">%1$s runs</string>
|
||||||
|
<string name="cron_history_loading">Loading run history</string>
|
||||||
|
<string name="cron_history_empty_title">No runs yet</string>
|
||||||
|
<string name="cron_history_empty_desc">Recent executions for this job will appear here.</string>
|
||||||
|
<string name="cron_history_status_active">Active</string>
|
||||||
|
<string name="cron_history_status_archived">Archived</string>
|
||||||
|
<string name="cron_history_status_completed">Completed</string>
|
||||||
|
<string name="cron_history_status_inactive">Inactive</string>
|
||||||
|
<string name="cron_history_profile">Profile: %1$s</string>
|
||||||
|
<string name="cron_history_messages">Messages: %1$d</string>
|
||||||
|
<string name="cron_history_close">Close</string>
|
||||||
|
|
||||||
<!-- Achievements Screen -->
|
<!-- Achievements Screen -->
|
||||||
<string name="achievements_empty_title">No achievements yet</string>
|
<string name="achievements_empty_title">No achievements yet</string>
|
||||||
|
|||||||
@@ -99,6 +99,108 @@ class HermesApiServiceTest {
|
|||||||
assertEquals("GET", recordedRequest.method)
|
assertEquals("GET", recordedRequest.method)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetCronJobRuns_requestsBoundedHistoryAndParsesResponse() =
|
||||||
|
runTest {
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"id": "cron_job_1_1700000000",
|
||||||
|
"source": "cron",
|
||||||
|
"model": "gpt",
|
||||||
|
"title": "Backup",
|
||||||
|
"started_at": 1700000000.5,
|
||||||
|
"ended_at": 1700000020.0,
|
||||||
|
"last_active": 1700000020.0,
|
||||||
|
"is_active": false,
|
||||||
|
"archived": true,
|
||||||
|
"message_count": 2,
|
||||||
|
"tool_call_count": 1,
|
||||||
|
"input_tokens": 123,
|
||||||
|
"output_tokens": 45,
|
||||||
|
"preview": "done",
|
||||||
|
"profile": "worker"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = apiService.getCronJobRuns("job_1", limit = 20)
|
||||||
|
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
val run = response.body()?.runs?.single()
|
||||||
|
assertNotNull(run)
|
||||||
|
assertEquals("cron_job_1_1700000000", run?.id)
|
||||||
|
assertEquals("worker", run?.profile)
|
||||||
|
assertEquals(true, run?.archived)
|
||||||
|
assertEquals(2, run?.message_count)
|
||||||
|
assertEquals("done", run?.preview)
|
||||||
|
|
||||||
|
val recordedRequest = mockWebServer.takeRequest()
|
||||||
|
assertEquals("/api/cron/jobs/job_1/runs?limit=20", recordedRequest.path)
|
||||||
|
assertEquals("GET", recordedRequest.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetCronJobRuns_emptyResponse() =
|
||||||
|
runTest {
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{"runs":[],"limit":20}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = apiService.getCronJobRuns("job_1")
|
||||||
|
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
assertTrue(response.body()?.runs?.isEmpty() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetCronJobRuns_missingOptionalFieldsUsesDefaults() =
|
||||||
|
runTest {
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{"runs":[{"id":"cron_job_1_1"}],"limit":20}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val run =
|
||||||
|
apiService
|
||||||
|
.getCronJobRuns("job_1")
|
||||||
|
.body()
|
||||||
|
?.runs
|
||||||
|
?.single()
|
||||||
|
|
||||||
|
assertNotNull(run)
|
||||||
|
assertNull(run?.started_at)
|
||||||
|
assertEquals(false, run?.is_active)
|
||||||
|
assertEquals(false, run?.archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetCronJobRuns_missingRequiredIdThrows() {
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("""{"runs":[{"started_at":1.0}],"limit":20}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThrows(kotlinx.serialization.SerializationException::class.java) {
|
||||||
|
runTest {
|
||||||
|
apiService.getCronJobRuns("job_1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testPauseCronJob_sendsPostRequest() =
|
fun testPauseCronJob_sendsPostRequest() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ class ProfileScopeInterceptorTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cronRunHistory_preservesLimitAndFollowsActiveProfileAcrossSwitches() {
|
||||||
|
val client = clientFor("alpha")
|
||||||
|
|
||||||
|
val alphaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20")
|
||||||
|
assertEquals("alpha", alphaUrl.queryParameter("profile"))
|
||||||
|
assertEquals("20", alphaUrl.queryParameter("limit"))
|
||||||
|
|
||||||
|
AuthManager.setActiveProfileId("beta")
|
||||||
|
val betaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20")
|
||||||
|
assertEquals("beta", betaUrl.queryParameter("profile"))
|
||||||
|
assertEquals("20", betaUrl.queryParameter("limit"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun skillsEndpoint_isScoped() {
|
fun skillsEndpoint_isScoped() {
|
||||||
val client = clientFor("alpha")
|
val client = clientFor("alpha")
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.CronBlueprintField
|
|||||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
import com.m57.hermescontrol.data.model.CronJobFireError
|
import com.m57.hermescontrol.data.model.CronJobFireError
|
||||||
|
import com.m57.hermescontrol.data.model.CronRun
|
||||||
|
import com.m57.hermescontrol.data.model.CronRunHistoryResponse
|
||||||
import com.m57.hermescontrol.data.model.DeliveryTarget
|
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||||
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
||||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||||
@@ -19,6 +21,7 @@ import io.mockk.mockk
|
|||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
@@ -125,6 +128,77 @@ class CronJobsViewModelTest {
|
|||||||
assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions)
|
assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openRunHistory loads bounded recent runs`() {
|
||||||
|
coEvery { mockApi.getCronJobRuns("j1", 20) } returns
|
||||||
|
Response.success(
|
||||||
|
CronRunHistoryResponse(
|
||||||
|
runs =
|
||||||
|
listOf(
|
||||||
|
CronRun(
|
||||||
|
id = "cron_j1_1",
|
||||||
|
started_at = 1_700_000_000.0,
|
||||||
|
is_active = true,
|
||||||
|
profile = "default",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
limit = 20,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
vm.openRunHistory(CronJob(id = "j1", name = "Morning"))
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val history = vm.uiState.value.runHistoryState
|
||||||
|
assertTrue(history.isOpen)
|
||||||
|
assertFalse(history.isLoading)
|
||||||
|
assertEquals("j1", history.jobId)
|
||||||
|
assertEquals("Morning", history.jobName)
|
||||||
|
assertEquals(listOf("cron_j1_1"), history.runs.map { it.id })
|
||||||
|
coVerify(exactly = 1) { mockApi.getCronJobRuns("j1", 20) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openRunHistory explains unsupported backend`() {
|
||||||
|
coEvery { mockApi.getCronJobRuns("j1", 20) } returns
|
||||||
|
Response.error(404, "{}".toResponseBody())
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
vm.openRunHistory(CronJob(id = "j1", name = "Morning"))
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val history = vm.uiState.value.runHistoryState
|
||||||
|
assertFalse(history.isLoading)
|
||||||
|
assertTrue(history.runs.isEmpty())
|
||||||
|
assertTrue(history.errorMessage.orEmpty().contains("unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scope reset cancels in flight run history and clears rows`() {
|
||||||
|
val response = CompletableDeferred<Response<CronRunHistoryResponse>>()
|
||||||
|
coEvery { mockApi.getCronJobRuns("j1", 20) } coAnswers { response.await() }
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
vm.openRunHistory(CronJob(id = "j1", name = "Morning"))
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
vm.clearScopeOwnedState()
|
||||||
|
response.complete(
|
||||||
|
Response.success(
|
||||||
|
CronRunHistoryResponse(
|
||||||
|
runs = listOf(CronRun(id = "cron_j1_stale", profile = "old-profile")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val history = vm.uiState.value.runHistoryState
|
||||||
|
assertFalse(history.isOpen)
|
||||||
|
assertFalse(history.isLoading)
|
||||||
|
assertTrue(history.runs.isEmpty())
|
||||||
|
assertNull(history.jobId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `selectBlueprint seeds slot defaults`() {
|
fun `selectBlueprint seeds slot defaults`() {
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
|
|||||||
Reference in new issue
Block a user