From 123a9545fb92b73d3eba3bc5675bf3b11d169d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 21:28:28 +0000 Subject: [PATCH] feat(app): full workflow-definition picker on the run board Replace the free-text "Workflow id" field in the New Run sheet with a dropdown of the channel's published workflow definitions (kind-30620), shown by name, plus an inline editor to define a new one. - Account.publishBuzzWorkflowDef: sign + publish a 30620 with a minted workflow UUID, name, and YAML recipe; returns the new id. - WorkflowRunBoardViewModel: fetch + watch the channel's 30620 defs (#h-scoped) alongside the runs, expose them name-sorted as WorkflowDefOption, and drive defineWorkflow(name, yaml). - NewRunSheet: WorkflowPicker dropdown + DefinitionEditor; a just-created definition auto-selects once it lands in the channel list. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6 --- .../vitorpamplona/amethyst/model/Account.kt | 22 +++ .../loggedIn/buzz/WorkflowRunBoardScreen.kt | 171 ++++++++++++++++-- .../buzz/WorkflowRunBoardViewModel.kt | 51 +++++- 3 files changed, 221 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index bab375c58c..f31bfb07c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -174,6 +174,7 @@ import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent import com.vitorpamplona.quartz.buzz.workflow.workflowChannel import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -3328,6 +3329,27 @@ class Account( return signed.id } + /** + * Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an + * addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a + * human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses + * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker + * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. + */ + suspend fun publishBuzzWorkflowDef( + relay: NormalizedRelayUrl, + channelId: String, + name: String, + yaml: String, + ): String? { + if (!isWriteable()) return null + val workflowId = RandomInstance.randomChars(16) + val signed = signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + return workflowId + } + /** * Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which * doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardScreen.kt index 86f4244aab..30cdfa7015 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardScreen.kt @@ -53,11 +53,17 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ColorScheme +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetState @@ -69,6 +75,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -132,6 +139,7 @@ fun WorkflowRunBoardScreen( } val runs by viewModel.runs.collectAsStateWithLifecycle() + val definitions by viewModel.definitions.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } @@ -187,7 +195,9 @@ fun WorkflowRunBoardScreen( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) NewRunSheet( sheetState = sheetState, + definitions = definitions, onDismiss = { composing = false }, + onDefine = { name, yaml, onCreated -> viewModel.defineWorkflow(name, yaml, onCreated) }, onTrigger = { workflowId, task -> viewModel.trigger(workflowId, task) composing = false @@ -653,45 +663,166 @@ private fun StatePill( } } +/** + * Trigger a run: pick one of the channel's published **workflow definitions** (kind-30620) from the + * dropdown — or define a new one inline (name + YAML) — then describe the task. The picker shows + * definitions by name; the free-text id field is gone. Defining a workflow publishes a 30620 and + * auto-selects it once it lands in the channel's definition list. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NewRunSheet( sheetState: SheetState, + definitions: List, onDismiss: () -> Unit, + onDefine: (String, String, (String) -> Unit) -> Unit, onTrigger: (String, String) -> Unit, ) { var task by remember { mutableStateOf("") } - var workflowId by remember { mutableStateOf("") } + var selected by remember { mutableStateOf(null) } + var defining by remember { mutableStateOf(false) } + var pendingSelectId by remember { mutableStateOf(null) } + + // Once a just-published definition lands in the channel list, select it and leave the editor. + LaunchedEffect(definitions, pendingSelectId) { + val id = pendingSelectId ?: return@LaunchedEffect + definitions.firstOrNull { it.id == id }?.let { + selected = it + defining = false + pendingSelectId = null + } + } + // Keep the selection valid if the list changes underneath us; default to the sole definition. + LaunchedEffect(definitions) { + if (selected != null && definitions.none { it.id == selected!!.id }) selected = null + if (selected == null && definitions.size == 1) selected = definitions.first() + } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Text("Trigger a workflow", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text( "The runner does the work, then pauses for a human to approve before it opens a PR. The whole channel sees the run.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( - value = workflowId, - onValueChange = { workflowId = it }, - label = { Text("Workflow id") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = task, - onValueChange = { task = it }, - label = { Text("What should it do?") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, + + if (defining) { + DefinitionEditor( + onCancel = { defining = false }, + onCreate = { name, yaml -> onDefine(name, yaml) { newId -> pendingSelectId = newId } }, + ) + } else { + WorkflowPicker( + definitions = definitions, + selected = selected, + onSelect = { selected = it }, + onNewDefinition = { defining = true }, + ) + OutlinedTextField( + value = task, + onValueChange = { task = it }, + label = { Text("What should it do?") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + Button( + onClick = { onTrigger(selected!!.id, task.trim()) }, + enabled = selected != null && task.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Trigger run") + } + } + } + } +} + +/** The definition dropdown: pick a published 30620 by name, with a trailing "new definition" entry. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WorkflowPicker( + definitions: List, + selected: WorkflowDefOption?, + onSelect: (WorkflowDefOption) -> Unit, + onNewDefinition: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selected?.label ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Workflow") }, + placeholder = { Text(if (definitions.isEmpty()) "No workflows defined yet" else "Choose a workflow") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + definitions.forEach { def -> + DropdownMenuItem( + text = { Text(def.label) }, + onClick = { + onSelect(def) + expanded = false + }, + ) + } + if (definitions.isNotEmpty()) HorizontalDivider() + DropdownMenuItem( + text = { Text("New definition…", fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) }, + leadingIcon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp)) }, + onClick = { + expanded = false + onNewDefinition() + }, ) + } + } +} + +/** Inline editor to publish a new kind-30620 definition: a name and its YAML recipe. */ +@Composable +private fun DefinitionEditor( + onCancel: () -> Unit, + onCreate: (String, String) -> Unit, +) { + var name by remember { mutableStateOf("") } + var yaml by remember { mutableStateOf("") } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("New workflow definition", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + "Names it for the channel and publishes its YAML recipe (kind-30620). A real Buzz relay runs the YAML; self-hosted, the runner runs its configured command — the definition names and catalogs the run.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + placeholder = { Text("build-and-test") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = yaml, + onValueChange = { yaml = it }, + label = { Text("YAML recipe") }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = onCancel) { Text("Cancel") } Button( - onClick = { onTrigger(workflowId.trim(), task.trim()) }, - enabled = workflowId.isNotBlank() && task.isNotBlank(), - modifier = Modifier.fillMaxWidth(), + onClick = { onCreate(name.trim(), yaml) }, + enabled = name.isNotBlank() && yaml.isNotBlank(), + modifier = Modifier.weight(1f), ) { - Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Trigger run") + Text("Create definition") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 9041d2e5cf..be4eeb2de4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRun @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent @@ -54,6 +56,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +/** One selectable workflow definition (kind-30620) — its UUID `d` tag, optional name, and YAML recipe. */ +@Immutable +data class WorkflowDefOption( + val id: String, + val name: String?, + val yaml: String, +) { + /** What the picker shows: the name if it has one, else a short form of the id. */ + val label: String get() = name ?: "Workflow ${id.take(8)}" +} + /** * Backing ViewModel for the [WorkflowRunBoardScreen] — the shared workflow runs of one Buzz channel. * @@ -76,6 +89,11 @@ class WorkflowRunBoardViewModel : ViewModel() { private val _runs = MutableStateFlow>(emptyList()) val runs: StateFlow> = _runs.asStateFlow() + private val _definitions = MutableStateFlow>(emptyList()) + + /** The channel's published workflow definitions (kind-30620), name-sorted — the picker's options. */ + val definitions: StateFlow> = _definitions.asStateFlow() + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() @@ -101,9 +119,10 @@ class WorkflowRunBoardViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true try { - // Phase 1: the `#h`-scoped trigger + lifecycle + approval gate. + // Phase 1: the `#h`-scoped trigger + lifecycle + approval gate, plus the channel's + // addressable workflow definitions (30620) that back the picker. account.client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = WORKFLOW_H_KINDS, tags = mapOf("h" to listOf(channelId))))), + filters = mapOf(relay to listOf(Filter(kinds = WORKFLOW_H_KINDS + WorkflowDefEvent.KIND, tags = mapOf("h" to listOf(channelId))))), timeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } @@ -132,7 +151,7 @@ class WorkflowRunBoardViewModel : ViewModel() { watchJob = viewModelScope.launch(Dispatchers.IO) { account.client - .subscribeAsFlow(relay, listOf(Filter(kinds = WORKFLOW_H_KINDS, tags = mapOf("h" to listOf(channelId))))) + .subscribeAsFlow(relay, listOf(Filter(kinds = WORKFLOW_H_KINDS + WorkflowDefEvent.KIND, tags = mapOf("h" to listOf(channelId))))) .collect { // The client's global listener already consumed the batch into LocalCache. reloadFromCache(channelId) @@ -147,6 +166,13 @@ class WorkflowRunBoardViewModel : ViewModel() { private suspend fun reloadFromCache(channelId: String) = reloadMutex.withLock { + _definitions.value = + LocalCache + .filter(Filter(kinds = listOf(WorkflowDefEvent.KIND), tags = mapOf("h" to listOf(channelId)))) + .mapNotNull { it.event as? WorkflowDefEvent } + .map { WorkflowDefOption(it.workflowId(), it.name()?.takeIf { n -> n.isNotBlank() }, it.yaml()) } + .distinctBy { it.id } + .sortedBy { (it.name ?: it.id).lowercase() } val base = LocalCache .filter(Filter(kinds = WORKFLOW_H_KINDS, tags = mapOf("h" to listOf(channelId)))) @@ -181,6 +207,25 @@ class WorkflowRunBoardViewModel : ViewModel() { account.triggerBuzzWorkflow(relay, channelId, workflowId, task) } + /** + * Publish a new workflow definition (kind-30620) for this channel and hand its freshly-minted id + * back on [onCreated] (on the main-relevant flow) so the picker can select it immediately. + */ + fun defineWorkflow( + name: String, + yaml: String, + onCreated: (String) -> Unit, + ) { + val account = account ?: return + val relay = relay ?: return + val channelId = channelId ?: return + viewModelScope.launch(Dispatchers.IO) { + val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml) + reloadFromCache(channelId) + if (newId != null) onCreated(newId) + } + } + fun approve(runId: HexKey) = act { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId)