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) + } }