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

@@ -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()