feat(cron): add Run Continuity toggle + badge (#924) (#932)

Expose backend cron self-continuity (context_from=["self"]) in the Cron
editor. Adds a Run Continuity checkbox to the job editor that sends
context_from=["self"] on create and update (null when off, clearing it),
prefills from the loaded job's context_from, and shows a "Continuity" badge
on recurring jobs in the list. context_from was already on CronJob and
CreateCronJobRequest, so this is editor/list wiring only. Strings added for
en/zh/ko. Includes two ViewModel tests.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-18 12:36:05 +03:00
1 parent 50a0f30d8f
commit 1039d7db1d
6 files changed
+122

No files matched your search

@@ -186,6 +186,14 @@ fun CronJobsScreen(
)
}
}
// Run-continuity badge: the job's own previous
// output is injected into each run.
if (job.context_from?.contains("self") == true) {
StatusBadge(
text = stringResource(R.string.cron_badge_continuity),
status = StatusBadgeType.INFO,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
Text(
@@ -267,6 +275,7 @@ fun CronJobsScreen(
state = state.editorState,
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
onToggleNoAgent = { viewModel.toggleNoAgent() },
onToggleRunContinuity = { viewModel.toggleRunContinuity() },
onSetMonitorMode = { viewModel.setMonitorMode(it) },
onSelectBlueprint = { viewModel.selectBlueprint(it) },
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
@@ -331,6 +340,7 @@ fun CronJobEditorDialog(
state: CronJobEditorState,
onFieldChange: (String, String) -> Unit,
onToggleNoAgent: () -> Unit,
onToggleRunContinuity: () -> Unit,
onSetMonitorMode: (String) -> Unit,
onSelectBlueprint: (String?) -> Unit,
onBlueprintFieldChange: (String, String) -> Unit,
@@ -571,6 +581,30 @@ fun CronJobEditorDialog(
)
}
// Run Continuity toggle (recurring jobs: inject own
// previous output into each run via context_from=["self"])
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.runContinuity,
onCheckedChange = { onToggleRunContinuity() },
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.cron_edit_field_run_continuity),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.cron_edit_hint_run_continuity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
}
// Monitor mode (disabled for no-agent jobs — the
// backend rejects monitor + no_agent together)
MonitorModeSection(
@@ -58,6 +58,10 @@ data class CronJobEditorState(
val workdir: String = "",
val enabled: Boolean = true,
val no_agent: Boolean = false,
// Run continuity: when true, the job's own previous output is injected
// into each subsequent run via context_from=["self"] (backend cron
// self-continuity). Only meaningful for recurring jobs.
val runContinuity: Boolean = false,
// Monitor mode: one of monitor_script / monitor_url set at a time
// (mutually exclusive on the backend; incompatible with no_agent).
val monitorMode: String = "off", // "off" | "script" | "url"
@@ -309,6 +313,7 @@ class CronJobsViewModel :
workdir = job.workdir.orEmpty(),
enabled = job.enabled ?: true,
no_agent = job.no_agent ?: false,
runContinuity = job.context_from?.contains("self") ?: false,
monitorMode =
when {
!job.monitor_script.isNullOrBlank() -> "script"
@@ -425,6 +430,8 @@ class CronJobsViewModel :
updates["script"] = editor.script.ifBlank { null }
updates["workdir"] = editor.workdir.ifBlank { null }
updates["no_agent"] = editor.no_agent
// Run continuity: send ["self"] when on, null (clears) when off.
updates["context_from"] = if (editor.runContinuity) listOf("self") else null
// Blank clears the monitor source on the backend (empty
// string or null both mean "clear the field").
updates["monitor_script"] = editor.monitor_script.ifBlank { null }
@@ -488,6 +495,7 @@ class CronJobsViewModel :
script = editor.script.ifBlank { null },
workdir = editor.workdir.ifBlank { null },
no_agent = editor.no_agent,
context_from = if (editor.runContinuity) listOf("self") else null,
monitor_script = editor.monitor_script.ifBlank { null },
monitor_url = editor.monitor_url.ifBlank { null },
),
@@ -553,12 +561,25 @@ class CronJobsViewModel :
"base_url" -> copy(base_url = value)
"script" -> copy(script = value)
"workdir" -> copy(workdir = value)
"run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false)
// Monitor fields are mutually exclusive: entering one clears the other.
"monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url)
"monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script)
else -> this
}
fun toggleRunContinuity() {
_uiState.update {
it.copy(
editorState =
it.editorState.copy(
runContinuity = !it.editorState.runContinuity,
toastMessage = null,
),
)
}
}
fun toggleNoAgent() {
_uiState.update {
it.copy(
+3
View File
@@ -581,6 +581,9 @@
<string name="cron_edit_field_script">스크립트</string>
<string name="cron_edit_field_workdir">작업 디렉토리</string>
<string name="cron_edit_field_no_agent">에이전트 없음 (스크립트 전용)</string>
<string name="cron_edit_field_run_continuity">실행 연속성</string>
<string name="cron_edit_hint_run_continuity">이 작업의 이전 출력을 매 실행에 주입</string>
<string name="cron_badge_continuity">연속</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_skills">한 줄에 하나씩</string>
+3
View File
@@ -729,6 +729,9 @@
<string name="cron_edit_field_script">脚本</string>
<string name="cron_edit_field_workdir">工作目录</string>
<string name="cron_edit_field_no_agent">无 Agent(仅脚本)</string>
<string name="cron_edit_field_run_continuity">运行连续性</string>
<string name="cron_edit_hint_run_continuity">将本任务上一次的输出注入到每次运行中</string>
<string name="cron_badge_continuity">连续</string>
<string name="cron_edit_monitor_mode">监控模式</string>
<string name="cron_edit_monitor_off">关</string>
<string name="cron_edit_monitor_script">脚本</string>
+3
View File
@@ -826,6 +826,9 @@
<string name="cron_edit_field_script">Script</string>
<string name="cron_edit_field_workdir">Work Directory</string>
<string name="cron_edit_field_no_agent">No agent (script-only)</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_badge_continuity">Continuity</string>
<string name="cron_edit_monitor_mode">Monitor mode</string>
<string name="cron_edit_monitor_off">Off</string>
<string name="cron_edit_monitor_script">Script</string>
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.cron
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
@@ -470,4 +471,61 @@ class CronJobsViewModelTest {
coVerify { mockApi.updateCronJob(eq("j8"), capture(updateSlot)) }
assertEquals(JsonNull, updateSlot.captured.updates["monitor_script"])
}
@Test
fun `new job with run continuity sends context_from self in create`() {
val vm = createViewModel()
vm.openNewJobDialog()
settle()
vm.updateEditorField("name", "Daily digest")
vm.updateEditorField("schedule", "every 1d")
vm.toggleRunContinuity()
val created = CronJob(id = "j9", name = "Daily digest")
coEvery { mockApi.createCronJob(any()) } returns Response.success(created)
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
vm.saveEditor()
settle()
val createSlot = slot<CreateCronJobRequest>()
coVerify(exactly = 1) { mockApi.createCronJob(capture(createSlot)) }
assertEquals(listOf("self"), createSlot.captured.context_from)
assertFalse(vm.uiState.value.editorState.isOpen)
}
@Test
fun `edit save preserves run continuity from loaded job`() {
val vm = createViewModel()
coEvery { mockApi.getCronJob("j10") } returns
Response.success(
CronJob(
id = "j10",
name = "Daily digest",
schedule = JsonPrimitive("every 1d"),
context_from = listOf("self"),
),
)
coEvery { mockApi.updateCronJob(eq("j10"), any()) } returns
Response.success(CronJob(id = "j10", name = "Daily digest"))
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
vm.openEditJobDialog("j10")
settle()
assertTrue(vm.uiState.value.editorState.runContinuity)
vm.updateEditorField("name", "Renamed digest")
vm.saveEditor()
settle()
val updateSlot = slot<UpdateCronJobRequest>()
coVerify { mockApi.updateCronJob(eq("j10"), capture(updateSlot)) }
assertEquals(
kotlinx.serialization.json.JsonArray(
listOf(JsonPrimitive("self")),
),
updateSlot.captured.updates["context_from"],
)
assertFalse(vm.uiState.value.editorState.isOpen)
}
}