Backend stamps last_fire_error ({at, detail}) on a job when a scheduled fire
can't reach the gateway loopback (gateway down / api_server not bound) —
the "job runs manually but never auto-fires" case. Previously invisible in
mobile.
- CronJob.kt: model as CronJobFireError(at, detail) — the backend sends a
nested object, NOT a flat string (issue's String? proposal would fail to
deserialize), plus the last_fire_error field.
- CronJobsScreen: "Fire failed" ERROR badge in the list + "Dispatch Error"
row (detail + timestamp) in the Run Details dialog.
- Strings en/zh/ko. Test: job with last_fire_error loads + parses.
Backward-compatible: field has a default, existing jobs without it parse fine.
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
1039d7db1d
commit
79c2ea1341
6 files changed
+69
No files matched your search
@@ -10,6 +10,18 @@ data class CronJobRepeat(
|
|||||||
val completed: Int = 0,
|
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
|
@Serializable
|
||||||
data class CronJob(
|
data class CronJob(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -24,6 +36,9 @@ data class CronJob(
|
|||||||
val last_run_at: String? = null,
|
val last_run_at: String? = null,
|
||||||
val last_error: String? = null,
|
val last_error: String? = null,
|
||||||
val last_delivery_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
|
// Full editor fields — all optional with defaults for backward compat
|
||||||
val enabled: Boolean? = null,
|
val enabled: Boolean? = null,
|
||||||
val prompt: String? = null,
|
val prompt: String? = null,
|
||||||
|
|||||||
@@ -194,6 +194,14 @@ fun CronJobsScreen(
|
|||||||
status = StatusBadgeType.INFO,
|
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))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
Text(
|
Text(
|
||||||
@@ -305,6 +313,20 @@ fun CronJobsScreen(
|
|||||||
if (job.last_error != null && job.last_error.isNotBlank()) {
|
if (job.last_error != null && job.last_error.isNotBlank()) {
|
||||||
RunDetailRow("Error", job.last_error)
|
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.script?.let { if (it.isNotBlank()) RunDetailRow("Script", it) }
|
||||||
job.monitorSource?.let { if (it.isNotBlank()) RunDetailRow("Monitor", it) }
|
job.monitorSource?.let { if (it.isNotBlank()) RunDetailRow("Monitor", it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -584,6 +584,8 @@
|
|||||||
<string name="cron_edit_field_run_continuity">실행 연속성</string>
|
<string name="cron_edit_field_run_continuity">실행 연속성</string>
|
||||||
<string name="cron_edit_hint_run_continuity">이 작업의 이전 출력을 매 실행에 주입</string>
|
<string name="cron_edit_hint_run_continuity">이 작업의 이전 출력을 매 실행에 주입</string>
|
||||||
<string name="cron_badge_continuity">연속</string>
|
<string name="cron_badge_continuity">연속</string>
|
||||||
|
<string name="cron_detail_dispatch_error">발송 오류</string>
|
||||||
|
<string name="cron_badge_dispatch_error">실행 실패</string>
|
||||||
<string name="cron_edit_hint_schedule">예: 0 9 * * * 또는 every 2h</string>
|
<string name="cron_edit_hint_schedule">예: 0 9 * * * 또는 every 2h</string>
|
||||||
<string name="cron_edit_hint_deliver">예: origin, local, all, 또는 telegram:chat_id</string>
|
<string name="cron_edit_hint_deliver">예: origin, local, all, 또는 telegram:chat_id</string>
|
||||||
<string name="cron_edit_hint_skills">한 줄에 하나씩</string>
|
<string name="cron_edit_hint_skills">한 줄에 하나씩</string>
|
||||||
|
|||||||
@@ -732,6 +732,8 @@
|
|||||||
<string name="cron_edit_field_run_continuity">运行连续性</string>
|
<string name="cron_edit_field_run_continuity">运行连续性</string>
|
||||||
<string name="cron_edit_hint_run_continuity">将本任务上一次的输出注入到每次运行中</string>
|
<string name="cron_edit_hint_run_continuity">将本任务上一次的输出注入到每次运行中</string>
|
||||||
<string name="cron_badge_continuity">连续</string>
|
<string name="cron_badge_continuity">连续</string>
|
||||||
|
<string name="cron_detail_dispatch_error">调度错误</string>
|
||||||
|
<string name="cron_badge_dispatch_error">触发失败</string>
|
||||||
<string name="cron_edit_monitor_mode">监控模式</string>
|
<string name="cron_edit_monitor_mode">监控模式</string>
|
||||||
<string name="cron_edit_monitor_off">关</string>
|
<string name="cron_edit_monitor_off">关</string>
|
||||||
<string name="cron_edit_monitor_script">脚本</string>
|
<string name="cron_edit_monitor_script">脚本</string>
|
||||||
|
|||||||
@@ -829,6 +829,8 @@
|
|||||||
<string name="cron_edit_field_run_continuity">Run continuity</string>
|
<string name="cron_edit_field_run_continuity">Run continuity</string>
|
||||||
<string name="cron_edit_hint_run_continuity">Inject this job\'s previous output into each run</string>
|
<string name="cron_edit_hint_run_continuity">Inject this job\'s previous output into each run</string>
|
||||||
<string name="cron_badge_continuity">Continuity</string>
|
<string name="cron_badge_continuity">Continuity</string>
|
||||||
|
<string name="cron_detail_dispatch_error">Dispatch Error</string>
|
||||||
|
<string name="cron_badge_dispatch_error">Fire failed</string>
|
||||||
<string name="cron_edit_monitor_mode">Monitor mode</string>
|
<string name="cron_edit_monitor_mode">Monitor mode</string>
|
||||||
<string name="cron_edit_monitor_off">Off</string>
|
<string name="cron_edit_monitor_off">Off</string>
|
||||||
<string name="cron_edit_monitor_script">Script</string>
|
<string name="cron_edit_monitor_script">Script</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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.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.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
|
||||||
@@ -30,6 +31,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody
|
|||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -528,4 +530,28 @@ class CronJobsViewModelTest {
|
|||||||
)
|
)
|
||||||
assertFalse(vm.uiState.value.editorState.isOpen)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user