mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
co-authored by
Claude Opus 5
parent
ba1baa359f
commit
8dcc78056d
+3
-1
@@ -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 },
|
||||
|
||||
+22
-17
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,3 +9,4 @@ 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 ]]
|
||||
Reference in New Issue
Block a user