mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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.
|
||||
|
||||
+151
-20
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user