feat(cron): monitor-mode jobs (monitor_script/monitor_url) + blocked_config status (issue #861) (#871)
- CronJob model gains optional monitor_script/monitor_url (additive-safe)
- Job editor: Monitor mode section (off/script/url) with XOR source clearing;
disabled while no-agent is set (backend invariant); new jobs apply monitor
via create -> follow-up PUT with rollback delete on failure (the REST create
model drops the fields)
- Job list/details: blocked_config renders as a distinct error badge
('Blocked (config)'), no_change as informational; monitor source shown in
run details
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
4a034684bb
commit
a9cb89a925
7 files changed
+448
-21
No files matched your search
@@ -38,6 +38,10 @@ data class CronJob(
|
|||||||
val workdir: String? = null,
|
val workdir: String? = null,
|
||||||
val no_agent: Boolean? = null,
|
val no_agent: Boolean? = null,
|
||||||
val repeat: CronJobRepeat? = null,
|
val repeat: CronJobRepeat? = null,
|
||||||
|
// Monitor-mode jobs (backend cron/monitor.py): a source script/URL whose
|
||||||
|
// output is hashed each tick — the agent runs only when it changes.
|
||||||
|
val monitor_script: String? = null,
|
||||||
|
val monitor_url: String? = null,
|
||||||
) {
|
) {
|
||||||
val scheduleText: String
|
val scheduleText: String
|
||||||
get() =
|
get() =
|
||||||
@@ -52,4 +56,8 @@ data class CronJob(
|
|||||||
|
|
||||||
val nextRunTime: String
|
val nextRunTime: String
|
||||||
get() = next_run ?: next_run_at ?: ""
|
get() = next_run ?: next_run_at ?: ""
|
||||||
|
|
||||||
|
/** The configured monitor source (script or URL), if any. */
|
||||||
|
val monitorSource: String?
|
||||||
|
get() = monitor_script ?: monitor_url
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,11 @@ data class CreateCronJobRequest(
|
|||||||
val workdir: String? = null,
|
val workdir: String? = null,
|
||||||
val no_agent: Boolean = false,
|
val no_agent: Boolean = false,
|
||||||
val repeat: Int? = null,
|
val repeat: Int? = null,
|
||||||
|
// Monitor mode. NOTE: the backend REST create model does not carry these
|
||||||
|
// yet (they are silently dropped on POST) — the ViewModel applies them
|
||||||
|
// via a follow-up PUT; keep them here for forward compatibility.
|
||||||
|
val monitor_script: String? = null,
|
||||||
|
val monitor_url: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -30,17 +30,19 @@ fun ExposedDropdownField(
|
|||||||
options: List<String>,
|
options: List<String>,
|
||||||
selectedValue: String,
|
selectedValue: String,
|
||||||
onOptionSelected: (String) -> Unit,
|
onOptionSelected: (String) -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = it },
|
onExpandedChange = { if (enabled) expanded = it },
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = selectedValue.ifEmpty { options.firstOrNull() ?: "" },
|
value = selectedValue.ifEmpty { options.firstOrNull() ?: "" },
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
|
enabled = enabled,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -169,6 +169,23 @@ fun CronJobsScreen(
|
|||||||
StatusBadgeType.NEUTRAL
|
StatusBadgeType.NEUTRAL
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// Run-status badge: blocked_config looks like a
|
||||||
|
// failure with no reason — surface it distinctly.
|
||||||
|
when (job.lastRunStatus) {
|
||||||
|
"blocked_config" -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.cron_status_blocked_config),
|
||||||
|
status = StatusBadgeType.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"no_change" -> {
|
||||||
|
StatusBadge(
|
||||||
|
text = stringResource(R.string.cron_status_no_change),
|
||||||
|
status = StatusBadgeType.INFO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
Text(
|
Text(
|
||||||
@@ -250,6 +267,7 @@ fun CronJobsScreen(
|
|||||||
state = state.editorState,
|
state = state.editorState,
|
||||||
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
|
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
|
||||||
onToggleNoAgent = { viewModel.toggleNoAgent() },
|
onToggleNoAgent = { viewModel.toggleNoAgent() },
|
||||||
|
onSetMonitorMode = { viewModel.setMonitorMode(it) },
|
||||||
onSelectBlueprint = { viewModel.selectBlueprint(it) },
|
onSelectBlueprint = { viewModel.selectBlueprint(it) },
|
||||||
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
|
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
|
||||||
onSave = { viewModel.saveEditor() },
|
onSave = { viewModel.saveEditor() },
|
||||||
@@ -265,13 +283,21 @@ fun CronJobsScreen(
|
|||||||
title = { Text(job.name, style = MaterialTheme.typography.titleLarge) },
|
title = { Text(job.name, style = MaterialTheme.typography.titleLarge) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||||
RunDetailRow("Status", job.lastRunStatus.ifEmpty { "unknown" })
|
RunDetailRow(
|
||||||
|
"Status",
|
||||||
|
when (job.lastRunStatus) {
|
||||||
|
"blocked_config" -> stringResource(R.string.cron_status_blocked_config)
|
||||||
|
"no_change" -> stringResource(R.string.cron_status_no_change)
|
||||||
|
else -> job.lastRunStatus.ifEmpty { "unknown" }
|
||||||
|
},
|
||||||
|
)
|
||||||
job.last_run_at?.let { if (it.isNotBlank()) RunDetailRow("Last run", it) }
|
job.last_run_at?.let { if (it.isNotBlank()) RunDetailRow("Last run", it) }
|
||||||
RunDetailRow("Schedule", CronExpressionFormatter.cronToHumanReadable(job.scheduleText))
|
RunDetailRow("Schedule", CronExpressionFormatter.cronToHumanReadable(job.scheduleText))
|
||||||
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.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) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@@ -286,6 +312,7 @@ fun CronJobEditorDialog(
|
|||||||
state: CronJobEditorState,
|
state: CronJobEditorState,
|
||||||
onFieldChange: (String, String) -> Unit,
|
onFieldChange: (String, String) -> Unit,
|
||||||
onToggleNoAgent: () -> Unit,
|
onToggleNoAgent: () -> Unit,
|
||||||
|
onSetMonitorMode: (String) -> Unit,
|
||||||
onSelectBlueprint: (String?) -> Unit,
|
onSelectBlueprint: (String?) -> Unit,
|
||||||
onBlueprintFieldChange: (String, String) -> Unit,
|
onBlueprintFieldChange: (String, String) -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
@@ -295,7 +322,9 @@ fun CronJobEditorDialog(
|
|||||||
var showDiscardConfirm by remember { mutableStateOf(false) }
|
var showDiscardConfirm by remember { mutableStateOf(false) }
|
||||||
val hasChanges =
|
val hasChanges =
|
||||||
state.name.isNotEmpty() || state.schedule.isNotEmpty() ||
|
state.name.isNotEmpty() || state.schedule.isNotEmpty() ||
|
||||||
state.prompt.isNotEmpty() || state.skills.isNotEmpty()
|
state.prompt.isNotEmpty() || state.skills.isNotEmpty() ||
|
||||||
|
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
|
||||||
|
state.monitorMode != "off"
|
||||||
|
|
||||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||||
|
|
||||||
@@ -522,6 +551,17 @@ fun CronJobEditorDialog(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Monitor mode (disabled for no-agent jobs — the
|
||||||
|
// backend rejects monitor + no_agent together)
|
||||||
|
MonitorModeSection(
|
||||||
|
monitorMode = state.monitorMode,
|
||||||
|
monitorScript = state.monitor_script,
|
||||||
|
monitorUrl = state.monitor_url,
|
||||||
|
enabled = !state.no_agent,
|
||||||
|
onFieldChange = onFieldChange,
|
||||||
|
onSetMonitorMode = onSetMonitorMode,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -555,6 +595,70 @@ fun CronJobEditorDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MonitorModeSection(
|
||||||
|
monitorMode: String,
|
||||||
|
monitorScript: String,
|
||||||
|
monitorUrl: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onFieldChange: (String, String) -> Unit,
|
||||||
|
onSetMonitorMode: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val spacing = LocalSpacing.current
|
||||||
|
val offLabel = stringResource(R.string.cron_edit_monitor_off)
|
||||||
|
val scriptLabel = stringResource(R.string.cron_edit_monitor_script)
|
||||||
|
val urlLabel = stringResource(R.string.cron_edit_monitor_url)
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_edit_monitor_mode),
|
||||||
|
options = listOf(offLabel, scriptLabel, urlLabel),
|
||||||
|
selectedValue =
|
||||||
|
when (monitorMode) {
|
||||||
|
"script" -> scriptLabel
|
||||||
|
"url" -> urlLabel
|
||||||
|
else -> offLabel
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
onOptionSelected = { selected ->
|
||||||
|
when (selected) {
|
||||||
|
scriptLabel -> onSetMonitorMode("script")
|
||||||
|
urlLabel -> onSetMonitorMode("url")
|
||||||
|
else -> onSetMonitorMode("off")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
when (monitorMode) {
|
||||||
|
"script" -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = monitorScript,
|
||||||
|
onValueChange = { onFieldChange("monitor_script", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_monitor_script_field)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"url" -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = monitorUrl,
|
||||||
|
onValueChange = { onFieldChange("monitor_url", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_monitor_url_field)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_edit_monitor_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RunDetailRow(
|
private fun RunDetailRow(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ data class CronJobEditorState(
|
|||||||
val workdir: String = "",
|
val workdir: String = "",
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
val no_agent: Boolean = false,
|
val no_agent: 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"
|
||||||
|
val monitor_script: String = "",
|
||||||
|
val monitor_url: String = "",
|
||||||
// Blueprint start-from state (new jobs only)
|
// Blueprint start-from state (new jobs only)
|
||||||
val blueprints: List<CronBlueprint> = emptyList(),
|
val blueprints: List<CronBlueprint> = emptyList(),
|
||||||
val deliveryTargets: List<DeliveryTarget> = emptyList(),
|
val deliveryTargets: List<DeliveryTarget> = emptyList(),
|
||||||
@@ -288,6 +293,14 @@ class CronJobsViewModel :
|
|||||||
workdir = job.workdir.orEmpty(),
|
workdir = job.workdir.orEmpty(),
|
||||||
enabled = job.enabled ?: true,
|
enabled = job.enabled ?: true,
|
||||||
no_agent = job.no_agent ?: false,
|
no_agent = job.no_agent ?: false,
|
||||||
|
monitorMode =
|
||||||
|
when {
|
||||||
|
!job.monitor_script.isNullOrBlank() -> "script"
|
||||||
|
!job.monitor_url.isNullOrBlank() -> "url"
|
||||||
|
else -> "off"
|
||||||
|
},
|
||||||
|
monitor_script = job.monitor_script.orEmpty(),
|
||||||
|
monitor_url = job.monitor_url.orEmpty(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -382,23 +395,7 @@ class CronJobsViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (editor.isNew) {
|
} else if (editor.isNew) {
|
||||||
safeApiCall {
|
createNewJobWithMonitor(editor)
|
||||||
ApiClient.hermesApi.createCronJob(
|
|
||||||
CreateCronJobRequest(
|
|
||||||
name = editor.name,
|
|
||||||
schedule = editor.schedule,
|
|
||||||
prompt = editor.prompt,
|
|
||||||
deliver = editor.deliver,
|
|
||||||
skills = parseLines(editor.skills),
|
|
||||||
model = editor.model.ifBlank { null },
|
|
||||||
provider = editor.provider.ifBlank { null },
|
|
||||||
base_url = editor.base_url.ifBlank { null },
|
|
||||||
script = editor.script.ifBlank { null },
|
|
||||||
workdir = editor.workdir.ifBlank { null },
|
|
||||||
no_agent = editor.no_agent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val updates = mutableMapOf<String, Any?>()
|
val updates = mutableMapOf<String, Any?>()
|
||||||
if (editor.name.isNotEmpty()) updates["name"] = editor.name
|
if (editor.name.isNotEmpty()) updates["name"] = editor.name
|
||||||
@@ -412,6 +409,10 @@ class CronJobsViewModel :
|
|||||||
updates["script"] = editor.script.ifBlank { null }
|
updates["script"] = editor.script.ifBlank { null }
|
||||||
updates["workdir"] = editor.workdir.ifBlank { null }
|
updates["workdir"] = editor.workdir.ifBlank { null }
|
||||||
updates["no_agent"] = editor.no_agent
|
updates["no_agent"] = editor.no_agent
|
||||||
|
// 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 }
|
||||||
|
updates["monitor_url"] = editor.monitor_url.ifBlank { null }
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.updateCronJob(
|
ApiClient.hermesApi.updateCronJob(
|
||||||
editor.jobId ?: "",
|
editor.jobId ?: "",
|
||||||
@@ -445,6 +446,56 @@ class CronJobsViewModel :
|
|||||||
_uiState.update { it.copy(editorState = it.editorState.copy(toastMessage = null)) }
|
_uiState.update { it.copy(editorState = it.editorState.copy(toastMessage = null)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a blank job, then apply monitor mode via a follow-up PUT.
|
||||||
|
*
|
||||||
|
* The backend REST create model (CronJobCreate) does not carry
|
||||||
|
* monitor_script/monitor_url yet — a POST alone would silently drop them.
|
||||||
|
* The PUT updates path accepts them (verified against web_server.py
|
||||||
|
* _update_cron_job_sync / cron.jobs.update_job), so create first, then
|
||||||
|
* apply. If the monitor update fails, the just-created job is rolled back
|
||||||
|
* (deleted) — a half-configured job is worse than no job.
|
||||||
|
*/
|
||||||
|
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||||
|
val createdResult =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.createCronJob(
|
||||||
|
CreateCronJobRequest(
|
||||||
|
name = editor.name,
|
||||||
|
schedule = editor.schedule,
|
||||||
|
prompt = editor.prompt,
|
||||||
|
deliver = editor.deliver,
|
||||||
|
skills = parseLines(editor.skills),
|
||||||
|
model = editor.model.ifBlank { null },
|
||||||
|
provider = editor.provider.ifBlank { null },
|
||||||
|
base_url = editor.base_url.ifBlank { null },
|
||||||
|
script = editor.script.ifBlank { null },
|
||||||
|
workdir = editor.workdir.ifBlank { null },
|
||||||
|
no_agent = editor.no_agent,
|
||||||
|
monitor_script = editor.monitor_script.ifBlank { null },
|
||||||
|
monitor_url = editor.monitor_url.ifBlank { null },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val monitorUpdates = editorMonitorUpdates(editor)
|
||||||
|
if (createdResult !is NetworkResult.Success || monitorUpdates.isEmpty()) {
|
||||||
|
return createdResult
|
||||||
|
}
|
||||||
|
val applyResult =
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.updateCronJob(
|
||||||
|
createdResult.data.id,
|
||||||
|
UpdateCronJobRequest(updates = monitorUpdates.mapValues { it.value.toJsonElement() }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (applyResult is NetworkResult.Success) {
|
||||||
|
return createdResult
|
||||||
|
}
|
||||||
|
// Roll back: the job exists but is missing its monitor source.
|
||||||
|
safeApiCall { ApiClient.hermesApi.deleteCronJob(createdResult.data.id) }
|
||||||
|
return applyResult
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearToast() {
|
override fun clearToast() {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
}
|
}
|
||||||
@@ -486,12 +537,58 @@ class CronJobsViewModel :
|
|||||||
"base_url" -> copy(base_url = value)
|
"base_url" -> copy(base_url = value)
|
||||||
"script" -> copy(script = value)
|
"script" -> copy(script = value)
|
||||||
"workdir" -> copy(workdir = value)
|
"workdir" -> copy(workdir = value)
|
||||||
|
// 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
|
else -> this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleNoAgent() {
|
fun toggleNoAgent() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(editorState = it.editorState.copy(no_agent = !it.editorState.no_agent))
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
it.editorState.copy(
|
||||||
|
no_agent = !it.editorState.no_agent,
|
||||||
|
// Monitor mode is incompatible with no_agent on the backend
|
||||||
|
// (cron/jobs.py _validate_job_mode_invariants) — clear it so
|
||||||
|
// a stale value can't 400 the save. Clear when turning
|
||||||
|
// no_agent ON (old value false).
|
||||||
|
monitorMode = if (!it.editorState.no_agent) "off" else it.editorState.monitorMode,
|
||||||
|
monitor_script = if (!it.editorState.no_agent) "" else it.editorState.monitor_script,
|
||||||
|
monitor_url = if (!it.editorState.no_agent) "" else it.editorState.monitor_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the editor's monitor mode. Selecting a different source clears
|
||||||
|
* the other one (backend invariant: monitor_script XOR monitor_url);
|
||||||
|
* selecting "off" clears both.
|
||||||
|
*/
|
||||||
|
fun setMonitorMode(mode: String) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val editor = state.editorState
|
||||||
|
state.copy(
|
||||||
|
editorState =
|
||||||
|
editor.copy(
|
||||||
|
monitorMode = mode,
|
||||||
|
monitor_script = if (mode != "script") "" else editor.monitor_script,
|
||||||
|
monitor_url = if (mode != "url") "" else editor.monitor_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monitor fields configured in the editor (at most one is non-blank). */
|
||||||
|
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> {
|
||||||
|
return buildMap {
|
||||||
|
if (editor.monitor_script.isNotBlank()) {
|
||||||
|
put("monitor_script", editor.monitor_script.trim())
|
||||||
|
}
|
||||||
|
if (editor.monitor_url.isNotBlank()) {
|
||||||
|
put("monitor_url", editor.monitor_url.trim())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -795,6 +795,15 @@
|
|||||||
<string name="cron_edit_field_script">Script</string>
|
<string name="cron_edit_field_script">Script</string>
|
||||||
<string name="cron_edit_field_workdir">Work Directory</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_no_agent">No agent (script-only)</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>
|
||||||
|
<string name="cron_edit_monitor_url">URL</string>
|
||||||
|
<string name="cron_edit_monitor_script_field">Monitor script</string>
|
||||||
|
<string name="cron_edit_monitor_url_field">Monitor URL</string>
|
||||||
|
<string name="cron_edit_monitor_hint">Agent runs only when the source output changes</string>
|
||||||
|
<string name="cron_status_blocked_config">Blocked (config)</string>
|
||||||
|
<string name="cron_status_no_change">No change</string>
|
||||||
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
|
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
|
||||||
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
|
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
|
||||||
<string name="cron_edit_hint_time">HH:MM</string>
|
<string name="cron_edit_hint_time">HH:MM</string>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.CronJob
|
|||||||
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
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
|||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@@ -223,4 +225,204 @@ class CronJobsViewModelTest {
|
|||||||
coVerify(exactly = 0) { mockApi.instantiateBlueprint(any()) }
|
coVerify(exactly = 0) { mockApi.instantiateBlueprint(any()) }
|
||||||
assertFalse(vm.uiState.value.editorState.isOpen)
|
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setMonitorMode switching source clears the other`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
vm.setMonitorMode("script")
|
||||||
|
vm.updateEditorField("monitor_script", "check.sh")
|
||||||
|
|
||||||
|
var editor = vm.uiState.value.editorState
|
||||||
|
assertEquals("script", editor.monitorMode)
|
||||||
|
assertEquals("check.sh", editor.monitor_script)
|
||||||
|
|
||||||
|
vm.setMonitorMode("url")
|
||||||
|
|
||||||
|
editor = vm.uiState.value.editorState
|
||||||
|
assertEquals("url", editor.monitorMode)
|
||||||
|
assertEquals("", editor.monitor_script)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setMonitorMode off clears both sources`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.setMonitorMode("url")
|
||||||
|
vm.updateEditorField("monitor_url", "https://example.com/status")
|
||||||
|
|
||||||
|
vm.setMonitorMode("off")
|
||||||
|
|
||||||
|
val editor = vm.uiState.value.editorState
|
||||||
|
assertEquals("off", editor.monitorMode)
|
||||||
|
assertEquals("", editor.monitor_script)
|
||||||
|
assertEquals("", editor.monitor_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleNoAgent clears monitor fields`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.setMonitorMode("script")
|
||||||
|
vm.updateEditorField("monitor_script", "check.sh")
|
||||||
|
|
||||||
|
vm.toggleNoAgent()
|
||||||
|
|
||||||
|
val editor = vm.uiState.value.editorState
|
||||||
|
assertTrue(editor.no_agent)
|
||||||
|
assertEquals("off", editor.monitorMode)
|
||||||
|
assertEquals("", editor.monitor_script)
|
||||||
|
assertEquals("", editor.monitor_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new job with monitor mode applies it via follow-up update`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("name", "Watch job")
|
||||||
|
vm.updateEditorField("schedule", "every 10m")
|
||||||
|
vm.updateEditorField("prompt", "Summarize changes")
|
||||||
|
vm.setMonitorMode("script")
|
||||||
|
vm.updateEditorField("monitor_script", "check.sh")
|
||||||
|
val created = CronJob(id = "j3", name = "Watch job")
|
||||||
|
coEvery { mockApi.createCronJob(any()) } returns Response.success(created)
|
||||||
|
coEvery { mockApi.updateCronJob(eq("j3"), any()) } returns Response.success(created)
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
|
||||||
|
val updateSlot = slot<UpdateCronJobRequest>()
|
||||||
|
coVerify { mockApi.updateCronJob(eq("j3"), capture(updateSlot)) }
|
||||||
|
assertEquals(JsonPrimitive("check.sh"), updateSlot.captured.updates["monitor_script"])
|
||||||
|
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new job without monitor skips follow-up update`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("name", "Plain job")
|
||||||
|
vm.updateEditorField("schedule", "every 10m")
|
||||||
|
val created = CronJob(id = "j4", name = "Plain job")
|
||||||
|
coEvery { mockApi.createCronJob(any()) } returns Response.success(created)
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
|
||||||
|
coVerify(exactly = 0) { mockApi.updateCronJob(any(), any()) }
|
||||||
|
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monitor update failure rolls back the created job`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("name", "Watch job")
|
||||||
|
vm.updateEditorField("schedule", "every 10m")
|
||||||
|
vm.setMonitorMode("url")
|
||||||
|
vm.updateEditorField("monitor_url", "https://example.com/status")
|
||||||
|
val created = CronJob(id = "j5", name = "Watch job")
|
||||||
|
coEvery { mockApi.createCronJob(any()) } returns Response.success(created)
|
||||||
|
coEvery { mockApi.updateCronJob(eq("j5"), any()) } returns
|
||||||
|
Response.error(
|
||||||
|
400,
|
||||||
|
"""{"detail":"monitor_script and monitor_url are mutually exclusive"}""".toResponseBody(),
|
||||||
|
)
|
||||||
|
coEvery { mockApi.deleteCronJob(eq("j5")) } returns Response.success(Unit)
|
||||||
|
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
coVerify { mockApi.deleteCronJob("j5") }
|
||||||
|
assertTrue(vm.uiState.value.editorState.isOpen)
|
||||||
|
assertTrue(vm.uiState.value.editorState.toastMessage.orEmpty().contains("Failed to save"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openEditJobDialog prefills monitor fields and mode`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
coEvery { mockApi.getCronJob("j6") } returns
|
||||||
|
Response.success(
|
||||||
|
CronJob(
|
||||||
|
id = "j6",
|
||||||
|
name = "Monitored",
|
||||||
|
schedule = JsonPrimitive("every 10m"),
|
||||||
|
monitor_script = "check.sh",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.openEditJobDialog("j6")
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val editor = vm.uiState.value.editorState
|
||||||
|
assertEquals("script", editor.monitorMode)
|
||||||
|
assertEquals("check.sh", editor.monitor_script)
|
||||||
|
assertEquals("", editor.monitor_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `edit save sends monitor fields in updates`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
coEvery { mockApi.getCronJob("j7") } returns
|
||||||
|
Response.success(
|
||||||
|
CronJob(
|
||||||
|
id = "j7",
|
||||||
|
name = "Monitored",
|
||||||
|
schedule = JsonPrimitive("every 10m"),
|
||||||
|
monitor_url = "https://example.com/status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
coEvery { mockApi.updateCronJob(eq("j7"), any()) } returns
|
||||||
|
Response.success(CronJob(id = "j7", name = "Monitored"))
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
|
||||||
|
vm.openEditJobDialog("j7")
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("name", "Renamed")
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val updateSlot = slot<UpdateCronJobRequest>()
|
||||||
|
coVerify { mockApi.updateCronJob(eq("j7"), capture(updateSlot)) }
|
||||||
|
assertEquals(JsonPrimitive("https://example.com/status"), updateSlot.captured.updates["monitor_url"])
|
||||||
|
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `edit save clears monitor when field emptied`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
coEvery { mockApi.getCronJob("j8") } returns
|
||||||
|
Response.success(
|
||||||
|
CronJob(
|
||||||
|
id = "j8",
|
||||||
|
name = "Monitored",
|
||||||
|
schedule = JsonPrimitive("every 10m"),
|
||||||
|
monitor_script = "check.sh",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
coEvery { mockApi.updateCronJob(eq("j8"), any()) } returns
|
||||||
|
Response.success(CronJob(id = "j8", name = "Monitored"))
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
|
||||||
|
vm.openEditJobDialog("j8")
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("monitor_script", "")
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val updateSlot = slot<UpdateCronJobRequest>()
|
||||||
|
coVerify { mockApi.updateCronJob(eq("j8"), capture(updateSlot)) }
|
||||||
|
assertEquals(JsonNull, updateSlot.captured.updates["monitor_script"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user