diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt index 58d24cee..2ce8a3e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt @@ -10,6 +10,18 @@ data class CronJobRepeat( val completed: Int = 0, ) +/** + * Missed scheduled-fire record stamped on the job when the scheduler cannot + * forward a fire to the gateway loopback (gateway down / api_server not bound). + * `{at: iso, detail: str}` — see hermes-agent cron/jobs.py `_stamp_fire_error`. + * A successful run clears it. + */ +@Serializable +data class CronJobFireError( + val at: String? = null, + val detail: String? = null, +) + @Serializable data class CronJob( val id: String, @@ -24,6 +36,9 @@ data class CronJob( val last_run_at: String? = null, val last_error: String? = null, val last_delivery_error: String? = null, + // Missed scheduled-fire stamp (gateway unreachable / api_server not bound). + // Nested {at, detail} object — see CronJobFireError. + val last_fire_error: CronJobFireError? = null, // Full editor fields — all optional with defaults for backward compat val enabled: Boolean? = null, val prompt: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 140a453c..b463b804 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -194,6 +194,14 @@ fun CronJobsScreen( status = StatusBadgeType.INFO, ) } + // Dispatch error badge: a scheduled fire was missed + // (gateway unreachable / api_server not bound). + if (job.last_fire_error != null) { + StatusBadge( + text = stringResource(R.string.cron_badge_dispatch_error), + status = StatusBadgeType.ERROR, + ) + } } Spacer(modifier = Modifier.height(spacing.xs)) Text( @@ -305,6 +313,20 @@ fun CronJobsScreen( if (job.last_error != null && job.last_error.isNotBlank()) { RunDetailRow("Error", job.last_error) } + job.last_fire_error?.let { fireErr -> + if (fireErr.detail != null || fireErr.at != null) { + RunDetailRow( + stringResource(R.string.cron_detail_dispatch_error), + buildString { + fireErr.detail?.let { append(it) } + fireErr.at?.let { at -> + if (fireErr.detail != null) append(" ") + append("($at)") + } + }, + ) + } + } job.script?.let { if (it.isNotBlank()) RunDetailRow("Script", it) } job.monitorSource?.let { if (it.isNotBlank()) RunDetailRow("Monitor", it) } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6350da29..0fc2c695 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -584,6 +584,8 @@ 실행 연속성 이 작업의 이전 출력을 매 실행에 주입 연속 + 발송 오류 + 실행 실패 예: 0 9 * * * 또는 every 2h 예: origin, local, all, 또는 telegram:chat_id 한 줄에 하나씩 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index aadf8c2f..67626a45 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -732,6 +732,8 @@ 运行连续性 将本任务上一次的输出注入到每次运行中 连续 + 调度错误 + 触发失败 监控模式 关 脚本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b71df96..968d28ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -829,6 +829,8 @@ Run continuity Inject this job\'s previous output into each run Continuity + Dispatch Error + Fire failed Monitor mode Off Script diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index 69ce7443..defba80b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.CronBlueprint 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.DeliveryTarget import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -30,6 +31,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -528,4 +530,28 @@ class CronJobsViewModelTest { ) assertFalse(vm.uiState.value.editorState.isOpen) } + + @Test + fun `loadCronJobs surfaces last_fire_error on a job`() { + val vm = createViewModel() + coEvery { mockApi.getCronJobs() } returns + Response.success( + listOf( + CronJob( + id = "j11", + name = "Flaky job", + schedule = JsonPrimitive("every 1h"), + last_fire_error = CronJobFireError(at = "2026-08-18T09:00:00", detail = "gateway unreachable"), + ), + ), + ) + + vm.loadCronJobs() + settle() + + val job = vm.uiState.value.jobs.first() + assertNotNull(job.last_fire_error) + assertEquals("gateway unreachable", job.last_fire_error?.detail) + assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at) + } }