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.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()
+12
View File
@@ -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()