From 8dcc78056d16874031cd790f71c792dab7f5810a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 23:37:22 -0400 Subject: [PATCH] refactor(buzz): move the Backlog and Workflow boards into the channel overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new boards were added as top-bar icons, which put a Buzz channel back to four (Canvas, Backlog, Workflow runs, ⋮) and truncated the title row the bar is there to show — "nosfabrica.commun…" instead of the full relay. That is the crowding #3729 had just removed; this branch predates it and the merge stacked them. Canvas keeps the only icon: it is the channel's shared document, i.e. content. Backlog and Workflow runs are two more views of the channel, reached occasionally, so they join Threads and Share in the overflow — and pick up the `!isDm` gate the icons never had. Also gives the job board a string resource; the branch's i18n pass left "Backlog" hardcoded in JobBoardScreen and in the icon's contentDescription. Adds cli/tests/buzz/agent-exec.sh, which covers what the two loop harnesses stub out. job-loop.sh proves the scheduler drives *an* --exec program; nothing committed exercised the real one — the wrapper that turns a job into a PR. With a stubbed `gh` and agent (no network, credentials, or Claude Code) it asserts the happy path end to end (task on stdin → agent → commit → push the job branch → PR url on stdout) and, as importantly, the paths that must fail: an agent that changed nothing becomes a job error, an empty task is rejected before the agent runs, missing scheduler env is a hard error rather than a silent no-op, an agent that committed for itself is not double-committed, and the default-branch guard holds — asserting `main` on the remote is left untouched. 19/19. Verified on emulator-5554: the bar is Canvas + ⋮ again with the full relay name visible, the menu reads Threads / Backlog / Workflow runs / Share / Members / Leave, and Backlog still opens from it. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/screen/loggedIn/buzz/JobBoardScreen.kt | 4 +- .../relayGroup/RelayGroupTopBar.kt | 39 ++++--- amethyst/src/main/res/values/strings.xml | 1 + .../2026-07-25-buzz-agent-support-channel.md | 15 ++- cli/tests/.gitignore | 1 + cli/tests/buzz/agent-exec.sh | 109 ++++++++++++++++++ 6 files changed, 147 insertions(+), 22 deletions(-) create mode 100755 cli/tests/buzz/agent-exec.sh diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt index 9c254f7d85..9d0d7bbe65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt @@ -79,6 +79,7 @@ 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 @@ -92,6 +93,7 @@ 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 @@ -132,7 +134,7 @@ fun JobBoardScreen( var composing by remember { mutableStateOf(false) } Scaffold( - topBar = { TopBarWithBackButton("Backlog", nav) }, + topBar = { TopBarWithBackButton(stringRes(R.string.buzz_job_board_title), nav) }, floatingActionButton = { ExtendedFloatingActionButton( onClick = { composing = true }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 553c01ee79..73392f1934 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -183,7 +183,8 @@ fun RelayGroupTopBar( // outright. So a DM shows the icon only when a canvas already exists — which is also what // keeps us from advertising "start a shared doc" in a two-person conversation. val hasCanvas by observeBuzzCanvas(channel.groupId.id) - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && (!isDm || hasCanvas)) { + val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + if (isBuzzRelay && (!isDm || hasCanvas)) { IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) { Icon( symbol = MaterialSymbols.Dashboard, @@ -191,22 +192,6 @@ fun RelayGroupTopBar( modifier = Modifier.size(20.dp), ) } - // Buzz agent backlog (kinds 43001-43006): the channel's shared job board. - IconButton(onClick = { nav.nav(Route.BuzzJobBoard(channel.groupId.id, channel.groupId.relayUrl.url)) }) { - Icon( - symbol = MaterialSymbols.Checklist, - contentDescription = "Backlog", - modifier = Modifier.size(20.dp), - ) - } - // Buzz workflow runs (46020 + lifecycle): the channel's shared run board + approval gates. - IconButton(onClick = { nav.nav(Route.BuzzWorkflowBoard(channel.groupId.id, channel.groupId.relayUrl.url)) }) { - Icon( - symbol = MaterialSymbols.Gavel, - contentDescription = "Workflow runs", - modifier = Modifier.size(20.dp), - ) - } } // remember the bech32 (naddr) encode — this top bar recomposes on every roster/metadata @@ -263,6 +248,26 @@ fun RelayGroupTopBar( }, ) } + // The agent surfaces: this channel's job backlog (43001-43006) and workflow runs + // (46020 + lifecycle). Menu entries rather than icons — they are two more views of + // the channel, reached occasionally, and as icons they 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_job_board_title)) }, + onClick = { + menuOpen = false + nav.nav(Route.BuzzJobBoard(channel.groupId.id, channel.groupId.relayUrl.url)) + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.buzz_workflow_runs_title)) }, + onClick = { + menuOpen = false + nav.nav(Route.BuzzWorkflowBoard(channel.groupId.id, channel.groupId.relayUrl.url)) + }, + ) + } if (naddr != null) { val context = LocalContext.current DropdownMenuItem( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b830adf5f7..5d453094bc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4854,6 +4854,7 @@ Public and ephemeral — no history, anyone in the cell can read it. + Backlog Workflow runs New run Needs your approval diff --git a/cli/plans/2026-07-25-buzz-agent-support-channel.md b/cli/plans/2026-07-25-buzz-agent-support-channel.md index f1ea5ed3bb..bccdcb77a8 100644 --- a/cli/plans/2026-07-25-buzz-agent-support-channel.md +++ b/cli/plans/2026-07-25-buzz-agent-support-channel.md @@ -147,8 +147,10 @@ is a coherent *owner telemetry* surface and should stay that — just get a bett 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 (a `Checklist` action → `Route.BuzzJobBoard(channelId, relayUrl)`), NOT -inside the owner Console. Keeping "owner fleet telemetry" and "this channel's shared backlog" +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 @@ -162,7 +164,8 @@ 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 `RelayGroupTopBar` on Buzz relays). + `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), @@ -231,10 +234,14 @@ wire handling, both verified against geode: - `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 `RelayGroupTopBar` on Buzz relays). Folds the workflow kinds via + 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 diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 77d0da270e..a74c7de8b8 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -9,3 +9,4 @@ blossom/state-blossom-live/ git/state-git-nip34/ buzz/state-job-loop/ buzz/state-workflow-loop/ +buzz/state-agent-exec/ diff --git a/cli/tests/buzz/agent-exec.sh b/cli/tests/buzz/agent-exec.sh new file mode 100755 index 0000000000..8ebff83d5b --- /dev/null +++ b/cli/tests/buzz/agent-exec.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# agent-exec.sh — self-contained headless test for the `--exec` wrapper that turns a Buzz job +# into a pull request (tools/buzz-agent/agent-exec.sh). +# +# job-loop.sh proves the scheduler drives *an* --exec program; this proves the real one does the +# right thing with git and `gh`. Both the agent and `gh` are stubbed, so it needs no network, no +# credentials, and no Claude Code — it exercises the plumbing around them: +# +# task on stdin → agent → verify a diff → commit → push the job branch → open/reuse PR → url +# +# The failure paths matter as much as the happy one: an agent that changed nothing must become a +# job error, and the default-branch guard must actually hold. +# +# Usage: ./agent-exec.sh +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +EXEC="$REPO_ROOT/tools/buzz-agent/agent-exec.sh" +BASE="$SCRIPT_DIR/state-agent-exec" +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then echo " ✓ PASS $1 ($2)"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: got [$2] want [$3]"; FAIL=$((FAIL+1)); fi; } +contains() { if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: [$2] lacks [$3]"; FAIL=$((FAIL+1)); fi; } + +rm -rf "$BASE"; mkdir -p "$BASE/bin" + +# --- stub gh: default branch, no existing PR, and a PR URL on create ------------------------- +cat > "$BASE/bin/gh" <<'GH' +#!/usr/bin/env bash +case "$1 $2" in + "repo view") echo main ;; + "pr list") echo "" ;; # no open PR for this head + "pr create") echo "https://github.com/example/repo/pull/42" ;; + *) exit 1 ;; +esac +GH +chmod +x "$BASE/bin/gh" +export PATH="$BASE/bin:$PATH" + +# --- a bare "origin" so the real `git push` in the script has somewhere to go ---------------- +git init -q --bare "$BASE/origin.git" +git init -q "$BASE/repo" +cd "$BASE/repo" +git config user.email t@t; git config user.name t +echo seed > seed.txt; git add -A; git -c commit.gpgsign=false commit -qm seed +git branch -M main +git remote add origin "$BASE/origin.git" +git push -q -u origin main + +run_case() { # run_case ; echoes exit code, sets OUT/ERRTXT + local branch="$1" agentcmd="$2" task="${3:-Add a greeting file}" + git -C "$BASE/repo" checkout -q main + git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null + git -C "$BASE/repo" worktree add -q -b "$branch" "$BASE/wt" main 2>/dev/null + OUT="$(cd "$BASE/wt" && printf '%s' "$task" | env \ + BUZZ_JOB_ID=job123 BUZZ_REQUESTER=alice BUZZ_BRANCH="$branch" \ + BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD="$agentcmd" \ + bash "$EXEC" 2>"$BASE/err.txt")" + RC=$? + ERRTXT="$(cat "$BASE/err.txt")" + return $RC +} + +echo "> 1. happy path: agent edits a file → commit → push → PR url on stdout" +run_case "claude/job-1" 'echo "wrote greeting" && echo hello > greeting.txt' +check "exit code" "$?" "0" +contains "stdout is the PR url" "$OUT" "https://github.com/example/repo/pull/42" +check "branch landed on origin" "$(git -C "$BASE/origin.git" rev-parse --verify -q claude/job-1 >/dev/null && echo yes || echo no)" "yes" +check "commit was made" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1" +contains "commit subject uses the task's first line" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "feat: Add a greeting file" +contains "file the agent wrote is in the commit" "$(git -C "$BASE/wt" show --stat --oneline HEAD)" "greeting.txt" +contains "the agent got the task on stdin" "$ERRTXT" "Add a greeting file" + +echo "> 2. agent makes no changes → job error, non-zero exit" +run_case "claude/job-2" 'echo "thought about it"' +check "exit code" "$?" "1" +contains "explains why" "$ERRTXT" "no changes" + +echo "> 3. agent commits by itself → wrapper pushes, does not double-commit" +run_case "claude/job-3" 'echo x > f.txt && git add -A && git -c user.email=a@b -c user.name=a commit -qm "agent: own commit"' +check "exit code" "$?" "0" +check "exactly one commit" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1" +contains "kept the agent's own subject" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "agent: own commit" + +echo "> 4. refuses to operate on the default branch" +# the worktree sits on a scratch branch, but the scheduler hands it BUZZ_BRANCH=main — +# exactly the mistake the guard exists to stop. +git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null +git -C "$BASE/repo" worktree add -q -b scratch-guard "$BASE/wt" main +OUT="$(cd "$BASE/wt" && printf 'do a thing' | env BUZZ_JOB_ID=job4 BUZZ_BRANCH=main BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD='echo hi > x.txt' bash "$EXEC" 2>"$BASE/err.txt")"; RC=$? +ERRTXT="$(cat "$BASE/err.txt")" +check "exit code" "$RC" "1" +contains "says why" "$ERRTXT" "refusing to operate on the default branch" +check "nothing was pushed to main" "$(git -C "$BASE/origin.git" rev-parse main)" "$(git -C "$BASE/repo" rev-parse main)" + +echo "> 5. missing scheduler env is a hard error, not a silent no-op" +OUT="$(printf 'task' | env -u BUZZ_BRANCH BUZZ_WORKTREE="$BASE/wt" bash "$EXEC" 2>&1)"; RC=$? +check "exit code" "$RC" "1" +contains "names the missing var" "$OUT" "BUZZ_BRANCH" + +echo "> 6. empty task is rejected before the agent runs" +run_case "claude/job-6" 'echo should-not-run > ran.txt' " " +check "exit code" "$?" "1" +contains "says why" "$ERRTXT" "empty task" + +echo +echo "> RESULTS: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]]