Merge pull request #3748 from vitorpamplona/claude/buzz-amethyst-support-nfxog7

Add Buzz job/workflow boards and CLI agent/workflow commands
This commit is contained in:
Vitor Pamplona
2026-07-27 15:20:16 -04:00
committed by GitHub
46 changed files with 7606 additions and 80 deletions
@@ -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>(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)
@@ -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<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzJobBoard> { JobBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzWorkflowBoard> { WorkflowRunBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzAgentWork> { AgentWorkBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzForumThread> { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) }
@@ -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,
@@ -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 065535 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<HexKey?>(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<OwnerAttestation?>(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 065535, 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 (065535)") },
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<List<HexKey>>(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\"" }
@@ -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))
}
}
}
@@ -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<WorkflowRun>,
jobs: List<JobView>,
): List<AgentWorkItem> =
(runs.map { from(it) } + jobs.map { from(it) })
.sortedWith(
compareBy<AgentWorkItem> { it.state.ordinal }
.thenByDescending { it.upvotes ?: 0 }
.thenByDescending { it.updatedAt },
)
}
@@ -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<String, MaterialSymbol> =
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
}
@@ -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<List<AgentWorkItem>>(emptyList())
val items: StateFlow<List<AgentWorkItem>> = _items.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _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<WorkflowApprovalRequestedEvent>()
.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)
}
}
@@ -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<DropdownOption>,
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
},
)
}
}
}
}
@@ -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<JobView>,
val queued: List<JobView>,
val done: List<JobView>,
val closed: List<JobView>,
) {
companion object {
fun from(jobs: List<JobView>) =
JobGroups(
running = jobs.filter { it.state == JobState.IN_PROGRESS || it.state == JobState.ACCEPTED },
queued =
jobs
.filter { it.state == JobState.REQUESTED }
.sortedWith(compareByDescending<JobView> { 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<JobView>,
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<Color, Color> =
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<String, MaterialSymbol> =
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
}
@@ -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<List<JobView>>(emptyList())
val jobs: StateFlow<List<JobView>> = _jobs.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _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))),
)
}
}
@@ -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<Color, String> =
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"
}
@@ -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<PendingDecision?>(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<WorkflowRun>,
val active: List<WorkflowRun>,
val done: List<WorkflowRun>,
val closed: List<WorkflowRun>,
) {
companion object {
fun from(runs: List<WorkflowRun>) =
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<WorkflowRun>,
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<WorkflowDefOption>,
onDismiss: () -> Unit,
onDefine: (String, String, (String?) -> Unit) -> Unit,
onTrigger: (String, String) -> Unit,
) {
var task by remember { mutableStateOf("") }
var selected by remember { mutableStateOf<WorkflowDefOption?>(null) }
var defining by remember { mutableStateOf(false) }
var pendingSelectId by remember { mutableStateOf<String?>(null) }
// Once a just-published definition lands in the channel list, select it and leave the editor.
LaunchedEffect(definitions, pendingSelectId) {
val id = pendingSelectId ?: return@LaunchedEffect
definitions.firstOrNull { it.id == id }?.let {
selected = it
defining = false
pendingSelectId = null
}
}
// Keep the selection valid if the list changes underneath us; default to the sole definition.
LaunchedEffect(definitions) {
if (selected != null && definitions.none { it.id == selected!!.id }) selected = null
if (selected == null && definitions.size == 1) selected = definitions.first()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(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<WorkflowDefOption>,
selected: WorkflowDefOption?,
onSelect: (WorkflowDefOption) -> Unit,
onNewDefinition: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selected?.label ?: "",
onValueChange = {},
readOnly = true,
label = { Text(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<String?>(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<Color, Color> =
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<Int, MaterialSymbol> =
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
}
@@ -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<List<WorkflowRun>>(emptyList())
val runs: StateFlow<List<WorkflowRun>> = _runs.asStateFlow()
private val _definitions = MutableStateFlow<List<WorkflowDefOption>>(emptyList())
/** The channel's published workflow definitions (kind-30620), name-sorted — the picker's options. */
val definitions: StateFlow<List<WorkflowDefOption>> = _definitions.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
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<WorkflowApprovalRequestedEvent>()
.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<Event>) {
_definitions.value =
events
.filterIsInstance<WorkflowDefEvent>()
.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)
}
}
@@ -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(
@@ -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,
@@ -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 ||
+102
View File
@@ -4861,4 +4861,106 @@
<string name="new_conversation_location_pro_1">Anonymous — a throwaway identity per area, never your npub.</string>
<string name="new_conversation_location_pro_2">Bitchat-compatible; reaches nearby users on the same relays.</string>
<string name="new_conversation_location_con_1">Public and ephemeral — no history, anyone in the cell can read it.</string>
<!-- Buzz workflow run board -->
<string name="buzz_job_board_title">Backlog</string>
<string name="buzz_workflow_runs_title">Workflow runs</string>
<string name="buzz_agent_work_title">Agent work</string>
<string name="buzz_workflow_new_run">New run</string>
<string name="buzz_workflow_section_awaiting">Needs your approval</string>
<string name="buzz_workflow_section_active">Working now</string>
<string name="buzz_workflow_section_shipped">Shipped</string>
<string name="buzz_workflow_section_closed">Closed</string>
<string name="buzz_workflow_gate_needs_you">Needs your approval</string>
<string name="buzz_workflow_gate_awaiting">Awaiting approval</string>
<string name="buzz_workflow_no_description">(no description)</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_by">by</string>
<string name="buzz_workflow_waiting_on">waiting on</string>
<string name="buzz_workflow_readonly_approver">You\'re the approver, but this login can\'t sign a decision.</string>
<string name="buzz_workflow_deny">Deny</string>
<string name="buzz_workflow_approve_open_pr">Approve &amp; open PR</string>
<string name="buzz_workflow_approve_title">Approve this run?</string>
<string name="buzz_workflow_deny_title">Deny this run?</string>
<string name="buzz_workflow_approve_body">The runner will push its branch and open a pull request for “%1$s”. Approving never merges or deploys — that still happens on GitHub.</string>
<string name="buzz_workflow_deny_body">The agent\'s work for “%1$s” will be discarded. This can\'t be undone.</string>
<string name="buzz_workflow_this_run">this run</string>
<string name="buzz_workflow_cancel">Cancel</string>
<string name="buzz_workflow_waiting_for_approval">Waiting for approval</string>
<string name="buzz_workflow_triggered_toast">Run triggered</string>
<string name="buzz_workflow_trigger_failed_toast">Couldn\'t trigger the run — check you can post to this workspace</string>
<string name="buzz_workflow_approved_toast">Approved — the runner is opening a pull request</string>
<string name="buzz_workflow_denied_toast">Denied — the work was discarded</string>
<string name="buzz_workflow_decision_failed_toast">Couldn\'t publish your decision — check you can post to this workspace</string>
<string name="buzz_workflow_view_pr">View PR</string>
<string name="buzz_workflow_closed_denied">Denied — work discarded</string>
<string name="buzz_workflow_closed_cancelled">Cancelled</string>
<string name="buzz_workflow_closed_failed_prefix">Failed: %1$s</string>
<string name="buzz_workflow_closed_failed">Failed</string>
<string name="buzz_workflow_pill_queued">Queued</string>
<string name="buzz_workflow_pill_working">Working</string>
<string name="buzz_workflow_pill_needs_approval">Needs approval</string>
<string name="buzz_workflow_pill_approved">Approved</string>
<string name="buzz_workflow_pill_shipped">Shipped</string>
<string name="buzz_workflow_pill_failed">Failed</string>
<string name="buzz_workflow_pill_cancelled">Cancelled</string>
<string name="buzz_workflow_pill_denied">Denied</string>
<string name="buzz_workflow_trigger_title">Trigger a workflow</string>
<string name="buzz_workflow_trigger_desc">The runner does the work, then pauses for a human to approve before it opens a PR. The whole channel sees the run.</string>
<string name="buzz_workflow_no_defs_hint">No workflows yet. Open the menu above and choose “New definition…” to create one, then trigger it.</string>
<string name="buzz_workflow_task_label">What should it do?</string>
<string name="buzz_workflow_trigger_run">Trigger run</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="buzz_workflow_picker_empty">No workflows defined yet</string>
<string name="buzz_workflow_picker_choose">Choose a workflow</string>
<string name="buzz_workflow_new_definition">New definition…</string>
<string name="buzz_workflow_def_title">New workflow definition</string>
<string name="buzz_workflow_def_desc">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.</string>
<string name="buzz_workflow_def_name">Name</string>
<string name="buzz_workflow_def_name_hint">build-and-test</string>
<string name="buzz_workflow_def_yaml">YAML recipe</string>
<string name="buzz_workflow_def_publish_failed">Couldn\'t publish the definition — check you can post to this workspace.</string>
<string name="buzz_workflow_publishing">Publishing…</string>
<string name="buzz_workflow_create_definition">Create definition</string>
<string name="buzz_workflow_empty_title">No runs yet</string>
<string name="buzz_workflow_empty_desc">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.</string>
<!-- Buzz agent attestation -->
<string name="buzz_attest_topbar">Attestations</string>
<string name="buzz_attest_hold_title">Hold an attestation</string>
<string name="buzz_attest_holding">Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.</string>
<string name="buzz_attest_grants_prefix">Grants: %1$s</string>
<string name="buzz_attest_grants_unrestricted">any kind, any time (unrestricted)</string>
<string name="buzz_attest_remove">Remove</string>
<string name="buzz_attest_hold_desc">Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.</string>
<string name="buzz_attest_authtag_label">auth tag JSON</string>
<string name="buzz_attest_hold_button">Hold attestation</string>
<string name="buzz_attest_readonly_title">Local key required</string>
<string name="buzz_attest_readonly_desc">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.</string>
<string name="buzz_attest_form_desc">Authorize an agent pubkey to publish in your workspace without enrolling its key. The agent attaches the signed tag below to its events.</string>
<string name="buzz_attest_agent_label">Agent (search a name, or paste npub / hex)</string>
<string name="buzz_attest_agent_paste_error">Enter an npub or 64-char hex key.</string>
<string name="buzz_attest_change_agent">Change agent</string>
<string name="buzz_attest_conditions_hint">Conditions (optional) — leave blank for an unrestricted attestation.</string>
<string name="buzz_attest_kind_label">Restrict to kind (065535)</string>
<string name="buzz_attest_after_label">Only events after (unix seconds)</string>
<string name="buzz_attest_before_label">Only events before (unix seconds)</string>
<string name="buzz_attest_generate">Generate attestation</string>
<string name="buzz_attest_signed_title">Signed attestation</string>
<string name="buzz_attest_copy_tag">Copy tag</string>
<string name="buzz_attest_warning">⚠ 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.</string>
<!-- Buzz agent persona editor -->
<string name="buzz_persona_new_title">New persona</string>
<string name="buzz_persona_edit_title">Edit persona</string>
<string name="buzz_persona_slug">Slug (persona id)</string>
<string name="buzz_persona_slug_help">a-z, 0-9, \'-\' or \'_\'. Cannot change after creation.</string>
<string name="buzz_persona_display_name">Display name</string>
<string name="buzz_persona_system_prompt">System prompt</string>
<string name="buzz_persona_model">Model (optional)</string>
<string name="buzz_persona_provider">Provider (optional)</string>
<string name="buzz_persona_runtime">Runtime (optional)</string>
<string name="buzz_persona_avatar">Avatar URL (optional)</string>
<string name="buzz_persona_publishing">Publishing…</string>
<string name="buzz_persona_publish">Publish persona</string>
</resources>
+44 -1
View File
@@ -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 <text> [--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 `<branch-prefix><jobid>`) 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)
+1
View File
@@ -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 🆕. |
+6
View File
@@ -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<ProcessResources>().configureEach {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
@@ -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 <npubs>`** (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 `<branch-prefix><jobid>`)
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 **SM**.
- **P1-4 Attestation persistence**`BuzzHeldAttestations` is in-memory; survive restart.
Size **SM**.
**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.
@@ -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 <text> 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
@@ -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<Pair<String, String>>())
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): 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<String>,
): 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<GroupMetadataEvent>()
.mapNotNull { it.groupId() }
.distinct()
.singleOrNull()
/** Extract the bundled gated wrappers to ~/.amy/buzz-agent and return (agentStepPath, shipStepPath). */
private fun extractWrappers(): Pair<String, String> {
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<String>,
): Int {
val args = Args(rest)
val repo = args.flag("repo") ?: System.getProperty("user.dir")
args.rejectUnknown("repo")
val checks = mutableListOf<Map<String, Any?>>()
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<String>,
): 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<HexKey>? =
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<HexKey>()
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<HexKey>?,
handled: MutableSet<HexKey>,
): Int {
val pending = selectPending(ctx, me, opts, allow, handled)
val done = mutableListOf<Map<String, Any?>>()
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<HexKey>?,
handled: MutableSet<HexKey>,
) {
supervisorScope {
val inflight = mutableSetOf<HexKey>()
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<HexKey>?,
exclude: Set<HexKey>,
): List<JobView> =
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<String, Any?> {
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<HexKey> {
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<GroupMembersEvent>()
.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<String, String>,
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<out Event>,
) {
val signed = ctx.signer.sign(template)
ctx.publish(signed, setOf(relay))
}
private const val MAX_BODY = 60_000
}
@@ -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) },
),
)
@@ -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 <text> 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<String>,
): 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 <text> [--agent PUBKEY] [--channel GID]` → publishes a kind-43001. */
private suspend fun request(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz job request RELAY <text> [--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<String>,
): 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<String>,
): 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<String>,
): 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<JobView> {
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<String, Any?> =
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)
}
@@ -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<String>,
): Int = run(dataDir, rest)
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): 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<String>,
): 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<String>,
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<String>,
): 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<String>,
): 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<WorkflowTriggerEvent>()
.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<WorkflowRun> {
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<WorkflowApprovalRequestedEvent>()
.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<String, Any?> =
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<String>,
): 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<HexKey>? =
if (fromChannel) {
(explicitAccept ?: emptySet()) + channelMembers(ctx, relay, channel, timeoutSecs)
} else {
explicitAccept
}
val started = mutableSetOf<HexKey>() // triggers we've begun
val awaiting = mutableMapOf<HexKey, AwaitingRun>() // runId -> worktree while at the gate
val decided = mutableSetOf<HexKey>()
// 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<Map<String, Any?>>()
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<out Event>,
) {
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<String, String>,
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<HexKey> =
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<GroupMembersEvent>()
.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)
}
@@ -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()
+70
View File
@@ -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"
+46
View File
@@ -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"
+3
View File
@@ -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/
+109
View File
@@ -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 <branch> <agent-cmd> ; 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 ]]
+115
View File
@@ -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 <label> <condition-desc> <actual> <expected>
if [[ "$3" == "$4" ]]; then echo " ✓ PASS $1 ($3)" >&2; PASS=$((PASS+1));
else echo " ✗ FAIL $1: expected [$4] got [$3]" >&2; FAIL=$((FAIL+1)); fi
}
contains() { # contains <label> <haystack> <needle>
if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1" >&2; PASS=$((PASS+1));
else echo " ✗ FAIL $1: [$2] has no [$3]" >&2; FAIL=$((FAIL+1)); fi
}
run_req init >/dev/null; run_agent init >/dev/null
ALICE=$(run_req whoami | jkey hex)
BOT=$(run_agent whoami | jkey hex)
echo "> alice=$ALICE bot=$BOT" >&2
HOME="$AGENT_HOME" "$AMY_BIN" --account bot --secret-backend plaintext serve --port "$PORT" >"$RELAY_LOG" 2>&1 &
RELAY_PID=$!
for _ in $(seq 1 60); do grep -q "relay up" "$RELAY_LOG" && break; sleep 0.5; done
grep -q "relay up" "$RELAY_LOG" || { echo "relay never came up" >&2; cat "$RELAY_LOG" >&2; exit 1; }
echo "> 1. alice files a job targeting the bot" >&2
JOB=$(run_req buzz job request "$RELAY" "hello from alice" --agent "$BOT")
JOB_ID=$(echo "$JOB" | jkey job_id)
check "request published" _ "$(echo "$JOB" | jkey published)" "true"
[[ -n "$JOB_ID" ]] && { echo " ✓ PASS got job id $JOB_ID" >&2; PASS=$((PASS+1)); } || { echo " ✗ FAIL no job id" >&2; FAIL=$((FAIL+1)); }
echo "> 2. UNAUTHORIZED responder (allowlist excludes alice) handles nothing" >&2
UNAUTH=$(run_agent buzz agent serve "$RELAY" --exec 'cat' --accept-from "$BOT" --once)
check "allowlist blocks non-listed requester" _ "$(echo "$UNAUTH" | jkey handled)" "0"
echo "> 3. authorized responder handles it, piping task text through --exec" >&2
AUTH=$(run_agent buzz agent serve "$RELAY" --exec 'printf "PR opened for: "; cat' --accept-from "$ALICE" --once)
check "authorized responder handled 1" _ "$(echo "$AUTH" | jkey handled)" "1"
echo "> 4. alice reads back the folded job" >&2
SHOW=$(run_req buzz job show "$RELAY" "$JOB_ID")
check "state" _ "$(echo "$SHOW" | jkey state)" "completed"
check "requester" _ "$(echo "$SHOW" | jkey requester)" "$ALICE"
check "agent" _ "$(echo "$SHOW" | jkey agent)" "$BOT"
contains "result carries exec stdout with piped stdin" "$(echo "$SHOW" | jkey result)" "PR opened for: hello from alice"
echo "> 5. parallel scheduler: 3 jobs, --parallel 3, one worktree+branch each" >&2
REPO="$STATE_DIR/repo"; mkdir -p "$REPO"
git -C "$REPO" -c init.defaultBranch=main init -q
git -C "$REPO" -c user.email=a@b.c -c user.name=t commit -q --allow-empty -m init
for n in one two three; do run_req buzz job request "$RELAY" "parallel $n" --agent "$BOT" >/dev/null; done
PAR=$(run_agent buzz agent serve "$RELAY" \
--exec 'printf "on %s: " "$BUZZ_BRANCH"; cat' \
--accept-from "$ALICE" --parallel 3 --worktree "$REPO" --base-ref main --once)
check "parallel scheduler handled 3" _ "$(echo "$PAR" | jkey handled)" "3"
BRANCHES=$(git -C "$REPO" branch --list 'claude/job-*' | wc -l | tr -d ' ')
check "created one branch per job" _ "$BRANCHES" "3"
LEFTOVER=$(git -C "$REPO" worktree list | sed 1d | wc -l | tr -d ' ')
check "worktrees cleaned up after runs" _ "$LEFTOVER" "0"
echo "" >&2
echo "> RESULTS: $PASS passed, $FAIL failed" >&2
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
#
# workflow-loop.sh — self-contained headless test for the Buzz workflow loop.
#
# Buzz's *source-confirmed* structured-work + human-approval primitive (kinds
# 30620 def / 46020 trigger / 46030-46031 grant-deny / 46001-46007 lifecycle),
# driven end to end through an embedded relay (`amy serve`, i.e. geode — no
# external binary). Three `amy` accounts:
#
# alice = requester (triggers the workflow, kind-46020)
# bot = the runner (`amy buzz workflow run`; does agent work, posts the
# 46010 approval gate, then on grant runs --on-approve → 46005)
# carol = the approver (publishes 46030 grant / 46031 deny)
#
# Because the run id IS the trigger event id (and doubles as the approval
# token), carol can grant/deny without any extra token bookkeeping.
#
# 1. alice triggers → run_id
# 2. bot run --once → agent work → 46010 gate (state=awaiting_approval)
# 3. carol approves → 46030 (d = run_id)
# 4. bot run --once → resolves the gate → --on-approve → 46005 completed
# 5. alice show → WorkflowRunAggregator folds it to state=completed + PR
# 6. deny path: a second run, carol denies → state=denied (work discarded)
#
# Usage: ./workflow-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-workflow-loop"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
PORT=7788
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"
CHANNEL="3f2504e0-4f89-41d3-9a0c-0305e82c3301"
WFID="agent-build"
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"; BOT_HOME="$STATE_DIR/bot"; APV_HOME="$STATE_DIR/carol"
mkdir -p "$REQ_HOME" "$BOT_HOME" "$APV_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_bot() { HOME="$BOT_HOME" "$AMY_BIN" --account bot --secret-backend plaintext --json "$@" 2>/dev/null; }
run_apv() { HOME="$APV_HOME" "$AMY_BIN" --account carol --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 <label> <actual> <expected>
if [[ "$2" == "$3" ]]; then echo " ✓ PASS $1 ($2)" >&2; PASS=$((PASS+1));
else echo " ✗ FAIL $1: expected [$3] got [$2]" >&2; FAIL=$((FAIL+1)); fi
}
contains() { # contains <label> <haystack> <needle>
if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1" >&2; PASS=$((PASS+1));
else echo " ✗ FAIL $1: [$2] has no [$3]" >&2; FAIL=$((FAIL+1)); fi
}
run_req init >/dev/null; run_bot init >/dev/null; run_apv init >/dev/null
ALICE=$(run_req whoami | jkey hex)
BOT=$(run_bot whoami | jkey hex)
CAROL=$(run_apv whoami | jkey hex)
echo "> alice=$ALICE bot=$BOT carol=$CAROL" >&2
HOME="$BOT_HOME" "$AMY_BIN" --account bot --secret-backend plaintext serve --port "$PORT" >"$RELAY_LOG" 2>&1 &
RELAY_PID=$!
for _ in $(seq 1 60); do grep -q "relay up" "$RELAY_LOG" && break; sleep 0.5; done
grep -q "relay up" "$RELAY_LOG" || { echo "relay never came up" >&2; cat "$RELAY_LOG" >&2; exit 1; }
# A tiny repo for the runner to worktree-isolate each run.
REPO="$STATE_DIR/repo"; mkdir -p "$REPO"
git -C "$REPO" -c init.defaultBranch=main init -q
git -C "$REPO" -c user.email=a@b.c -c user.name=t commit -q --allow-empty -m init
echo "> 1. alice triggers the workflow (kind-46020)" >&2
TRIG=$(run_req buzz workflow trigger "$RELAY" "$WFID" --task "add dark mode" --channel "$CHANNEL")
RUN_ID=$(echo "$TRIG" | jkey run_id)
check "trigger published" "$(echo "$TRIG" | jkey published)" "true"
[[ -n "$RUN_ID" ]] && { echo " ✓ PASS got run id $RUN_ID" >&2; PASS=$((PASS+1)); } || { echo " ✗ FAIL no run id" >&2; FAIL=$((FAIL+1)); }
echo "> 2. bot runs the runner once: agent work → posts the 46010 approval gate" >&2
R1=$(run_bot buzz workflow run "$RELAY" \
--exec 'printf "diff: %s on %s" "$(cat)" "$BUZZ_BRANCH"' \
--on-approve 'printf "https://github.com/x/y/pull/7"' \
--channel "$CHANNEL" --approver "$CAROL" --worktree "$REPO" --base-ref main --once)
check "runner handled the trigger" "$(echo "$R1" | jkey handled)" "1"
SHOW1=$(run_req buzz workflow show "$RELAY" "$RUN_ID")
check "run is parked at the gate" "$(echo "$SHOW1" | jkey state)" "awaiting_approval"
check "gate addressed to carol" "$(echo "$SHOW1" | jkey pending_approver)" "$CAROL"
check "approval token is the run id" "$(echo "$SHOW1" | jkey approval_token)" "$RUN_ID"
echo "> 3. carol approves (kind-46030, d = run id)" >&2
GRANT=$(run_apv buzz workflow approve "$RELAY" "$RUN_ID" --note "lgtm")
check "grant published" "$(echo "$GRANT" | jkey published)" "true"
echo "> 4. bot runs again: the grant resolves the gate → --on-approve → 46005" >&2
R2=$(run_bot buzz workflow run "$RELAY" \
--exec 'printf "diff: %s on %s" "$(cat)" "$BUZZ_BRANCH"' \
--on-approve 'printf "https://github.com/x/y/pull/7"' \
--channel "$CHANNEL" --approver "$CAROL" --worktree "$REPO" --base-ref main --once)
check "runner resolved 1 gate" "$(echo "$R2" | jkey handled)" "1"
echo "> 5. alice reads back the completed run" >&2
SHOW2=$(run_req buzz workflow show "$RELAY" "$RUN_ID")
check "state completed" "$(echo "$SHOW2" | jkey state)" "completed"
check "requester" "$(echo "$SHOW2" | jkey requester)" "$ALICE"
check "workflow id" "$(echo "$SHOW2" | jkey workflow)" "$WFID"
contains "result carries the PR url from --on-approve" "$(echo "$SHOW2" | jkey result)" "https://github.com/x/y/pull/7"
echo "> 6. deny path: a second run, carol denies → denied (work discarded)" >&2
TRIG2=$(run_req buzz workflow trigger "$RELAY" "$WFID" --task "risky change" --channel "$CHANNEL")
RUN_ID2=$(echo "$TRIG2" | jkey run_id)
run_bot buzz workflow run "$RELAY" --exec 'printf done' --on-approve 'printf pr' \
--channel "$CHANNEL" --approver "$CAROL" --worktree "$REPO" --base-ref main --once >/dev/null
run_apv buzz workflow deny "$RELAY" "$RUN_ID2" --note "not yet" >/dev/null
run_bot buzz workflow run "$RELAY" --exec 'printf done' --on-approve 'printf pr' \
--channel "$CHANNEL" --approver "$CAROL" --worktree "$REPO" --base-ref main --once >/dev/null
SHOW3=$(run_req buzz workflow show "$RELAY" "$RUN_ID2")
check "denied run is terminal (denied)" "$(echo "$SHOW3" | jkey state)" "denied"
echo "> 7. leftover worktrees cleaned up" >&2
LEFTOVER=$(git -C "$REPO" worktree list | sed 1d | wc -l | tr -d ' ')
check "worktrees removed after runs" "$LEFTOVER" "0"
echo "" >&2
echo "> RESULTS: $PASS passed, $FAIL failed" >&2
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
@@ -0,0 +1,200 @@
/*
* 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.commons.model.buzz
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
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.nip25Reactions.ReactionEvent
/** The lifecycle state of one Buzz agent job, folded from its 43001-43006 events. */
enum class JobState {
/** Only the 43001 request is present — no agent has picked it up yet. */
REQUESTED,
/** An agent published a 43002 acceptance. */
ACCEPTED,
/** At least one 43003 progress ping has arrived after acceptance. */
IN_PROGRESS,
/** Terminal success — a 43004 result. */
COMPLETED,
/** Terminal failure — a 43006 error. */
FAILED,
/** Terminal cancellation — a 43005 cancel. */
CANCELLED,
}
/**
* One agent job, correlated from its request and every reply that references it.
*
* A job is identified by the id of its [JobRequestEvent] (kind 43001). Every reply
* (accepted/progress/result/error/cancel) points back to that id via its `e` tag, so
* the whole thread folds into a single record regardless of how many relays served it.
*/
@Immutable
data class JobView(
val jobId: HexKey,
/** The request author — who asked for the work (or a `p` counterparty when the request event is absent). */
val requester: HexKey?,
/** The target/responding agent — the request `p` tag, else whoever signed the agent-side replies. */
val agent: HexKey?,
/** The `h` channel UUID the job is scoped to, if any. */
val channel: String?,
/** The task description (43001 content); null when the request event wasn't in the input set. */
val request: String?,
val state: JobState,
/** The most recent 43003 progress message, if any. */
val lastProgress: String?,
val progressUpdates: Int,
/** The 43004 result payload, when [state] is COMPLETED. */
val result: String?,
/** The 43006 error message, when [state] is FAILED. */
val error: String?,
/** The 43005 cancel reason, when [state] is CANCELLED. */
val cancelReason: String?,
/**
* Distinct channel members who upvoted this job a NIP-25 like (kind-7, content not `-`)
* whose `e` tag targets the job's request id. The group's priority signal: a scheduler
* orders the backlog by this, newest-first as a tiebreaker.
*/
val upvotes: Int,
/** The request timestamp, or the earliest correlated event when the request is absent. */
val createdAt: Long,
/** The timestamp of the most recent event in the thread. */
val updatedAt: Long,
) {
val isTerminal: Boolean get() = state == JobState.COMPLETED || state == JobState.FAILED || state == JobState.CANCELLED
}
/**
* Folds a flat list of Buzz agent-job events (kinds 43001-43006, as dispatched by
* `EventFactory`) into per-job [JobView] records. Pure and platform-agnostic so both the
* `amy` CLI and a future mobile Jobs board share one correlation + state machine.
*
* SCHEMA CAVEAT: the 43001-43006 protocol is *reserved* in Buzz (no upstream builder);
* the tag layout this reads (`e` request reference, `h` channel, `p` counterparty,
* `status` token) is Quartz's best-effort model and must be reconciled once Buzz
* implements the job protocol. See [JobRequestEvent].
*/
object BuzzJobAggregator {
fun aggregate(events: List<Event>): List<JobView> {
if (events.isEmpty()) return emptyList()
val distinct = events.distinctBy { it.id }
// Upvotes: distinct authors of a NIP-25 like (kind-7, content not `-`) per targeted
// event id. Counting distinct pubkeys stops one member inflating priority by spamming.
val upvoters = HashMap<HexKey, MutableSet<HexKey>>()
distinct.forEach { e ->
if (e is ReactionEvent && e.content != ReactionEvent.DISLIKE) {
e.originalPost().forEach { target -> upvoters.getOrPut(target) { mutableSetOf() }.add(e.pubKey) }
}
}
// Correlate every job event to a job id: the request is its own id; a reply carries
// the request id in its `e` tag. Replies we can't correlate (no `e`) are dropped.
val byJob = LinkedHashMap<HexKey, MutableList<Event>>()
distinct.forEach { e ->
val jobId =
when (e) {
is JobRequestEvent -> e.id
is JobAcceptedEvent -> e.jobRequest()
is JobProgressEvent -> e.jobRequest()
is JobResultEvent -> e.jobRequest()
is JobErrorEvent -> e.jobRequest()
is JobCancelEvent -> e.jobRequest()
else -> null
} ?: return@forEach
byJob.getOrPut(jobId) { mutableListOf() }.add(e)
}
return byJob
.map { (jobId, thread) -> fold(jobId, thread, upvoters[jobId]?.size ?: 0) }
.sortedByDescending { it.updatedAt }
}
private fun fold(
jobId: HexKey,
thread: List<Event>,
upvotes: Int,
): JobView {
val request = thread.filterIsInstance<JobRequestEvent>().maxByOrNull { it.createdAt }
val accepted = thread.filterIsInstance<JobAcceptedEvent>().maxByOrNull { it.createdAt }
val progress = thread.filterIsInstance<JobProgressEvent>().sortedBy { it.createdAt }
val result = thread.filterIsInstance<JobResultEvent>().maxByOrNull { it.createdAt }
val error = thread.filterIsInstance<JobErrorEvent>().maxByOrNull { it.createdAt }
val cancel = thread.filterIsInstance<JobCancelEvent>().maxByOrNull { it.createdAt }
// Among terminal outcomes, the newest one wins (a result can supersede a cancel and
// vice-versa depending on arrival order — trust the latest signed timestamp).
val terminal =
listOfNotNull(
result?.let { JobState.COMPLETED to it.createdAt },
error?.let { JobState.FAILED to it.createdAt },
cancel?.let { JobState.CANCELLED to it.createdAt },
).maxByOrNull { it.second }?.first
val state =
terminal
?: when {
progress.isNotEmpty() -> JobState.IN_PROGRESS
accepted != null -> JobState.ACCEPTED
else -> JobState.REQUESTED
}
val requester = request?.pubKey ?: cancel?.pubKey ?: accepted?.requester() ?: result?.requester()
val agent = request?.target() ?: accepted?.pubKey ?: result?.pubKey ?: error?.pubKey ?: progress.lastOrNull()?.pubKey
return JobView(
jobId = jobId,
requester = requester,
agent = agent,
channel = request?.channel() ?: accepted?.channel() ?: result?.channel(),
request = request?.request(),
state = state,
lastProgress = progress.lastOrNull()?.content,
progressUpdates = progress.size,
result = result?.result(),
error = error?.error(),
cancelReason = cancel?.reason()?.ifBlank { null },
upvotes = upvotes,
createdAt = request?.createdAt ?: thread.minOf { it.createdAt },
updatedAt = thread.maxOf { it.createdAt },
)
}
/**
* Backlog ordering for a scheduler: most-upvoted first (the group's priority signal),
* oldest-first as the tiebreaker so an un-upvoted item still drains FIFO. Applies to the
* caller-provided set (typically the REQUESTED jobs targeting the agent).
*/
fun byPriority(jobs: List<JobView>): List<JobView> = jobs.sortedWith(compareByDescending<JobView> { it.upvotes }.thenBy { it.createdAt })
}
@@ -0,0 +1,222 @@
/*
* 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.commons.model.buzz
import androidx.compose.runtime.Immutable
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 kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* The Buzz workflow run/step lifecycle events (46001-46012) carry only a channel `h` tag and a
* free-form JSON `content` (they're relay-emitted and their content schema isn't pinned by Buzz).
* This is the shape the Amethyst runner emits and the aggregator reads. The **run id is the
* trigger event id**, and it doubles as the approval token so an [ApprovalGrantEvent]'s `d`
* tag equals the run id, and no separate token bookkeeping is needed.
*/
@Serializable
data class WorkflowRunPayload(
val run: String? = null,
val workflow: String? = null,
val task: String? = null,
val step: String? = null,
val pr: String? = null,
val error: String? = null,
val note: String? = null,
)
/** The lifecycle state of one workflow run. */
enum class WorkflowRunState {
TRIGGERED,
RUNNING,
/** Paused on a 46010 approval gate — a human must grant or deny before it proceeds. */
AWAITING_APPROVAL,
APPROVED,
COMPLETED,
FAILED,
/** The relay/runner cancelled the run (46007). */
CANCELLED,
/** A human denied the approval gate (46031). */
DENIED,
}
/** One workflow run, folded from its trigger + lifecycle + approval events. */
@Immutable
data class WorkflowRun(
val runId: HexKey,
val workflowId: String?,
val channel: String?,
val task: String?,
/** Who triggered the run (the 46020 author). */
val requester: HexKey?,
val state: WorkflowRunState,
/** When [AWAITING_APPROVAL], the key the 46010 asked to decide (its `p` tag). */
val pendingApprover: HexKey?,
/** The result payload of a completed run — e.g. the PR URL. */
val result: String?,
val error: String?,
val lastStep: String?,
val createdAt: Long,
val updatedAt: Long,
) {
val isTerminal: Boolean
get() =
state == WorkflowRunState.COMPLETED || state == WorkflowRunState.FAILED ||
state == WorkflowRunState.CANCELLED || state == WorkflowRunState.DENIED
/** The approval token to grant/deny this run is the run id itself. */
val approvalToken: HexKey get() = runId
}
/**
* Folds Buzz workflow events into per-run [WorkflowRun] records. Correlates by run id: the trigger
* (46020) *is* the run (id = run id); every lifecycle event (46001-46010) carries `run` in its JSON
* content; an approval grant/deny (46030/46031) references the run via its `d` tag (= the token =
* the run id). Pure and platform-agnostic shared by the `amy` runner/CLI and the mobile app.
*
* This is Buzz's **source-confirmed** structured-work + human-approval primitive (the command
* events 30620/46020/46030/46031 are pinned against buzz-relay's Rust handlers); the lifecycle
* content shape is Amethyst's, since Buzz leaves it relay-defined. See
* `cli/plans/2026-07-25-buzz-agent-support-channel.md`.
*/
object WorkflowRunAggregator {
private val json = Json { ignoreUnknownKeys = true }
private fun payload(content: String): WorkflowRunPayload? = runCatching { json.decodeFromString<WorkflowRunPayload>(content) }.getOrNull()
fun aggregate(events: List<Event>): List<WorkflowRun> {
if (events.isEmpty()) return emptyList()
val distinct = events.distinctBy { it.id }
// run id -> events that belong to it.
val byRun = LinkedHashMap<HexKey, MutableList<Event>>()
// Approval decisions reference the run via the `d` tag (= run id = token).
val grants = HashMap<HexKey, Long>() // runId -> newest grant time
val denies = HashMap<HexKey, Long>()
distinct.forEach { e ->
when (e) {
is WorkflowTriggerEvent -> byRun.getOrPut(e.id) { mutableListOf() }.add(e)
is WorkflowTriggeredEvent -> route(e, byRun)
is WorkflowStepStartedEvent -> route(e, byRun)
is WorkflowStepCompletedEvent -> route(e, byRun)
is WorkflowApprovalRequestedEvent -> route(e, byRun)
is WorkflowCompletedEvent -> route(e, byRun)
is WorkflowFailedEvent -> route(e, byRun)
is WorkflowCancelledEvent -> route(e, byRun)
is ApprovalGrantEvent -> e.tokenHash()?.let { grants[it] = maxOf(grants[it] ?: 0, e.createdAt) }
is ApprovalDenyEvent -> e.tokenHash()?.let { denies[it] = maxOf(denies[it] ?: 0, e.createdAt) }
else -> Unit
}
}
return byRun
.map { (runId, thread) -> fold(runId, thread, grants[runId], denies[runId]) }
.sortedByDescending { it.updatedAt }
}
/** Group a lifecycle event under the run id carried in its JSON `content`. */
private fun route(
e: Event,
byRun: MutableMap<HexKey, MutableList<Event>>,
) {
val run = payload(e.content)?.run ?: return
byRun.getOrPut(run) { mutableListOf() }.add(e)
}
private fun fold(
runId: HexKey,
thread: List<Event>,
grantAt: Long?,
denyAt: Long?,
): WorkflowRun {
val trigger = thread.filterIsInstance<WorkflowTriggerEvent>().maxByOrNull { it.createdAt }
val triggered = thread.filterIsInstance<WorkflowTriggeredEvent>().maxByOrNull { it.createdAt }
val approval = thread.filterIsInstance<WorkflowApprovalRequestedEvent>().maxByOrNull { it.createdAt }
val completed = thread.filterIsInstance<WorkflowCompletedEvent>().maxByOrNull { it.createdAt }
val failed = thread.filterIsInstance<WorkflowFailedEvent>().maxByOrNull { it.createdAt }
val cancelled = thread.filterIsInstance<WorkflowCancelledEvent>().maxByOrNull { it.createdAt }
val steps =
(thread.filterIsInstance<WorkflowStepStartedEvent>() + thread.filterIsInstance<WorkflowStepCompletedEvent>())
.sortedBy { it.createdAt }
// Parse each event's JSON content at most once — `fold` runs per run, per board re-derive.
val triggerPayload = trigger?.let { payload(it.content) }
val triggeredPayload = triggered?.let { payload(it.content) }
// Terminal outcomes and the deny decision compete by timestamp — newest wins.
val terminal =
listOfNotNull(
completed?.let { WorkflowRunState.COMPLETED to it.createdAt },
failed?.let { WorkflowRunState.FAILED to it.createdAt },
cancelled?.let { WorkflowRunState.CANCELLED to it.createdAt },
denyAt?.let { WorkflowRunState.DENIED to it },
).maxByOrNull { it.second }?.first
val state =
terminal
?: when {
// An approval that hasn't been granted yet holds the run at the gate.
approval != null && (grantAt == null || approval.createdAt > grantAt) -> WorkflowRunState.AWAITING_APPROVAL
grantAt != null -> WorkflowRunState.APPROVED
steps.isNotEmpty() || triggered != null -> WorkflowRunState.RUNNING
else -> WorkflowRunState.TRIGGERED
}
return WorkflowRun(
runId = runId,
workflowId = trigger?.workflowId() ?: triggerPayload?.workflow ?: triggeredPayload?.workflow,
channel = trigger?.tags?.workflowChannel() ?: approval?.channel() ?: triggered?.channel(),
task = triggerPayload?.task ?: triggeredPayload?.task,
requester = trigger?.pubKey,
state = state,
pendingApprover = approval?.approver(),
result = completed?.let { payload(it.content)?.pr },
error = failed?.let { payload(it.content)?.error },
lastStep = steps.lastOrNull()?.let { payload(it.content)?.step },
createdAt = trigger?.createdAt ?: thread.minOf { it.createdAt },
updatedAt = maxOf(thread.maxOf { it.createdAt }, grantAt ?: 0, denyAt ?: 0),
)
}
/** Backlog ordering: awaiting-approval first (needs a human), then running, then the rest by recency. */
fun byPriority(runs: List<WorkflowRun>): List<WorkflowRun> =
runs.sortedWith(
compareByDescending<WorkflowRun> { it.state == WorkflowRunState.AWAITING_APPROVAL }
.thenByDescending { it.updatedAt },
)
}
@@ -0,0 +1,237 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BuzzJobAggregatorTest {
private val requester = "a".repeat(64)
private val agent = "f".repeat(64)
private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
private val jobId = "1".repeat(64)
// Events are built via the quartz template and constructed directly with an explicit
// id/author/createdAt (no signing) — mirroring the quartz job-event unit tests. The
// aggregator only reads typed accessors, so unsigned fixtures exercise it faithfully.
private fun request(
id: String = jobId,
createdAt: Long = 1000,
): JobRequestEvent {
val t = JobRequestEvent.build("fix the login bug", channel, agent, createdAt)
return JobRequestEvent(id, requester, t.createdAt, t.tags, t.content, "sig")
}
private fun accepted(createdAt: Long = 1010): JobAcceptedEvent {
val t = JobAcceptedEvent.build(jobId, channel, requester, "on it", createdAt)
return JobAcceptedEvent("2".repeat(64), agent, t.createdAt, t.tags, t.content, "sig")
}
private fun progress(
msg: String,
createdAt: Long,
id: String,
): JobProgressEvent {
val t = JobProgressEvent.build(jobId, msg, channel, "running", createdAt)
return JobProgressEvent(id, agent, t.createdAt, t.tags, t.content, "sig")
}
private fun result(createdAt: Long = 1040): JobResultEvent {
val t = JobResultEvent.build(jobId, "opened PR #123", channel, requester, "completed", createdAt)
return JobResultEvent("4".repeat(64), agent, t.createdAt, t.tags, t.content, "sig")
}
private fun error(createdAt: Long = 1040): JobErrorEvent {
val t = JobErrorEvent.build(jobId, "build failed", channel, status = "error", createdAt = createdAt)
return JobErrorEvent("6".repeat(64), agent, t.createdAt, t.tags, t.content, "sig")
}
private fun cancel(createdAt: Long = 1035): JobCancelEvent {
val t = JobCancelEvent.build(jobId, "changed my mind", channel, createdAt)
return JobCancelEvent("5".repeat(64), requester, t.createdAt, t.tags, t.content, "sig")
}
// A NIP-25 like targeting a job's request id — the group's upvote signal.
private fun like(
reactor: String,
target: String = jobId,
id: String,
content: String = "+",
): ReactionEvent = ReactionEvent(id, reactor, 1050, arrayOf(arrayOf("e", target)), content, "sig")
@Test
fun emptyInput() {
assertEquals(emptyList(), BuzzJobAggregator.aggregate(emptyList()))
}
@Test
fun fullHappyPath() {
val events: List<Event> =
listOf(
request(),
accepted(),
progress("cloning", 1020, "a1".padEnd(64, '0')),
progress("running tests", 1030, "a2".padEnd(64, '0')),
result(),
)
val jobs = BuzzJobAggregator.aggregate(events)
assertEquals(1, jobs.size)
val job = jobs.single()
assertEquals(jobId, job.jobId)
assertEquals(requester, job.requester)
assertEquals(agent, job.agent)
assertEquals(channel, job.channel)
assertEquals("fix the login bug", job.request)
assertEquals(JobState.COMPLETED, job.state)
assertTrue(job.isTerminal)
assertEquals("opened PR #123", job.result)
assertEquals("running tests", job.lastProgress)
assertEquals(2, job.progressUpdates)
assertEquals(1000, job.createdAt)
assertEquals(1040, job.updatedAt)
}
@Test
fun requestOnlyIsRequested() {
val job = BuzzJobAggregator.aggregate(listOf(request())).single()
assertEquals(JobState.REQUESTED, job.state)
assertTrue(!job.isTerminal)
assertNull(job.result)
assertEquals(0, job.progressUpdates)
}
@Test
fun acceptedWithoutProgressIsAccepted() {
val job = BuzzJobAggregator.aggregate(listOf(request(), accepted())).single()
assertEquals(JobState.ACCEPTED, job.state)
}
@Test
fun progressWithoutTerminalIsInProgress() {
val job =
BuzzJobAggregator
.aggregate(listOf(request(), accepted(), progress("working", 1020, "a1".padEnd(64, '0'))))
.single()
assertEquals(JobState.IN_PROGRESS, job.state)
}
@Test
fun errorIsFailed() {
val job = BuzzJobAggregator.aggregate(listOf(request(), accepted(), error())).single()
assertEquals(JobState.FAILED, job.state)
assertEquals("build failed", job.error)
}
@Test
fun cancelIsCancelled() {
val job = BuzzJobAggregator.aggregate(listOf(request(), cancel())).single()
assertEquals(JobState.CANCELLED, job.state)
assertEquals("changed my mind", job.cancelReason)
}
@Test
fun newestTerminalWins() {
// A cancel at 1035 followed by a result at 1040: the later result is authoritative.
val job = BuzzJobAggregator.aggregate(listOf(request(), cancel(1035), result(1040))).single()
assertEquals(JobState.COMPLETED, job.state)
// Reversed timing: a result at 1030 then a cancel at 1050 → CANCELLED.
val job2 = BuzzJobAggregator.aggregate(listOf(request(), result(1030), cancel(1050))).single()
assertEquals(JobState.CANCELLED, job2.state)
}
@Test
fun orphanRepliesWithoutRequestStillFold() {
// The 43001 request wasn't served to us, only the agent's result. We still surface
// the job, keyed by the `e`-referenced request id, with the request text unknown.
val job = BuzzJobAggregator.aggregate(listOf(accepted(), result())).single()
assertEquals(jobId, job.jobId)
assertNull(job.request)
assertEquals(JobState.COMPLETED, job.state)
assertEquals(agent, job.agent)
// requester is recovered from the reply's `p` tag when the request event is absent.
assertEquals(requester, job.requester)
}
@Test
fun upvotesCountDistinctReactorsIgnoringDislikes() {
val bob = "b".repeat(64)
val carol = "c".repeat(64)
val events =
listOf(
request(),
like(bob, id = "e1".padEnd(64, '0')),
like(carol, id = "e2".padEnd(64, '0')),
like(bob, id = "e3".padEnd(64, '0')), // same reactor again → not double-counted
like(carol, id = "e4".padEnd(64, '0'), content = "-"), // dislike → excluded
)
val job = BuzzJobAggregator.aggregate(events).single()
assertEquals(2, job.upvotes)
}
@Test
fun byPriorityOrdersByUpvotesThenAge() {
val jobA = "a".repeat(64) // older, 0 upvotes
val jobB = "b".repeat(64) // newer, 2 upvotes
val jobC = "c".repeat(64) // oldest, 0 upvotes
fun req(
id: String,
at: Long,
): JobRequestEvent {
val t = JobRequestEvent.build("task $id", channel, agent, at)
return JobRequestEvent(id, requester, t.createdAt, t.tags, t.content, "sig")
}
val events =
listOf(
req(jobA, 2000),
req(jobB, 3000),
req(jobC, 1000),
like("1".repeat(64), target = jobB, id = "u1".padEnd(64, '0')),
like("2".repeat(64), target = jobB, id = "u2".padEnd(64, '0')),
)
val ordered = BuzzJobAggregator.byPriority(BuzzJobAggregator.aggregate(events))
// B first (2 upvotes), then the 0-upvote jobs oldest-first: C (1000) before A (2000).
assertEquals(listOf(jobB, jobC, jobA), ordered.map { it.jobId })
}
@Test
fun multipleJobsSortedByUpdatedAtDesc() {
val otherId = "9".repeat(64)
val otherReq = JobRequestEvent.build("second task", channel, agent, 2000)
val other = JobRequestEvent(otherId, requester, otherReq.createdAt, otherReq.tags, otherReq.content, "sig")
val jobs = BuzzJobAggregator.aggregate(listOf(request(), result(), other))
assertEquals(2, jobs.size)
// `request()`+`result()` updates at 1040; `other` at 2000 → other is newest, first.
assertEquals(otherId, jobs.first().jobId)
}
}
@@ -0,0 +1,131 @@
/*
* 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.commons.model.buzz
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.WorkflowCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
class WorkflowRunAggregatorTest {
private val requester = "a".repeat(64)
private val approver = "b".repeat(64)
private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
private val wf = "agent-build"
private val runId = "1".repeat(64) // the trigger event id == run id == approval token
private fun trigger(createdAt: Long = 1000): WorkflowTriggerEvent {
val t = WorkflowTriggerEvent.build(wf, """{"task":"add dark mode"}""", createdAt) { workflowChannel(channel) }
return WorkflowTriggerEvent(runId, requester, t.createdAt, t.tags, t.content, "sig")
}
private fun stepStarted(createdAt: Long = 1010): WorkflowStepStartedEvent {
val t = WorkflowStepStartedEvent.build(channel, """{"run":"$runId","step":"build"}""", createdAt)
return WorkflowStepStartedEvent("2".repeat(64), requester, t.createdAt, t.tags, t.content, "sig")
}
private fun approvalRequested(createdAt: Long = 1020): WorkflowApprovalRequestedEvent {
val t = WorkflowApprovalRequestedEvent.build(channel, approver, """{"run":"$runId"}""", createdAt)
return WorkflowApprovalRequestedEvent("3".repeat(64), requester, t.createdAt, t.tags, t.content, "sig")
}
private fun grant(createdAt: Long = 1030): ApprovalGrantEvent {
val t = ApprovalGrantEvent.build(runId, "lgtm", createdAt)
return ApprovalGrantEvent("4".repeat(64), approver, t.createdAt, t.tags, t.content, "sig")
}
private fun deny(createdAt: Long = 1030): ApprovalDenyEvent {
val t = ApprovalDenyEvent.build(runId, "not yet", createdAt)
return ApprovalDenyEvent("5".repeat(64), approver, t.createdAt, t.tags, t.content, "sig")
}
private fun completed(createdAt: Long = 1040): WorkflowCompletedEvent {
val t = WorkflowCompletedEvent.build(channel, """{"run":"$runId","pr":"https://github.com/x/y/pull/7"}""", createdAt)
return WorkflowCompletedEvent("6".repeat(64), requester, t.createdAt, t.tags, t.content, "sig")
}
private fun agg(events: List<Event>) = WorkflowRunAggregator.aggregate(events).single()
@Test
fun triggerOnlyIsTriggered() {
val run = agg(listOf(trigger()))
assertEquals(WorkflowRunState.TRIGGERED, run.state)
assertEquals(wf, run.workflowId)
assertEquals(channel, run.channel)
assertEquals("add dark mode", run.task)
assertEquals(requester, run.requester)
}
@Test
fun stepStartedIsRunning() {
assertEquals(WorkflowRunState.RUNNING, agg(listOf(trigger(), stepStarted())).state)
}
@Test
fun approvalRequestedPausesTheRun() {
val run = agg(listOf(trigger(), stepStarted(), approvalRequested()))
assertEquals(WorkflowRunState.AWAITING_APPROVAL, run.state)
assertEquals(approver, run.pendingApprover)
assertEquals(runId, run.approvalToken)
}
@Test
fun grantResumesToApproved() {
val run = agg(listOf(trigger(), stepStarted(), approvalRequested(), grant()))
assertEquals(WorkflowRunState.APPROVED, run.state)
}
@Test
fun completedIsTerminalWithResult() {
val run = agg(listOf(trigger(), stepStarted(), approvalRequested(), grant(), completed()))
assertEquals(WorkflowRunState.COMPLETED, run.state)
assertEquals("https://github.com/x/y/pull/7", run.result)
}
@Test
fun denyIsTerminal() {
val run = agg(listOf(trigger(), stepStarted(), approvalRequested(), deny()))
assertEquals(WorkflowRunState.DENIED, run.state)
}
@Test
fun newerApprovalAfterAGrantPausesAgain() {
// A fresh approval gate (createdAt 1050) after an earlier grant (1030) holds the run.
val run = agg(listOf(trigger(), grant(1030), approvalRequested(1050)))
assertEquals(WorkflowRunState.AWAITING_APPROVAL, run.state)
}
@Test
fun awaitingApprovalSortsFirst() {
val otherId = "9".repeat(64)
val otherTrig = WorkflowTriggerEvent.build(wf, """{"task":"other"}""", 3000) { workflowChannel(channel) }
val other = WorkflowTriggerEvent(otherId, requester, otherTrig.createdAt, otherTrig.tags, otherTrig.content, "sig")
// `runId` is awaiting approval (older); `other` is a newer plain trigger.
val runs = WorkflowRunAggregator.byPriority(WorkflowRunAggregator.aggregate(listOf(trigger(), approvalRequested(), other)))
assertEquals(runId, runs.first().runId) // needs-a-human beats recency
}
}
@@ -50,6 +50,9 @@ class JobErrorEvent(
/** The channel this job is scoped to - the `h` tag. */
fun channel() = tags.jobChannel()
/** The requester the failure is addressed to - the `p` tag. */
fun requester() = tags.jobParticipant()
/** The optional status token - the `status` tag. */
fun status() = tags.jobStatus()
@@ -63,12 +66,14 @@ class JobErrorEvent(
requestId: HexKey,
error: String,
channelId: String? = null,
requester: HexKey? = null,
status: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JobErrorEvent>.() -> Unit = {},
) = eventTemplate<JobErrorEvent>(KIND, error, createdAt) {
jobRequest(requestId)
channelId?.let { jobChannel(it) }
requester?.let { jobParticipant(it) }
status?.let { jobStatus(it) }
initializer()
}
@@ -0,0 +1,112 @@
/*
* 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.quartz.buzz.relay
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.tags.AuthTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
/**
* A **private, agent-authorized workspace** relay policy the thin server half that lets
* `geode` (via `amy serve --buzz`) host a Buzz-style agent channel without Block's Rust relay
* + Postgres/Redis/MinIO stack. Built on quartz's own relay-server code.
*
* It extends [FullAuthPolicy], so it runs the full NIP-42 handshake and then layers Buzz's
* two authorization rules on top:
*
* 1. **Membership.** Only a [members] key (the team) may publish or read. A completed NIP-42
* auth alone is not enough auth proves identity; membership is the authorization.
* 2. **NIP-OA virtual membership.** An un-enrolled *agent* key is granted membership **for its
* connection** when its NIP-42 auth event carries an owner-signed `auth` tag
* ([com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation]) whose owner is a
* member and whose signature authorizes that agent the same primitive Block's relay uses
* to make agents first-class without enrolling every key. The grant lives only as long as
* the connection (a fresh policy is built per connection), so revoking the owner's
* membership or dropping the socket revokes the agent.
*
* What this deliberately does NOT do (out of scope for a job channel; needs a relay that
* *emits* signed events, which a policy cannot): relay-signed NIP-29 metadata (39000-39003),
* relay-assigned DM UUIDs, and workflow execution (46xxx). See
* `cli/plans/2026-07-25-buzz-agent-support-channel.md`.
*
* [allowedKinds], when non-null, further restricts every author to those kinds (e.g. lock a
* channel down to the Buzz job + chat + reaction kinds); null accepts any kind.
*/
open class BuzzMembershipPolicy(
relay: NormalizedRelayUrl,
private val members: Set<HexKey>,
private val allowedKinds: Set<Int>? = null,
) : FullAuthPolicy(relay) {
/** Agent keys granted virtual membership on THIS connection via a valid NIP-OA `auth` tag. */
private val authorizedAgents = mutableSetOf<HexKey>()
/**
* Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event
* carries an owner-signed attestation authorizing this agent, and the owner is a member,
* remember the agent as a member for this connection. We never throw here a missing or
* invalid attestation just means the key is authenticated but not (yet) authorized, which the
* membership gate below handles.
*/
override suspend fun authorize(event: RelayAuthEvent) {
val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return
if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) {
authorizedAgents.add(event.pubKey)
}
}
private fun isMember(pubKey: HexKey): Boolean = pubKey in members || pubKey in authorizedAgents
/**
* The read gate as a rejection reason, or null to allow. Crucially, an *unauthenticated*
* connection is told `auth-required` (not `restricted`) so the client runs the NIP-42
* handshake and retries only an authenticated non-member is `restricted`.
*/
private fun readGate(): String? =
when {
authenticatedUsers.isEmpty() -> "auth-required: authenticate before reading this workspace"
authenticatedUsers.any(::isMember) -> null
else -> "restricted: not a workspace member"
}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val author = cmd.event.pubKey
if (author !in authenticatedUsers) {
return PolicyResult.Rejected("auth-required: authenticate before publishing")
}
if (!isMember(author)) {
return PolicyResult.Rejected("restricted: not a workspace member")
}
allowedKinds?.let {
if (cmd.event.kind !in it) return PolicyResult.Rejected("restricted: kind ${cmd.event.kind} is not accepted on this workspace")
}
return PolicyResult.Accepted(cmd)
}
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd)
}
@@ -26,16 +26,18 @@ import kotlin.test.assertEquals
class JobErrorEventTest {
private val requestId = "e".repeat(64)
private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
private val requester = "a".repeat(64)
@Test
fun buildTagsAndContent() {
val t = JobErrorEvent.build(requestId, "tool timeout", channel, "failed")
val t = JobErrorEvent.build(requestId, "tool timeout", channel, requester, "failed")
val ev = JobErrorEvent("00", "f".repeat(64), t.createdAt, t.tags, t.content, "sig")
assertEquals(JobErrorEvent.KIND, ev.kind)
assertEquals("tool timeout", ev.error())
assertEquals(requestId, ev.jobRequest())
assertEquals(channel, ev.channel())
assertEquals(requester, ev.requester())
assertEquals("failed", ev.status())
}
}
@@ -0,0 +1,145 @@
/*
* 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.quartz.buzz.relay
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.tags.AuthTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertTrue
class BuzzMembershipPolicyTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://work.example.com")!!
private val owner = KeyPair()
private val agent = KeyPair()
private val stranger = KeyPair()
private val ownerPub = owner.pubKey.toHexKey()
private val agentPub = agent.pubKey.toHexKey()
private val strangerPub = stranger.pubKey.toHexKey()
private class Ctx(
override val authenticatedUsers: Set<HexKey>,
) : RequestContext {
override val connectionId = 1L
override val policy: IRelayPolicy = EmptyPolicy
}
/** A fresh policy whose connection has [authed] recorded as authenticated. */
private fun policyOn(authed: Set<HexKey>): BuzzMembershipPolicy = BuzzMembershipPolicy(relay, members = setOf(ownerPub)).apply { onConnect(Ctx(authed)) {} }
private fun event(
author: HexKey,
kind: Int = 40002,
) = EventCmd(Event("00", author, 0, kind, emptyArray(), "", "sig"))
private fun req() = ReqCmd("sub", emptyList())
private fun accepted(r: PolicyResult<*>) = r is PolicyResult.Accepted
private fun reason(r: PolicyResult<*>) = (r as PolicyResult.Rejected).reason
@Test
fun memberMayPublish() {
assertTrue(accepted(policyOn(setOf(ownerPub)).accept(event(ownerPub))))
}
@Test
fun authenticatedNonMemberIsRejected() {
val r = policyOn(setOf(strangerPub)).accept(event(strangerPub))
assertTrue(reason(r).startsWith("restricted"))
}
@Test
fun unauthenticatedIsRejected() {
val r = policyOn(emptySet()).accept(event(ownerPub))
assertTrue(reason(r).startsWith("auth-required"))
}
@Test
fun readsAreMemberGated() {
assertTrue(accepted(policyOn(setOf(ownerPub)).accept(req())))
// Authenticated non-member: restricted. Unauthenticated: auth-required (so the client
// runs NIP-42 and retries, rather than silently getting nothing).
assertTrue(reason(policyOn(setOf(strangerPub)).accept(req())).startsWith("restricted"))
assertTrue(reason(policyOn(emptySet()).accept(req())).startsWith("auth-required"))
}
@Test
fun allowedKindsRestrictsMembers() {
val policy = BuzzMembershipPolicy(relay, setOf(ownerPub), allowedKinds = setOf(40002)).apply { onConnect(Ctx(setOf(ownerPub))) {} }
assertTrue(accepted(policy.accept(event(ownerPub, kind = 40002))))
assertTrue(reason(policy.accept(event(ownerPub, kind = 1))).startsWith("restricted"))
}
@Test
fun nipOaAgentIsGrantedMembershipForTheConnection() =
runTest {
// The owner (a member) attests the un-enrolled agent key.
val attestation = OwnerAttestation.sign(agentPub, "", owner.privKey!!)
val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(attestation)), "", "sig")
val policy = policyOn(setOf(agentPub))
policy.onAuthenticated(authEvent) // engine calls this after the NIP-42 proof verifies
assertTrue(accepted(policy.accept(event(agentPub))), "attested agent may publish")
assertTrue(accepted(policy.accept(req())), "attested agent may read")
}
@Test
fun attestationFromANonMemberOwnerIsIgnored() =
runTest {
// The attestation is validly signed, but by an owner who is NOT a workspace member.
val outsider = KeyPair()
val attestation = OwnerAttestation.sign(agentPub, "", outsider.privKey!!)
val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(attestation)), "", "sig")
val policy = policyOn(setOf(agentPub))
policy.onAuthenticated(authEvent)
assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted"), "unauthorized agent is rejected")
}
@Test
fun tamperedAttestationIsIgnored() =
runTest {
// Owner IS a member, but the signature doesn't match the agent (tampered).
val real = OwnerAttestation.sign(strangerPub, "", owner.privKey!!) // signed for a DIFFERENT key
val forged = OwnerAttestation(ownerPub, real.conditions, real.sig)
val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(forged)), "", "sig")
val policy = policyOn(setOf(agentPub))
policy.onAuthenticated(authEvent)
assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted"))
}
}
+104
View File
@@ -0,0 +1,104 @@
# Buzz agent `--exec` wrapper
`agent-exec.sh` is the reference command you point `amy buzz agent serve --exec` at to turn
Buzz job requests into pull requests. It runs a coding agent (Claude Code by default) on the
task inside the job's isolated git worktree, then commits, pushes the job branch, and opens a
PR — printing the PR URL as the job result. **It never touches the default branch and never
force-pushes; the merge is a human action on GitHub.**
See the design in [`cli/plans/2026-07-25-buzz-agent-support-channel.md`](../../cli/plans/2026-07-25-buzz-agent-support-channel.md).
## The contract (how the scheduler calls it)
| Channel | Meaning |
|---|---|
| **stdin** | the task text (the kind-43001 request) |
| **cwd** | the job's git worktree — a fresh branch off `--base-ref` |
| **env** | `BUZZ_JOB_ID` `BUZZ_REQUESTER` `BUZZ_CHANNEL` `BUZZ_RELAY` `BUZZ_AGENT` `BUZZ_UPVOTES` `BUZZ_BRANCH` `BUZZ_WORKTREE` `BUZZ_BASE_REF` |
| **stdout** | becomes the job **result** (kind-43004) — the wrapper prints the PR URL |
| **non-zero exit** | becomes the job **error** (kind-43006); stderr is the detail |
Its steps: read task → run agent → verify a diff exists → commit → push the branch → open (or
reuse) the PR → print the URL.
## Two paths: gated vs direct
- **Direct (ungated) jobs**`agent-exec.sh` (this file's subject) does agent → commit → push → PR
in one shot. Point `amy buzz agent serve` at it.
- **Gated workflow runs** — the work pauses on a human-approval gate before anything ships, so it's
split into two steps around the gate: **`workflow-agent.sh`** (the `--exec` step: agent → commit,
no push) and **`workflow-ship.sh`** (the `--on-approve` step: push → PR, runs only after a human
grants). Point `amy buzz workflow run` at them.
## The easy button: `amy buzz agent up`
For the gated path you don't need to wire any of this by hand. `amy` bundles `workflow-agent.sh` +
`workflow-ship.sh` and runs them for you:
```bash
amy buzz agent up wss://your-buzz-relay --repo /path/to/your/checkout --approver npub1you…
```
It resolves the channel (the relay's only one, or pass `--channel`), defaults the worktree to
`--repo`, scopes intake to the channel roster (`--accept-from-channel`), and extracts the wrappers to
`~/.amy/buzz-agent/` (edit them there to customize, or pass your own `--exec`/`--on-approve`). Before
first run, check the host is safe:
```bash
amy buzz agent doctor --repo /path/to/your/checkout # gh token scope + branch protection + clean tree
```
## Usage (manual / direct path)
```bash
amy buzz agent serve wss://your-buzz-relay <channel-uuid> \
--exec /path/to/tools/buzz-agent/agent-exec.sh \
--worktree /path/to/amethyst \
--accept-from-channel \
--parallel 2
```
The bundled copies `agent up` extracts live in `cli/src/main/resources/buzz-agent/`; the copies here
in `tools/buzz-agent/` are the readable, customizable reference (same content).
`--worktree` is **required** (the wrapper needs `BUZZ_BRANCH`/`BUZZ_WORKTREE`). `--parallel N`
runs N jobs at once, each in its own worktree+branch.
## Config knobs (env)
| Var | Default | Purpose |
|---|---|---|
| `AGENT_CMD` | *(unset)* | Override the whole agent invocation. Receives the prompt on **stdin** and as `$AGENT_PROMPT`. Use this for Goose/Codex or a custom runner. |
| `AGENT_ALLOWED_TOOLS` | `Edit,Write,Read,Bash,Glob,Grep` | Claude Code `--allowedTools` — the agent's capability boundary. |
| `COMMIT_PREFIX` | `feat` | Prefix for the auto-commit subject when the agent didn't commit. |
Adjust the default `claude -p … --permission-mode acceptEdits --allowedTools …` line to match
your Claude Code version, or bypass it entirely with `AGENT_CMD`.
## Security — the guardrails that make this safe
Buzz authorizes by identity, not capability flags, so **none** of the "can't merge or destroy
`main`, can't code other things" guarantees come from Buzz. They come from three things you
configure here:
1. **A PR-only git credential.** Authenticate `gh` on the agent host with a **fine-grained PAT
scoped to the `amethyst` repo only**, granting exactly **Contents: Read and write** +
**Pull requests: Read and write** — and nothing else. No Administration, no org scope. The
token can open PRs on feature branches; it cannot merge, bypass checks, or reach other repos.
2. **Branch protection on the default branch.** Protect `main`: require a pull request, require
review approval, require status checks (CI) to pass, and **block force-pushes and branch
deletion**. Do **not** grant the bot an exception. This is what actually stops a bad merge —
the wrapper only ever pushes `claude/job-*` feature branches.
3. **Scoped intake + agent tools.** Run the scheduler with `--accept-from` / `--accept-from-channel`
so only your team's keys can file jobs, keep `AGENT_ALLOWED_TOOLS` tight, and run on a host
whose checkout is amethyst only — so the agent "can't code other things."
The merge is deliberately outside this loop: a completed job's result is its PR, and a human
merges it on GitHub.
## Testing
`agent-exec.sh` is verified end-to-end against a throwaway repo with a stubbed `gh` + agent
(reads task → runs agent → commits the diff → pushes the feature branch → prints the PR URL;
and errors cleanly when the agent makes no changes). Point `AGENT_CMD` at a stub to dry-run the
git/PR plumbing without invoking a real agent.
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# agent-exec.sh — reference `--exec` wrapper for `amy buzz agent serve`.
#
# The scheduler runs this once per Buzz job (kind-43001). Contract:
# stdin ............ the task text (the job request)
# cwd .............. the job's isolated git worktree (a fresh branch off --base-ref)
# env .............. BUZZ_JOB_ID BUZZ_REQUESTER BUZZ_CHANNEL BUZZ_RELAY BUZZ_AGENT
# BUZZ_UPVOTES BUZZ_BRANCH BUZZ_WORKTREE BUZZ_BASE_REF
# stdout ........... becomes the job RESULT (kind-43004) — we print the PR URL
# non-zero exit .... becomes the job ERROR (kind-43006); stderr is the error detail
#
# What it does: runs a coding agent (Claude Code by default) on the task inside the
# worktree, commits + pushes the job branch, opens a PR, and prints the PR URL. It NEVER
# touches the default branch and NEVER force-pushes — the merge is a human action on
# GitHub. See README.md for the required token scope + branch-protection checklist.
#
# Host requirements: git, gh (authenticated with a PR-ONLY token), and the agent CLI
# (`claude`), or a custom command via $AGENT_CMD.
set -euo pipefail
log() { printf '%s\n' "$*" >&2; } # progress → stderr (job-error tail on failure)
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# --- config knobs (all overridable via env) --------------------------------
AGENT_CMD="${AGENT_CMD:-}" # override the entire agent invocation; reads the
# prompt on stdin and as $AGENT_PROMPT
AGENT_ALLOWED_TOOLS="${AGENT_ALLOWED_TOOLS:-Edit,Write,Read,Bash,Glob,Grep}"
COMMIT_PREFIX="${COMMIT_PREFIX:-feat}"
# --- guards ----------------------------------------------------------------
[[ -n "${BUZZ_BRANCH:-}" ]] || die "BUZZ_BRANCH unset — run the scheduler with --worktree"
[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run the scheduler with --worktree"
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"
# 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
# Pin the branch's starting commit now, so "did the agent change anything?" is correct even
# when --base-ref is the symbolic "HEAD" (which moves as the agent commits).
base_sha="$(git rev-parse HEAD)"
# --- 1. read the task ------------------------------------------------------
task="$(cat)"
[[ -n "${task//[[:space:]]/}" ]] || die "empty task"
title="$(printf '%s' "$task" | head -n1 | cut -c1-72)"
# --- 2. run the coding agent inside the worktree ---------------------------
log "[agent-exec] job ${BUZZ_JOB_ID:-?}: running agent on: $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. Keep changes scoped to this repository.
TASK:
$task"
if [[ -n "$AGENT_CMD" ]]; then
export AGENT_PROMPT="$prompt"
agent_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)"
agent_summary="$(claude -p "$prompt" --permission-mode acceptEdits --allowedTools "$AGENT_ALLOWED_TOOLS" 2>&1)" ||
die "claude run failed"
fi
# --- 3. verify the agent produced changes ----------------------------------
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
# --- 4. commit anything the agent left uncommitted -------------------------
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 "Filed via Buzz job ${BUZZ_JOB_ID:-unknown}."
fi
# --- 5. push the job branch (feature branch only, never --force) -----------
log "[agent-exec] pushing $BUZZ_BRANCH"
git push -u origin "HEAD:$BUZZ_BRANCH" || die "push failed (is a PR-only token configured?)"
# --- 6. open (or reuse) the PR ---------------------------------------------
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="$task
---
Filed via Buzz job \`${BUZZ_JOB_ID:-unknown}\`${BUZZ_REQUESTER:+ by \`$BUZZ_REQUESTER\`}.
Agent notes:
$agent_summary"
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
# --- 7. emit the result → job 43004 ----------------------------------------
printf 'Opened PR: %s\n' "$pr_url"
+70
View File
@@ -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"
+46
View File
@@ -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"