feat(cron): blueprint start-from + delivery target picker in job editor (issue #780) (#822)

- New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate
- Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms)
- Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-05 22:44:13 +03:00
1 parent 80be168801
commit 442e868ce2
6 files changed
+740 -118

No files matched your search

@@ -0,0 +1,58 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class CronBlueprintListResponse(
val blueprints: List<CronBlueprint> = emptyList(),
)
@Serializable
data class CronBlueprint(
val key: String,
val title: String = "",
val description: String = "",
val category: String = "",
val tags: List<String> = emptyList(),
val fields: List<CronBlueprintField> = emptyList(),
val schedule: String = "",
val scheduleHuman: String = "",
val command: String = "",
val appUrl: String = "",
)
@Serializable
data class CronBlueprintField(
val name: String,
val type: String = "text",
val label: String = "",
val default: JsonElement? = null,
val options: List<String> = emptyList(),
val optional: Boolean = false,
val strict: Boolean = true,
val help: String = "",
) {
val defaultText: String
get() = (default as? JsonPrimitive)?.content.orEmpty()
}
@Serializable
data class DeliveryTargetsResponse(
val targets: List<DeliveryTarget> = emptyList(),
)
@Serializable
data class DeliveryTarget(
val id: String,
val name: String = "",
val home_target_set: Boolean = false,
val home_env_var: String? = null,
)
@Serializable
data class InstantiateBlueprintRequest(
val blueprint: String,
val values: Map<String, String>,
)
@@ -19,10 +19,12 @@ import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.CredentialPoolResponse
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
@@ -31,6 +33,7 @@ import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.HealthStatus
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.LearningGraphResponse
@@ -308,6 +311,17 @@ interface HermesApiService {
@Path("id") id: String,
): Response<Unit>
@GET("api/cron/blueprints")
suspend fun getCronBlueprints(): Response<CronBlueprintListResponse>
@POST("api/cron/blueprints/instantiate")
suspend fun instantiateBlueprint(
@Body body: InstantiateBlueprintRequest,
): Response<CronJob>
@GET("api/cron/delivery-targets")
suspend fun getCronDeliveryTargets(): Response<DeliveryTargetsResponse>
@POST("api/gateway/start")
suspend fun startGateway(): Response<Unit>
@@ -27,6 +27,11 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -50,10 +55,14 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronBlueprintField
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.DeliveryTarget
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.ExposedDropdownField
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
@@ -241,6 +250,8 @@ fun CronJobsScreen(
state = state.editorState,
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
onToggleNoAgent = { viewModel.toggleNoAgent() },
onSelectBlueprint = { viewModel.selectBlueprint(it) },
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
onSave = { viewModel.saveEditor() },
onDismiss = { viewModel.closeEditor() },
onClearToast = { viewModel.clearEditorToast() },
@@ -275,6 +286,8 @@ fun CronJobEditorDialog(
state: CronJobEditorState,
onFieldChange: (String, String) -> Unit,
onToggleNoAgent: () -> Unit,
onSelectBlueprint: (String?) -> Unit,
onBlueprintFieldChange: (String, String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
onClearToast: () -> Unit,
@@ -360,127 +373,155 @@ fun CronJobEditorDialog(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Name
OutlinedTextField(
value = state.name,
onValueChange = { onFieldChange("name", it) },
label = { Text(stringResource(R.string.cron_edit_field_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
val blueprint = state.selectedBlueprint
// Schedule (required)
OutlinedTextField(
value = state.schedule,
onValueChange = { onFieldChange("schedule", it) },
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Prompt
OutlinedTextField(
value = state.prompt,
onValueChange = { onFieldChange("prompt", it) },
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
modifier =
Modifier
.fillMaxWidth()
.height(120.dp),
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
// Delivery
OutlinedTextField(
value = state.deliver,
onValueChange = { onFieldChange("deliver", it) },
label = { Text(stringResource(R.string.cron_edit_field_deliver)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
supportingText = {
// Start-from blueprint picker (new jobs only; hidden when the
// catalog failed to load)
if (state.isNew && state.blueprints.isNotEmpty()) {
BlueprintStartFromDropdown(
blueprints = state.blueprints,
selectedKey = state.selectedBlueprintKey,
onSelect = onSelectBlueprint,
)
blueprint?.let {
Text(
stringResource(R.string.cron_edit_hint_deliver),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
text = it.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
// Skills
OutlinedTextField(
value = state.skills,
onValueChange = { onFieldChange("skills", it) },
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
// Model
OutlinedTextField(
value = state.model,
onValueChange = { onFieldChange("model", it) },
label = { Text(stringResource(R.string.cron_edit_field_model)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Provider
OutlinedTextField(
value = state.provider,
onValueChange = { onFieldChange("provider", it) },
label = { Text(stringResource(R.string.cron_edit_field_provider)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Base URL
OutlinedTextField(
value = state.base_url,
onValueChange = { onFieldChange("base_url", it) },
label = { Text(stringResource(R.string.cron_edit_field_base_url)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Script path
OutlinedTextField(
value = state.script,
onValueChange = { onFieldChange("script", it) },
label = { Text(stringResource(R.string.cron_edit_field_script)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Work directory
OutlinedTextField(
value = state.workdir,
onValueChange = { onFieldChange("workdir", it) },
label = { Text(stringResource(R.string.cron_edit_field_workdir)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// No Agent toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.no_agent,
onCheckedChange = { onToggleNoAgent() },
if (blueprint != null) {
// Blueprint mode: typed slots only — the backend renders the
// prompt and schedule from the filled values.
blueprint.fields.forEach { field ->
BlueprintSlotField(
field = field,
value = state.blueprintValues[field.name].orEmpty(),
deliveryOptions = state.deliveryOptions,
deliveryTargets = state.deliveryTargets,
onValueChange = { onBlueprintFieldChange(field.name, it) },
)
}
} else {
// Name
OutlinedTextField(
value = state.name,
onValueChange = { onFieldChange("name", it) },
label = { Text(stringResource(R.string.cron_edit_field_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.cron_edit_field_no_agent),
style = MaterialTheme.typography.bodyMedium,
// Schedule (required)
OutlinedTextField(
value = state.schedule,
onValueChange = { onFieldChange("schedule", it) },
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Prompt
OutlinedTextField(
value = state.prompt,
onValueChange = { onFieldChange("prompt", it) },
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
modifier =
Modifier
.fillMaxWidth()
.height(120.dp),
textStyle =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
)
// Delivery
DeliverField(
label = stringResource(R.string.cron_edit_field_deliver),
value = state.deliver,
options = state.deliveryOptions,
deliveryTargets = state.deliveryTargets,
onValueChange = { onFieldChange("deliver", it) },
hint = stringResource(R.string.cron_edit_hint_deliver),
)
// Skills
OutlinedTextField(
value = state.skills,
onValueChange = { onFieldChange("skills", it) },
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
// Model
OutlinedTextField(
value = state.model,
onValueChange = { onFieldChange("model", it) },
label = { Text(stringResource(R.string.cron_edit_field_model)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Provider
OutlinedTextField(
value = state.provider,
onValueChange = { onFieldChange("provider", it) },
label = { Text(stringResource(R.string.cron_edit_field_provider)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Base URL
OutlinedTextField(
value = state.base_url,
onValueChange = { onFieldChange("base_url", it) },
label = { Text(stringResource(R.string.cron_edit_field_base_url)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Script path
OutlinedTextField(
value = state.script,
onValueChange = { onFieldChange("script", it) },
label = { Text(stringResource(R.string.cron_edit_field_script)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// Work directory
OutlinedTextField(
value = state.workdir,
onValueChange = { onFieldChange("workdir", it) },
label = { Text(stringResource(R.string.cron_edit_field_workdir)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
// No Agent toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = state.no_agent,
onCheckedChange = { onToggleNoAgent() },
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.cron_edit_field_no_agent),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
@@ -537,3 +578,175 @@ private fun RunDetailRow(
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BlueprintStartFromDropdown(
blueprints: List<CronBlueprint>,
selectedKey: String?,
onSelect: (String?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedTitle =
blueprints.firstOrNull { it.key == selectedKey }?.title
?: stringResource(R.string.cron_edit_blank_job)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = selectedTitle,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.cron_edit_start_from)) },
modifier =
Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
singleLine = true,
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.cron_edit_blank_job)) },
onClick = {
onSelect(null)
expanded = false
},
)
blueprints.forEach { blueprint ->
DropdownMenuItem(
text = { Text(blueprint.title) },
onClick = {
onSelect(blueprint.key)
expanded = false
},
)
}
}
}
}
@Composable
private fun BlueprintSlotField(
field: CronBlueprintField,
value: String,
deliveryOptions: List<String>,
deliveryTargets: List<DeliveryTarget>,
onValueChange: (String) -> Unit,
) {
when {
field.name == "deliver" -> {
DeliverField(
label = field.label,
value = value,
options = deliveryOptions,
deliveryTargets = deliveryTargets,
onValueChange = onValueChange,
)
}
field.type == "enum" || field.type == "weekdays" -> {
ExposedDropdownField(
label = field.label,
options = field.options,
selectedValue = value,
onOptionSelected = onValueChange,
)
}
else -> {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(field.label) },
placeholder =
if (field.type == "time") {
{ Text(stringResource(R.string.cron_edit_hint_time)) }
} else {
null
},
supportingText =
if (field.help.isNotBlank()) {
{
Text(
text = field.help,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
} else {
null
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
}
}
/**
* Delivery target picker: an editable dropdown fed by the backend's
* delivery-targets endpoint (origin + local + connected platforms). Picking
* fills the field; typing keeps the legacy free-text behaviour (e.g.
* `telegram:chat_id`).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeliverField(
label: String,
value: String,
options: List<String>,
deliveryTargets: List<DeliveryTarget>,
onValueChange: (String) -> Unit,
hint: String? = null,
) {
var expanded by remember { mutableStateOf(false) }
val displayNames =
deliveryTargets.associate { it.id to it.name.ifBlank { it.id } } + ("origin" to "origin")
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier =
Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
supportingText =
hint?.let {
{
Text(
text = it,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
},
singleLine = true,
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { id ->
DropdownMenuItem(
text = { Text(displayNames[id] ?: id) },
onClick = {
onValueChange(id)
expanded = false
},
)
}
}
}
}
@@ -3,7 +3,10 @@ package com.m57.hermescontrol.ui.cron
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CronBlueprint
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.DeliveryTarget
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.NetworkResult
@@ -51,7 +54,26 @@ data class CronJobEditorState(
val workdir: String = "",
val enabled: Boolean = true,
val no_agent: Boolean = false,
)
// Blueprint start-from state (new jobs only)
val blueprints: List<CronBlueprint> = emptyList(),
val deliveryTargets: List<DeliveryTarget> = emptyList(),
val selectedBlueprintKey: String? = null,
val blueprintValues: Map<String, String> = emptyMap(),
) {
val selectedBlueprint: CronBlueprint?
get() = blueprints.firstOrNull { it.key == selectedBlueprintKey }
/** Delivery ids offered by the picker: origin, then local + connected platforms. */
val deliveryOptions: List<String>
get() =
buildList {
add("origin")
addAll(deliveryTargets.map { it.id })
if (deliveryTargets.none { it.id == "local" }) {
add("local")
}
}
}
class CronJobsViewModel :
ViewModel(),
@@ -169,6 +191,50 @@ class CronJobsViewModel :
),
)
}
loadEditorBlueprints()
loadDeliveryTargets()
}
fun loadEditorBlueprints() {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getCronBlueprints() }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(editorState = it.editorState.copy(blueprints = result.data.blueprints))
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
editorState =
it.editorState.copy(
toastMessage = "Failed to load blueprints: ${result.error.message}",
),
)
}
}
}
}
}
fun loadDeliveryTargets() {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() }
}
if (result is NetworkResult.Success) {
_uiState.update {
it.copy(editorState = it.editorState.copy(deliveryTargets = result.data.targets))
}
}
// Failure: editor keeps working; the deliver picker falls back to origin/local.
}
}
fun openEditJobDialog(id: String) {
@@ -230,12 +296,45 @@ class CronJobsViewModel :
}
}
}
loadDeliveryTargets()
}
fun closeEditor() {
_uiState.update { it.copy(editorState = CronJobEditorState()) }
}
/** Pick a start-from blueprint (null = blank job); seeds its slot defaults. */
fun selectBlueprint(key: String?) {
_uiState.update { state ->
val editor = state.editorState
val blueprint = key?.let { k -> editor.blueprints.firstOrNull { it.key == k } }
val values = blueprint?.fields?.associate { it.name to it.defaultText }.orEmpty()
state.copy(
editorState =
editor.copy(
selectedBlueprintKey = key,
blueprintValues = values,
toastMessage = null,
),
)
}
}
fun updateBlueprintValue(
name: String,
value: String,
) {
_uiState.update { state ->
state.copy(
editorState =
state.editorState.copy(
blueprintValues = state.editorState.blueprintValues + (name to value),
toastMessage = null,
),
)
}
}
fun updateEditorField(
name: String,
value: String,
@@ -252,7 +351,7 @@ class CronJobsViewModel :
fun saveEditor() {
val editor = _uiState.value.editorState
if (editor.schedule.isBlank()) {
if (editor.selectedBlueprintKey == null && editor.schedule.isBlank()) {
_uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) }
return
}
@@ -261,7 +360,16 @@ class CronJobsViewModel :
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
if (editor.isNew) {
if (editor.isNew && editor.selectedBlueprintKey != null) {
safeApiCall {
ApiClient.hermesApi.instantiateBlueprint(
InstantiateBlueprintRequest(
blueprint = editor.selectedBlueprintKey,
values = editor.blueprintValues,
),
)
}
} else if (editor.isNew) {
safeApiCall {
ApiClient.hermesApi.createCronJob(
CreateCronJobRequest(