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:
M57andM57 authored and GitHub committed 2026-08-10 15:35:05 +03:00
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 no_agent: Boolean? = 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
get() =
@@ -52,4 +56,8 @@ data class CronJob(
val nextRunTime: String
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 no_agent: Boolean = false,
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
@@ -30,17 +30,19 @@ fun ExposedDropdownField(
options: List<String>,
selectedValue: String,
onOptionSelected: (String) -> Unit,
enabled: Boolean = true,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
onExpandedChange = { if (enabled) expanded = it },
) {
OutlinedTextField(
value = selectedValue.ifEmpty { options.firstOrNull() ?: "" },
onValueChange = {},
readOnly = true,
enabled = enabled,
modifier =
Modifier
.fillMaxWidth()
@@ -169,6 +169,23 @@ fun CronJobsScreen(
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))
Text(
@@ -250,6 +267,7 @@ fun CronJobsScreen(
state = state.editorState,
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
onToggleNoAgent = { viewModel.toggleNoAgent() },
onSetMonitorMode = { viewModel.setMonitorMode(it) },
onSelectBlueprint = { viewModel.selectBlueprint(it) },
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
onSave = { viewModel.saveEditor() },
@@ -265,13 +283,21 @@ fun CronJobsScreen(
title = { Text(job.name, style = MaterialTheme.typography.titleLarge) },
text = {
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) }
RunDetailRow("Schedule", CronExpressionFormatter.cronToHumanReadable(job.scheduleText))
if (job.last_error != null && job.last_error.isNotBlank()) {
RunDetailRow("Error", job.last_error)
}
job.script?.let { if (it.isNotBlank()) RunDetailRow("Script", it) }
job.monitorSource?.let { if (it.isNotBlank()) RunDetailRow("Monitor", it) }
}
},
confirmButton = {
@@ -286,6 +312,7 @@ fun CronJobEditorDialog(
state: CronJobEditorState,
onFieldChange: (String, String) -> Unit,
onToggleNoAgent: () -> Unit,
onSetMonitorMode: (String) -> Unit,
onSelectBlueprint: (String?) -> Unit,
onBlueprintFieldChange: (String, String) -> Unit,
onSave: () -> Unit,
@@ -295,7 +322,9 @@ fun CronJobEditorDialog(
var showDiscardConfirm by remember { mutableStateOf(false) }
val hasChanges =
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)
@@ -522,6 +551,17 @@ fun CronJobEditorDialog(
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))
@@ -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
private fun RunDetailRow(
label: String,
@@ -56,6 +56,11 @@ data class CronJobEditorState(
val workdir: String = "",
val enabled: Boolean = true,
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)
val blueprints: List<CronBlueprint> = emptyList(),
val deliveryTargets: List<DeliveryTarget> = emptyList(),
@@ -288,6 +293,14 @@ class CronJobsViewModel :
workdir = job.workdir.orEmpty(),
enabled = job.enabled ?: true,
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) {
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,
),
)
}
createNewJobWithMonitor(editor)
} else {
val updates = mutableMapOf<String, Any?>()
if (editor.name.isNotEmpty()) updates["name"] = editor.name
@@ -412,6 +409,10 @@ class CronJobsViewModel :
updates["script"] = editor.script.ifBlank { null }
updates["workdir"] = editor.workdir.ifBlank { null }
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 {
ApiClient.hermesApi.updateCronJob(
editor.jobId ?: "",
@@ -445,6 +446,56 @@ class CronJobsViewModel :
_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() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -486,12 +537,58 @@ class CronJobsViewModel :
"base_url" -> copy(base_url = value)
"script" -> copy(script = 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
}
fun toggleNoAgent() {
_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())
}
}
}
}