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 fa940f597b..24d0c839c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -163,12 +164,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent +import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent import com.vitorpamplona.quartz.buzz.threading.buzzThread 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.buzz.workspace.BUZZ_ROLE_ADMIN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN @@ -241,6 +249,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNu import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan @@ -293,6 +302,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache @@ -397,6 +407,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException @@ -3282,6 +3294,132 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared + * feature-request the workspace bot can pick up. Untargeted: any agent watching the + * channel may accept it. Returns the new job id (the request event id), or null when the + * account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]. + */ + suspend fun fileBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + request: String, + ): HexKey? { + if (!isWriteable()) return null + val signed = signer.sign(JobRequestEvent.build(request, channelId, null)) + // Reflect it locally so the board updates immediately (publish only sends to relays). + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + return signed.id + } + + /** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */ + suspend fun cancelBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + jobId: HexKey, + ) { + if (!isWriteable()) return + val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId)) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + } + + /** + * Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on + * [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the + * approval token), returned here. A run pauses on a human-approval gate before anything ships — + * see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator]. + */ + suspend fun triggerBuzzWorkflow( + relay: NormalizedRelayUrl, + channelId: String, + workflowId: String, + task: String, + ): HexKey? { + if (!isWriteable()) return null + val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) + val signed = signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + 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. + * Publishing to the single group [relay]; the runner discovers the decision by author. + */ + suspend fun approveBuzzWorkflowRun( + relay: NormalizedRelayUrl, + runId: HexKey, + note: String = "", + ): HexKey? { + if (!isWriteable()) return null + val signed = signer.sign(ApprovalGrantEvent.build(runId, note)) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + return signed.id + } + + /** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */ + suspend fun denyBuzzWorkflowRun( + relay: NormalizedRelayUrl, + runId: HexKey, + note: String = "", + ): HexKey? { + if (!isWriteable()) return null + val signed = signer.sign(ApprovalDenyEvent.build(runId, note)) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + return signed.id + } + + /** + * Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging + * the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and + * `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority. + */ + suspend fun upvoteBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + jobId: HexKey, + jobAuthor: HexKey?, + ) { + if (!isWriteable()) return + val template = + eventTemplate(ReactionEvent.KIND, ReactionEvent.LIKE) { + addUnique(ETag.assemble(jobId, null, null)) + jobAuthor?.let { addUnique(PTag.assemble(it, null)) } + addUnique(arrayOf("k", JobRequestEvent.KIND.toString())) + addUnique(GroupIdTag.assemble(channelId)) + } + val signed = signer.sign(template) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(relay)) + } + /** Send a kind 9022 leave request to the host relay and drop it from our list. */ suspend fun leaveRelayGroup(channel: RelayGroupChannel) { val template = LeaveRequestEvent.build(channel.groupId.id) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fafad6126a..c4a31489c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -104,12 +104,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentWorkBoardScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.JobBoardScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.WorkflowRunBoardScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen @@ -773,6 +776,9 @@ fun BuildNavigation( ) } composableFromEndArgs { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) } + composableFromEndArgs { JobBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) } + composableFromEndArgs { WorkflowRunBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) } + composableFromEndArgs { AgentWorkBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromBottomArgs { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromEndArgs { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index acb3f8f6f9..37f76aa7b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -711,6 +711,21 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable data class BuzzJobBoard( + val channelId: String, + val relayUrl: String, + ) : Route() + + @Serializable data class BuzzAgentWork( + val channelId: String, + val relayUrl: String, + ) : Route() + + @Serializable data class BuzzWorkflowBoard( + val channelId: String, + val relayUrl: String, + ) : Route() + @Serializable data class BuzzForumPost( val channelId: String, val relayUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index 3a708ace4e..a6a5f16670 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -20,49 +20,89 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +// Common event kinds an agent might be restricted to — suggestions for the "Restrict to kind" field; +// any 0–65535 is still accepted by free numeric entry. +private val KIND_OPTIONS = + listOf( + DropdownOption("1", "1 · Text note"), + DropdownOption("7", "7 · Reaction"), + DropdownOption("9", "9 · Group chat message"), + DropdownOption("1111", "1111 · Comment"), + DropdownOption("30023", "30023 · Long-form article"), + DropdownOption("40002", "40002 · Buzz minichat message"), + ) + +private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label } /** * Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment @@ -85,7 +125,7 @@ fun AgentAttestationScreen( val myPubkey = accountViewModel.account.userProfile().pubkeyHex Scaffold( - topBar = { TopBarWithBackButton("Attestations", nav) }, + topBar = { TopBarWithBackButton(stringRes(R.string.buzz_attest_topbar), nav) }, ) { padding -> Column( modifier = @@ -106,7 +146,7 @@ fun AgentAttestationScreen( if (privKey == null) { ReadOnlyKeyNotice() } else { - AttestationForm(ownerKey = keyPair) + AttestationForm(ownerKey = keyPair, accountViewModel = accountViewModel, nav = nav) } } } @@ -129,26 +169,26 @@ private fun HoldAttestationSection(myPubkey: String) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Hold an attestation", + text = stringRes(R.string.buzz_attest_hold_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) if (mine != null) { Text( - text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.", + text = stringRes(R.string.buzz_attest_holding), style = MaterialTheme.typography.bodyMedium, ) Text( - text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" }, + text = stringRes(R.string.buzz_attest_grants_prefix, mine.conditions.ifEmpty { stringRes(R.string.buzz_attest_grants_unrestricted) }), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) { - Text("Remove") + Text(stringRes(R.string.buzz_attest_remove)) } } else { Text( - text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.", + text = stringRes(R.string.buzz_attest_hold_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -158,7 +198,7 @@ private fun HoldAttestationSection(myPubkey: String) { input = it error = null }, - label = { Text("auth tag JSON") }, + label = { Text(stringRes(R.string.buzz_attest_authtag_label)) }, minLines = 2, modifier = Modifier.fillMaxWidth(), ) @@ -179,7 +219,7 @@ private fun HoldAttestationSection(myPubkey: String) { enabled = input.isNotBlank(), modifier = Modifier.fillMaxWidth(), ) { - Text("Hold attestation") + Text(stringRes(R.string.buzz_attest_hold_button)) } } } @@ -229,16 +269,12 @@ private fun ReadOnlyKeyNotice() { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Local key required", + text = stringRes(R.string.buzz_attest_readonly_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) Text( - text = - "A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " + - "so it can only be produced by a signer that holds your raw private key. This " + - "account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " + - "sign an attestation.", + text = stringRes(R.string.buzz_attest_readonly_desc), style = MaterialTheme.typography.bodyMedium, ) } @@ -246,11 +282,15 @@ private fun ReadOnlyKeyNotice() { } @Composable -private fun AttestationForm(ownerKey: KeyPair) { +private fun AttestationForm( + ownerKey: KeyPair, + accountViewModel: AccountViewModel, + nav: INav, +) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() - var agentInput by remember { mutableStateOf("") } + var selectedAgent by remember { mutableStateOf(null) } var kindInput by remember { mutableStateOf("") } var afterInput by remember { mutableStateOf("") } var beforeInput by remember { mutableStateOf("") } @@ -258,42 +298,47 @@ private fun AttestationForm(ownerKey: KeyPair) { var result by remember { mutableStateOf(null) } Text( - text = - "Authorize an agent pubkey to publish in your workspace without enrolling its key. " + - "The agent attaches the signed tag below to its events.", + text = stringRes(R.string.buzz_attest_form_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( - value = agentInput, - onValueChange = { - agentInput = it + // #1: pick the agent by name from the local user cache — or paste an npub/hex for a key that + // isn't a contact yet (the common case for an external agent operator). + AgentKeyPicker( + selected = selectedAgent, + accountViewModel = accountViewModel, + nav = nav, + onSelect = { + selectedAgent = it + error = null + result = null + }, + onClear = { + selectedAgent = null error = null result = null }, - label = { Text("Agent public key (npub or hex)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), ) Text( - text = "Conditions (optional) — leave blank for an unrestricted attestation.", + text = stringRes(R.string.buzz_attest_conditions_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( + // #3: kind is any 0–65535, but the common ones have names — offer them, keep free numeric entry. + EditableSuggestDropdown( value = kindInput, onValueChange = { kindInput = it.filter(Char::isDigit) error = null result = null }, - label = { Text("Restrict to kind (0–65535)") }, - singleLine = true, + label = stringRes(R.string.buzz_attest_kind_label), + options = KIND_OPTIONS, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), + supportingText = KIND_LABELS[kindInput], ) OutlinedTextField( @@ -303,9 +348,11 @@ private fun AttestationForm(ownerKey: KeyPair) { error = null result = null }, - label = { Text("Only events after (unix seconds)") }, + label = { Text(stringRes(R.string.buzz_attest_after_label)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + // Echo the entered epoch back as a readable UTC time so nobody has to eyeball unix seconds. + supportingText = unixEcho(afterInput)?.let { echo -> { Text(echo) } }, modifier = Modifier.fillMaxWidth(), ) @@ -316,9 +363,10 @@ private fun AttestationForm(ownerKey: KeyPair) { error = null result = null }, - label = { Text("Only events before (unix seconds)") }, + label = { Text(stringRes(R.string.buzz_attest_before_label)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = unixEcho(beforeInput)?.let { echo -> { Text(echo) } }, modifier = Modifier.fillMaxWidth(), ) @@ -332,7 +380,8 @@ private fun AttestationForm(ownerKey: KeyPair) { Button( onClick = { - when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) { + val agent = selectedAgent ?: return@Button + when (val outcome = buildAttestation(agent, kindInput, afterInput, beforeInput, ownerKey)) { is AttestationOutcome.Failure -> { error = outcome.message result = null @@ -343,10 +392,10 @@ private fun AttestationForm(ownerKey: KeyPair) { } } }, - enabled = agentInput.isNotBlank(), + enabled = selectedAgent != null, modifier = Modifier.fillMaxWidth(), ) { - Text("Generate attestation") + Text(stringRes(R.string.buzz_attest_generate)) } result?.let { attestation -> @@ -357,6 +406,112 @@ private fun AttestationForm(ownerKey: KeyPair) { } } +/** + * Single-agent people picker: once an agent is chosen it shows as a removable chip; otherwise a + * name typeahead over the local user cache with an npub/hex paste escape hatch (Enter accepts it). + * Mirrors the New-DM recipient picker so authorizing an agent stops being the one raw-key field. + */ +@Composable +private fun AgentKeyPicker( + selected: HexKey?, + accountViewModel: AccountViewModel, + nav: INav, + onSelect: (HexKey) -> Unit, + onClear: () -> Unit, +) { + if (selected != null) { + AgentChip(selected, accountViewModel, nav, onClear) + return + } + + var query by remember { mutableStateOf("") } + var suggestions by remember { mutableStateOf>(emptyList()) } + var pasteError by remember { mutableStateOf(false) } + + LaunchedEffect(query) { + if (query.isBlank()) { + suggestions = emptyList() + return@LaunchedEffect + } + delay(150) + suggestions = + withContext(Dispatchers.IO) { + LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8) + } + } + + OutlinedTextField( + value = query, + onValueChange = { + query = it + pasteError = false + }, + label = { Text(stringRes(R.string.buzz_attest_agent_label)) }, + leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) }, + singleLine = true, + isError = pasteError, + // Keep the invalid-paste error on the field the user just typed in, not far down the form. + supportingText = stringRes(R.string.buzz_attest_agent_paste_error).takeIf { pasteError }?.let { msg -> { Text(msg) } }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + val hex = decodePublicKeyAsHexOrNull(query.trim())?.takeIf { it.isValid() } + if (hex != null) onSelect(hex) else pasteError = true + }, + ), + modifier = Modifier.fillMaxWidth(), + ) + // A plain Column (not LazyColumn) — this form lives inside a verticalScroll parent. + suggestions.forEach { hex -> + AgentSuggestionRow(hex, accountViewModel, nav) { onSelect(hex) } + } +} + +/** One tappable agent search result — avatar + resolved name. */ +@Composable +private fun AgentSuggestionRow( + hex: HexKey, + accountViewModel: AccountViewModel, + nav: INav, + onClick: () -> Unit, +) { + val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) } + val name by observeUserName(user, accountViewModel) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserPicture(hex, 34.dp, accountViewModel = accountViewModel, nav = nav) + Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge) + } +} + +/** The chosen agent as a removable chip — tapping the close affordance clears the selection. */ +@Composable +private fun AgentChip( + hex: HexKey, + accountViewModel: AccountViewModel, + nav: INav, + onRemove: () -> Unit, +) { + val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) } + val name by observeUserName(user, accountViewModel) + InputChip( + selected = false, + onClick = onRemove, + label = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + avatar = { UserPicture(hex, 22.dp, accountViewModel = accountViewModel, nav = nav) }, + trailingIcon = { Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.buzz_attest_change_agent), modifier = Modifier.size(16.dp)) }, + ) +} + @Composable private fun AttestationResultCard( attestation: OwnerAttestation, @@ -365,14 +520,12 @@ private fun AttestationResultCard( Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Signed attestation", + text = stringRes(R.string.buzz_attest_signed_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) Text( - text = - "Grants: " + - (attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }), + text = stringRes(R.string.buzz_attest_grants_prefix, attestation.conditions.ifEmpty { stringRes(R.string.buzz_attest_grants_unrestricted) }), style = MaterialTheme.typography.bodyMedium, ) Text( @@ -386,14 +539,11 @@ private fun AttestationResultCard( horizontalArrangement = Arrangement.End, ) { OutlinedButton(onClick = onCopy) { - Text("Copy tag") + Text(stringRes(R.string.buzz_attest_copy_tag)) } } Text( - text = - "⚠ Hand this to the agent operator only. While it is valid and you remain a " + - "workspace member, the relay lets this agent post as a member under the " + - "conditions above.", + text = stringRes(R.string.buzz_attest_warning), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -465,5 +615,15 @@ private fun parseOptionalUnix(input: String): OptionalUnix? { return OptionalUnix(parsed) } +private val UNIX_ECHO_FORMAT = + SimpleDateFormat("yyyy-MM-dd HH:mm 'UTC'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } + +/** A readable UTC rendering of an entered epoch-seconds string, or null when it's blank/out of range. */ +private fun unixEcho(input: String): String? { + if (input.isBlank()) return null + val secs = input.toLongOrNull()?.takeIf { it in 0..4294967295L } ?: return null + return UNIX_ECHO_FORMAT.format(Date(secs * 1000)) +} + /** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */ private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt index bbf3c87faf..1a40cdc152 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt @@ -38,9 +38,33 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +// Well-known values for the persona's optional model / provider / runtime — suggestions only; any +// string is still accepted (the fields are free-form both in NIP-AP and in Buzz's persona events). +private val MODEL_OPTIONS = + listOf( + "claude-opus-4-8", + "claude-opus-4", + "claude-sonnet-4-5", + "claude-haiku-4-5", + "gpt-4o", + "gpt-4.1", + "o3", + "gemini-2.5-pro", + "gemini-2.5-flash", + "llama-3.3-70b", + ).map { DropdownOption(it) } + +private val PROVIDER_OPTIONS = + listOf("anthropic", "openai", "google", "groq", "openrouter", "ollama", "bedrock", "azure").map { DropdownOption(it) } + +private val RUNTIME_OPTIONS = + listOf("goose", "claude-code", "custom").map { DropdownOption(it) } /** * Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona; @@ -59,7 +83,7 @@ fun AgentPersonaEditScreen( viewModel.bind(accountViewModel.account, slug) val state by viewModel.state.collectAsStateWithLifecycle() - val title = if (slug == null) "New persona" else "Edit persona" + val title = if (slug == null) stringRes(R.string.buzz_persona_new_title) else stringRes(R.string.buzz_persona_edit_title) Scaffold( topBar = { TopBarWithBackButton(title, nav) }, @@ -76,51 +100,50 @@ fun AgentPersonaEditScreen( OutlinedTextField( value = state.slug, onValueChange = viewModel::onSlugChange, - label = { Text("Slug (persona id)") }, + label = { Text(stringRes(R.string.buzz_persona_slug)) }, singleLine = true, enabled = !state.slugLocked, - supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") }, + supportingText = { Text(stringRes(R.string.buzz_persona_slug_help)) }, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = state.displayName, onValueChange = viewModel::onDisplayNameChange, - label = { Text("Display name") }, + label = { Text(stringRes(R.string.buzz_persona_display_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = state.systemPrompt, onValueChange = viewModel::onSystemPromptChange, - label = { Text("System prompt") }, + label = { Text(stringRes(R.string.buzz_persona_system_prompt)) }, minLines = 3, modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + // Model / provider / runtime are free-form strings upstream, but their real-world values + // are a small known set — offer them as suggestions while keeping free entry. + EditableSuggestDropdown( value = state.model, onValueChange = viewModel::onModelChange, - label = { Text("Model (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + label = stringRes(R.string.buzz_persona_model), + options = MODEL_OPTIONS, ) - OutlinedTextField( + EditableSuggestDropdown( value = state.provider, onValueChange = viewModel::onProviderChange, - label = { Text("Provider (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + label = stringRes(R.string.buzz_persona_provider), + options = PROVIDER_OPTIONS, ) - OutlinedTextField( + EditableSuggestDropdown( value = state.runtime, onValueChange = viewModel::onRuntimeChange, - label = { Text("Runtime (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + label = stringRes(R.string.buzz_persona_runtime), + options = RUNTIME_OPTIONS, ) OutlinedTextField( value = state.avatarUrl, onValueChange = viewModel::onAvatarUrlChange, - label = { Text("Avatar URL (optional)") }, + label = { Text(stringRes(R.string.buzz_persona_avatar)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -138,7 +161,7 @@ fun AgentPersonaEditScreen( enabled = state.canSave, modifier = Modifier.fillMaxWidth(), ) { - Text(if (state.isSaving) "Publishing…" else "Publish persona") + Text(if (state.isSaving) stringRes(R.string.buzz_persona_publishing) else stringRes(R.string.buzz_persona_publish)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWork.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWork.kt new file mode 100644 index 0000000000..fa7b3412a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWork.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.buzz.JobState +import com.vitorpamplona.amethyst.commons.model.buzz.JobView +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRun +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunState +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The **one** vocabulary the merged "Agent work" board speaks, folding two protocols the user should + * never have to tell apart: agent **jobs** (kinds 43001-43006, no gate) and **workflow runs** (46020 + * + the 46010 human-approval gate). Both are "ask the agent → get a PR"; the only real difference is + * whether a human must approve before it ships — so here that difference is a card **state** + * ([NEEDS_APPROVAL]), not a separate screen. + */ +enum class AgentWorkState { + /** Parked on a workflow approval gate — a named human must grant/deny before it ships. Sorts first. */ + NEEDS_APPROVAL, + + /** The agent is actively working (job accepted/in-progress, or a triggered/approved run). */ + WORKING, + + /** Filed but not picked up yet. */ + QUEUED, + + /** Terminal success — its result is a PR. */ + SHIPPED, + + /** Terminal not-success — failed, cancelled, or denied. */ + CLOSED, +} + +/** Which protocol an item came from — surfaced only as a quiet tag, never as a navigation choice. */ +enum class AgentWorkKind { + /** Ungated agent job (43001-43006): ships its PR directly. */ + JOB, + + /** Gated workflow run (46020 + 46010): pauses for a human before shipping. */ + WORKFLOW, +} + +/** One unit of agent work on the merged board, projected from either a [JobView] or a [WorkflowRun]. */ +@Immutable +data class AgentWorkItem( + val id: HexKey, + val source: AgentWorkKind, + val title: String, + val requester: HexKey?, + val state: AgentWorkState, + /** When [NEEDS_APPROVAL], the key the gate asked to decide. */ + val pendingApprover: HexKey?, + /** A shipped item's result — typically the PR URL. */ + val result: String?, + /** The most recent progress/step line, or the error/close reason. */ + val detail: String?, + /** Upvote count for jobs (the group's priority signal); null for workflow runs. */ + val upvotes: Int?, + val createdAt: Long, + val updatedAt: Long, +) + +/** Projects the two aggregators' outputs into one board, needs-approval first. */ +object AgentWorkBoard { + fun from(run: WorkflowRun): AgentWorkItem = + AgentWorkItem( + id = run.runId, + source = AgentWorkKind.WORKFLOW, + title = run.task?.takeIf { it.isNotBlank() } ?: run.workflowId?.let { "Workflow $it" } ?: "(no description)", + requester = run.requester, + state = + when (run.state) { + WorkflowRunState.AWAITING_APPROVAL -> AgentWorkState.NEEDS_APPROVAL + WorkflowRunState.RUNNING, WorkflowRunState.APPROVED -> AgentWorkState.WORKING + WorkflowRunState.TRIGGERED -> AgentWorkState.QUEUED + WorkflowRunState.COMPLETED -> AgentWorkState.SHIPPED + WorkflowRunState.FAILED, WorkflowRunState.CANCELLED, WorkflowRunState.DENIED -> AgentWorkState.CLOSED + }, + pendingApprover = run.pendingApprover, + result = run.result, + detail = run.lastStep ?: run.error, + upvotes = null, + createdAt = run.createdAt, + updatedAt = run.updatedAt, + ) + + fun from(job: JobView): AgentWorkItem = + AgentWorkItem( + id = job.jobId, + source = AgentWorkKind.JOB, + title = job.request?.takeIf { it.isNotBlank() } ?: "(no description)", + requester = job.requester, + state = + when (job.state) { + JobState.REQUESTED -> AgentWorkState.QUEUED + JobState.ACCEPTED, JobState.IN_PROGRESS -> AgentWorkState.WORKING + JobState.COMPLETED -> AgentWorkState.SHIPPED + JobState.FAILED, JobState.CANCELLED -> AgentWorkState.CLOSED + }, + pendingApprover = null, + result = job.result, + detail = job.lastProgress ?: job.error ?: job.cancelReason, + upvotes = job.upvotes, + createdAt = job.createdAt, + updatedAt = job.updatedAt, + ) + + /** + * Merge + order: needs-approval first (it blocks the room), then working, then the queue + * (highest-upvoted first), then shipped, then closed — recency breaks ties within each band. + */ + fun merge( + runs: List, + jobs: List, + ): List = + (runs.map { from(it) } + jobs.map { from(it) }) + .sortedWith( + compareBy { it.state.ordinal } + .thenByDescending { it.upvotes ?: 0 } + .thenByDescending { it.updatedAt }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardScreen.kt new file mode 100644 index 0000000000..0edd757ddc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardScreen.kt @@ -0,0 +1,581 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.launch + +/** + * The merged **Agent work** board: one channel, one list, both agent protocols (ungated jobs and + * gated workflow runs) folded into the single [AgentWorkItem] vocabulary and sorted needs-approval + * first. The human-approval gate — the thing that used to be its own screen — is now just the + * elevated card at the top; everything else (queued, working, shipped, closed) reads the same + * whether it came from a job or a workflow. + * + * Prototype note: strings are inline pending adoption; the standalone [WorkflowRunBoardScreen]'s + * confirm-before-approve dialog and full localization are the carry-over follow-ups. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentWorkBoardScreen( + channelId: String, + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val me = remember(accountViewModel) { accountViewModel.account.userProfile().pubkeyHex } + val canWrite = remember(accountViewModel) { accountViewModel.account.isWriteable() } + val viewModel: AgentWorkBoardViewModel = viewModel(key = "AgentWorkBoard-$relayUrl-$channelId") + viewModel.bind(accountViewModel.account, channelId, relayUrl) + + DisposableEffect(channelId, relayUrl) { + viewModel.startWatching() + onDispose { viewModel.stopWatching() } + } + + val itemsList by viewModel.items.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } + + var composing by remember { mutableStateOf(false) } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { TopBarWithBackButton("Agent work", nav) }, + snackbarHost = { SnackbarHost(snackbar) }, + floatingActionButton = { + if (canWrite) { + ExtendedFloatingActionButton( + onClick = { composing = true }, + icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) }, + text = { Text("New task") }, + ) + } + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + relay?.let { RelayStatusBar(it, accountViewModel) } + + Box(modifier = Modifier.fillMaxSize()) { + if (itemsList.isEmpty() && !isLoading) { + EmptyAgentWork() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 10.dp, bottom = 96.dp), + ) { + items(itemsList, key = { it.id }, contentType = { it.state }) { item -> + if (item.state == AgentWorkState.NEEDS_APPROVAL) { + GateCard(item, me, canWrite, accountViewModel, nav) { grant -> + val onResult: (Boolean) -> Unit = { ok -> + scope.launch { + snackbar.showSnackbar( + when { + ok && grant -> "Approved — the runner is opening a pull request" + ok -> "Denied — the work was discarded" + else -> "Couldn't publish your decision — check you can post here" + }, + ) + } + } + if (grant) viewModel.approve(item.id, onResult) else viewModel.deny(item.id, onResult) + } + } else { + WorkCard( + item = item, + accountViewModel = accountViewModel, + nav = nav, + onUpvote = { viewModel.upvote(item.id, item.requester) }, + onCancel = { viewModel.cancel(item.id) }, + canWrite = canWrite, + ) + } + } + } + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp), + ) + } + } + } + } + + if (composing) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + NewTaskSheet( + sheetState = sheetState, + onDismiss = { composing = false }, + onSubmit = { text, requireApproval -> + viewModel.newTask(text, requireApproval) { ok -> + if (ok) { + composing = false + scope.launch { snackbar.showSnackbar(if (requireApproval) "Task filed — it'll pause for approval" else "Task filed") } + } else { + scope.launch { snackbar.showSnackbar("Couldn't file the task — check you can post here") } + } + } + }, + ) + } +} + +/** The one elevated, glowing card: a workflow run parked on its approval gate — the room's blocker. */ +@Composable +private fun LazyItemScope.GateCard( + item: AgentWorkItem, + me: String, + canWrite: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + onDecide: (Boolean) -> Unit, +) { + val scheme = MaterialTheme.colorScheme + val glowT = rememberInfiniteTransition(label = "gate") + val glow by glowT.animateFloat( + initialValue = 0.30f, + targetValue = 0.85f, + animationSpec = infiniteRepeatable(tween(1500), RepeatMode.Reverse), + label = "gateGlow", + ) + val mine = item.pendingApprover == me + Surface( + shape = RoundedCornerShape(22.dp), + color = scheme.surface, + shadowElevation = 10.dp, + border = BorderStroke(1.5.dp, scheme.primary.copy(alpha = glow)), + modifier = Modifier.fillMaxWidth().animateItem(), + ) { + Column( + modifier = + Modifier + .background(Brush.verticalGradient(listOf(scheme.primaryContainer.copy(alpha = 0.85f), scheme.surface))) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(scheme.primary), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.Gavel, contentDescription = null, tint = scheme.onPrimary, modifier = Modifier.size(22.dp)) + } + Text( + text = if (mine) "Waiting on you" else "Awaiting approval", + style = MaterialTheme.typography.titleSmall, + color = scheme.primary, + fontWeight = FontWeight.Bold, + ) + } + TimeAgo(time = item.createdAt, style = TimeAgoStyle.Short) + } + + Text(item.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Person("by", item.requester, accountViewModel, nav) + + if (mine && canWrite) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onDecide(false) }, colors = ButtonDefaults.textButtonColors(contentColor = scheme.error)) { + Text("Deny", fontWeight = FontWeight.SemiBold) + } + Button(onClick = { onDecide(true) }, shape = RoundedCornerShape(14.dp), modifier = Modifier.weight(1f).heightIn(min = 52.dp)) { + Icon(symbol = MaterialSymbols.CheckCircle, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Approve & open PR", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + } + } + } else if (mine) { + Text("You're the approver, but this login can't sign a decision.", style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant) + } else { + WaitingPill(item.pendingApprover, accountViewModel, nav) + } + } + } +} + +/** Every other state — queued / working / shipped / closed — as a colour-railed card with its actions. */ +@Composable +private fun LazyItemScope.WorkCard( + item: AgentWorkItem, + accountViewModel: AccountViewModel, + nav: INav, + onUpvote: () -> Unit, + onCancel: () -> Unit, + canWrite: Boolean, +) { + val scheme = MaterialTheme.colorScheme + val accent = accentFor(item.state) + val container = containerFor(item.state) + val elevation = + if (item.state == AgentWorkState.WORKING) { + 4.dp + } else if (item.state == AgentWorkState.SHIPPED) { + 3.dp + } else { + 1.dp + } + Surface( + color = container, + shape = RoundedCornerShape(18.dp), + shadowElevation = elevation, + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .animateItem() + .alpha(if (item.state == AgentWorkState.CLOSED) 0.75f else 1f), + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + Box(modifier = Modifier.fillMaxHeight().width(5.dp).background(accent)) + Column(modifier = Modifier.padding(16.dp).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + StatePill(item.state, accent) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SourceTag(item.source) + TimeAgo(time = item.createdAt, style = TimeAgoStyle.Short) + } + } + + Text( + text = item.title, + style = if (item.state == AgentWorkState.WORKING) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge, + fontWeight = if (item.state == AgentWorkState.WORKING) FontWeight.SemiBold else FontWeight.Normal, + ) + Person("by", item.requester, accountViewModel, nav) + + when (item.state) { + AgentWorkState.WORKING -> { + item.detail?.takeIf { it.isNotBlank() }?.let { WorkingLine(it, accent) } + RunningBar(accent) + } + AgentWorkState.SHIPPED -> ResultLine(item.result) + AgentWorkState.CLOSED -> item.detail?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant) } + AgentWorkState.QUEUED -> Unit + AgentWorkState.NEEDS_APPROVAL -> Unit + } + + // Jobs carry upvote priority + a cancel affordance; workflow runs don't. + if (item.source == AgentWorkKind.JOB && canWrite && !item.isTerminal()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + UpvoteChip(item.upvotes ?: 0, onUpvote) + TextButton(onClick = onCancel, colors = ButtonDefaults.textButtonColors(contentColor = scheme.onSurfaceVariant)) { + Text("Cancel", style = MaterialTheme.typography.labelLarge) + } + } + } + } + } + } +} + +private fun AgentWorkItem.isTerminal() = state == AgentWorkState.SHIPPED || state == AgentWorkState.CLOSED + +@Composable +private fun UpvoteChip( + count: Int, + onClick: () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.clip(RoundedCornerShape(20.dp)).clickable(onClick = onClick), + ) { + Row(modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Icon(symbol = MaterialSymbols.ThumbUp, contentDescription = "Upvote", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp)) + Text("$count", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun RunningBar(accent: Color) { + LinearProgressIndicator( + color = accent, + trackColor = accent.copy(alpha = 0.18f), + modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)), + ) +} + +@Composable +private fun WorkingLine( + text: String, + accent: Color, +) { + val pulse = rememberInfiniteTransition(label = "working") + val a = + pulse.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "workingAlpha", + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.graphicsLayer { alpha = a.value }) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(accent)) + Text(text, style = MaterialTheme.typography.bodyMedium, color = accent, fontWeight = FontWeight.Medium) + } +} + +private val URL_REGEX = Regex("https?://\\S+") + +@Composable +private fun ResultLine(result: String?) { + val url = remember(result) { result?.let { URL_REGEX.find(it)?.value } } + if (url != null) { + val uriHandler = LocalUriHandler.current + Button(onClick = { uriHandler.openUri(url) }, shape = RoundedCornerShape(12.dp)) { + Icon(symbol = MaterialSymbols.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("View PR") + } + } else if (!result.isNullOrBlank()) { + Text(result, style = MaterialTheme.typography.bodyMedium) + } +} + +/** A quiet, secondary marker of which protocol an item came from — transparent, never a nav choice. */ +@Composable +private fun SourceTag(source: AgentWorkKind) { + val (label, symbol) = if (source == AgentWorkKind.WORKFLOW) "gated" to MaterialSymbols.Lock else "direct" to MaterialSymbols.LockOpen + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + Icon(symbol = symbol, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(12.dp)) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun Person( + prefix: String, + hex: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (hex == null) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Text(prefix, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + UserPicture(userHex = hex, size = Size20dp, accountViewModel = accountViewModel, nav = nav) + LoadUser(baseUserHex = hex, accountViewModel = accountViewModel) { user -> + if (user != null) UsernameDisplay(baseUser = user, fontWeight = FontWeight.Medium, accountViewModel = accountViewModel) + } + } +} + +@Composable +private fun WaitingPill( + approver: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(20.dp)) { + Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(symbol = MaterialSymbols.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + if (approver != null) Person("waiting on", approver, accountViewModel, nav) else Text("Waiting for approval", style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +private fun StatePill( + state: AgentWorkState, + accent: Color, +) { + val (label, symbol) = pillContent(state) + Surface(color = accent.copy(alpha = 0.14f), shape = RoundedCornerShape(20.dp)) { + Row(modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(symbol = symbol, contentDescription = null, tint = accent, modifier = Modifier.size(15.dp)) + Text(text = label, style = MaterialTheme.typography.labelMedium, color = accent, fontWeight = FontWeight.Bold) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NewTaskSheet( + sheetState: SheetState, + onDismiss: () -> Unit, + onSubmit: (String, Boolean) -> Unit, +) { + var task by remember { mutableStateOf("") } + var requireApproval by remember { mutableStateOf(true) } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("New task", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + "Describe what the agent should do. The whole channel sees it and watches it work.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = task, + onValueChange = { task = it }, + label = { Text("What should it do?") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Require approval before it ships", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text( + if (requireApproval) "A human grants it before the PR opens (a workflow run)." else "Ships its PR directly, no gate (a job).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = requireApproval, onCheckedChange = { requireApproval = it }) + } + } + Button(onClick = { onSubmit(task.trim(), requireApproval) }, enabled = task.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Icon(symbol = if (requireApproval) MaterialSymbols.Gavel else MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (requireApproval) "File — with approval gate" else "File — ship directly") + } + } + } +} + +@Composable +private fun EmptyAgentWork() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Icon(symbol = MaterialSymbols.Checklist, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp)) + Text("No agent work yet", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Tap “New task” to ask the agent to build something. Choose whether it ships directly or pauses for a human to approve — either way the whole channel follows along.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun accentFor(state: AgentWorkState): Color = + when (state) { + AgentWorkState.NEEDS_APPROVAL -> MaterialTheme.colorScheme.primary + AgentWorkState.WORKING -> MaterialTheme.colorScheme.secondary + AgentWorkState.QUEUED -> MaterialTheme.colorScheme.tertiary + AgentWorkState.SHIPPED -> MaterialTheme.colorScheme.tertiary + AgentWorkState.CLOSED -> MaterialTheme.colorScheme.outline + } + +@Composable +private fun containerFor(state: AgentWorkState): Color = + when (state) { + AgentWorkState.NEEDS_APPROVAL -> MaterialTheme.colorScheme.primaryContainer + AgentWorkState.WORKING -> MaterialTheme.colorScheme.secondaryContainer + AgentWorkState.QUEUED -> MaterialTheme.colorScheme.surfaceContainerLow + AgentWorkState.SHIPPED -> MaterialTheme.colorScheme.tertiaryContainer + AgentWorkState.CLOSED -> MaterialTheme.colorScheme.surfaceContainerLow + } + +private fun pillContent(state: AgentWorkState): Pair = + when (state) { + AgentWorkState.NEEDS_APPROVAL -> "Needs approval" to MaterialSymbols.Gavel + AgentWorkState.WORKING -> "Working" to MaterialSymbols.Bolt + AgentWorkState.QUEUED -> "Queued" to MaterialSymbols.Schedule + AgentWorkState.SHIPPED -> "Shipped" to MaterialSymbols.CheckCircle + AgentWorkState.CLOSED -> "Closed" to MaterialSymbols.Cancel + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt new file mode 100644 index 0000000000..1aa34aaccf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +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.WorkflowStepStartedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Backing ViewModel for the merged **Agent work** board — one channel, both agent protocols folded + * into one [AgentWorkItem] list. It runs the two board subscriptions the split screens used to run + * separately and merges their output: + * + * - **Jobs** (43001-43006 + kind-7 upvotes), folded by [BuzzJobAggregator]. + * - **Workflow runs** (46020 + lifecycle + the 46010 gate, plus 30620 defs) folded by + * [WorkflowRunAggregator]; the client-signed 46030/46031 decisions ride a by-author sub (rebuilt + * only when the approver set changes), so grants/denies land live. + * + * All read straight off [subscribeAsFlow] (not `LocalCache`, which can't serve these ≥10 000 kinds). + * Writes reuse the same `Account` extensions the split boards used. "New task" chooses the protocol + * from a single toggle: **require approval → a gated workflow run; otherwise → a direct job.** + */ +class AgentWorkBoardViewModel : ViewModel() { + @Volatile private var account: Account? = null + private var relay: NormalizedRelayUrl? = null + private var channelId: String? = null + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private var watchJob: Job? = null + + fun bind( + account: Account, + channelId: String, + relayUrl: String, + ) { + if (this.account != null) return + this.account = account + this.channelId = channelId + this.relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun startWatching() { + val account = account ?: return + val relay = relay ?: return + val channelId = channelId ?: return + if (watchJob != null) return + + watchJob = + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + val loadingTimeout = + launch { + delay(LOADING_TIMEOUT_MS) + _isLoading.value = false + } + + // Jobs (43xxx) + their upvotes, one #h subscription. + val jobsFlow = + account.client + .subscribeAsFlow(relay, listOf(Filter(kinds = JOB_KINDS + ReactionEvent.KIND, tags = mapOf("h" to listOf(channelId))))) + .onStart { emit(emptyList()) } + .map { BuzzJobAggregator.aggregate(it) } + + // Workflow base (#h) + a by-author decisions sub rebuilt only when the approver set changes. + val wfBaseFlow = + account.client + .subscribeAsFlow(relay, listOf(Filter(kinds = WORKFLOW_H_KINDS + WorkflowDefEvent.KIND, tags = mapOf("h" to listOf(channelId))))) + .onStart { emit(emptyList()) } + val wfDecisionFlow = + wfBaseFlow + .map { base -> + base + .filterIsInstance() + .mapNotNull { it.approver() } + .distinct() + .sorted() + }.distinctUntilChanged() + .flatMapLatest { approvers -> + if (approvers.isEmpty()) { + flowOf(emptyList()) + } else { + account.client.subscribeAsFlow(relay, listOf(Filter(kinds = DECISION_KINDS, authors = approvers))).onStart { emit(emptyList()) } + } + } + val wfFlow = combine(wfBaseFlow, wfDecisionFlow) { base, decisions -> WorkflowRunAggregator.aggregate(base + decisions) } + + combine(jobsFlow, wfFlow) { jobs, runs -> AgentWorkBoard.merge(runs, jobs) } + .collect { merged -> + if (merged.isNotEmpty()) { + loadingTimeout.cancel() + _isLoading.value = false + } + _items.value = merged + } + } + } + + fun stopWatching() { + watchJob?.cancel() + watchJob = null + } + + /** + * File a new piece of agent work. [requireApproval] is the whole gate/no-gate choice: on → a + * workflow run that pauses for a human (an ad-hoc workflow id, since self-hosted the id is just a + * label); off → a direct job that ships its PR without a gate. + */ + fun newTask( + text: String, + requireApproval: Boolean, + onResult: (Boolean) -> Unit, + ) = act(onResult) { account, relay, channelId -> + if (requireApproval) { + account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null + } else { + account.fileBuzzJob(relay, channelId, text) != null + } + } + + fun approve( + runId: HexKey, + onResult: (Boolean) -> Unit, + ) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null } + + fun deny( + runId: HexKey, + onResult: (Boolean) -> Unit, + ) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null } + + fun upvote( + jobId: HexKey, + jobAuthor: HexKey?, + ) = act({}) { account, relay, channelId -> + account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + true + } + + fun cancel(jobId: HexKey) = + act({}) { account, relay, channelId -> + account.cancelBuzzJob(relay, channelId, jobId) + true + } + + private inline fun act( + crossinline onResult: (Boolean) -> Unit, + crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Boolean, + ) { + val account = account + val relay = relay + val channelId = channelId + if (account == null || relay == null || channelId == null) { + onResult(false) + return + } + viewModelScope.launch(Dispatchers.IO) { + val ok = block(account, relay, channelId) + withContext(Dispatchers.Main) { onResult(ok) } + } + } + + override fun onCleared() { + stopWatching() + super.onCleared() + } + + companion object { + private const val LOADING_TIMEOUT_MS = 6_000L + private const val ADHOC_WORKFLOW_ID = "adhoc" + + private val JOB_KINDS = (43001..43006).toList() + + // Same set the standalone workflow board folds: trigger + foldable lifecycle + the gate. + private val WORKFLOW_H_KINDS = + listOf( + WorkflowTriggerEvent.KIND, + WorkflowTriggeredEvent.KIND, + WorkflowStepStartedEvent.KIND, + WorkflowStepCompletedEvent.KIND, + WorkflowCompletedEvent.KIND, + WorkflowFailedEvent.KIND, + WorkflowCancelledEvent.KIND, + WorkflowApprovalRequestedEvent.KIND, + ) + private val DECISION_KINDS = listOf(ApprovalGrantEvent.KIND, ApprovalDenyEvent.KIND) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzOptionDropdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzOptionDropdown.kt new file mode 100644 index 0000000000..4d39c69d4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzOptionDropdown.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +/** One suggested value for an [EditableSuggestDropdown] — its stored [value] and the [label] shown. */ +data class DropdownOption( + val value: String, + val label: String = value, +) { + fun matches(query: String): Boolean = value.contains(query, ignoreCase = true) || label.contains(query, ignoreCase = true) +} + +/** + * An editable text field that also offers a dropdown of known [options] — pick a suggestion or type + * any value (free entry is preserved). The menu narrows to options matching the current text. This + * is the "code → picker, but still open-ended" pattern for fields whose value set is well-known but + * not closed (model / provider / runtime names, common event kinds). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditableSuggestDropdown( + value: String, + onValueChange: (String) -> Unit, + label: String, + options: List, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + supportingText: String? = null, +) { + var expanded by remember { mutableStateOf(false) } + val filtered = remember(value, options) { if (value.isBlank()) options else options.filter { it.matches(value) } } + val showMenu = expanded && filtered.isNotEmpty() + + ExposedDropdownMenuBox(expanded = showMenu, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = value, + onValueChange = { + onValueChange(it) + expanded = true + }, + label = { Text(label) }, + singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showMenu) }, + keyboardOptions = keyboardOptions, + supportingText = supportingText?.let { { Text(it) } }, + modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable), + ) + ExposedDropdownMenu(expanded = showMenu, onDismissRequest = { expanded = false }) { + filtered.forEach { opt -> + DropdownMenuItem( + text = { Text(opt.label) }, + onClick = { + onValueChange(opt.value) + expanded = false + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt new file mode 100644 index 0000000000..9d0d7bbe65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt @@ -0,0 +1,511 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.JobState +import com.vitorpamplona.amethyst.commons.model.buzz.JobView +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * The shared **backlog** of one Buzz channel — where a team drives an AI agent together. + * + * Every member sees the same board: file a task (kind-43001), upvote to reprioritize (kind-7), + * and watch the workspace bot work items back through accept → progress → result/error + * (43002-43006). A live [RelayStatusBar] pins the relay's health up top; jobs are grouped by + * lifecycle with the **active work** as the visual hero, the queue ordered by the group's + * upvotes. Correlation/state/priority is the shared + * [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]; this screen renders it and + * routes file/upvote/cancel through [JobBoardViewModel]. + * + * Merge is deliberately NOT here — a shipped job's result is its PR; the merge happens on GitHub. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JobBoardScreen( + channelId: String, + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val me = accountViewModel.account.userProfile().pubkeyHex + val viewModel: JobBoardViewModel = viewModel(key = "JobBoard-$relayUrl-$channelId") + viewModel.bind(accountViewModel.account, channelId, relayUrl) + + DisposableEffect(channelId, relayUrl) { + viewModel.startWatching() + onDispose { viewModel.stopWatching() } + } + + val jobs by viewModel.jobs.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } + + var composing by remember { mutableStateOf(false) } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.buzz_job_board_title), nav) }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { composing = true }, + icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) }, + text = { Text("New task") }, + ) + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + relay?.let { RelayStatusBar(it, accountViewModel) } + + Box(modifier = Modifier.fillMaxSize()) { + // Bucketed once per new backlog, not on every recomposition (e.g. isLoading toggles). + val groups = remember(jobs) { JobGroups.from(jobs) } + + if (jobs.isEmpty() && !isLoading) { + EmptyBoard() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), + ) { + section("Working now", groups.running, JobStyle.HERO, me, accountViewModel, nav, viewModel) + section("Up next", groups.queued, JobStyle.QUEUED, me, accountViewModel, nav, viewModel) + section("Shipped", groups.done, JobStyle.SHIPPED, me, accountViewModel, nav, viewModel) + section("Closed", groups.closed, JobStyle.CLOSED, me, accountViewModel, nav, viewModel) + } + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp), + ) + } + } + } + } + + if (composing) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + NewTaskSheet( + sheetState = sheetState, + onDismiss = { composing = false }, + onFile = { text -> + viewModel.file(text) + composing = false + }, + ) + } +} + +private enum class JobStyle { HERO, QUEUED, SHIPPED, CLOSED } + +/** The backlog bucketed by lifecycle; the queue is ordered by the group's upvotes. */ +private class JobGroups( + val running: List, + val queued: List, + val done: List, + val closed: List, +) { + companion object { + fun from(jobs: List) = + JobGroups( + running = jobs.filter { it.state == JobState.IN_PROGRESS || it.state == JobState.ACCEPTED }, + queued = + jobs + .filter { it.state == JobState.REQUESTED } + .sortedWith(compareByDescending { it.upvotes }.thenBy { it.createdAt }), + done = jobs.filter { it.state == JobState.COMPLETED }, + closed = jobs.filter { it.state == JobState.FAILED || it.state == JobState.CANCELLED }, + ) + } +} + +private fun LazyListScope.section( + title: String, + jobs: List, + style: JobStyle, + me: String, + accountViewModel: AccountViewModel, + nav: INav, + viewModel: JobBoardViewModel, +) { + if (jobs.isEmpty()) return + item(key = "header-$title") { + val accent = if (style == JobStyle.HERO) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + ) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + ) + Surface(color = accent.copy(alpha = 0.14f), shape = CircleShape) { + Text( + text = jobs.size.toString(), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp), + ) + } + } + } + items(jobs, key = { it.jobId }) { job -> + JobCard(job, style, me, accountViewModel, nav, viewModel) + } +} + +@Composable +private fun LazyItemScope.JobCard( + job: JobView, + style: JobStyle, + me: String, + accountViewModel: AccountViewModel, + nav: INav, + viewModel: JobBoardViewModel, +) { + val (container, accent) = styleColors(style) + val elevation = + if (style == JobStyle.HERO) { + 4.dp + } else if (style == JobStyle.SHIPPED) { + 3.dp + } else { + 1.dp + } + Surface( + color = container, + shape = RoundedCornerShape(18.dp), + shadowElevation = elevation, + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .animateItem() + .alpha(if (style == JobStyle.CLOSED) 0.7f else 1f), + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + // Left status rail — a quick-scan colour strip. + Box(modifier = Modifier.fillMaxHeight().width(4.dp).background(accent)) + + Column( + modifier = Modifier.padding(14.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + StatePill(job.state, accent) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TimeAgo(time = job.createdAt, style = TimeAgoStyle.Short) + UpvoteChip(job.upvotes) { viewModel.upvote(job.jobId, job.requester) } + } + } + + Text( + text = job.request?.takeIf { it.isNotBlank() } ?: "(no description)", + style = if (style == JobStyle.HERO) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge, + fontWeight = if (style == JobStyle.HERO) FontWeight.SemiBold else FontWeight.Normal, + ) + + // Who's involved — real avatars + names, not hex. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Person("by", job.requester, accountViewModel, nav) + if (job.agent != null && job.agent != job.requester) Person("agent", job.agent, accountViewModel, nav) + } + + when (style) { + JobStyle.HERO -> { + job.lastProgress?.takeIf { it.isNotBlank() }?.let { WorkingLine(it, accent) } + if (job.state == JobState.IN_PROGRESS) { + LinearProgressIndicator( + color = accent, + trackColor = accent.copy(alpha = 0.18f), + modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)), + ) + } + } + JobStyle.SHIPPED -> + job.result?.takeIf { it.isNotBlank() }?.let { ResultLine(it) } + JobStyle.CLOSED -> + (job.error ?: job.cancelReason?.let { "Cancelled: $it" })?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = if (job.state == JobState.FAILED) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) + } + JobStyle.QUEUED -> Unit + } + + if (!job.isTerminal && job.requester == me) { + TextButton(onClick = { viewModel.cancel(job.jobId) }, modifier = Modifier.align(Alignment.End)) { + Text("Cancel") + } + } + } + } + } +} + +/** The active-work pulse: the streaming progress line breathes while the agent runs. */ +@Composable +private fun WorkingLine( + text: String, + accent: Color, +) { + val pulse = rememberInfiniteTransition(label = "working") + val a by pulse.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "workingAlpha", + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.alpha(a)) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(accent)) + Text(text, style = MaterialTheme.typography.bodyMedium, color = accent, fontWeight = FontWeight.Medium) + } +} + +/** A shipped result — if it carries a URL, offer a prominent "View PR" instead of raw text. */ +@Composable +private fun ResultLine(result: String) { + val url = remember(result) { Regex("https?://\\S+").find(result)?.value } + if (url != null) { + val uriHandler = LocalUriHandler.current + Button(onClick = { uriHandler.openUri(url) }) { + Icon(symbol = MaterialSymbols.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("View PR") + } + } else { + Text(result, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun Person( + prefix: String, + hex: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (hex == null) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Text(prefix, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + UserPicture(userHex = hex, size = Size20dp, accountViewModel = accountViewModel, nav = nav) + LoadUser(baseUserHex = hex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay(baseUser = user, fontWeight = FontWeight.Medium, accountViewModel = accountViewModel) + } + } + } +} + +@Composable +private fun StatePill( + state: JobState, + accent: Color, +) { + val (label, symbol) = pillContent(state) + Surface(color = accent.copy(alpha = 0.14f), shape = RoundedCornerShape(20.dp)) { + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(symbol = symbol, contentDescription = null, tint = accent, modifier = Modifier.size(15.dp)) + Text(text = label, style = MaterialTheme.typography.labelMedium, color = accent, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun UpvoteChip( + count: Int, + onClick: () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp).animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(symbol = MaterialSymbols.ThumbUp, contentDescription = "Upvote", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Text(text = count.toString(), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NewTaskSheet( + sheetState: SheetState, + onDismiss: () -> Unit, + onFile: (String) -> Unit, +) { + var text by remember { mutableStateOf("") } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Ask the workspace agent", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + "Describe a feature or fix. The whole channel sees it, and the agent opens a PR.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("Fix a bug", "Add a setting", "Improve a screen").forEach { example -> + SuggestionChip(onClick = { if (text.isBlank()) text = "$example: " }, label = { Text(example) }) + } + } + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("What should we build?") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + Button( + onClick = { onFile(text.trim()) }, + enabled = text.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("File task") + } + } + } +} + +@Composable +private fun EmptyBoard() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Icon(symbol = MaterialSymbols.Checklist, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp)) + Text("No tasks yet", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Tap “New task” to ask the workspace agent to build or fix something. The whole channel will see it, upvote it, and watch it ship.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun styleColors(style: JobStyle): Pair = + when (style) { + JobStyle.HERO -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.primary + JobStyle.QUEUED -> MaterialTheme.colorScheme.surfaceContainer to MaterialTheme.colorScheme.onSurfaceVariant + JobStyle.SHIPPED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.tertiary + JobStyle.CLOSED -> MaterialTheme.colorScheme.surfaceContainerLow to MaterialTheme.colorScheme.outline + } + +private fun pillContent(state: JobState): Pair = + when (state) { + JobState.REQUESTED -> "Queued" to MaterialSymbols.Schedule + JobState.ACCEPTED -> "Picked up" to MaterialSymbols.Bolt + JobState.IN_PROGRESS -> "Working" to MaterialSymbols.Bolt + JobState.COMPLETED -> "Shipped" to MaterialSymbols.CheckCircle + JobState.FAILED -> "Failed" to MaterialSymbols.Error + JobState.CANCELLED -> "Cancelled" to MaterialSymbols.Cancel + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt new file mode 100644 index 0000000000..d634500dd6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator +import com.vitorpamplona.amethyst.commons.model.buzz.JobView +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +/** + * Backing ViewModel for the [JobBoardScreen] — the shared backlog of one Buzz channel. + * + * A Buzz "job" is the agent-job protocol (kinds 43001-43006): a member files a request, the + * workspace bot works it, and every step lands as a signed event the whole room sees. This VM + * folds those events (plus their kind-7 upvotes) into per-job [JobView] records via the shared + * [BuzzJobAggregator] and exposes the backlog as a [StateFlow]. It also drives the three write + * actions the board offers: file, upvote, cancel. + * + * **Aggregated from the live subscription, not `LocalCache`:** the job kinds 43001-43006 are neither + * regular (`< 10_000`) nor addressable, so `LocalCache.filter` can't serve them (its note branch only + * matches `kind.isRegular()`) — reading them back from cache returned nothing and the board stayed + * empty. We consume the events straight off [subscribeAsFlow], which accumulates the channel's stored + * + live events (deduped) and re-emits the list. The kind-7 upvotes ride the same `#h` subscription. + */ +class JobBoardViewModel : ViewModel() { + @Volatile private var account: Account? = null + private var relay: NormalizedRelayUrl? = null + private var channelId: String? = null + + private val _jobs = MutableStateFlow>(emptyList()) + val jobs: StateFlow> = _jobs.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private var watchJob: Job? = null + + fun bind( + account: Account, + channelId: String, + relayUrl: String, + ) { + if (this.account != null) return + this.account = account + this.channelId = channelId + this.relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) + } + + /** Keep the board live while it's on screen: the subscription backfills then streams job/reaction events. */ + fun startWatching() { + val account = account ?: return + val relay = relay ?: return + val channelId = channelId ?: return + if (watchJob != null) return + watchJob = + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + val loadingTimeout = + launch { + delay(LOADING_TIMEOUT_MS) + _isLoading.value = false + } + account.client + .subscribeAsFlow(relay, boardFilters(channelId)) + .onStart { emit(emptyList()) } + .collect { events -> + if (events.isNotEmpty()) { + loadingTimeout.cancel() + _isLoading.value = false + } + _jobs.value = BuzzJobAggregator.aggregate(events) + } + } + } + + fun stopWatching() { + watchJob?.cancel() + watchJob = null + } + + fun file(request: String) = + act { account, relay, channelId -> + account.fileBuzzJob(relay, channelId, request) + } + + fun upvote( + jobId: String, + jobAuthor: String?, + ) = act { account, relay, channelId -> + account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + } + + fun cancel(jobId: String) = + act { account, relay, channelId -> + account.cancelBuzzJob(relay, channelId, jobId) + } + + private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) { + val account = account ?: return + val relay = relay ?: return + val channelId = channelId ?: return + // The live subscription picks up the relay's echo of our own write, so no local re-derive here. + viewModelScope.launch(Dispatchers.IO) { block(account, relay, channelId) } + } + + override fun onCleared() { + stopWatching() + super.onCleared() + } + + companion object { + private const val LOADING_TIMEOUT_MS = 6_000L + + // The Buzz agent-job protocol (request/accepted/progress/result/cancel/error) plus the + // kind-7 upvotes that prioritize it. + private val JOB_KINDS = (43001..43006).toList() + + private fun boardFilters(channelId: String) = + listOf( + Filter(kinds = JOB_KINDS, tags = mapOf("h" to listOf(channelId))), + Filter(kinds = listOf(ReactionEvent.KIND), tags = mapOf("h" to listOf(channelId))), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/RelayStatusBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/RelayStatusBar.kt new file mode 100644 index 0000000000..a18b89e82c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/RelayStatusBar.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.warningColor +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthSnapshot +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * A compact, tappable header that makes **all** of one relay's live state observable in context: + * connection (from `client.connectedRelaysFlow`), NIP-42 auth phase (from the auth coordinator), + * the Buzz-dialect marker, and — expanded — the NIP-11 document (software, supported NIPs, + * limitations, description) plus latency. Every signal here is a real `StateFlow`/`produceState`, + * so the bar reflects reality as it changes rather than a one-shot snapshot. + * + * Reusable across the agent surfaces; the Jobs board pins it above the backlog. + */ +@Composable +fun RelayStatusBar( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +) { + // These are global StateFlows (a change on ANY relay re-emits). Derive this relay's booleans + // so the bar only recomposes when THIS relay's connection/auth/dialect actually changes. + val connectedState = + accountViewModel.account.client + .connectedRelaysFlow() + .collectAsStateWithLifecycle() + val authState = + Amethyst.instance.authCoordinator.receiver.authStateFlow + .collectAsStateWithLifecycle() + val buzzState = BuzzRelayDialect.flow.collectAsStateWithLifecycle() + val info by loadRelayInfo(relay) + + val isConnected by remember(relay) { derivedStateOf { relay in connectedState.value } } + val phase by remember(relay) { derivedStateOf { authState.value[relay]?.phase ?: RelayAuthSnapshot.Phase.IDLE } } + val isBuzz by remember(relay) { derivedStateOf { relay in buzzState.value } } + val stat = remember(relay, isConnected) { Amethyst.instance.relayStats.get(relay) } + + val (dotColor, statusLabel) = health(isConnected, phase) + var expanded by remember { mutableStateOf(false) } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.clickable { expanded = !expanded }.padding(horizontal = 14.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LiveDot(dotColor, pulsing = phase == RelayAuthSnapshot.Phase.AUTHENTICATING || (isConnected && phase != RelayAuthSnapshot.Phase.AUTH_FAILED)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = info.name?.takeIf { it.isNotBlank() } ?: relay.url, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.bodySmall, + color = dotColor, + ) + } + if (isBuzz) Chip("Buzz", MaterialSymbols.Bolt) + Icon( + symbol = if (phase == RelayAuthSnapshot.Phase.AUTHENTICATED) MaterialSymbols.Lock else MaterialSymbols.LockOpen, + contentDescription = if (phase == RelayAuthSnapshot.Phase.AUTHENTICATED) "Authenticated" else "Not authenticated", + tint = if (phase == RelayAuthSnapshot.Phase.AUTHENTICATED) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand relay details", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + AnimatedVisibility(visible = expanded) { + Column(modifier = Modifier.padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + InfoLine("Host", relay.url) + InfoLine("Connection", if (isConnected) "Connected" else "Disconnected") + InfoLine("Auth (NIP-42)", phase.name.lowercase().replace('_', ' ')) + InfoLine("Dialect", if (isBuzz) "Buzz workspace" else "vanilla NIP-29") + if (stat.pingInMs > 0) InfoLine("Latency", "${stat.pingInMs} ms") + info.software?.takeIf { it.isNotBlank() }?.let { InfoLine("Software", it + (info.version?.let { v -> " $v" } ?: "")) } + info.supported_nips?.takeIf { it.isNotEmpty() }?.let { InfoLine("Supported NIPs", it.joinToString(", ")) } + info.limitation?.let { lim -> + val flags = + buildList { + if (lim.auth_required == true) add("auth required") + if (lim.payment_required == true) add("payment required") + if (lim.restricted_writes == true) add("restricted writes") + } + if (flags.isNotEmpty()) InfoLine("Limitations", flags.joinToString(", ")) + } + info.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } +} + +@Composable +private fun LiveDot( + color: Color, + pulsing: Boolean, +) { + val alpha = + if (pulsing) { + val t = rememberInfiniteTransition(label = "relayDot") + t + .animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), + label = "relayDotAlpha", + ).value + } else { + 1f + } + Box( + modifier = + Modifier + .size(10.dp) + .alpha(alpha) + .clip(CircleShape) + .background(color), + ) +} + +@Composable +private fun Chip( + label: String, + symbol: MaterialSymbol, +) { + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp)) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon(symbol = symbol, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(13.dp)) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun InfoLine( + label: String, + value: String, +) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.4f)) + Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.weight(0.6f)) + } +} + +@Composable +private fun health( + isConnected: Boolean, + phase: RelayAuthSnapshot.Phase, +): Pair = + when { + !isConnected -> MaterialTheme.colorScheme.error to "Disconnected" + phase == RelayAuthSnapshot.Phase.AUTH_FAILED -> MaterialTheme.colorScheme.error to "Auth failed" + phase == RelayAuthSnapshot.Phase.AUTHENTICATING -> MaterialTheme.colorScheme.warningColor to "Authenticating…" + phase == RelayAuthSnapshot.Phase.AUTHENTICATED -> MaterialTheme.colorScheme.allGoodColor to "Connected · authenticated" + else -> MaterialTheme.colorScheme.allGoodColor to "Connected" + } 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 new file mode 100644 index 0000000000..1525e7dba2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardScreen.kt @@ -0,0 +1,951 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +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 +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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 +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRun +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunState +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.launch + +/** + * The shared **workflow run board** of one Buzz channel — where a team drives an AI agent under a + * human-approval gate. + * + * Every member sees the same runs: trigger a workflow (kind-46020), watch the runner work it through + * triggered → step → the **approval gate** (46010), and — if the run named you as approver — grant or + * deny it right here (46030/46031). A granted run ships and lands as a shipped result (its PR); + * merge stays on GitHub. Runs awaiting a human are the visual hero — an elevated, glowing card sorted + * to the top — because they're the ones blocking. Correlation/state is the shared + * [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator]; this screen renders it and + * routes trigger/approve/deny through [WorkflowRunBoardViewModel]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkflowRunBoardScreen( + channelId: String, + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val me = remember(accountViewModel) { accountViewModel.account.userProfile().pubkeyHex } + val canWrite = remember(accountViewModel) { accountViewModel.account.isWriteable() } + val viewModel: WorkflowRunBoardViewModel = viewModel(key = "WorkflowRunBoard-$relayUrl-$channelId") + viewModel.bind(accountViewModel.account, channelId, relayUrl) + + DisposableEffect(channelId, relayUrl) { + viewModel.startWatching() + onDispose { viewModel.stopWatching() } + } + + 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) } + + var composing by remember { mutableStateOf(false) } + var pending by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + // Resolve snackbar copy here (composable scope) so the coroutine lambdas below don't call stringRes. + val msgTriggered = stringRes(R.string.buzz_workflow_triggered_toast) + val msgTriggerFailed = stringRes(R.string.buzz_workflow_trigger_failed_toast) + val msgApproved = stringRes(R.string.buzz_workflow_approved_toast) + val msgDenied = stringRes(R.string.buzz_workflow_denied_toast) + val msgDecisionFailed = stringRes(R.string.buzz_workflow_decision_failed_toast) + + // Section titles resolved here too — `section()` runs in LazyListScope, not a composable scope. + val titleAwaiting = stringRes(R.string.buzz_workflow_section_awaiting) + val titleActive = stringRes(R.string.buzz_workflow_section_active) + val titleShipped = stringRes(R.string.buzz_workflow_section_shipped) + val titleClosed = stringRes(R.string.buzz_workflow_section_closed) + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.buzz_workflow_runs_title), nav) }, + snackbarHost = { SnackbarHost(snackbar) }, + floatingActionButton = { + if (canWrite) { + ExtendedFloatingActionButton( + onClick = { composing = true }, + icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) }, + text = { Text(stringRes(R.string.buzz_workflow_new_run)) }, + ) + } + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + relay?.let { RelayStatusBar(it, accountViewModel) } + + Box(modifier = Modifier.fillMaxSize()) { + val groups = remember(runs) { RunGroups.from(runs) } + + if (runs.isEmpty() && !isLoading) { + EmptyRunBoard() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 10.dp, bottom = 96.dp), + ) { + section(titleAwaiting, groups.awaiting, RunStyle.GATE, me, canWrite, accountViewModel, nav) { run, grant -> + pending = PendingDecision(run, grant) + } + section(titleActive, groups.active, RunStyle.ACTIVE, me, canWrite, accountViewModel, nav) + section(titleShipped, groups.done, RunStyle.SHIPPED, me, canWrite, accountViewModel, nav) + section(titleClosed, groups.closed, RunStyle.CLOSED, me, canWrite, accountViewModel, nav) + } + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp), + ) + } + } + } + } + + if (composing) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + NewRunSheet( + sheetState = sheetState, + definitions = definitions, + onDismiss = { composing = false }, + onDefine = { name, yaml, onResult -> viewModel.defineWorkflow(name, yaml, onResult) }, + onTrigger = { workflowId, task -> + // Only close the sheet on a confirmed publish; on failure keep it open (task text intact) + // and tell the user, instead of silently swallowing a read-only / rejected write. + viewModel.trigger(workflowId, task) { ok -> + if (ok) { + composing = false + scope.launch { snackbar.showSnackbar(msgTriggered) } + } else { + scope.launch { snackbar.showSnackbar(msgTriggerFailed) } + } + } + }, + ) + } + + // Approving pushes code and opens a PR; denying discards it — both consequential enough to confirm. + pending?.let { decision -> + ConfirmDecisionDialog( + decision = decision, + onDismiss = { pending = null }, + onConfirm = { + val grant = decision.grant + val onResult: (Boolean) -> Unit = { ok -> + scope.launch { + snackbar.showSnackbar( + when { + ok && grant -> msgApproved + ok -> msgDenied + else -> msgDecisionFailed + }, + ) + } + } + if (grant) viewModel.approve(decision.run.runId, onResult) else viewModel.deny(decision.run.runId, onResult) + pending = null + }, + ) + } +} + +private enum class RunStyle { GATE, ACTIVE, SHIPPED, CLOSED } + +/** A grant/deny the approver has tapped but not yet confirmed. */ +private data class PendingDecision( + val run: WorkflowRun, + val grant: Boolean, +) + +/** The runs bucketed by lifecycle; awaiting-a-human first (those block the room). */ +private class RunGroups( + val awaiting: List, + val active: List, + val done: List, + val closed: List, +) { + companion object { + fun from(runs: List) = + RunGroups( + awaiting = runs.filter { it.state == WorkflowRunState.AWAITING_APPROVAL }, + active = + runs.filter { + it.state == WorkflowRunState.TRIGGERED || + it.state == WorkflowRunState.RUNNING || + it.state == WorkflowRunState.APPROVED + }, + done = runs.filter { it.state == WorkflowRunState.COMPLETED }, + closed = + runs.filter { + it.state == WorkflowRunState.FAILED || + it.state == WorkflowRunState.CANCELLED || + it.state == WorkflowRunState.DENIED + }, + ) + } +} + +private fun LazyListScope.section( + title: String, + runs: List, + style: RunStyle, + me: String, + canWrite: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + onDecide: (WorkflowRun, Boolean) -> Unit = { _, _ -> }, +) { + if (runs.isEmpty()) return + item(key = "header-$title", contentType = "header") { + SectionHeader(title, runs.size, style) + } + items(runs, key = { it.runId }, contentType = { style }) { run -> + if (style == RunStyle.GATE) { + GateCard(run, me, canWrite, accountViewModel, nav, onDecide) + } else { + RunCard(run, style, accountViewModel, nav) + } + } +} + +@Composable +private fun SectionHeader( + title: String, + count: Int, + style: RunStyle, +) { + val accent = if (style == RunStyle.GATE) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + ) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + ) + Surface(color = accent.copy(alpha = 0.14f), shape = CircleShape) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp), + ) + } + } +} + +/** + * The centerpiece: a run paused on its approval gate. Elevated and softly glowing so the one thing + * that needs a human reads as the hero of the board. If I'm the named approver it carries the + * grant/deny actions; otherwise it shows who the room is waiting on. + */ +@Composable +private fun LazyItemScope.GateCard( + run: WorkflowRun, + me: String, + canWrite: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + onDecide: (WorkflowRun, Boolean) -> Unit, +) { + val scheme = MaterialTheme.colorScheme + val glowT = rememberInfiniteTransition(label = "gate") + val glow by glowT.animateFloat( + initialValue = 0.30f, + targetValue = 0.85f, + animationSpec = infiniteRepeatable(tween(1500), RepeatMode.Reverse), + label = "gateGlow", + ) + val mine = run.pendingApprover == me + Surface( + shape = RoundedCornerShape(22.dp), + color = scheme.surface, + shadowElevation = 10.dp, + border = BorderStroke(1.5.dp, scheme.primary.copy(alpha = glow)), + modifier = Modifier.fillMaxWidth().animateItem(), + ) { + Column( + modifier = + Modifier + .background(Brush.verticalGradient(listOf(scheme.primaryContainer.copy(alpha = 0.85f), scheme.surface))) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier.size(40.dp).clip(CircleShape).background(scheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon(symbol = MaterialSymbols.Gavel, contentDescription = null, tint = scheme.onPrimary, modifier = Modifier.size(22.dp)) + } + Text( + text = if (mine) stringRes(R.string.buzz_workflow_gate_needs_you) else stringRes(R.string.buzz_workflow_gate_awaiting), + style = MaterialTheme.typography.titleSmall, + color = scheme.primary, + fontWeight = FontWeight.Bold, + ) + } + TimeAgo(time = run.createdAt, style = TimeAgoStyle.Short) + } + + Text( + text = runHeadline(run), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Person(stringRes(R.string.buzz_workflow_by), run.requester, accountViewModel, nav) + } + + if (mine && canWrite) { + ApprovalActions( + onApprove = { onDecide(run, true) }, + onDeny = { onDecide(run, false) }, + ) + } else if (mine) { + // Named approver, but this login can't sign (read-only / remote signer w/o write). + Text( + text = stringRes(R.string.buzz_workflow_readonly_approver), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + WaitingPill(run.pendingApprover, accountViewModel, nav) + } + } + } +} + +/** The grant/deny buttons the named approver sees — Approve dominates, Deny stays a quiet exit. */ +@Composable +private fun ApprovalActions( + onApprove: () -> Unit, + onDeny: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = onDeny, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringRes(R.string.buzz_workflow_deny), fontWeight = FontWeight.SemiBold) + } + Button( + onClick = onApprove, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.weight(1f).heightIn(min = 52.dp), + ) { + Icon(symbol = MaterialSymbols.CheckCircle, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.buzz_workflow_approve_open_pr), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + } + } +} + +/** + * The confirm step before a decision is published. Approving is a real commitment (it authorizes a + * push + PR), so it gets a filled confirm; denying discards work, so it gets an error-toned confirm. + * The copy states the boundary plainly: approving never merges or deploys. + */ +@Composable +private fun ConfirmDecisionDialog( + decision: PendingDecision, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val task = decision.run.task?.takeIf { it.isNotBlank() } ?: stringRes(R.string.buzz_workflow_this_run) + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + symbol = if (decision.grant) MaterialSymbols.Gavel else MaterialSymbols.Close, + contentDescription = null, + tint = if (decision.grant) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + }, + title = { Text(if (decision.grant) stringRes(R.string.buzz_workflow_approve_title) else stringRes(R.string.buzz_workflow_deny_title)) }, + text = { + Text( + if (decision.grant) { + stringRes(R.string.buzz_workflow_approve_body, task) + } else { + stringRes(R.string.buzz_workflow_deny_body, task) + }, + ) + }, + confirmButton = { + if (decision.grant) { + Button(onClick = onConfirm) { + Icon(symbol = MaterialSymbols.CheckCircle, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.buzz_workflow_approve_open_pr)) + } + } else { + TextButton(onClick = onConfirm, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { + Text(stringRes(R.string.buzz_workflow_deny), fontWeight = FontWeight.SemiBold) + } + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.buzz_workflow_cancel)) } }, + ) +} + +/** Who the room is waiting on, for members who aren't the approver. */ +@Composable +private fun WaitingPill( + approver: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(20.dp)) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(symbol = MaterialSymbols.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + if (approver != null) { + Person(stringRes(R.string.buzz_workflow_waiting_on), approver, accountViewModel, nav) + } else { + Text(stringRes(R.string.buzz_workflow_waiting_for_approval), style = MaterialTheme.typography.labelLarge) + } + } + } +} + +/** Active / shipped / closed runs — a colour-railed card with real depth and a state-tuned accent. */ +@Composable +private fun LazyItemScope.RunCard( + run: WorkflowRun, + style: RunStyle, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scheme = MaterialTheme.colorScheme + val (container, accent) = styleColors(style) + val elevation = + if (style == RunStyle.ACTIVE) { + 4.dp + } else if (style == RunStyle.SHIPPED) { + 3.dp + } else { + 1.dp + } + Surface( + color = container, + shape = RoundedCornerShape(18.dp), + shadowElevation = elevation, + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .animateItem() + .alpha(if (style == RunStyle.CLOSED) 0.75f else 1f), + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + Box(modifier = Modifier.fillMaxHeight().width(5.dp).background(accent)) + + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + StatePill(run.state, accent) + TimeAgo(time = run.createdAt, style = TimeAgoStyle.Short) + } + + Text( + text = runHeadline(run), + style = if (style == RunStyle.ACTIVE) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge, + fontWeight = if (style == RunStyle.ACTIVE) FontWeight.SemiBold else FontWeight.Normal, + ) + + Person(stringRes(R.string.buzz_workflow_by), run.requester, accountViewModel, nav) + + when (style) { + RunStyle.ACTIVE -> { + run.lastStep?.takeIf { it.isNotBlank() }?.let { WorkingLine(it, accent) } + if (run.state == WorkflowRunState.RUNNING || run.state == WorkflowRunState.APPROVED) RunningBar(accent) + } + RunStyle.SHIPPED -> + run.result?.takeIf { it.isNotBlank() }?.let { ResultLine(it) } + RunStyle.CLOSED -> ClosedLine(run, scheme) + RunStyle.GATE -> Unit + } + } + } + } +} + +/** A thin indeterminate bar so a run that's actively working *looks* alive. */ +@Composable +private fun RunningBar(accent: Color) { + LinearProgressIndicator( + color = accent, + trackColor = accent.copy(alpha = 0.18f), + modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)), + ) +} + +@Composable +private fun ClosedLine( + run: WorkflowRun, + scheme: ColorScheme, +) { + val text = + when (run.state) { + WorkflowRunState.DENIED -> stringRes(R.string.buzz_workflow_closed_denied) + WorkflowRunState.CANCELLED -> stringRes(R.string.buzz_workflow_closed_cancelled) + else -> run.error?.let { stringRes(R.string.buzz_workflow_closed_failed_prefix, it) } ?: stringRes(R.string.buzz_workflow_closed_failed) + } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (run.state == WorkflowRunState.FAILED) scheme.error else scheme.onSurfaceVariant, + ) +} + +/** The active-work pulse: the current step breathes while the runner works. */ +@Composable +private fun WorkingLine( + text: String, + accent: Color, +) { + val pulse = rememberInfiniteTransition(label = "working") + // Keep the State (no `by`) and read it inside graphicsLayer so the pulse redraws, not recomposes. + val a = + pulse.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "workingAlpha", + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.graphicsLayer { alpha = a.value }) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(accent)) + Text(text, style = MaterialTheme.typography.bodyMedium, color = accent, fontWeight = FontWeight.Medium) + } +} + +/** Matches the first URL in a shipped-run result; compiled once for the process, not per result. */ +private val URL_REGEX = Regex("https?://\\S+") + +/** A shipped result — if it carries a URL, offer a prominent "View PR" instead of raw text. */ +@Composable +private fun ResultLine(result: String) { + val url = remember(result) { URL_REGEX.find(result)?.value } + if (url != null) { + val uriHandler = LocalUriHandler.current + Button(onClick = { uriHandler.openUri(url) }, shape = RoundedCornerShape(12.dp)) { + Icon(symbol = MaterialSymbols.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.buzz_workflow_view_pr)) + } + } else { + Text(result, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun Person( + prefix: String, + hex: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (hex == null) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Text(prefix, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + UserPicture(userHex = hex, size = Size20dp, accountViewModel = accountViewModel, nav = nav) + LoadUser(baseUserHex = hex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay(baseUser = user, fontWeight = FontWeight.Medium, accountViewModel = accountViewModel) + } + } + } +} + +@Composable +private fun StatePill( + state: WorkflowRunState, + accent: Color, +) { + val (labelRes, symbol) = pillContent(state) + Surface(color = accent.copy(alpha = 0.14f), shape = RoundedCornerShape(20.dp)) { + Row( + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(symbol = symbol, contentDescription = null, tint = accent, modifier = Modifier.size(15.dp)) + Text(text = stringRes(labelRes), style = MaterialTheme.typography.labelMedium, color = accent, fontWeight = FontWeight.Bold) + } + } +} + +/** The task/workflow-id/placeholder headline shown for a run, resolved for the current locale. */ +@Composable +private fun runHeadline(run: WorkflowRun): String = + run.task?.takeIf { it.isNotBlank() } + ?: run.workflowId?.let { stringRes(R.string.buzz_workflow_id_prefix, it) } + ?: stringRes(R.string.buzz_workflow_no_description) + +/** + * 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 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(14.dp)) { + Text(stringRes(R.string.buzz_workflow_trigger_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + stringRes(R.string.buzz_workflow_trigger_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (defining) { + DefinitionEditor( + onCancel = { defining = false }, + onCreate = { name, yaml, onResult -> + onDefine(name, yaml) { newId -> + pendingSelectId = newId // non-null → the effect below selects it and closes the editor + onResult(newId != null) // null → the editor shows its error and re-enables + } + }, + ) + } else { + WorkflowPicker( + definitions = definitions, + selected = selected, + onSelect = { selected = it }, + onNewDefinition = { defining = true }, + ) + if (definitions.isEmpty()) { + // Don't leave a first-time user staring at a disabled Trigger button and an empty + // dropdown — point them at the way forward. + Text( + stringRes(R.string.buzz_workflow_no_defs_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedTextField( + value = task, + onValueChange = { task = it }, + label = { Text(stringRes(R.string.buzz_workflow_task_label)) }, + 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(stringRes(R.string.buzz_workflow_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(stringRes(R.string.buzz_workflow_picker_label)) }, + placeholder = { Text(if (definitions.isEmpty()) stringRes(R.string.buzz_workflow_picker_empty) else stringRes(R.string.buzz_workflow_picker_choose)) }, + 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(stringRes(R.string.buzz_workflow_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. [onCreate] hands + * back a success flag; on failure the editor stays open, shows an error, and re-enables the button so + * the work isn't lost and the user isn't nudged into publishing a duplicate. + */ +@Composable +private fun DefinitionEditor( + onCancel: () -> Unit, + onCreate: (String, String, (Boolean) -> Unit) -> Unit, +) { + var name by remember { mutableStateOf("") } + var yaml by remember { mutableStateOf("") } + var publishing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val publishFailedMsg = stringRes(R.string.buzz_workflow_def_publish_failed) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringRes(R.string.buzz_workflow_def_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + stringRes(R.string.buzz_workflow_def_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringRes(R.string.buzz_workflow_def_name)) }, + placeholder = { Text(stringRes(R.string.buzz_workflow_def_name_hint)) }, + singleLine = true, + enabled = !publishing, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = yaml, + onValueChange = { yaml = it }, + label = { Text(stringRes(R.string.buzz_workflow_def_yaml)) }, + enabled = !publishing, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + ) + error?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = onCancel, enabled = !publishing) { Text(stringRes(R.string.buzz_workflow_cancel)) } + Button( + onClick = { + error = null + publishing = true + onCreate(name.trim(), yaml) { ok -> + publishing = false + if (!ok) error = publishFailedMsg + } + }, + enabled = !publishing && name.isNotBlank() && yaml.isNotBlank(), + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (publishing) stringRes(R.string.buzz_workflow_publishing) else stringRes(R.string.buzz_workflow_create_definition)) + } + } + } +} + +@Composable +private fun EmptyRunBoard() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Icon(symbol = MaterialSymbols.Gavel, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp)) + Text(stringRes(R.string.buzz_workflow_empty_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + stringRes(R.string.buzz_workflow_empty_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun styleColors(style: RunStyle): Pair = + when (style) { + RunStyle.GATE -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.primary + RunStyle.ACTIVE -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.secondary + RunStyle.SHIPPED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.tertiary + RunStyle.CLOSED -> MaterialTheme.colorScheme.surfaceContainerLow to MaterialTheme.colorScheme.outline + } + +private fun pillContent(state: WorkflowRunState): Pair = + when (state) { + WorkflowRunState.TRIGGERED -> R.string.buzz_workflow_pill_queued to MaterialSymbols.Schedule + WorkflowRunState.RUNNING -> R.string.buzz_workflow_pill_working to MaterialSymbols.Bolt + WorkflowRunState.AWAITING_APPROVAL -> R.string.buzz_workflow_pill_needs_approval to MaterialSymbols.Gavel + WorkflowRunState.APPROVED -> R.string.buzz_workflow_pill_approved to MaterialSymbols.CheckCircle + WorkflowRunState.COMPLETED -> R.string.buzz_workflow_pill_shipped to MaterialSymbols.CheckCircle + WorkflowRunState.FAILED -> R.string.buzz_workflow_pill_failed to MaterialSymbols.Error + WorkflowRunState.CANCELLED -> R.string.buzz_workflow_pill_cancelled to MaterialSymbols.Cancel + WorkflowRunState.DENIED -> R.string.buzz_workflow_pill_denied to MaterialSymbols.Close + } 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 new file mode 100644 index 0000000000..31b3145ee9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +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.WorkflowStepStartedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** 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. + * + * A Buzz "workflow" is the source-confirmed structured-work primitive (30620 def, 46020 trigger, + * 46001-46007 lifecycle, 46010 approval gate, 46030/46031 grant/deny): a member triggers a run, the + * runner does the work and pauses on a **human-approval gate**, and only ships after someone grants. + * This VM folds those events into per-run [WorkflowRun] records via the shared [WorkflowRunAggregator] + * and exposes them, awaiting-approval first, as a [StateFlow]; it drives trigger + approve + deny. + * + * **Why we aggregate from the live subscription, not `LocalCache`:** the run/lifecycle/gate kinds are + * 46001-46031, which are neither regular (`< 10_000`) nor addressable, so `LocalCache.filter` cannot + * serve them — its note branch only matches `kind.isRegular()` events. Reading them back from the + * cache always returned nothing, so the board never showed a run. Instead we consume the events + * straight off [subscribeAsFlow], which accumulates the channel's stored + live events (deduped by id) + * and re-emits the growing list — exactly the data the aggregator needs. + * + * Two subscription realities (both mirrored from `amy buzz workflow`): the trigger + lifecycle + gate + * + the addressable definitions (30620) are `#h`-scoped to the channel — one subscription, one `#h` + * value, so Buzz's relay keeps it channel-scoped and delivers live (a multi-`#h` filter would be + * forced global and go deaf). The grant/deny decisions (46030/46031) carry only a `d` tag, so they + * ride a second subscription filtered by **author** — every 46010 gate names its approver in a `p` + * tag, giving us exactly those authors. The two streams are merged and folded on every emission. + */ +class WorkflowRunBoardViewModel : ViewModel() { + @Volatile private var account: Account? = null + private var relay: NormalizedRelayUrl? = null + private var channelId: String? = null + + 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() + + private var watchJob: Job? = null + + fun bind( + account: Account, + channelId: String, + relayUrl: String, + ) { + if (this.account != null) return + this.account = account + this.channelId = channelId + this.relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) + } + + /** + * Open the channel's workflow subscriptions and fold every emission into the board. The base + * `#h` subscription backfills stored runs then streams live ones; a nested by-author subscription + * (rebuilt only when the approver set actually changes) streams the grant/deny decisions. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun startWatching() { + val account = account ?: return + val relay = relay ?: return + val channelId = channelId ?: return + if (watchJob != null) return + + watchJob = + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + // Fall out of the loading state even if the channel is genuinely empty (no EOSE signal). + val loadingTimeout = + launch { + delay(LOADING_TIMEOUT_MS) + _isLoading.value = false + } + + val baseFilter = Filter(kinds = WORKFLOW_H_KINDS + WorkflowDefEvent.KIND, tags = mapOf("h" to listOf(channelId))) + // `onStart(emptyList)` so the merged flow produces a first value even before any event + // arrives — otherwise `combine` would never emit for an empty channel and the board + // would sit on the spinner forever. + val baseFlow = account.client.subscribeAsFlow(relay, listOf(baseFilter)).onStart { emit(emptyList()) } + + val decisionFlow = + baseFlow + .map { base -> + base + .filterIsInstance() + .mapNotNull { it.approver() } + .distinct() + .sorted() + }.distinctUntilChanged() + .flatMapLatest { approvers -> + if (approvers.isEmpty()) { + flowOf(emptyList()) + } else { + account.client.subscribeAsFlow(relay, listOf(Filter(kinds = DECISION_KINDS, authors = approvers))).onStart { emit(emptyList()) } + } + } + + combine(baseFlow, decisionFlow) { base, decisions -> base + decisions } + .collect { events -> + if (events.isNotEmpty()) { + loadingTimeout.cancel() + _isLoading.value = false + } + derive(events) + } + } + } + + fun stopWatching() { + watchJob?.cancel() + watchJob = null + } + + /** Fold the merged event list into the definitions picker + the prioritized run board. */ + private fun derive(events: List) { + _definitions.value = + events + .filterIsInstance() + .map { WorkflowDefOption(it.workflowId(), it.name()?.takeIf { n -> n.isNotBlank() }, it.yaml()) } + .distinctBy { it.id } + .sortedBy { (it.name ?: it.id).lowercase() } + _runs.value = WorkflowRunAggregator.byPriority(WorkflowRunAggregator.aggregate(events)) + } + + fun trigger( + workflowId: String, + task: String, + onResult: (Boolean) -> Unit, + ) = act(onResult) { account, relay, channelId -> + account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null + } + + fun approve( + runId: HexKey, + onResult: (Boolean) -> Unit, + ) = act(onResult) { account, relay, _ -> + account.approveBuzzWorkflowRun(relay, runId) != null + } + + fun deny( + runId: HexKey, + onResult: (Boolean) -> Unit, + ) = act(onResult) { account, relay, _ -> + account.denyBuzzWorkflowRun(relay, runId) != null + } + + /** + * Publish a new workflow definition (kind-30620) for this channel and hand its freshly-minted id + * back on [onResult] (on the main thread) — or `null` if the account can't write / the publish + * failed, so the caller can surface an error instead of hanging the editor open silently. + */ + fun defineWorkflow( + name: String, + yaml: String, + onResult: (String?) -> Unit, + ) { + val account = account + val relay = relay + val channelId = channelId + if (account == null || relay == null || channelId == null) { + onResult(null) + return + } + viewModelScope.launch(Dispatchers.IO) { + val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml) + withContext(Dispatchers.Main) { onResult(newId) } + } + } + + private inline fun act( + crossinline onResult: (Boolean) -> Unit, + crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Boolean, + ) { + val account = account + val relay = relay + val channelId = channelId + if (account == null || relay == null || channelId == null) { + onResult(false) + return + } + viewModelScope.launch(Dispatchers.IO) { + val ok = block(account, relay, channelId) + withContext(Dispatchers.Main) { onResult(ok) } + } + } + + override fun onCleared() { + stopWatching() + super.onCleared() + } + + companion object { + private const val LOADING_TIMEOUT_MS = 6_000L + + // The `#h`-scoped workflow kinds the aggregator can actually fold: trigger (46020), run/step + // lifecycle (46001-46003, 46005-46007) and the relay-signed approval-requested gate (46010). + // Deliberately excluded: the client-signed grant/deny commands (46030/46031) carry no `h` tag + // (fetched by author instead), and 46004/46011/46012 carry no correlatable run id, so the + // aggregator ignores them — fetching them would be pure cost. + private val WORKFLOW_H_KINDS = + listOf( + WorkflowTriggerEvent.KIND, + WorkflowTriggeredEvent.KIND, + WorkflowStepStartedEvent.KIND, + WorkflowStepCompletedEvent.KIND, + WorkflowCompletedEvent.KIND, + WorkflowFailedEvent.KIND, + WorkflowCancelledEvent.KIND, + WorkflowApprovalRequestedEvent.KIND, + ) + private val DECISION_KINDS = listOf(ApprovalGrantEvent.KIND, ApprovalDenyEvent.KIND) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index bb984b4ba6..a4c9c159be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -183,7 +183,8 @@ fun RelayGroupTopBar( // outright. So a DM shows the icon only when a canvas already exists — which is also what // keeps us from advertising "start a shared doc" in a two-person conversation. val hasCanvas by observeBuzzCanvas(channel.groupId.id) - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && (!isDm || hasCanvas)) { + val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + if (isBuzzRelay && (!isDm || hasCanvas)) { IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) { Icon( symbol = MaterialSymbols.Dashboard, @@ -247,6 +248,20 @@ fun RelayGroupTopBar( }, ) } + // The agent surface: this channel's job backlog (43001-43006) and workflow runs + // (46020 + lifecycle) folded into one **Agent work** board, where the human-approval + // gate is an inline card state. A menu entry rather than an icon — it's a view of + // the channel reached occasionally, and icons pushed the bar back to four, truncating + // the channel name and relay the title row is there to show. + if (isBuzzRelay && !isDm) { + DropdownMenuItem( + text = { Text(stringRes(R.string.buzz_agent_work_title)) }, + onClick = { + menuOpen = false + nav.nav(Route.BuzzAgentWork(channel.groupId.id, channel.groupId.relayUrl.url)) + }, + ) + } if (naddr != null) { val context = LocalContext.current DropdownMenuItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index 9c1a2b3e15..95003a354e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -39,6 +39,17 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent +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.WorkflowFailedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -148,6 +159,9 @@ val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) * - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100) * - forum posts/votes/comments (45001-45003) * - agent jobs (43001-43006) + * - workflow trigger + run/step lifecycle + approval gate (46020, 46001-46007, 46010-46012); + * note the client-signed grant/deny (46030/46031) carry only a `d` tag (no `h`), so they're + * NOT here — surfaces that need them fetch by author (see the workflow board VM / CLI) * - huddle lifecycle (48100-48103) * * Consumption for every one of these already exists in `LocalCache` (see @@ -170,6 +184,17 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS = JobResultEvent.KIND, JobCancelEvent.KIND, JobErrorEvent.KIND, + WorkflowTriggerEvent.KIND, + WorkflowTriggeredEvent.KIND, + WorkflowStepStartedEvent.KIND, + WorkflowStepCompletedEvent.KIND, + WorkflowStepFailedEvent.KIND, + WorkflowCompletedEvent.KIND, + WorkflowFailedEvent.KIND, + WorkflowCancelledEvent.KIND, + WorkflowApprovalRequestedEvent.KIND, + WorkflowApprovalGrantedEvent.KIND, + WorkflowApprovalDeniedEvent.KIND, HuddleStartedEvent.KIND, HuddleParticipantJoinedEvent.KIND, HuddleParticipantLeftEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index b7ae8a5c78..3c5083986a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -32,9 +32,12 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent +import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent @@ -186,6 +189,9 @@ class NotificationFeedFilter( VideoShortEvent.KIND, VoiceEvent.KIND, VoiceReplyEvent.KIND, + // A Buzz workflow approval gate (46010) addressed to me — I need to grant/deny it. + // Also gates the push dispatcher, which uses NOTIFICATION_KINDS as its first filter. + WorkflowApprovalRequestedEvent.KIND, ) + ADDRESSABLE_KINDS // How deep to walk a public chat reply chain looking for one of the @@ -492,6 +498,21 @@ class NotificationFeedFilter( return it.author?.pubkeyHex != loggedInUserHex } + // A finished or failed agent job I filed: the workspace bot addresses the outcome to me via a + // `p` tag = the requester. Notify me directly — I don't "follow" the bot and the job kinds aren't + // in the generic relevance path, so mirror the Buzz-DM early return above rather than the p-tag + // heuristic. This is activity (not a chat message), so it ignores the Messages toggle. + if (noteEvent is JobResultEvent || noteEvent is JobErrorEvent) { + val requester = (noteEvent as? JobResultEvent)?.requester() ?: (noteEvent as JobErrorEvent).requester() + return requester == loggedInUserHex && it.author?.pubkeyHex != loggedInUserHex + } + + // A Buzz workflow paused on an approval gate (46010) addressed to me as the approver: I must + // grant or deny before the run ships. Same early-return shape — it's activity, not a message. + if (noteEvent is WorkflowApprovalRequestedEvent) { + return noteEvent.approver() == loggedInUserHex && it.author?.pubkeyHex != loggedInUserHex + } + if (!showMessages && ( noteEvent is ChatMessageEvent || diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7393d3bc40..c4ac22857a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4861,4 +4861,106 @@ Anonymous — a throwaway identity per area, never your npub. Bitchat-compatible; reaches nearby users on the same relays. Public and ephemeral — no history, anyone in the cell can read it. + + + Backlog + Workflow runs + Agent work + New run + Needs your approval + Working now + Shipped + Closed + Needs your approval + Awaiting approval + (no description) + Workflow: %1$s + by + waiting on + You\'re the approver, but this login can\'t sign a decision. + Deny + Approve & open PR + Approve this run? + Deny this run? + The runner will push its branch and open a pull request for “%1$s”. Approving never merges or deploys — that still happens on GitHub. + The agent\'s work for “%1$s” will be discarded. This can\'t be undone. + this run + Cancel + Waiting for approval + Run triggered + Couldn\'t trigger the run — check you can post to this workspace + Approved — the runner is opening a pull request + Denied — the work was discarded + Couldn\'t publish your decision — check you can post to this workspace + View PR + Denied — work discarded + Cancelled + Failed: %1$s + Failed + Queued + Working + Needs approval + Approved + Shipped + Failed + Cancelled + Denied + Trigger a workflow + The runner does the work, then pauses for a human to approve before it opens a PR. The whole channel sees the run. + No workflows yet. Open the menu above and choose “New definition…” to create one, then trigger it. + What should it do? + Trigger run + Workflow + No workflows defined yet + Choose a workflow + New definition… + New workflow definition + 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 — here the definition just names and catalogs the run. + Name + build-and-test + YAML recipe + Couldn\'t publish the definition — check you can post to this workspace. + Publishing… + Create definition + No runs yet + Tap “New run” to trigger a workflow. The runner does the work and pauses on an approval gate — a human grants it before anything ships. + + + Attestations + Hold an attestation + Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay. + Grants: %1$s + any kind, any time (unrestricted) + Remove + Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member. + auth tag JSON + Hold attestation + Local key required + A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, so it can only be produced by a signer that holds your raw private key. This account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot sign an attestation. + Authorize an agent pubkey to publish in your workspace without enrolling its key. The agent attaches the signed tag below to its events. + Agent (search a name, or paste npub / hex) + Enter an npub or 64-char hex key. + Change agent + Conditions (optional) — leave blank for an unrestricted attestation. + Restrict to kind (0–65535) + Only events after (unix seconds) + Only events before (unix seconds) + Generate attestation + Signed attestation + Copy tag + ⚠ Hand this to the agent operator only. While it is valid and you remain a workspace member, the relay lets this agent post as a member under the conditions above. + + + New persona + Edit persona + Slug (persona id) + a-z, 0-9, \'-\' or \'_\'. Cannot change after creation. + Display name + System prompt + Model (optional) + Provider (optional) + Runtime (optional) + Avatar URL (optional) + Publishing… + Publish persona diff --git a/cli/README.md b/cli/README.md index b6577ed3e3..d3f0591f78 100644 --- a/cli/README.md +++ b/cli/README.md @@ -460,7 +460,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | Command | What it does | |---|---| -| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. Blocks until interrupted. | +| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS] [--buzz [--members NPUBS]]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. `--buzz` makes it a **private Buzz workspace relay** (`BuzzMembershipPolicy`): NIP-42 required, and only members (admins + `--members`) or NIP-OA-attested agents may read/write — so a team can self-host the agent channel on one JVM process instead of Block's Rust `buzz-relay` + Postgres/Redis/MinIO. Blocks until interrupted. | ### Identity @@ -610,6 +610,49 @@ and `commons` aggregator the app uses. | `amy buzz dm open RELAY PUBKEY [PUBKEY…]` | Open (or re-surface) a DM with 1-8 people (kind:41010). The relay assigns the channel id and confirms via 41001. | | `amy buzz dm hide RELAY CHANNEL` | Hide a DM from my sidebar (kind:41012); re-opening it un-hides. | | `amy buzz dm add-member RELAY CHANNEL PUBKEY` | Add a member to an existing group DM (kind:41011). | +| `amy buzz job request RELAY [--agent PUBKEY] [--channel GID]` | File an agent job (kind:43001): ask an agent to do a task, optionally targeting an agent (`p`) and/or scoping to a channel (`h`). | +| `amy buzz job list RELAY [--channel GID] [--mine\|--assigned] [--limit N] [--timeout SECS]` | List jobs and their folded state (REQUESTED/ACCEPTED/IN_PROGRESS/COMPLETED/FAILED/CANCELLED). `--mine` = jobs I requested; `--assigned` = jobs targeting me. | +| `amy buzz job show RELAY JOBID [--timeout SECS]` | Show one job's full lifecycle (request + every reply, folded). | +| `amy buzz job cancel RELAY JOBID [--reason R] [--channel GID]` | Cancel a job (kind:43005). | +| `amy buzz agent serve RELAY --exec CMD [--channel GID] [--accept-from npub,…] [--accept-from-channel] [--parallel N] [--worktree REPODIR] [--base-ref REF] [--branch-prefix P] [--claim-untargeted] [--poll SECS] [--exec-timeout SECS] [--no-progress] [--dry-run] [--once]` | Run a backlog **scheduler**. Watches a channel's REQUESTED jobs, orders them by the group's upvotes (kind-7 likes), and runs up to `--parallel N` at once — each in its own `git worktree`+branch (`--worktree REPODIR`, off `--base-ref`, named ``) so concurrent runs never collide. Per job: accept (43002) → progress (43003) → runs `sh -c CMD` inside the worktree (task text on stdin; `BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/AGENT/UPVOTES/BRANCH/WORKTREE/BASE_REF` in env) → result (43004) or error (43006). Intake gate: `--accept-from` (explicit npubs) and/or `--accept-from-channel` (the channel's kind-39002 member roster). `--parallel > 1` requires `--worktree`. The exec commits/pushes its branch and opens the PR; **merge stays on GitHub, never here.** | + +> **Agent-job schema is provisional.** Kinds 43001-43006 are *reserved* in Buzz with no +> upstream builder; the tag layout (`e`/`h`/`p`/`status`) is a best-effort model and will be +> reconciled once Buzz implements the protocol. See +> [`cli/plans/2026-07-25-buzz-agent-support-channel.md`](plans/2026-07-25-buzz-agent-support-channel.md). + +#### Buzz workflows (source-confirmed — the human-approval primitive) + +Where agent-jobs are speculative, **workflows are Buzz's real structured-work primitive**: the +command kinds (30620 definition, 46020 trigger, 46030/46031 grant/deny) are pinned against +buzz-relay's Rust `command_executor.rs`. A run pauses on a **human-approval gate** and only ships +after someone grants it — exactly the "anyone can drive, but a human gates the merge" model. + +On a real Buzz relay the *relay* parses the workflow YAML and executes it. Self-hosted on geode +there is no workflow engine, so **`amy` is the runner** and emits the lifecycle events itself — a +documented divergence. The **run id is the trigger's event id and doubles as the approval token**, +so a grant's `d` tag equals the run id (no separate token bookkeeping). Because quartz's event store +serves `#d` only for addressable kinds, decisions (regular kind 46030/46031) are fetched **by +author** — every 46010 gate names its approver in a `p` tag — and matched to their run by the token. + +| Command | What it does | +| --- | --- | +| `amy buzz workflow trigger RELAY WFID --task TEXT --channel GID` | Trigger a run (kind:46020). Prints the `run_id` (= the trigger event id = the approval token). | +| `amy buzz workflow list RELAY --channel GID [--timeout SECS]` | List a channel's runs, folded to state (TRIGGERED/RUNNING/AWAITING_APPROVAL/APPROVED/COMPLETED/FAILED/DENIED), awaiting-approval first. | +| `amy buzz workflow show RELAY RUNID [--timeout SECS]` | Show one run's folded state + lifecycle (resolves the channel from the trigger, then folds it). | +| `amy buzz workflow approve RELAY RUNID [--note N]` | Grant a run's approval gate (kind:46030, `d`=run id). Resumes the paused run. | +| `amy buzz workflow deny RELAY RUNID [--note N]` | Deny a run's approval gate (kind:46031). The run is terminal (DENIED); the runner discards the unshipped work. | +| `amy buzz workflow run RELAY --exec CMD --channel GID --approver NPUB [--on-approve CMD] [--worktree REPODIR] [--base-ref REF] [--accept-from npub,…] [--poll SECS] [--once]` | Run the **runner**. Per new trigger: emits triggered (46001) → step-started (46002) → runs `sh -c CMD` inside a fresh `git worktree`+branch (task on stdin; `BUZZ_RUN/CHANNEL/RELAY/AGENT/REQUESTER/BRANCH/WORKTREE/BASE_REF` in env) → step-completed (46003) → posts the **approval gate** (46010, addressed to `--approver`). On a later poll, when the approver publishes a grant it runs `--on-approve CMD` (the push + open-PR step) and emits completed (46005, carrying the PR url); a deny discards the worktree. Restart-safe: runs still at the gate are rebuilt from the run id on startup. | + +> **Permissions (same three-layer model as jobs).** Buzz gates *who can trigger/approve* +> (`--approver`, `--accept-from`); the exec credential bounds *what the agent can touch* (keep the +> git token PR-only); GitHub branch protection keeps *merge off the agent's path*. The approval gate +> adds a fourth: a human must grant before anything is pushed. See +> [`cli/plans/2026-07-25-buzz-agent-support-channel.md`](plans/2026-07-25-buzz-agent-support-channel.md). +> +> **Permissions.** Buzz scopes by identity, not capability flags. `--accept-from` is the +> intake gate; what the agent can do to a repo is bounded by the credentials you give +> `--exec` (keep its git token PR-only) and by branch-protecting `main` — not by Buzz. ### Concord Channels (encrypted communities) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 83eb894d9d..add1d87d06 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -60,6 +60,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. | | NIP-29 relay groups (`amy relaygroup`) | ✅ | `RelayGroupCommands` — list/browse/info/create/join/leave/message/edit/invite/put-user/remove-user against a host relay; kind:10009 joined-list kept in sync. | | Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). | +| Buzz agent jobs (`amy buzz job` / `agent serve`) | ✅ | `BuzzJobCommands` (request/list/show/cancel, kinds 43001-43006) + `BuzzAgentCommands` (`agent serve` responder loop: gate on `--accept-from`, run `--exec`, report accept/progress/result/error). Correlation + state via the shared `BuzzJobAggregator` in `commons`. Schema provisional (43001-43006 reserved upstream). See `cli/plans/2026-07-25-buzz-agent-support-channel.md`. | | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. | | BOLT12 zaps (NIP-B1, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. | diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 770cd13288..0da6af4bdb 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -23,6 +23,12 @@ sourceSets { } } +// `resources.srcDir(...)` above re-adds the default resource root, so every resource is registered +// twice (identical source + destination). Pick last-wins instead of failing the copy. +tasks.withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} + dependencies { implementation(project(":quartz")) implementation(project(":commons")) diff --git a/cli/plans/2026-07-25-buzz-agent-support-channel.md b/cli/plans/2026-07-25-buzz-agent-support-channel.md new file mode 100644 index 0000000000..bccdcb77a8 --- /dev/null +++ b/cli/plans/2026-07-25-buzz-agent-support-channel.md @@ -0,0 +1,270 @@ +# Buzz-driven agent support channel for Amethyst + +**Date:** 2026-07-25 +**Status:** prototype landing (CLI) · mobile gaps scoped +**Owning module:** `cli/` (with a shared aggregator in `commons/`) + +## Goal + +Give the Amethyst team a **shared feature-request channel** where anyone can drive work: the +team debates and files requests, an AI coding agent — Claude Code running as *this* Anthropic +account — **manages the backlog by itself and works items in parallel**, over a self-hosted +[`block/buzz`](https://github.com/block/buzz) workspace. Every request, upvote, and result is +a signed, audited Nostr event the whole room sees. This is **not** a 1:1 chat with the bot. + +Interaction model (decided): +- **Anyone in the channel can drive** a work stream — no propose-and-confirm gate; a member's + job request is auto-accepted and scheduled (**full auto from intake**). +- The bot **owns a stack**: it orders the backlog by the group's upvotes and runs up to N in + parallel, each isolated in its own git worktree/branch. +- **The only human gate is the merge, and it happens on GitHub** (branch protection + review) — + never inside Amy or the channel. The agent opens PRs; it can never merge or damage `main`. + +### Can this live in Amy? Yes — Amy is the scheduler, the coding agent is `--exec`. + +A clean three-way split, no separate project needed for the team-on-a-box case: +- **Amy** owns the Buzz side: watch the backlog, order by upvotes, dispatch up to `--parallel N`, + isolate each job in a worktree/branch, report status as job events. Reuses everything already + built (relay client, job models, `BuzzJobAggregator`, the responder, subprocess spawning, the + long-running `serve` pattern). Decision logic lives in `commons` (pure/testable); git + + process I/O lives in the `cli` command — so Amy stays a thin assembly layer. +- **`--exec`** is the coding agent (Claude Code via buzz-acp / Goose / a script) Amy spawns per + job. Not a new project — an existing tool. It runs inside the job's worktree (`BUZZ_BRANCH`, + `BUZZ_WORKTREE` exported), commits, pushes the branch, opens the PR; its stdout is the result. +- **GitHub** owns review + merge, entirely outside the loop. + +Graduate to a separate service only if you outgrow one host (hosted, multi-tenant, a web +dashboard, a cross-machine worker fleet) — and even then Amy/`quartz`/`commons` stay the library +underneath. + +## Why Buzz is the right substrate (and what it is NOT) + +Buzz is a self-hosted Nostr relay that acts as a workspace where humans and agents share +rooms; Amethyst already models ~78 of its kinds (`quartz/.../buzz/`) plus client UI (agent +console, workspaces, DMs, attestations — shipped in v1.13.0). Upstream, Buzz ships +`buzz-acp`, an ACP harness that already plugs **Claude Code** (and Goose/Codex) in as the +agent runner, and produces code as **NIP-34 patches / git diffs / PRs** — the same flow +this repo's `claude/*` branches already use. + +What already exists in-repo to build on: + +| Layer | Status | +|---|---| +| Workspace = a relay you own; channels/threads/canvas | app + `amy buzz post/read` | +| DMs to an agent key (open/hide/add-member/list) | app + `amy buzz dm …` | +| Agent authorization — NIP-OA owner attestation (virtual membership) | `AgentAttestationScreen` + `amy buzz attest` | +| Agent config — personas (30175), managed agents (30177), agent profiles (10100) | quartz models + persona editor | +| Cost/activity telemetry — turn metrics (44200), observer (24200) | Agent Console + `amy buzz console` | +| Code changes in the room — diff (40008), NIP-34 patches | rendered in chat | +| Human-in-the-loop gate — workflow approval (46010/46030/46031) | quartz models only, no UI | +| Structured jobs — 43001-43006 | quartz models + EventFactory dispatch; **no client surface (this plan)** | + +### The permission reality — the crux + +Buzz authorizes **by identity, not by capability flags**. Its entire vocabulary is coarse: +membership + `owner`/`admin`/`member` roles, NIP-OA conditions limited to a single `kind` +and `created_at` before/after bounds, and per-agent `respond_to` / `channel_add_policy` +gates. **There is no way in Buzz to express "may push but not merge" or "only this repo."** +So the constraints the task asks for live in **three layers**, and Buzz is only one: + +| Requirement | Enforced by | How | +|---|---|---| +| Can't merge/destroy `main` | **GitHub branch protection** (load-bearing) | Protect `main` (PR + review + green CI, no direct/force push, no branch delete). The agent runner's git credential can only open PRs on feature branches — never merge. | +| Can't use the agent to code other things | **Agent runtime + Buzz intake** | `--exec` checked out in `amethyst` only, scoped tools; persona system-prompt scopes the task; `--accept-from` allowlist = team npubs only. | +| Only the team can drive it | **Buzz** | Team npubs = relay members / the `--accept-from` allowlist. | +| Everything accountable | **Buzz** | Every request/progress/result is a signed event in the tenant's hash-chained audit log. | +| Human sign-off before risky actions | **Buzz workflow gate** | 46010 pause → 46030/46031 grant/deny by a designated approver key (two-signer; a run can't self-approve). | + +Honest blast radius: a Buzz-authorized agent key has member-level reach *on the relay* +only. Its reach into **code** is bounded entirely by the git credential handed to `--exec`. +Keep that credential minimal; branch protection is what actually stops a bad merge. + +## Architecture (MVP) + +1. **The workspace relay.** For the agent job channel you have two options: + - **`amy serve --buzz --members `** (recommended to start) — a private, agent-authorized + workspace on a single JVM process via **`BuzzMembershipPolicy`** (quartz): NIP-42 required, + only members + NIP-OA-attested agents may read/write. No Rust, no Postgres/Redis/MinIO. The + job board + scheduler run on this today. It does NOT emit relay-signed NIP-29 metadata + (39000-39003) or run workflows — the job channel doesn't need them. + - **Block's Rust `buzz-relay`** — only if you want the full in-app Buzz *workspace/DM* UI + (relay-signed rosters, relay-assigned DM UUIDs) or server-run workflows. Heavier stack. +2. **One agent identity** = its own nostr key, authorized by a NIP-OA attestation the owner + issues (`amy buzz attest` / `AgentAttestationScreen`). On GitHub it authenticates with a + PR-only token; `main` is branch-protected. +3. **Intake:** a team member files a job in the `#build` channel (or DMs the agent). The + responder picks it up, runs a coding agent in an `amethyst` checkout, streams progress, + posts the result, and opens a PR on a `claude/*` branch. **Merge stays human.** +4. Optional **approval gate** (46010/46030/46031) for irreversible mid-run steps. + +## CLI prototype (this change) + +Thin assembly over quartz job models + a shared aggregator; no protocol logic in `cli/`. + +- **`commons/.../model/buzz/BuzzJobs.kt`** — `BuzzJobAggregator`, a pure, tested + (`BuzzJobAggregatorTest`, 9 cases) folder that correlates 43001-43006 events (by the + reply `e` → request id) into `JobView` records with a `JobState` machine + (REQUESTED→ACCEPTED→IN_PROGRESS→COMPLETED/FAILED/CANCELLED; newest terminal wins). Shared + so a future mobile Jobs board reuses one correlation path. +- **`amy buzz job request|list|show|cancel`** (`BuzzJobCommands.kt`) — the requester side: + file a 43001 (optional `--agent`, `--channel`), list/fold jobs (`--mine`/`--assigned`), + show one job's lifecycle, cancel (43005). +- **`amy buzz agent serve RELAY --exec CMD`** (`BuzzAgentCommands.kt`) — the **backlog + scheduler**. Watches a channel's REQUESTED jobs, orders them by `BuzzJobAggregator.byPriority` + (upvotes desc, oldest-first tiebreak), and runs up to `--parallel N` at once — each in its own + `git worktree` + branch (`--worktree REPODIR`, off `--base-ref`, named ``) + so concurrent runs never collide (`--parallel > 1` requires `--worktree`; worktree add/remove + is mutex-serialized, the agent work runs concurrently). Per job: 43002 accept → 43003 progress + → `sh -c CMD` inside the worktree (task text on stdin; `BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/ + AGENT/UPVOTES/BRANCH/WORKTREE/BASE_REF` in env) → 43004 result or 43006 error. Intake gate: + `--accept-from` (explicit npubs) and/or `--accept-from-channel` (the channel's kind-39002 + member roster — "anyone in the channel drives"). `--dry-run`, `--once`, `--claim-untargeted`, + `--exec-timeout` for testing/ops. This is where Claude Code plugs in: `--exec` runs the agent, + which opens the PR and echoes the URL as the result. +- **Upvote priority** (`BuzzJobs.kt`): `BuzzJobAggregator` folds kind-7 likes (distinct reactors, + dislikes excluded) targeting a job into `JobView.upvotes`; `byPriority` orders the backlog. The + group reprioritizes the stack just by reacting. + +Guardrails restated in the command's KDoc: `--accept-from` / `--accept-from-channel` is the +Buzz-layer intake gate; repo blast radius is the `--exec` credential (PR-only) + branch +protection, not Buzz. Merge is never done here — only on GitHub. + +### Schema caveat + +Kinds 43001-43006 are *reserved* in Buzz with no upstream builder; the tag layout +(`e`/`h`/`p`/`status`) is Quartz's best-effort model and must be reconciled once Buzz +implements the job protocol. The prototype is deliberately isolated so that reconciliation +touches only the quartz models + this aggregator. + +## Mobile app — placement evaluation + +The existing agent screens are **owner-global concepts entered per-relay, and buried**: +`AgentConsole(relayUrl)` (Costs/Personas/Observer, read-only telemetry) is only reachable via +a footer in the channel list or a bot-member tap; Costs/Personas/Observer are really the +owner's whole fleet, not one relay's. That's a discoverability + scoping smell, but the Console +is a coherent *owner telemetry* surface and should stay that — just get a better entry later. + +The **shared work surface is a different thing and belongs at the channel level.** A Buzz job is +`h`-scoped to a channel, so the backlog is *per-channel* — exactly like the Canvas (40100) and +Forum, which launch from `RelayGroupTopBar` gated by `BuzzRelayDialect.isBuzz`. So the Jobs +board sits there too (→ `Route.BuzzJobBoard(channelId, relayUrl)`), NOT inside the owner +Console. It lives in that bar's **overflow menu** rather than as an icon: Canvas is the only +affordance holding an icon there, because a fourth and fifth one squeeze the title row until the +channel name and relay truncate. Keeping "owner fleet telemetry" and "this channel's shared backlog" +as separate surfaces is the right call. + +Note the model change also **deprioritizes the workflow-approval inbox (46010/46030/46031)**: with +full-auto intake and merge-on-GitHub, the human gate moved to the PR — so the approvals inbox is +now P1/optional, not P0. The true P0 is the shared board. + +## Mobile app gaps (prioritized) + +The quartz layer + LocalCache ingest are complete for every kind; the app has **zero +create/interact surface** for the two kinds that define the workflow. Priorities: + +**P0 — the shared work surface** +- **P0-2 Jobs board — ✅ LANDED.** `JobBoardScreen` + `JobBoardViewModel` (per-channel, + `Route.BuzzJobBoard(channelId, relayUrl)`, entered from the `RelayGroupTopBar` overflow menu + on Buzz relays). + Reads job kinds + kind-7 upvotes scoped to the channel `h`, folds via `BuzzJobAggregator`, + groups by state (In progress / Queued-by-upvotes / Done / Closed), live via `subscribeAsFlow`. + Three write actions through new `Account` helpers: **file** a task (43001, FAB → dialog), + **upvote** (kind-7 `+` with `h`), **cancel** own job (43005). Merge stays on GitHub. +- **P0-1 Approvals inbox** — deprioritized to P1 by the full-auto/merge-on-GitHub model (the + human gate is now the PR, not a 46010 gate). Still worth it if a workflow-gate flow returns: + render 46010, publish 46030/46031, token-hash correlation, push-urgent. +- **P0-3 Agent picker** — the board files **untargeted** jobs (any channel agent claims them), + so a picker isn't needed for the shared-channel model; revisit only for directed jobs. + +**P1 — a credible agent-driving client** +- **P1-1 Diff/PR review surface** — upgrade read-only 40008 (`RenderBuzzDiff`) into a + full-screen per-file review whose approve action emits 46030/46031. Size **M**. +- **P1-2 Managed-agent (30177) editor** — clone `AgentPersonaEditScreen`. Size **M**. +- **P1-3 Persona `respond_to`/allowlist editing** — the safety gate for pointing a persona + at a support channel. Size **S–M**. +- **P1-4 Attestation persistence** — `BuzzHeldAttestations` is in-memory; survive restart. + Size **S–M**. + +**P2 — completeness**: agent-profile (10100) viewer; a stable "Agents" hub; +workflow-run timeline (46020 family, all stored, unrendered); turn-metric → job attribution. + +Key files: routes `amethyst/.../navigation/routes/Routes.kt`; render dispatch +`.../chats/feed/ChatMessageCompose.kt`; renderers `.../chats/feed/types/RenderBuzzNotes.kt`; +ingest `model/LocalCache.kt` (~L4780-4855); subscription +`.../relayGroup/datasource/RelayGroupFilterBuilders.kt`. + +## Pivot — jobs → workflows (2026-07-26) + +The 43001-43006 job prototype above proved the *shape* (drive an agent from a shared channel, +worktree-isolate, PR-only, merge-on-GitHub), but those kinds are **reserved/speculative** with no +upstream builder. Buzz's **real, source-confirmed** structured-work primitive is the **workflow** +family — the command kinds are pinned against buzz-relay's Rust `command_executor.rs`: + +- **30620** workflow definition, **46020** trigger, **46001-46007** run/step lifecycle, +- **46010** approval-requested gate, **46030 / 46031** grant / deny. + +So the driving surface switched to workflows. What that buys over jobs: a **first-class +human-approval gate** (46010 → 46030/46031) baked into the protocol — the exact "anyone in the +channel can drive, but a human gates the merge" model the goal asks for — rather than relying on +GitHub branch-protection alone. + +**Divergence (documented):** on a real Buzz relay the *relay* parses the workflow YAML and executes +it, signing the lifecycle + approval events. Self-hosted on geode there is no workflow engine, so +**`amy` is the runner** (`amy buzz workflow run`) and emits the lifecycle events itself. The command +events (30620/46020/46030/46031) stay faithful to Buzz; only the lifecycle *content* shape is +Amethyst's (Buzz leaves it relay-defined). + +**Correlation:** the **run id is the trigger's event id and doubles as the approval token**, so a +grant's `d` tag equals the run id — no separate token bookkeeping. Two store realities shaped the +wire handling, both verified against geode: +- quartz's `SQLiteEventStore` routes every `#d` filter to the addressable `d_tag` column (NULL for a + regular kind like 46030), so **decisions are fetched by author** — every 46010 gate names its + approver in a `p` tag — and matched to their run by the token the aggregator reads off the event. +- The runner is **restart-safe**: runs still at the gate (AWAITING_APPROVAL / APPROVED / DENIED) are + rebuilt into the in-flight map from the run id on startup (worktree path + branch are + deterministic), so a decision arriving in a later poll — or a fresh `--once` process — still + resolves. The relay is the source of truth, not the in-memory map. + +**Landed (CLI + commons):** +- `commons/.../model/buzz/WorkflowRuns.kt` — `WorkflowRunAggregator` folds trigger + lifecycle + + grant/deny into per-run state (`WorkflowRunAggregatorTest`, 8 cases). +- `cli/.../commands/BuzzWorkflowCommands.kt` — `trigger` / `list` / `show` / `approve` / `deny` and + the **`run`** runner (agent work → 46010 gate → on grant runs `--on-approve` → 46005 completed; a + deny discards the worktree, run is DENIED). Wired into `amy buzz workflow`. +- `cli/tests/buzz/workflow-loop.sh` — end-to-end headless harness (alice triggers, bot runs, + carol approves/denies) through embedded geode; 14/14 green, including the deny path and + worktree cleanup. +- `cli/tests/buzz/agent-exec.sh` — covers the real `--exec` wrapper the loop harnesses stub out: + task → agent → commit → push → PR url, plus the paths that must fail (no diff, empty task, + missing scheduler env) and the default-branch guard, asserting `main` is left unmoved. Stubbed + `gh` + agent, so no network, credentials, or Claude Code; 19/19 green. + +**Landed (Android app):** +- `WorkflowRunBoardScreen` + `WorkflowRunBoardViewModel` (per channel, `Route.BuzzWorkflowBoard`, + entered from the `RelayGroupTopBar` overflow menu on Buzz relays). Folds the workflow kinds via + `WorkflowRunAggregator`, groups runs by state with **"Needs your approval" pinned first**, and the + named approver grants/denies a paused run inline (46030/46031). Merge stays on GitHub. +- `Account.triggerBuzzWorkflow` / `approveBuzzWorkflowRun` / `denyBuzzWorkflowRun` (same + sign → local-echo → publish-to-group-relay contract as the job helpers). +- `RelayGroupFilterBuilders` subscribes the `#h`-scoped workflow kinds; the board fetches the + `d`-only grant/deny decisions **by author** (the CLI's approach). +- `NotificationFeedFilter` — a 46010 gate addressed to me notifies and is **push-eligible** (added + to `NOTIFICATION_KINDS` + an `acceptableEvent` early-return gating on `approver() == me`). +- Backbone reused as-is: quartz `EventFactory` already registers the 46xxx kinds and `LocalCache` + already ingests them (store-only), so no protocol/ingest changes were needed. + +So the workflow **run board + approval gate is the P0-1 approvals surface** the mobile section below +anticipated. The jobs board/code stays for now, but the workflow path is the one matching Buzz +upstream. + +## Follow-ups + +1. Reconcile 43001-43006 with Buzz upstream once it defines the job protocol (or retire the job + path in favor of workflows). +2. Wire the P0 mobile screens (approvals inbox + jobs board) on top of `BuzzJobAggregator` / + `WorkflowRunAggregator`. +3. ✅ **Done** — a reference `--exec` wrapper (`tools/buzz-agent/agent-exec.sh` + README) runs + the coding agent in the job worktree, commits, pushes the feature branch, opens a PR with a + PR-only token, and prints the URL as the job result — with the branch-protection + token-scope + checklist documented. Verified end-to-end against a stubbed `gh`/agent. +4. Consider promoting the approval gate (46010) into the responder for irreversible steps. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 172139c893..5b14b8da61 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -822,6 +822,14 @@ private fun printUsage() { | buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline) | buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics | buzz personas [--relays R,R] list my kind-30175 personas + | buzz job request RELAY file an agent job (kind-43001) + | buzz job list/show/cancel RELAY … track agent jobs (43001-43006) + | buzz workflow trigger RELAY WFID … trigger a Buzz workflow run (kind-46020) + | buzz workflow run RELAY --exec CMD … run the workflow runner (agent work → approval gate) + | buzz workflow approve/deny RELAY RUNID grant/deny a run's approval gate (46030/46031) + | buzz agent up RELAY --repo DIR --approver NPUB one-command gated runner (bundled wrapper) + | buzz agent doctor [--repo DIR] preflight the host: gh token + branch protection + | buzz agent serve RELAY --exec CMD run a parallel backlog scheduler (worktree-isolated) | |Marmot (MLS group messaging): | marmot key-package publish publish a fresh KeyPackage diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt new file mode 100644 index 0000000000..590a5ee385 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt @@ -0,0 +1,717 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator +import com.vitorpamplona.amethyst.commons.model.buzz.JobState +import com.vitorpamplona.amethyst.commons.model.buzz.JobView +import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent +import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent +import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent +import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isValid +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Collections +import java.util.concurrent.TimeUnit + +/** + * `amy buzz agent …` — the AGENT side of the Buzz job protocol: a headless SCHEDULER that + * manages a shared backlog by itself. It watches a channel's job requests (kind-43001), + * orders them by the group's upvotes, and runs up to `--parallel N` at a time — each in its + * own git worktree/branch so concurrent runs never clobber each other — reporting every step + * back as accept/progress/result/error events (43002/43003/43004/43006) the whole room sees. + * + * This is the "shared channel where the team drives an AI to build Amethyst" model: + * **anyone in the channel files a job, the bot works them autonomously in parallel, and the + * only human gate left is the merge — which happens on GitHub, never here.** Point `--exec` + * at a coding agent (Claude Code via buzz-acp, a Goose/Codex wrapper, or a script): the job's + * task text is piped to its stdin, and it runs inside a fresh worktree whose branch is + * exported as `BUZZ_BRANCH`. The agent commits + pushes that branch and opens the PR; its + * stdout (e.g. the PR URL) becomes the job result. + * + * PERMISSIONS — Buzz authorizes by identity, not capability flags, so this is only as safe as: + * 1. INTAKE — `--accept-from` / `--accept-from-channel` gate WHO the bot obeys (the channel + * roster). Without either, it answers anyone who can post to the relay. + * 2. BLAST RADIUS — what `--exec` can DO to a repo is bounded by the credentials you give it, + * NOT by Buzz. Each job gets its own branch off `--base-ref`; the exec's git token should + * only open PRs on feature branches (never merge, never force-push), and `main` must be + * branch-protected. See `cli/plans/2026-07-25-buzz-agent-support-channel.md`. + * + * SCHEMA CAVEAT: kinds 43001-43006 are *reserved* in Buzz with no upstream builder; see + * [com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent]. + */ +object BuzzAgentCommands { + private val USAGE = + """ + |amy buzz agent up RELAY --repo DIR --approver NPUB one-command gated runner (recommended) + | [--channel GID] defaults to the relay's only channel + | [--base-ref REF] [--poll SECS] [--once] bundled wrapper; intake = channel members + |amy buzz agent doctor [--repo DIR] [--json] preflight: gh token scope + branch protection + |amy buzz agent serve RELAY --exec CMD run a backlog scheduler + | [--channel GID] only handle jobs scoped to this channel + | [--accept-from npub,npub] allowlist of requester keys + | [--accept-from-channel] obey any member of --channel (kind-39002 roster) + | [--claim-untargeted] also handle jobs with no `p` target + | [--parallel N] run up to N jobs at once (default 1) + | [--worktree REPODIR] base git repo; each job gets its own worktree+branch + | [--base-ref REF] branch base for worktrees (default HEAD) + | [--branch-prefix P] job branch prefix (default claude/job-) + | [--poll SECS] poll interval (default 5) + | [--exec-timeout SECS] kill --exec after N seconds (default 1800; 0 = none) + | [--timeout SECS] per-fetch relay timeout (default 8) + | [--no-progress] [--dry-run] [--once] + """.trimMargin() + + private val worktreeMutex = Mutex() // git worktree add/remove touch shared repo metadata + + // (worktreePath, branch) for jobs currently executing — a JVM shutdown hook force-removes + // these on Ctrl-C/kill, when coroutine `finally` blocks don't run. + private val activeWorktrees = Collections.synchronizedSet(mutableSetOf>()) + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "buzz agent", + tail, + USAGE, + mapOf( + "up" to { rest -> up(dataDir, rest) }, + "doctor" to { rest -> doctor(dataDir, rest) }, + "serve" to { rest -> serve(dataDir, rest) }, + ), + ) + + // ---- one-command bundles ------------------------------------------------- + + /** + * `amy buzz agent up RELAY --repo DIR --approver NPUB [--channel GID] …` — the low-ceremony way to + * put a **gated** agent runner on a channel. It resolves the channel (the relay's only one unless + * `--channel` is given), defaults the worktree to `--repo` and intake to the channel roster, + * extracts the bundled agent/ship wrappers, and hands off to `buzz workflow run`. Everything it + * defaults stays overridable there; this just removes the eight flags and the two scripts for the + * common case. The one thing it can't default is `--approver` — a human must own the gate. + */ + private suspend fun up( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz agent up RELAY --repo DIR --approver NPUB [--channel GID] [--base-ref REF] [--poll SECS] [--once]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val repo = args.flag("repo") ?: return Output.error("bad_args", "pass --repo DIR (your git checkout — the agent works and opens PRs here)") + if (!File(repo).resolve(".git").exists()) return Output.error("bad_args", "--repo is not a git repository: $repo") + val approver = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (the human who signs off each run)") + val baseRef = args.flag("base-ref") + val poll = args.flag("poll") + val timeout = args.flag("timeout") + val once = args.bool("once") + val explicitChannel = args.flag("channel") + args.rejectUnknown("repo", "approver", "channel", "base-ref", "poll", "timeout", "once") + + val channel = + explicitChannel + ?: Context.open(dataDir).use { ctx -> + ctx.prepare() + resolveSingleChannel(ctx, relay, timeout?.toLongOrNull() ?: 8) + ?: return Output.error("bad_args", "couldn't pick a channel automatically — pass --channel GID (this relay hosts none or several)") + } + + val (agentStep, shipStep) = extractWrappers() + + val runArgs = + buildList { + add(relayUrl) + add("--channel") + add(channel) + add("--approver") + add(approver) + add("--exec") + add(agentStep) + add("--on-approve") + add(shipStep) + add("--worktree") + add(repo) + add("--accept-from-channel") + baseRef?.let { + add("--base-ref") + add(it) + } + poll?.let { + add("--poll") + add(it) + } + timeout?.let { + add("--timeout") + add(it) + } + if (once) add("--once") + }.toTypedArray() + + System.err.println("[agent up] gated runner on ${relay.url} #$channel — repo $repo — approver $approver") + System.err.println("[agent up] wrappers: $agentStep + $shipStep (edit to customize, or re-run with your own --exec/--on-approve)") + return BuzzWorkflowCommands.runFromArgs(dataDir, runArgs) + } + + /** The relay's single hosted channel (its 39000 group id), or null when there are zero or many. */ + private suspend fun resolveSingleChannel( + ctx: Context, + relay: NormalizedRelayUrl, + timeoutSecs: Long, + ): String? = + ctx + .drain(mapOf(relay to listOf(Filter(kinds = listOf(GroupMetadataEvent.KIND)))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .mapNotNull { it.groupId() } + .distinct() + .singleOrNull() + + /** Extract the bundled gated wrappers to ~/.amy/buzz-agent and return (agentStepPath, shipStepPath). */ + private fun extractWrappers(): Pair { + val dir = File(System.getProperty("user.home"), ".amy/buzz-agent").apply { mkdirs() } + + fun extract(name: String): String { + val out = File(dir, name) + ( + BuzzAgentCommands::class.java.getResourceAsStream("/buzz-agent/$name") + ?: error("bundled wrapper /buzz-agent/$name missing from the amy jar") + ).use { input -> out.outputStream().use { input.copyTo(it) } } + out.setExecutable(true) + return out.absolutePath + } + return extract("workflow-agent.sh") to extract("workflow-ship.sh") + } + + /** + * `amy buzz agent doctor [--repo DIR]` — preflight the host safety the gate relies on: `gh` is + * authenticated and its token can write to the repo, the default branch is protected against + * force-push, and the worktree is a clean git checkout. Turns the tutorial's security checklist + * into a green/red report; exits non-zero if anything is off. Honours `--json` like every verb. + */ + private suspend fun doctor( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val repo = args.flag("repo") ?: System.getProperty("user.dir") + args.rejectUnknown("repo") + + val checks = mutableListOf>() + + fun check( + name: String, + ok: Boolean, + detail: String, + ) = checks.add(mapOf("name" to name, "ok" to ok, "detail" to detail)) + + val isRepo = File(repo).resolve(".git").exists() + check("git repo", isRepo, if (isRepo) repo else "$repo is not a git checkout") + if (isRepo) { + val status = git(repo, "status", "--porcelain") + check("worktree clean", status.stdout.isBlank(), if (status.stdout.isBlank()) "no uncommitted changes" else "uncommitted changes present") + } + + val ghAuth = runExec("gh auth status", "", emptyMap(), 20, repo) + val ghOk = ghAuth.exit == 0 + check("gh authenticated", ghOk, if (ghOk) "ok" else "run: gh auth login") + + if (ghOk) { + val perm = runExec("gh repo view --json viewerPermission -q .viewerPermission", "", emptyMap(), 20, repo).stdout.trim() + val canWrite = perm == "WRITE" || perm == "MAINTAIN" || perm == "ADMIN" + check("token can write to repo", canWrite, if (canWrite) "permission: $perm" else "permission: ${perm.ifBlank { "unknown" }} — needs Contents:RW + Pull requests:RW on this repo") + + val def = runExec("gh repo view --json defaultBranchRef -q .defaultBranchRef.name", "", emptyMap(), 20, repo).stdout.trim().ifBlank { "main" } + val prot = runExec("gh api repos/{owner}/{repo}/branches/$def/protection --jq .allow_force_pushes.enabled", "", emptyMap(), 20, repo) + val isProtected = prot.exit == 0 + val forcePushOff = prot.stdout.trim() == "false" + check( + "default branch protected ($def)", + isProtected && forcePushOff, + when { + !isProtected -> "'$def' has no branch protection — require a PR + reviews and block force-push" + !forcePushOff -> "'$def' allows force-push — disable it in branch protection" + else -> "protected; force-push blocked" + }, + ) + } + + val allOk = checks.all { it["ok"] == true } + Output.emit(mapOf("ok" to allOk, "repo" to repo, "checks" to checks)) + return if (allOk) 0 else 1 + } + + private class Opts( + val relay: NormalizedRelayUrl, + val exec: String?, + val channel: String?, + val claimUntargeted: Boolean, + val postProgress: Boolean, + val dryRun: Boolean, + val parallel: Int, + val pollSecs: Long, + val timeoutSecs: Long, + val execTimeoutSecs: Long, + val worktreeBase: String?, + val baseRef: String, + val branchPrefix: String, + ) + + private suspend fun serve( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", USAGE) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val dryRun = args.bool("dry-run") + val exec = args.flag("exec") + if (exec == null && !dryRun) return Output.error("bad_args", "pass --exec CMD (or --dry-run to test without running anything)") + val channel = args.flag("channel") + val claimUntargeted = args.bool("claim-untargeted") + val postProgress = !args.bool("no-progress") + val once = args.bool("once") + val parallel = args.flag("parallel")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val pollSecs = args.flag("poll")?.toLongOrNull() ?: 5 + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 + val execTimeoutSecs = args.flag("exec-timeout")?.toLongOrNull() ?: 1800 + val worktreeBase = args.flag("worktree") + val baseRef = args.flag("base-ref") ?: "HEAD" + val branchPrefix = args.flag("branch-prefix") ?: "claude/job-" + val fromChannel = args.bool("accept-from-channel") + val acceptFrom = + args + .flag("accept-from") + ?.split(",") + ?.mapNotNull { it.trim().ifBlank { null } } + ?.map { + decodePublicKeyAsHexOrNull(it)?.takeIf { hex -> hex.isValid() } + ?: return Output.error("bad_args", "invalid --accept-from key (npub or 64-char hex): $it") + }?.toMutableSet() + args.rejectUnknown( + "exec", + "channel", + "accept-from", + "accept-from-channel", + "claim-untargeted", + "parallel", + "worktree", + "base-ref", + "branch-prefix", + "poll", + "exec-timeout", + "timeout", + "no-progress", + "dry-run", + "once", + ) + + // Parallel runs share one working tree unless each gets its own worktree — that's a + // guaranteed clobber. Require --worktree once concurrency is on. + if (parallel > 1 && worktreeBase == null) { + return Output.error("bad_args", "--parallel > 1 needs --worktree REPODIR so concurrent jobs don't clobber one working tree") + } + if (fromChannel && channel == null) { + return Output.error("bad_args", "--accept-from-channel needs --channel GID") + } + if (worktreeBase != null && !File(worktreeBase).isDirectory) { + return Output.error("bad_args", "--worktree is not a directory: $worktreeBase") + } + + val opts = + Opts( + relay, + exec, + channel, + claimUntargeted, + postProgress, + dryRun, + parallel, + pollSecs, + timeoutSecs, + execTimeoutSecs, + worktreeBase, + baseRef, + branchPrefix, + ) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val me = ctx.identity.pubKeyHex + + if (worktreeBase != null && git(worktreeBase, "rev-parse", "--git-dir").exit != 0) { + return Output.error("bad_args", "--worktree is not a git repo: $worktreeBase") + } + + // Coroutine `finally` blocks don't run on a hard kill, so a Ctrl-C mid-job would leak + // its worktree + branch. Force-remove any still-active ones on JVM shutdown. + if (worktreeBase != null) { + Runtime.getRuntime().addShutdownHook( + Thread { + activeWorktrees.toList().forEach { (path, branch) -> + runCatching { ProcessBuilder("git", "-C", worktreeBase, "worktree", "remove", "--force", path).start().waitFor() } + runCatching { ProcessBuilder("git", "-C", worktreeBase, "branch", "-D", branch).start().waitFor() } + } + }, + ) + } + + // Resolve the intake allowlist: explicit --accept-from ∪ the channel's kind-39002 + // member roster (when --accept-from-channel). Null = obey anyone (no gate). + val allow: Set? = + if (fromChannel) { + val members = channelMembers(ctx, relay, channel!!, timeoutSecs) + (acceptFrom ?: mutableSetOf()).apply { addAll(members) } + } else { + acceptFrom + } + + // Seed the handled set so a restart doesn't re-run work already picked up. + val handled = mutableSetOf() + BuzzJobCommands.fetchJobs(ctx, relay, channel, timeoutSecs).forEach { job -> + if (job.state != JobState.REQUESTED) handled.add(job.jobId) + } + + if (once) return runOnce(ctx, me, opts, allow, handled) + + Output.emit( + mapOf( + "serving" to me, + "relay" to relay.url, + "channel" to channel, + "exec" to (exec ?: "(dry-run)"), + "parallel" to parallel, + "worktree" to worktreeBase, + "accept_from" to allow?.toList(), + "already_handled" to handled.size, + ), + ) + System.err.println("[agent] serving as $me on ${relay.url} — parallel=$parallel — Ctrl-C to stop") + runForever(ctx, me, opts, allow, handled) + } + @Suppress("UNREACHABLE_CODE") + return 0 + } + + /** One pass: launch every pending job (throttled to --parallel), wait for all, emit a summary. */ + private suspend fun runOnce( + ctx: Context, + me: HexKey, + opts: Opts, + allow: Set?, + handled: MutableSet, + ): Int { + val pending = selectPending(ctx, me, opts, allow, handled) + val done = mutableListOf>() + val doneMutex = Mutex() + coroutineScope { + val sem = Semaphore(opts.parallel) + pending.forEach { job -> + handled.add(job.jobId) + launch { + sem.withPermit { + // Isolate a throwing job so it can't cancel its siblings in this batch. + val r = + try { + handle(ctx, me, opts, job) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + mapOf("job_id" to job.jobId, "state" to "failed", "error" to (e.message ?: "exception")) + } + doneMutex.withLock { done.add(r) } + } + } + } + } + Output.emit(mapOf("relay" to opts.relay.url, "handled" to done.size, "jobs" to done)) + return 0 + } + + /** The long-running loop: keep the in-flight count at ≤ --parallel, launching by priority. */ + private suspend fun runForever( + ctx: Context, + me: HexKey, + opts: Opts, + allow: Set?, + handled: MutableSet, + ) { + supervisorScope { + val inflight = mutableSetOf() + val mutex = Mutex() + while (true) { + // A poll runs selectPending()/drain() directly in this scope; a transient relay + // error must not kill the unattended daemon, so isolate each poll and retry. + try { + val busy = mutex.withLock { inflight.toSet() } + val free = opts.parallel - busy.size + if (free > 0) { + val pending = selectPending(ctx, me, opts, allow, handled + busy).take(free) + pending.forEach { job -> + handled.add(job.jobId) + mutex.withLock { inflight.add(job.jobId) } + launch { + try { + val r = handle(ctx, me, opts, job) + System.err.println("[agent] ${r["state"]} job ${job.jobId.take(12)}… (${job.upvotes} upvotes)") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + System.err.println("[agent] job ${job.jobId.take(12)}… errored: ${e.message}") + } finally { + mutex.withLock { inflight.remove(job.jobId) } + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + System.err.println("[agent] poll error: ${e.message} — retrying in ${opts.pollSecs}s") + } + delay(opts.pollSecs * 1000) + } + } + } + + /** REQUESTED jobs targeting me, from an allowed requester, not already taken — priority-ordered. */ + private suspend fun selectPending( + ctx: Context, + me: HexKey, + opts: Opts, + allow: Set?, + exclude: Set, + ): List = + BuzzJobAggregator.byPriority( + BuzzJobCommands + .fetchJobs(ctx, opts.relay, opts.channel, opts.timeoutSecs) + .filter { it.state == JobState.REQUESTED && it.jobId !in exclude } + .filter { targetedAtMe(it, me, opts.claimUntargeted) } + .filter { allow == null || it.requester in allow }, + ) + + /** A REQUESTED job is mine to handle if it `p`-targets me, or has no target and I opted in. */ + private fun targetedAtMe( + job: JobView, + me: HexKey, + claimUntargeted: Boolean, + ): Boolean = + when (job.agent) { + me -> true + null -> claimUntargeted + else -> false + } + + /** Accept → (worktree) → (progress) → run --exec → result/error. Returns a summary row. */ + private suspend fun handle( + ctx: Context, + me: HexKey, + opts: Opts, + job: JobView, + ): Map { + val channel = job.channel + publish(ctx, opts.relay, JobAcceptedEvent.build(job.jobId, channel, job.requester, "picked up by amy")) + + if (opts.dryRun) { + publish(ctx, opts.relay, JobResultEvent.build(job.jobId, "[dry-run] would run: ${opts.exec ?: "(none)"}", channel, job.requester, "completed")) + return mapOf("job_id" to job.jobId, "state" to "completed", "dry_run" to true) + } + + // Each job gets its own worktree+branch off base-ref so N run without collision. + val short = job.jobId.take(12) + val branch = opts.branchPrefix + short + var workdir: String? = null + var worktreePath: String? = null + try { + if (opts.worktreeBase != null) { + val wt = File(System.getProperty("java.io.tmpdir"), "buzz-worktrees/$short") + worktreePath = wt.absolutePath + val add = + worktreeMutex.withLock { + wt.parentFile?.mkdirs() + // Clear anything a crashed prior run left behind for this exact job id. `-B` + // (reset-or-create) makes the branch idempotent so a leftover branch from a + // hard-killed run doesn't make the job permanently un-runnable. + git(opts.worktreeBase, "worktree", "prune") + wt.deleteRecursively() + git(opts.worktreeBase, "worktree", "add", "-B", branch, worktreePath, opts.baseRef) + } + if (add.exit != 0) { + publish(ctx, opts.relay, JobErrorEvent.build(job.jobId, "worktree setup failed: ${add.stderr.take(MAX_BODY)}", channel, job.requester, "error")) + return mapOf("job_id" to job.jobId, "state" to "failed", "error" to "worktree") + } + workdir = worktreePath + activeWorktrees.add(worktreePath to branch) + } + + if (opts.postProgress) { + publish(ctx, opts.relay, JobProgressEvent.build(job.jobId, "working on $branch…", channel, "running")) + } + val env = + buildMap { + put("BUZZ_JOB_ID", job.jobId) + job.requester?.let { put("BUZZ_REQUESTER", it) } + channel?.let { put("BUZZ_CHANNEL", it) } + put("BUZZ_RELAY", opts.relay.url) + put("BUZZ_AGENT", me) + put("BUZZ_UPVOTES", job.upvotes.toString()) + if (opts.worktreeBase != null) { + put("BUZZ_BRANCH", branch) + put("BUZZ_WORKTREE", worktreePath!!) + put("BUZZ_BASE_REF", opts.baseRef) + } + } + val run = runExec(opts.exec!!, job.request ?: "", env, opts.execTimeoutSecs, workdir) + + return if (run.exit == 0) { + val body = run.stdout.ifBlank { "(no output)" } + publish(ctx, opts.relay, JobResultEvent.build(job.jobId, body.take(MAX_BODY), channel, job.requester, "completed")) + mapOf("job_id" to job.jobId, "state" to "completed", "exit" to 0, "branch" to if (opts.worktreeBase != null) branch else null) + } else { + val body = (run.stderr.ifBlank { run.stdout }).ifBlank { "exited ${run.exit}" } + publish(ctx, opts.relay, JobErrorEvent.build(job.jobId, body.take(MAX_BODY), channel, job.requester, "error")) + mapOf("job_id" to job.jobId, "state" to "failed", "exit" to run.exit) + } + } finally { + // Drop the worktree; the branch stays in the base repo (the exec pushed it). Runs on + // normal completion AND on a failed/early-return worktree setup (removal no-ops if the + // worktree was never created). + if (worktreePath != null) { + worktreeMutex.withLock { git(opts.worktreeBase!!, "worktree", "remove", "--force", worktreePath) } + activeWorktrees.remove(worktreePath to branch) + } + } + } + + /** Latest kind-39002 roster for the channel → its member pubkeys. Empty if none served. */ + private suspend fun channelMembers( + ctx: Context, + relay: NormalizedRelayUrl, + channel: String, + timeoutSecs: Long, + ): Set { + val filter = Filter(kinds = listOf(GroupMembersEvent.KIND), tags = mapOf("d" to listOf(channel))) + return ctx + .drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .maxByOrNull { it.createdAt } + ?.members() + ?.toSet() + .orEmpty() + } + + private class ExecResult( + val exit: Int, + val stdout: String, + val stderr: String, + ) + + /** Run `sh -c CMD` in [workdir], piping [input] to stdin and exporting [env]; capture both streams. */ + private suspend fun runExec( + cmd: String, + input: String, + env: Map, + timeoutSecs: Long, + workdir: String?, + ): ExecResult = + withContext(Dispatchers.IO) { + val pb = ProcessBuilder("sh", "-c", cmd) + workdir?.let { pb.directory(File(it)) } + pb.environment().putAll(env) + val proc = pb.start() + coroutineScope { + // Drain stdout and stderr on their own coroutines and feed stdin on another, so a + // child that fills one pipe while we block on the other can't deadlock. The timeout + // is enforced by waitFor (NOT by the reads, which have none): on expiry we + // destroyForcibly, which closes the child's pipes and lets the readers finish. + val outDeferred = async { proc.inputStream.readBytes().decodeToString() } + val errDeferred = async { proc.errorStream.readBytes().decodeToString() } + launch { runCatching { proc.outputStream.use { it.write(input.encodeToByteArray()) } } } + + val finished = + if (timeoutSecs > 0) { + proc.waitFor(timeoutSecs, TimeUnit.SECONDS) + } else { + proc.waitFor() + true + } + if (!finished) proc.destroyForcibly() + + val out = outDeferred.await() + val err = errDeferred.await() + if (finished) ExecResult(proc.exitValue(), out, err) else ExecResult(124, out, "exec timed out after ${timeoutSecs}s") + } + } + + /** Run `git -C dir args…`, capturing exit + both streams. */ + private suspend fun git( + dir: String, + vararg gitArgs: String, + ): ExecResult = + withContext(Dispatchers.IO) { + val proc = ProcessBuilder(listOf("git", "-C", dir) + gitArgs).start() + coroutineScope { + // Drain both pipes concurrently (same rationale as runExec) before waiting. + val outDeferred = async { proc.inputStream.readBytes().decodeToString() } + val errDeferred = async { proc.errorStream.readBytes().decodeToString() } + proc.waitFor() + ExecResult(proc.exitValue(), outDeferred.await(), errDeferred.await()) + } + } + + private suspend fun publish( + ctx: Context, + relay: NormalizedRelayUrl, + template: EventTemplate, + ) { + val signed = ctx.signer.sign(template) + ctx.publish(signed, setOf(relay)) + } + + private const val MAX_BODY = 60_000 +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index 60562864dc..8d1f37a498 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -80,6 +80,9 @@ object BuzzCommands { |amy buzz dm open RELAY PUBKEY [PUBKEY…] open a DM with 1-8 people (kind-41010) |amy buzz dm hide RELAY CHANNEL hide a DM from my sidebar (kind-41012) |amy buzz dm add-member RELAY CHANNEL PUBKEY add a member to a group DM (kind-41011) + |amy buzz job … file/list/show/cancel agent jobs (43001-43006) + |amy buzz workflow … trigger/run/approve Buzz workflows (30620/46020/46030) + |amy buzz agent serve RELAY --exec CMD run a parallel backlog scheduler (worktree-isolated) """.trimMargin() suspend fun dispatch( @@ -98,6 +101,9 @@ object BuzzCommands { "console" to { rest -> console(dataDir, rest) }, "personas" to { rest -> personas(dataDir, rest) }, "dm" to { rest -> dm(dataDir, rest) }, + "job" to { rest -> BuzzJobCommands.dispatch(dataDir, rest) }, + "workflow" to { rest -> BuzzWorkflowCommands.dispatch(dataDir, rest) }, + "agent" to { rest -> BuzzAgentCommands.dispatch(dataDir, rest) }, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzJobCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzJobCommands.kt new file mode 100644 index 0000000000..e2b6cd6c00 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzJobCommands.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator +import com.vitorpamplona.amethyst.commons.model.buzz.JobState +import com.vitorpamplona.amethyst.commons.model.buzz.JobView +import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent +import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.isValid +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + +/** + * `amy buzz job …` — the requester side of the Buzz agent-job protocol (kinds + * 43001-43006): ask an agent to do a task, list jobs, inspect one job's lifecycle, and + * cancel. The agent side (accept/progress/result/error, plus a driving loop) lives in + * [BuzzAgentCommands]. Job correlation + state folding is the shared, tested + * [BuzzJobAggregator] in `commons`, so this file stays a thin assembly layer. + * + * SCHEMA CAVEAT: 43001-43006 are *reserved* in Buzz with no upstream builder; the tag + * layout is Quartz's best-effort model (see [JobRequestEvent]). + */ +object BuzzJobCommands { + private val USAGE = + """ + |amy buzz job request RELAY file a job (kind-43001) + | [--agent PUBKEY] [--channel GID] target agent (p) / channel scope (h) + |amy buzz job list RELAY [--channel GID] list jobs and their state + | [--mine|--assigned] [--limit N] [--timeout SECS] + |amy buzz job show RELAY JOBID [--timeout SECS] show one job's full lifecycle + |amy buzz job cancel RELAY JOBID [--reason R] cancel a job (kind-43005) + | [--channel GID] + """.trimMargin() + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "buzz job", + tail, + USAGE, + mapOf( + "request" to { rest -> request(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "cancel" to { rest -> cancel(dataDir, rest) }, + ), + ) + + /** `buzz job request RELAY [--agent PUBKEY] [--channel GID]` → publishes a kind-43001. */ + private suspend fun request( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz job request RELAY [--agent PUBKEY] [--channel GID]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val text = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + if (text.isBlank()) return Output.error("bad_args", "job text must not be blank") + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val channel = args.flag("channel") + val agent = + args.flag("agent")?.let { + decodePublicKeyAsHexOrNull(it.trim())?.takeIf { hex -> hex.isValid() } + ?: return Output.error("bad_args", "invalid agent public key (npub or 64-char hex): $it") + } + args.rejectUnknown("agent", "channel") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(JobRequestEvent.build(text, channel, agent)) + val ack = ctx.publish(signed, setOf(relay)) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "job_id" to signed.id, + "kind" to signed.kind, + "relay" to relay.url, + "agent" to agent, + "channel" to channel, + "published" to ack.values.any { it.accepted }, + ), + ) + return 0 + } + } + + /** + * `buzz job list RELAY [--channel GID] [--mine|--assigned] [--limit N] [--timeout SECS]` → + * drains the job kinds and folds them into per-job state. `--mine` keeps jobs I + * requested; `--assigned` keeps jobs targeting me; default shows both. + */ + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz job list RELAY [--channel GID] [--mine|--assigned] [--limit N] [--timeout SECS]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val channel = args.flag("channel") + val mine = args.bool("mine") + val assigned = args.bool("assigned") + val limit = args.flag("limit")?.toIntOrNull() ?: 50 + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 + args.rejectUnknown("channel", "mine", "assigned", "limit", "timeout") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val me = ctx.identity.pubKeyHex + val jobs = + fetchJobs(ctx, relay, channel, timeoutSecs) + .filter { job -> + when { + mine && !assigned -> job.requester == me + assigned && !mine -> job.agent == me + else -> true + } + }.take(limit) + Output.emit(mapOf("relay" to relay.url, "count" to jobs.size, "jobs" to jobs.map { it.toRow() })) + return 0 + } + } + + /** `buzz job show RELAY JOBID [--timeout SECS]` → the full folded lifecycle of one job. */ + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz job show RELAY JOBID [--timeout SECS]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val jobId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 + args.rejectUnknown("timeout") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + // Fetch the request by id, every reply that references it via `e`, and every + // upvote (kind-7 reaction) targeting it. + val filters = + listOf( + Filter(kinds = JOB_KINDS, ids = listOf(jobId)), + Filter(kinds = JOB_REPLY_KINDS + ReactionEvent.KIND, tags = mapOf("e" to listOf(jobId))), + ) + val events = + ctx + .drain(mapOf(relay to filters), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + val job = + BuzzJobAggregator.aggregate(events).firstOrNull { it.jobId == jobId } + ?: return Output.error("not_found", "no job $jobId on ${relay.url}") + Output.emit(job.toRow() + mapOf("relay" to relay.url)) + return 0 + } + } + + /** `buzz job cancel RELAY JOBID [--reason R] [--channel GID]` → publishes a kind-43005. */ + private suspend fun cancel( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz job cancel RELAY JOBID [--reason R] [--channel GID]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val jobId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val reason = args.flag("reason") ?: "" + val channel = args.flag("channel") + args.rejectUnknown("reason", "channel") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(JobCancelEvent.build(jobId, reason, channel)) + val ack = ctx.publish(signed, setOf(relay)) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "job_id" to jobId, + "relay" to relay.url, + "published" to ack.values.any { it.accepted }, + ), + ) + return 0 + } + } + + /** + * Drain every job kind (optionally channel-scoped) plus, when a channel is given, its + * kind-7 upvotes, and fold via the shared aggregator. Upvotes are only fetched with a + * channel scope — a bare kind-7 query would pull the relay's entire reaction firehose. + */ + internal suspend fun fetchJobs( + ctx: Context, + relay: NormalizedRelayUrl, + channel: String?, + timeoutSecs: Long, + ): List { + val tags = channel?.let { mapOf("h" to listOf(it)) } + val filters = + buildList { + add(Filter(kinds = JOB_KINDS, tags = tags)) + if (channel != null) add(Filter(kinds = listOf(ReactionEvent.KIND), tags = tags)) + } + val events = + ctx + .drain(mapOf(relay to filters), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + return BuzzJobAggregator.aggregate(events) + } + + private fun JobView.toRow(): Map = + mapOf( + "job_id" to jobId, + "state" to state.name.lowercase(), + "requester" to requester, + "agent" to agent, + "channel" to channel, + "request" to request, + "upvotes" to upvotes, + "progress_updates" to progressUpdates, + "last_progress" to lastProgress, + "result" to result, + "error" to error, + "cancel_reason" to cancelReason, + "created_at" to createdAt, + "updated_at" to updatedAt, + ) + + internal val JOB_KINDS = (43001..43006).toList() + internal val JOB_REPLY_KINDS = (43002..43006).toList() + + /** The terminal states — jobs a responder should never re-handle. */ + internal val TERMINAL = setOf(JobState.COMPLETED, JobState.FAILED, JobState.CANCELLED) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt new file mode 100644 index 0000000000..5196584d1b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt @@ -0,0 +1,611 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRun +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunState +import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +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.WorkflowFailedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent +import com.vitorpamplona.quartz.buzz.workflow.workflowChannel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isValid +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +/** + * `amy buzz workflow …` — Buzz's **source-confirmed** structured-work + human-approval primitive + * (kinds 30620 def, 46020 trigger, 46010 approval-requested, 46030/46031 grant/deny, 46001-46007 + * lifecycle), replacing the speculative agent-job protocol (43001-43006). + * + * On a real Buzz relay the RELAY parses the workflow YAML, runs the steps, and signs the + * lifecycle + approval events. Self-hosted on geode there is no workflow engine, so **`amy` is + * the runner** (`workflow run`) and emits the lifecycle events itself — a documented divergence. + * The approval gate is faithful: the runner does the agent's work, pauses on a 46010 (addressed to + * an approver), and only pushes/opens the PR after a human publishes a 46030 grant (46031 = deny). + * + * Correlation is simple: the **run id is the trigger's event id, and it doubles as the approval + * token**, so an `ApprovalGrant`'s `d` tag equals the run id. Run/step folding is the shared + * [WorkflowRunAggregator] in `commons`. + */ +object BuzzWorkflowCommands { + private val json = Json { ignoreUnknownKeys = true } + + private val USAGE = + """ + |amy buzz workflow trigger RELAY WFID --task TEXT --channel GID start a run (kind-46020) + |amy buzz workflow list RELAY --channel GID [--timeout SECS] list runs + their state + |amy buzz workflow show RELAY RUNID [--timeout SECS] one run's lifecycle + |amy buzz workflow approve RELAY RUNID [--note N] grant the approval gate (46030) + |amy buzz workflow deny RELAY RUNID [--note N] deny the approval gate (46031) + |amy buzz workflow run RELAY --exec CMD --channel GID run the workflow runner + | --approver NPUB [--on-approve CMD] [--worktree REPODIR] agent work → 46010 gate → + | [--base-ref REF] [--accept-from npub,…] on grant: --on-approve → 46005 + | [--accept-from-channel] [--poll SECS] [--once] (--worktree defaults to cwd; + | --accept-from-channel = obey members) + """.trimMargin() + + /** Entry point for `amy buzz agent up` — build the flag list, then reuse the runner below. */ + internal suspend fun runFromArgs( + dataDir: DataDir, + rest: Array, + ): Int = run(dataDir, rest) + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "buzz workflow", + tail, + USAGE, + mapOf( + "trigger" to { rest -> trigger(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "approve" to { rest -> decide(dataDir, rest, grant = true) }, + "deny" to { rest -> decide(dataDir, rest, grant = false) }, + "run" to { rest -> run(dataDir, rest) }, + ), + ) + + // ---- requester side ------------------------------------------------------ + + /** `workflow trigger RELAY WFID --task TEXT --channel GID` → publishes a kind-46020. */ + private suspend fun trigger( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz workflow trigger RELAY WFID --task TEXT --channel GID" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val wfId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val task = args.flag("task")?.takeIf { it.isNotBlank() } ?: return Output.error("bad_args", "pass --task TEXT") + val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID") + args.rejectUnknown("task", "channel") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val content = json.encodeToString(WorkflowRunPayload(task = task, workflow = wfId)) + val signed = ctx.signer.sign(WorkflowTriggerEvent.build(wfId, content) { workflowChannel(channel) }) + val ack = ctx.publish(signed, setOf(relay)) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "run_id" to signed.id, // the trigger id IS the run id and the approval token + "workflow" to wfId, + "channel" to channel, + "relay" to relay.url, + "published" to ack.values.any { it.accepted }, + ), + ) + return 0 + } + } + + /** `workflow approve|deny RELAY RUNID [--note N]` → publishes a kind-46030/46031 with `d` = run id. */ + private suspend fun decide( + dataDir: DataDir, + rest: Array, + grant: Boolean, + ): Int { + val args = Args(rest) + val verb = if (grant) "approve" else "deny" + val usage = "buzz workflow $verb RELAY RUNID [--note N]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val runId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val note = args.flag("note") ?: "" + args.rejectUnknown("note") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = if (grant) ApprovalGrantEvent.build(runId, note) else ApprovalDenyEvent.build(runId, note) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, setOf(relay)) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit(mapOf("event_id" to signed.id, "kind" to signed.kind, "run_id" to runId, "decision" to verb, "published" to ack.values.any { it.accepted })) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz workflow list RELAY --channel GID [--timeout SECS]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID") + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 + args.rejectUnknown("channel", "timeout") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val runs = WorkflowRunAggregator.byPriority(fetchRuns(ctx, relay, channel, timeoutSecs)) + Output.emit(mapOf("relay" to relay.url, "count" to runs.size, "runs" to runs.map { it.toRow() })) + return 0 + } + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz workflow show RELAY RUNID [--timeout SECS]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val runId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 + args.rejectUnknown("timeout") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + // The run id is the trigger's id; the lifecycle events carry the run id only in their JSON + // `content` (+ an `h` channel tag), so we can't query them by `#e`/`#d`. Resolve the channel + // from the trigger, then fold the whole channel — the same path `list` takes. + val trigger = + ctx + .drain(mapOf(relay to listOf(Filter(kinds = listOf(WorkflowTriggerEvent.KIND), ids = listOf(runId)))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .firstOrNull { it.id == runId } + ?: return Output.error("not_found", "no workflow trigger $runId on ${relay.url}") + val channel = trigger.tags.workflowChannel() ?: return Output.error("not_found", "trigger $runId has no channel") + val run = + fetchRuns(ctx, relay, channel, timeoutSecs).firstOrNull { it.runId == runId } + ?: return Output.error("not_found", "no run $runId on ${relay.url}") + Output.emit(run.toRow() + mapOf("relay" to relay.url)) + return 0 + } + } + + /** + * All workflow events on a channel, folded into runs. Two-phase: the trigger + lifecycle events + * are scoped by the channel `h` tag, but the approval grant/deny events (46030/46031) reference + * their run only by a `d` tag (= the token = the run id) — and quartz's shared event store routes + * every `#d` filter to the addressable `d_tag` column, which is NULL for a regular kind like + * 46030, so they can't be fetched by `#d`. Instead we fetch them by **author** — every 46010 gate + * names its approver in a `p` tag, so those are exactly the keys that can sign a decision — and + * the aggregator matches each decision to its run by the `d`-tag token it reads off the event. + */ + private suspend fun fetchRuns( + ctx: Context, + relay: NormalizedRelayUrl, + channel: String, + timeoutSecs: Long, + ): List { + val base = + ctx + .drain(mapOf(relay to listOf(Filter(kinds = listOf(WorkflowTriggerEvent.KIND) + LIFECYCLE_KINDS, tags = mapOf("h" to listOf(channel))))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + val approvers = + base + .filterIsInstance() + .mapNotNull { it.approver() } + .distinct() + val decisions = + if (approvers.isEmpty()) { + emptyList() + } else { + ctx + .drain(mapOf(relay to listOf(Filter(kinds = DECISION_KINDS, authors = approvers))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + } + return WorkflowRunAggregator.aggregate(base + decisions) + } + + private fun WorkflowRun.toRow(): Map = + mapOf( + "run_id" to runId, + "state" to state.name.lowercase(), + "workflow" to workflowId, + "channel" to channel, + "task" to task, + "requester" to requester, + "pending_approver" to pendingApprover, + "approval_token" to if (state == WorkflowRunState.AWAITING_APPROVAL) approvalToken else null, + "result" to result, + "error" to error, + "last_step" to lastStep, + "created_at" to createdAt, + "updated_at" to updatedAt, + ) + + // ---- runner -------------------------------------------------------------- + + private class AwaitingRun( + val channel: String, + val requester: HexKey?, + val worktree: String?, + val branch: String?, + ) + + /** The worktree path/branch for a run are deterministic from its id, so a restarted runner + * (or a `--once` resolve pass in a fresh process) can rebuild the [AwaitingRun] it lost. */ + private fun worktreeDirFor(runId: HexKey): File = File(System.getProperty("java.io.tmpdir"), "buzz-runs/${runId.take(12)}") + + private fun branchFor(runId: HexKey): String = "claude/run-${runId.take(12)}" + + private suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", USAGE) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val exec = args.flag("exec") ?: return Output.error("bad_args", "pass --exec CMD (the agent's work step)") + val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID") + val approverInput = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (who signs off the gate)") + val approver = + decodePublicKeyAsHexOrNull(approverInput.trim())?.takeIf { it.isValid() } + ?: return Output.error("bad_args", "invalid --approver key: $approverInput") + val onApprove = args.flag("on-approve") // push + open PR; runs in the worktree after grant + // The worktree defaults to the current directory: the runner isolates each run in its own + // git worktree off it, and the common case is "run me inside my checkout." Pass --worktree to + // point elsewhere. + val worktreeBase = args.flag("worktree") ?: System.getProperty("user.dir") + val baseRef = args.flag("base-ref") ?: "HEAD" + val once = args.bool("once") + val pollSecs = args.flag("poll")?.toLongOrNull() ?: 5 + val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 + val fromChannel = args.bool("accept-from-channel") + val explicitAccept = + args + .flag("accept-from") + ?.split(",") + ?.mapNotNull { it.trim().ifBlank { null } } + ?.map { + decodePublicKeyAsHexOrNull(it)?.takeIf { hex -> hex.isValid() } ?: return Output.error("bad_args", "invalid --accept-from key: $it") + }?.toSet() + args.rejectUnknown("exec", "channel", "approver", "on-approve", "worktree", "base-ref", "once", "poll", "timeout", "accept-from", "accept-from-channel") + + if (!File(worktreeBase).isDirectory) return Output.error("bad_args", "--worktree is not a directory: $worktreeBase") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val me = ctx.identity.pubKeyHex + + // Intake allowlist: explicit --accept-from ∪ the channel's kind-39002 roster (when + // --accept-from-channel). Null = obey anyone who can post to the relay. On a shared + // community you own no relay for, --accept-from-channel scopes the agent to members. + val acceptFrom: Set? = + if (fromChannel) { + (explicitAccept ?: emptySet()) + channelMembers(ctx, relay, channel, timeoutSecs) + } else { + explicitAccept + } + val started = mutableSetOf() // triggers we've begun + val awaiting = mutableMapOf() // runId -> worktree while at the gate + val decided = mutableSetOf() + + // Seed from existing runs so a restart doesn't re-run finished work. A run the runner has + // already carried to a terminal outcome (COMPLETED/FAILED/CANCELLED) is done. A run still + // needing the runner to act — parked at the gate (AWAITING_APPROVAL), granted-but-not-yet + // -shipped (APPROVED), or denied-but-its-worktree-still-around (DENIED) — is rebuilt into + // `awaiting` from its run id (the worktree/branch are deterministic) so a decision arriving + // in a later poll (or a fresh `--once` process) still resolves. The in-memory `awaiting` + // map is a cache, not the source of truth — the relay is. + fetchRuns(ctx, relay, channel, timeoutSecs).forEach { runv -> + if (runv.state != WorkflowRunState.TRIGGERED) started.add(runv.runId) + val ch = runv.channel + when (runv.state) { + WorkflowRunState.COMPLETED, WorkflowRunState.FAILED, WorkflowRunState.CANCELLED -> + decided.add(runv.runId) + WorkflowRunState.AWAITING_APPROVAL, WorkflowRunState.APPROVED, WorkflowRunState.DENIED -> + if (ch != null) { + awaiting[runv.runId] = + AwaitingRun( + channel = ch, + requester = runv.requester, + worktree = worktreeBase?.let { worktreeDirFor(runv.runId).absolutePath }, + branch = worktreeBase?.let { branchFor(runv.runId) }, + ) + } + WorkflowRunState.TRIGGERED, WorkflowRunState.RUNNING -> Unit + } + } + + if (!once) { + Output.emit(mapOf("running" to me, "relay" to relay.url, "channel" to channel, "approver" to approver, "seeded" to started.size)) + System.err.println("[workflow] runner up on ${relay.url} #$channel — gate → $approver — Ctrl-C to stop") + } + + val summary = mutableListOf>() + while (true) { + // 1. Start new triggers (agent work → 46010 gate). + fetchRuns(ctx, relay, channel, timeoutSecs) + .filter { it.state == WorkflowRunState.TRIGGERED && it.runId !in started } + .filter { acceptFrom == null || it.requester in acceptFrom } + .forEach { runv -> + started.add(runv.runId) + val a = startRun(ctx, relay, me, runv, exec, approver, worktreeBase, baseRef, timeoutSecs) + if (a != null) awaiting[runv.runId] = a else decided.add(runv.runId) + summary.add(mapOf("run_id" to runv.runId, "stage" to if (a != null) "awaiting_approval" else "failed")) + } + + // 2. Resolve gates whose decision has arrived. Grants/denies reference the run only by + // a `d`-tag token, which quartz's store can't serve via `#d` on a regular kind, so we + // fetch the approver's decisions by author and match the token to a run at the gate. + val decisions = + if (awaiting.isEmpty()) { + emptyList() + } else { + ctx.drain(mapOf(relay to listOf(Filter(kinds = DECISION_KINDS, authors = listOf(approver)))), timeoutSecs * 1000, pendingOnAuthRequired = true).map { it.second } + } + decisions.forEach { d -> + val (runId, granted) = + when (d) { + is ApprovalGrantEvent -> (d.tokenHash() ?: return@forEach) to true + is ApprovalDenyEvent -> (d.tokenHash() ?: return@forEach) to false + else -> return@forEach + } + val a = awaiting[runId] ?: return@forEach + if (runId in decided) return@forEach + decided.add(runId) + awaiting.remove(runId) + resolve(ctx, relay, runId, a, granted, onApprove, worktreeBase, timeoutSecs) + summary.add(mapOf("run_id" to runId, "stage" to if (granted) "completed" else "denied")) + if (!once) System.err.println("[workflow] ${if (granted) "granted → shipped" else "denied"} run ${runId.take(12)}…") + } + + if (once) { + Output.emit(mapOf("relay" to relay.url, "handled" to summary.size, "runs" to summary)) + return 0 + } + delay(pollSecs * 1000) + } + } + @Suppress("UNREACHABLE_CODE") + return 0 + } + + /** Emit 46001/46002, run --exec in a worktree, emit 46003 + the 46010 gate. Null if the work failed. */ + private suspend fun startRun( + ctx: Context, + relay: NormalizedRelayUrl, + me: HexKey, + runv: WorkflowRun, + exec: String, + approver: HexKey, + worktreeBase: String?, + baseRef: String, + timeoutSecs: Long, + ): AwaitingRun? { + val channel = runv.channel ?: return null + val runId = runv.runId + emit(ctx, relay, WorkflowTriggeredEvent.build(channel, payload(WorkflowRunPayload(run = runId, workflow = runv.workflowId, task = runv.task)))) + emit(ctx, relay, WorkflowStepStartedEvent.build(channel, payload(WorkflowRunPayload(run = runId, step = "build")))) + + var workdir: String? = null + var branch: String? = null + if (worktreeBase != null) { + branch = branchFor(runId) + val wt = worktreeDirFor(runId) + wt.parentFile?.mkdirs() + git(worktreeBase, "worktree", "prune") + wt.deleteRecursively() + val add = git(worktreeBase, "worktree", "add", "-B", branch, wt.absolutePath, baseRef) + if (add.exit != 0) { + emit(ctx, relay, WorkflowFailedEvent.build(channel, payload(WorkflowRunPayload(run = runId, error = "worktree: ${add.err.take(500)}")))) + return null + } + workdir = wt.absolutePath + } + + val env = + buildMap { + put("BUZZ_RUN", runId) + put("BUZZ_CHANNEL", channel) + put("BUZZ_RELAY", relay.url) + put("BUZZ_AGENT", me) + runv.requester?.let { put("BUZZ_REQUESTER", it) } + branch?.let { put("BUZZ_BRANCH", it) } + workdir?.let { put("BUZZ_WORKTREE", it) } + put("BUZZ_BASE_REF", baseRef) + } + val work = exec(exec, runv.task ?: "", env, workdir) + if (work.exit != 0) { + emit(ctx, relay, WorkflowFailedEvent.build(channel, payload(WorkflowRunPayload(run = runId, error = (work.err.ifBlank { work.out }).take(MAX))))) + return null + } + emit(ctx, relay, WorkflowStepCompletedEvent.build(channel, payload(WorkflowRunPayload(run = runId, step = "build")))) + // Pause on the approval gate — a human reviews the work before it ships. + emit(ctx, relay, WorkflowApprovalRequestedEvent.build(channel, approver, payload(WorkflowRunPayload(run = runId, note = work.out.take(1000))))) + return AwaitingRun(channel, runv.requester, workdir, branch) + } + + /** On grant: run --on-approve (push + PR) and emit 46005 with its output. On deny: just discard. */ + private suspend fun resolve( + ctx: Context, + relay: NormalizedRelayUrl, + runId: HexKey, + a: AwaitingRun, + granted: Boolean, + onApprove: String?, + worktreeBase: String?, + timeoutSecs: Long, + ) { + try { + // A deny (46031) is itself the terminal signal — the aggregator folds it to DENIED — so the + // runner only discards the unshipped work (the `finally` removes the worktree); emitting a + // competing 46007 cancelled would just race the deny for "newest terminal". + if (!granted) return + val prUrl = + if (onApprove != null) { + val env = + buildMap { + put("BUZZ_RUN", runId) + a.branch?.let { put("BUZZ_BRANCH", it) } + a.worktree?.let { put("BUZZ_WORKTREE", it) } + } + val r = exec(onApprove, "", env, a.worktree) + if (r.exit != 0) { + emit(ctx, relay, WorkflowFailedEvent.build(a.channel, payload(WorkflowRunPayload(run = runId, error = "on-approve: ${(r.err.ifBlank { r.out }).take(MAX)}")))) + return + } + r.out.trim().takeIf { it.isNotBlank() } + } else { + null + } + emit(ctx, relay, WorkflowCompletedEvent.build(a.channel, payload(WorkflowRunPayload(run = runId, pr = prUrl)))) + } finally { + // The worktree lives under the runner's tmpdir but *belongs* to the repo at worktreeBase, + // so `git worktree remove` must run against that repo, not the worktree's own parent path. + if (a.worktree != null && worktreeBase != null) { + git(worktreeBase, "worktree", "remove", "--force", a.worktree) + git(worktreeBase, "worktree", "prune") + } + } + } + + // ---- helpers ------------------------------------------------------------- + + private fun payload(p: WorkflowRunPayload): String = json.encodeToString(p) + + private suspend fun emit( + ctx: Context, + relay: NormalizedRelayUrl, + template: EventTemplate, + ) { + val signed = ctx.signer.sign(template) + ctx.publish(signed, setOf(relay)) + } + + private class ExecResult( + val exit: Int, + val out: String, + val err: String, + ) + + private suspend fun exec( + cmd: String, + input: String, + env: Map, + workdir: String?, + ): ExecResult = + withContext(Dispatchers.IO) { + val pb = ProcessBuilder("sh", "-c", cmd) + workdir?.let { pb.directory(File(it)) } + pb.environment().putAll(env) + val proc = pb.start() + coroutineScope { + val out = async { proc.inputStream.readBytes().decodeToString() } + val err = async { proc.errorStream.readBytes().decodeToString() } + // Feed the task on stdin, but a command that never reads it (e.g. `printf …`) exits + // and closes the pipe first — tolerate the resulting broken pipe rather than abort. + runCatching { proc.outputStream.use { it.write(input.encodeToByteArray()) } } + proc.waitFor() + ExecResult(proc.exitValue(), out.await(), err.await()) + } + } + + private suspend fun git( + dir: String, + vararg gitArgs: String, + ): ExecResult = + withContext(Dispatchers.IO) { + val proc = ProcessBuilder(listOf("git", "-C", dir) + gitArgs).start() + coroutineScope { + val out = async { proc.inputStream.readBytes().decodeToString() } + val err = async { proc.errorStream.readBytes().decodeToString() } + proc.waitFor() + ExecResult(proc.exitValue(), out.await(), err.await()) + } + } + + /** Latest kind-39002 roster for [channel] → its member pubkeys (empty if the relay serves none). */ + private suspend fun channelMembers( + ctx: Context, + relay: NormalizedRelayUrl, + channel: String, + timeoutSecs: Long, + ): Set = + ctx + .drain(mapOf(relay to listOf(Filter(kinds = listOf(GroupMembersEvent.KIND), tags = mapOf("d" to listOf(channel))))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .maxByOrNull { it.createdAt } + ?.members() + ?.toSet() + .orEmpty() + + private const val MAX = 60_000 + private val LIFECYCLE_KINDS = + listOf( + WorkflowTriggeredEvent.KIND, + WorkflowStepStartedEvent.KIND, + WorkflowStepCompletedEvent.KIND, + WorkflowApprovalRequestedEvent.KIND, + WorkflowCompletedEvent.KIND, + WorkflowFailedEvent.KIND, + WorkflowCancelledEvent.KIND, + ) + private val DECISION_KINDS = listOf(ApprovalGrantEvent.KIND, ApprovalDenyEvent.KIND) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt index 235437cbcd..871acb6d87 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt @@ -26,7 +26,10 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.geode.KtorRelay import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.quartz.buzz.relay.BuzzMembershipPolicy import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import kotlinx.coroutines.awaitCancellation @@ -48,9 +51,12 @@ object ServeCommand { | | serve [--host H] [--port N] [--path P] in-memory by default (ephemeral); --db FILE | [--db FILE] [--admin NPUBS] for a persistent SQLite store. The account's - | pubkey is always an admin; --admin adds more + | [--buzz [--members NPUBS]] pubkey is always an admin; --admin adds more | (comma-separated npub/hex). Blocks until | interrupted. Defaults: 127.0.0.1:7447/ + | + | --buzz turns this into a private Buzz workspace relay (NIP-42 required; only members + | and NIP-OA-attested agents may read/write). Members = admins + --members. """.trimMargin() suspend fun run( @@ -66,34 +72,54 @@ object ServeCommand { val port = args.intFlag("port", 7447) val path = args.flag("path") ?: "/" val dbFile = args.flag("db") - val extraAdmins = + + fun csv(name: String) = args - .flag("admin") + .flag(name) ?.split(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } .orEmpty() + val extraAdmins = csv("admin") + val buzz = args.bool("buzz") + val extraMembers = csv("members") args.rejectUnknown() - // Resolve admin pubkeys (self + --admin) up front, then drop the + // Resolve admin + member pubkeys (self + --admin [+ --members]) up front, then drop the // Context — the embedded relay owns its own store and needs no account. - val adminPubkeys = + val (adminPubkeys, memberPubkeys) = Context.open(dataDir).use { ctx -> - buildSet { - add(ctx.identity.pubKeyHex) - extraAdmins.forEach { add(ctx.requireUserHex(it)) } - } + val admins = + buildSet { + add(ctx.identity.pubKeyHex) + extraAdmins.forEach { add(ctx.requireUserHex(it)) } + } + val members = + buildSet { + addAll(admins) + extraMembers.forEach { add(ctx.requireUserHex(it)) } + } + admins to members } // 0.0.0.0 isn't routable in a NIP-42 challenge; advertise loopback. val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host val url = "ws://$advertisedHost:$port$path".normalizeRelayUrl() + + // --buzz locks the relay to members + NIP-OA-attested agents; otherwise a vanilla relay. + val policyBuilder: () -> IRelayPolicy = + if (buzz) { + { BuzzMembershipPolicy(url, memberPubkeys) } + } else { + { EmptyPolicy } + } + // In-memory is RelayEngine's default; only build a SQLite store for --db. val relay = if (dbFile != null) { - RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), adminPubkeys = adminPubkeys) + RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), policyBuilder = policyBuilder, adminPubkeys = adminPubkeys) } else { - RelayEngine(url, adminPubkeys = adminPubkeys) + RelayEngine(url, policyBuilder = policyBuilder, adminPubkeys = adminPubkeys) } val server = KtorRelay(relay, host = host, port = port, path = path).start() @@ -112,9 +138,11 @@ object ServeCommand { "path" to path, "persistent" to (dbFile != null), "admin_pubkeys" to adminPubkeys.toList(), + "buzz" to buzz, + "members" to if (buzz) memberPubkeys.toList() else null, ), ) - System.err.println("[serve] relay up at ${server.url} — Ctrl-C to stop") + System.err.println("[serve] relay up at ${server.url}${if (buzz) " (Buzz workspace, ${memberPubkeys.size} members)" else ""} — Ctrl-C to stop") // Block until the process is interrupted; the shutdown hook tears down. awaitCancellation() diff --git a/cli/src/main/resources/buzz-agent/workflow-agent.sh b/cli/src/main/resources/buzz-agent/workflow-agent.sh new file mode 100755 index 0000000000..32774003df --- /dev/null +++ b/cli/src/main/resources/buzz-agent/workflow-agent.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# workflow-agent.sh — the --exec step of `amy buzz workflow run` (the GATED path). +# +# The runner calls this once per workflow run to do the agent's work. It runs a coding agent +# (Claude Code by default) on the task inside the run's isolated git worktree and COMMITS the +# result — but it does NOT push. A human approves the 46010 gate first; then workflow-ship.sh +# (the --on-approve step) pushes the branch and opens the PR. +# +# Contract (set by the runner): +# stdin ............ the task text +# BUZZ_WORKTREE .... the run's git worktree (a fresh branch off BUZZ_BASE_REF) +# BUZZ_BRANCH ...... the run's branch name +# BUZZ_BASE_REF .... what the branch was cut from +# BUZZ_RUN ......... the run id; BUZZ_REQUESTER — who asked +# stdout ........... becomes the approver's gate note (46010) — we print a short summary +# non-zero exit .... fails the run before it ever reaches the gate; stderr is the detail +# +# Config knobs (env): AGENT_CMD (override the whole agent call; reads the prompt on stdin and as +# $AGENT_PROMPT), AGENT_ALLOWED_TOOLS (Claude Code --allowedTools), COMMIT_PREFIX. + +set -euo pipefail +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +AGENT_CMD="${AGENT_CMD:-}" +AGENT_ALLOWED_TOOLS="${AGENT_ALLOWED_TOOLS:-Edit,Write,Read,Bash,Glob,Grep}" +COMMIT_PREFIX="${COMMIT_PREFIX:-feat}" + +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run this under 'amy buzz workflow run'" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "worktree is not a git repo" + +# Pin the start commit so "did the agent change anything?" is correct even off a moving HEAD. +base_sha="$(git rev-parse HEAD)" + +task="$(cat)" +[[ -n "${task//[[:space:]]/}" ]] || die "empty task" +title="$(printf '%s' "$task" | head -n1 | cut -c1-72)" + +log "[workflow-agent] run ${BUZZ_RUN:-?}: $title" +prompt="You are working in a fresh git worktree on branch '${BUZZ_BRANCH:-?}' (off '${BUZZ_BASE_REF:-HEAD}'). +Implement the request below and then stop. Do NOT switch branches, push, or open a PR — the +wrapper handles git after a human approves. Keep changes scoped to this repository. + +TASK: +$task" + +if [[ -n "$AGENT_CMD" ]]; then + export AGENT_PROMPT="$prompt" + summary="$(printf '%s' "$prompt" | bash -c "$AGENT_CMD" 2>&1)" || die "agent command failed" +else + command -v claude >/dev/null 2>&1 || die "claude CLI not found (set AGENT_CMD to your agent)" + summary="$(claude -p "$prompt" --permission-mode acceptEdits --allowedTools "$AGENT_ALLOWED_TOOLS" 2>&1)" || + die "claude run failed" +fi + +# Verify the agent produced changes; commit anything it left uncommitted. No push here — the gate. +committed="$(git rev-list --count "$base_sha"..HEAD 2>/dev/null || echo 0)" +if [[ -z "$(git status --porcelain)" && "$committed" == "0" ]]; then + die "the agent produced no changes" +fi +if [[ -n "$(git status --porcelain)" ]]; then + git add -A + git -c user.name="Buzz Agent" -c user.email="agent@localhost" commit -q \ + -m "$COMMIT_PREFIX: $title" -m "Buzz run ${BUZZ_RUN:-unknown} — awaiting approval." +fi + +# stdout → the gate note the approver reads before granting. +printf 'Ready for review on %s.\n\n%s\n' "${BUZZ_BRANCH:-?}" "$summary" diff --git a/cli/src/main/resources/buzz-agent/workflow-ship.sh b/cli/src/main/resources/buzz-agent/workflow-ship.sh new file mode 100755 index 0000000000..a4140e5388 --- /dev/null +++ b/cli/src/main/resources/buzz-agent/workflow-ship.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# workflow-ship.sh — the --on-approve step of `amy buzz workflow run` (the GATED path). +# +# The runner calls this ONLY after a human grants the run's 46010 approval gate. It pushes the +# run's branch and opens (or reuses) the PR, printing the PR URL as the run's result (46005). It +# NEVER touches the default branch and NEVER force-pushes — the merge stays a human action on GitHub. +# +# Contract (set by the runner): +# BUZZ_WORKTREE .... the run's git worktree (already carries the agent's commits) +# BUZZ_BRANCH ...... the run's branch to push +# BUZZ_RUN ......... the run id +# stdout ........... the PR URL → the run result (46005) +# non-zero exit .... fails the run; stderr is the detail +# +# Host requirements: git + gh authenticated with a PR-ONLY token (Contents:RW + PRs:RW on this repo), +# and branch protection on the default branch. See README.md. + +set -euo pipefail +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run this under 'amy buzz workflow run'" +[[ -n "${BUZZ_BRANCH:-}" ]] || die "BUZZ_BRANCH unset" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" + +# The PR base = the repo's default branch. Never operate on it directly. +base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo main)" +case "$BUZZ_BRANCH" in + "$base_branch" | main | master) die "refusing to operate on the default branch ($BUZZ_BRANCH)" ;; +esac + +title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)" +[[ -n "$title" ]] || title="Buzz run ${BUZZ_RUN:-}" + +log "[workflow-ship] pushing $BUZZ_BRANCH" +git push -u origin "HEAD:$BUZZ_BRANCH" || die "push failed (is a PR-only token configured?)" + +pr_url="$(gh pr list --head "$BUZZ_BRANCH" --state open --json url -q '.[0].url' 2>/dev/null || true)" +if [[ -z "$pr_url" ]]; then + body="Approved via Buzz workflow run \`${BUZZ_RUN:-unknown}\`. Merge is a human action on GitHub." + pr_url="$(gh pr create --base "$base_branch" --head "$BUZZ_BRANCH" --title "$title" --body "$body" 2>/dev/null)" || + die "gh pr create failed (PR-only token + branch protection configured?)" +fi + +printf 'Opened PR: %s\n' "$pr_url" diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 61f2fb29a9..a74c7de8b8 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -7,3 +7,6 @@ relaygroup/state-relaygroup-headless/ sync/state-sync-deletions/ blossom/state-blossom-live/ git/state-git-nip34/ +buzz/state-job-loop/ +buzz/state-workflow-loop/ +buzz/state-agent-exec/ diff --git a/cli/tests/buzz/agent-exec.sh b/cli/tests/buzz/agent-exec.sh new file mode 100755 index 0000000000..8ebff83d5b --- /dev/null +++ b/cli/tests/buzz/agent-exec.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# agent-exec.sh — self-contained headless test for the `--exec` wrapper that turns a Buzz job +# into a pull request (tools/buzz-agent/agent-exec.sh). +# +# job-loop.sh proves the scheduler drives *an* --exec program; this proves the real one does the +# right thing with git and `gh`. Both the agent and `gh` are stubbed, so it needs no network, no +# credentials, and no Claude Code — it exercises the plumbing around them: +# +# task on stdin → agent → verify a diff → commit → push the job branch → open/reuse PR → url +# +# The failure paths matter as much as the happy one: an agent that changed nothing must become a +# job error, and the default-branch guard must actually hold. +# +# Usage: ./agent-exec.sh +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +EXEC="$REPO_ROOT/tools/buzz-agent/agent-exec.sh" +BASE="$SCRIPT_DIR/state-agent-exec" +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then echo " ✓ PASS $1 ($2)"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: got [$2] want [$3]"; FAIL=$((FAIL+1)); fi; } +contains() { if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: [$2] lacks [$3]"; FAIL=$((FAIL+1)); fi; } + +rm -rf "$BASE"; mkdir -p "$BASE/bin" + +# --- stub gh: default branch, no existing PR, and a PR URL on create ------------------------- +cat > "$BASE/bin/gh" <<'GH' +#!/usr/bin/env bash +case "$1 $2" in + "repo view") echo main ;; + "pr list") echo "" ;; # no open PR for this head + "pr create") echo "https://github.com/example/repo/pull/42" ;; + *) exit 1 ;; +esac +GH +chmod +x "$BASE/bin/gh" +export PATH="$BASE/bin:$PATH" + +# --- a bare "origin" so the real `git push` in the script has somewhere to go ---------------- +git init -q --bare "$BASE/origin.git" +git init -q "$BASE/repo" +cd "$BASE/repo" +git config user.email t@t; git config user.name t +echo seed > seed.txt; git add -A; git -c commit.gpgsign=false commit -qm seed +git branch -M main +git remote add origin "$BASE/origin.git" +git push -q -u origin main + +run_case() { # run_case ; echoes exit code, sets OUT/ERRTXT + local branch="$1" agentcmd="$2" task="${3:-Add a greeting file}" + git -C "$BASE/repo" checkout -q main + git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null + git -C "$BASE/repo" worktree add -q -b "$branch" "$BASE/wt" main 2>/dev/null + OUT="$(cd "$BASE/wt" && printf '%s' "$task" | env \ + BUZZ_JOB_ID=job123 BUZZ_REQUESTER=alice BUZZ_BRANCH="$branch" \ + BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD="$agentcmd" \ + bash "$EXEC" 2>"$BASE/err.txt")" + RC=$? + ERRTXT="$(cat "$BASE/err.txt")" + return $RC +} + +echo "> 1. happy path: agent edits a file → commit → push → PR url on stdout" +run_case "claude/job-1" 'echo "wrote greeting" && echo hello > greeting.txt' +check "exit code" "$?" "0" +contains "stdout is the PR url" "$OUT" "https://github.com/example/repo/pull/42" +check "branch landed on origin" "$(git -C "$BASE/origin.git" rev-parse --verify -q claude/job-1 >/dev/null && echo yes || echo no)" "yes" +check "commit was made" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1" +contains "commit subject uses the task's first line" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "feat: Add a greeting file" +contains "file the agent wrote is in the commit" "$(git -C "$BASE/wt" show --stat --oneline HEAD)" "greeting.txt" +contains "the agent got the task on stdin" "$ERRTXT" "Add a greeting file" + +echo "> 2. agent makes no changes → job error, non-zero exit" +run_case "claude/job-2" 'echo "thought about it"' +check "exit code" "$?" "1" +contains "explains why" "$ERRTXT" "no changes" + +echo "> 3. agent commits by itself → wrapper pushes, does not double-commit" +run_case "claude/job-3" 'echo x > f.txt && git add -A && git -c user.email=a@b -c user.name=a commit -qm "agent: own commit"' +check "exit code" "$?" "0" +check "exactly one commit" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1" +contains "kept the agent's own subject" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "agent: own commit" + +echo "> 4. refuses to operate on the default branch" +# the worktree sits on a scratch branch, but the scheduler hands it BUZZ_BRANCH=main — +# exactly the mistake the guard exists to stop. +git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null +git -C "$BASE/repo" worktree add -q -b scratch-guard "$BASE/wt" main +OUT="$(cd "$BASE/wt" && printf 'do a thing' | env BUZZ_JOB_ID=job4 BUZZ_BRANCH=main BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD='echo hi > x.txt' bash "$EXEC" 2>"$BASE/err.txt")"; RC=$? +ERRTXT="$(cat "$BASE/err.txt")" +check "exit code" "$RC" "1" +contains "says why" "$ERRTXT" "refusing to operate on the default branch" +check "nothing was pushed to main" "$(git -C "$BASE/origin.git" rev-parse main)" "$(git -C "$BASE/repo" rev-parse main)" + +echo "> 5. missing scheduler env is a hard error, not a silent no-op" +OUT="$(printf 'task' | env -u BUZZ_BRANCH BUZZ_WORKTREE="$BASE/wt" bash "$EXEC" 2>&1)"; RC=$? +check "exit code" "$RC" "1" +contains "names the missing var" "$OUT" "BUZZ_BRANCH" + +echo "> 6. empty task is rejected before the agent runs" +run_case "claude/job-6" 'echo should-not-run > ran.txt' " " +check "exit code" "$?" "1" +contains "says why" "$ERRTXT" "empty task" + +echo +echo "> RESULTS: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] diff --git a/cli/tests/buzz/job-loop.sh b/cli/tests/buzz/job-loop.sh new file mode 100755 index 0000000000..972684455f --- /dev/null +++ b/cli/tests/buzz/job-loop.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# job-loop.sh — self-contained headless test for the Buzz agent-job loop. +# +# Two `amy` accounts (alice = requester, bot = agent) talk through an embedded +# relay (`amy serve`, i.e. geode — no external binary). Exercises the whole +# drive-an-agent loop end to end: +# +# alice: buzz job request (kind-43001, targeting the bot) +# bot: buzz agent serve --once (accept 43002 → progress 43003 → +# run --exec → result 43004) +# alice: buzz job show (BuzzJobAggregator folds it to state=completed) +# +# It also proves the permission gate: a responder whose --accept-from allowlist +# excludes alice handles NOTHING; only an allowlisted requester is obeyed. +# +# Usage: ./job-loop.sh [--port N] [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-job-loop" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +PORT=7799 +BUILD=1 +while [[ $# -gt 0 ]]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + --no-build) BUILD=0; shift ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +RELAY="ws://127.0.0.1:$PORT" + +if [[ $BUILD -eq 1 ]]; then + echo "> building amy…" >&2 + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) || { echo "build failed" >&2; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { echo "amy not built at $AMY_BIN (drop --no-build)" >&2; exit 1; } + +rm -rf "$STATE_DIR"; mkdir -p "$STATE_DIR" +REQ_HOME="$STATE_DIR/alice"; AGENT_HOME="$STATE_DIR/bot"; mkdir -p "$REQ_HOME" "$AGENT_HOME" +RELAY_LOG="$STATE_DIR/relay.log" +PASS=0; FAIL=0 +RELAY_PID="" +cleanup() { [[ -n "$RELAY_PID" ]] && kill "$RELAY_PID" 2>/dev/null; } +trap cleanup EXIT + +run_req() { HOME="$REQ_HOME" "$AMY_BIN" --account alice --secret-backend plaintext --json "$@" 2>/dev/null; } +run_agent() { HOME="$AGENT_HOME" "$AMY_BIN" --account bot --secret-backend plaintext --json "$@" 2>/dev/null; } +jkey() { + python3 -c ' +import sys, json +v = json.load(sys.stdin).get("'"$1"'", "") +print(str(v).lower() if isinstance(v, bool) else v)' +} +check() { # check