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 6f4409c2..140a453c 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
@@ -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(
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt
index c54e2313..fe9414b6 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt
@@ -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(
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 7fde9d15..6350da29 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -581,6 +581,9 @@
스크립트
작업 디렉토리
에이전트 없음 (스크립트 전용)
+ 실행 연속성
+ 이 작업의 이전 출력을 매 실행에 주입
+ 연속
예: 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 34b2470c..aadf8c2f 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -729,6 +729,9 @@
脚本
工作目录
无 Agent(仅脚本)
+ 运行连续性
+ 将本任务上一次的输出注入到每次运行中
+ 连续
监控模式
关
脚本
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5413917f..0b71df96 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -826,6 +826,9 @@
Script
Work Directory
No agent (script-only)
+ Run continuity
+ Inject this job\'s previous output into each run
+ Continuity
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 d93ce12d..69ce7443 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
@@ -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()
+ 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()
+ 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)
+ }
}