refactor(buzz): move the Backlog and Workflow boards into the channel overflow

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-26 23:37:22 -04:00
co-authored by Claude Opus 5
parent ba1baa359f
commit 8dcc78056d
6 changed files with 147 additions and 22 deletions
@@ -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 },
@@ -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(
+1
View File
@@ -4854,6 +4854,7 @@
<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_workflow_new_run">New run</string>
<string name="buzz_workflow_section_awaiting">Needs your approval</string>
@@ -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
+1
View File
@@ -9,3 +9,4 @@ blossom/state-blossom-live/
git/state-git-nip34/
buzz/state-job-loop/
buzz/state-workflow-loop/
buzz/state-agent-exec/
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
#
# agent-exec.sh — self-contained headless test for the `--exec` wrapper that turns a Buzz job
# into a pull request (tools/buzz-agent/agent-exec.sh).
#
# job-loop.sh proves the scheduler drives *an* --exec program; this proves the real one does the
# right thing with git and `gh`. Both the agent and `gh` are stubbed, so it needs no network, no
# credentials, and no Claude Code — it exercises the plumbing around them:
#
# task on stdin → agent → verify a diff → commit → push the job branch → open/reuse PR → url
#
# The failure paths matter as much as the happy one: an agent that changed nothing must become a
# job error, and the default-branch guard must actually hold.
#
# Usage: ./agent-exec.sh
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
EXEC="$REPO_ROOT/tools/buzz-agent/agent-exec.sh"
BASE="$SCRIPT_DIR/state-agent-exec"
PASS=0; FAIL=0
check() { if [[ "$2" == "$3" ]]; then echo " ✓ PASS $1 ($2)"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: got [$2] want [$3]"; FAIL=$((FAIL+1)); fi; }
contains() { if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1"; PASS=$((PASS+1)); else echo " ✗ FAIL $1: [$2] lacks [$3]"; FAIL=$((FAIL+1)); fi; }
rm -rf "$BASE"; mkdir -p "$BASE/bin"
# --- stub gh: default branch, no existing PR, and a PR URL on create -------------------------
cat > "$BASE/bin/gh" <<'GH'
#!/usr/bin/env bash
case "$1 $2" in
"repo view") echo main ;;
"pr list") echo "" ;; # no open PR for this head
"pr create") echo "https://github.com/example/repo/pull/42" ;;
*) exit 1 ;;
esac
GH
chmod +x "$BASE/bin/gh"
export PATH="$BASE/bin:$PATH"
# --- a bare "origin" so the real `git push` in the script has somewhere to go ----------------
git init -q --bare "$BASE/origin.git"
git init -q "$BASE/repo"
cd "$BASE/repo"
git config user.email t@t; git config user.name t
echo seed > seed.txt; git add -A; git -c commit.gpgsign=false commit -qm seed
git branch -M main
git remote add origin "$BASE/origin.git"
git push -q -u origin main
run_case() { # run_case <branch> <agent-cmd> ; echoes exit code, sets OUT/ERRTXT
local branch="$1" agentcmd="$2" task="${3:-Add a greeting file}"
git -C "$BASE/repo" checkout -q main
git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null
git -C "$BASE/repo" worktree add -q -b "$branch" "$BASE/wt" main 2>/dev/null
OUT="$(cd "$BASE/wt" && printf '%s' "$task" | env \
BUZZ_JOB_ID=job123 BUZZ_REQUESTER=alice BUZZ_BRANCH="$branch" \
BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD="$agentcmd" \
bash "$EXEC" 2>"$BASE/err.txt")"
RC=$?
ERRTXT="$(cat "$BASE/err.txt")"
return $RC
}
echo "> 1. happy path: agent edits a file → commit → push → PR url on stdout"
run_case "claude/job-1" 'echo "wrote greeting" && echo hello > greeting.txt'
check "exit code" "$?" "0"
contains "stdout is the PR url" "$OUT" "https://github.com/example/repo/pull/42"
check "branch landed on origin" "$(git -C "$BASE/origin.git" rev-parse --verify -q claude/job-1 >/dev/null && echo yes || echo no)" "yes"
check "commit was made" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1"
contains "commit subject uses the task's first line" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "feat: Add a greeting file"
contains "file the agent wrote is in the commit" "$(git -C "$BASE/wt" show --stat --oneline HEAD)" "greeting.txt"
contains "the agent got the task on stdin" "$ERRTXT" "Add a greeting file"
echo "> 2. agent makes no changes → job error, non-zero exit"
run_case "claude/job-2" 'echo "thought about it"'
check "exit code" "$?" "1"
contains "explains why" "$ERRTXT" "no changes"
echo "> 3. agent commits by itself → wrapper pushes, does not double-commit"
run_case "claude/job-3" 'echo x > f.txt && git add -A && git -c user.email=a@b -c user.name=a commit -qm "agent: own commit"'
check "exit code" "$?" "0"
check "exactly one commit" "$(git -C "$BASE/wt" rev-list --count main..HEAD)" "1"
contains "kept the agent's own subject" "$(git -C "$BASE/wt" log -1 --pretty=%s)" "agent: own commit"
echo "> 4. refuses to operate on the default branch"
# the worktree sits on a scratch branch, but the scheduler hands it BUZZ_BRANCH=main —
# exactly the mistake the guard exists to stop.
git -C "$BASE/repo" worktree remove --force "$BASE/wt" 2>/dev/null
git -C "$BASE/repo" worktree add -q -b scratch-guard "$BASE/wt" main
OUT="$(cd "$BASE/wt" && printf 'do a thing' | env BUZZ_JOB_ID=job4 BUZZ_BRANCH=main BUZZ_WORKTREE="$BASE/wt" BUZZ_BASE_REF=main AGENT_CMD='echo hi > x.txt' bash "$EXEC" 2>"$BASE/err.txt")"; RC=$?
ERRTXT="$(cat "$BASE/err.txt")"
check "exit code" "$RC" "1"
contains "says why" "$ERRTXT" "refusing to operate on the default branch"
check "nothing was pushed to main" "$(git -C "$BASE/origin.git" rev-parse main)" "$(git -C "$BASE/repo" rev-parse main)"
echo "> 5. missing scheduler env is a hard error, not a silent no-op"
OUT="$(printf 'task' | env -u BUZZ_BRANCH BUZZ_WORKTREE="$BASE/wt" bash "$EXEC" 2>&1)"; RC=$?
check "exit code" "$RC" "1"
contains "names the missing var" "$OUT" "BUZZ_BRANCH"
echo "> 6. empty task is rejected before the agent runs"
run_case "claude/job-6" 'echo should-not-run > ran.txt' " "
check "exit code" "$?" "1"
contains "says why" "$ERRTXT" "empty task"
echo
echo "> RESULTS: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]