feat(android): add v2 cron job editor

This commit is contained in:
Ayaan Zaidi
2026-05-19 21:24:31 +05:30
parent de195645f9
commit 499ccd1522
3 changed files with 221 additions and 1 deletions

View File

@@ -93,6 +93,7 @@ class MainViewModel(
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
val cronSaving: StateFlow<Boolean> = runtimeState(initial = false) { it.cronSaving }
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
@@ -402,6 +403,20 @@ class MainViewModel(
ensureRuntime().refreshCronJobs()
}
fun createCronJob(
name: String,
message: String,
scheduleKind: String,
scheduleValue: String,
) {
ensureRuntime().createCronJob(
name = name,
message = message,
scheduleKind = scheduleKind,
scheduleValue = scheduleValue,
)
}
fun refreshUsage() {
ensureRuntime().refreshUsage()
}

View File

@@ -70,6 +70,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@@ -322,6 +323,8 @@ class NodeRuntime(
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = _cronJobs.asStateFlow()
private val _cronRefreshing = MutableStateFlow(false)
val cronRefreshing: StateFlow<Boolean> = _cronRefreshing.asStateFlow()
private val _cronSaving = MutableStateFlow(false)
val cronSaving: StateFlow<Boolean> = _cronSaving.asStateFlow()
private val _cronErrorText = MutableStateFlow<String?>(null)
val cronErrorText: StateFlow<String?> = _cronErrorText.asStateFlow()
private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList()))
@@ -719,6 +722,22 @@ class NodeRuntime(
}
}
fun createCronJob(
name: String,
message: String,
scheduleKind: String,
scheduleValue: String,
) {
scope.launch {
createCronJobOnGateway(
name = name,
message = message,
scheduleKind = scheduleKind,
scheduleValue = scheduleValue,
)
}
}
fun refreshUsage() {
scope.launch {
refreshUsageFromGateway()
@@ -1744,6 +1763,119 @@ class NodeRuntime(
}
}
private suspend fun createCronJobOnGateway(
name: String,
message: String,
scheduleKind: String,
scheduleValue: String,
) {
_cronSaving.value = true
_cronErrorText.value = null
if (!operatorConnected) {
_cronErrorText.value = "Connect the gateway before creating a cron job."
_cronSaving.value = false
return
}
try {
val cleanName = name.trim()
val cleanMessage = message.trim()
if (cleanName.isEmpty()) {
throw IllegalArgumentException("Add a job name.")
}
if (cleanMessage.isEmpty()) {
throw IllegalArgumentException("Add the message OpenClaw should run.")
}
val schedule = buildCronCreateSchedule(scheduleKind = scheduleKind, scheduleValue = scheduleValue.trim())
val params =
buildJsonObject {
put("name", JsonPrimitive(cleanName))
put("enabled", JsonPrimitive(true))
put("deleteAfterRun", JsonPrimitive(scheduleKind == "Once"))
put("sessionTarget", JsonPrimitive("isolated"))
put("wakeMode", JsonPrimitive("now"))
put("schedule", schedule)
put(
"payload",
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
put("message", JsonPrimitive(cleanMessage))
},
)
put(
"delivery",
buildJsonObject {
put("mode", JsonPrimitive("announce"))
put("channel", JsonPrimitive("last"))
put("bestEffort", JsonPrimitive(true))
},
)
}
operatorSession.request("cron.add", params.toString())
refreshCronFromGateway()
} catch (err: IllegalArgumentException) {
_cronErrorText.value = err.message ?: "Could not create cron job."
} catch (_: Throwable) {
_cronErrorText.value = "Could not create cron job."
} finally {
_cronSaving.value = false
}
}
private fun buildCronCreateSchedule(
scheduleKind: String,
scheduleValue: String,
): JsonObject =
when (scheduleKind) {
"Every" ->
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(parseCronDurationMs(scheduleValue)))
}
"Once" ->
buildJsonObject {
put("kind", JsonPrimitive("at"))
put("at", JsonPrimitive(resolveCronAtIso(scheduleValue)))
}
"Cron" ->
buildJsonObject {
val expr = scheduleValue.takeIf { it.isNotEmpty() } ?: throw IllegalArgumentException("Add a cron expression.")
put("kind", JsonPrimitive("cron"))
put("expr", JsonPrimitive(expr))
put("staggerMs", JsonPrimitive(0))
}
else -> throw IllegalArgumentException("Choose a schedule.")
}
private fun resolveCronAtIso(value: String): String {
val clean = value.trim()
if (clean.startsWith("+")) {
return Instant.ofEpochMilli(System.currentTimeMillis() + parseCronDurationMs(clean.drop(1))).toString()
}
return try {
Instant.parse(clean).toString()
} catch (_: Throwable) {
throw IllegalArgumentException("Use ISO time or +30m.")
}
}
private fun parseCronDurationMs(value: String): Long {
val match =
Regex("""^(\d+)\s*([mhd])$""")
.matchEntire(value.trim().lowercase())
?: throw IllegalArgumentException("Use 15m, 1h, or 1d.")
val amount = match.groupValues[1].toLong()
if (amount <= 0) {
throw IllegalArgumentException("Use a duration greater than zero.")
}
val unit = match.groupValues[2]
return amount *
when (unit) {
"m" -> 60_000L
"h" -> 3_600_000L
else -> 86_400_000L
}
}
private suspend fun refreshUsageFromGateway() {
_usageRefreshing.value = true
_usageErrorText.value = null

View File

@@ -177,8 +177,14 @@ private fun V2CronJobsSettingsScreen(
val cronStatus by viewModel.cronStatus.collectAsState()
val cronJobs by viewModel.cronJobs.collectAsState()
val cronRefreshing by viewModel.cronRefreshing.collectAsState()
val cronSaving by viewModel.cronSaving.collectAsState()
val cronErrorText by viewModel.cronErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
var showEditor by remember { mutableStateOf(false) }
var jobName by remember { mutableStateOf("") }
var jobMessage by remember { mutableStateOf("") }
var scheduleKind by remember { mutableStateOf("Every") }
var scheduleValue by remember { mutableStateOf("1h") }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -197,6 +203,36 @@ private fun V2CronJobsSettingsScreen(
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.weight(1f))
ClawPrimaryButton(text = if (showEditor) "Close" else "New Job", onClick = { showEditor = !showEditor }, enabled = isConnected, modifier = Modifier.weight(1f))
}
if (showEditor) {
V2CronJobEditorPanel(
name = jobName,
onNameChange = { jobName = it },
message = jobMessage,
onMessageChange = { jobMessage = it },
scheduleKind = scheduleKind,
onScheduleKindChange = {
scheduleKind = it
scheduleValue =
when (it) {
"Once" -> "+30m"
"Cron" -> "0 9 * * *"
else -> "1h"
}
},
scheduleValue = scheduleValue,
onScheduleValueChange = { scheduleValue = it },
saving = cronSaving,
onSave = {
viewModel.createCronJob(
name = jobName,
message = jobMessage,
scheduleKind = scheduleKind,
scheduleValue = scheduleValue,
)
},
)
}
cronErrorText?.let { errorText ->
ClawPanel {
@@ -212,7 +248,7 @@ private fun V2CronJobsSettingsScreen(
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No scheduled jobs.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Create jobs from the WebUI or CLI and they will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Create recurring OpenClaw work here or from the WebUI.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> V2CronJobsPanel(jobs = cronJobs)
@@ -220,6 +256,43 @@ private fun V2CronJobsSettingsScreen(
}
}
@Composable
private fun V2CronJobEditorPanel(
name: String,
onNameChange: (String) -> Unit,
message: String,
onMessageChange: (String) -> Unit,
scheduleKind: String,
onScheduleKindChange: (String) -> Unit,
scheduleValue: String,
onScheduleValueChange: (String) -> Unit,
saving: Boolean,
onSave: () -> Unit,
) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "New Job", style = ClawTheme.type.section, color = ClawTheme.colors.text)
ClawTextField(value = name, onValueChange = onNameChange, placeholder = "Name")
ClawTextField(value = message, onValueChange = onMessageChange, placeholder = "Message OpenClaw should run", minLines = 3)
ClawSegmentedControl(
options = listOf("Every", "Once", "Cron"),
selected = scheduleKind,
onSelect = onScheduleKindChange,
modifier = Modifier.fillMaxWidth(),
)
ClawTextField(value = scheduleValue, onValueChange = onScheduleValueChange, placeholder = cronSchedulePlaceholder(scheduleKind))
ClawPrimaryButton(text = if (saving) "Saving" else "Save Job", onClick = onSave, enabled = !saving, modifier = Modifier.fillMaxWidth())
}
}
}
private fun cronSchedulePlaceholder(scheduleKind: String): String =
when (scheduleKind) {
"Once" -> "+30m or 2026-05-19T18:00:00Z"
"Cron" -> "0 9 * * *"
else -> "15m, 1h, or 1d"
}
@Composable
private fun V2AgentsSettingsScreen(
viewModel: MainViewModel,