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 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user