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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user