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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
Claude
2026-07-26 21:28:28 +00:00
parent 8854dfc156
commit 123a9545fb
3 changed files with 221 additions and 23 deletions
@@ -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.
@@ -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<WorkflowDefOption>,
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<WorkflowDefOption?>(null) }
var defining by remember { mutableStateOf(false) }
var pendingSelectId by remember { mutableStateOf<String?>(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<WorkflowDefOption>,
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")
}
}
}
@@ -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<List<WorkflowRun>>(emptyList())
val runs: StateFlow<List<WorkflowRun>> = _runs.asStateFlow()
private val _definitions = MutableStateFlow<List<WorkflowDefOption>>(emptyList())
/** The channel's published workflow definitions (kind-30620), name-sorted — the picker's options. */
val definitions: StateFlow<List<WorkflowDefOption>> = _definitions.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _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)