feat(#1216): add cron run history (#1226)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 14:57:42 +03:00
1 parent a78b20bc99
commit 2e6ae95fe0
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()
+12
View File
@@ -1148,6 +1148,7 @@
<string name="cron_action_pause">Pause</string>
<string name="cron_action_resume">Resume</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_add">Add 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_delete_title">Delete job?</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 -->
<string name="achievements_empty_title">No achievements yet</string>
@@ -99,6 +99,108 @@ class HermesApiServiceTest {
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
fun testPauseCronJob_sendsPostRequest() =
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
fun skillsEndpoint_isScoped() {
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.CronJob
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.DeliveryTargetsResponse
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
@@ -19,6 +21,7 @@ import io.mockk.mockk
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.StandardTestDispatcher
@@ -125,6 +128,77 @@ class CronJobsViewModelTest {
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
fun `selectBlueprint seeds slot defaults`() {
val vm = createViewModel()