mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -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,
|
||||
|
||||
+206
-46
@@ -20,49 +20,89 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
// Common event kinds an agent might be restricted to — suggestions for the "Restrict to kind" field;
|
||||
// any 0–65535 is still accepted by free numeric entry.
|
||||
private val KIND_OPTIONS =
|
||||
listOf(
|
||||
DropdownOption("1", "1 · Text note"),
|
||||
DropdownOption("7", "7 · Reaction"),
|
||||
DropdownOption("9", "9 · Group chat message"),
|
||||
DropdownOption("1111", "1111 · Comment"),
|
||||
DropdownOption("30023", "30023 · Long-form article"),
|
||||
DropdownOption("40002", "40002 · Buzz minichat message"),
|
||||
)
|
||||
|
||||
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
|
||||
|
||||
/**
|
||||
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
|
||||
@@ -85,7 +125,7 @@ fun AgentAttestationScreen(
|
||||
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Attestations", nav) },
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_attest_topbar), nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
@@ -106,7 +146,7 @@ fun AgentAttestationScreen(
|
||||
if (privKey == null) {
|
||||
ReadOnlyKeyNotice()
|
||||
} else {
|
||||
AttestationForm(ownerKey = keyPair)
|
||||
AttestationForm(ownerKey = keyPair, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,26 +169,26 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Hold an attestation",
|
||||
text = stringRes(R.string.buzz_attest_hold_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (mine != null) {
|
||||
Text(
|
||||
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
|
||||
text = stringRes(R.string.buzz_attest_holding),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
|
||||
text = stringRes(R.string.buzz_attest_grants_prefix, mine.conditions.ifEmpty { stringRes(R.string.buzz_attest_grants_unrestricted) }),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
Text("Remove")
|
||||
Text(stringRes(R.string.buzz_attest_remove))
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
|
||||
text = stringRes(R.string.buzz_attest_hold_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -158,7 +198,7 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
input = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("auth tag JSON") },
|
||||
label = { Text(stringRes(R.string.buzz_attest_authtag_label)) },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -179,7 +219,7 @@ private fun HoldAttestationSection(myPubkey: String) {
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Hold attestation")
|
||||
Text(stringRes(R.string.buzz_attest_hold_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,16 +269,12 @@ private fun ReadOnlyKeyNotice() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Local key required",
|
||||
text = stringRes(R.string.buzz_attest_readonly_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
|
||||
"so it can only be produced by a signer that holds your raw private key. This " +
|
||||
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
|
||||
"sign an attestation.",
|
||||
text = stringRes(R.string.buzz_attest_readonly_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -246,11 +282,15 @@ private fun ReadOnlyKeyNotice() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationForm(ownerKey: KeyPair) {
|
||||
private fun AttestationForm(
|
||||
ownerKey: KeyPair,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var agentInput by remember { mutableStateOf("") }
|
||||
var selectedAgent by remember { mutableStateOf<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 0–65535, but the common ones have names — offer them, keep free numeric entry.
|
||||
EditableSuggestDropdown(
|
||||
value = kindInput,
|
||||
onValueChange = {
|
||||
kindInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Restrict to kind (0–65535)") },
|
||||
singleLine = true,
|
||||
label = stringRes(R.string.buzz_attest_kind_label),
|
||||
options = KIND_OPTIONS,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = KIND_LABELS[kindInput],
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -303,9 +348,11 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events after (unix seconds)") },
|
||||
label = { Text(stringRes(R.string.buzz_attest_after_label)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
// Echo the entered epoch back as a readable UTC time so nobody has to eyeball unix seconds.
|
||||
supportingText = unixEcho(afterInput)?.let { echo -> { Text(echo) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -316,9 +363,10 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events before (unix seconds)") },
|
||||
label = { Text(stringRes(R.string.buzz_attest_before_label)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = unixEcho(beforeInput)?.let { echo -> { Text(echo) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -332,7 +380,8 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
val agent = selectedAgent ?: return@Button
|
||||
when (val outcome = buildAttestation(agent, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
is AttestationOutcome.Failure -> {
|
||||
error = outcome.message
|
||||
result = null
|
||||
@@ -343,10 +392,10 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = agentInput.isNotBlank(),
|
||||
enabled = selectedAgent != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Generate attestation")
|
||||
Text(stringRes(R.string.buzz_attest_generate))
|
||||
}
|
||||
|
||||
result?.let { attestation ->
|
||||
@@ -357,6 +406,112 @@ private fun AttestationForm(ownerKey: KeyPair) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-agent people picker: once an agent is chosen it shows as a removable chip; otherwise a
|
||||
* name typeahead over the local user cache with an npub/hex paste escape hatch (Enter accepts it).
|
||||
* Mirrors the New-DM recipient picker so authorizing an agent stops being the one raw-key field.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgentKeyPicker(
|
||||
selected: HexKey?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onSelect: (HexKey) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
if (selected != null) {
|
||||
AgentChip(selected, accountViewModel, nav, onClear)
|
||||
return
|
||||
}
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var suggestions by remember { mutableStateOf<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\"" }
|
||||
|
||||
+42
-19
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+142
@@ -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 },
|
||||
)
|
||||
}
|
||||
+581
@@ -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
|
||||
}
|
||||
+244
@@ -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)
|
||||
}
|
||||
}
|
||||
+94
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+511
@@ -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
|
||||
}
|
||||
+156
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
+234
@@ -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"
|
||||
}
|
||||
+951
@@ -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
|
||||
}
|
||||
+285
@@ -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)
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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(
|
||||
|
||||
+25
@@ -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,
|
||||
|
||||
+21
@@ -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 ||
|
||||
|
||||
@@ -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 & 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 (0–65535)</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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 🆕. |
|
||||
|
||||
@@ -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 **S–M**.
|
||||
- **P1-4 Attestation persistence** — `BuzzHeldAttestations` is in-memory; survive restart.
|
||||
Size **S–M**.
|
||||
|
||||
**P2 — completeness**: agent-profile (10100) viewer; a stable "Agents" hub;
|
||||
workflow-run timeline (46020 family, all stored, unrendered); turn-metric → job attribution.
|
||||
|
||||
Key files: routes `amethyst/.../navigation/routes/Routes.kt`; render dispatch
|
||||
`.../chats/feed/ChatMessageCompose.kt`; renderers `.../chats/feed/types/RenderBuzzNotes.kt`;
|
||||
ingest `model/LocalCache.kt` (~L4780-4855); subscription
|
||||
`.../relayGroup/datasource/RelayGroupFilterBuilders.kt`.
|
||||
|
||||
## Pivot — jobs → workflows (2026-07-26)
|
||||
|
||||
The 43001-43006 job prototype above proved the *shape* (drive an agent from a shared channel,
|
||||
worktree-isolate, PR-only, merge-on-GitHub), but those kinds are **reserved/speculative** with no
|
||||
upstream builder. Buzz's **real, source-confirmed** structured-work primitive is the **workflow**
|
||||
family — the command kinds are pinned against buzz-relay's Rust `command_executor.rs`:
|
||||
|
||||
- **30620** workflow definition, **46020** trigger, **46001-46007** run/step lifecycle,
|
||||
- **46010** approval-requested gate, **46030 / 46031** grant / deny.
|
||||
|
||||
So the driving surface switched to workflows. What that buys over jobs: a **first-class
|
||||
human-approval gate** (46010 → 46030/46031) baked into the protocol — the exact "anyone in the
|
||||
channel can drive, but a human gates the merge" model the goal asks for — rather than relying on
|
||||
GitHub branch-protection alone.
|
||||
|
||||
**Divergence (documented):** on a real Buzz relay the *relay* parses the workflow YAML and executes
|
||||
it, signing the lifecycle + approval events. Self-hosted on geode there is no workflow engine, so
|
||||
**`amy` is the runner** (`amy buzz workflow run`) and emits the lifecycle events itself. The command
|
||||
events (30620/46020/46030/46031) stay faithful to Buzz; only the lifecycle *content* shape is
|
||||
Amethyst's (Buzz leaves it relay-defined).
|
||||
|
||||
**Correlation:** the **run id is the trigger's event id and doubles as the approval token**, so a
|
||||
grant's `d` tag equals the run id — no separate token bookkeeping. Two store realities shaped the
|
||||
wire handling, both verified against geode:
|
||||
- quartz's `SQLiteEventStore` routes every `#d` filter to the addressable `d_tag` column (NULL for a
|
||||
regular kind like 46030), so **decisions are fetched by author** — every 46010 gate names its
|
||||
approver in a `p` tag — and matched to their run by the token the aggregator reads off the event.
|
||||
- The runner is **restart-safe**: runs still at the gate (AWAITING_APPROVAL / APPROVED / DENIED) are
|
||||
rebuilt into the in-flight map from the run id on startup (worktree path + branch are
|
||||
deterministic), so a decision arriving in a later poll — or a fresh `--once` process — still
|
||||
resolves. The relay is the source of truth, not the in-memory map.
|
||||
|
||||
**Landed (CLI + commons):**
|
||||
- `commons/.../model/buzz/WorkflowRuns.kt` — `WorkflowRunAggregator` folds trigger + lifecycle +
|
||||
grant/deny into per-run state (`WorkflowRunAggregatorTest`, 8 cases).
|
||||
- `cli/.../commands/BuzzWorkflowCommands.kt` — `trigger` / `list` / `show` / `approve` / `deny` and
|
||||
the **`run`** runner (agent work → 46010 gate → on grant runs `--on-approve` → 46005 completed; a
|
||||
deny discards the worktree, run is DENIED). Wired into `amy buzz workflow`.
|
||||
- `cli/tests/buzz/workflow-loop.sh` — end-to-end headless harness (alice triggers, bot runs,
|
||||
carol approves/denies) through embedded geode; 14/14 green, including the deny path and
|
||||
worktree cleanup.
|
||||
- `cli/tests/buzz/agent-exec.sh` — covers the real `--exec` wrapper the loop harnesses stub out:
|
||||
task → agent → commit → push → PR url, plus the paths that must fail (no diff, empty task,
|
||||
missing scheduler env) and the default-branch guard, asserting `main` is left unmoved. Stubbed
|
||||
`gh` + agent, so no network, credentials, or Claude Code; 19/19 green.
|
||||
|
||||
**Landed (Android app):**
|
||||
- `WorkflowRunBoardScreen` + `WorkflowRunBoardViewModel` (per channel, `Route.BuzzWorkflowBoard`,
|
||||
entered from the `RelayGroupTopBar` overflow menu on Buzz relays). Folds the workflow kinds via
|
||||
`WorkflowRunAggregator`, groups runs by state with **"Needs your approval" pinned first**, and the
|
||||
named approver grants/denies a paused run inline (46030/46031). Merge stays on GitHub.
|
||||
- `Account.triggerBuzzWorkflow` / `approveBuzzWorkflowRun` / `denyBuzzWorkflowRun` (same
|
||||
sign → local-echo → publish-to-group-relay contract as the job helpers).
|
||||
- `RelayGroupFilterBuilders` subscribes the `#h`-scoped workflow kinds; the board fetches the
|
||||
`d`-only grant/deny decisions **by author** (the CLI's approach).
|
||||
- `NotificationFeedFilter` — a 46010 gate addressed to me notifies and is **push-eligible** (added
|
||||
to `NOTIFICATION_KINDS` + an `acceptableEvent` early-return gating on `approver() == me`).
|
||||
- Backbone reused as-is: quartz `EventFactory` already registers the 46xxx kinds and `LocalCache`
|
||||
already ingests them (store-only), so no protocol/ingest changes were needed.
|
||||
|
||||
So the workflow **run board + approval gate is the P0-1 approvals surface** the mobile section below
|
||||
anticipated. The jobs board/code stays for now, but the workflow path is the one matching Buzz
|
||||
upstream.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
1. Reconcile 43001-43006 with Buzz upstream once it defines the job protocol (or retire the job
|
||||
path in favor of workflows).
|
||||
2. Wire the P0 mobile screens (approvals inbox + jobs board) on top of `BuzzJobAggregator` /
|
||||
`WorkflowRunAggregator`.
|
||||
3. ✅ **Done** — a reference `--exec` wrapper (`tools/buzz-agent/agent-exec.sh` + README) runs
|
||||
the coding agent in the job worktree, commits, pushes the feature branch, opens a PR with a
|
||||
PR-only token, and prints the URL as the job result — with the branch-protection + token-scope
|
||||
checklist documented. Verified end-to-end against a stubbed `gh`/agent.
|
||||
4. Consider promoting the approval gate (46010) into the responder for irreversible steps.
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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/
|
||||
|
||||
Executable
+109
@@ -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 ]]
|
||||
Executable
+115
@@ -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
|
||||
Executable
+148
@@ -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
|
||||
+200
@@ -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 })
|
||||
}
|
||||
+222
@@ -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 },
|
||||
)
|
||||
}
|
||||
+237
@@ -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)
|
||||
}
|
||||
}
|
||||
+131
@@ -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()
|
||||
}
|
||||
|
||||
+112
@@ -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)
|
||||
}
|
||||
+3
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+104
@@ -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"
|
||||
Executable
+70
@@ -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"
|
||||
Executable
+46
@@ -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"
|
||||
Reference in New Issue
Block a user