screenshots: PR #1918

This commit is contained in:
Atish Patel
2026-07-15 13:55:49 -05:00
parent 371ab872e1
commit 74e5337fe4
2687 changed files with 2 additions and 716679 deletions

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/nest_skill.md

View File

@@ -1,11 +0,0 @@
[profile.dev]
# Full file/line backtraces, no per-variable debug info. ~20-30% faster
# builds and much faster macOS link. Need lldb locals? Override per-shell:
# CARGO_PROFILE_DEV_DEBUG=2
debug = "line-tables-only"
[env]
# CMake 4 (pinned by hermit) removed compat with projects declaring
# cmake_minimum_required < 3.5; audiopus_sys's vendored opus declares 3.1.
# Same workaround CI uses. A value already set in the environment wins.
CMAKE_POLICY_VERSION_MINIMUM = "3.5"

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/nest_skill.md

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/nest_skill.md

View File

@@ -1,54 +0,0 @@
# Build outputs
target/
**/target/
desktop/src-tauri/target/
# Native non-relay UI builds (not part of the public image)
desktop/
mobile/
# Node artifacts (built inside the image; host outputs would invalidate cache)
node_modules/
**/node_modules/
web/dist/
# VCS, IDE, scratch
.git/
.github/
.gitignore
.gitattributes
.vscode/
.idea/
.scratch/
# Local environment / secrets — never want these in the build context
.env
.env.*
!.env.example
*.pem
*.key
secrets/
# OS junk
.DS_Store
Thumbs.db
# CI artifacts and tooling that the image doesn't need
.cache/
coverage/
dist/
playwright-report/
test-results/
bin/
sidecars/
# Docs the image doesn't ship. examples/ stays — examples/countdown-bot is
# a Cargo workspace member, so cargo-chef needs its manifest. Markdown files
# inside crates (Cargo.toml `readme = ...`) likewise need to be present to
# avoid manifest warnings; the savings from excluding them are negligible.
docs/
# Old Dockerfile noise (this Dockerfile is THE source of truth now)
docker-compose.yml
docker-compose.*.yml
prometheus.yml

View File

@@ -1,199 +0,0 @@
# =============================================================================
# Buzz Backend — Local Development Environment
# =============================================================================
# Copy this file to .env and adjust as needed:
# cp .env.example .env
#
# All defaults here work with `docker compose up` out of the box.
#
# Service ports (defaults):
# Postgres → localhost:5432
# Redis → localhost:6379
# Typesense → localhost:8108
# Adminer → localhost:8082 (DB browser UI)
#
# Note: If port 8082 conflicts, change the adminer port in docker-compose.yml
# =============================================================================
# -----------------------------------------------------------------------------
# Database (Postgres 17)
# -----------------------------------------------------------------------------
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz
PGHOST=localhost
PGPORT=5432
PGUSER=buzz
PGPASSWORD=buzz_dev
PGDATABASE=buzz
# -----------------------------------------------------------------------------
# Redis 7
# -----------------------------------------------------------------------------
REDIS_URL=redis://localhost:6379
# -----------------------------------------------------------------------------
# Typesense (search)
# -----------------------------------------------------------------------------
TYPESENSE_API_KEY=buzz_dev_key
TYPESENSE_URL=http://localhost:8108
# -----------------------------------------------------------------------------
# Relay (WebSocket server)
# -----------------------------------------------------------------------------
# Bind address for the relay (host:port)
BUZZ_BIND_ADDR=0.0.0.0:3000
# Public WebSocket URL — used in NIP-42 auth challenges
RELAY_URL=ws://localhost:3000
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist
# -----------------------------------------------------------------------------
# Git (NIP-34 bare repositories)
# -----------------------------------------------------------------------------
# Root directory for bare git repos. Repos are stored at
# {path}/{owner_hex}/{repo_id}.git/. Default: ./repos (relative to CWD).
# Set an absolute path to keep repos stable across worktrees.
# BUZZ_GIT_REPO_PATH=./repos
# BUZZ_GIT_MAX_PACK_BYTES=524288000
# BUZZ_GIT_MAX_REPO_BYTES=1048576000
# -----------------------------------------------------------------------------
# Media Upload Admission
# -----------------------------------------------------------------------------
# Bound image/video parser and storage work admitted by one relay process.
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
# -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing)
# -----------------------------------------------------------------------------
# Override the TTL for all ephemeral channels (in seconds). When set, any
# channel created with a TTL tag will use this value instead of the
# client-provided one. Unset to use the client-provided TTL.
# BUZZ_EPHEMERAL_TTL_OVERRIDE=60
# How often the reaper checks for expired ephemeral channels (default: 60s).
# BUZZ_REAPER_INTERVAL_SECS=5
# -----------------------------------------------------------------------------
# Logging / Tracing
# -----------------------------------------------------------------------------
RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug
# OTLP tracing endpoint (optional — leave unset to disable)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# -----------------------------------------------------------------------------
# ACP (Agent Communication Protocol — buzz-acp harness)
# -----------------------------------------------------------------------------
# The ACP harness bridges Buzz events to AI agents. Each env var below maps
# to a CLI flag of the same name (lowercase, hyphens → underscores). All values
# are optional unless noted; defaults are shown in comments.
#
# Quick start:
# BUZZ_PRIVATE_KEY=<hex> BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp
# ── Identity & auth ──────────────────────────────────────────────────────────
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
# BUZZ_PRIVATE_KEY=<32-byte hex or nsec1… private key>
# Relay WebSocket URL the harness connects to.
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
# connection target — they happen to point at the same place in local dev.
# BUZZ_RELAY_URL=ws://localhost:3000
# ── Agent subprocess ─────────────────────────────────────────────────────────
# Binary to spawn as the AI agent (e.g. "goose", "codex-acp", "claude-code").
# BUZZ_ACP_AGENT_COMMAND=goose
# Comma-separated arguments passed to the agent binary.
# Goose default: "acp". Codex/Claude default: "" (empty).
# BUZZ_ACP_AGENT_ARGS=acp
# Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent).
# BUZZ_ACP_MCP_COMMAND=
# Number of parallel agent subprocesses (1–32).
# BUZZ_ACP_AGENTS=1
# Desired LLM model ID. Applied to every new ACP session.
# Use `buzz-acp models` to discover available model IDs.
# BUZZ_ACP_MODEL=
# ── Timeouts & sessions ──────────────────────────────────────────────────────
# Max seconds per agent turn before timeout (default 320 = ~5 min).
# BUZZ_ACP_TURN_TIMEOUT=320
# Max turns per session before proactive rotation. 0 = disabled (rotate only
# on MaxTokens / MaxTurnRequests). Recommended: 50 for long-running agents.
# BUZZ_ACP_MAX_TURNS_PER_SESSION=0
# ── Prompts ──────────────────────────────────────────────────────────────────
# System prompt injected into every agent session (inline text).
# BUZZ_ACP_SYSTEM_PROMPT=
# Path to a file containing the system prompt (mutually exclusive with above).
# BUZZ_ACP_SYSTEM_PROMPT_FILE=
# Message sent to the agent immediately after session creation.
# BUZZ_ACP_INITIAL_MESSAGE=
# ── Heartbeat ────────────────────────────────────────────────────────────────
# Seconds between heartbeat prompts. 0 = disabled. Must be 0 or ≥10.
# Recommended: 60 for long-running agents to prevent idle session timeouts.
# BUZZ_ACP_HEARTBEAT_INTERVAL=0
# Heartbeat prompt text (inline). Mutually exclusive with file variant.
# BUZZ_ACP_HEARTBEAT_PROMPT=
# Path to a file containing the heartbeat prompt.
# BUZZ_ACP_HEARTBEAT_PROMPT_FILE=
# ── Subscription & filtering ─────────────────────────────────────────────────
# Subscribe mode: "mentions" (default), "all", or "config" (rule-based).
# BUZZ_ACP_SUBSCRIBE=mentions
# Comma-separated event kind numbers to subscribe to (overrides mode defaults).
# BUZZ_ACP_KINDS=
# Comma-separated channel UUIDs to limit subscription scope.
# BUZZ_ACP_CHANNELS=
# Set to true to disable the @-mention filter in mentions mode.
# BUZZ_ACP_NO_MENTION_FILTER=false
# Path to TOML config file for rule-based subscriptions (config mode).
# BUZZ_ACP_CONFIG=./buzz-acp.toml
# ── Dedup & self-ignore ──────────────────────────────────────────────────────
# How to handle duplicate events: "queue" (default) or "drop".
# BUZZ_ACP_DEDUP=queue
# Set to true to process the agent's own messages (default: ignore self).
# BUZZ_ACP_NO_IGNORE_SELF=false
# ── Context ──────────────────────────────────────────────────────────────────
# Max context messages fetched for thread replies and DMs (0–100). 0 = disabled.
# BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12
# ── Presence & typing ────────────────────────────────────────────────────────
# Set to true to disable automatic online/offline presence status.
# BUZZ_ACP_NO_PRESENCE=false
# Set to true to disable typing indicators while the agent is processing.
# BUZZ_ACP_NO_TYPING=false
# ── Advanced tuning ──────────────────────────────────────────────────────────
# Event channel buffer capacity (WebSocket → harness). Increase for
# high-throughput agents. Minimum 1.
# BUZZ_ACP_EVENT_BUFFER=256
# ── Legacy aliases ───────────────────────────────────────────────────────────
# These are accepted for backward compatibility but the canonical names above
# are preferred:
# BUZZ_ACP_PRIVATE_KEY → BUZZ_PRIVATE_KEY

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @block/buzz-oss-team

View File

@@ -1,31 +0,0 @@
---
name: 🐛 Bug Report
about: Thank you for taking the time, please report a reproducible bug
title: "[Bug] <Bug Title Here>"
labels: bug
assignees: add codeowner's @name here
---
**Describe the bug**
*A clear and concise description of what the bug is.*
**To Reproduce:**
*Steps to reproduce the behavior:*
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior:**
*A clear and concise description of what you expected to happen.*
**Supporting Material**
*If applicable, add screenshots, output log and/or other documentation to help explain your problem.*
**Environment (please complete the following information):**
- OS: [ex: iOS]
- Version
**Additional context**
Add any other context that you feel is relevant about the problem here.

View File

@@ -1,4 +0,0 @@
contact_links:
- name: ❓ Questions and Help 🤔
url: https://discord.gg/block-opensource (/add your discord channel if applicable)
about: This issue tracker is not for support questions. Please refer to the community for more help.

View File

@@ -1,149 +0,0 @@
name: Auto-tag on Release PR Merge
# Five release lanes share this one workflow. Four use an explicit branch
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so
# a chart feature PR can publish its own new version when merged:
#
# version-bump/<v> → tag v<v> → dispatch release.yml (desktop app)
# relay-release/<v> → tag relay-v<v> → dispatch docker.yml (relay image)
# chart-release/<v> → tag chart-v<v> → dispatch helm-chart.yml (main helm chart)
# push-chart-release/<v> → tag push-chart-v<v> → dispatch push-gateway-helm-chart.yml
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
# → tag chart-v<v> → dispatch helm-chart.yml (helm chart)
# mobile-release/<v> → tag mobile-v<v> → (manual sprout_ref for buzz-releases build — see below)
#
# The desktop, relay, and chart lanes dispatch their build workflow rather than
# relying on the consumer's `on.push.tags` trigger: auto-tag pushes the tag
# with the default GITHUB_TOKEN, which GitHub's recursion guard blocks from
# firing any `on: push` trigger. So release.yml, docker.yml, and helm-chart.yml
# all have a `push.tags` trigger that is dead for auto-pushed tags — the
# dispatch is the real path. Each dispatch passes the bare version and the tag
# ref so the consumer builds the tagged commit (github.ref on a dispatch is
# `main`).
#
# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must
# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across
# that boundary is deliberately disallowed. The mobile-v* tag is consumed
# manually instead — a human feeds it as the `sprout_ref` input to the
# `buzz-releases` Buildkite pipeline, which builds and ships mobile.
on:
pull_request:
types: [closed]
branches: [main]
permissions:
contents: write
actions: write
jobs:
auto-tag:
if: >
github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
- name: Resolve release lane and version
id: release
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
# Explicit release branches retain their established behavior. For an
# ordinary internal PR, publish only when Chart.yaml itself changed
# and its version differs from the PR's base commit.
case "$BRANCH" in
version-bump/*)
VERSION="${BRANCH#version-bump/}"
TAG_PREFIX="v"
DISPATCH="release" ;;
relay-release/*)
VERSION="${BRANCH#relay-release/}"
TAG_PREFIX="relay-v"
DISPATCH="docker" ;;
chart-release/*)
VERSION="${BRANCH#chart-release/}"
TAG_PREFIX="chart-v"
DISPATCH="helm-chart" ;;
push-chart-release/*)
VERSION="${BRANCH#push-chart-release/}"
TAG_PREFIX="push-chart-v"
DISPATCH="push-gateway-helm-chart" ;;
mobile-release/*)
VERSION="${BRANCH#mobile-release/}"
TAG_PREFIX="mobile-v"
DISPATCH="" ;;
*)
parent_sha="$(git rev-parse HEAD^)"
old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"
VERSION="$(awk '/^version:/ {print $2}' deploy/charts/buzz/Chart.yaml)"
if [ -z "$old_version" ] || [ -z "$VERSION" ] || [ "$old_version" = "$VERSION" ]; then
echo "No release branch or chart version bump — nothing to tag"
echo "enabled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
TAG_PREFIX="chart-v"
DISPATCH="helm-chart" ;;
esac
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid release version: '$VERSION'"
exit 1
fi
{
echo "enabled=true"
echo "version=$VERSION"
echo "tag=${TAG_PREFIX}${VERSION}"
echo "dispatch=$DISPATCH"
} >> "$GITHUB_OUTPUT"
echo "Tagging ${TAG_PREFIX}${VERSION}"
- name: Create and push tag
if: steps.release.outputs.enabled == 'true'
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')"
if [ -n "$EXISTING_SHA" ]; then
if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then
echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation"
exit 0
else
echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)"
exit 1
fi
fi
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git tag "$TAG"
git push origin "$TAG"
- name: Trigger release build
# The desktop, relay, and chart lanes dispatch their build workflow
# because the consumer's on:push:tags trigger is dead for tags pushed
# by GITHUB_TOKEN (recursion guard — see header comment). Mobile has no
# consumer yet, so dispatch="" skips this step entirely.
if: steps.release.outputs.enabled == 'true' && steps.release.outputs.dispatch != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISPATCH: ${{ steps.release.outputs.dispatch }}
VERSION: ${{ steps.release.outputs.version }}
TAG: ${{ steps.release.outputs.tag }}
run: |
# Both workflows take the bare version (for the build) and the tag ref
# (so the dispatch builds the tagged commit, not main).
case "$DISPATCH" in
release) WORKFLOW="release.yml" ;;
docker) WORKFLOW="docker.yml" ;;
helm-chart) WORKFLOW="helm-chart.yml" ;;
push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml" ;;
*)
echo "::error::Unhandled dispatch target: '$DISPATCH'"
exit 1 ;;
esac
gh workflow run "$WORKFLOW" \
-f version="$VERSION" \
-f ref="$TAG"

View File

@@ -1,39 +0,0 @@
name: Harbor Buzz Orchestra
on:
push:
branches: [main]
paths:
- "benchmarks/harbor-buzz-orchestra/**"
- ".github/workflows/benchmark-harbor.yml"
pull_request:
paths:
- "benchmarks/harbor-buzz-orchestra/**"
- ".github/workflows/benchmark-harbor.yml"
permissions:
contents: read
jobs:
test:
name: Python tests and lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
cache: pip
- name: Test adapter
working-directory: benchmarks/harbor-buzz-orchestra
run: |
python -m pip install --disable-pip-version-check -e ".[dev]"
pytest -q
ruff check .
- name: Test provisioner
working-directory: benchmarks/harbor-buzz-orchestra/testbed
run: |
python -m pip install --disable-pip-version-check -e ".[dev]"
pytest -q
ruff check .

View File

@@ -1,974 +0,0 @@
name: CI
on:
push:
branches: [main, release]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
jobs:
changes:
name: Detect Changed Paths
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
pull-requests: read
outputs:
rust: ${{ steps.filter.outputs.rust }}
desktop: ${{ steps.filter.outputs.desktop }}
desktop-rust: ${{ steps.filter.outputs.desktop-rust }}
web: ${{ steps.filter.outputs.web }}
mobile: ${{ steps.filter.outputs.mobile }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
filters: |
rust:
- 'crates/**'
- 'migrations/**'
- 'schema/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'deny.toml'
- '.github/workflows/ci.yml'
- 'scripts/run-tests.sh'
- 'justfile'
desktop:
- 'desktop/**'
- '!desktop/src-tauri/**'
- 'pnpm-lock.yaml'
desktop-rust:
- 'desktop/src-tauri/**'
web:
- 'web/**'
- 'pnpm-lock.yaml'
mobile:
- 'mobile/**'
- '.github/workflows/ci.yml'
rust-lint:
name: Rust Lint
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
save-if: ${{ github.event_name != 'pull_request' }}
- name: Format check
run: just fmt-check
- name: Desktop Tauri format check
run: just desktop-tauri-fmt-check
- name: Clippy
run: just clippy
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
save-if: ${{ github.event_name != 'pull_request' }}
- name: Install cargo-nextest
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cargo-nextest@0.9.136
- name: Unit tests
run: just test-unit
desktop-core:
name: Desktop Core
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: desktop/src-tauri
save-if: ${{ github.event_name != 'pull_request' }}
- name: Install Tauri dependencies (Linux)
env:
DEBIAN_FRONTEND: noninteractive
run: |
sudo apt-get update \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30
sudo apt-get install -y --no-install-recommends \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=120 \
build-essential \
curl \
file \
libasound2-dev \
libayatana-appindicator3-dev \
libgtk-3-dev \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
patchelf \
wget
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Desktop lint and format
run: just desktop-check
- name: Desktop unit tests
run: just desktop-test
- name: Desktop build
run: just desktop-build
- name: Desktop Tauri clippy
run: just desktop-tauri-clippy
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Desktop Tauri check
run: just desktop-tauri-check
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Desktop Tauri tests
run: just desktop-tauri-test
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Upload desktop e2e artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-e2e-artifacts
path: |
desktop/playwright-report
desktop/test-results
if-no-files-found: ignore
- name: Save pnpm store cache
if: github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
desktop-smoke-e2e:
name: Desktop Smoke E2E (${{ matrix.shard }})
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Get Playwright version
id: pw-version
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
id: playwright-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd desktop && pnpm exec playwright install chromium
- name: Install Playwright system dependencies
run: cd desktop && pnpm exec playwright install-deps chromium
- name: Save Playwright browser cache
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Desktop build
run: just desktop-build
- name: Desktop smoke e2e
run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4
- name: Summarize flaky tests
if: ${{ !cancelled() }}
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})"
working-directory: desktop
- name: Upload desktop smoke e2e artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-smoke-e2e-artifacts-${{ matrix.shard }}
path: |
desktop/playwright-report
desktop/playwright-report.json
desktop/test-results
if-no-files-found: ignore
retention-days: 7
desktop:
name: Desktop
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [changes, desktop-core, desktop-smoke-e2e]
if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
permissions:
contents: read
steps:
- name: Check desktop jobs
run: |
if [ "${{ needs.desktop-core.result }}" != "success" ]; then
echo "Desktop Core finished with: ${{ needs.desktop-core.result }}"
exit 1
fi
if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then
echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}"
exit 1
fi
echo "Desktop jobs passed"
desktop-e2e-relay:
name: Desktop E2E Relay
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
save-if: ${{ github.event_name != 'pull_request' }}
- name: Build relay
run: cargo build --profile ci -p buzz-relay
- name: Upload relay binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-e2e-relay
path: target/ci/buzz-relay
if-no-files-found: error
retention-days: 1
desktop-e2e-integration-shard:
name: Desktop E2E Integration (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes, desktop-e2e-relay]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2]
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Start integration services
run: |
for attempt in 1 2 3; do
if docker compose up -d postgres redis minio minio-init; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "docker compose up failed after 3 attempts" >&2
exit 1
fi
echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2
sleep $((attempt * 5))
done
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Get Playwright version
id: pw-version
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
id: playwright-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd desktop && pnpm exec playwright install chromium
- name: Install Playwright system dependencies
run: cd desktop && pnpm exec playwright install-deps chromium
- name: Save Playwright browser cache
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Desktop build
run: just desktop-build
- name: Wait for integration services
run: |
wait_healthy() {
local service="$1"
local container="$2"
for attempt in $(seq 1 60); do
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found")
if [ "${status}" = "healthy" ]; then
echo "${service} is healthy"
return 0
fi
sleep 2
done
docker logs "${container}" || true
return 1
}
wait_healthy "Postgres" "buzz-postgres"
wait_healthy "Redis" "buzz-redis"
wait_healthy "MinIO" "buzz-minio"
- name: Download relay binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-e2e-relay
path: target/ci
- name: Apply schema and seed deployment community
# MT: the relay resolves each request's tenant from the communities host
# map and fails closed on an unmapped host. The channel reconciler binds
# the deployment community ONCE at boot (outside its retry loop) and
# exits permanently on an unmapped host, so the 'localhost:3000'
# community MUST exist before the relay starts — the retry loop only
# handles late-seeded channels, not a late-seeded community. The relay
# migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the
# pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE
# below). lower(host) is the unique index → ON CONFLICT target. psql
# isn't on PATH in hermit → exec into the buzz-postgres container.
env:
PGHOST: localhost
PGPORT: "5432"
PGUSER: buzz
PGPASSWORD: buzz_dev
PGDATABASE: buzz
# Use the already-running docker postgres for desired-state planning instead of
# downloading an embedded Postgres from Maven Central (transient-fetch flake source).
PGSCHEMA_PLAN_HOST: localhost
PGSCHEMA_PLAN_PORT: "5432"
PGSCHEMA_PLAN_DB: buzz
PGSCHEMA_PLAN_USER: buzz
PGSCHEMA_PLAN_PASSWORD: buzz_dev
run: |
./bin/pgschema apply --file schema/schema.sql --auto-approve
docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -qtA -c "
INSERT INTO communities (id, host)
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
ON CONFLICT (lower(host)) DO NOTHING
;"
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
REDIS_URL=redis://localhost:6379 \
RELAY_URL=ws://localhost:3000 \
BUZZ_BIND_ADDR=0.0.0.0:3000 \
BUZZ_REQUIRE_AUTH_TOKEN=false \
BUZZ_RECONCILE_CHANNELS=true \
BUZZ_GIT_PROBE_WRITERS=8 \
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
echo $! > /tmp/buzz-relay.pid
for attempt in $(seq 1 60); do
if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then
cat /tmp/buzz-relay.log
exit 1
fi
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
if [ "${status_code}" = "200" ]; then
exit 0
fi
sleep 1
done
cat /tmp/buzz-relay.log
exit 1
- name: Seed desktop e2e data
run: bash scripts/setup-desktop-test-data.sh
- name: Desktop relay-backed e2e
run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2
- name: Summarize flaky tests
if: ${{ !cancelled() }}
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)"
working-directory: desktop
- name: Upload desktop integration artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-e2e-integration-artifacts-${{ matrix.shard }}
path: |
desktop/playwright-report
desktop/playwright-report.json
desktop/test-results
/tmp/buzz-relay.log
if-no-files-found: ignore
retention-days: 7
- name: Save pnpm store cache
if: github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
desktop-e2e-integration:
name: Desktop E2E Integration
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [changes, desktop-e2e-integration-shard]
if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
permissions:
contents: read
steps:
- name: Check integration shards
run: |
if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then
echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}"
exit 1
fi
echo "Desktop E2E Integration shards passed"
backend-integration:
name: Backend Integration (relay e2e)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes, desktop-e2e-relay]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
save-if: ${{ github.event_name != 'pull_request' }}
- name: Start integration services
run: |
for attempt in 1 2 3; do
if docker compose up -d postgres redis minio minio-init; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "docker compose up failed after 3 attempts" >&2
exit 1
fi
echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2
sleep $((attempt * 5))
done
- name: Wait for integration services
run: |
wait_healthy() {
local service="$1"
local container="$2"
for attempt in $(seq 1 60); do
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found")
if [ "${status}" = "healthy" ]; then
echo "${service} is healthy"
return 0
fi
sleep 2
done
docker logs "${container}" || true
return 1
}
wait_healthy "Postgres" "buzz-postgres"
wait_healthy "Redis" "buzz-redis"
wait_healthy "MinIO" "buzz-minio"
- name: Download relay binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-e2e-relay
path: target/ci
- name: Apply schema and seed deployment community
# MT: the relay resolves each request's tenant from the communities host
# map and fails closed on an unmapped host. The reminder scheduler binds
# the deployment community ONCE at boot and exits permanently on an
# unmapped host (no retry, unlike the channel reconciler), so the
# 'localhost:3000' community MUST exist before the relay starts — seeding
# after boot leaves the scheduler dead. The relay migrates at boot via
# BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply
# the schema here first (then drop AUTO_MIGRATE below). lower(host) is the
# unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec
# into the buzz-postgres container.
env:
PGHOST: localhost
PGPORT: "5432"
PGUSER: buzz
PGPASSWORD: buzz_dev
PGDATABASE: buzz
# Use the already-running docker postgres for desired-state planning instead of
# downloading an embedded Postgres from Maven Central (transient-fetch flake source).
PGSCHEMA_PLAN_HOST: localhost
PGSCHEMA_PLAN_PORT: "5432"
PGSCHEMA_PLAN_DB: buzz
PGSCHEMA_PLAN_USER: buzz
PGSCHEMA_PLAN_PASSWORD: buzz_dev
run: |
./bin/pgschema apply --file schema/schema.sql --auto-approve
docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -qtA -c "
INSERT INTO communities (id, host)
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
ON CONFLICT (lower(host)) DO NOTHING
;"
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
REDIS_URL=redis://localhost:6379 \
RELAY_URL=ws://localhost:3000 \
BUZZ_BIND_ADDR=0.0.0.0:3000 \
BUZZ_REQUIRE_AUTH_TOKEN=false \
BUZZ_RECONCILE_CHANNELS=true \
BUZZ_GIT_PROBE_WRITERS=8 \
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
echo $! > /tmp/buzz-relay.pid
for attempt in $(seq 1 60); do
if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then
cat /tmp/buzz-relay.log
exit 1
fi
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
if [ "${status_code}" = "200" ]; then
exit 0
fi
sleep 1
done
cat /tmp/buzz-relay.log
exit 1
- name: NIP-ER reminder e2e
# Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path
# validation, author-only read filtering, and scheduler delivery against
# a live relay. The schema-drift / migration-version guarantee is owned
# by the buzz-db migration.rs unit tests, not this suite.
run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored
env:
RELAY_URL: ws://localhost:3000
- name: Upload relay log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: backend-integration-relay-log
path: /tmp/buzz-relay.log
if-no-files-found: ignore
relay-e2e:
name: Relay E2E
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
save-if: ${{ github.event_name != 'pull_request' }}
- name: Start relay
run: ./scripts/start-relay-for-tests.sh
- name: Relay E2E tests
run: cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
- name: Upload relay logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: relay-e2e-artifacts
path: /tmp/buzz-relay.log
if-no-files-found: ignore
web:
name: Web
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.web == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Web lint and format
run: just web-check
- name: Web build
run: just web-build
- name: Save pnpm store cache
if: github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
mobile:
name: Mobile
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Compute Hermit cache key
id: hermit-bin-hash
run: |
hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)"
echo "hash=$hash" >> "$GITHUB_OUTPUT"
- name: Restore Hermit package cache
id: hermit-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ~/.cache/hermit/pkg
key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }}
restore-keys: ${{ runner.os }}-hermit-cache-
- name: Prime Flutter SDK
run: flutter --version
- name: Save Hermit package cache
if: always() && steps.hermit-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
continue-on-error: true
with:
path: ~/.cache/hermit/pkg
key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }}
- name: Restore pub cache
id: pub-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Install dependencies
run: cd mobile && flutter pub get
- name: Save pub cache
if: always() && steps.pub-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
continue-on-error: true
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
- name: Format check
run: cd mobile && dart format --output=none --set-exit-if-changed .
- name: Analyze
run: cd mobile && flutter analyze
- name: Test
run: cd mobile && flutter test
- name: Build Android debug APK
run: just mobile-build-android
security:
name: Security
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Dependency policy
run: cargo-deny check
dead-token-guard:
name: Dead Token Reference Guard
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Check for dead API token references in client code
run: |
# Fail if dead API token patterns reappear in desktop, mobile, docs, or config.
# Relay crates are excluded — they still use token auth internally.
PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_'
PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example'
EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool'
if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then
echo "::error::Dead API token references found in client code. See above."
exit 1
fi
echo "No dead token references found."
server-cross-compile:
name: Server Cross-Compile
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: cross-${{ matrix.target }}
save-if: ${{ github.event_name != 'pull_request' }}
- name: Install cross
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cross@0.2.5
- name: Build server binaries
env:
TARGET: ${{ matrix.target }}
# PRs: compile + build-script gate only (no codegen/link). Main: full link gate.
CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }}
run: |
cross "$CARGO_CMD" --release --target "$TARGET" \
-p buzz-relay \
-p buzz-acp \
-p buzz-agent \
-p buzz-dev-mcp \
-p git-credential-nostr \
-p git-sign-nostr
windows-rust:
name: Windows Rust (x86_64-pc-windows-msvc)
runs-on: windows-latest
# Windows runners are slow and this compiles the workspace + Tauri crate
# cold across four steps; budget generously.
timeout-minutes: 45
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'
permissions:
contents: read
env:
TARGET: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows
# runner — hermit, used by the Linux jobs, does not provide MSVC. The
# toolchain (1.95.0 + clippy via profile = default) comes from the
# repo-root rust-toolchain.toml, which the runner's preinstalled rustup
# honors on demand; the host triple already is x86_64-pc-windows-msvc.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
key: windows-msvc
save-if: ${{ github.event_name != 'pull_request' }}
# Tauri validates externalBin at compile time, so the Tauri-crate steps
# below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's
# Windows naming (binaries/<bin>-<triple>.exe); empty files suffice for a
# type-check since nothing executes them.
- name: Create sidecar placeholders
shell: bash
run: |
mkdir -p desktop/src-tauri/binaries
for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe"
done
- name: Clippy (workspace)
run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings
- name: Check (workspace)
run: cargo check --workspace --all-targets --target $env:TARGET
- name: Test (buzz-dev-mcp)
# The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests
# only gate if this crate is tested ON Windows.
# Serial: windows_resolver_tests mutate process-global env
# (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads.
run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1
# Smoke-test the new host-prereq contract: Git for Windows (which provides
# bash) is available on the runner, a shell command round-trips, and bash
# does NOT resolve from System32 (so WSL's launcher is never picked up).
# windows-latest runners have Git for Windows pre-installed; the unit tests
# above exercise the MCP resolver itself. This step verifies the host env.
- name: Smoke-test host Git Bash prereq (host env check)
shell: bash
run: |
set -euo pipefail
# Git for Windows ships bash.exe under its bin/ directory; confirm it
# resolves from the standard location the runtime resolver probes first.
bash_path=$(command -v bash 2>/dev/null || true)
[[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; }
echo "Resolved bash: $bash_path"
[[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; }
# Run a basic pipeline through the resolved bash (same invocation the
# agent uses: bash -c '...').
out=$(bash -c 'echo hello | tr a-z A-Z')
[[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; }
# Confirm git itself works — agents run git commands frequently.
git --version
repo=$(mktemp -d)
cd "$repo"
git init -q
git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke
git log -1 --format=%s | grep -qx smoke
echo "Host bash resolved and functional; git commit round-trip passed"
- name: Check (Tauri crate)
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Test (Tauri crate)
run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
desktop-build-macos:
name: Desktop Build (macOS)
runs-on: macos-latest
timeout-minutes: 45
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: desktop/src-tauri
save-if: ${{ github.event_name != 'pull_request' }}
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Create sidecar placeholders
run: |
TARGET=$(rustc -vV | sed -n 's|host: ||p')
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
# Mesh rev is derived from Cargo.lock so a dependency bump needs no
# lockstep edit here; the cache key tracks it automatically.
- name: Resolve mesh-llm rev
id: mesh_rev
run: |
set -euo pipefail
REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])')
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
echo "rev=$REV" >> "$GITHUB_OUTPUT"
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
- name: Restore mesh llama build cache
id: llama_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build mesh llama native libraries
if: steps.llama_cache.outputs.cache-hit != 'true'
env:
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
run: |
set -euo pipefail
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
SHORT="$MESH_REV_SHORT"
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
if [[ -z "$MESH_ROOT" ]]; then
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
exit 1
fi
export LLAMA_STAGE_BACKEND=metal
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
- name: Save mesh llama build cache
if: steps.llama_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build Tauri app
run: cd desktop && pnpm tauri build
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
MACOSX_DEPLOYMENT_TARGET: "10.15"
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
LLAMA_STAGE_BACKEND: metal
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
SKIPPY_LLAMA_AUTO_BUILD: "0"

View File

@@ -1,454 +0,0 @@
name: Docker image
# Builds and publishes the public Buzz relay image as ghcr.io/block/buzz.
#
# Strategy: each architecture builds on its native runner (ubuntu-24.04 for
# amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final
# job stitches the per-arch digests into a single multi-arch manifest.
# This avoids QEMU emulation (~10× slower for Rust) at zero cost on free
# GitHub-hosted runners.
#
# Versioning: the relay is versioned independently of the desktop app via
# its own `relay-v*` tags (see `just release-relay`). Desktop `v*` tags and
# agent `sprig-v*` tags do NOT publish this image — only `relay-v*` does, so
# the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop.
#
# Triggers:
# - push to main → :main + :sha-<7>
# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major}
# (+ :latest for stable, NOT for prereleases)
# - pull_request → build only (no push), cache stays warm
# - workflow_dispatch → manual canary (no inputs), or relay-tag rescue
# dispatch with version+ref inputs (see below)
#
# Why workflow_dispatch carries version/ref inputs:
# auto-tag-on-release-pr-merge.yml pushes relay-v* with the default
# GITHUB_TOKEN, which GitHub deliberately does NOT let fire on:push triggers
# (recursion guard). So the push:tags trigger above never runs for releases.
# auto-tag instead dispatches this workflow with the bare version + tag ref,
# the same rescue release.yml already uses for the desktop lane. On dispatch
# github.ref is `main`, so the tag ref is plumbed through explicitly: checkout
# pins to inputs.ref, and the semver tags take inputs.version via `value=`.
# On the rescue path inputs.version is already bare (e.g. 0.3.0), so the
# match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value
# intact) and the bare version flows straight to the semver parser. On a
# real push event value= is empty and the match strips relay-v from the ref.
# Inputless canary dispatch (version="", ref=main) renders no semver tag.
#
# The :latest tag tracks the latest STABLE relay release: metadata-action's
# `flavor.latest=auto` (its default) emits :latest only for non-prerelease
# semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest,
# and main pushes (no semver tag) never produce :latest.
on:
push:
branches: [main]
tags: ["relay-v[0-9]*"]
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.push-gateway"
- ".dockerignore"
- ".github/workflows/docker.yml"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "crates/**"
- "web/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "patches/**"
workflow_dispatch:
inputs:
version:
description: "Semver version e.g. 0.3.0 (no relay-v prefix) — for relay-tag rescue dispatch"
required: false
ref:
description: "Tag/branch/SHA to build, e.g. relay-v0.3.0"
required: false
default: main
# One image build per ref; cancel superseded PR builds, but never cancel
# tag/main builds (publishing must not be aborted mid-flight).
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
permissions: {}
env:
# Single source of truth for the image name. Set GHCR_IMAGE as a repo
# variable to override (e.g., for forks that want to push to their own
# namespace without forking this file).
IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }}
jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
packages: write # push to GHCR
id-token: write # OIDC for build provenance attestation
attestations: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
outputs:
# Used downstream by `merge` to stitch the manifest.
version: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# On workflow_dispatch (relay-tag rescue) build the tagged commit,
# not main. Empty string = default ref for push/PR events.
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
with:
# Default parallelism of 4 OOMs the 7GB GitHub runner during Rust
# compiles (see moby/buildkit#3969). Vaultwarden hit this; we will
# too without the cap.
buildkitd-config-inline: |
[worker.oci]
max-parallelism = 2
- name: Log in to GHCR
# Skip on pull_request from forks — no GHCR creds, build-only.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_NAME }}
# Tag matrix — every main commit gets sha-<7>, relay releases get the
# full semver family. The semver entries carry match=^relay-v(.*)$
# because metadata-action does NOT strip a `relay-v` prefix on its
# own — it only strips refs/tags/, then runs the raw ref through
# semver.valid(), which rejects "relay-v0.3.0". The match capture
# group feeds the bare version to the semver parser. value= supplies
# the version on the auto-tag rescue dispatch (github.ref is `main`
# there, not the tag): it is already bare, so match no-ops (warns,
# value intact) and the bare version validates as-is. On push value=
# is empty, so the ref drives it and match strips relay-v — push
# behavior is unchanged. Pull requests get nothing (push: false
# below). :latest is intentionally absent — flavor.latest defaults to
# `auto`, which adds :latest for stable semver tags only (not
# prereleases, not main pushes).
tags: |
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }}
labels: |
org.opencontainers.image.title=Buzz
org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform
org.opencontainers.image.licenses=Apache-2.0
- name: Build and push by digest
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
# Push by digest, not by tag — the merge job assembles the tags
# into one multi-arch manifest. This is what makes the native-arm
# matrix possible.
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: |
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
cache-to: |
${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}
- name: Export digest
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge multi-arch manifest
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
needs: build
timeout-minutes: 15
permissions:
contents: read
packages: write # push the merged manifest
id-token: write # OIDC for provenance attestation on the manifest
attestations: write
steps:
- name: Download all per-arch digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_NAME }}
# Must mirror the build job's tag matrix exactly — the merge job
# re-derives tags to stamp them onto the multi-arch manifest. See
# the build job's `meta` step for why match=^relay-v(.*)$, why
# value=${{ inputs.version }} carries the rescue-dispatch version,
# and why :latest is left to flavor.latest=auto.
tags: |
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }}
- name: Create and push manifest list
id: manifest
working-directory: /tmp/digests
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
# Build -t flags from the metadata-action output.
tags=()
while IFS= read -r tag; do
[ -n "$tag" ] && tags+=("-t" "$tag")
done <<< "$META_TAGS"
# Build the digest refs from the per-arch artifacts.
digests=()
for digest in *; do
digests+=("${IMAGE_NAME}@sha256:${digest}")
done
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
# Capture the merged manifest digest for the attestation step.
first_tag=$(echo "$META_TAGS" | head -n1)
merged_digest=$(docker buildx imagetools inspect "$first_tag" \
--format '{{json .Manifest}}' | jq -r '.digest')
echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
- name: Attest provenance for the merged image
# Sigstore-signed in-toto attestation, verifiable with:
# gh attestation verify oci://ghcr.io/block/buzz:<tag> --owner block
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Summary
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
MERGED_DIGEST: ${{ steps.manifest.outputs.digest }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
{
echo "### Published \`${IMAGE_NAME}\`"
echo
echo "**Digest:** \`${MERGED_DIGEST}\`"
echo
echo "**Tags:**"
echo '```'
echo "${META_TAGS}"
echo '```'
echo
echo "Verify provenance:"
echo '```'
echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
push-gateway-build:
name: Build public push gateway (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
with:
buildkitd-config-inline: |
[worker.oci]
max-parallelism = 2
- name: Log in to GHCR
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/block/buzz-push-gateway
labels: |
org.opencontainers.image.title=Buzz Push Gateway
org.opencontainers.image.description=Capability-gated APNs last hop for Buzz
org.opencontainers.image.licenses=Apache-2.0
- name: Build and push by digest
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile.push-gateway
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }}
cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }}
- name: Export digest
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gateway-digests-${{ matrix.arch }}
path: /tmp/gateway-digests/*
if-no-files-found: error
retention-days: 1
push-gateway-merge:
name: Publish public push gateway image
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
needs: push-gateway-build
timeout-minutes: 15
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download per-arch digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/gateway-digests
pattern: gateway-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/block/buzz-push-gateway
tags: |
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
- name: Merge and publish manifest
id: manifest
working-directory: /tmp/gateway-digests
env:
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS"
digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
first_tag=$(echo "$META_TAGS" | head -n1)
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest')
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
- name: Attest gateway image provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/block/buzz-push-gateway
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Gateway publication summary
env:
GATEWAY_DIGEST: ${{ steps.manifest.outputs.digest }}
GATEWAY_TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
{
echo "### Published \`ghcr.io/block/buzz-push-gateway\`"
echo
printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST"
echo
echo '**Tags:**'
echo "\`\`\`"
printf '%s\n' "$GATEWAY_TAGS"
echo "\`\`\`"
echo
echo 'Verify provenance before deployment:'
echo "\`\`\`"
printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,259 +0,0 @@
name: helm chart
# Lints + unit-tests + render-checks the chart on every PR/main push, and
# PUBLISHES it to GHCR as an OCI artifact on `chart-v*` tags.
#
# Publishing mirrors the relay image (see docker.yml): the chart is versioned
# independently of the desktop app and the relay via its own `chart-v*` tags
# (Chart.yaml `version`, cut by merging a `chart-release/<version>` PR). Only
# `chart-v*` tags publish — `main` pushes and PRs stay lint/render-only so we
# never overwrite a released chart version from an in-progress `main`.
#
# Why workflow_dispatch carries version/ref inputs (same reason as docker.yml):
# auto-tag-on-release-pr-merge.yml pushes `chart-v*` with the default
# GITHUB_TOKEN, which GitHub's recursion guard blocks from firing any
# `on: push` trigger. So the `push.tags` trigger below is dead for
# auto-pushed tags — auto-tag instead dispatches this workflow with the bare
# version + tag ref, and the publish job checks out inputs.ref and packages at
# inputs.version. On a real manual `git push` of a `chart-v*` tag the
# push.tags trigger fires and inputs are empty.
on:
workflow_dispatch:
inputs:
version:
description: "Chart semver e.g. 0.1.0 (no chart-v prefix) — for chart-tag rescue dispatch"
required: false
ref:
description: "Chart tag ref to publish, e.g. chart-v0.1.0 (required when version is set)"
required: false
default: main
push:
# No `paths` filter here: GitHub applies a push `paths` filter to tag
# pushes too, so a `chart-v*` tag whose commit didn't touch a chart file
# would be filtered out and never publish. docker.yml / release.yml / sprig
# all keep `paths` out of a tag-carrying `push` for exactly this reason —
# PR runs stay scoped via `pull_request.paths` below; main pushes lint
# unconditionally (cheap), and tag pushes always run so publish can fire.
branches: [main]
tags: ["chart-v[0-9]*"]
pull_request:
paths:
- "deploy/charts/buzz/**"
- "deploy/charts/buzz-push-gateway/**"
- ".github/workflows/helm-chart.yml"
- "ct.yaml"
# Match docker.yml: deny-by-default, each job grants only what it needs.
permissions: {}
env:
# Single source of truth for the OCI chart repository (helm appends the chart
# name `buzz`, yielding oci://ghcr.io/block/buzz/charts/buzz, which is exactly
# the install ref documented in deploy/charts/buzz/README.md). Set
# GHCR_CHART_REPO as a repo variable to override (e.g., forks pushing to their
# own namespace without editing this file) — mirrors docker.yml's GHCR_IMAGE.
CHART_REPO: ${{ vars.GHCR_CHART_REPO != '' && vars.GHCR_CHART_REPO || 'oci://ghcr.io/block/buzz/charts' }}
jobs:
lint-and-unittest:
name: lint + unittest + render matrix
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# On chart-tag rescue dispatch, lint/render the tagged commit that the
# publish job will package, not whatever `main` is when the dispatch
# runs. Empty string = default ref for push/PR events.
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with:
version: v3.16.4
- name: Install helm-unittest plugin
run: helm plugin install --version 0.8.2 https://github.com/helm-unittest/helm-unittest
- name: Set up Python (for chart-testing)
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Set up chart-testing
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Build chart dependencies
run: helm dependency build deploy/charts/buzz
- name: Push gateway chart lint + render guard
run: deploy/charts/buzz-push-gateway/tests/render.sh
- name: ct lint
run: ct lint --config ct.yaml --all
- name: helm-unittest
run: helm unittest deploy/charts/buzz
- name: helm template (render every fixture)
run: |
set -euo pipefail
for f in deploy/charts/buzz/ci/*-values.yaml deploy/charts/buzz/tests/fixtures/*-values.yaml; do
echo "::group::render $f"
helm template buzz deploy/charts/buzz -f "$f"
echo "::endgroup::"
done
install-on-kind:
# Full end-to-end install requires the public ghcr.io/block/buzz image to
# exist and to embed Max's startup migrations. Runs only after Sami's
# image PR merges (`workflow_dispatch`) or on a schedule once main carries
# both prerequisites. Render/lint above is the per-PR signal.
#
# `inputs.version == ''` excludes the chart-tag rescue dispatch (which
# carries a version): that path only packages+publishes, it does not install.
name: install on kind (gated)
if: github.event_name == 'workflow_dispatch' && inputs.version == ''
runs-on: ubuntu-latest
needs: lint-and-unittest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with:
version: v3.16.4
- name: Set up Python (for chart-testing)
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Set up chart-testing
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Create kind cluster
uses: helm/kind-action@0025e74a8c7512023d06dc019c617aa3cf561fde # v1.10.0
with:
version: v0.24.0
node_image: kindest/node:v1.31.0
- name: Build chart dependencies
run: helm dependency build deploy/charts/buzz
- name: ct install (quickstart profile)
run: ct install --config ct.yaml --charts deploy/charts/buzz --helm-extra-args "--timeout 600s"
publish:
# Packages the chart and pushes it to GHCR as an OCI artifact. Fires only on
# a `chart-v*` tag push (manual) or the auto-tag rescue dispatch (which
# carries inputs.version) — never on `main` pushes or PRs, so an in-progress
# `main` can never overwrite a released chart version. Mirrors docker.yml's
# GHCR publish (login with GITHUB_TOKEN, packages: write, fork override var).
name: publish chart to GHCR
if: >
startsWith(github.ref, 'refs/tags/chart-v') ||
(github.event_name == 'workflow_dispatch' && inputs.version != '')
runs-on: ubuntu-latest
needs: lint-and-unittest
timeout-minutes: 15
permissions:
contents: read
packages: write # push the chart to GHCR
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# On the rescue dispatch, build the tagged commit (github.ref is
# `main` there); on a tag push, the default ref is already the tag.
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
fetch-depth: 0
persist-credentials: false
- name: Set up Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with:
version: v3.16.4
- name: Resolve chart version
id: ver
env:
# Bare on the rescue dispatch; empty on a tag push (derive from ref).
INPUT_VERSION: ${{ inputs.version }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -n "$INPUT_VERSION" ]; then
version="$INPUT_VERSION"
tag_ref="refs/tags/chart-v${version}"
tag_sha="$(git rev-parse -q --verify "${tag_ref}^{commit}" || true)"
head_sha="$(git rev-parse HEAD)"
if [ -z "$tag_sha" ] || [ "$head_sha" != "$tag_sha" ]; then
echo "::error::workflow_dispatch with version '$version' must check out matching tag 'chart-v${version}' (HEAD=$head_sha, tag=${tag_sha:-missing})"
exit 1
fi
else
# refs/tags/chart-v0.1.0 → github.ref_name is `chart-v0.1.0`.
version="${REF_NAME#chart-v}"
fi
if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Resolved chart version '$version' is not valid semver"
exit 1
fi
# The tag is the source of truth, but the published artifact's version
# comes from Chart.yaml — they must agree or we'd publish a mislabeled
# chart. Fail loudly on drift rather than silently shipping a mismatch.
chart_version="$(helm show chart deploy/charts/buzz | awk '/^version:/ {print $2}')"
if [ "$chart_version" != "$version" ]; then
echo "::error::Tag version '$version' != Chart.yaml version '$chart_version'. Bump Chart.yaml to match the tag."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Publishing chart version $version"
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build chart dependencies
run: helm dependency build deploy/charts/buzz
- name: Package chart
run: helm package deploy/charts/buzz --destination dist
- name: Push chart to GHCR
env:
CHART_REPO: ${{ env.CHART_REPO }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
helm push "dist/buzz-${VERSION}.tgz" "$CHART_REPO"
- name: Summary
env:
CHART_REPO: ${{ env.CHART_REPO }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
# CHART_REPO is oci://ghcr.io/block/buzz/charts; helm push appends the
# chart name, so the install ref is .../charts/buzz.
INSTALL_REF="${CHART_REPO}/buzz"
{
echo "### Published chart \`buzz\` \`${VERSION}\`"
echo
echo "**OCI ref:** \`${INSTALL_REF}:${VERSION}\`"
echo
echo "Install:"
echo '```'
echo "helm install buzz ${INSTALL_REF} --version ${VERSION}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,68 +0,0 @@
name: push gateway helm chart
on:
workflow_dispatch:
inputs:
version:
description: "Chart semver (without push-chart-v prefix)"
required: true
ref:
description: "Matching push-chart-v tag"
required: true
push:
tags: ["push-chart-v[0-9]*"]
pull_request:
paths:
- "deploy/charts/buzz-push-gateway/**"
- ".github/workflows/push-gateway-helm-chart.yml"
permissions: {}
env:
CHART_REPO: oci://ghcr.io/block/buzz/charts
jobs:
validate:
runs-on: ubuntu-latest
permissions: { contents: read }
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
fetch-depth: 0
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4
with: { version: v3.16.4 }
- run: deploy/charts/buzz-push-gateway/tests/render.sh
- run: deploy/charts/buzz-push-gateway/tests/release-contract.sh
publish:
if: github.event_name != 'pull_request'
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
fetch-depth: 0
persist-credentials: false
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4
with: { version: v3.16.4 }
- name: Verify release tag and chart version
env:
INPUT_VERSION: ${{ inputs.version }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]
test "$(helm show chart deploy/charts/buzz-push-gateway | awk '/^version:/ {print $2}')" = "$version"
if [ -n "$INPUT_VERSION" ]; then
test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/push-chart-v${version}^{commit}")"
fi
echo "VERSION=$version" >> "$GITHUB_ENV"
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: helm package deploy/charts/buzz-push-gateway --destination dist
- run: helm push "dist/buzz-push-gateway-${VERSION}.tgz" "$CHART_REPO"

View File

@@ -1,948 +0,0 @@
name: Release
on:
push:
tags:
- 'v[0-9]*'
workflow_dispatch:
inputs:
version:
description: "Semver version (e.g. 0.4.0) — only for manual runs"
required: true
ref:
description: "Branch, tag, or SHA to build — only for manual runs"
default: main
required: true
jobs:
# Shared setup: determine the version and create the release objects all four
# platform jobs upload into. Runs on ubuntu with no OIDC so a branch
# workflow_dispatch can exercise the platform builds without the codesign
# AssumeRole wall that gates the arm64 job.
setup:
name: Setup
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Determine version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ "$EVENT_NAME" == "push" ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$INPUT_VERSION"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid version '$VERSION'. Expected semver (e.g. 0.4.0 or 1.0.0-beta.1)"
exit 1
fi
# Checkout at the same ref the platform jobs use so git rev-parse HEAD
# pins the release to the right commit via --target.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- name: Create versioned GitHub release
env:
VERSION: ${{ steps.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_SHA=$(git rev-parse HEAD)
NOTES=""
if [[ -f CHANGELOG.md ]]; then
NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md)
fi
if [[ -z "$NOTES" ]]; then
NOTES="Buzz Desktop v${VERSION}"
fi
PRERELEASE_FLAGS=()
if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then
PRERELEASE_FLAGS=(--prerelease --latest=false)
fi
gh release create "v${VERSION}" \
--target "$RELEASE_SHA" \
--title "Buzz Desktop v${VERSION}" \
--notes "$NOTES" \
"${PRERELEASE_FLAGS[@]}"
- name: Create rolling auto-update release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create buzz-desktop-latest \
--prerelease \
--title "Buzz Desktop Auto-Update" \
--notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \
2>/dev/null || true
release:
name: Release
if: github.repository == 'block/buzz'
runs-on: macos-latest
needs: setup
timeout-minutes: 60
permissions:
contents: write
id-token: write # required by block/apple-codesign-action for OIDC
outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }}
sig: ${{ steps.read-sig.outputs.sig }}
env:
VERSION: ${{ needs.setup.outputs.version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Patch version
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Generate release config
run: cd desktop && node scripts/build-release-config.mjs
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
- name: Build sidecars
run: |
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
- name: Resolve mesh-llm rev
id: mesh_rev
run: |
set -euo pipefail
REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])')
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
echo "rev=$REV" >> "$GITHUB_OUTPUT"
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
- name: Restore mesh llama build cache
id: llama_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build mesh llama native libraries
if: steps.llama_cache.outputs.cache-hit != 'true'
env:
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
run: |
set -euo pipefail
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
SHORT="$MESH_REV_SHORT"
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
if [[ -z "$MESH_ROOT" ]]; then
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
exit 1
fi
export LLAMA_STAGE_BACKEND=metal
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
- name: Save mesh llama build cache
if: steps.llama_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ github.workspace }}/.cache/mesh-llama
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
- name: Build unsigned Tauri app
run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.release.conf.json
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
MACOSX_DEPLOYMENT_TARGET: "10.15"
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
LLAMA_STAGE_BACKEND: metal
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
SKIPPY_LLAMA_AUTO_BUILD: "0"
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
- name: Locate unsigned DMG
id: unsigned
run: |
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
- name: Set DMG Finder label text size
env:
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
- name: Codesign and Notarize
id: codesign
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
with:
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
entitlements-plist-path: desktop/src-tauri/Entitlements.plist
artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-arm64
- name: Replace DMG and rebuild updater archive
env:
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
APP_DIR="${BUNDLE_DIR}/macos"
# Replace unsigned DMG with the signed/notarized one.
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
# Swap the unsigned .app for the signed .app extracted from the action's zip.
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract"
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
rm -rf "${APP_DIR}/Buzz.app"
cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app"
# Rebuild the updater archive from the signed .app and re-sign it with the Tauri updater key.
rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig"
(cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app)
TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz"
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
- name: Verify code signature
run: |
codesign --verify --deep --strict --verbose=2 \
desktop/src-tauri/target/release/bundle/macos/Buzz.app
spctl --assess --type execute --verbose=4 \
desktop/src-tauri/target/release/bundle/macos/Buzz.app
- name: Locate build artifacts
id: artifacts
run: |
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
# Find the DMG (Tauri names it Buzz_<version>_<arch>.dmg)
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
# Find the updater .tar.gz and .sig
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
SIG="${ARCHIVE}.sig"
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1
fi
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
- name: Read updater signature
id: read-sig
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- name: Upload arm64 DMG to versioned GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber
- name: Upload updater archive to rolling release
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
run: |
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
release-macos-x64:
name: Release macOS (Intel)
if: github.repository == 'block/buzz'
runs-on: macos-latest
needs: setup
timeout-minutes: 60
permissions:
contents: write
id-token: write # required by block/apple-codesign-action for OIDC
outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }}
sig: ${{ steps.read-sig.outputs.sig }}
env:
VERSION: ${{ needs.setup.outputs.version }}
TARGET: x86_64-apple-darwin
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Add Rust target
run: rustup target add "$TARGET"
- name: Patch version
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Generate release config
run: cd desktop && node scripts/build-release-config.mjs
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
- name: Build sidecars
run: |
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build unsigned Tauri app
run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
MACOSX_DEPLOYMENT_TARGET: "10.15"
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
- name: Locate unsigned DMG
id: unsigned
run: |
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
- name: Set DMG Finder label text size
env:
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
- name: Codesign and Notarize
id: codesign
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
with:
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
entitlements-plist-path: desktop/src-tauri/Entitlements.plist
artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-x64
- name: Replace DMG and rebuild updater archive
env:
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos"
# Replace the unsigned DMG with the signed/notarized one.
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
# Swap the unsigned .app for the signed .app from the action's zip.
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract-x64"
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
rm -rf "${APP_DIR}/Buzz.app"
cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app"
# Rebuild the updater archive from the signed .app and re-sign with the Tauri updater key.
rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig"
(cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app)
TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz"
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
- name: Verify code signature
run: |
APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app"
codesign --verify --deep --strict --verbose=2 "$APP_DIR"
spctl --assess --type execute --verbose=4 "$APP_DIR"
- name: Locate updater archive
id: artifacts
run: |
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
SIG="${ARCHIVE}.sig"
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1
fi
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
- name: Read updater signature
id: read-sig
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- name: Upload Intel DMG to versioned GitHub release
run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
- name: Upload updater archive to rolling release
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
run: |
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
release-linux:
name: Release Linux
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
# Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh.
container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982
needs: setup
timeout-minutes: 60
permissions:
contents: write
env:
# AppImage tools (linuxdeploy, appimagetool) are themselves AppImages.
# Containers lack FUSE, so we must use the extract-and-run fallback.
APPIMAGE_EXTRACT_AND_RUN: "1"
# This job runs in a container where the default run shell is dash;
# the AppImage steps below use bash-only syntax ([[ ]], mapfile, arrays).
defaults:
run:
shell: bash
outputs:
archive_name: ${{ steps.linux-artifacts.outputs.archive_name }}
sig: ${{ steps.read-sig.outputs.sig }}
steps:
- name: Install system dependencies
env:
DEBIAN_FRONTEND: noninteractive
run: |
# Must run first: bare ubuntu:22.04 ships without curl, wget, git, or
# ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs
# both), and actions/checkout falls back to a REST tarball without git.
# Running as root — no sudo needed.
apt-get update \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30
apt-get install -y --no-install-recommends \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=120 \
build-essential \
ca-certificates \
curl \
desktop-file-utils \
file \
git \
libasound2-dev \
libayatana-appindicator3-dev \
libgtk-3-dev \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
patchelf \
pkg-config \
squashfs-tools \
wget \
xdg-utils
# Install GitHub CLI — preinstalled on runners but absent in containers.
# wget and ca-certificates are now available from the step above.
mkdir -p -m 755 /etc/apt/keyrings
wget -q --tries=3 --timeout=30 -O /usr/share/keyrings/githubcli-archive-keyring.gpg \
https://cli.github.com/packages/githubcli-archive-keyring.gpg
# Pin the keyring like appimagetool below. If GitHub rotates the
# keyring this fails loudly — recompute and update the hash.
echo "6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b /usr/share/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list
apt-get update
apt-get install -y --no-install-recommends gh
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- name: Mark workspace safe for git (containerized job)
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: desktop/src-tauri
lookup-only: true
- name: Install appimagetool
run: |
# Pin to an immutable release tag to avoid supply-chain drift from the
# mutable `continuous` tag. Tag: 1.9.1, asset: appimagetool-<arch>.AppImage
# (https://github.com/AppImage/appimagetool/releases/tag/1.9.1)
case "$(uname -m)" in
x86_64) ARCH_SUFFIX="x86_64" ;;
aarch64) ARCH_SUFFIX="aarch64" ;;
*)
echo "::error::Unsupported architecture: $(uname -m)"
exit 1
;;
esac
wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage"
# SHA256 integrity check. Refuse to run an unverified binary: if a new
# arch (e.g. aarch64) is enabled in CI, compute its hash and add it here.
if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c
else
echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture"
exit 1
fi
install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool
# appimagetool otherwise fetches the AppImage type2 runtime from the
# MUTABLE `continuous` tag at repack time — the runtime is the first
# code users execute, so pin it too. Tag: 20251108, hash is for the
# x86_64 asset (non-x86_64 already hard-fails above).
# (https://github.com/AppImage/type2-runtime/releases/tag/20251108)
wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \
"https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}"
echo "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime" | sha256sum -c
install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime
echo "APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime" >> "$GITHUB_ENV"
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Patch version
env:
VERSION: ${{ needs.setup.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Build sidecars
run: |
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Generate release config
run: cd desktop && node scripts/build-release-config.mjs
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
- name: Build Linux Tauri app
run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Fix AppImage (remove infra libs, symlink system GStreamer)
run: |
mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f)
if [[ ${#APPIMAGES[@]} -eq 0 ]]; then
echo "::error::No AppImage found to post-process"
exit 1
fi
if [[ ${#APPIMAGES[@]} -gt 1 ]]; then
echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}"
exit 1
fi
bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}"
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Locate Linux build artifacts
id: linux-artifacts
run: |
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
DEB=$(find "$BUNDLE_DIR/deb" -name '*.deb' -type f | head -1)
if [[ -z "$DEB" ]]; then
echo "::error::No DEB found in $BUNDLE_DIR/deb"
exit 1
fi
echo "deb=$DEB" >> "$GITHUB_OUTPUT"
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' -type f | head -1)
if [[ -z "$APPIMAGE" ]]; then
echo "::error::No AppImage found in $BUNDLE_DIR/appimage"
exit 1
fi
echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT"
# Updater archive: Tauri 2.11+ with createUpdaterArtifacts signs the
# AppImage directly (*.AppImage + *.AppImage.sig). Earlier versions
# wrapped it in a tar.gz. Try the new format first, fall back to legacy.
ARCHIVE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage.tar.gz' ! -name '*.sig' -type f | head -1)
if [[ -n "$ARCHIVE" ]]; then
SIG="${ARCHIVE}.sig"
else
ARCHIVE="$APPIMAGE"
SIG="${APPIMAGE}.sig"
fi
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::AppImage updater archive or signature not found in $BUNDLE_DIR/appimage"
exit 1
fi
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
- name: Read updater signature
id: read-sig
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
env:
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
# NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux)
- name: Upload Linux artifacts to versioned GitHub release
env:
VERSION: ${{ needs.setup.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }}
APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }}
run: |
gh release upload "v$VERSION" \
"$DEB_PATH" \
"$APPIMAGE_PATH" \
--clobber
- name: Upload updater archive to rolling release
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
run: |
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }}
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
release-windows:
name: Release Windows
runs-on: windows-latest
needs: setup
timeout-minutes: 60
permissions:
contents: write
outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }}
sig: ${{ steps.read-sig.outputs.sig }}
env:
VERSION: ${{ needs.setup.outputs.version }}
TARGET: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
targets: ${{ env.TARGET }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
# Disable dependency caching: a writable cache in this release workflow
# (contents: write, feeds a signed installer) is a poisoning vector. pnpm
# install runs uncached below.
package-manager-cache: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11.4.0
- name: Install desktop dependencies
shell: bash
run: pnpm install --frozen-lockfile
- name: Patch version
shell: bash
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Generate release config
shell: bash
run: cd desktop && node scripts/build-release-config.mjs
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
- name: Build sidecars
shell: bash
run: |
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build Windows NSIS installer (unsigned)
shell: bash
run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json
env:
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Locate Windows build artifacts
id: artifacts
shell: bash
run: |
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
# Find the NSIS installer .exe
EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1)
if [[ -z "$EXE" ]]; then
echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis"
exit 1
fi
# Tauri 2.x with createUpdaterArtifacts: true signs the NSIS
# installer in place (<name>-setup.exe + <name>-setup.exe.sig).
SIG="${EXE}.sig"
if [[ ! -f "$SIG" ]]; then
echo "::error::NSIS installer signature not found: $SIG"
exit 1
fi
# Rename with _alpha-unsigned marker, keeping the detached signature
# in lockstep so latest.json matches the uploaded updater artifact.
EXE_DIR=$(dirname "$EXE")
EXE_BASE=$(basename "$EXE" .exe)
MARKED_EXE="${EXE_DIR}/${EXE_BASE}_alpha-unsigned.exe"
MARKED_SIG="${MARKED_EXE}.sig"
mv "$EXE" "$MARKED_EXE"
mv "$SIG" "$MARKED_SIG"
echo "exe=$MARKED_EXE" >> "$GITHUB_OUTPUT"
echo "archive=$MARKED_EXE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$MARKED_EXE")" >> "$GITHUB_OUTPUT"
echo "sig=$MARKED_SIG" >> "$GITHUB_OUTPUT"
- name: Read updater signature
id: read-sig
shell: bash
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- name: Upload Windows installer to versioned GitHub release
shell: bash
run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EXE_PATH: ${{ steps.artifacts.outputs.exe }}
- name: Upload updater archive to rolling release
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
shell: bash
run: |
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
assemble-manifest:
name: Assemble multi-platform latest.json
# Only tag-backed releases assemble latest.json. Branch workflow_dispatch
# runs skip rolling-release uploads, so their archive URLs never exist.
if: |
always() &&
needs.setup.result == 'success' &&
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version)))
runs-on: ubuntu-latest
needs: [setup, release, release-macos-x64, release-linux, release-windows]
timeout-minutes: 10
permissions:
contents: write
env:
VERSION: ${{ needs.setup.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- name: Write signature files
env:
RESULT_ARM64: ${{ needs.release.result }}
RESULT_X64: ${{ needs.release-macos-x64.result }}
RESULT_LINUX: ${{ needs.release-linux.result }}
RESULT_WIN: ${{ needs.release-windows.result }}
SIG_ARM64: ${{ needs.release.outputs.sig }}
SIG_X64: ${{ needs.release-macos-x64.outputs.sig }}
SIG_LINUX: ${{ needs.release-linux.outputs.sig }}
SIG_WIN: ${{ needs.release-windows.outputs.sig }}
run: |
set -euo pipefail
mkdir -p /tmp/sigs
write_sig() {
local result="$1" platform="$2" sig="$3"
if [[ "$result" == "success" ]]; then
[[ -n "$sig" ]] || { echo "::error::Missing signature for successful platform: $platform"; exit 1; }
printf '%s' "$sig" > "/tmp/sigs/${platform}.sig"
fi
}
write_sig "$RESULT_ARM64" darwin-aarch64 "$SIG_ARM64"
write_sig "$RESULT_X64" darwin-x86_64 "$SIG_X64"
write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX"
write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN"
- name: Verify archive URLs are accessible
env:
RESULT_ARM64: ${{ needs.release.result }}
RESULT_X64: ${{ needs.release-macos-x64.result }}
RESULT_LINUX: ${{ needs.release-linux.result }}
RESULT_WIN: ${{ needs.release-windows.result }}
ARCHIVE_ARM64: ${{ needs.release.outputs.archive_name }}
ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }}
ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }}
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
run: |
set -euo pipefail
BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest"
ARCHIVES=()
add_archive() {
local result="$1" platform="$2" archive="$3"
if [[ "$result" == "success" ]]; then
[[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; }
ARCHIVES+=("$archive")
fi
}
add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
for name in "${ARCHIVES[@]}"; do
echo "Checking $BASE/$name ..."
success=false
for attempt in 1 2 3; do
if curl -fsI "$BASE/$name" > /dev/null 2>&1; then
success=true
break
fi
echo "Attempt $attempt failed for $name, retrying in 10s..."
sleep 10
done
if [ "$success" != "true" ]; then
echo "::error::Archive not accessible after 3 attempts: $BASE/$name"
exit 1
fi
done
echo "All archive URLs verified."
- name: Generate unified latest.json
env:
RESULT_ARM64: ${{ needs.release.result }}
RESULT_X64: ${{ needs.release-macos-x64.result }}
RESULT_LINUX: ${{ needs.release-linux.result }}
RESULT_WIN: ${{ needs.release-windows.result }}
ARCHIVE_ARM64: ${{ needs.release.outputs.archive_name }}
ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }}
ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }}
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
run: |
set -euo pipefail
BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest"
TRIPLES=()
add_triple() {
local result="$1" platform="$2" archive="$3"
if [[ "$result" == "success" ]]; then
[[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; }
TRIPLES+=("${platform}:/tmp/sigs/${platform}.sig:${BASE}/${archive}")
fi
}
add_triple "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
add_triple "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
add_triple "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
add_triple "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
[ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; }
bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json
cat latest.json
- name: Upload latest.json to rolling release
run: |
gh release upload buzz-desktop-latest latest.json --clobber

View File

@@ -1,201 +0,0 @@
name: Sprig
# Builds and publishes Sprig — one deploy-anywhere Linux multicall binary for:
# buzz-acp ACP harness that bridges Buzz events to the LLM agent
# buzz-agent ACP-compliant agent (spawns MCP, calls LLMs)
# buzz-dev-mcp Developer MCP server (multicall: rg, tree, buzz,
# git-credential-nostr, git-sign-nostr)
#
# Targets: x86_64-unknown-linux-musl and aarch64-unknown-linux-musl (static
# musl so the tarball runs on any modern Linux without libc surprises).
#
# Triggers:
# - push to main → updates rolling `sprig-latest` release
# - tag `sprig-v*` → versioned release
# - workflow_dispatch → manual canary build (no release publish unless asked)
on:
push:
branches: [main]
tags: ["sprig-v*"]
workflow_dispatch:
inputs:
publish:
description: "Publish to the rolling release (otherwise artifacts only)"
type: boolean
default: false
permissions:
contents: read
jobs:
build:
name: Build (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install cross
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
with:
tool: cross@0.2.5
- name: Resolve version
id: ver
run: |
set -euo pipefail
WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \
| jq -r '.packages[] | select(.name=="sprig") | .version')
REF="${GITHUB_REF#refs/tags/}"
if [[ "$GITHUB_REF" == refs/tags/sprig-v* ]]; then
VERSION="${REF#sprig-v}"
CHANNEL="tag"
else
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${WORKSPACE_VERSION}+git.${SHORT_SHA}"
CHANNEL="rolling"
fi
{
echo "workspace_version=$WORKSPACE_VERSION"
echo "version=$VERSION"
echo "channel=$CHANNEL"
} >> "$GITHUB_OUTPUT"
echo "Resolved version=$VERSION channel=$CHANNEL"
- name: Build & package Sprig
id: pkg
env:
TARGET: ${{ matrix.target }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
GIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ "$CHANNEL" == "tag" ]]; then
ARCHIVE_BASENAME="sprig-${VERSION}-${TARGET}"
else
ARCHIVE_BASENAME="sprig-${TARGET}"
fi
ARCHIVE_BASENAME="$ARCHIVE_BASENAME" \
./scripts/build-sprig.sh "$VERSION" "$TARGET"
ARCHIVE="dist/${ARCHIVE_BASENAME}.tar.gz"
test -f "$ARCHIVE"
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=${ARCHIVE_BASENAME}.tar.gz" >> "$GITHUB_OUTPUT"
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sprig-${{ matrix.target }}
path: |
${{ steps.pkg.outputs.archive }}
${{ steps.pkg.outputs.archive }}.sha256
if-no-files-found: error
retention-days: 30
publish:
name: Publish rolling release
needs: build
if: |
github.event_name == 'push' && github.ref == 'refs/heads/main'
|| (github.event_name == 'workflow_dispatch' && inputs.publish)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Download all Sprig artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: sprig-*
merge-multiple: true
- name: List assets
run: ls -lh dist/
- name: Update rolling release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
TAG="sprig-latest"
TITLE="Sprig (rolling)"
NOTES="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${SHA}\`)."
if gh api "repos/${REPO}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
gh api -X PATCH "repos/${REPO}/git/refs/tags/${TAG}" \
-f sha="${SHA}" -F force=true >/dev/null
else
gh api -X POST "repos/${REPO}/git/refs" \
-f ref="refs/tags/${TAG}" -f sha="${SHA}" >/dev/null
fi
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" \
--prerelease \
--target "${SHA}" \
--title "$TITLE" \
--notes "$NOTES"
else
gh release edit "$TAG" \
--prerelease \
--target "${SHA}" \
--title "$TITLE" \
--notes "$NOTES"
fi
gh release upload "$TAG" dist/* --clobber
publish-tag:
name: Publish tagged release
needs: build
if: startsWith(github.ref, 'refs/tags/sprig-v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Download all Sprig artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: sprig-*
merge-multiple: true
- name: Resolve tag version
id: ver
run: |
REF="${GITHUB_REF#refs/tags/}"
VERSION="${REF#sprig-v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$REF" >> "$GITHUB_OUTPUT"
- name: Create tagged release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
gh release create "$TAG" \
--title "Sprig v${VERSION}" \
--notes "Sprig v${VERSION} — Linux all-in-one builds of buzz-acp + buzz-agent + buzz-dev-mcp." \
dist/*

69
.gitignore vendored
View File

@@ -1,69 +0,0 @@
# Build artifacts
/target/
/dist/
# lefthook-generated hook scripts (machine-specific)
.hooks/
# Environment files (may contain secrets)
.env
.env.local
.env.*.local
# Editor / IDE
.idea/
.vscode/*
!.vscode/settings.json
*.swp
*.swo
*~
.*.sw?
# OS artifacts
.DS_Store
Thumbs.db
# Scratch / working files (AI reviews, notes, drafts)
.scratch/
# Playwright artifacts
playwright-report/
test-results/
blob-report/
.playwright-mcp/
# Node modules (pnpm workspace hoists to root)
node_modules/
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
/package-lock.json
# sqlx offline query data (generated, not portable)
.sqlx/
# SQLite database (created by relay in CWD)
buzz.db
buzz.db-wal
buzz.db-shm
# Docker volumes (if mounted locally)
mysql-data/
typesense-data/
# Hermit (toolchain manager cache)
.hermit/
doc/
/repos/
# Local identity files
identity.key
**/identity.key
# Claude Code worktrees
.claude/worktrees/
# mesh-llm build cache
.cache/
# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
deploy/charts/*/charts/*.tgz

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md

View File

@@ -1 +0,0 @@
../../../desktop/src-tauri/src/managed_agents/nest_skill.md

View File

@@ -1,3 +0,0 @@
{
"rust-analyzer.cargo.targetDir": true
}

561
AGENTS.md
View File

@@ -1,561 +0,0 @@
# AGENTS.md — AI Agent Contributor Guide
This guide is for AI agents contributing to the Buzz codebase. It covers
agent-specific context and conventions. For general contributor info (setup,
code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).
---
## Ecosystem
Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment:
| Repo | Purpose |
|------|---------|
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix |
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
```
block/buzz (source)
├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
├─► sprout-oss (relay Docker image → ECR)
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
```
See [RELEASING.md](RELEASING.md) for the desktop release flow and
[CONTRIBUTING.md § Ecosystem](CONTRIBUTING.md#ecosystem) for contributor
access information.
---
## Repo Structure
```
crates/
# Relay + core
buzz-relay # WebSocket relay server — main entry point; also hosts git + huddle audio
buzz-core # Core types, event verification, filter matching, kind registry
buzz-db # Postgres event store and data access layer
buzz-auth # Authentication and authorization
buzz-pubsub # Redis pub/sub fan-out, presence, typing indicators
buzz-search # Postgres FTS full-text search
buzz-audit # Hash-chain audit log
buzz-media # Blossom/S3 media storage
# Agent surface
buzz-acp # ACP harness bridging Buzz events to AI agents
buzz-agent # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
buzz-dev-mcp # Developer MCP server — shell + file-edit tools
buzz-persona # Agent persona packs
buzz-workflow # YAML-as-code workflow engine (evalexpr conditions)
# Clients + interop
buzz-pair-relay # Ephemeral sidecar relay for NIP-AB device pairing
buzz-pairing-cli # CLI for NIP-AB device pairing interop testing
git-sign-nostr # Sign git objects with a Nostr key
git-credential-nostr # Git credential helper for Nostr-authed push/fetch
# Tooling + shared
buzz-cli # Agent-first CLI
buzz-sdk # Typed Nostr event builders
buzz-admin # Operator CLI for relay administration
buzz-ws-client # Shared NIP-42 WebSocket client (connect, auth, publish)
buzz-test-client # Integration test client and E2E test suite
sprig # All-in-one harness bundling ACP, agent, and dev MCP
desktop/ # Tauri 2 + React 19 desktop app
web/ # Browser web client (repo browser, served by the relay)
mobile/ # Flutter mobile app
migrations/ # SQL migrations (auto-applied on relay startup)
scripts/ # Dev tooling
.env.example # Config template — copy to .env before running
```
---
## Getting Started
```bash
. ./bin/activate-hermit # activate hermit toolchain (Rust, Node, etc.)
cp .env.example .env # configure local environment
just setup # install deps, run migrations
just relay # start relay at ws://localhost:3000
just ci # run before any PR
```
See CONTRIBUTING.md for full setup details and dependency requirements.
---
## Quality Gates
Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +
unit tests + builds. Clippy passing does not mean fmt passes; run both.
Run `just test` for integration tests if you touched `buzz-relay`,
`buzz-db`, or `buzz-auth` — these require a running Postgres and Redis.
**Pre-commit hooks** are installed automatically by `just setup` and auto-fix
formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust
fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).
Auto-fixable issues are fixed and re-staged; unfixable lint issues block the
commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests
in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with
pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting
in one shot. Run `just ci` for the full local gate. Run `just hooks` to
re-install hooks after env changes.
Additional rules:
- No `unsafe` code
- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types
- New public API must have doc comments
---
## Key Patterns
**Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary.
**Prefer Nostr events over new HTTP endpoints**: For new feature work, model
the operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler
in `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is
reserved for things that genuinely need an HTTP-only surface: media upload/download
(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks,
and the generic Nostr bridge endpoints:
- `POST /events` — submit any signed event (same path the WebSocket uses).
- `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters
are routed to `buzz-search` (Postgres FTS) automatically.
- `POST /count` — Nostr COUNT filters over HTTP.
If you find yourself reaching for a new HTTP endpoint, first check whether
an event kind would do the job — it usually will, and you get realtime
fan-out, NIP-29 scoping, and the existing auth pipeline for free.
Reference https://github.com/nostr-protocol/nips
**Event kinds**: All event kind integers are defined in
`buzz-core/src/kind.rs`. New features get new kind integers — add them here
first, then implement handling in the relay.
**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
Filters and queries must scope to `h` tags when operating within a channel.
**Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate.
**Workflow conditions**: `buzz-workflow` uses
[evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions
simple and testable.
**Thread counters**: `reply_count` and `descendant_count` are materialized on
thread root events. Any code that inserts replies must update these counters —
check existing reply handlers for the pattern.
---
## Agent CLI (`buzz-cli`)
`buzz` is the agent-first CLI. Auth env vars
(`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected
by the ACP harness into managed agent subprocesses. In development, set
`BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually.
### Building the CLI
```bash
cargo build --release -p buzz-cli
```
Binary location: `./target/release/buzz`. Add `./target/release` to `PATH`
or invoke with the full path.
### Deep Links
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
thread. To read the linked thread:
```bash
buzz messages thread --channel <uuid> --event <hex> --format compact
```
Extract `channel` and `id` from the URL query parameters. The optional
`thread` parameter (root event ID) can be ignored — `messages thread` resolves
the full thread from the event ID alone.
All reads return sig-stripped JSON arrays; all writes return
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).
`--format compact` is a **global** flag — it goes before the subcommand:
`buzz --format compact channels list`, NOT `buzz channels list --format compact`.
See `crates/buzz-cli/TESTING.md` for the full live-testing runbook.
---
## Testing
```bash
just test-unit # unit tests, no infrastructure needed
just test # full integration suite (requires Postgres + Redis)
```
E2E tests live in `crates/buzz-test-client/tests/`:
- `e2e_relay.rs` — WebSocket relay protocol
- `e2e_media.rs` — media upload/download (Blossom)
- `e2e_media_extended.rs` — extended media scenarios
- `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)
Desktop E2E: `cd desktop && pnpm exec playwright test`
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
### PR Screenshots
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
> from a PR body/comment. If you hand-edit PR markdown, run
> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.
For mobile simulator screenshots, save the PNGs in a local directory and run
`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument
with a markdown template containing `{{filename}}` placeholders.
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
starts preview server, runs Playwright automatically):
```bash
just desktop-screenshot --name home
just desktop-screenshot --name channel --route /channels/general
just desktop-screenshot --name search --click open-search
just desktop-screenshot --name settings --click open-settings
```
Options: `--name` (filename), `--route` (client route), `--active-channel`
(channel to view), `--click` (left-click data-testid or CSS selector),
`--right-click` (right-click for context menus), `--hover` (hover before
capture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar
only), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),
`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).
Output is a PNG path on stdout.
Use `--messages` to inject content into a channel before capture. The JSON file
is an array of objects — `channelName` and `content` are required, all other
fields are optional and passed through to `__BUZZ_E2E_EMIT_MOCK_MESSAGE__`:
```json
[
{
"channelName": "random",
"content": "Hey @tyler check this out",
"pubkey": "953d...",
"kind": 40002,
"mentionPubkeys": ["deadbeef..."],
"extraTags": [["broadcast", "1"], ["e", "some-root-id"]],
"parentEventId": "abc123"
}
]
```
Without `--active-channel`, all messages must target the same channel and the
helper navigates to that channel (useful for showing message content). With
`--active-channel`, messages can target multiple channels while the "camera"
stays on the specified channel (useful for unread indicators, badges, etc.).
```bash
# Messages in the channel you're viewing (code blocks, formatting, etc.)
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
# Messages in OTHER channels to trigger unread state
just desktop-screenshot --name unread-dot \
--active-channel general --messages /tmp/badge-msgs.json
# Cropped to sidebar only (256px wide)
just desktop-screenshot --name sidebar-unread \
--active-channel general --messages /tmp/badge-msgs.json \
--clip 0,0,256,720
# Context menu on an unread channel (wider crop to include popup)
just desktop-screenshot --name ctx-mark-read \
--active-channel general --messages /tmp/badge-msgs.json \
--right-click channel-random --clip 0,200,320,300
# Hover state (e.g. copy button reveal)
just desktop-screenshot --name copy-hover \
--messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"
```
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
`scripts/post-screenshots.sh` hosts PNGs on a per-developer branch
(`agent-screenshots/<github-username>`) and posts a PR comment with
commit-SHA-based image URLs (immutable — safe from later overwrites):
```bash
./scripts/post-screenshots.sh 803 test-results/screenshots
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
```
The body file supports `{{filename}}` placeholders (without `.png`) to inline
images at specific positions. Images not referenced by any placeholder are
appended at the end. Without placeholders, all images are appended (backward
compatible).
```markdown
### Unread dot
A message arrives in `#random`.
{{01-unread-dot}}
### Context menu
Right-click shows "Mark as read".
{{02-context-menu}}
```
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
branch, but the script **appends a new PR comment** — it does not edit or
delete the previous one. After reposting, delete the superseded comment so
only the current set remains, otherwise reviewers still see the stale images:
```bash
# List screenshot comments to find the stale one's id
gh pr view <pr> --repo block/buzz --json comments \
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
gh api -X DELETE repos/block/buzz/issues/comments/<stale-comment-id>
```
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
### Writing E2E Screenshot Specs
When screenshots need seeded state, live messages, or UI interaction before
capture, write a Playwright spec instead of using `just desktop-screenshot`.
Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
**Stale server:** `reuseExistingServer: true` means a previous build's server
serves old code. Kill port 4173 and `pnpm run build` before re-running tests
after code changes.
**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
must run BEFORE `installMockBridge(page)` — React reads state on mount, the
bridge triggers mount.
**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
`__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
subscription. Navigate to the channel first (triggers subscription), then away
(so unread indicators appear), then inject.
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
resolves mid-animation — wait for completion before screenshotting. Use the
shared helper (mandatory before any `page.screenshot()` or
`locator.screenshot()` in specs):
```ts
import { waitForAnimations } from "../helpers/animations";
// ... after the element is visible but before capturing:
await waitForAnimations(page);
await page.screenshot({ path: "...", clip: { ... } });
```
The `just desktop-screenshot` path (`screenshot.mjs`) calls
`waitForAnimations` automatically — no manual step needed there.
For per-element waits (rare — prefer the page-level helper above):
```ts
await menuItem.evaluate((el) =>
Promise.all(
el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
),
);
```
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
for sidebar features. Sidebar = 256px; context menus ~450px.
**Distinct states — verify before posting:** when one view renders many
elements at once (e.g. all team cards in a single grid), an unscoped
full-page `page.screenshot()` captures the *same* pixels for every shot, so
multiple PNGs come out byte-identical. Scope each shot to its subject with
`locator.screenshot()` (full-page `clip` only when an overlay like an open
dropdown must be included). Then gate on hash distinctness before posting:
```bash
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
```
Identical hashes mean two shots captured the same state — fix the spec, do
not post. This catches the most common screenshot regression.
**`general` has pre-seeded messages** making `hasUnread` always true. Use
`engineering` for "muted + no unread" visual states.
**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
description. See [PR #803](https://github.com/block/buzz/pull/803).
---
## Common Gotchas
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
3. **`messages search` must include `--kinds`** — an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least `--kinds 9,45001,45003` to scope the query.
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
---
## Desktop App
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
organized under `desktop/src/features/`. Biome handles linting and formatting.
```bash
just desktop-dev # web-only dev server (faster iteration)
just dev # full Tauri app with native shell
```
### Text sizing & zoom (use rem, never px)
The desktop app implements Cmd +/- zoom by scaling the root `<html>`
font-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native
webview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes
are frozen.**
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
px:
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author
text === `text-base` (16px) — chat is the app's base type size**, and the
surrounding timeline elements (timestamps, system rows, code, reactions) are
deliberate steps on that same stock ramp.
- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary
values.
- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom
and caused the message-timeline regression (PR #891).
- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They
zoom fine but re-fragment the scale we consolidated. Use a named token.
Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely
needs a size the stock/`2xs`/`3xs` scale can't express should you **add a
rem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`)
rather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in
`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any
new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
script.
### Workspace Switching
The desktop app supports multiple workspaces (each backed by a different relay).
Switching workspaces does **not** reload the page — it uses React key-based
remounting. `<AppReady key={workspaceKey} />` in `App.tsx` forces the entire
workspace-scoped subtree to unmount and remount with fresh state.
**Module-level singletons must be explicitly reset.** React remounting only
clears React state (useState, useRef, context). Module-level variables (Maps,
class instances, cached promises) survive across remounts. Every workspace-scoped
singleton needs a reset function wired into `resetWorkspaceState()` in
`desktop/src/features/workspaces/useWorkspaceInit.ts`.
Current singletons that are reset on workspace switch:
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
- `resetMediaCaches()` — proxy port and relay origin caches
- `clearSearchHitEventCache()` — search result event cache
- `clearAllDrafts()` — message draft cache
**If you add a new module-level cache, Map, or class instance that holds
workspace-scoped data, you must add its reset to `resetWorkspaceState()`.**
Failure to do so causes data from the old workspace to leak into the new one.
Key files:
- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary
- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend
- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell)
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`)
---
## Mobile App (Flutter)
The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
### Architecture
- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`
- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must
stay in sync with `desktop/src/shared/constants/kinds.ts`
### Rules
- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use
`HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.
- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or
`flutter upgrade`** — only `flutter test`, `flutter analyze`, and
`dart format` are safe for agents to run.
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
over raw `Theme.of(context)` calls.
- **Keep widgets small and composable.** One public widget per file; push
private sub-widgets (`_Foo`) into sibling `part` files under a
`<page>/` folder rather than growing the page file. Hard ceiling:
**1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via
`just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).
If the guard trips, **split the file — never bump the limit or add an
override to slip under it.**
- Feature modules must not import from other feature modules — only from
`shared/`.
- Use `Grid` tokens for spacing, `Radii` for border radius.
### Quality Checks
```bash
cd mobile
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
```
Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).
To run the app locally (starts Docker, relay, iOS simulator automatically):
```bash
just mobile-dev
```
### Testing Conventions
- Prefer **widget tests** over unit tests for UI components — test the
whole widget tree, not individual methods.
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
- Fake notifiers should extend the real notifier class and override `build()`.
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
---
## See Also
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds
- [README.md](README.md) — project overview and quick start

View File

@@ -1,827 +0,0 @@
# Buzz Architecture
## 1. Executive Summary
Buzz is a self-hosted team communication platform built on the Nostr protocol (NIP-01 wire format), where AI agents and humans are first-class equals. Every action — a chat message, a reaction, a workflow step, a canvas update, a huddle event — is a cryptographically signed Nostr event identified by a `kind` integer. Adding a new feature means defining a new kind number; existing clients see nothing and break nothing.
The relay is the single source of truth. All reads and writes flow through it. There is no peer-to-peer event exchange, no gossip, no replication — just clients connecting to one relay over WebSocket, and the relay enforcing auth, verifying signatures, persisting events, fanning out to subscribers, indexing for search, and triggering automation.
A Buzz **community** is the tenant-visible workspace selected by the request host.
The self-hosted default remains one host, one relay process, one implicit
community. Multi-community deployments move that semantic boundary one level up:
`req.community = resolve_host(connection.host)` is established before AUTH,
EVENT, REQ, REST, media, git, search, workflow, or pub/sub handling. Unknown
hosts fail closed, and NIP-98/API-token stamps must agree with the host-derived
community rather than overriding it.
Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc.
---
### System Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ │
│ Human (Nostr app, web, mobile) Agent (CLI tools via buzz-cli) │
│ │ │ │
│ └──────────── WebSocket ─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ buzz-relay (Axum) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ NIP-42 │ │ EVENT │ │ REQ │ │ HTTP bridge │ │
│ │ auth │ │ pipeline │ │ handler │ │ /events │ │
│ └──────────┘ └──────────┘ └──────────┘ │ /query │ │
│ │ /count │ │
│ ┌──────────────────────────────────────┐ │ /hooks/{id} │ │
│ │ SubscriptionRegistry │ │ /media/* │ │
│ │ DashMap: (channel_id, kind) → conns │ │ /git/* │ │
│ └──────────────────────────────────────┘ │ /info, NIP-05 │ │
│ └─────────────────────┘ │
└──────────┬──────────────┬──────────────────────────────────────────┘
│ │
┌─────▼──────┐ ┌────▼──────┐
│ Postgres │ │ Redis │
│ (events, │ │ (presence │
│ channels, │ │ SET EX, │
│ tokens, │ │ typing │
│ workflows, │ │ ZADD, │
│ audit) │ │ PUBLISH) │
└────────────┘ └───────────┘
Fan-out: sub_registry.fan_out() → conn_manager.send_to()
(in-process for local events; Redis round-trip for
events from other relay instances)
Redis PUBLISH occurs for channel-scoped events.
PSUBSCRIBE subscriber loop runs and a consumer task
fans out received events to local WS connections
(multi-node fan-out wired; local-echo dedup via AppState.local_event_ids).
┌──────────────┐
│ Postgres │ ← buzz-search (FTS over the search_tsv
│ (full-text │ generated column + GIN index)
│ search) │
└──────────────┘
```
---
### Crate Dependency Hierarchy
```
buzz-core (zero I/O — types, verification, filter matching, kind registry)
│
├── buzz-db (Postgres: events, channels, tokens, workflows, audit)
├── buzz-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting)
├── buzz-pubsub (Redis pub/sub, presence, typing indicators)
├── buzz-search (Postgres FTS: query, delete)
├── buzz-audit (hash-chain tamper-evident log)
└── buzz-workflow (YAML-as-code automation engine)
│
└── buzz-relay (ties everything together — the server)
buzz-acp (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC)
buzz-sdk (typed Nostr event builders — used by buzz-acp and buzz-cli)
buzz-media (Blossom/S3 media storage)
buzz-cli (agent-first CLI)
buzz-admin (operator CLI: relay membership + key generation)
buzz-test-client (integration test harness + manual CLI)
```
**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags.
---
## 2. The Protocol
Buzz uses Nostr NIP-01 on the wire. Every action is a JSON event with six fields:
```json
{
"id": "<sha256 of canonical serialization>",
"pubkey": "<secp256k1 public key, hex>",
"kind": <unsigned integer>,
"tags": [["e", "<event-id>"], ["p", "<pubkey>"], ...],
"content": "<JSON payload or plain text>",
"sig": "<Schnorr signature over id>"
}
```
The `kind` integer is the only dispatch switch. The relay routes, stores, and fans out events based on kind. Clients filter subscriptions by kind. New feature = new kind number = zero breaking changes to existing clients.
### Kind Ranges
| Range | Meaning |
|-------|---------|
| 0–9999 | Standard Nostr kinds (NIP-01 through NIP-XX) |
| 10000–19999 | Replaceable events (NIP-16) |
| 20000–29999 | Ephemeral events — not stored, not audited |
| 30000–39999 | Parameterized replaceable events |
| 40000–49999 | Buzz custom kinds |
### Buzz Custom Kinds (selected)
| Kind | Name | Description |
|------|------|-------------|
| 7 | KIND_REACTION | Emoji reaction (standard NIP-25) |
| 9 | KIND_STREAM_MESSAGE | Chat message in a Stream channel (NIP-29 group chat) |
| 40002 | KIND_STREAM_MESSAGE_V2 | Stream message v2 format |
| 40003 | KIND_STREAM_MESSAGE_EDIT | Edit of a stream message |
| 43001 | KIND_JOB_REQUEST | Agent job request |
| 45001 | KIND_FORUM_POST | Forum thread root |
| 45003 | KIND_FORUM_COMMENT | Forum thread reply |
| 46001–46012 | KIND_WORKFLOW_* | Workflow execution events |
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |
`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`.
### Wire Protocol (NIP-01 messages)
| Direction | Message | Purpose |
|-----------|---------|---------|
| Client → Relay | `["EVENT", <event>]` | Submit a signed event |
| Client → Relay | `["REQ", <sub_id>, <filter>, ...]` | Subscribe to events |
| Client → Relay | `["CLOSE", <sub_id>]` | Cancel a subscription |
| Client → Relay | `["AUTH", <event>]` | Authenticate (NIP-42) |
| Relay → Client | `["EVENT", <sub_id>, <event>]` | Deliver a matching event |
| Relay → Client | `["EOSE", <sub_id>]` | End of stored events |
| Relay → Client | `["OK", <event_id>, true/false, ""]` | Event acceptance result |
| Relay → Client | `["CLOSED", <sub_id>, "reason"]` | Subscription closed |
| Relay → Client | `["NOTICE", "message"]` | Informational message |
| Relay → Client | `["AUTH", <challenge>]` | Authentication challenge |
Max frame size: 65,536 bytes. Max subscriptions per connection: 1024. Max historical results per filter: 500.
---
## 3. Connection Lifecycle
Every WebSocket connection follows this exact sequence:
### Step 0: Community Binding
The server resolves `TenantContext` from the request host before any handler can
observe tenant data. The URL/domain is authoritative for the community, matching
today's "the relay URL is the workspace" behavior. In single-community mode the
configured host maps to the default community. In multi-community mode, an
unknown or unmapped host rejects generically and never falls through to a default
tenant. Client-supplied `#h` tags are still channel identifiers; they must resolve
to a channel inside the host-derived community.
### Step 1: Semaphore Acquire
`state.conn_semaphore.try_acquire_owned()` — if the relay is at connection capacity, the connection is rejected immediately before any data is read. The permit is held for the entire connection lifetime and dropped on cleanup.
### Step 2: NIP-42 Challenge
The relay immediately sends `["AUTH", "<challenge>"]`. The challenge is a random string. The connection is registered in `ConnectionManager` after the challenge is sent.
### Step 3: Authentication
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Authentication paths:
| Path | Mechanism | Use Case |
|------|-----------|---------|
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on HTTP bridge endpoints | HTTP clients |
On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.
### Step 4: Active Loops
Three concurrent tasks run for the lifetime of the connection:
- **recv_loop** (inline): reads frames, parses `ClientMessage`, dispatches to handlers
- **send_loop** (spawned): drains the mpsc channel, writes frames to the WebSocket
- **heartbeat_loop** (spawned): sends WebSocket ping every 30 seconds; 3 missed pongs → disconnect
A `CancellationToken` coordinates shutdown across all three loops.
Slow clients: `ConnectionState::send()` uses `try_send` — if the send buffer is full, a grace counter increments. After `SLOW_CLIENT_GRACE_LIMIT` (3) consecutive full-buffer events, the connection is cancelled. A successful send resets the counter.
### Step 5: Cleanup
On disconnect (any cause):
1. `cancel.cancel()` — signals all loops
2. Await send_loop and heartbeat_loop tasks
3. `sub_registry.remove_connection(conn_id)` — removes all subscriptions from the DashMap indexes
4. `conn_manager.deregister(conn_id)` — removes from the send-channel map
5. `drop(permit)` — releases the connection semaphore slot
---
## 4. Event Pipeline
When the relay receives `["EVENT", <event>]`, the handler in `handlers/event.rs` runs this pipeline in order:
```
1. AUTH CHECK — AuthState::Authenticated? MessagesWrite scope?
2. PUBKEY MATCH — event.pubkey == auth_context.pubkey?
3. KIND_AUTH REJECT — kind == 22242 (AUTH events never stored)
4. EPHEMERAL ROUTE — kind 20000–29999 → ephemeral sub-pipeline (see below)
5. VERIFY — spawn_blocking(verify_event) — Schnorr sig + ID hash
6. MEMBERSHIP — channel_id in event tags? → check_channel_membership
7. DB INSERT — db.insert_event (ON CONFLICT DO NOTHING — idempotent)
8. REDIS PUBLISH — pubsub.publish_event (if channel-scoped)
9. FAN-OUT — sub_registry.fan_out → conn_manager.send_to
10. SEARCH INDEX — search_index_tx.send (bounded worker queue, non-blocking)
11. AUDIT LOG — audit.log (spawned async, non-blocking)
12. WORKFLOW TRIGGER — wf.on_event (spawned async, excludes kinds 46001–46012)
```
Steps 10–12 are fire-and-forget. Search indexing is sent to a bounded worker queue (`search_index_tx`, capacity 1000); audit and workflow triggers are spawned as independent async tasks. A failure in any of these does not fail the event submission. The client receives `["OK", <id>, true, ""]` at the end of the pipeline, not immediately after DB insert.
Step 9 (fan-out) explicitly **excludes** global subscriptions (no `channel_id` constraint) from channel-scoped events — global subscriptions do NOT receive events from private channels, regardless of filter match. This is a deliberate security boundary: only subscriptions scoped to an accessible `channel_id` receive those events.
Workflow loop prevention: workflow execution kinds (46001–46012), relay-signed messages with `buzz:workflow` tag, and `KIND_GIFT_WRAP` are excluded from triggering workflows. All other stored events (including kind 9 stream messages) trigger workflow evaluation.
### Ephemeral Sub-Pipeline (kinds 20000–29999)
Ephemeral events bypass DB storage, audit, and search. Two sub-paths:
**Presence events (kind 20001):**
```
1. VERIFY — spawn_blocking(verify_event)
2. REDIS PRESENCE — set_presence() or clear_presence() based on content
3. LOCAL FAN-OUT — sub_registry.fan_out → conn_manager.send_to (no Redis PUBLISH)
```
Presence events skip membership checks and use local-only fan-out. Multi-node presence fan-out would require Redis pub/sub (documented as future work).
**Other ephemeral events (e.g., typing indicators):**
```
1. VERIFY — spawn_blocking(verify_event)
2. MEMBERSHIP — check_channel_membership (if channel-scoped)
3. MARK LOCAL — state.mark_local_event (dedup before Redis round-trip)
4. REDIS PUBLISH — pubsub.publish_event (no DB write)
5. LOCAL FAN-OUT — sub_registry.fan_out → conn_manager.send_to
```
Ephemeral events are never stored in Postgres and never appear in REQ historical queries.
### Handler Semaphore
Beyond the per-connection semaphore, a `handler_semaphore` (capacity 1024) limits concurrent EVENT and REQ processing across all connections. CLOSE is not rate-limited.
---
## 5. Subscription System
### SubscriptionRegistry
The subscription registry is a DashMap-backed structure in `subscription.rs`:
```rust
pub struct SubscriptionRegistry {
subs: DashMap<ConnId, HashMap<SubId, SubEntry>>,
channel_kind_index: DashMap<IndexKey, Vec<(ConnId, SubId)>>,
channel_wildcard_index: DashMap<Uuid, Vec<(ConnId, SubId)>>,
}
pub struct IndexKey {
pub channel_id: Uuid,
pub kind: Kind,
}
```
### Three-Tier Fan-Out
When an event arrives, `fan_out` consults three indexes in order:
| Tier | Index | Key | Use Case |
|------|-------|-----|---------|
| 1 | `channel_kind_index` | `(channel_id, kind)` | Subs with explicit channel + kind filter — O(1) lookup |
| 2 | `channel_wildcard_index` | `channel_id` | Subs with channel but no `kinds` constraint |
| 3 | `subs` (linear scan) | — | Global subs (no channel_id) — fallback scan |
Global subs (tier 3) are checked for non-channel-scoped events only. Channel-scoped events are delivered exclusively to subscriptions that carry a matching `channel_id` — global subscriptions are explicitly excluded from channel fan-out as a security boundary.
### NIP-01 Edge Cases
- `kinds: []` (explicit empty array) means "match nothing" — NOT a wildcard. Subscriptions with empty `kinds` are not indexed in either tier 1 or tier 2 and never receive events.
- `kinds` absent (no field) means "match all kinds" — indexed in tier 2 (channel wildcard) or tier 3 (global).
### REQ Handler Access Control
The REQ handler checks channel access **before** registering the subscription:
```
1. Parse filters, extract channel_id
2. Load accessible_channel_ids for this connection's pubkey
3. If channel_id not in accessible_channels → send CLOSED "restricted: not a channel member"
4. Only then: sub_registry.register(conn_id, sub_id, filters, channel_id)
```
This prevents a race where a non-member receives live fan-out events from a private channel between registration and the access check.
### Historical Query (EOSE)
After registering, the REQ handler queries Postgres for stored events matching the filters (up to 500 per filter, hard cap). These are sent as `["EVENT", sub_id, event]` frames before `["EOSE", sub_id]`. New events arriving after EOSE are delivered via the fan-out path.
---
## 6. Crate Reference
### buzz-core — Shared Types and Verification
**Zero I/O.** The foundation every other crate builds on. Explicitly prohibits tokio, sqlx, redis, and axum in its `Cargo.toml`.
**Key types:**
```rust
pub struct StoredEvent {
pub event: nostr::Event,
pub received_at: DateTime<Utc>,
pub channel_id: Option<Uuid>,
verified: bool, // private — use is_verified()
}
pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)
```
**Key functions:**
| Function | Purpose |
|----------|---------|
| `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. |
| `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. |
| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. |
**Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime.
---
### buzz-auth — Authentication and Authorization
Handles authentication paths, scope enforcement, and token operations.
**Auth paths:**
| Path | Entry Point | Notes |
|------|-------------|-------|
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
| NIP-98 HTTP Auth | `validate_nip98_auth()` | HTTP bridge endpoints; Schnorr-signed `kind:27235` event |
**Key types:**
```rust
pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec<Scope>, pub auth_method: AuthMethod }
pub enum AuthMethod { Nip42, Nip98 }
pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite,
AdminChannels, UsersRead, UsersWrite, AdminUsers,
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
FilesRead, FilesWrite, Unknown(String) }
pub trait ChannelAccessChecker: Send + Sync { ... }
pub trait RateLimiter: Send + Sync { ... }
```
**Security details:**
- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags.
- NIP-42 timestamp tolerance: ±60 seconds.
- Dev-only key derivation: `SHA-256("buzz-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments.
**Does NOT:** implement `RateLimiter` beyond a test stub (`AlwaysAllowRateLimiter`, gated behind `#[cfg(any(test, feature = "test-utils"))]`). No Redis-backed rate limiter exists anywhere in the codebase — rate limiting is not currently enforced. `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) as a design target.
---
### buzz-db — Postgres Event Store
All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — no `.sqlx/` offline cache required.
**Key operations:**
| Module | Responsibility |
|--------|---------------|
| `event.rs` | `insert_event` (ON CONFLICT DO NOTHING), `query_events` (QueryBuilder), `get_event_by_id` |
| `channel.rs` | Channel CRUD, membership management, role enforcement (transactional) |
| `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` |
| `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens |
| `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables |
| `dm.rs` | DM channel management |
| `reaction.rs` | Reaction storage and retrieval |
| `thread.rs` | Thread/reply tracking |
| `user.rs` | User profile storage |
| `error.rs` | Database error types |
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot`
**Workflow statuses:** `Active`, `Disabled`, `Archived`
**Run statuses:** `Pending`, `Running`, `WaitingApproval`, `Completed`, `Failed`, `Cancelled`
**Key behaviors:**
- `ON CONFLICT DO NOTHING` for event dedup — returns `(StoredEvent, was_inserted: bool)`.
- Rejects `KIND_AUTH` (22242) and ephemeral (20000–29999) with distinct error variants.
- Transactional role enforcement in `add_member`/`remove_member`/`create_channel` — TOCTOU-safe.
- Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it.
- Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit.
- `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`.
- Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256.
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.
**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres.
---
### buzz-pubsub — Redis Pub/Sub, Presence, Typing
Manages Redis pub/sub fan-out, presence tracking, and typing indicators. In multi-community mode all tenant-visible keys are prefixed or otherwise partitioned by community (`buzz:{community}:...`) so channel fan-out, presence, typing, and cache invalidation cannot cross hosts.
**Architecture:**
```
Publisher → pool connection → PUBLISH buzz:channel:{uuid}
Subscriber → dedicated PubSub → PSUBSCRIBE buzz:channel:*
→ broadcast::channel(4096)
```
The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from the pool. This is intentional: pool connections cannot hold `PSUBSCRIBE` state.
**Current state:** The subscriber loop is spawned in `buzz-relay/src/main.rs` and populates the broadcast channel. A consumer task subscribes via `pubsub.subscribe_local()`, calls `sub_registry.fan_out()` on each received event, and delivers matches to local WebSocket connections via `conn_manager.send_to()`. Multi-node fan-out is now wired end-to-end. Local-echo deduplication is implemented via `AppState.local_event_ids` — events published by the local relay instance are tracked and skipped when received via the Redis round-trip.
**Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt.
**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap.
**Typing indicators:**
```
ZADD buzz:typing:{channel_id} {now_unix} {pubkey_hex}
ZREMRANGEBYSCORE buzz:typing:{channel_id} -inf {now - 5.0}
EXPIRE buzz:typing:{channel_id} 60
```
5-second activity window. 60-second key TTL prevents orphaned empty sets.
**Does NOT:** implement the rate limiter. Does NOT store events. `PubSubManager` is not `Clone` — callers use `Arc<PubSubManager>`.
---
### buzz-search — Postgres FTS Integration
Full-text search via Postgres FTS. Events are searchable through the
`events.search_tsv` generated `tsvector` column (populated on insert, indexed
by a GIN index) — there is no separate search service or out-of-band indexer.
Privacy-sensitive kinds are excluded at the storage level (the `search_tsv`
`CASE WHEN kind IN (...)` yields `NULL`, which never matches `@@`). In
multi-community mode every query filter includes `community_id`, so the shared
`events` table is infrastructure, not a cross-community result space; the relay
re-authorizes every candidate hit before returning it.
**Key behaviors:**
- `SearchService::new(pool)` wraps a `PgPool`; `search(&SearchQuery)` runs a
parameterized FTS query against the `events.search_tsv` GIN index and returns
`SearchResult` (candidate `SearchHit`s).
- `ChannelScope` makes the channel constraint explicit (`Any` /
`ChannelLessOnly` / `Channels` / `ChannelsOrChannelLess`), closing the
ambiguity the old `Option<Vec<Uuid>> + bool` matrix could not express.
- Every query carries `community_id`; the FTS predicate is BitmapAnd-ed with
the community-leading btree filters so a query never crosses tenants.
- Permission filtering is **caller's responsibility** — `buzz-search` returns
candidate hits; the relay re-authorizes each one (channel membership, `#p`,
owner gates) before delivering it.
**Does NOT:** enforce channel membership or access control. Does NOT write
events (indexing is the `search_tsv` generated column on the `events` insert).
---
### buzz-audit — Hash-Chain Audit Log
Tamper-evident append-only log with SHA-256 hash chaining.
**Hash chain:** each entry stores `prev_hash` (hash of the previous entry). In multi-community mode audit heads/chains are per-community; operator metrics may aggregate, but tenant-readable audit verification walks one community chain. `verify_chain()` walks entries and recomputes hashes to detect tampering. Genesis entry uses `GENESIS_HASH` (64 zeros).
**Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash.
**Single-writer guarantee:** `pg_advisory_lock` before each transaction. Lock released in all branches including panic (`catch_unwind`).
**10 audit actions:** `EventCreated`, `EventDeleted`, `ChannelCreated`, `ChannelUpdated`, `ChannelDeleted`, `MemberAdded`, `MemberRemoved`, `AuthSuccess`, `AuthFailure`, `RateLimitExceeded`.
**Does NOT:** log `KIND_AUTH` (22242) events — returns `AuditError::AuthEventForbidden` immediately. Does NOT log ephemeral events (they never reach the audit pipeline).
---
### buzz-workflow — YAML-as-Code Automation Engine
Parses, validates, and executes channel-scoped workflow definitions. In multi-community mode workflow definitions, runs, approvals, webhook routes, and schedules inherit the host-derived community and evaluate triggers only against events in that community.
**Workflow definition structure:**
```yaml
name: "Incident Triage"
trigger:
on: message_posted
filter: "str_contains(trigger_text, 'P1')"
steps:
- id: notify
action: send_message
text: "P1 incident detected: {{trigger.text}}"
- id: page
if: "str_contains(trigger_text, 'production')"
action: request_approval
from: "{{trigger.author}}"
message: "Page on-call?"
```
Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Triggers use `on:` as the tag field; actions use `action:` as the tag field. Fields are flattened into the parent struct, not nested.
**4 trigger types:** `message_posted`, `reaction_added`, `schedule`, `webhook`
**7 action types:**
| Action | Description |
|--------|-------------|
| `send_message` | Post to the workflow's channel (or override channel) |
| `send_dm` | Direct message to a user (pubkey hex or `{{trigger.author}}`) |
| `set_channel_topic` | Update channel topic |
| `add_reaction` | React to the trigger message |
| `call_webhook` | HTTP POST to external URL (SSRF-protected, redirects disabled, 1 MiB response cap) |
| `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) |
| `delay` | Pause execution (max 300 seconds) |
**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text.
**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking.
**Concurrency:** `Arc<Semaphore>` with 100 permits. `try_acquire()` — returns `CapacityExceeded` immediately rather than queuing.
**Approval gates:** `request_approval` action returns `StepResult::Suspended` with a generated UUID token, but the engine does not yet persist the token or resume execution — runs that hit an approval gate are marked as failed (🚧 WF-08). `execute_from_step()` exists for future resumption support.
**Cron scheduler:** loop ticks every 60 seconds, evaluates cron expressions with window-based matching, and creates workflow runs for matched triggers. Fully implemented.
**Does NOT:** recursively resolve templates (single-pass only). Does NOT queue workflow runs when at capacity — returns `CapacityExceeded` immediately.
---
### Huddle Audio — WebSocket Opus Relay
Real-time voice lives inside `buzz-relay` (`src/audio/`), not a separate crate. A WebSocket endpoint (`wss://.../huddle/{channel_id}/audio`) authenticates each participant with a NIP-42 challenge, checks channel membership, admits them to an in-memory room, and forwards opaque Opus frames between peers. No external SFU.
**Frame protocol (v2):** 8-byte big-endian header (sequence `u16`, 48 kHz timestamp `u32`, level dBov `i8`, flags `u8`) followed by an opaque Opus payload. Invalid `level_dbov` values are clamped rather than dropped — losing a metric beats losing audio.
**Room state:** an admission guard synchronizes joins against the room's ended flag; soft cap 25 peers (hard cap 255 via `u8` peer index). Per-peer audio uses a bounded channel (drop-on-full); the control channel is separate and never drops join/leave.
**Lifecycle events:** the relay emits Nostr events for participant joined / left and huddle ended; the desktop client emits huddle started and guidelines. When the last peer leaves, the room ends and the channel archives atomically.
**Not yet built:** recording and per-track publishing (the corresponding kinds are reserved, no producer exists).
---
### buzz-relay — The Server
Axum WebSocket server. Ties all other crates together. The only crate that imports and orchestrates all subsystems.
**`AppState`** (Arc-wrapped, shared across all connections — key fields shown, not exhaustive):
```rust
pub struct AppState {
pub db: Db,
pub audit: Arc<AuditService>,
pub pubsub: Arc<PubSubManager>,
pub auth: Arc<AuthService>,
pub search: Arc<SearchService>,
pub sub_registry: Arc<SubscriptionRegistry>,
pub conn_manager: Arc<ConnectionManager>,
pub workflow_engine: Arc<WorkflowEngine>,
pub conn_semaphore: Arc<Semaphore>, // connection limit
pub handler_semaphore: Arc<Semaphore>, // 1024 concurrent handlers
pub relay_keypair: nostr::Keys, // relay identity
pub local_event_ids: moka::sync::Cache, // local-echo dedup
pub search_index_tx: mpsc::Sender, // bounded search worker queue
// + config, redis_pool, membership_cache, media_storage, shutdown state
}
```
**`ConnectionState`** (per-connection):
```rust
pub struct ConnectionState {
pub auth_state: RwLock<AuthState>,
pub subscriptions: Mutex<HashMap<String, Vec<Filter>>>,
// + send_tx, cancel token
}
pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
```
**HTTP endpoints:**
| Method | Path | Handler |
|--------|------|---------|
| GET | `/` | WebSocket upgrade or NIP-11 relay info |
| GET | `/info` | NIP-11 relay info |
| GET | `/.well-known/nostr.json` | NIP-05 identity |
| GET | `/health` | Health check |
| GET | `/_liveness` | Liveness probe |
| GET | `/_readiness` | Readiness probe |
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |
| POST | `/hooks/{id}` | Workflow webhook trigger (secret-authenticated) |
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
| GET | `/git/{owner}/{repo}/info/refs` | Git smart HTTP advertisement |
| POST | `/git/{owner}/{repo}/git-upload-pack` | Git smart HTTP fetch |
| POST | `/git/{owner}/{repo}/git-receive-pack` | Git smart HTTP push |
| POST | `/internal/git/policy` | Internal git hook policy check |
**Constants:**
| Constant | Value | Purpose |
|----------|-------|---------|
| `MAX_FRAME_BYTES` | 65,536 | Max WebSocket frame size |
| `MAX_SUBSCRIPTIONS` | 1024 | Per-connection subscription limit |
| `MAX_HISTORICAL_LIMIT` | 500 | Per-filter historical query cap |
| `handler_semaphore` capacity | 1024 | Concurrent EVENT/REQ handlers |
**Does NOT:** implement business logic — delegates to the appropriate crate for every operation.
---
### buzz-acp — Agent Communication Protocol Harness
Standalone binary that bridges Buzz relay events to AI agents via the [Agent Communication Protocol](https://agentclientprotocol.com/) (ACP).
**Architecture:**
```
Buzz Relay ──WS──→ buzz-acp ──stdio (ACP/JSON-RPC)──→ Agent (goose/codex/claude)
```
`buzz-acp` spawns AI agent subprocesses (1–32, default 1), connects to the relay via WebSocket with NIP-42 auth, discovers channels via REST API, and queues `@mention` events per channel. At most one prompt is in-flight per channel. Queued events are batched into a single prompt sent via `session/prompt` over ACP.
**Key modules:**
| Module | LOC | Responsibility |
|--------|-----|---------------|
| `relay.rs` | 3,143 | WebSocket + REST relay connection, NIP-42 auth |
| `queue.rs` | 2,565 | Per-channel event queue, batching, dedup |
| `main.rs` | 2,457 | Event loop, pool orchestration, heartbeat |
| `pool.rs` | 2,253 | N-agent pool, claim/return lifecycle |
| `config.rs` | 1,903 | CLI/env/TOML configuration |
| `acp.rs` | 1,785 | ACP client, stdio JSON-RPC, timeouts |
| `filter.rs` | 814 | Subscription rules, evalexpr filtering |
**Key behaviors:**
- Pool of 1–32 agent subprocesses with claim/return lifecycle.
- Per-channel queuing: at most one prompt in-flight per channel; subsequent @mentions queue until the agent responds.
- Crash recovery: agent subprocess crashes are detected and the agent is respawned.
- Depends on `buzz-core` (kind constants) and `buzz-sdk` (relay/REST utilities).
**Does NOT:** persist state.
---
### buzz-admin — Operator CLI
Subcommands:
| Subcommand | Purpose |
|------------|---------|
| `add-member` | Add a pubkey to the relay membership list (`--pubkey`, `--role`); accepts npub or hex; publishes kind:13534 roster |
| `remove-member` | Remove a pubkey from the relay membership list (`--pubkey`, optional `--role` guard); publishes kind:13534 roster |
| `list-members` | List all relay members |
| `generate-key` | Generate a new Nostr keypair (for bootstrapping) |
| `reconcile-channels` | Emit kind:39000/39002 discovery events for channels missing them (idempotent) |
The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/buzz-admin`) and is the recommended way to manage relay membership in production. Use `./run.sh add-member`, `./run.sh remove-member`, and `./run.sh list-members` in Docker Compose deployments.
---
### buzz-test-client — Integration Test Harness
**`BuzzTestClient`** wraps a WebSocket connection with a `VecDeque<RelayMessage>` buffer for message interleaving. Methods: `connect`, `connect_unauthenticated`, `authenticate`, `send_event`, `send_text_message`, `subscribe`, `close_subscription`, `recv_event`, `collect_until_eose`, `disconnect`.
**Test coverage:**
| File | Tests | Scope |
|------|-------|-------|
| `tests/e2e_relay.rs` | 27 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
| `tests/e2e_media.rs` | 7 | Media upload/download (Blossom) |
| `tests/e2e_media_extended.rs` | 18 | Extended media scenarios |
| `tests/e2e_nostr_interop.rs` | 15 | Nostr interoperability: NIP-50 search, NIP-10 threads, NIP-17 gift wraps, DM discovery |
All e2e tests are `#[ignore]` — require a running relay. Total: **134 e2e tests**.
`src/main.rs` is a manual testing CLI (`buzz-test-cli`) with `--send`, `--subscribe`, `--channel`, `--url`, `--kind` flags.
Defines `parse_relay_message`, `OkResponse`, `RelayMessage` directly in `src/lib.rs`.
---
## 7. Security Model
Every security-sensitive operation uses an explicit, verified pattern. No implicit trust.
### Authentication
| Concern | Mechanism |
|---------|-----------|
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
| AUTH events | Never stored in Postgres, never logged in audit chain |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` events — URL and method verification |
### Input Validation
| Concern | Mechanism |
|---------|-----------|
| Schnorr signatures | `verify_event()` in `buzz-core` — every event verified before storage |
| Event ID | SHA-256 of canonical serialization verified independently of signature |
| Frame size | `MAX_FRAME_BYTES = 65,536` — oversized frames rejected, connection closed |
| Search event IDs | 64-char hex validation before URL construction — prevents path injection |
| Workflow step IDs | Alphanumeric + underscore only — prevents evalexpr variable injection |
| Partition names | Allowlist of table names + strict suffix/date validators — prevents DDL injection |
### SSRF Protection
`is_private_ip()` in `buzz-core` covers:
- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255)
- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32)
- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address
Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility).
### Audit Integrity
- Hash chain: each entry's SHA-256 covers all fields including `prev_hash` — tampering any entry breaks all subsequent hashes
- Canonical JSON: `BTreeMap` for deterministic key ordering — hash is reproducible
- Single-writer lock: `pg_advisory_lock` — prevents concurrent writes from breaking the chain
- Panic-safe: `catch_unwind` ensures lock release even on panic
### Access Control
- Channel membership is the only gate — enforced by the relay at every operation
- REQ handler checks access before subscription registration — no race window for private channel leaks
- TOCTOU-safe membership operations: all check-then-modify sequences run inside Postgres transactions
- Approval tokens: UUID (CSPRNG), stored as SHA-256 hash, single-use enforced with `AND status = 'pending'` in UPDATE
### Webhook Security
- Workflow webhooks: constant-time XOR comparison of stored UUID secret (not HMAC — compares the secret directly, not a body MAC)
- Outbound webhooks (CallWebhook): SSRF protection + redirects disabled + 1 MiB response cap
---
## 8. Infrastructure
Docker Compose provides the full local development stack. All services include health checks and resource limits.
### Services
| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit; full-text search (`search_tsv` GIN) |
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
### Postgres Schema (key tables)
| Table | Purpose |
|-------|---------|
| `events` | All stored Nostr events; monthly range-partitioned by `PARTITION BY RANGE` on `created_at`; multi-community mode keys every tenant-visible event by `community_id` |
| `channels` | Channel records (type, visibility, canvas, topic); `community_id` is immutable after creation in multi-community mode |
| `channel_members` | Membership with roles; soft-delete via `removed_at` |
| `workflows` | Workflow definitions (YAML stored as canonical JSON); scoped by community in multi-community mode |
| `workflow_runs` | Execution records with trigger context and trace |
| `workflow_approvals` | Approval gates (token stored as SHA-256 hash) |
| `audit_log` | Hash-chain audit entries; per-community chain/head in multi-community mode |
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |
### Redis Key Patterns
| Pattern | Type | TTL | Purpose |
|---------|------|-----|---------|
| `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) |
| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
### Full-Text Search (Postgres FTS)
Search runs over the `events.search_tsv` generated `tsvector` column on the
`events` table (no separate collection or service). The column is populated on
insert — `to_tsvector('simple', content)` — and excludes privacy-sensitive
kinds via `CASE WHEN kind IN (1059, 30300, 30622) THEN NULL`, so those rows are
storage-level unsearchable (a `NULL` tsvector never matches `@@`). A GIN index
(`idx_events_search_tsv`) backs the `@@` probe; in multi-community mode the
community-leading btree filters BitmapAnd with the GIN probe so every query is
fenced to its `community_id`.
---
## 9. Known Limitations
These are verified gaps in the current implementation — not design aspirations.
| # | Limitation | Detail |
|---|-----------|--------|
| 1 | **No sqlx offline query cache** | Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). No `.sqlx/` directory. Queries are not validated at compile time. |
| 2 | **No rate limiting implementation** | `RateLimiter` trait exists in `buzz-auth`. Only implementation is `AlwaysAllowRateLimiter` (test stub, gated behind `#[cfg(any(test, feature = "test-utils"))]`). `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) but none are enforced. |
| 3 | **No dedicated typing REST endpoint** | Typing indicators (kind 20002) are delivered via both local fan-out and Redis pub/sub (cross-node). There is no REST endpoint to query current typers — `/api/presence` returns online/away status only, not typing state. |
| 4 | **Huddle recording/tracks not built** | Voice, room lifecycle, and join/leave/end events are wired (see Huddle Audio above). Recording and per-track publishing have reserved kinds but no producer yet. |
| 5 | **Approval gates not wired end-to-end** | The executor returns `StepResult::Suspended` and the relay has grant/deny API endpoints with DB CRUD, but the engine intercepts before creating `WaitingApproval` rows — runs that hit an approval gate are marked as Failed (🚧 WF-08). |
| 6 | **Workflow actions partially stubbed** | The `send_dm` and `set_channel_topic` workflow actions are in the schema but return `NotImplemented` — a run that reaches one fails at execution (🚧 WF-07). |

View File

@@ -1,962 +0,0 @@
# Changelog
## v0.4.6
- Polish agent and team sharing and snapshots ([#1852](https://github.com/block/buzz/pull/1852)) ([`2f3fa586f`](https://github.com/block/buzz/commit/2f3fa586f3bd12b26b8a9dc69a424d5c28b1e27e))
- fix(desktop): filter punctuation from avatar initials ([#1904](https://github.com/block/buzz/pull/1904)) ([`63e099709`](https://github.com/block/buzz/commit/63e099709667669be41426e7144a4a9057f8537b))
- fix(desktop): preserve snapshot attachment URLs ([#1905](https://github.com/block/buzz/pull/1905)) ([`1ab7bbfe4`](https://github.com/block/buzz/commit/1ab7bbfe453d10cfd3522c358e5ed18931f1e1ec))
- Chat-first agent creation via portable buzz CLI drafts ([#1878](https://github.com/block/buzz/pull/1878)) ([`54e174b5e`](https://github.com/block/buzz/commit/54e174b5e1424c13fe7451c86a418ba8b9c01f1c))
- feat(desktop): enable community rail by default ([#1902](https://github.com/block/buzz/pull/1902)) ([`55a46b063`](https://github.com/block/buzz/commit/55a46b063e291862c034d4ce685b3d3a92804951))
- feat(desktop): redesign Nostr bind verification flow ([#1850](https://github.com/block/buzz/pull/1850)) ([`abfe78aaf`](https://github.com/block/buzz/commit/abfe78aafb131c9100f33ed361850fb9cfeb0881))
- fix(desktop): simplify sign out settings row ([#1903](https://github.com/block/buzz/pull/1903)) ([`3e8dae7ff`](https://github.com/block/buzz/commit/3e8dae7ff3c73b1b2b1ae26d5a4bed5db17cb269))
- feat(desktop): Send feedback modal + profile presence chip ([#1756](https://github.com/block/buzz/pull/1756)) ([`f5c33d333`](https://github.com/block/buzz/commit/f5c33d3335fc3fa7e304bb51a9931d4cc741d255))
- fix(desktop): seed timeline virtualization row heights ([#1887](https://github.com/block/buzz/pull/1887)) ([`406cf7911`](https://github.com/block/buzz/commit/406cf7911ee1f335fdf0be131ee057ea2c455037))
- fix(desktop): dedupe react-dismissable-layer to stop modal menu → dialog freezes ([#1899](https://github.com/block/buzz/pull/1899)) ([`366567ac2`](https://github.com/block/buzz/commit/366567ac20b4786c1ca8e754ac5c45f0e7d59122))
- fix(desktop): clean up Remind me later dialog footer and loading state ([#1898](https://github.com/block/buzz/pull/1898)) ([`40fe65095`](https://github.com/block/buzz/commit/40fe6509583e93054eab04906aacde834c5fcc68))
- fix(desktop): let sidebar action card description wrap over multiple lines ([#1891](https://github.com/block/buzz/pull/1891)) ([`b36ee3e30`](https://github.com/block/buzz/commit/b36ee3e30203b4cc4ec8056ae4d9c57acf26891e))
- mesh: upgrade runtime, enforce membership, add shared compute provider ([#1656](https://github.com/block/buzz/pull/1656)) ([`54638ff4b`](https://github.com/block/buzz/commit/54638ff4bb5af2d3d3759b44118b43052f814bb1))
## v0.4.5
- test(desktop): remove stale collapsed home chrome check ([#1871](https://github.com/block/buzz/pull/1871)) ([`395a3f149`](https://github.com/block/buzz/commit/395a3f1492807b065f8edbee9bb9c8d51018095a))
- fix(desktop): hide message action for inaccessible agents ([#1883](https://github.com/block/buzz/pull/1883)) ([`8ce9e7914`](https://github.com/block/buzz/commit/8ce9e791453a262f21e675d3f5d27ace22a09e9b))
- feat(desktop): add API key field, global default indicators, and collapsed advanced ([#1875](https://github.com/block/buzz/pull/1875)) ([`1742dce7b`](https://github.com/block/buzz/commit/1742dce7b25676e91631ce4056bdd2bbc05944ab))
- fix(desktop): foreground app for deep links ([#1880](https://github.com/block/buzz/pull/1880)) ([`a26168f55`](https://github.com/block/buzz/commit/a26168f553fd8bfce8b5df88915e76ff2d7004ee))
- chore(desktop): replace setup ? operators with clean exit ([#1882](https://github.com/block/buzz/pull/1882)) ([`4c106d878`](https://github.com/block/buzz/commit/4c106d878564e3f5b4def92f4667fe95dec015f4))
- fix(desktop): remediate all onboarding dead paths (D1-D7) ([#1856](https://github.com/block/buzz/pull/1856)) ([`5bb619295`](https://github.com/block/buzz/commit/5bb6192951bd57c535a34cd835aba20714f408b6))
## v0.4.4
- fix(desktop): allow staged updater commands ([#1870](https://github.com/block/buzz/pull/1870)) ([`109c2c526`](https://github.com/block/buzz/commit/109c2c52641c5edd50890bffef9a236a10ea6106))
- feat(teams): unify team model, snapshot sharing, and PNG memory parity ([#1846](https://github.com/block/buzz/pull/1846)) ([`448baeef7`](https://github.com/block/buzz/commit/448baeef770478411678e2f0be1729f04b6800d7))
- fix(desktop): always start agents and hide unavailable mesh ([#1860](https://github.com/block/buzz/pull/1860)) ([`37bc962cb`](https://github.com/block/buzz/commit/37bc962cb8fb60857337bbfb97eb4d31c359de15))
- Relay mesh: cross-pod tunnel + huddle transport (buzz-relay-mesh) ([#1670](https://github.com/block/buzz/pull/1670)) ([`ccb021d71`](https://github.com/block/buzz/commit/ccb021d71339009aabedc383c8f3d8e5c23e1e42))
- feat(desktop): add Sign Out to Settings to reset and relaunch ([#1842](https://github.com/block/buzz/pull/1842)) ([`cf9723796`](https://github.com/block/buzz/commit/cf97237965a5ed9870f1c1f73ce9952e963b418b))
## v0.4.3
- Stabilize persona and stream end-to-end tests ([#1865](https://github.com/block/buzz/pull/1865)) ([`916de4e4d`](https://github.com/block/buzz/commit/916de4e4dbbf9299695a91defa6e3802b1820256))
- refactor(clients): standardize product naming on community ([#1858](https://github.com/block/buzz/pull/1858)) ([`3e76481a1`](https://github.com/block/buzz/commit/3e76481a149bb3de459298cc989505c470c2372c))
- fix(desktop): retain live rows after window exhaustion ([#1810](https://github.com/block/buzz/pull/1810)) ([`fa6f4819b`](https://github.com/block/buzz/commit/fa6f4819b3468a5a3725356dd71463bb37cc30c7))
- Add private product feedback sidecar ([#1857](https://github.com/block/buzz/pull/1857)) ([`af190c93e`](https://github.com/block/buzz/commit/af190c93e1048af64c3fbfb3831c689cb703997c))
- fix(desktop): resolve Doctor install shell and command detection on Windows ([#1854](https://github.com/block/buzz/pull/1854)) ([`1aec7ea7a`](https://github.com/block/buzz/commit/1aec7ea7a798c8bdb131a802dcd3d2ea0fb8692a))
- fix(desktop): navigate to channel from inbox thread header ([#1847](https://github.com/block/buzz/pull/1847)) ([`ff11f26bc`](https://github.com/block/buzz/commit/ff11f26bcdc8e107d7d04f5682b2d17f586536d6))
- feat(desktop): surface needsRestart badge on live UI surfaces ([#1853](https://github.com/block/buzz/pull/1853)) ([`b1d323c4e`](https://github.com/block/buzz/commit/b1d323c4e7ddbabbe52a868f3699a523bb6c010e))
- fix(desktop): prevent menu-to-dialog UI lockups ([#1839](https://github.com/block/buzz/pull/1839)) ([`7f81d93eb`](https://github.com/block/buzz/commit/7f81d93eb1cf6a10c84d948d91c45a1fd4778060))
- feat(desktop): add conversation-style DM composer ([#1768](https://github.com/block/buzz/pull/1768)) ([`bb9d9b0c0`](https://github.com/block/buzz/commit/bb9d9b0c049b9561458414b24eff157c6bbbd446))
- feat(push): add public APNs gateway ([#1770](https://github.com/block/buzz/pull/1770)) ([`1c006822e`](https://github.com/block/buzz/commit/1c006822e4484d68e33fce14f9139c2f70ce9d66))
- fix(desktop): stabilize agent identity restore ([#1831](https://github.com/block/buzz/pull/1831)) ([`3370cd083`](https://github.com/block/buzz/commit/3370cd0836e458f519cf90fe6299279cde6c7463))
- fix(observer): align scroll-anchor ids with transcript display-block keys ([#1849](https://github.com/block/buzz/pull/1849)) ([`1df4a4e77`](https://github.com/block/buzz/commit/1df4a4e77da732cadad91738bc0271a926c22040))
- [codex] Add view activity label to agent popover ([#1748](https://github.com/block/buzz/pull/1748)) ([`458c7f915`](https://github.com/block/buzz/commit/458c7f9154fc9580a766117cad0e3e378ffe5ac0))
- ci(desktop): surface flaky E2E tests instead of retry-masking them ([#1838](https://github.com/block/buzz/pull/1838)) ([`a65340630`](https://github.com/block/buzz/commit/a653406309aa6dab8122cf3e84ddeca0c87013be))
- fix(desktop): treat channel creator as member before 39002 provisioning ([#1830](https://github.com/block/buzz/pull/1830)) ([`7e62a25af`](https://github.com/block/buzz/commit/7e62a25af0bc71df17de806673ed02514e556e6e))
## v0.4.2
- fix(desktop): unify observer feed scroll onto useAnchoredScroll ([#1825](https://github.com/block/buzz/pull/1825)) ([`d75c2e913`](https://github.com/block/buzz/commit/d75c2e913eef763e8ea2e748c58353c3a22d544d))
- fix(desktop): sync sidebar update card copy with global installing state ([#1827](https://github.com/block/buzz/pull/1827)) ([`b63a2e423`](https://github.com/block/buzz/commit/b63a2e4231d27f61eae952d2febab36489e773a5))
- fix(desktop): parse codex ACP plan entries[] into checklist ([#1824](https://github.com/block/buzz/pull/1824)) ([`34dcd13d5`](https://github.com/block/buzz/commit/34dcd13d559b6c606312fca1bbc2c52310b35603))
- fix(desktop): resolve Git Bash for Windows shell tool via PATH/git/registry fallback ([#1821](https://github.com/block/buzz/pull/1821)) ([`020ac7f40`](https://github.com/block/buzz/commit/020ac7f405c24f72e390b605571fb65e2481612c))
- feat(desktop): show read-only MCP server config ([#1780](https://github.com/block/buzz/pull/1780)) ([`51ee1c473`](https://github.com/block/buzz/commit/51ee1c473dae018499e42106086c359c19108137))
- fix(desktop): require user action before applying updates ([#1820](https://github.com/block/buzz/pull/1820)) ([`deed64a14`](https://github.com/block/buzz/commit/deed64a14f1ec39a1242bed46711e44d77a3086e))
- fix(desktop): preserve selected inbox rows through reflow ([#1817](https://github.com/block/buzz/pull/1817)) ([`c06ddcf14`](https://github.com/block/buzz/commit/c06ddcf14b81834a59a4eedf020ccbe8ca72dfd7))
- fix(snapshot): send Buzz shares as PNG avatar cards ([#1811](https://github.com/block/buzz/pull/1811)) ([`a6cfd6551`](https://github.com/block/buzz/commit/a6cfd65511ee06f9073f2d5405e2242a9e2ff4ef))
- test(desktop): consolidate and stabilize scroll coverage ([#1815](https://github.com/block/buzz/pull/1815)) ([`d41b4c390`](https://github.com/block/buzz/commit/d41b4c390522d63a0a4e1b3e8a93fe9c84289b2a))
- fix(buzz-agent): support max effort for gpt-5.6 family ([#1806](https://github.com/block/buzz/pull/1806)) ([`e34e0974a`](https://github.com/block/buzz/commit/e34e0974a149a2b776e779e5798cc4252cc5030c))
- fix(desktop): widen probe test timing margins for parallel pre-push ([#1812](https://github.com/block/buzz/pull/1812)) ([`fac79215a`](https://github.com/block/buzz/commit/fac79215a4cdc8289cc94282641e69d822d419d6))
- fix(desktop): keep pairing tests after production items ([#1807](https://github.com/block/buzz/pull/1807)) ([`68909629f`](https://github.com/block/buzz/commit/68909629f96a95797a627b19e76e03bcbd6983de))
- chore(deps): update rust crate nostr to v0.44.4 ([#1789](https://github.com/block/buzz/pull/1789)) ([`f1f5002a9`](https://github.com/block/buzz/commit/f1f5002a970bb651173ef49027e9609b903e3fa3))
- Add optional standalone pairing relay to Helm chart ([#1799](https://github.com/block/buzz/pull/1799)) ([`9b47c8548`](https://github.com/block/buzz/commit/9b47c8548fd061fbb806ea8b9ddee831c19cf80e))
- chore(ci): lint desktop Tauri crate in CI ([#1801](https://github.com/block/buzz/pull/1801)) ([`bea507d8a`](https://github.com/block/buzz/commit/bea507d8aa611d8d0be893bac1339dad50162d7b))
- fix(desktop): preserve live events and window order across channel refreshes ([#1802](https://github.com/block/buzz/pull/1802)) ([`e09e94ab8`](https://github.com/block/buzz/commit/e09e94ab88543ea0e0c6411092852c1c98187feb))
- fix(desktop): restore macOS navigation chrome alignment ([#1797](https://github.com/block/buzz/pull/1797)) ([`109800fe5`](https://github.com/block/buzz/commit/109800fe5a67c560e3ffb750e7df28177c03f509))
- fix(desktop): omit invalid snapshot imeta thumbnails ([#1800](https://github.com/block/buzz/pull/1800)) ([`b32f2c08f`](https://github.com/block/buzz/commit/b32f2c08f7cdbad1bcc3b6a73711c2e7a21c71bd))
- fix(desktop): centralize known-agent trust set in useKnownAgentPubkeys ([#1703](https://github.com/block/buzz/pull/1703)) ([`095f3c728`](https://github.com/block/buzz/commit/095f3c7280153394bf2a8215b1355ef734af9ee6))
- chore(deps): update all non-major dependencies ([#1778](https://github.com/block/buzz/pull/1778)) ([`221aceb1c`](https://github.com/block/buzz/commit/221aceb1c2f794db0bf2050d1cc66fe8d2db9cec))
- fix(desktop): pass augmented PATH to codex-acp version probe ([#1794](https://github.com/block/buzz/pull/1794)) ([`10ae66893`](https://github.com/block/buzz/commit/10ae66893c8b454255298d0eab40bfd47a46041c))
- feat(desktop): add team snapshot sharing commands ([#1790](https://github.com/block/buzz/pull/1790)) ([`150514f66`](https://github.com/block/buzz/commit/150514f665c5aeebb83f9c42c17e793d88ee6b9f))
- feat(managed_agents): add buzz-team-snapshot v1 codec ([#1784](https://github.com/block/buzz/pull/1784)) ([`aceb5e045`](https://github.com/block/buzz/commit/aceb5e0456c19761f1210585be520fa291ef903c))
## v0.4.1
- feat(desktop): auto-restart setup-mode agents after adapter install, badge drift fallback ([#1786](https://github.com/block/buzz/pull/1786)) ([`11a286b9a`](https://github.com/block/buzz/commit/11a286b9a4284f4f80943d1321f86fa41c472dcf))
- Retain a stable message window in channel timelines ([#1698](https://github.com/block/buzz/pull/1698)) ([`b0ad2b66d`](https://github.com/block/buzz/commit/b0ad2b66ddb2d91bcee1473050d21f27e2a38eac))
- refactor(desktop): remove legacy persona-card flows ([#1781](https://github.com/block/buzz/pull/1781)) ([`1fa91f569`](https://github.com/block/buzz/commit/1fa91f569114cfa1d22951311fe80cef0def36d7))
- Serialize managed agent PATH tests ([#1777](https://github.com/block/buzz/pull/1777)) ([`1fc7488ea`](https://github.com/block/buzz/commit/1fc7488ea58accf8bd96afe94cd93ee54174df00))
- fix(desktop): stable inbox selection and scroll/draft preservation ([#1760](https://github.com/block/buzz/pull/1760)) ([`7e3252ea3`](https://github.com/block/buzz/commit/7e3252ea3e6b5c6c0db2e2a36c839d9cc2700c89))
- feat(desktop): add buzz-agent-snapshot v1 export, import, sender-native send, and recipient card/import ([#1753](https://github.com/block/buzz/pull/1753)) ([`1580046b3`](https://github.com/block/buzz/commit/1580046b3c2c3d208e84fb240467454b8f3813f0))
- refactor(desktop): remove vestigial MCP toolsets config ([#1776](https://github.com/block/buzz/pull/1776)) ([`dfec75b3c`](https://github.com/block/buzz/commit/dfec75b3c0b8080529e4d9089d4ed80e3902aaed))
- feat(desktop): add key backup and agent defaults to onboarding, make avatar optional ([#1767](https://github.com/block/buzz/pull/1767)) ([`d2e87e1ca`](https://github.com/block/buzz/commit/d2e87e1ca71368312576b7f492818a50042e61e1))
- fix(desktop): correct provider and model handling in agent config dialogs ([#1764](https://github.com/block/buzz/pull/1764)) ([`f3319dd11`](https://github.com/block/buzz/commit/f3319dd11102bfd52e1ec3065a4d9272a03075ec))
- fix(desktop): cascade persona deletes and restart agents on global config save ([#1766](https://github.com/block/buzz/pull/1766)) ([`7c346d7f8`](https://github.com/block/buzz/commit/7c346d7f85e7fcb64999b502306f7895d37fb42a))
- fix(desktop): make doctor installs retryable with per-runtime progress and auth status ([#1765](https://github.com/block/buzz/pull/1765)) ([`25bb71475`](https://github.com/block/buzz/commit/25bb7147523f27a88f1bfece0310889442699c5f))
- fix(codex): swap to @agentclientprotocol/codex-acp 1.x + detect outdated adapter ([#1750](https://github.com/block/buzz/pull/1750)) ([`6d1a77e49`](https://github.com/block/buzz/commit/6d1a77e49255f18434593c9c7daa811f5c76db0b))
- fix(desktop): show thread replies loader ([#1773](https://github.com/block/buzz/pull/1773)) ([`e07e02c0e`](https://github.com/block/buzz/commit/e07e02c0eb76376b96e0a9d5fe27009111430845))
- fix(desktop): restore multi-image mosaic galleries ([#1769](https://github.com/block/buzz/pull/1769)) ([`2c41e9e6b`](https://github.com/block/buzz/commit/2c41e9e6b836fad57da981ec9329fe51e5796085))
- Group channel membership events ([#1713](https://github.com/block/buzz/pull/1713)) ([`5dc70bd0b`](https://github.com/block/buzz/commit/5dc70bd0b8561ce8aa24338903167e62d6035d10))
- Fix desktop launch motion and reaction spacing ([#1717](https://github.com/block/buzz/pull/1717)) ([`89e2e2e4a`](https://github.com/block/buzz/commit/89e2e2e4adf4d062995aed46b32f4935c6992513))
- fix(desktop): align sidebar search across themes ([#1712](https://github.com/block/buzz/pull/1712)) ([`53c177e34`](https://github.com/block/buzz/commit/53c177e341d4f9e7b254b7c83af39380e9b10242))
- fix(desktop): Buzz theme flicker, white bar & accent-picker motion ([#1681](https://github.com/block/buzz/pull/1681)) ([`5e15a588e`](https://github.com/block/buzz/commit/5e15a588e072198b922fb3793a48d9c98146fc45))
- feat(desktop): surface Team Instructions as distinct observer section ([#1759](https://github.com/block/buzz/pull/1759)) ([`56d822dc2`](https://github.com/block/buzz/commit/56d822dc2c9b78df50fad7928a8d088b7618e671))
- fix(desktop): separate system prompt from turn context ([#1754](https://github.com/block/buzz/pull/1754)) ([`84ec63aa2`](https://github.com/block/buzz/commit/84ec63aa24d14553206dde5dbfc61807755313b8))
- fix(desktop): preserve archived observer history ([#1752](https://github.com/block/buzz/pull/1752)) ([`51234bd89`](https://github.com/block/buzz/commit/51234bd89f1dffcd82bae45428094cff35c4081e))
- fix(desktop): surface codex config-parse failures and -32603 internal errors clearly ([#1745](https://github.com/block/buzz/pull/1745)) ([`ec3762552`](https://github.com/block/buzz/commit/ec37625522ce1847e2684da2a477ca742255fbf1))
- fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread ([#1747](https://github.com/block/buzz/pull/1747)) ([`e8df6ba69`](https://github.com/block/buzz/commit/e8df6ba694703a26963524433e0e3a3f6ad252f1))
- chore(desktop): remove container-only npm-preflight E2E harness ([#1749](https://github.com/block/buzz/pull/1749)) ([`9895b4d39`](https://github.com/block/buzz/commit/9895b4d390591a5adfc368bbc97f53989463c8ef))
- fix(desktop): preflight npm prefix writability in doctor installs ([#1732](https://github.com/block/buzz/pull/1732)) ([`58aee8f91`](https://github.com/block/buzz/commit/58aee8f91394a97b68902f61a116696ed8ca3e1f))
- fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge ([#1738](https://github.com/block/buzz/pull/1738)) ([`ea1c75b0d`](https://github.com/block/buzz/commit/ea1c75b0d2bf954efe3816b303a9972e26ff893a))
- feat(desktop): add explicit Edit avatar CTA in agent instance edit dialog ([#1736](https://github.com/block/buzz/pull/1736)) ([`26966aace`](https://github.com/block/buzz/commit/26966aacec1d8ca58ac5951818e55249e63506a3))
- fix(observer): order system prompt below current-session divider on restart ([#1734](https://github.com/block/buzz/pull/1734)) ([`67a2d047e`](https://github.com/block/buzz/commit/67a2d047eb866dff3faae9f96b0a4f478e5e2a8b))
## v0.4.0
- feat(desktop): default observer archive on for dev-nest builds ([#1726](https://github.com/block/buzz/pull/1726)) ([`d9e4edbbf`](https://github.com/block/buzz/commit/d9e4edbbf54996df22c59afdaaaaf571b4e3a5c6))
- feat(desktop): thread baked build env into global agent config UI ([#1722](https://github.com/block/buzz/pull/1722)) ([`2f2ad409a`](https://github.com/block/buzz/commit/2f2ad409a76606dd9c54872dc518b0ecb72756af))
- feat: relay invite links (mint + claim + landing page + deep link) ([#1668](https://github.com/block/buzz/pull/1668)) ([`2e529aab7`](https://github.com/block/buzz/commit/2e529aab759a18c1bb81e447f3696fe99db53a27))
- fix(desktop): pass GlobalAgentConfig to spawn_config_hash in tests ([#1721](https://github.com/block/buzz/pull/1721)) ([`b97777350`](https://github.com/block/buzz/commit/b97777350dec375a1fe444891c8e10f9cee74d0c))
- feat(desktop): global agent config defaults with readiness/spawn parity ([#1448](https://github.com/block/buzz/pull/1448)) ([`77c08365e`](https://github.com/block/buzz/commit/77c08365e4a66c0cc8731b66280ef029500bbfcf))
- fix(desktop): remove "Remove all stopped" bulk action from agents menu ([#1720](https://github.com/block/buzz/pull/1720)) ([`651efe8d9`](https://github.com/block/buzz/commit/651efe8d95d11a591d8421973bf4352e36d69a3f))
- fix(desktop): retire single-member built-in Fizz team ([#1718](https://github.com/block/buzz/pull/1718)) ([`f12bd3689`](https://github.com/block/buzz/commit/f12bd36893ab37f15261d47d2412732089e1645f))
- fix(desktop): hide relay mesh option when mesh-llm feature is absent ([#1719](https://github.com/block/buzz/pull/1719)) ([`f0b8f806f`](https://github.com/block/buzz/commit/f0b8f806fde83b2ea8fc048e1076f4338e445545))
- fix(desktop): propagate agent definition edits to instances and remove dead config knobs ([#1715](https://github.com/block/buzz/pull/1715)) ([`1f48860b7`](https://github.com/block/buzz/commit/1f48860b7b5454a07f4f066ccca665dc7ee26b20))
- perf(desktop): thread panel rebuilt on every render — fix memo + add typing-latency benchmark ([#1652](https://github.com/block/buzz/pull/1652)) ([`9b2f0c8cb`](https://github.com/block/buzz/commit/9b2f0c8cb574f37eb38033f849b8420d15a63716))
- fix(drafts): stop clearing drafts on workspace switch and hide sent section ([#1708](https://github.com/block/buzz/pull/1708)) ([`e2d104f66`](https://github.com/block/buzz/commit/e2d104f669468358ea917f1d7fa2dc9fc5958764))
- fix(inbox): reply at same thread level as inbox item ([#1714](https://github.com/block/buzz/pull/1714)) ([`177fc30be`](https://github.com/block/buzz/commit/177fc30bea397b9422c25eb467c33496ebba9cbe))
- fix(desktop): make @mention clicks reliably open the profile panel ([#1705](https://github.com/block/buzz/pull/1705)) ([`5f0d8309d`](https://github.com/block/buzz/commit/5f0d8309d266262965489eedea516f95a02fd410))
- fix(desktop): walk ancestor chain in orphan sweep exemption ([#1711](https://github.com/block/buzz/pull/1711)) ([`7fb215c4f`](https://github.com/block/buzz/commit/7fb215c4fc29ecf2311f84d8f1f93b0301a582fa))
- fix(desktop): stop showing /dev/null in diff labels, keep type badge visible ([#1704](https://github.com/block/buzz/pull/1704)) ([`868c9ad05`](https://github.com/block/buzz/commit/868c9ad05555dbc11d5c1ceece1e0a310bc3ab4a))
- perf(desktop): stop re-rendering the app shell on every keystroke; fix two latent cache races it unmasked ([#1692](https://github.com/block/buzz/pull/1692)) ([`129c91a2b`](https://github.com/block/buzz/commit/129c91a2ba0017b20b65cbad8099269a383b46a4))
- fix(observer): widen live subscription lookback to capture session/prompt ([#1710](https://github.com/block/buzz/pull/1710)) ([`715df2537`](https://github.com/block/buzz/commit/715df2537594da42c5b19406805c6c410c86a15f))
- test(desktop): stabilize custom emoji e2e ([#1685](https://github.com/block/buzz/pull/1685)) ([`20c40d176`](https://github.com/block/buzz/commit/20c40d1769438a1b0fdcd032b3dcfdc79a683898))
- fix(observer): use lifecycle item title as verb instead of hardcoded "Status" ([#1709](https://github.com/block/buzz/pull/1709)) ([`95bc640a5`](https://github.com/block/buzz/commit/95bc640a539c87c1fe0560d1dfa960b653bb96a1))
- chore(desktop): remove dead archive-migration and keyring load_readonly helpers ([#1701](https://github.com/block/buzz/pull/1701)) ([`389a956e0`](https://github.com/block/buzz/commit/389a956e02e85b1a3379df384a14c6d9c449d3c6))
- fix(desktop): stop losing imported identity on every launch ([#1568](https://github.com/block/buzz/pull/1568)) ([`e15f06857`](https://github.com/block/buzz/commit/e15f0685718bcba6d0947a8286ab2deeab0137ef))
- fix(desktop): use augmented PATH for readiness probes ([#1613](https://github.com/block/buzz/pull/1613)) ([`94ec2c2bc`](https://github.com/block/buzz/commit/94ec2c2bc6f5505dbbbd4783c41f3bb20d5a642c))
- fix(desktop): isolate dev keyring from production and make BUZZ_SHARE_IDENTITY keyring-aware ([#1680](https://github.com/block/buzz/pull/1680)) ([`e5d215e3c`](https://github.com/block/buzz/commit/e5d215e3c2f4939fe4d0e1f09c8f9a265f818385))
- fix(desktop): migrate Databricks V1→V2 records at boot and fix readiness gate ([#1686](https://github.com/block/buzz/pull/1686)) ([`1f2afce29`](https://github.com/block/buzz/commit/1f2afce29d46ec09f63d467af4cacdd479a9a336))
- B5 residue: inbound-event security hardening + persona→agent copy sweep ([#1688](https://github.com/block/buzz/pull/1688)) ([`f0e65589a`](https://github.com/block/buzz/commit/f0e65589a9a68cd19457e1196b8eb4db68217a3b))
- fix(desktop): paginate complete channel directory ([#1690](https://github.com/block/buzz/pull/1690)) ([`b41cf3ffc`](https://github.com/block/buzz/commit/b41cf3ffc6d232dc1199f7563bd3c4da8571bfe1))
- fix: flush debounce on Tab/Enter to prevent stale autocomplete ([#1661](https://github.com/block/buzz/pull/1661)) ([`841282141`](https://github.com/block/buzz/commit/841282141b5feb728dd111c9163cd0060f9bbb86))
- fix(desktop): preserve agent timer state across workspace switches ([#1679](https://github.com/block/buzz/pull/1679)) ([`46613dcce`](https://github.com/block/buzz/commit/46613dccec89b064b2ba61d65b191c155a5aac3f))
- fix(desktop): honor macOS title-bar double-click preference ([#1674](https://github.com/block/buzz/pull/1674)) ([`6b2147a5d`](https://github.com/block/buzz/commit/6b2147a5d18d70a3a46d2484d746de3506f8138d))
- feat(desktop): projects overview v2 — aggregate rail, PR review flow, commit detail, and straighter layout ([#1677](https://github.com/block/buzz/pull/1677)) ([`58fe9388c`](https://github.com/block/buzz/commit/58fe9388c81ace47001cba55f29afb76f288f3e0))
- B5: one create path — behavioral quad activation, backfill, menu collapse, legacy dialog deletion ([#1667](https://github.com/block/buzz/pull/1667)) ([`3a7e40027`](https://github.com/block/buzz/commit/3a7e4002787ea3e8f0f91a62fdbd2f1eb3e7a49f))
- Polish Buzz sidebar theme ([#1671](https://github.com/block/buzz/pull/1671)) ([`cdba2a08e`](https://github.com/block/buzz/commit/cdba2a08e1645f63e71965771cfbbf4a1437f169))
- fix(agents): harden structured error-code classification edge cases ([#1663](https://github.com/block/buzz/pull/1663)) ([`fc8a17445`](https://github.com/block/buzz/commit/fc8a17445a5712d0a1456684d78952edf41cf5ae))
- feat: add deeplink nostr identity binding flow ([#1648](https://github.com/block/buzz/pull/1648)) ([`cecd03142`](https://github.com/block/buzz/commit/cecd031428358d7b3aa0326ccca74649bd928231))
- perf(desktop): cache parsed markdown across channel-switch remounts ([#1635](https://github.com/block/buzz/pull/1635)) ([`2b3246934`](https://github.com/block/buzz/commit/2b324693404623b2fa2e53ab6ec570977526ace3))
- fix(agents): surface provider errors structurally instead of raw dumps ([#1653](https://github.com/block/buzz/pull/1653)) ([`9b244ff22`](https://github.com/block/buzz/commit/9b244ff22b2fdab1d4aa20fcf661d22aa0cf84aa))
- feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) ([#1655](https://github.com/block/buzz/pull/1655)) ([`b2c63291f`](https://github.com/block/buzz/commit/b2c63291f1e432951f49c57bd1ec65812870f515))
- fix(desktop): scope observer feed by channel and add session-boundary dividers ([#1634](https://github.com/block/buzz/pull/1634)) ([`fd5d04de0`](https://github.com/block/buzz/commit/fd5d04de0593ee8c335b57bd2d248c638b055ade))
- feat(config-nudge): distinguish install/auth state in cli_login nudge cards ([#1633](https://github.com/block/buzz/pull/1633)) ([`4f1a487ab`](https://github.com/block/buzz/commit/4f1a487ab49531d4ebe284fd3cee689c13651c7f))
- fix(desktop): async-ify the auto-restart setter after the perf-sweep merge collision ([#1651](https://github.com/block/buzz/pull/1651)) ([`dc7ececdc`](https://github.com/block/buzz/commit/dc7ececdccd8ed5675f1560a5319518ade6b5e8e))
- feat(desktop): auto-restart agents on config change (Chunk F) ([#1649](https://github.com/block/buzz/pull/1649)) ([`a415f0f31`](https://github.com/block/buzz/commit/a415f0f3109fae970d266474fec64f16bab53b33))
- perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization ([#1641](https://github.com/block/buzz/pull/1641)) ([`2cc0eb539`](https://github.com/block/buzz/commit/2cc0eb53968ec6281be0e6778a06ea18e044395e))
- fix: user search returns zero results for partially typed names ([#1603](https://github.com/block/buzz/pull/1603)) ([`11608ef67`](https://github.com/block/buzz/commit/11608ef675ef11c375b2ac9c04bdcc287affeb46))
- copy(desktop): finish the persona→agent rename in rendered UI (B4.1) ([#1647](https://github.com/block/buzz/pull/1647)) ([`577fc8df5`](https://github.com/block/buzz/commit/577fc8df5254f8fc1c5ec260d22f64f8786c6244))
- Community moderation UI: integration branch (data layer + member surface base) ([#1617](https://github.com/block/buzz/pull/1617)) ([`c9c868b25`](https://github.com/block/buzz/commit/c9c868b250e1dc8f6019abaaa6ae2c203294dfc4))
- Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices ([#1616](https://github.com/block/buzz/pull/1616)) ([`863aeb79f`](https://github.com/block/buzz/commit/863aeb79f35a43222bd5de3901ab06220948fb9d))
- copy(desktop): the word "persona" leaves the UI (Phase 1B.4) ([#1646](https://github.com/block/buzz/pull/1646)) ([`41b187f93`](https://github.com/block/buzz/commit/41b187f93646403689311d5f5bfcbc4cc1bf4927))
- feat(desktop): flapping bee on the setup loading screen ([#1631](https://github.com/block/buzz/pull/1631)) ([`c145037aa`](https://github.com/block/buzz/commit/c145037aad121bb9f6fd6e9068ff3cf77fb86a48))
- fix(desktop): make ⌘K open the composer link editor for selections ([#1644](https://github.com/block/buzz/pull/1644)) ([`3e982fd37`](https://github.com/block/buzz/commit/3e982fd371b97aebf39a3e418e323e6c0de7b665))
- fix(linux): make AppImage work on Mesa 25+ / GLib 2.88 distros ([#1567](https://github.com/block/buzz/pull/1567)) ([`563dab92e`](https://github.com/block/buzz/commit/563dab92e4f07b87db0c4156a20b0041c60b561c))
- fix(desktop): add unread dot to workspace rail for channel unreads ([#1637](https://github.com/block/buzz/pull/1637)) ([`f968601ef`](https://github.com/block/buzz/commit/f968601ef6208ce411989617d15b2f3592a51665))
- refactor(desktop): converge the three definition→instance mappings (Phase 1B.3.5) ([#1645](https://github.com/block/buzz/pull/1645)) ([`9683ef807`](https://github.com/block/buzz/commit/9683ef807919f4d04f960280bff3b9d99da12af3))
- Hide non-invocable member agents from @-mention autocomplete ([#1611](https://github.com/block/buzz/pull/1611)) ([`f929aa09e`](https://github.com/block/buzz/commit/f929aa09e685eece9ff710c7506b07834df1f00e))
- fix(desktop): clamp HDR avatar images to SDR range ([#1642](https://github.com/block/buzz/pull/1642)) ([`fa816d5bf`](https://github.com/block/buzz/commit/fa816d5bfade9eb84e93b720bfc55e6af4e623f4))
- refactor(desktop): definition-edit routes through AgentDialog — single dialog entry point (Phase 1B.3c) ([#1643](https://github.com/block/buzz/pull/1643)) ([`aa17294ad`](https://github.com/block/buzz/commit/aa17294ad07c0fa64b94502e9e9a07aa9bd5c70d))
- fix(desktop): refresh persona env vars on respawn — record.env_vars is overrides-only ([#1640](https://github.com/block/buzz/pull/1640)) ([`79a057534`](https://github.com/block/buzz/commit/79a0575342016b8aebeff322e84c9cea0bf76443))
- refactor(desktop): re-host instance edit as AgentInstanceEditDialog behind AgentDialog (Phase 1B.3b) ([#1639](https://github.com/block/buzz/pull/1639)) ([`8af18ee32`](https://github.com/block/buzz/commit/8af18ee32515db20ec5b4d3d430a49f702209cdc))
- feat(desktop): show full URL tooltip on masked link hover ([#1625](https://github.com/block/buzz/pull/1625)) ([`01c6b8566`](https://github.com/block/buzz/commit/01c6b8566e7d6ccd291c4a244285927476196b6d))
- fix(desktop): paint Buzz gradient on the workspace rail ([#1636](https://github.com/block/buzz/pull/1636)) ([`2f1cd6771`](https://github.com/block/buzz/commit/2f1cd677147b5d07979cef112c728a96a4dff4f1))
- refactor(desktop): single-home the definition form as AgentDefinitionDialog (Phase 1B.3a) ([#1627](https://github.com/block/buzz/pull/1627)) ([`48bd8abfe`](https://github.com/block/buzz/commit/48bd8abfeb165f23f95d456be91b45c4b698a0df))
- feat(desktop): unified AgentDialog create entry point with create intents (Phase 1B.2) ([#1626](https://github.com/block/buzz/pull/1626)) ([`6f38db680`](https://github.com/block/buzz/commit/6f38db68039300d7f92122b9d7631b5a4bbe5e53))
- Update DMG background and label size ([#1629](https://github.com/block/buzz/pull/1629)) ([`5b1a53ceb`](https://github.com/block/buzz/commit/5b1a53ceba2c6d9196a52b478c1f581c4d42b8df))
- test(desktop): deflake mid-scroll older-history spinner smoke test ([#1632](https://github.com/block/buzz/pull/1632)) ([`7ff58d9af`](https://github.com/block/buzz/commit/7ff58d9af9edbdfd3f1a8c508641dda12710320a))
- feat(desktop): add Buzz light + dark themes with branded sidebar gradient ([#1630](https://github.com/block/buzz/pull/1630)) ([`290be2a03`](https://github.com/block/buzz/commit/290be2a0395c1d2151ae5524c5474d95cffc302c))
- fix(desktop): render thread facepile oldest-replier-first ([#1595](https://github.com/block/buzz/pull/1595)) ([`f9701a386`](https://github.com/block/buzz/commit/f9701a38600302b3a97845b6f25681b6ef63dbd7))
- fix(desktop): keep sidebar section actions visible while ⋮ menu is open ([#1584](https://github.com/block/buzz/pull/1584)) ([`943d7a663`](https://github.com/block/buzz/commit/943d7a663028a32485f3a372a1ae73286c225e95))
- refactor(desktop): extract shared runtime/provider/model selection + env-var helpers (Phase 1B.1) ([#1624](https://github.com/block/buzz/pull/1624)) ([`4b5cac074`](https://github.com/block/buzz/commit/4b5cac074b10dab75404aa09f65d5a4e43ff4848))
- feat(desktop): fold personas.json into the unified agent store (Phase 1A.2) ([#1623](https://github.com/block/buzz/pull/1623)) ([`381c7714f`](https://github.com/block/buzz/commit/381c7714fa6943f033eb3963aa105397098b782b))
- fix(desktop): hash spawn config as the env receives it, not raw record fields ([#1621](https://github.com/block/buzz/pull/1621)) ([`f0eb4eccd`](https://github.com/block/buzz/commit/f0eb4eccd9dbae6819d44c282db675c89f8e972a))
- test(desktop): add edit-agent dialog e2e coverage (Phase 1B.3b-pre) ([#1628](https://github.com/block/buzz/pull/1628)) ([`a609e796b`](https://github.com/block/buzz/commit/a609e796bd1b5eda01c733041e5e5320c2f4557d))
- feat(desktop): unified AgentRecord groundwork — record-first resolution + runtime materialization (Phase 1A.1) ([#1618](https://github.com/block/buzz/pull/1618)) ([`5dce80aff`](https://github.com/block/buzz/commit/5dce80aff53ca698c1c17ac21866bc3949ed14bc))
- fix(desktop): backfill edits for thread replies so edited replies survive refetch ([#1610](https://github.com/block/buzz/pull/1610)) ([`d1f3194e7`](https://github.com/block/buzz/commit/d1f3194e73abd1389c62b32e0502fcf8c5ce9b4c))
- chore(desktop): clear desktop-tauri clippy backlog ([#1612](https://github.com/block/buzz/pull/1612)) ([`36fd41c5c`](https://github.com/block/buzz/commit/36fd41c5ce63bf0f8f0ad8d740fce47cae7ef9d1))
- fix(desktop): restrict shared-agent sync to dev data dirs ([#1597](https://github.com/block/buzz/pull/1597)) ([`e5f831d2c`](https://github.com/block/buzz/commit/e5f831d2c2358592e24a20fb020f43fc50ea9d1b))
- feat(desktop): restart-required badge from spawn-time config hash ([#1602](https://github.com/block/buzz/pull/1602)) ([`cc42a4979`](https://github.com/block/buzz/commit/cc42a497991ee6fe632734239c53a7c4d4fb4424))
- feat(desktop): boot-time reconcile of managed agents to relay events ([#1601](https://github.com/block/buzz/pull/1601)) ([`c5a541ee2`](https://github.com/block/buzz/commit/c5a541ee2d2436d0fb970e5c0164bed349a76c4a))
- feat(desktop): canonical <PubKey> component — hover to view/copy full keys, owner "you" labels ([#1589](https://github.com/block/buzz/pull/1589)) ([`777babf39`](https://github.com/block/buzz/commit/777babf3938a2ae7ef97fd12f12e6247e7c18cec))
- fix(desktop): hydrate reactions for Inbox context messages ([#1596](https://github.com/block/buzz/pull/1596)) ([`cdf982bdb`](https://github.com/block/buzz/commit/cdf982bdb3fa4693416104a3e26a324e7703b58c))
## v0.3.46
- fix(desktop): preserve agent model/provider when persona snapshot fields are blank ([#1583](https://github.com/block/buzz/pull/1583)) ([`a3ee2c569`](https://github.com/block/buzz/commit/a3ee2c5693d58f32b17072a49add2dbaff8e0ffd))
- feat(acp,desktop): identify and reap stale agent harness processes ([#1582](https://github.com/block/buzz/pull/1582)) ([`3c6c0d447`](https://github.com/block/buzz/commit/3c6c0d4478e5bb6e734cc16eafa6de6a433bf8fe))
- feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state ([#1581](https://github.com/block/buzz/pull/1581)) ([`b394e95ef`](https://github.com/block/buzz/commit/b394e95ef03b131fb43bda0954d900ae56473f31))
- fix(desktop): treat baked build env vars as satisfying required agent config ([#1580](https://github.com/block/buzz/pull/1580)) ([`b90e80443`](https://github.com/block/buzz/commit/b90e80443ea498e2b7b97bde9069d4b7c6ab4048))
- feat(desktop): add "Copy image" to image right-click context menu ([#1579](https://github.com/block/buzz/pull/1579)) ([`683f7fec1`](https://github.com/block/buzz/commit/683f7fec1c357f1140a4f874720f513b69d919a4))
- fix(nest): use buzz-dev symlink name for dev builds ([#1587](https://github.com/block/buzz/pull/1587)) ([`dcbb3ff78`](https://github.com/block/buzz/commit/dcbb3ff7892891cdc3c447049444cd2be8570f5e))
- fix(composer): address image-editor follow-up nits on #1491 ([#1565](https://github.com/block/buzz/pull/1565)) ([`49391d807`](https://github.com/block/buzz/commit/49391d80713b74d1f48a442b193d9a91b086d99a))
- fix(desktop): render black static boot screen ([#1570](https://github.com/block/buzz/pull/1570)) ([`10444d248`](https://github.com/block/buzz/commit/10444d248e26f6ab119cb13ccef906f42dc333da))
- feat(agents): group activity tool bursts ([#1571](https://github.com/block/buzz/pull/1571)) ([`426f04484`](https://github.com/block/buzz/commit/426f044844951fc1ced7670d2dd5d25812deced9))
- feat(desktop): aggregated overview rail, commit detail page, and full breadcrumbs ([#1573](https://github.com/block/buzz/pull/1573)) ([`c97263901`](https://github.com/block/buzz/commit/c97263901f9757ed6e9ddbf3f47097e2c6567e24))
- fix(desktop): fetch profiles for reaction actors and thread-reply authors ([#1550](https://github.com/block/buzz/pull/1550)) ([`62def1459`](https://github.com/block/buzz/commit/62def1459c6e4d2beb5fda9e0c4accde16aa2c8d))
- refactor(desktop): unify EditAgentDialog styling with PersonaDialog ([#1540](https://github.com/block/buzz/pull/1540)) ([`c258ffc4a`](https://github.com/block/buzz/commit/c258ffc4a87fb523900801d0959334912f25d30a))
- feat(desktop): add 10-minute message grouping window ([#1578](https://github.com/block/buzz/pull/1578)) ([`564ead385`](https://github.com/block/buzz/commit/564ead3856c1e9f5c8760aaa6bcc9bd16cbbd9be))
- feat(desktop): unify sidebar section actions into a per-section ⋮ menu ([#1577](https://github.com/block/buzz/pull/1577)) ([`d94561795`](https://github.com/block/buzz/commit/d94561795a48af743278bfa1e912b894228312f3))
- feat(desktop): emoji avatar picker for agents + reliable picker scroll ([#1576](https://github.com/block/buzz/pull/1576)) ([`ed84a8735`](https://github.com/block/buzz/commit/ed84a87358495a4cdd6e61ee1cfe01db9a367b35))
- fix(desktop): sync derived active-turn liveness in channel activity path ([#1492](https://github.com/block/buzz/pull/1492)) ([`1f5ba5bb2`](https://github.com/block/buzz/commit/1f5ba5bb27a94d33cce5aaa7cfea1d116c7d582d))
- fix(personas): remove goose runtime pin from Fizz built-in ([#1566](https://github.com/block/buzz/pull/1566)) ([`e3008b360`](https://github.com/block/buzz/commit/e3008b3604330803975d66920204e7365fc571a9))
- feat(acp,buzz-agent): thread model name through NIP-AM kind 44200 emit path ([#1564](https://github.com/block/buzz/pull/1564)) ([`08e707eb5`](https://github.com/block/buzz/commit/08e707eb573a187432a950154a15f9cb4785d25f))
- sync channel sort preferences across clients ([#1556](https://github.com/block/buzz/pull/1556)) ([`04a9d1029`](https://github.com/block/buzz/commit/04a9d10299b92c5b0e7597a53914b9a753985c51))
- fix desktop canvas header blur in split channel management ([#1558](https://github.com/block/buzz/pull/1558)) ([`2ef26ec21`](https://github.com/block/buzz/commit/2ef26ec21048857fc11982cd8c153e1c795f2c6d))
- feat(composer): compose spoiler, annotation, and tooltip controls ([#1491](https://github.com/block/buzz/pull/1491)) ([`1aa87bb26`](https://github.com/block/buzz/commit/1aa87bb26b96d13fa2bba042ccd933e988d79bae))
- feat(channel list): add persistent channel sort toggle ([#1505](https://github.com/block/buzz/pull/1505)) ([`36ac5243f`](https://github.com/block/buzz/commit/36ac5243ff8aed4401d3453a115afc4a603fccf0))
## v0.3.45
- chore(deps): bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#1563](https://github.com/block/buzz/pull/1563)) ([`75b52ad7e`](https://github.com/block/buzz/commit/75b52ad7e54ca7f930656e756910a08d0f146b82))
- fix(archive): atomic remove-kind path + split test modules ([#1562](https://github.com/block/buzz/pull/1562)) ([`484bede75`](https://github.com/block/buzz/commit/484bede7597814dd7dc322d1f72f69c08545ef42))
- feat(archive): add agent turn-metric (kind 44200) local archive ([#1555](https://github.com/block/buzz/pull/1555)) ([`429f54b4f`](https://github.com/block/buzz/commit/429f54b4f45df0d5f4a52aafb8913e903ebdc6b3))
- feat(desktop,buzz-acp): add harness-agnostic config bridge and setup-listener mode ([#1411](https://github.com/block/buzz/pull/1411)) ([`d8355d318`](https://github.com/block/buzz/commit/d8355d318853f85ffaf53dc7b5215bc998b460c4))
- fix(desktop): stop edit-channel dialog hanging on "Saving..." ([#1557](https://github.com/block/buzz/pull/1557)) ([`76806cb1f`](https://github.com/block/buzz/commit/76806cb1f654650a412b7f0368f495062a9ee224))
- feat(archive): add local-save archive with observer-feed default-on option ([#1442](https://github.com/block/buzz/pull/1442)) ([`711078807`](https://github.com/block/buzz/commit/711078807777db77960404e51ffd862c08a33ded))
- feat(desktop): add Slack-like Drafts inbox with persistence and image fix ([#1539](https://github.com/block/buzz/pull/1539)) ([`4c598dcef`](https://github.com/block/buzz/commit/4c598dcef577bb204f9666eaac5b95743a0f8736))
- fix(setup): fix syntax error in seed-local-community.sh ([#1547](https://github.com/block/buzz/pull/1547)) ([`3729a6515`](https://github.com/block/buzz/commit/3729a6515617abdae03117d9713dfeb7e1ff74c2))
- feat(desktop): add right-click context menu to workspace rail ([#1552](https://github.com/block/buzz/pull/1552)) ([`0e87998f8`](https://github.com/block/buzz/commit/0e87998f8fcac0ff1612d661cbd505a63b51c550))
- fix(desktop): restore saved window geometry on launch ([#1554](https://github.com/block/buzz/pull/1554)) ([`e790c9828`](https://github.com/block/buzz/commit/e790c9828f1ae85ce7618d06e67e78732be4221b))
- feat(desktop): add typeahead search to persona model dropdown ([#1542](https://github.com/block/buzz/pull/1542)) ([`3b43743e1`](https://github.com/block/buzz/commit/3b43743e1f2acd192705a80629ceadfa7a737831))
- fix(desktop): publish new agent profiles to active relay ([#1546](https://github.com/block/buzz/pull/1546)) ([`346efe08b`](https://github.com/block/buzz/commit/346efe08b3ca6e847b6c9c2f843cdb8834db123c))
- chore(desktop): use dot access for host header assignment ([#1548](https://github.com/block/buzz/pull/1548)) ([`ccfdf45bc`](https://github.com/block/buzz/commit/ccfdf45bc5dd61ef7d59644293d84deed24e1789))
- fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal ([#1508](https://github.com/block/buzz/pull/1508)) ([`c2ee4d162`](https://github.com/block/buzz/commit/c2ee4d162753a3338dc434f85c80a82eb284b3d2))
## v0.3.44
- fix(desktop): keep thread-summary badges mounted through scrollback prepends ([#1533](https://github.com/block/buzz/pull/1533)) ([`6abf614fd`](https://github.com/block/buzz/commit/6abf614fd4eb512cf2e4175f17dc09892a8b472d))
- refactor(shell): drop bundled PortableGit, add BUZZ_SHELL override + dialect hint ([#1536](https://github.com/block/buzz/pull/1536)) ([`cfa208983`](https://github.com/block/buzz/commit/cfa20898313080b7a106ae0e092d8ed6815daeb0))
- feat(nips,relay,acp): NIP-AM durable encrypted agent turn metrics (kind 44200) ([#1441](https://github.com/block/buzz/pull/1441)) ([`71265ca36`](https://github.com/block/buzz/commit/71265ca36105dbf62453a99c998c3f3dd134a304))
## v0.3.43
- fix(desktop): sync agent relay profile when persona avatar changes ([#1512](https://github.com/block/buzz/pull/1512)) ([`ce901f7c1`](https://github.com/block/buzz/commit/ce901f7c1e548a0aab0d9e97f4951ea134f6b3d2))
- feat(desktop): redesign appearance settings with mode-first theme picker ([#1528](https://github.com/block/buzz/pull/1528)) ([`9e773f103`](https://github.com/block/buzz/commit/9e773f103a2829d1b0195a2bf4f01a4b3ff69291))
- fix(deps): restore windows-* crates downgraded by chrono bump (fixes Windows Rust on main) ([#1532](https://github.com/block/buzz/pull/1532)) ([`d40837085`](https://github.com/block/buzz/commit/d408370851643fca1525cdb00147e589e7408f21))
- chore(renovate): stop rebase churn between weekly sweeps ([#1530](https://github.com/block/buzz/pull/1530)) ([`a8238cd67`](https://github.com/block/buzz/commit/a8238cd670031bf3595fa8d0f2e79e6f64571054))
- fix(deps): unversion isomorphic-git pnpm patch key ([#1529](https://github.com/block/buzz/pull/1529)) ([`2a24765fd`](https://github.com/block/buzz/commit/2a24765fd74f66764890746a84f9654e578986f3))
- chore(deps): update dependency @tanstack/react-virtual to v3.14.5 ([#1523](https://github.com/block/buzz/pull/1523)) ([`b66a53f5b`](https://github.com/block/buzz/commit/b66a53f5b5ba4355511043ab621f1219417b0a4b))
- chore(deps): update redis to v1.2.2 ([#1049](https://github.com/block/buzz/pull/1049)) ([`f1b29140c`](https://github.com/block/buzz/commit/f1b29140c66e2987fedb0fa66174552754721d97))
- feat(desktop): restore section icon/emoji picker ([#1516](https://github.com/block/buzz/pull/1516)) ([`232b6066c`](https://github.com/block/buzz/commit/232b6066c0fe54e849537aaa8da644d42bc3e33c))
- chore(deps): update radix-ui-primitives monorepo ([#1524](https://github.com/block/buzz/pull/1524)) ([`dc383c199`](https://github.com/block/buzz/commit/dc383c1993c5dfe8fea95f4e10f1e5ee5648f7aa))
- fix(desktop): stop Leave-channel dialog from freezing the app ([#1482](https://github.com/block/buzz/pull/1482)) ([`668d30b0c`](https://github.com/block/buzz/commit/668d30b0cf7a466480d07d024583963dc51e2d51))
- perf: lazy-load avatars and message images ([#1517](https://github.com/block/buzz/pull/1517)) ([`d09d7dea5`](https://github.com/block/buzz/commit/d09d7dea558bcd17d66315ca099a5a2752a3be60))
- Live thread-summary push: badge counts update on reply ingest ([#1521](https://github.com/block/buzz/pull/1521)) ([`74a30c3de`](https://github.com/block/buzz/commit/74a30c3de8ff61005fecac53eb007e8a616a9229))
- chore(deps): update all non-major dependencies ([#1163](https://github.com/block/buzz/pull/1163)) ([`a255a7735`](https://github.com/block/buzz/commit/a255a7735855048065b3cc4e530b4af5a70f444d))
- chore(deps): update rust crate anyhow to v1.0.103 ([#1525](https://github.com/block/buzz/pull/1525)) ([`26d609cbe`](https://github.com/block/buzz/commit/26d609cbe1770b3bb5809c57bb0d66e775e6ac63))
- chore(deps): update cashapp/activate-hermit digest to cea9af7 ([#1522](https://github.com/block/buzz/pull/1522)) ([`19d98319a`](https://github.com/block/buzz/commit/19d98319a811cbc63dd34300ac732fa9941f054a))
- chore(deps): update rust crate chrono to v0.4.45 ([#1050](https://github.com/block/buzz/pull/1050)) ([`3caa66d4f`](https://github.com/block/buzz/commit/3caa66d4f047fced63626667261372d49d3c04a9))
- chore(deps): update actions/cache digest to caa2961 ([#1341](https://github.com/block/buzz/pull/1341)) ([`8dea817fa`](https://github.com/block/buzz/commit/8dea817fa97b384b7ea8a2f20789300d5e0a8c60))
- chore(deps): pin dependencies ([#1162](https://github.com/block/buzz/pull/1162)) ([`4b01040af`](https://github.com/block/buzz/commit/4b01040afb3cc778f3a2a1d8f89af37352b2ef35))
- feat(desktop): replace placeholder icon with actual app icon on welcome screen ([#1527](https://github.com/block/buzz/pull/1527)) ([`5b88e5956`](https://github.com/block/buzz/commit/5b88e595657cf4ffc740e30b187439fe53547331))
- feat(desktop): enable native spellcheck in message composer ([#1515](https://github.com/block/buzz/pull/1515)) ([`a1b5983e5`](https://github.com/block/buzz/commit/a1b5983e5400a6bd9f4c8056086de616931e33b4))
- fix: let agent owners delete their agent's messages (relay kind:5 + desktop/mobile UX) ([#1519](https://github.com/block/buzz/pull/1519)) ([`642800548`](https://github.com/block/buzz/commit/6428005487f0690019dc27449a6a52cc29cc6479))
- chore(desktop): bring ChannelScreen back under the size gate ([#1520](https://github.com/block/buzz/pull/1520)) ([`8174f2b0a`](https://github.com/block/buzz/commit/8174f2b0a0aee3d0252ef8923a043b2aa2773499))
- fix(zoom) desktop chrome clearance under text zoom ([#1490](https://github.com/block/buzz/pull/1490)) ([`ee4c9ef13`](https://github.com/block/buzz/commit/ee4c9ef139724a3cb82841d449674e6c96932b7b))
- fix(activity panel): handle back navigation ([#1487](https://github.com/block/buzz/pull/1487)) ([`caf644eda`](https://github.com/block/buzz/commit/caf644eda730c8472183e6801d428f3fd8c05ef7))
- Port channel windows to mobile ([#1518](https://github.com/block/buzz/pull/1518)) ([`6716cd31a`](https://github.com/block/buzz/commit/6716cd31a5303f3964a7acac8159c365d28f55f0))
- perf: GIN index for e-tag containment + delta profile fetch (scroll-back ~2.1s/page) ([#1514](https://github.com/block/buzz/pull/1514)) ([`33886e3de`](https://github.com/block/buzz/commit/33886e3dec4130e512d8242207adfe2811a92579))
- GUI read-model overhaul: server-assembled channel windows (Correct™ pagination + relay-signed bounds) ([#1500](https://github.com/block/buzz/pull/1500)) ([`62bb9fe8c`](https://github.com/block/buzz/commit/62bb9fe8c81eee6573c434ffa3227fa96ad9dd4b))
- feat(desktop): show activity timestamps on demand ([#1506](https://github.com/block/buzz/pull/1506)) ([`c7a8b0bab`](https://github.com/block/buzz/commit/c7a8b0babb35e296fbc4a1fc3df0015518085d32))
- feat(reconnect): replace top banner with animated sidebar overlay ([#1510](https://github.com/block/buzz/pull/1510)) ([`f35aeb798`](https://github.com/block/buzz/commit/f35aeb7986b2751f3ff2e4e33403aa5c28af0b43))
- docs(nest-skill): explain agent-owned git repos and automatic auth ([#1437](https://github.com/block/buzz/pull/1437)) ([`20d3cdc22`](https://github.com/block/buzz/commit/20d3cdc22f4a53a792c4b249ef0338ff6411581d))
- fix(agent): make stop-hook rejection budget per-prompt, fix stale hook docs ([#1503](https://github.com/block/buzz/pull/1503)) ([`4a0951042`](https://github.com/block/buzz/commit/4a095104297a58e50d3ffb0ce600162b9a298323))
## v0.3.42
- fix(desktop): bound read-state localStorage growth and recover from quota errors ([#1502](https://github.com/block/buzz/pull/1502)) ([`a3cf7eec1`](https://github.com/block/buzz/commit/a3cf7eec1d5c876c65aabc827620ca2b7b79127b))
- Customize macOS DMG installer ([#1496](https://github.com/block/buzz/pull/1496)) ([`bbf8d8912`](https://github.com/block/buzz/commit/bbf8d8912a19d9f8f249b3b7897999e343f803dd))
- mobile: thread scroll-to-bottom and desktop-parity mention autocomplete ([#1499](https://github.com/block/buzz/pull/1499)) ([`e9318f66d`](https://github.com/block/buzz/commit/e9318f66d8fe5bff1df8afb7a2dcc36baf0876ac))
- fix(agent): honor stop hook retry budget ([#1501](https://github.com/block/buzz/pull/1501)) ([`1c297d2f2`](https://github.com/block/buzz/commit/1c297d2f26688c35718e90a875e0bece41392253))
- feat(profile): embed live activity feed in profile aux panel ([#1380](https://github.com/block/buzz/pull/1380)) ([`654b6c374`](https://github.com/block/buzz/commit/654b6c374b3f8a2f2a59c72f604bc5de4546f53f))
- feat(desktop): contribution heatmap and graphical cards on projects overview ([#1497](https://github.com/block/buzz/pull/1497)) ([`204a0fd2e`](https://github.com/block/buzz/commit/204a0fd2ec2332cdbad7279be537aee27f20a70d))
- feat(desktop): repository-first projects with git workflows ([#1471](https://github.com/block/buzz/pull/1471)) ([`8e3c0ee95`](https://github.com/block/buzz/commit/8e3c0ee958af8777ba54fd835de03b0e8eada531))
- fix(desktop): lock horizontal webview pan (Magic Mouse side-scroll) ([#1480](https://github.com/block/buzz/pull/1480)) ([`5d4edf153`](https://github.com/block/buzz/commit/5d4edf1535c4b19543e1f57722f4dc94b5af675c))
- Add agent catalog modal ([#1302](https://github.com/block/buzz/pull/1302)) ([`228122fdd`](https://github.com/block/buzz/commit/228122fddcc35cef0cb69c89892276c9b5986c25))
- fix(desktop): actually close relay sockets — plugin:websocket|disconnect does not exist ([#1481](https://github.com/block/buzz/pull/1481)) ([`e70dd1a1e`](https://github.com/block/buzz/commit/e70dd1a1e70cc0f56aa6094e4da22cd91e3203b0))
- feat(buzz-agent): config parity — thinking effort, model switching, normalized token limits ([#1470](https://github.com/block/buzz/pull/1470)) ([`3e282a241`](https://github.com/block/buzz/commit/3e282a24181aeef1c2cf06d4e4bc3b1c87f60067))
- fix(sidebar): scope channel sections storage to relay URL ([#1477](https://github.com/block/buzz/pull/1477)) ([`f9d06ae21`](https://github.com/block/buzz/commit/f9d06ae21aaa20c3de299a6f7065863e9e8d76b3))
- chore(mobile): declare non-exempt encryption usage in Info.plist ([#1474](https://github.com/block/buzz/pull/1474)) ([`453b8b1e4`](https://github.com/block/buzz/commit/453b8b1e47d0a13fb568b2f9b797113c4d663c0f))
- fix(desktop): bind channel and thread context at compose time to prevent wrong-channel send ([#1472](https://github.com/block/buzz/pull/1472)) ([`d369ca9df`](https://github.com/block/buzz/commit/d369ca9df1248e2ee16a40b2a193bf08dd8126c4))
- fix(relay-reconnect): resilient reconnect with fast-path, escalation, and polling ([#1456](https://github.com/block/buzz/pull/1456)) ([`02ff06cac`](https://github.com/block/buzz/commit/02ff06cac230ae708e1dffaa10bf3f71351ae582))
- feat: per-community workspace icon set by admins, served via NIP-11 ([#1463](https://github.com/block/buzz/pull/1463)) ([`5bfd5ca27`](https://github.com/block/buzz/commit/5bfd5ca2700483498e83224a40a5628a29cf2e9e))
- perf(relay): batch outbound websocket data frames ([#1464](https://github.com/block/buzz/pull/1464)) ([`01b92faa1`](https://github.com/block/buzz/commit/01b92faa156648835f143e84583b8ec3bd7490ab))
- Make reaction ingest atomic ([#1458](https://github.com/block/buzz/pull/1458)) ([`835302cc8`](https://github.com/block/buzz/commit/835302cc829c8a63bf254d3e40156fc446e040f6))
- Serialize fan-out EVENT frames once ([#1459](https://github.com/block/buzz/pull/1459)) ([`3c661fb48`](https://github.com/block/buzz/commit/3c661fb48f81c294b529592d2b2ff874bf96ee96))
- fix: agent reliability — no restart on channel-add, visible dead-letter notice ([#1468](https://github.com/block/buzz/pull/1468)) ([`d9c4e4aa7`](https://github.com/block/buzz/commit/d9c4e4aa7fb5634f19d96f31da9f602951f503d0))
- fix(profile): consolidate agent profile runtime metadata ([#1451](https://github.com/block/buzz/pull/1451)) ([`c48006fc3`](https://github.com/block/buzz/commit/c48006fc3e2e3a2a7984be1f02f6e6ba9d11b8eb))
- fix(desktop): simplify workspace rail badges ([#1462](https://github.com/block/buzz/pull/1462)) ([`e42dae3f9`](https://github.com/block/buzz/commit/e42dae3f9ce767e832e1b954875e3bf3e662d35c))
- perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots ([#1452](https://github.com/block/buzz/pull/1452)) ([`deb3e6adc`](https://github.com/block/buzz/commit/deb3e6adcaeb744439e794a71090d2d1dcfc004c))
- perf(relay): bounded-concurrency multi-filter query execution (S2) ([#1457](https://github.com/block/buzz/pull/1457)) ([`a9e752e25`](https://github.com/block/buzz/commit/a9e752e2540a94d304a51ddeecbf68464ca9ec69))
- fix(desktop): classify timeline prepends so history loads don't bump unread ([#1416](https://github.com/block/buzz/pull/1416)) ([`9967b97f5`](https://github.com/block/buzz/commit/9967b97f59179a0261ef5e2046df9632652be619))
- fix(desktop): quiet gate for workspace switches instead of boot splash ([#1449](https://github.com/block/buzz/pull/1449)) ([`b779a3ee2`](https://github.com/block/buzz/commit/b779a3ee2f4fea3599d61511ae82fed439c919ce))
- fix(read-path): reach complete threads, dense-second timelines, and all people in the GUI ([#1418](https://github.com/block/buzz/pull/1418)) ([`7da936fff`](https://github.com/block/buzz/commit/7da936fff82a9a956f338c690e9605888725ea3b))
- E1+E3: reduce relay ingest/fan-out DB round trips; ack p99 −7–16%, fd p99 −6–28%, p999 tails −29–53% vs PR #1453 tip ([#1454](https://github.com/block/buzz/pull/1454)) ([`a504ad619`](https://github.com/block/buzz/commit/a504ad6197558575c0db7b9f53806d7337e0c64f))
- perf(relay): defer post-commit dispatch and avoid verify clone ([#1453](https://github.com/block/buzz/pull/1453)) ([`7bd3760c8`](https://github.com/block/buzz/commit/7bd3760c82a6d640af199ed2301525877e629ced))
- fix(relay): include git hook tools in runtime image ([#1326](https://github.com/block/buzz/pull/1326)) ([`88c089d3b`](https://github.com/block/buzz/commit/88c089d3b652bc952adbe8b32a6fc585121c982f))
- feat(chart): per-pod emptyDir git scratch when persistence disabled (multi-replica HA) ([#1450](https://github.com/block/buzz/pull/1450)) ([`c88799ac6`](https://github.com/block/buzz/commit/c88799ac6c3b5b149196223abe7a6134c8823359))
- fix(relay): remove media bearer-token auth ([#1444](https://github.com/block/buzz/pull/1444)) ([`0701f47f4`](https://github.com/block/buzz/commit/0701f47f4a31a904ebcd9f360cbd6aadaff9d784))
- fix(desktop): stop search shortcut from hijacking the sidebar ([#1447](https://github.com/block/buzz/pull/1447)) ([`15ad7ae87`](https://github.com/block/buzz/commit/15ad7ae87e655a7873fa0b0a51f53b5f800afd9b))
- fix(ci): set PGSCHEMA_PLAN_* in start-relay-for-tests.sh to avoid embedded-PG fetch ([#1443](https://github.com/block/buzz/pull/1443)) ([`89c4f7657`](https://github.com/block/buzz/commit/89c4f76579944ae1c7f86e37d76cecf2b378e60e))
- feat(desktop): restore observer-feed regressions from #1381 and classify 4 new session/update types ([#1412](https://github.com/block/buzz/pull/1412)) ([`fec768436`](https://github.com/block/buzz/commit/fec76843665aeb4aff06688f83b93fc6ed2b603d))
- fix(desktop): disable spellcheck/autocorrect/autocapitalize on emoji picker search ([#1438](https://github.com/block/buzz/pull/1438)) ([`bdeab23b5`](https://github.com/block/buzz/commit/bdeab23b59ca8e622e5c23b3368ecaccf21c5652))
- feat(relay): add OpenTelemetry tracing, keep Prometheus metrics ([#1398](https://github.com/block/buzz/pull/1398)) ([`b1d9d955d`](https://github.com/block/buzz/commit/b1d9d955de83538c231a3034bf190af6df03070d))
- feat(buzz-agent): emit agent_thought_chunk for reasoning content ([#1436](https://github.com/block/buzz/pull/1436)) ([`9f2a11b33`](https://github.com/block/buzz/commit/9f2a11b33827036d7b7415ede72c1dacd0fcd6f9))
- feat(git): move repo-name registry to Postgres + relax RWM chart gate (HA relay) ([#1432](https://github.com/block/buzz/pull/1432)) ([`e5aa4a213`](https://github.com/block/buzz/commit/e5aa4a21327438c02fb25baea4d0849a498c9059))
## v0.3.41
- Group consecutive desktop messages ([#1429](https://github.com/block/buzz/pull/1429)) ([`3d08c3b0`](https://github.com/block/buzz/commit/3d08c3b02b284d062e2932df8f96e2467bb40946))
- Update Buzz app icon ([#1430](https://github.com/block/buzz/pull/1430)) ([`db2a9701`](https://github.com/block/buzz/commit/db2a97011536e810abdc3f813ec1867cbcef21f6))
- feat(desktop): add workspace rail unread observer ([#1428](https://github.com/block/buzz/pull/1428)) ([`ce1f13e8`](https://github.com/block/buzz/commit/ce1f13e8f7a36621621d9249f7535a33439cfb87))
- Prioritize channel members in mention autocomplete ([#1431](https://github.com/block/buzz/pull/1431)) ([`8fb33bdb`](https://github.com/block/buzz/commit/8fb33bdbca4285cac379ad18d42533859eacbd12))
- Tighten message density ([#1426](https://github.com/block/buzz/pull/1426)) ([`6a08a3f4`](https://github.com/block/buzz/commit/6a08a3f4c8f79c2ed4e9ff7e425f3faa0b3627e5))
- Tighten sidebar section actions ([#1424](https://github.com/block/buzz/pull/1424)) ([`c0e10d67`](https://github.com/block/buzz/commit/c0e10d67e06d405829ad8c4682b3cb621702d150))
- Add memory copy actions ([#1427](https://github.com/block/buzz/pull/1427)) ([`6c0e6f0b`](https://github.com/block/buzz/commit/6c0e6f0bb74fe0b4777afbdb8151dddf7107c99f))
- Limit agent profile quick actions ([#1425](https://github.com/block/buzz/pull/1425)) ([`177fe5a3`](https://github.com/block/buzz/commit/177fe5a3bfe7774205ca0a9de689924a5df988df))
- Fade sidebar pinned chrome edges ([#1423](https://github.com/block/buzz/pull/1423)) ([`3e4e9dda`](https://github.com/block/buzz/commit/3e4e9dda94e04a67b6b73c372e853e961cca461d))
- Apply smooth corners to inline media ([#1422](https://github.com/block/buzz/pull/1422)) ([`2fc8b9cf`](https://github.com/block/buzz/commit/2fc8b9cf5f14410750a97bbebc07241ce8628a1f))
- Align thread summary rows ([#1421](https://github.com/block/buzz/pull/1421)) ([`4ae5a0d5`](https://github.com/block/buzz/commit/4ae5a0d5e372c5617aa32242c8e08c13a03d20b5))
- Keep channel day dividers sticky ([#1420](https://github.com/block/buzz/pull/1420)) ([`8c388479`](https://github.com/block/buzz/commit/8c3884791361933f89e6771e1403e4799fc00c1d))
- fix(deps): pin aws-creds to fork with EKS Pod Identity support ([#1419](https://github.com/block/buzz/pull/1419)) ([`86d6388e`](https://github.com/block/buzz/commit/86d6388e68d40aaa5449e3021e116a57cb2aefe0))
- fix(relay): enable Redis TLS for rediss:// (ElastiCache) ([#1417](https://github.com/block/buzz/pull/1417)) ([`3292b502`](https://github.com/block/buzz/commit/3292b502aad44a5f849296d7bf28429bac272fb7))
## v0.3.40
- fix(desktop): stabilize channel-timeline scrollback with per-row height reserves ([#1413](https://github.com/block/buzz/pull/1413)) ([`4fdc68f1`](https://github.com/block/buzz/commit/4fdc68f1568364b3e44d7003c83a9a4ad961e1ee))
- fix(sidebar): trim working badge label and name working agents in tooltip ([#1408](https://github.com/block/buzz/pull/1408)) ([`697f63dd`](https://github.com/block/buzz/commit/697f63ddcc45df71e48a0c0ac81adada67a056e1))
- Mobile tab bar polish ([#1368](https://github.com/block/buzz/pull/1368)) ([`c444e344`](https://github.com/block/buzz/commit/c444e3445bba4967246f790a296237cc597336a9))
- feat(desktop): let thread pane expand on ultrawide monitors ([#1407](https://github.com/block/buzz/pull/1407)) ([`f86f97bb`](https://github.com/block/buzz/commit/f86f97bbde1e3ee914ef2d6ef385482f15a43d6a))
## v0.3.39
- fix: close cross-process keychain race and namespace dev-build nest ([#1409](https://github.com/block/buzz/pull/1409)) ([`e8adc3383`](https://github.com/block/buzz/commit/e8adc3383fae3060124ed212d00538d543be0054))
- feat(relay): allow agent owners to edit/manage agent-owned content ([#1403](https://github.com/block/buzz/pull/1403)) ([`0042c8e10`](https://github.com/block/buzz/commit/0042c8e106d952f408cf4afca052f7053a7c967e))
- fix(media): support IRSA/credential-chain S3 auth and configurable signing region ([#1406](https://github.com/block/buzz/pull/1406)) ([`06ef533ec`](https://github.com/block/buzz/commit/06ef533ec7fec6cf7366f52d3b9fe2f83011bf24))
- fix(desktop): fold baked build env into in-process model discovery ([#1376](https://github.com/block/buzz/pull/1376)) ([`f061ae9c8`](https://github.com/block/buzz/commit/f061ae9c879e95f650fb99347920763361d1fe22))
- docs: link VISION_ACTIVITY from the VISION index ([#1405](https://github.com/block/buzz/pull/1405)) ([`aa1042b16`](https://github.com/block/buzz/commit/aa1042b1629466a94983d098e706a3905c645e9d))
- test(desktop): gate keychain-write test behind --ignored ([#1404](https://github.com/block/buzz/pull/1404)) ([`36f32291e`](https://github.com/block/buzz/commit/36f32291e79fc2069b8ef4cae3c90915232f702a))
- Refactor oversized mobile widgets ([#1401](https://github.com/block/buzz/pull/1401)) ([`71b2c41e9`](https://github.com/block/buzz/commit/71b2c41e927ca9c4aa2fd0f0c4662d4c8de6479a))
- perf(desktop): stop beachballs on agents menu and thread open ([#1402](https://github.com/block/buzz/pull/1402)) ([`34d61acc9`](https://github.com/block/buzz/commit/34d61acc9545676bb33e1675de2ab9f226242a29))
- feat(desktop): rebuild agent activity feed around a classifier registry and twelve render classes ([#1381](https://github.com/block/buzz/pull/1381)) ([`5cc09e698`](https://github.com/block/buzz/commit/5cc09e6989198df3918db8bc67e3bf020e6e0838))
- fix(thread): stop mid-scroll content jump in live threads ([#1397](https://github.com/block/buzz/pull/1397)) ([`42ec17f13`](https://github.com/block/buzz/commit/42ec17f1375f8886cda817921addda2c3bf55fd9))
- fix(ci): restore main to green — tauri fmt, personas.rs file-size split, Windows path test ([#1399](https://github.com/block/buzz/pull/1399)) ([`67c47de69`](https://github.com/block/buzz/commit/67c47de69c8a0028bcc35c1f5e9de489a8df9fb5))
- fix(desktop): enable buzz-dev-mcp MCP server for Codex agents ([#1394](https://github.com/block/buzz/pull/1394)) ([`b74ed858f`](https://github.com/block/buzz/commit/b74ed858ff337b9db186215b9cbbe43c89d63132))
- fix(ci): restore E2E flakiness fixes for pgschema, docker-pull, and spec timing ([#1396](https://github.com/block/buzz/pull/1396)) ([`fdf29c457`](https://github.com/block/buzz/commit/fdf29c457d0a4b10f99a703130786f71f724aa69))
- fix(personas): persist pack-backed persona UI edits across reboot ([#1392](https://github.com/block/buzz/pull/1392)) ([`a7e1202cc`](https://github.com/block/buzz/commit/a7e1202cc545983d2fc1449dcca2d778d5c0f88d))
- fix(buzz-acp): clear steer_rx on all run_prompt_task exit paths ([#1391](https://github.com/block/buzz/pull/1391)) ([`10aaa72f0`](https://github.com/block/buzz/commit/10aaa72f017b2d4cb1423de10c36718097fcb6c0))
- Restore channel date divider rule ([#1395](https://github.com/block/buzz/pull/1395)) ([`0827171d5`](https://github.com/block/buzz/commit/0827171d5e1db6332bfb819fcf4d8441ac82be1e))
- Speed up profile wave action ([#1379](https://github.com/block/buzz/pull/1379)) ([`2fc5dd35b`](https://github.com/block/buzz/commit/2fc5dd35b1cf48297613f7c7caba811f5248b431))
- Restore visible links for rich previews ([#1378](https://github.com/block/buzz/pull/1378)) ([`8d8fc6331`](https://github.com/block/buzz/commit/8d8fc6331f11da2904c2798c56c7833c13dfa090))
- Mobile channel list polish ([#1367](https://github.com/block/buzz/pull/1367)) ([`09d8965ab`](https://github.com/block/buzz/commit/09d8965ab686e5c910b7a04a20df58488f50a60c))
- style(desktop): unify corner radii to rounded-2xl (16px) ([#1393](https://github.com/block/buzz/pull/1393)) ([`2f496debc`](https://github.com/block/buzz/commit/2f496debce4961c88a20d7c0b497ec99626413b2))
- fix(desktop): skip keychain write when blob contents are unchanged ([#1377](https://github.com/block/buzz/pull/1377)) ([`5a64ee4a6`](https://github.com/block/buzz/commit/5a64ee4a69d46d9cef816a04c14b9abc9313eaa6))
- fix(desktop): stop clipping the agent-activity row under the composer ([#1371](https://github.com/block/buzz/pull/1371)) ([`2044ad76f`](https://github.com/block/buzz/commit/2044ad76f3dfd9f260a48cef176bed3b054f637d))
- Constrain macOS overscroll to conversations ([#1317](https://github.com/block/buzz/pull/1317)) ([`9b711f47a`](https://github.com/block/buzz/commit/9b711f47a791810a7c46069648d1a280b9739c6e))
- Mobile appearance foundation ([#1366](https://github.com/block/buzz/pull/1366)) ([`945e9b879`](https://github.com/block/buzz/commit/945e9b879ae2935312698a1e913cfb3009f8c10d))
## v0.3.38
- feat(desktop): provider-agnostic model selection + databricks discovery ([#1307](https://github.com/block/buzz/pull/1307)) ([`eacbbe880`](https://github.com/block/buzz/commit/eacbbe880a50acf400ff7c162b5bc8705ab0063f))
- release(helm): buzz chart 0.1.1 ([#1374](https://github.com/block/buzz/pull/1374)) ([`2561cbd06`](https://github.com/block/buzz/commit/2561cbd069a4f7a0ca4824f780867aa30ea9f744))
- Harden relay attack surfaces ([#1369](https://github.com/block/buzz/pull/1369)) ([`29368cf17`](https://github.com/block/buzz/commit/29368cf17b7d5924fe571512b2194e3f48b21a16))
- ci(helm): publish chart to GHCR on chart-v* tags ([#1372](https://github.com/block/buzz/pull/1372)) ([`2722ce422`](https://github.com/block/buzz/commit/2722ce4226838272ab36dc8630feacd2a90e1775))
- feat(buzz-agent): add databricks_v2 provider for AI Gateway v2 ([#1311](https://github.com/block/buzz/pull/1311)) ([`15a73aa27`](https://github.com/block/buzz/commit/15a73aa27feb9db72c9535a8b1113189d2be8dd4))
- refactor(desktop): centralize auxiliary panel shell ([#1343](https://github.com/block/buzz/pull/1343)) ([`e6738c501`](https://github.com/block/buzz/commit/e6738c50153cdb3f78448c912a2e8cd660da5779))
- perf(desktop): stop typing from re-rendering the channel pane + timeline ([#1364](https://github.com/block/buzz/pull/1364)) ([`db1b617ab`](https://github.com/block/buzz/commit/db1b617ab12792b6c5d90d988e52b9a86d7aa361))
- fix(desktop): check PGID in orphan sweep and signal correct process groups ([#1359](https://github.com/block/buzz/pull/1359)) ([`59be27ff3`](https://github.com/block/buzz/commit/59be27ff3fa4c0b0b95688565e945f6f5e0d60ef))
- feat(desktop): harness-agnostic config bridge ([#887](https://github.com/block/buzz/pull/887)) ([`c65989a61`](https://github.com/block/buzz/commit/c65989a61bba7050e4fbc1798630f96d541ab8db))
- fix(acp): enable sandbox network access for Codex MCP subprocesses ([#1363](https://github.com/block/buzz/pull/1363)) ([`401bb51bf`](https://github.com/block/buzz/commit/401bb51bf2dd64776a1862575548cb25ab41ff70))
- perf(desktop): don't block channel create on channel-list refetch ([#1360](https://github.com/block/buzz/pull/1360)) ([`cf57bcbea`](https://github.com/block/buzz/commit/cf57bcbea4af355774d499e109238a5d527ae39e))
- refactor(desktop): split global styles and markdown renderers ([#1361](https://github.com/block/buzz/pull/1361)) ([`c27b4251a`](https://github.com/block/buzz/commit/c27b4251a9304911f2359a3ee22b6f26b13af78b))
- feat(read-state): multi-slot splitting + no-op suppression for oversized blobs ([#1309](https://github.com/block/buzz/pull/1309)) ([`2612324fd`](https://github.com/block/buzz/commit/2612324fd935b3f19ef78e07885aa05f7ef907f1))
- fix: unconditionally replace CLI symlink on boot ([#1357](https://github.com/block/buzz/pull/1357)) ([`50873ef98`](https://github.com/block/buzz/commit/50873ef98d9080614720cd770781698c9b16313d))
## v0.3.37
- feat(buzz-acp): steering as the default mid-turn mention delivery ([#1160](https://github.com/block/buzz/pull/1160)) ([`e567491a`](https://github.com/block/buzz/commit/e567491a196396658ef4c1d4ff6128efd8b2744e))
- Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) ([#1321](https://github.com/block/buzz/pull/1321)) ([`14fba21e`](https://github.com/block/buzz/commit/14fba21e57b8d671ebbea473226be52a5f2ae636))
- Disable persona start while runtime discovery runs ([#1353](https://github.com/block/buzz/pull/1353)) ([`92a5f1fc`](https://github.com/block/buzz/commit/92a5f1fc6624066f391bd5c57e9bb433df613b56))
- chore(deps): update dependency @tanstack/react-virtual to v3.14.4 ([#1342](https://github.com/block/buzz/pull/1342)) ([`1c221eaa`](https://github.com/block/buzz/commit/1c221eaafd1c8537532c3c8ce76c502330b00bf7))
- Fix sidebar unread indicator placement ([#1319](https://github.com/block/buzz/pull/1319)) ([`51b2613c`](https://github.com/block/buzz/commit/51b2613c0e42327d9ba0352c3bb3be7e7c4737a6))
- fix(desktop): un-clip hover action bar's upward bleed under content-visibility ([#1354](https://github.com/block/buzz/pull/1354)) ([`7adbd05b`](https://github.com/block/buzz/commit/7adbd05b45db3db0abb7cc1f5eb0dc7248d47711))
- Allow Huddle between 2 humans in DM ([#1347](https://github.com/block/buzz/pull/1347)) ([`b58f671b`](https://github.com/block/buzz/commit/b58f671b4ee3d9f6a8fd51ca23f0dc8b8036ed36))
## v0.3.36
- Polish agent runtime cards ([#1327](https://github.com/block/buzz/pull/1327)) ([`a3c4f3f6`](https://github.com/block/buzz/commit/a3c4f3f625a23addb6053c12f11f79f583c394c1))
- Rework desktop message-timeline scrolling: de-virtualize + native overflow-anchor ([#1338](https://github.com/block/buzz/pull/1338)) ([`4d619693`](https://github.com/block/buzz/commit/4d6196934e1f082ff5f16277edd33e3108aee38f))
- Keep wave huddles pending for placeholder profiles ([#1349](https://github.com/block/buzz/pull/1349)) ([`c1d6f3f2`](https://github.com/block/buzz/commit/c1d6f3f291b0f419bf0666f258d4689041747327))
- Polish profile hover cards ([#1346](https://github.com/block/buzz/pull/1346)) ([`e3fc0e02`](https://github.com/block/buzz/commit/e3fc0e0278d76b545adb6e7921387da9eeb7fdc3))
- Fix channel header shared blur layering ([#1336](https://github.com/block/buzz/pull/1336)) ([`360d2e54`](https://github.com/block/buzz/commit/360d2e54409eec21ec59a31c9a12d9d7821506a3))
- Add rich link previews ([#1334](https://github.com/block/buzz/pull/1334)) ([`bc925780`](https://github.com/block/buzz/commit/bc925780eed2169dd6dba9a2c9f85d05e2c9de1b))
- Revamp new agent dialog ([#1201](https://github.com/block/buzz/pull/1201)) ([`826d735f`](https://github.com/block/buzz/commit/826d735fe6712be820616d4cb1d6228cfe3be47e))
- Image attachment gallery lightbox ([#1345](https://github.com/block/buzz/pull/1345)) ([`7d3ee683`](https://github.com/block/buzz/commit/7d3ee6833665a4370db51b1ce0fa13f7174d9279))
- Reset prevent-sleep cap on agent activity ([#1335](https://github.com/block/buzz/pull/1335)) ([`433b1794`](https://github.com/block/buzz/commit/433b1794d55b568f8a1331d18536d136a1a03463))
- docs: update sprout repository references and document buzz mem ([#1333](https://github.com/block/buzz/pull/1333)) ([`52b6365e`](https://github.com/block/buzz/commit/52b6365e17529f385864433210985813023476ff))
- fix(buzz-agent): charge images a token-equivalent for the handoff gate ([#1332](https://github.com/block/buzz/pull/1332)) ([`744c77bc`](https://github.com/block/buzz/commit/744c77bc2d2db7d57a07a7701042db73ca40faa2))
- Polish thread reply hover states ([#1329](https://github.com/block/buzz/pull/1329)) ([`aca40b62`](https://github.com/block/buzz/commit/aca40b62899248359c2587efaae80c44009b87bb))
- Add persona and team import polish ([#1203](https://github.com/block/buzz/pull/1203)) ([`f00c86e7`](https://github.com/block/buzz/commit/f00c86e7446b6addfc83870f7d0571373642afd4))
- fix(desktop): reserve PTT shortcut only during active huddle ([#1315](https://github.com/block/buzz/pull/1315)) ([`6c60cb59`](https://github.com/block/buzz/commit/6c60cb59abb3e086281dd470390ba7e87bc5ab25))
## v0.3.35
- fix(desktop): split lib modules under size guard ([#1314](https://github.com/block/buzz/pull/1314)) ([`e7d43dc2`](https://github.com/block/buzz/commit/e7d43dc2253f0d1efe7689ac82a8b6b4a7788fd0))
- Fix desktop notifications on GNOME 46+ Linux ([#1246](https://github.com/block/buzz/pull/1246)) ([`ca50d832`](https://github.com/block/buzz/commit/ca50d832892f6203a59eeaadf3fe7b7e8b8e9888))
- perf(desktop): debounce channel-list refetch + profile get_channels ([#1310](https://github.com/block/buzz/pull/1310)) ([`c6e3e947`](https://github.com/block/buzz/commit/c6e3e947abc2592eea08c7601adc76afe0c14c95))
- feat(desktop): move agent management into profile sidebar ([#1274](https://github.com/block/buzz/pull/1274)) ([`8d40150c`](https://github.com/block/buzz/commit/8d40150c8377df08dbaed61de987ca69d45d15e0))
- feat(desktop): re-land virtualized timeline to fix macOS beachball ([#1250](https://github.com/block/buzz/pull/1250)) ([`8c3d0c92`](https://github.com/block/buzz/commit/8c3d0c92e83f1b482dbd93f2bd0d307988c91788))
- feat(acp): add BUZZ_ACP_ALLOWED_RESPOND_TO and BUZZ_ALLOWED_CHANNEL_ADD_POLICIES gates ([#1304](https://github.com/block/buzz/pull/1304)) ([`1a61d783`](https://github.com/block/buzz/commit/1a61d783ad072eefd03bb070be66ec2cf889dbde))
- fix(read-state): enforce byte-budget eviction in currentContexts() ([#1305](https://github.com/block/buzz/pull/1305)) ([`6b056461`](https://github.com/block/buzz/commit/6b0564618bafd3737778511b291e3b80ab7fc43e))
- feat(media): transcode HEIC/HEIF to JPEG on desktop upload ([#1257](https://github.com/block/buzz/pull/1257)) ([`d32f3c0a`](https://github.com/block/buzz/commit/d32f3c0a58dc3a3ce52b72cc0a8899228f17d69e))
- Bring mobile sidebar unread and DM parity ([#1303](https://github.com/block/buzz/pull/1303)) ([`1843a057`](https://github.com/block/buzz/commit/1843a057477129e392518dcc62a9814717a67c0b))
- Multi-tenant relay: spec + mechanized formal proof (S1–S8) ([#1285](https://github.com/block/buzz/pull/1285)) ([`2ecdcce7`](https://github.com/block/buzz/commit/2ecdcce7bdb5471cddf79cb5d4ce486a75ed2fda))
- Polish side panel motion and composer alignment ([#1294](https://github.com/block/buzz/pull/1294)) ([`34b2d3e8`](https://github.com/block/buzz/commit/34b2d3e8b43af132111e878288f1c4b4aa7777dd))
- feat(mobile): harden unread badges and float tabs ([#1298](https://github.com/block/buzz/pull/1298)) ([`59b21592`](https://github.com/block/buzz/commit/59b21592c92d55f6bd13524697f9cef257d9e4fe))
- feat(desktop): restore archive identity UI in profile panel ([#961](https://github.com/block/buzz/pull/961)) ([`4fce5aab`](https://github.com/block/buzz/commit/4fce5aab2e09a16a954cf6b685b7e2c1e897bd27))
- fix(sidebar): non-selectable channel names + copy/leave context menu actions ([#1260](https://github.com/block/buzz/pull/1260)) ([`4481f8fd`](https://github.com/block/buzz/commit/4481f8fd5a9cd8cd5a482a78ed63e2a34e600066))
- fix(runtime): sweep node wrapper processes hosting managed agent shims ([#1296](https://github.com/block/buzz/pull/1296)) ([`f072032a`](https://github.com/block/buzz/commit/f072032aaf52e216eecf652cd8625367c8c943ce))
- fix(buzz-agent): follow symlinks when discovering skill directories ([#1295](https://github.com/block/buzz/pull/1295)) ([`8717ddf2`](https://github.com/block/buzz/commit/8717ddf2ebc8cc6324996f9e5c16b596d1febc71))
- chore: add grab-emoji.sh to register Slack emoji in Buzz ([#1292](https://github.com/block/buzz/pull/1292)) ([`dfa864fc`](https://github.com/block/buzz/commit/dfa864fc457baa66dc9f4b4f501cccdd0d219e55))
- Fix cross-pod membership notification fanout ([#1291](https://github.com/block/buzz/pull/1291)) ([`e1c51d71`](https://github.com/block/buzz/commit/e1c51d71d48f9ad0a08c3e7e0af35929a0234ea1))
- fix(buzz-acp): strengthen agent communication rules in base prompt ([#1293](https://github.com/block/buzz/pull/1293)) ([`ccdb8975`](https://github.com/block/buzz/commit/ccdb8975d3683cd4710b52443284025aa6d05cd9))
## v0.3.34
- feat(desktop): refresh Agents tab live on inbound relay sync ([#1256](https://github.com/block/buzz/pull/1256)) ([`9a59e308`](https://github.com/block/buzz/commit/9a59e30817c642b1e6aab296863570869e215a19))
- fix(buzz-acp): inject Codex network allowlist for relay hostname at spawn time ([#1287](https://github.com/block/buzz/pull/1287)) ([`0379247e`](https://github.com/block/buzz/commit/0379247eba8a6c7e48a94f3fb8a329e7bdbca13c))
- refactor(desktop): consolidate notification helpers and add channel names to toasts ([#1286](https://github.com/block/buzz/pull/1286)) ([`dd5592e3`](https://github.com/block/buzz/commit/dd5592e34fcd7690340633dcec368db485557294))
- feat(buzz-agent): lazy skill loading via load_skill tool ([#1283](https://github.com/block/buzz/pull/1283)) ([`25396c06`](https://github.com/block/buzz/commit/25396c06d65c5e9a6d1271559cde4230e06ff35d))
- fix(buzz-acp): human-aware reply anchoring to keep threads flat ([#1281](https://github.com/block/buzz/pull/1281)) ([`6c920d21`](https://github.com/block/buzz/commit/6c920d21a852d301effb8a667d824e6b96485a7d))
- feat(desktop): add 'Follow system' theme mode ([#1262](https://github.com/block/buzz/pull/1262)) ([`c996be39`](https://github.com/block/buzz/commit/c996be395e23e50e5097f088738b3de9176759d4))
- Fix mention autocomplete layout in narrow threads ([#1282](https://github.com/block/buzz/pull/1282)) ([`227518c2`](https://github.com/block/buzz/commit/227518c234175440f9cd3734d01b96bbbbc89065))
- Clean up screenshot e2e tests ([#1284](https://github.com/block/buzz/pull/1284)) ([`2499d339`](https://github.com/block/buzz/commit/2499d33971524774f07f5186dadc8b33e055d812))
- fix(desktop): DM close button replaces unread badge on hover ([#1280](https://github.com/block/buzz/pull/1280)) ([`e3663721`](https://github.com/block/buzz/commit/e3663721830d79df6562a0b3ef78c8fe6bc1b7c3))
- Add NIP-34 git pull request CLI support ([#1279](https://github.com/block/buzz/pull/1279)) ([`5fef9b72`](https://github.com/block/buzz/commit/5fef9b727fb8856d45840ccc97d8ec820a48f6e0))
- fix(desktop): preserve timeline scroll when opening threads ([#1278](https://github.com/block/buzz/pull/1278)) ([`d05f122d`](https://github.com/block/buzz/commit/d05f122d8acf49e924fe11992dde4bde41dee3f7))
- chore: remove LLM-slop comments across the codebase ([#1277](https://github.com/block/buzz/pull/1277)) ([`73cc31cc`](https://github.com/block/buzz/commit/73cc31cc528318debedd38e85d67b79d1feb55e8))
- fix(desktop): allow saving personas with an empty system prompt ([#1276](https://github.com/block/buzz/pull/1276)) ([`996a3f89`](https://github.com/block/buzz/commit/996a3f89d5f3835009236782f2b3a93409b090cf))
## v0.3.33
- fix(desktop): always use legacy keyring for blob entry on macOS ([#1271](https://github.com/block/buzz/pull/1271)) ([`e7c3638fe`](https://github.com/block/buzz/commit/e7c3638fe984e7785a6e51e547b5844e894ed013))
- chore(release): release Buzz Relay version 0.1.1 ([#1269](https://github.com/block/buzz/pull/1269)) ([`68a0cc850`](https://github.com/block/buzz/commit/68a0cc8506be4ea1fb110b65eec0787e4cd84378))
- perf(desktop): consolidate keychain secrets into a single blob entry ([#1267](https://github.com/block/buzz/pull/1267)) ([`fa942cb51`](https://github.com/block/buzz/commit/fa942cb51d0714cba54747bcc7197c14e770c338))
- feat(desktop): re-snapshot persona config on every agent spawn ([#1268](https://github.com/block/buzz/pull/1268)) ([`048e8fdc0`](https://github.com/block/buzz/commit/048e8fdc004322e5feccc4fd76c8fc0dc87d91f0))
- feat(relay): add buzz-admin member management CLI with NIP-43 roster publish ([#1265](https://github.com/block/buzz/pull/1265)) ([`0cee0435f`](https://github.com/block/buzz/commit/0cee0435f7f955a42d7824f1967e4f4fc6a02f74))
- fix(desktop): fall back to old keychain when DPK unavailable (unsigned builds) ([#1266](https://github.com/block/buzz/pull/1266)) ([`958ac7aaa`](https://github.com/block/buzz/commit/958ac7aaaae90da76c864af744242f29ee4f08de))
- Align channel management panel with profile ([#1066](https://github.com/block/buzz/pull/1066)) ([`9f35b0188`](https://github.com/block/buzz/commit/9f35b018803f56b2330bfb79de4ab19f18d80256))
- fix(relay): multi-pod subscription coherence (one access-gated fan-out path + cross-pod cache invalidation + REQ/COUNT DB guard) ([#1261](https://github.com/block/buzz/pull/1261)) ([`628445429`](https://github.com/block/buzz/commit/6284454298cb5be72cefbeaf13abed04afe3cc9e))
- fix(desktop): switch macOS keychain to Data Protection Keychain ([#1264](https://github.com/block/buzz/pull/1264)) ([`35522311a`](https://github.com/block/buzz/commit/35522311a963b54f32df864d338ab7d75102247f))
- fix(desktop): use IPv4 loopback for media proxy URLs ([#1245](https://github.com/block/buzz/pull/1245)) ([`cee2c5f26`](https://github.com/block/buzz/commit/cee2c5f2634ae7d15262a2ec07cccacc8460cd0e))
- perf(relay/git): stream read-path, manifest info/refs fast path, idx sidecar (A/B/C) ([#1240](https://github.com/block/buzz/pull/1240)) ([`856815994`](https://github.com/block/buzz/commit/856815994c3441268eb48d2264aeb3492b701fe5))
## v0.3.32
- fix(desktop): restore solid dot for top-level channel unreads ([#1253](https://github.com/block/buzz/pull/1253)) ([`9d3bfd38b`](https://github.com/block/buzz/commit/9d3bfd38b0876cd1bdf5bb90d6639f200ceb12e0))
- Allow explicit root posts from reply prompts ([#1251](https://github.com/block/buzz/pull/1251)) ([`ebd74d11b`](https://github.com/block/buzz/commit/ebd74d11b4e77195fa94158205787e6e5edca3ff))
- feat: publish personas, teams, and managed agents as relay events ([#939](https://github.com/block/buzz/pull/939)) ([`d256935c3`](https://github.com/block/buzz/commit/d256935c3620f30ae54e9d02e48adc6ee0bc578f))
- fix(observability): gate agent observability on declared ownership, not key custody ([#1229](https://github.com/block/buzz/pull/1229)) ([`e993b9e74`](https://github.com/block/buzz/commit/e993b9e741649f73c24f9afbb01420cffc822e5a))
- fix(desktop): split AppShell notification effects ([#1248](https://github.com/block/buzz/pull/1248)) ([`a9ce477a0`](https://github.com/block/buzz/commit/a9ce477a041c7f201b1f7c0b79c68d7b0aa3f38e))
- feat(desktop): store nsec private keys in the OS keyring ([#1172](https://github.com/block/buzz/pull/1172)) ([`4be43d708`](https://github.com/block/buzz/commit/4be43d7086753e843884f5f541c3a8cbf1512fff))
- fix: add evict-completed-work prompt rule and unblock desktop file-size gate ([#1247](https://github.com/block/buzz/pull/1247)) ([`048228889`](https://github.com/block/buzz/commit/048228889122160ce73f9e32aba36961613e984e))
- fix(desktop): scope mention/add autocomplete to reachable identities ([#1243](https://github.com/block/buzz/pull/1243)) ([`a3e8fb6fd`](https://github.com/block/buzz/commit/a3e8fb6fd512f34d53b7ed36e68ab7f8bfa01291))
- test(e2e): raise scroll-history pagination test timeout to 90s ([#1234](https://github.com/block/buzz/pull/1234)) ([`09dbeb86f`](https://github.com/block/buzz/commit/09dbeb86fa8b6f430c79adf7c585194e2ec0a3d0))
- Polish thread and media layout ([#1239](https://github.com/block/buzz/pull/1239)) ([`b264a32cf`](https://github.com/block/buzz/commit/b264a32cfbdc8481e9dd4ceadbb05990bd7af02a))
- Polish desktop navigation chrome ([#1238](https://github.com/block/buzz/pull/1238)) ([`092546495`](https://github.com/block/buzz/commit/092546495d73d305e4d0226ee14bf0d5de4d2f53))
- fix: propagate persona harness edits to live agent instances ([#1244](https://github.com/block/buzz/pull/1244)) ([`2e426b2fd`](https://github.com/block/buzz/commit/2e426b2fddbbec1f1261125c81d83cd57e838fd1))
- fix(desktop): render autolinked message links as chips ([#1241](https://github.com/block/buzz/pull/1241)) ([`45067ec13`](https://github.com/block/buzz/commit/45067ec13507c406ee41d3f184fc96c988b1348b))
- fix(desktop): clear unread projections from message read markers ([#1242](https://github.com/block/buzz/pull/1242)) ([`733c766eb`](https://github.com/block/buzz/commit/733c766eba90733a00dc397dd2ab9706935e5183))
- fix(desktop): resolve repos_dir symlink at boot before agent restore ([#1231](https://github.com/block/buzz/pull/1231)) ([`86d7748eb`](https://github.com/block/buzz/commit/86d7748ebd93027754bfd2326958c4324aa817d6))
- Fix presence fan-out across relay pods ([#1227](https://github.com/block/buzz/pull/1227)) ([`36d3d2ed1`](https://github.com/block/buzz/commit/36d3d2ed1e57e399d5221edfd109082fd8a4d73e))
- fix(relay): raise grace limit, add replay backpressure, and NOTICE on oversized frames ([#1226](https://github.com/block/buzz/pull/1226)) ([`142a5c909`](https://github.com/block/buzz/commit/142a5c909542f1e1d2119ca4562129d492aca94c))
- [codex] Make relay frame limit configurable ([#1225](https://github.com/block/buzz/pull/1225)) ([`38a953343`](https://github.com/block/buzz/commit/38a95334396dae0e271a478b46baa62d29deebff))
## v0.3.31
- fix(release): publish versioned relay Docker tags via independent release lanes ([#1173](https://github.com/block/buzz/pull/1173)) ([`549b7d24`](https://github.com/block/buzz/commit/549b7d24813320045bdda629d865c6c7418e7450))
- fix(desktop): align settings section headers ([#1165](https://github.com/block/buzz/pull/1165)) ([`6ad68a6b`](https://github.com/block/buzz/commit/6ad68a6b095cd5db328ae07dfac4013eeda5820a))
- fix(desktop): ground agent workspace, migrate legacy nest, configurable repos_dir ([#1194](https://github.com/block/buzz/pull/1194)) ([`1011cea2`](https://github.com/block/buzz/commit/1011cea2682a5e8cd92c9da255d5ba6c8f7ced78))
- fix(desktop): enable mesh llm for release builds ([#1221](https://github.com/block/buzz/pull/1221)) ([`fa1262a9`](https://github.com/block/buzz/commit/fa1262a92c85b96c1d643c247d28df4f2f57e81a))
- fix(desktop): move crypto commands off the main thread ([#1222](https://github.com/block/buzz/pull/1222)) ([`e35e84b0`](https://github.com/block/buzz/commit/e35e84b08bdc44f1f5297e6957bcc52e7c31eb70))
- fix: tolerate missing private_key_nsec in agent store ([#1220](https://github.com/block/buzz/pull/1220)) ([`c58e9880`](https://github.com/block/buzz/commit/c58e9880bf60324b0fbb64917b6d1c8e197d4ea4))
- Update navigation header height ([#1212](https://github.com/block/buzz/pull/1212)) ([`6b5cf325`](https://github.com/block/buzz/commit/6b5cf325c2777f64696923cf5b1c1ffd4fdf82e2))
- Improve global search ([#1195](https://github.com/block/buzz/pull/1195)) ([`5130a6a0`](https://github.com/block/buzz/commit/5130a6a0b60c56a5c31549d3d4e85b956e35a671))
- Parse Typesense multi_search errors ([#1208](https://github.com/block/buzz/pull/1208)) ([`65ccb126`](https://github.com/block/buzz/commit/65ccb1262fb876b74584bca1165feef39eda67a6))
- fix(desktop): keep settings shortcut from opening search ([#1204](https://github.com/block/buzz/pull/1204)) ([`89ff9504`](https://github.com/block/buzz/commit/89ff950444d03ea09eb54661a21f0cb96f0bfcb6))
- Polish sidebar channel navigation ([#1213](https://github.com/block/buzz/pull/1213)) ([`c0a872e8`](https://github.com/block/buzz/commit/c0a872e898479bcb2c3dda1b642c0d1373174f68))
- fix(desktop): restore channel unread badges ([#1218](https://github.com/block/buzz/pull/1218)) ([`89aaa264`](https://github.com/block/buzz/commit/89aaa26443486244b6f004a7c419f4e0dc86aa44))
- Fix collapsed home header chrome overlap ([#1215](https://github.com/block/buzz/pull/1215)) ([`b4e75a1e`](https://github.com/block/buzz/commit/b4e75a1e41a614fa3449e814ccbd9f31090dfbfc))
- fix(desktop): dedupe welcome intro per channel ([#1216](https://github.com/block/buzz/pull/1216)) ([`2a522826`](https://github.com/block/buzz/commit/2a522826edc6dfb4df79f34256beea6b8597505b))
- fix(desktop): defer agent page secondary requests ([#1217](https://github.com/block/buzz/pull/1217)) ([`bee2d64c`](https://github.com/block/buzz/commit/bee2d64cf7f093088cc28463e96a6c94b64f280e))
- ci(release): enable Tauri auto-updater on Windows and Linux builds ([#1206](https://github.com/block/buzz/pull/1206)) ([`3ef2a8e5`](https://github.com/block/buzz/commit/3ef2a8e5c7e655f3347931135dde5f65b919c915))
- Hydrate reactions for rendered messages ([#1205](https://github.com/block/buzz/pull/1205)) ([`ed556f3d`](https://github.com/block/buzz/commit/ed556f3deb895e0adfa18274b3ed90f255b5f6ad))
- fix(desktop): show NIP-OA owners in profile pane ([#1198](https://github.com/block/buzz/pull/1198)) ([`40070a58`](https://github.com/block/buzz/commit/40070a58559938ed649950ccabce0725dd3c966e))
- fix(desktop): preserve login-shell PATH for managed agents ([#1193](https://github.com/block/buzz/pull/1193)) ([`29978b6f`](https://github.com/block/buzz/commit/29978b6f93cdd2c5d061093ddce87d567d8d4c17))
- Fix nav chrome offset in fullscreen ([#1192](https://github.com/block/buzz/pull/1192)) ([`b3b0704e`](https://github.com/block/buzz/commit/b3b0704efb5afc74dca0a093a6e4594973e9edf4))
- fix(desktop): show due-reminder count in the Inbox nav badge ([#1191](https://github.com/block/buzz/pull/1191)) ([`c0858dac`](https://github.com/block/buzz/commit/c0858dac12a3efd09d301f5424074f18df5cf422))
## v0.3.30
- fix(desktop): collapse mark-read/unread menu into one toggling item ([#1188](https://github.com/block/buzz/pull/1188)) ([`ce994df74`](https://github.com/block/buzz/commit/ce994df74e60cf43b2fb0b97ea9989aacd47650e))
- fix(channels): replace thread-unread badge frontier with per-message read markers ([#1178](https://github.com/block/buzz/pull/1178)) ([`28db41dfd`](https://github.com/block/buzz/commit/28db41dfd383f3f7acd57011bd98661a08e7bfe4))
- fix(relay): resubscribe agents when an archived channel is restored ([#1187](https://github.com/block/buzz/pull/1187)) ([`6780ea21e`](https://github.com/block/buzz/commit/6780ea21e44a525aa73455c4ae79702564f783f2))
## v0.3.29
- build: make mesh-llm opt-in for dev/staging to speed up iteration ([#1183](https://github.com/block/buzz/pull/1183)) ([`ce526106`](https://github.com/block/buzz/commit/ce5261067d2b970ecca7e0d6c24479fcd2ccad43))
- feat(desktop): default temporary channels to 7-day expiry ([#1182](https://github.com/block/buzz/pull/1182)) ([`4b518fc4`](https://github.com/block/buzz/commit/4b518fc4b3ec6c55eeb4d4b203057d55d8ba0c56))
- fix(desktop): render lightbox image sharply at full resolution ([#1181](https://github.com/block/buzz/pull/1181)) ([`ff727af9`](https://github.com/block/buzz/commit/ff727af926ce5a7a840b042d6cff4b5a40a81db5))
- fix(desktop): keep thread guides behind the reply composer ([#1179](https://github.com/block/buzz/pull/1179)) ([`781d1acf`](https://github.com/block/buzz/commit/781d1acf8a412df882fbd49ae1dd2f31a1a3096f))
- Sort direct messages alphabetically ([#1180](https://github.com/block/buzz/pull/1180)) ([`04df2c8d`](https://github.com/block/buzz/commit/04df2c8dc6ab055a5a9360f4a492715af6d28dc1))
- Add composer selection formatting tray ([#1133](https://github.com/block/buzz/pull/1133)) ([`7dcf22d5`](https://github.com/block/buzz/commit/7dcf22d50eb4c7503248caa95398f889e6290839))
- fix(desktop): honor per-agent relay override, default to workspace relay ([#1131](https://github.com/block/buzz/pull/1131)) ([`ea97e219`](https://github.com/block/buzz/commit/ea97e219895e396708cfc626d64b21f864a2674e))
- [codex] Stabilize desktop E2E flakes ([#1174](https://github.com/block/buzz/pull/1174)) ([`141f7118`](https://github.com/block/buzz/commit/141f7118ae6aedd910966651c53d92f9c6f71702))
- feat(desktop): show reminder author + source and click-to-navigate in inbox ([#1176](https://github.com/block/buzz/pull/1176)) ([`1186ea48`](https://github.com/block/buzz/commit/1186ea48b99cf2d2cd789137b3e907cee5d6e16c))
- Improve inbox thread updates ([#1114](https://github.com/block/buzz/pull/1114)) ([`de3f21ab`](https://github.com/block/buzz/commit/de3f21abcd60985c47f43c34d963a68300018f0b))
- docs(readme): mark git hosting as shipped in status table ([#1175](https://github.com/block/buzz/pull/1175)) ([`26fdfb67`](https://github.com/block/buzz/commit/26fdfb674c387fca20637ee4c2169f20fb98e50b))
- feat(windows): bundle full Git-for-Windows toolchain for the shell tool ([#1145](https://github.com/block/buzz/pull/1145)) ([`592508ad`](https://github.com/block/buzz/commit/592508adddd889d1b2bbfd8b5384fc2b7107c35c))
- fix(buzz-dev-mcp): resolve MSYS-absolute paths in the Windows file tools ([#1169](https://github.com/block/buzz/pull/1169)) ([`ec5cfa37`](https://github.com/block/buzz/commit/ec5cfa3796d88b6ea92c620817163e5c10fad8b3))
## v0.3.28
- Improve thread branch display ([#1166](https://github.com/block/buzz/pull/1166)) ([`342a251a`](https://github.com/block/buzz/commit/342a251a9199b55ffc0e19cb50ee7e09a8ec051f))
- Copy channel name from header ([#1157](https://github.com/block/buzz/pull/1157)) ([`a96d173e`](https://github.com/block/buzz/commit/a96d173e996da82a1e59fbdd60081ead01a0e869))
- docs: add product screenshots to README ([#1168](https://github.com/block/buzz/pull/1168)) ([`ff976e69`](https://github.com/block/buzz/commit/ff976e690e6488b0ea90336d5d724ccb097548ac))
- Use accent color for video timecodes ([#1167](https://github.com/block/buzz/pull/1167)) ([`c243a013`](https://github.com/block/buzz/commit/c243a01362e03e5436e22618fe60f89e71fed1b2))
- Add video playback speed control ([#1164](https://github.com/block/buzz/pull/1164)) ([`f218b815`](https://github.com/block/buzz/commit/f218b8158ef426b26013f0508668f3fee000b368))
- Update README.md ([`fee4b1f5`](https://github.com/block/buzz/commit/fee4b1f556b97e06dfb5dda34859ddf6250e13ea))
## v0.3.27
- chore: remove block team id from Xcode ([#1156](https://github.com/block/buzz/pull/1156)) ([`67fcf41b`](https://github.com/block/buzz/commit/67fcf41b53e081d1bed950257a3372683905a81c))
- fix(desktop): keep channel chrome clear of window controls ([#1159](https://github.com/block/buzz/pull/1159)) ([`fa10871d`](https://github.com/block/buzz/commit/fa10871df61f4426441e887839772e49f4e03196))
- desktop: polish sidebar search dialog ([#1155](https://github.com/block/buzz/pull/1155)) ([`5f79830e`](https://github.com/block/buzz/commit/5f79830e191102b4884487474ea70e1e522788a2))
- desktop: fix scrollback paging and visible history depth ([#1153](https://github.com/block/buzz/pull/1153)) ([`6cca7599`](https://github.com/block/buzz/commit/6cca75993be9b61c9a50159130492c183fcd2304))
- Fix ACP activity tool title detection ([#1149](https://github.com/block/buzz/pull/1149)) ([`4c7df9b0`](https://github.com/block/buzz/commit/4c7df9b0b3ea6884d0d9f59d7d1b244542885495))
- fix(desktop): clear channel unread badges from thread reads ([#1148](https://github.com/block/buzz/pull/1148)) ([`7c3e411f`](https://github.com/block/buzz/commit/7c3e411fa8df14ef706bfd657899de6ec1f976ff))
- desktop: move global search into sidebar header and remove vestigial top band ([#1150](https://github.com/block/buzz/pull/1150)) ([`c488d845`](https://github.com/block/buzz/commit/c488d8452c50f882a28b3f09224f3c6a30fb1b47))
- Update README.md ([`7c67dfa6`](https://github.com/block/buzz/commit/7c67dfa66d38c1f25d7fab185ea1b2584bb82898))
- Hide channel intro actions until history start ([#1151](https://github.com/block/buzz/pull/1151)) ([`7aa6daa4`](https://github.com/block/buzz/commit/7aa6daa4967c88dcda45560f6d4c5d2a0a2a63da))
- fix(desktop): remount timeline scroll node per channel ([#1147](https://github.com/block/buzz/pull/1147)) ([`0c5dbf34`](https://github.com/block/buzz/commit/0c5dbf3452802a0370c1bdb445efa602c98c4148))
- Render custom emoji in the inbox; DRY up emoji derivation ([#1146](https://github.com/block/buzz/pull/1146)) ([`39626197`](https://github.com/block/buzz/commit/39626197c0c80ae29dbb9814b6f3b6a603b174d0))
- desktop: open thread pane optimistically ([#1143](https://github.com/block/buzz/pull/1143)) ([`663b554c`](https://github.com/block/buzz/commit/663b554c7c42371aac66fa2d6cb9524280f88600))
- fix(desktop): keep channel header search/add icons always visible ([#1144](https://github.com/block/buzz/pull/1144)) ([`9c685282`](https://github.com/block/buzz/commit/9c6852824eecceecb5d504ce61a4906dd6a2aab9))
- fix(desktop): show skeleton during channel switch ([#1141](https://github.com/block/buzz/pull/1141)) ([`d1c3574b`](https://github.com/block/buzz/commit/d1c3574ba73b2febb8ee8306b58fddffce6001bf))
- Avoid duplicate thread event fetches ([#1129](https://github.com/block/buzz/pull/1129)) ([`959f2598`](https://github.com/block/buzz/commit/959f25982bbd135182c7c9f0d9c810a0324f1b2e))
- desktop: restore channel browse button ([#1140](https://github.com/block/buzz/pull/1140)) ([`cc8958f5`](https://github.com/block/buzz/commit/cc8958f5cc0421c1cabe7cee882da898fc09e1f0))
- desktop: make date dividers pill shaped ([#1139](https://github.com/block/buzz/pull/1139)) ([`bee41597`](https://github.com/block/buzz/commit/bee41597157eec63ec631caf3b1b6fb6e114e268))
- fix(desktop): align sidebar unread pills ([#1138](https://github.com/block/buzz/pull/1138)) ([`59754218`](https://github.com/block/buzz/commit/5975421889f2cdf31f353deb0b401ddfc78b86c4))
- Fix display names flashing to pubkeys when loading older messages ([#1137](https://github.com/block/buzz/pull/1137)) ([`c448f1c7`](https://github.com/block/buzz/commit/c448f1c72966b045f7d9b84560b3c1d04ca8b72c))
- desktop: keep sidebar unread pill below top chrome strip ([#1136](https://github.com/block/buzz/pull/1136)) ([`aeafb0b0`](https://github.com/block/buzz/commit/aeafb0b0b2623789a47be78cbd35782866fb00b3))
- fix(desktop): restore sticky date divider handoff ([#1135](https://github.com/block/buzz/pull/1135)) ([`3e9d7ff4`](https://github.com/block/buzz/commit/3e9d7ff4d6a152c944cf45ecfa70b641eac0f393))
- desktop: drop redundant post-subscribe history refetch ([#1130](https://github.com/block/buzz/pull/1130)) ([`b2bf471e`](https://github.com/block/buzz/commit/b2bf471ea581e768647f3f3ad055bffbf7803f08))
## v0.3.26
- ci: cache Flutter pub packages in the Mobile job ([#1128](https://github.com/block/buzz/pull/1128)) ([`ceb5eff53`](https://github.com/block/buzz/commit/ceb5eff53717a18afb9ac6cd3f3a93f01394b5bc))
- Delete RESEARCH directory ([`a1d5570d3`](https://github.com/block/buzz/commit/a1d5570d3136c2c52823f512a5c59429f8995ca0))
- feat(cli): support ephemeral channels via --ttl on create/update ([#1126](https://github.com/block/buzz/pull/1126)) ([`4f671a255`](https://github.com/block/buzz/commit/4f671a255cb526520151596b01dcf4a50ab2242d))
- fix(justfile): suppress grep exit code in release changelog formatter ([`d40c86626`](https://github.com/block/buzz/commit/d40c86626a3c2d10eaa647f0e4664b32ef9060a5))
- fix(desktop): single-owner anchored scroll for dynamic loading ([#1115](https://github.com/block/buzz/pull/1115)) ([`047db4214`](https://github.com/block/buzz/commit/047db4214e6ce79a615b8842e83f9f03c8e10f55))
- fix(buzz-dev-mcp): resolve non-WSL bash so the MCP shell works on Windows ([#1119](https://github.com/block/buzz/pull/1119)) ([`4d2eb52a6`](https://github.com/block/buzz/commit/4d2eb52a6c89fe71f37c2c5abf3fed9e95d45977))
- ci: key main-push concurrency group on SHA to stop run eviction ([#1124](https://github.com/block/buzz/pull/1124)) ([`e99105edc`](https://github.com/block/buzz/commit/e99105edc47bf36facbadbc94aeea99c7f15dd38))
- fix(desktop): seed up agent JSON files in sync_shared_agent_data ([#1121](https://github.com/block/buzz/pull/1121)) ([`065625bb4`](https://github.com/block/buzz/commit/065625bb4f8dc49e1d3d56698c637383f9f82eef))
- fix(acp): emit per-section prompt blocks so observer counts every section ([#1122](https://github.com/block/buzz/pull/1122)) ([`a1cf1db67`](https://github.com/block/buzz/commit/a1cf1db67faf9728fa1301ece5ef838ff2b2d906))
- refactor(justfile): rename, relay auto-start, bootstrap, DRY cleanup ([#1117](https://github.com/block/buzz/pull/1117)) ([`1dc4fb5da`](https://github.com/block/buzz/commit/1dc4fb5daf048e5e44af82450b067175c75814c2))
- fix(desktop): keep active-turn badges through transient relay drops ([#1120](https://github.com/block/buzz/pull/1120)) ([`cf122fcf1`](https://github.com/block/buzz/commit/cf122fcf1406378e5de2e652bd3f66ce5516f56b))
- Polish desktop sidebar navigation ([#1107](https://github.com/block/buzz/pull/1107)) ([`7072281f6`](https://github.com/block/buzz/commit/7072281f63de5005b9046af58ef3fb38b151ee09))
- fix(desktop): collapse thread-unread badge on thread-open ([#1118](https://github.com/block/buzz/pull/1118)) ([`0176d5f31`](https://github.com/block/buzz/commit/0176d5f31ec327641444fc89edb8ee1f04638578))
- feat(desktop): unify unread pills into one shared UnreadPill component ([#1111](https://github.com/block/buzz/pull/1111)) ([`826945e1b`](https://github.com/block/buzz/commit/826945e1bbee2b93a2bc5ec13c2d1c3241bb7754))
- fix(cli): use relay workflow id on create ([#872](https://github.com/block/buzz/pull/872)) ([`762ca43d2`](https://github.com/block/buzz/commit/762ca43d26be8f5376f96a1376ed7eb6f213e411))
- Fold agent core memory into the session system prompt ([#1112](https://github.com/block/buzz/pull/1112)) ([`633544788`](https://github.com/block/buzz/commit/6335447887f2d8cc8ea7fa2468f37a287a08e224))
- feat(cli): add patches and issues commands for NIP-34 git collaboration ([#1073](https://github.com/block/buzz/pull/1073)) ([`cd8292fe9`](https://github.com/block/buzz/commit/cd8292fe9e7941b3b25510c604491041a723cb7a))
- fix(desktop): stop random timeline message loss + page reconnect replay ([#1105](https://github.com/block/buzz/pull/1105)) ([`0181603eb`](https://github.com/block/buzz/commit/0181603eba2a1a632467105d00fd41ddb96c526a))
- Update README.md ([`c338d8840`](https://github.com/block/buzz/commit/c338d8840cb21ad27475b1d4b29f6fec539ca663))
- fix(desktop): keep thread replies from scrolling channel ([#1109](https://github.com/block/buzz/pull/1109)) ([`d24eac61a`](https://github.com/block/buzz/commit/d24eac61a86f0da63e12e2758863b8364b67320d))
- fix(buzz-acp): accept siblings under allowlist author gate ([#1108](https://github.com/block/buzz/pull/1108)) ([`dd9ce0902`](https://github.com/block/buzz/commit/dd9ce090209c282921c7c0e821795b12ac69796f))
- feat(deploy): add production Helm chart for Buzz ([#990](https://github.com/block/buzz/pull/990)) ([`629fb57bf`](https://github.com/block/buzz/commit/629fb57bf0802f1b57eaa3068fc6eaad85b9d38f))
- fix(desktop): keep MembersSidebar input usable while an add is in flight ([#1106](https://github.com/block/buzz/pull/1106)) ([`aadbe6735`](https://github.com/block/buzz/commit/aadbe67354f407a31c913de87b3e3d698fc3dddf))
## v0.3.25
- fix(desktop): stop dimming deferred message lists ([#1104](https://github.com/block/buzz/pull/1104)) ([`718b596e5`](https://github.com/block/buzz/commit/718b596e5b696dbe439993c40a16ba39dd862828))
- Smooth channel loading: single-surface timeline state machine ([#1099](https://github.com/block/buzz/pull/1099)) ([`d47572209`](https://github.com/block/buzz/commit/d47572209dd26ee11cb7d77c85d420fb11947ce8))
- feat: surface base + persona system prompts in observer feed ([#1103](https://github.com/block/buzz/pull/1103)) ([`5004758fb`](https://github.com/block/buzz/commit/5004758fbfda8ea9c014174b43464b9835952090))
- ci: move reminder e2e to a dedicated backend-integration job ([#1098](https://github.com/block/buzz/pull/1098)) ([`466bb993c`](https://github.com/block/buzz/commit/466bb993ca5a5bbf99db47ec31356b1a36e9aa86))
- fix: give agent-observer sub a replay-capable limit ([#1100](https://github.com/block/buzz/pull/1100)) ([`959fc6e9d`](https://github.com/block/buzz/commit/959fc6e9d0a68ff4de1e3b4dc3d731fb8f0b3b03))
- fix: make managed-agent spawn and teardown portable to Windows ([#1097](https://github.com/block/buzz/pull/1097)) ([`420131182`](https://github.com/block/buzz/commit/42013118215bbeb87a4fed30c5744e99bb0d8ae9))
- fix(desktop): constrain message timeline width with min-w-0 ([#1092](https://github.com/block/buzz/pull/1092)) ([`7de5517f1`](https://github.com/block/buzz/commit/7de5517f1d3d48e929e49fb8be20e04995428b50))
- feat(desktop): reminders notifications, snooze, overlay, and inbox view mode ([#1093](https://github.com/block/buzz/pull/1093)) ([`22b47db8e`](https://github.com/block/buzz/commit/22b47db8ecb80fb6deccad8323b38f7cbb1f5cf7))
- feat(prompt): add memory hygiene and hoist universal engineering discipline to base prompt ([#1085](https://github.com/block/buzz/pull/1085)) ([`010e8022b`](https://github.com/block/buzz/commit/010e8022b0b07b135db652d61afd7250c27e0564))
- fix(desktop): correct thread-unread badge flicker, stale clear, phantom count, mention gate, and nested count ([#1080](https://github.com/block/buzz/pull/1080)) ([`c8e1120fc`](https://github.com/block/buzz/commit/c8e1120fc2e78424526f49248b5d531a567a97b8))
- Fix mention chip alignment ([#1094](https://github.com/block/buzz/pull/1094)) ([`4f93d52e1`](https://github.com/block/buzz/commit/4f93d52e156a5683f114897d43f25f75704a292e))
- perf(desktop): virtualize unbounded lists and warm the emoji index ([#1089](https://github.com/block/buzz/pull/1089)) ([`a4fbebb39`](https://github.com/block/buzz/commit/a4fbebb397c0af4f19b3fc7af531ff067d8dead0))
- Adjust unread pill spacing ([#1091](https://github.com/block/buzz/pull/1091)) ([`3bf2ac004`](https://github.com/block/buzz/commit/3bf2ac00476440cc454c4d5b2ada87073c808500))
- Improve image lightbox controls ([#1084](https://github.com/block/buzz/pull/1084)) ([`bb9b6674e`](https://github.com/block/buzz/commit/bb9b6674e36a38d3a6395153930d2fb8ed502f45))
- Use Inter for app typography ([#899](https://github.com/block/buzz/pull/899)) ([`7b0500c93`](https://github.com/block/buzz/commit/7b0500c93e8923c0826a28af4fee3c88ed688280))
- fix(desktop): paint thread replies on open without scroll nudge ([#1090](https://github.com/block/buzz/pull/1090)) ([`422a90f5e`](https://github.com/block/buzz/commit/422a90f5e180d066128c51e45991278d39a707de))
- Polish sidebar update and relay cards ([#1009](https://github.com/block/buzz/pull/1009)) ([`d4cd919b8`](https://github.com/block/buzz/commit/d4cd919b8506469f0bfd68100f13cdf16d3e76bc))
- Normalize desktop icon sizing ([#1088](https://github.com/block/buzz/pull/1088)) ([`1a37ee395`](https://github.com/block/buzz/commit/1a37ee395119e5e54cb2798346bad4b5c9a5b29c))
- fix(desktop): normalize loopback host for HTTP writes and stop Reminders nav flicker ([#1086](https://github.com/block/buzz/pull/1086)) ([`771086fd6`](https://github.com/block/buzz/commit/771086fd69243b3bdccf213783190ad119486de9))
- feat(composer): link editing — inline popover, modal picker, buzz:// in-app nav ([#1045](https://github.com/block/buzz/pull/1045)) ([`770266d2a`](https://github.com/block/buzz/commit/770266d2a99925b92716b8991139f223d8ff5dda))
- chore(Pulse): gate Follow button behind Pulse feature flag ([#1019](https://github.com/block/buzz/pull/1019)) ([`48994e01b`](https://github.com/block/buzz/commit/48994e01bca2345d56fd0cf72458caa52686e2ec))
- fix(desktop): wrap long markdown autolinks ([#1081](https://github.com/block/buzz/pull/1081)) ([`b7e22f4b5`](https://github.com/block/buzz/commit/b7e22f4b5c13b995fe26587c2a52bbe0e775e0b9))
- feat(desktop): add NIP-ER reminder UI — create, view, and manage encrypted reminders ([#963](https://github.com/block/buzz/pull/963)) ([`ff824a365`](https://github.com/block/buzz/commit/ff824a365e95651edc08b7721e81e091734e8664))
- feat(relay): add NIP-ER push scheduler with cross-pod delivery ([#957](https://github.com/block/buzz/pull/957)) ([`26563968c`](https://github.com/block/buzz/commit/26563968cba232db7b16c0b696273819b5e2c571))
- feat(relay): implement NIP-ER event reminder support (kind:30300) ([#934](https://github.com/block/buzz/pull/934)) ([`79fcfd82b`](https://github.com/block/buzz/commit/79fcfd82bc3a883e1400449f87779853dc0e575c))
- feat(desktop): adding ui state into the history stack ([#967](https://github.com/block/buzz/pull/967)) ([`538f33341`](https://github.com/block/buzz/commit/538f33341fbdfa9794728dabd5ce995769dc151c))
- fix(desktop): restore timeline zoom via rem tokens + chat-as-base type scale ([#1052](https://github.com/block/buzz/pull/1052)) ([`c22c54e7a`](https://github.com/block/buzz/commit/c22c54e7ae4318f9648dc9441a152732cb29d6d5))
- fix(release): format changelog as linked markdown bullets ([#1075](https://github.com/block/buzz/pull/1075)) ([`d879fac4c`](https://github.com/block/buzz/commit/d879fac4c424550d5fa839861c53583cdf032af5))
## v0.3.24
- feat(desktop): refine thread-unread badge to two-token form ([#1069](https://github.com/block/buzz/pull/1069)) ([`de24b90aa`](https://github.com/block/buzz/commit/de24b90aa1076822c4de184c3bb8b5f6ebc682a2))
- fix(buzz): prevent reconnect storms from reaped ephemeral channels ([#1071](https://github.com/block/buzz/pull/1071)) ([`fd2553726`](https://github.com/block/buzz/commit/fd2553726c6a8277c472b44227d13546673117b2))
- fix(buzz-acp): trim oversized observer frames to fit instead of dropping ([#1072](https://github.com/block/buzz/pull/1072)) ([`5a651632d`](https://github.com/block/buzz/commit/5a651632d873d29d9867fc2a1445547dc519e95d))
- perf(ci): speed up PR CI wall clock and local dev builds ([#1028](https://github.com/block/buzz/pull/1028)) ([`07efae7b5`](https://github.com/block/buzz/commit/07efae7b5b8948ba12231436e7f2820b42d37496))
- chore(deps): update react monorepo ([#1048](https://github.com/block/buzz/pull/1048)) ([`56a5ac279`](https://github.com/block/buzz/commit/56a5ac27900661aa1674b93f81867d2930ccff93))
- Polish desktop visual details ([#1067](https://github.com/block/buzz/pull/1067)) ([`9f99e62d4`](https://github.com/block/buzz/commit/9f99e62d409fe7d5f9330de5641e9c6fcfc3b752))
- ci: use running postgres for pgschema desired-state planning ([#1070](https://github.com/block/buzz/pull/1070)) ([`e3736f08b`](https://github.com/block/buzz/commit/e3736f08b77b81d3752c3aceb1e25022e5dc759b))
- fix(desktop): anchor active-turn badge to skew-corrected agent start ([#1068](https://github.com/block/buzz/pull/1068)) ([`2d26db6d8`](https://github.com/block/buzz/commit/2d26db6d8a7381ff0dc9b50194fd28d42957de74))
- feat(desktop): add configurable transport reconnect hook ([#1059](https://github.com/block/buzz/pull/1059)) ([`ba776b995`](https://github.com/block/buzz/commit/ba776b9959324e10ffacf40126875783f0310ac0))
- Add automatic database migrations ([#988](https://github.com/block/buzz/pull/988)) ([`2300248d3`](https://github.com/block/buzz/commit/2300248d3b40359da7d92b6665041a70b5520047))
- Add composer spoiler formatting ([#1055](https://github.com/block/buzz/pull/1055)) ([`f8715612a`](https://github.com/block/buzz/commit/f8715612afb03f1c7938432141c9904c20e9b9ee))
- feat(desktop): in-channel and in-thread unread indicators ([#1008](https://github.com/block/buzz/pull/1008)) ([`2a2c1c800`](https://github.com/block/buzz/commit/2a2c1c8001a2eb6dbf35b3f13ec5468d1a7a1417))
- perf(timeline): gate heavy message render behind useDeferredValue ([#1022](https://github.com/block/buzz/pull/1022)) ([`cbc754cff`](https://github.com/block/buzz/commit/cbc754cffb0e14c25b380de6c2d5f8d62c14a183))
- Add animated profile avatars ([#1031](https://github.com/block/buzz/pull/1031)) ([`116486592`](https://github.com/block/buzz/commit/116486592d05f8ab079b2d26a8805d8fe7a055b5))
- Polish direct message and members modals ([#1054](https://github.com/block/buzz/pull/1054)) ([`89ae31d20`](https://github.com/block/buzz/commit/89ae31d2034eab36445be9b43d9edf51cd34576f))
- Polish huddles UI ([#1041](https://github.com/block/buzz/pull/1041)) ([`5234fc816`](https://github.com/block/buzz/commit/5234fc81675f46a0d616d336453c51f9b90f66cd))
- Fix video review comments in threads ([#1056](https://github.com/block/buzz/pull/1056)) ([`424ea7025`](https://github.com/block/buzz/commit/424ea70254f7703ec31688aa7096f145ec4a59a8))
- Polish message reaction tray ([#1002](https://github.com/block/buzz/pull/1002)) ([`81296d976`](https://github.com/block/buzz/commit/81296d9766af0f0ddecd9e14526cb9456952b489))
- Refine app loading skeletons ([#1001](https://github.com/block/buzz/pull/1001)) ([`c30d7274c`](https://github.com/block/buzz/commit/c30d7274c2febee6b274ea0160b9465cc93899b8))
- Polish channel modal forms ([#1000](https://github.com/block/buzz/pull/1000)) ([`ee34ca818`](https://github.com/block/buzz/commit/ee34ca818bc19a3f7f62f00debe4c7f2862b906d))
- Normalize desktop icon sizing ([#999](https://github.com/block/buzz/pull/999)) ([`19656fff4`](https://github.com/block/buzz/commit/19656fff46176e44ca7b4e8025f2fb7b34ee3589))
- Add shared skeleton loader primitives ([#998](https://github.com/block/buzz/pull/998)) ([`7a2e35521`](https://github.com/block/buzz/commit/7a2e35521ca103d1f2086fb195b7d91e588dc070))
- chore(scripts): update post-screenshots repo name to block/buzz ([#1042](https://github.com/block/buzz/pull/1042)) ([`9ee5aeebd`](https://github.com/block/buzz/commit/9ee5aeebd912940fc09ed9707ff716b63bc28409))
- docs: fix stale sprout repo references in RELEASING.md ([#1043](https://github.com/block/buzz/pull/1043)) ([`b2ad3074b`](https://github.com/block/buzz/commit/b2ad3074bacf8f29d179c80003228b4dd451efd0))
## v0.3.23
- fix(release): publish manifest from successful platforms ([#1039](https://github.com/block/buzz/pull/1039)) ([`9b410325a`](https://github.com/block/buzz/commit/9b410325a754305405dbaf80b63546df9af403ea))
## v0.3.22
- fix(release): publish rolling updater manifest for automated release tags
## v0.3.21
- fix(release): use signed NSIS installer for updates ([#1036](https://github.com/block/buzz/pull/1036)) ([`4d19a5901`](https://github.com/block/buzz/commit/4d19a5901d45aab7c615440e725e6d17d799e0da))
- handoff: pass full session history to summarizer ([#1033](https://github.com/block/buzz/pull/1033)) ([`fa1cade3b`](https://github.com/block/buzz/commit/fa1cade3ba76309bcbeac5e87e8932e2087622f2))
- feat(emoji): latest-set-wins union for custom emoji across desktop, mobile, and CLI ([#989](https://github.com/block/buzz/pull/989)) ([`6e4c8680c`](https://github.com/block/buzz/commit/6e4c8680c8c2346c8ff4612e79343d55b435d0d6))
- Fix relay NIP-11 software URL ([#1030](https://github.com/block/buzz/pull/1030)) ([`5f8ab33b3`](https://github.com/block/buzz/commit/5f8ab33b3b132decc35b80723fb8f197d37edda9))
- fix(desktop): make Windows release compile cleanly ([#1029](https://github.com/block/buzz/pull/1029)) ([`5c2f46e62`](https://github.com/block/buzz/commit/5c2f46e627e8a9c257e183bf98289c31757fc6b3))
- Add production Docker Compose bundle ([#985](https://github.com/block/buzz/pull/985)) ([`6caa359d7`](https://github.com/block/buzz/commit/6caa359d7052d494d1b2f55290d93d9e603e74dd))
- feat(profile): show active turn badges on agent profile panel and popover ([#1026](https://github.com/block/buzz/pull/1026)) ([`a32681fd4`](https://github.com/block/buzz/commit/a32681fd48e58355052d6980bc8f44d79cf4cd66))
## v0.3.20
- fix(release): resolve Windows sidecar path and Linux AppImage updater format ([#1024](https://github.com/block/buzz/pull/1024)) ([`c7dd4295b`](https://github.com/block/buzz/commit/c7dd4295b8feee4acd78bac86d1d92cf7e7463ad))
## v0.3.19
- fix(release): ignore prerelease tags in changelog generation ([#1021](https://github.com/block/buzz/pull/1021)) ([`faf00724f`](https://github.com/block/buzz/commit/faf00724fb4d187ad92a378b4069bf4fd1b4033f))
- fix: repair main build after cross-PR merge skew ([#1020](https://github.com/block/buzz/pull/1020)) ([`b8c0556e7`](https://github.com/block/buzz/commit/b8c0556e7cdda13c52ba1cf80e675e4fc40b6696))
- feat(agents): show per-turn duration and prune dead turns within ~25s of host crash ([#1017](https://github.com/block/buzz/pull/1017)) ([`87e45c65b`](https://github.com/block/buzz/commit/87e45c65bf9a0c841df2262f712a30c554888cac))
- fix(release): replace hermit with native tool setup on Windows job ([#1018](https://github.com/block/buzz/pull/1018)) ([`2fef8d664`](https://github.com/block/buzz/commit/2fef8d6643ad15268254d89ec84199c735142404))
- feat(acp): surface error-class outcomes to the activity feed only, never the channel ([#1010](https://github.com/block/buzz/pull/1010)) ([`6db90514b`](https://github.com/block/buzz/commit/6db90514b24d9c70073d52c9c8e8df332ccb82d5))
- fix(desktop): migrate Sprout workspace storage ([#1016](https://github.com/block/buzz/pull/1016)) ([`563f68434`](https://github.com/block/buzz/commit/563f684342cdf7b59642b124f9349b0a3ae3d284))
- feat(auth): force token refresh on rejected token (401/403), never the browser ([#1015](https://github.com/block/buzz/pull/1015)) ([`5a8cc79c6`](https://github.com/block/buzz/commit/5a8cc79c6a1d229b0ef7d33e1b98168d6d7841c0))
- fix(release): mark prerelease versions so they do not become latest ([#1013](https://github.com/block/buzz/pull/1013)) ([`59a7e5da8`](https://github.com/block/buzz/commit/59a7e5da8af296a51ff43fe8ff4b09e4bb9c48fa))
- feat(acp): implement systemPrompt with protocol version gating ([#981](https://github.com/block/buzz/pull/981)) ([`f08588245`](https://github.com/block/buzz/commit/f0858824548e97a73451ae6178a9ed14299cee42))
- fix(release): update repository name check from block/sprout to block/buzz ([#1012](https://github.com/block/buzz/pull/1012)) ([`d07c8216c`](https://github.com/block/buzz/commit/d07c8216cc1550a1a101f4e478d5deb3d60fa633))
- feat(release): all-OS desktop builds + universal auto-update manifest ([#1011](https://github.com/block/buzz/pull/1011)) ([`de641fce5`](https://github.com/block/buzz/commit/de641fce5247994958635244c29e2fd8fa51ccf0))
- Add relay disconnect UX: friendly errors, reconnect, cached identity ([#1004](https://github.com/block/buzz/pull/1004)) ([`8c9211ffc`](https://github.com/block/buzz/commit/8c9211ffc675000bdf55c4643528cf5d32a2d27a))
- feat(agents): add active turn indicators to Agents Menu ([#1005](https://github.com/block/buzz/pull/1005)) ([`7983bf675`](https://github.com/block/buzz/commit/7983bf6751f12562b2703dce73697e5cfb7f44da))
- ci: add fork guards to docker, release, and auto-tag workflows ([#1007](https://github.com/block/buzz/pull/1007)) ([`39d9aa826`](https://github.com/block/buzz/commit/39d9aa8260e0c8eb6fdc6277c8138b63688dad0c))
- docs(nip-rs): add optional thread read context scheme ([#1006](https://github.com/block/buzz/pull/1006)) ([`43d1ce353`](https://github.com/block/buzz/commit/43d1ce3532b769b23c20e0cb5228e007d574f0d3))
- fix(huddle): Pocket TTS quality overhaul — reference parity + cross-message pipelining ([#997](https://github.com/block/buzz/pull/997)) ([`12433077a`](https://github.com/block/buzz/commit/12433077add076dd89f5412a55ec3048b6b1b64d))
- Add manual ACP session rotation command ([#932](https://github.com/block/buzz/pull/932)) ([`00dc4915d`](https://github.com/block/buzz/commit/00dc4915d4b874c312a82fa707ee4d8f2437f61f))
- fix(desktop): heal stale persona_team_dir paths in release builds ([#1003](https://github.com/block/buzz/pull/1003)) ([`df8896f13`](https://github.com/block/buzz/commit/df8896f13ec5a937a443b968439016823f34e6b6))
- ci(docker): publish public ghcr.io/block/buzz image (native multi-arch) ([#986](https://github.com/block/buzz/pull/986)) ([`1fa63bada`](https://github.com/block/buzz/commit/1fa63badad283cff753ab403105344060fa74efa))
- fix(buzz-agent): cap tool-result text at 50 KiB with middle elision ([#952](https://github.com/block/buzz/pull/952)) ([`84f499cb6`](https://github.com/block/buzz/commit/84f499cb6ed65f8889ae7f0401451c02696b164e))
- feat(huddle): sentence-at-a-time voice-mode guidelines for lower TTS latency ([#996](https://github.com/block/buzz/pull/996)) ([`2846a96ed`](https://github.com/block/buzz/commit/2846a96ed2ed64077da3b7ad866e40c1cf26e952))
- Shard desktop Playwright CI jobs ([#992](https://github.com/block/buzz/pull/992)) ([`a1c28f487`](https://github.com/block/buzz/commit/a1c28f487d2af01d620619d940cf377d21c1a81a))
## v0.3.18
- Video Player Improvements ([#993](https://github.com/block/buzz/pull/993)) ([`05fc69b84`](https://github.com/block/buzz/commit/05fc69b84082d7e7e9360cab7ae9c9c0524511f9))
- Improve first-run welcome setup ([#970](https://github.com/block/buzz/pull/970)) ([`d9ce0943e`](https://github.com/block/buzz/commit/d9ce0943edb286404a3c99b501d53e7b35095955))
- fix(release): use legacy updater key secret ([#991](https://github.com/block/buzz/pull/991)) ([`50986406f`](https://github.com/block/buzz/commit/50986406ffa1f4e3dcff3e5373a3e0838d11fb2b))
- Replace built-in personas with Fizz ([#987](https://github.com/block/buzz/pull/987)) ([`ea5a0a9b4`](https://github.com/block/buzz/commit/ea5a0a9b40592d6192fb0ddbb99bf257009de5a8))
- docs(buzz-acp): rewrite Communication Patterns for mention accuracy and threading clarity ([#982](https://github.com/block/buzz/pull/982)) ([`654176541`](https://github.com/block/buzz/commit/6541765416b9da25dcd8c5c9da692b33d12782c7))
- chore(justfile): build git-credential-nostr in dev and staging recipes ([#980](https://github.com/block/buzz/pull/980)) ([`a101fd6ad`](https://github.com/block/buzz/commit/a101fd6ad38d4c2d8c668c2d5d79d2a35ab71176))
- Fix Buzz command migration for saved agents ([#979](https://github.com/block/buzz/pull/979)) ([`824c55114`](https://github.com/block/buzz/commit/824c55114ef388990f22df901caf9e37b1102b04))
- fix(desktop): resolve effective model and prompt from persona in display path ([#972](https://github.com/block/buzz/pull/972)) ([`63738139b`](https://github.com/block/buzz/commit/63738139bac3ce860fd6a0a50dc5883a374d6245))
- docs: clean up remaining Buzz references ([#977](https://github.com/block/buzz/pull/977)) ([`1bb8b8d54`](https://github.com/block/buzz/commit/1bb8b8d547a020622948225b32a8f9bbf3b0de2e))
## v0.3.17
- docs: finish Buzz rename cleanup ([#974](https://github.com/block/buzz/pull/974)) ([`79bcee55c`](https://github.com/block/buzz/commit/79bcee55cb717b117f267145a6b82d477f5aa428))
- fix(desktop): let channel members bypass mention agent gate ([#965](https://github.com/block/buzz/pull/965)) ([`6f3733d43`](https://github.com/block/buzz/commit/6f3733d43b4296b228f06874d10d22a7bb9a3c73))
- Rename desktop app to Buzz ([#960](https://github.com/block/buzz/pull/960)) ([`8f580f308`](https://github.com/block/buzz/commit/8f580f308cc3d92b07a65ca08cb117e8f7656a7d))
- feat(desktop): open profile panel from MembersSidebar rows ([#962](https://github.com/block/buzz/pull/962)) ([`dcb2639b3`](https://github.com/block/buzz/commit/dcb2639b355c988ca6c5529640cfd55c957edd05))
- feat(desktop): per-event notification sounds and alert controls ([#968](https://github.com/block/buzz/pull/968)) ([`4e4dc723e`](https://github.com/block/buzz/commit/4e4dc723e4cc1969efe9a8cc8d6e0e95a8e2d695))
- fix(desktop): make header chrome zoom-correct and tidy split-pane ([#941](https://github.com/block/buzz/pull/941)) ([`1ca16c898`](https://github.com/block/buzz/commit/1ca16c898c7ccd5c9a4f78768e697b411367c975))
- fix(desktop): rename SPROUT_ env vars to BUZZ_ for child agent processes ([#971](https://github.com/block/buzz/pull/971)) ([`8c8312932`](https://github.com/block/buzz/commit/8c8312932af9dced17fe1225d899885ec603b08b))
- fix(justfile): complete buzz rename in dev and staging recipes ([#966](https://github.com/block/buzz/pull/966)) ([`31b0665cf`](https://github.com/block/buzz/commit/31b0665cff7cb4735397696bd7ce03a17404d54a))
- refactor: rename sprout backend to buzz ([#958](https://github.com/block/buzz/pull/958)) ([`d99ad131f`](https://github.com/block/buzz/commit/d99ad131f176040549b041e297b813ef9705663e))
- fix(desktop): reap orphaned agent processes across instances ([#954](https://github.com/block/buzz/pull/954)) ([`53e3f0948`](https://github.com/block/buzz/commit/53e3f09485838fbff242c883ff412dd39d03ec27))
- Rename web app to Buzz ([#959](https://github.com/block/buzz/pull/959)) ([`c5a54dcc3`](https://github.com/block/buzz/commit/c5a54dcc3909a319e1d3604de4415142a3903619))
- fix(desktop): allow restarting saved relay-mesh agents from the UI ([#956](https://github.com/block/buzz/pull/956)) ([`510009c11`](https://github.com/block/buzz/commit/510009c11db58176c4af907414ecc994e36c6c0b))
- feat(acp): agent timeout resilience — idle margin, tool-call reset, death notices, keepalive ([#935](https://github.com/block/buzz/pull/935)) ([`60c8c5036`](https://github.com/block/buzz/commit/60c8c5036a20d10b8c32c95dd40bbb4d6098b59e))
- Rename mobile app to Buzz ([#955](https://github.com/block/buzz/pull/955)) ([`c63e018b0`](https://github.com/block/buzz/commit/c63e018b05b9e387b413f00417c2df6c5979fff5))
- fix(desktop): repair team-persona mismatch and deduplicate legacy imports ([#949](https://github.com/block/buzz/pull/949)) ([`929cc8861`](https://github.com/block/buzz/commit/929cc8861ee5629737fd9f7dafe4501ee071fa1b))
- fix(desktop): populate last_message_at in channel browser ([#951](https://github.com/block/buzz/pull/951)) ([`b792aa470`](https://github.com/block/buzz/commit/b792aa4704a2aeb669008c9c48736fc3acef35d9))
- Kit/circular avatars ([#927](https://github.com/block/buzz/pull/927)) ([`e5f0c3264`](https://github.com/block/buzz/commit/e5f0c32648be83908d3f4e06c8f746581b365527))
- fix(relay): accept mesh signaling kinds (24620/24621) via POST /events ([#946](https://github.com/block/buzz/pull/946)) ([`dbe973dac`](https://github.com/block/buzz/commit/dbe973dacd0165ad40689f28601660bb307c67ea))
- feat(sprout-dev-mcp): add read_file tool and replace_all to str_replace ([#928](https://github.com/block/buzz/pull/928)) ([`f49cdcdd3`](https://github.com/block/buzz/commit/f49cdcdd300dca846a7cd7023a29e5bf54fc96a1))
## v0.3.16
- fix(desktop): land live presence updates for not-yet-cached pubkeys ([#947](https://github.com/block/buzz/pull/947)) ([`34c8bdab1`](https://github.com/block/buzz/commit/34c8bdab1a8bde4fa6d24afcb081a60131e10ff3))
- fix(release): make `just release` idempotent for re-runs ([#948](https://github.com/block/buzz/pull/948)) ([`5c0af0bc9`](https://github.com/block/buzz/commit/5c0af0bc93d713beb1f77f6c9bc3dad65c0ff15b))
- Improve mentions for agents + people ([#942](https://github.com/block/buzz/pull/942)) ([`e9cd1c392`](https://github.com/block/buzz/commit/e9cd1c39264233317c1639d7389c1a843db7ef93))
- feat: agent memory viewer (read-only) in profile panel ([#917](https://github.com/block/buzz/pull/917)) ([`384eb6cba`](https://github.com/block/buzz/commit/384eb6cba4d589627f6ddf92d288c00dd988361f))
- Fix channel visibility controls ([#940](https://github.com/block/buzz/pull/940)) ([`2dc466fe0`](https://github.com/block/buzz/commit/2dc466fe0b45ee85e772af8ec5e47eeeb2051ce4))
- fix(delete): make agent-deleted messages disappear from desktop UI immediately ([#918](https://github.com/block/buzz/pull/918)) ([`3e56331e9`](https://github.com/block/buzz/commit/3e56331e9c2866d736d3f92fec03ef065d19007e))
- Fix emoji message rendering ([#938](https://github.com/block/buzz/pull/938)) ([`e08937cdd`](https://github.com/block/buzz/commit/e08937cddeff5702d8dfa3d7bfa187d2fbfcf305))
- refactor(desktop): consolidate packs into teams ([#852](https://github.com/block/buzz/pull/852)) ([`ba2fdbf69`](https://github.com/block/buzz/commit/ba2fdbf6978c8904b26e6f62a2d88782740d890c))
- Update README.md wording ([`384a34ec3`](https://github.com/block/buzz/commit/384a34ec31df67b10024e773c349fbd17030c51a))
- Fix post-compact handoff context for OpenAI providers ([#931](https://github.com/block/buzz/pull/931)) ([`fe14daa5d`](https://github.com/block/buzz/commit/fe14daa5d6a6dc681d3838b4f61a65d9b694be19))
## v0.3.15
- fix: persona is source of truth at spawn + thread-depth conventions ([#930](https://github.com/block/buzz/pull/930)) ([`877048d68`](https://github.com/block/buzz/commit/877048d68fb064947c8845d0051237064937660b))
- fix: skip avatar reconciliation for legacy agent records ([#933](https://github.com/block/buzz/pull/933)) ([`73cd8d082`](https://github.com/block/buzz/commit/73cd8d082d474a5cd16bdd4b404010027ed3ad17))
- feat(desktop): add nest commit identity guidance with human sign-off ([#929](https://github.com/block/buzz/pull/929)) ([`165b9f7a5`](https://github.com/block/buzz/commit/165b9f7a5f5ba3d5a48ff19d04c25b455058e537))
- feat: provider/model selection for personas and runtime-aware env injection ([#794](https://github.com/block/buzz/pull/794)) ([`9a98e60fc`](https://github.com/block/buzz/commit/9a98e60fc29edacfb27134c19676d114c2ce20a2))
- fix: reconcile agent profile on startup when relay publish was missed ([#921](https://github.com/block/buzz/pull/921)) ([`762a45969`](https://github.com/block/buzz/commit/762a45969a6bec0e6fe3efe6b86c2aa1f47d7eeb))
- Revamp first-run onboarding ([#924](https://github.com/block/buzz/pull/924)) ([`5d927aba0`](https://github.com/block/buzz/commit/5d927aba0c45a4a139b559be89bb1bb1595e7fdb))
- Update setup loading screen ([#926](https://github.com/block/buzz/pull/926)) ([`f36265019`](https://github.com/block/buzz/commit/f3626501952139974e641fecae3dc17fcfade187))
- fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV) ([#857](https://github.com/block/buzz/pull/857)) ([`c38301bca`](https://github.com/block/buzz/commit/c38301bca5a3d8015f1e7340b4e732ef8c0e3744))
- Maximize desktop window on launch ([#925](https://github.com/block/buzz/pull/925)) ([`4dfae61f2`](https://github.com/block/buzz/commit/4dfae61f24271fa2e0616e3d896b5f72f3a3ca0f))
- feat: preview features (experiments settings UI) ([#888](https://github.com/block/buzz/pull/888)) ([`ae430d4dd`](https://github.com/block/buzz/commit/ae430d4dd954fc8cf9e96ea6ac6940714cde9181))
- fix(updater): send no-cache header on update check to avoid stale manifest ([#922](https://github.com/block/buzz/pull/922)) ([`a357e220d`](https://github.com/block/buzz/commit/a357e220d82230277a123d809e3130dc359f0fbb))
- fix(desktop): refresh channel state after unarchive ([#923](https://github.com/block/buzz/pull/923)) ([`56230c144`](https://github.com/block/buzz/commit/56230c1442f9881278910f6ecbc5bb66178e9bd6))
- Add channel visibility & ephemeral TTL controls to manage sidebar ([#911](https://github.com/block/buzz/pull/911)) ([`7dd5b3453`](https://github.com/block/buzz/commit/7dd5b34536b82cb072b92a7e8000f543b4170d3a))
- ci(release): add Intel macOS (x86_64) DMG as a release target ([#748](https://github.com/block/buzz/pull/748)) ([`4b78fe3be`](https://github.com/block/buzz/commit/4b78fe3bea22c44568bc656db74e577c748d7627))
- mesh: Rust-owned coordinator — fix saved-agent reconnect flakiness + DRY the start path ([#879](https://github.com/block/buzz/pull/879)) ([`421593062`](https://github.com/block/buzz/commit/421593062f25ab19e9ce9fbbaa204d843231657f))
## v0.3.14
- fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction ([#905](https://github.com/block/buzz/pull/905)) ([`bfafdd46b`](https://github.com/block/buzz/commit/bfafdd46b29a04421efdd95ee0a157e2da86ee54))
- fix(desktop): re-enable mcp_command reconciliation and harden spawn site ([#909](https://github.com/block/buzz/pull/909)) ([`15f610dcd`](https://github.com/block/buzz/commit/15f610dcd5c1d1308beed167c316721b2deefc2d))
- Fix desktop DM and sidebar UI polish ([#908](https://github.com/block/buzz/pull/908)) ([`da80c7340`](https://github.com/block/buzz/commit/da80c7340f300cd349cfee15b8f61c9a2f576b92))
- Animate reaction counts ([#904](https://github.com/block/buzz/pull/904)) ([`dd08f988d`](https://github.com/block/buzz/commit/dd08f988dec85f42f2202bfad0589278a3d22a34))
- Mobile custom emoji + settings redesign ([#906](https://github.com/block/buzz/pull/906)) ([`10b6674bd`](https://github.com/block/buzz/commit/10b6674bd7907e6bf3d81cc2a3f128d896d233f2))
- Renew TTL when unarchiving ephemeral channels ([#902](https://github.com/block/buzz/pull/902)) ([`732e23dd5`](https://github.com/block/buzz/commit/732e23dd5c37becd0ae24f4afdca757ff0b85264))
## v0.3.13
- Collapse channel header actions ([#901](https://github.com/block/buzz/pull/901)) ([`ecca5e77e`](https://github.com/block/buzz/commit/ecca5e77e4ec973ddeab1d0f21981d97958515f9))
- sprout-agent: make Databricks defaults env-only ([#868](https://github.com/block/buzz/pull/868)) ([`4ec7f8125`](https://github.com/block/buzz/commit/4ec7f8125e845783f3329ab7f2f7193dd2d83938))
- Restyle settings sections ([#894](https://github.com/block/buzz/pull/894)) ([`b384354e2`](https://github.com/block/buzz/commit/b384354e2bd110a511ed8d9d045419939f4d1d2b))
- Add emoji reaction particles ([#890](https://github.com/block/buzz/pull/890)) ([`fdcbb696f`](https://github.com/block/buzz/commit/fdcbb696fe0797fe9777da7db3116df366cb54ea))
- Move settings into the app shell ([#893](https://github.com/block/buzz/pull/893)) ([`32039b9a2`](https://github.com/block/buzz/commit/32039b9a25b4f7feb625662391f09b55fe6090ca))
- Tune chat text sizing ([#891](https://github.com/block/buzz/pull/891)) ([`45f3dfe5b`](https://github.com/block/buzz/commit/45f3dfe5ba62bfda2d1279814e52f5954b489a28))
- Style channel header navigation ([#889](https://github.com/block/buzz/pull/889)) ([`29f6ccf9e`](https://github.com/block/buzz/commit/29f6ccf9e9c54acc05d106e0be33bb366ebf3f02))
- fix: rename missed known_acp_provider_exact → known_acp_runtime_exact ([#900](https://github.com/block/buzz/pull/900)) ([`2ebe55174`](https://github.com/block/buzz/commit/2ebe55174106164dc56834f223c549b7a073b0aa))
- chore(deps): update radix-ui-primitives monorepo ([#898](https://github.com/block/buzz/pull/898)) ([`97bdb79de`](https://github.com/block/buzz/commit/97bdb79ded545dc7c91b1dfd6a01e32f631b8df9))
- chore(deps): update actions/checkout digest to df4cb1c ([#897](https://github.com/block/buzz/pull/897)) ([`4a93100e1`](https://github.com/block/buzz/commit/4a93100e199a2c7c623d4b2db0e11b26684330c9))
- refactor: rename ACP "provider" to "runtime" across the codebase ([#783](https://github.com/block/buzz/pull/783)) ([`0a6067ca1`](https://github.com/block/buzz/commit/0a6067ca1bebd48b84e6fcd63db12d255a10071f))
- Unify avatar radius ([#892](https://github.com/block/buzz/pull/892)) ([`056b87d3d`](https://github.com/block/buzz/commit/056b87d3da4584cef3b9d8d8e1807731fb16ea64))
## v0.3.12
- Show hover cards for inline message emoji ([#885](https://github.com/block/buzz/pull/885)) ([`1b7b6978f`](https://github.com/block/buzz/commit/1b7b6978fc83dc2001ce0a04380c978bd57f7a7e))
- Fix monotonic read-state merges ([#884](https://github.com/block/buzz/pull/884)) ([`5268fac2d`](https://github.com/block/buzz/commit/5268fac2d840bb77f2c64d358ef91fa173f8295f))
- Refine sidebar behavior and borders ([#869](https://github.com/block/buzz/pull/869)) ([`0a4783c6f`](https://github.com/block/buzz/commit/0a4783c6f8afb02355bded483cecf2a2eb3abc68))
- fix(presence): clear on disconnect, fix heartbeat/TTL, drop broken REST path ([#877](https://github.com/block/buzz/pull/877)) ([`5d7c74896`](https://github.com/block/buzz/commit/5d7c74896989938def2d54637cad21bd1e18d0c1))
- fix(cli): publish ephemeral events over WebSocket via sprout-ws-client ([#876](https://github.com/block/buzz/pull/876)) ([`ef98ae942`](https://github.com/block/buzz/commit/ef98ae942a5768153c72fca8c38c0255375f46b5))
- docs(sprout-acp): add communication discipline rules to base prompt + deprecate --mention flag ([#883](https://github.com/block/buzz/pull/883)) ([`2f50011bd`](https://github.com/block/buzz/commit/2f50011bdd25406ff47140b70b020ccf3cfbf6be))
- Polish thread summaries and reactions ([#881](https://github.com/block/buzz/pull/881)) ([`5c2476a71`](https://github.com/block/buzz/commit/5c2476a71e45e6c94fcb6d586e6a3658feb6b262))
- feat(cli): add emoji export and import subcommands ([#882](https://github.com/block/buzz/pull/882)) ([`7129cd6f2`](https://github.com/block/buzz/commit/7129cd6f23e4191fc8e4f092da91324982b3edec))
- Polish message row hover states ([#880](https://github.com/block/buzz/pull/880)) ([`b84f8e6a0`](https://github.com/block/buzz/commit/b84f8e6a010b837608f0e7c755e82a38367c4a73))
- Improve emoji naming and custom emoji UX ([#878](https://github.com/block/buzz/pull/878)) ([`bc5300867`](https://github.com/block/buzz/commit/bc53008676fc9b67209a809167d8f3fb52c0c03a))
- docs: add ecosystem section to CONTRIBUTING.md, fix stale release info ([#873](https://github.com/block/buzz/pull/873)) ([`581c7e95a`](https://github.com/block/buzz/commit/581c7e95a9b0ee847d20cdccc803c1f773a89621))
- fix(relay): wire custom filter fields through HTTP bridge ([#864](https://github.com/block/buzz/pull/864)) ([`031152221`](https://github.com/block/buzz/commit/031152221bdbb0cad9f9046f2ea999912463427f))
- chore: deprecate sprout-mcp — fill CLI gaps, remove crate and all references ([#850](https://github.com/block/buzz/pull/850)) ([`f1c672fea`](https://github.com/block/buzz/commit/f1c672fea53de252055d96bb76685a5bef731b9b))
- Fix custom emoji status in profile popover ([#874](https://github.com/block/buzz/pull/874)) ([`5bdac0566`](https://github.com/block/buzz/commit/5bdac0566fb8e73be99cfc4540af12f68a90448f))
- fix(agent): gate handoff on provider token usage, not byte estimate ([#821](https://github.com/block/buzz/pull/821)) ([`b295f51c9`](https://github.com/block/buzz/commit/b295f51c904946e78071ac6e45297396c88ee87f))
- docs: add VISION_MESH.md — the compute-commons vision ([#867](https://github.com/block/buzz/pull/867)) ([`cdb7bc27e`](https://github.com/block/buzz/commit/cdb7bc27e1183e7616b477877f2e8273ff26db8c))
- fix(desktop): simplify profile popover header ([#853](https://github.com/block/buzz/pull/853)) ([`d4bb7f66e`](https://github.com/block/buzz/commit/d4bb7f66e0df89682ceff0765f57b5e27f1ef8b3))
- fix(desktop): remove thread comment hover outline ([#861](https://github.com/block/buzz/pull/861)) ([`ccff5464a`](https://github.com/block/buzz/commit/ccff5464a41fce777d7f00fab15fec459bbac322))
- feat(desktop): always show channel section search/add buttons ([#856](https://github.com/block/buzz/pull/856)) ([`ad7ab482e`](https://github.com/block/buzz/commit/ad7ab482eb16bed63209d473110a2006a2e6caeb))
## v0.3.11
- fix(mobile+desktop): cross-device read state sync + diagnostic logging ([#843](https://github.com/block/buzz/pull/843)) ([`269b35e8d`](https://github.com/block/buzz/commit/269b35e8de7b2cb6b6908b1bddee9460774b2802))
- feat(mobile): star channels (Slack-style favorites) ([#863](https://github.com/block/buzz/pull/863)) ([`3ddfe5fcc`](https://github.com/block/buzz/commit/3ddfe5fcc25992bd0733d5013589a3521c9989f0))
- feat: desktop-screenshot skill to stop agents uploading relay media to PRs ([#862](https://github.com/block/buzz/pull/862)) ([`36d7dbd7c`](https://github.com/block/buzz/commit/36d7dbd7cae45311252a6c10bdf915c0a3dd3e44))
- feat(desktop): star channels (Slack-style favorites) ([#860](https://github.com/block/buzz/pull/860)) ([`c10b4f8f5`](https://github.com/block/buzz/commit/c10b4f8f5c646a5d89dfc29bb14ca1c0aa11faea))
- fix(desktop): handle symlinked persona pack directories ([#859](https://github.com/block/buzz/pull/859)) ([`f748f7126`](https://github.com/block/buzz/commit/f748f71268eb4f61e0e1dde5d216068b76e69804))
- feat: channel muting for desktop and mobile ([#838](https://github.com/block/buzz/pull/838)) ([`1fe7bf287`](https://github.com/block/buzz/commit/1fe7bf2872561d2e2f36b0db4202611fb665f676))
- feat(acp): default SPROUT_ACP_MEMORY to on ([#854](https://github.com/block/buzz/pull/854)) ([`4ead7de46`](https://github.com/block/buzz/commit/4ead7de46301efa4743df5b4c5f06374de537c6c))
- fix(desktop): eliminate image-hover layout jump in messages ([#813](https://github.com/block/buzz/pull/813)) ([`759e5cd92`](https://github.com/block/buzz/commit/759e5cd923544c307b5b81992ae6010df437bdd2))
## v0.3.10
- fix(desktop): harden relay mesh connect p-tag ([#834](https://github.com/block/buzz/pull/834)) ([`34ac3ba1d`](https://github.com/block/buzz/commit/34ac3ba1d1825e1dfbe5d81dd67e9b7375b29785))
- fix(desktop): scroll activity panel to bottom on open ([#848](https://github.com/block/buzz/pull/848)) ([`b3aefae15`](https://github.com/block/buzz/commit/b3aefae152ebc68a7f8d8720396f3e6ec06c947b))
- Polish desktop profile menu interactions ([#836](https://github.com/block/buzz/pull/836)) ([`a13691b62`](https://github.com/block/buzz/commit/a13691b6207fedb1b663ec1458bdea76bb6ec64c))
- fix(desktop): outline thread hover targets ([#845](https://github.com/block/buzz/pull/845)) ([`0d9b8148f`](https://github.com/block/buzz/commit/0d9b8148f86e39651871e438cad4b17560d5ffef))
- fix(desktop): keep message actions hover-only ([#844](https://github.com/block/buzz/pull/844)) ([`b3be9ecba`](https://github.com/block/buzz/commit/b3be9ecba70f17c12096e52489a6f7de3e00cd56))
- fix(desktop): let inbox composer fill available width ([#841](https://github.com/block/buzz/pull/841)) ([`db46c4254`](https://github.com/block/buzz/commit/db46c4254636561fcbec6f63478997102caab770))
- fix: use immutable commit-SHA URLs in screenshot PR comments ([#842](https://github.com/block/buzz/pull/842)) ([`2a0572c0d`](https://github.com/block/buzz/commit/2a0572c0d8e4d85afd58e161ade2f927bf19d76d))
- feat(mobile+desktop): two-tier Slack-style app icon badge ([#802](https://github.com/block/buzz/pull/802)) ([`3b78dc569`](https://github.com/block/buzz/commit/3b78dc5690b915822fdbe82c20b1bd565ab26108))
- chore: simplify file-size check to a flat 1000-line limit ([#839](https://github.com/block/buzz/pull/839)) ([`0c225f4d7`](https://github.com/block/buzz/commit/0c225f4d7d32d79ba38fb154fa878564d6f851de))
- fix(desktop): robust emoji picker — unify picker + fix custom emoji in editing, status, reactions ([#837](https://github.com/block/buzz/pull/837)) ([`d8b602a35`](https://github.com/block/buzz/commit/d8b602a359508c816bb7bee468a3e5eb389e3f81))
- feat(desktop): reusable screenshot workflow for agents ([#826](https://github.com/block/buzz/pull/826)) ([`06bc67fd3`](https://github.com/block/buzz/commit/06bc67fd342b658bd3ade26d23ac9d63bfb0392a))
- desktop(mesh-llm): let a serving node route a different model ([#833](https://github.com/block/buzz/pull/833)) ([`9f0c22a43`](https://github.com/block/buzz/commit/9f0c22a43a6b13030e0e0f75e25fca9983710a18))
## v0.3.9
- fix: native arbitrary-file download + image context-menu flash ([#830](https://github.com/block/buzz/pull/830)) ([`82ae85f79`](https://github.com/block/buzz/commit/82ae85f79a9e687b51a599f44cf8e8b47b756b80))
- fix(desktop): custom emoji reaction rendering + picker autofocus ([#831](https://github.com/block/buzz/pull/831)) ([`7797ae77f`](https://github.com/block/buzz/commit/7797ae77f6412972a95a9f3ffd90555fbd2d1051))
- Mesh-LLM v1: relay-gated direct-iroh inference between users (WAN) ([#822](https://github.com/block/buzz/pull/822)) ([`33cfc8529`](https://github.com/block/buzz/commit/33cfc8529325b99ebdf4afc1c4761a9ed87dad86))
## v0.3.8
## v0.3.7
- feat: custom emoji — user-owned NIP-30 sets with a client-side union ([#816](https://github.com/block/buzz/pull/816)) ([`234942130`](https://github.com/block/buzz/commit/2349421304d84b0b2537bebf2cf7923a1ce30e27))
- Install sprout-cli skill at repo root + fix desktop clippy ([#818](https://github.com/block/buzz/pull/818)) ([`7a12e5051`](https://github.com/block/buzz/commit/7a12e50518ec51db23c83301b99c66e9bbfdb95f))
- fix(desktop): use public re-export path for ensure_client_node_for_model ([#824](https://github.com/block/buzz/pull/824)) ([`2ea3fd88d`](https://github.com/block/buzz/commit/2ea3fd88d23966985e06af494a556fbdd4efc300))
- refactor(desktop): feature-gate mesh-llm-sdk behind optional Cargo feature ([#823](https://github.com/block/buzz/pull/823)) ([`fb514c891`](https://github.com/block/buzz/commit/fb514c8918d06b1b76e16c78fa6397c2871d80b4))
- fix(desktop): align workflow read/save commands to the frontend contract ([#820](https://github.com/block/buzz/pull/820)) ([`b72eee365`](https://github.com/block/buzz/commit/b72eee365f02465334869c5e48fd6136a829d39c))
- fix(desktop): disable mesh-llm auto-build to prevent git config corruption ([#819](https://github.com/block/buzz/pull/819)) ([`5b572d6f5`](https://github.com/block/buzz/commit/5b572d6f5e94ff4d8199a4da40342f301a2896e6))
- fix(desktop): clear clippy lints in agents/mesh_llm commands ([#817](https://github.com/block/buzz/pull/817)) ([`192388a3c`](https://github.com/block/buzz/commit/192388a3cbf7df254e0117be8459574282f52deb))
- fix(desktop): let channel members add members and bots without admin ([#815](https://github.com/block/buzz/pull/815)) ([`41a3fc158`](https://github.com/block/buzz/commit/41a3fc1589bed81d0ae856036dcf4676b53e1969))
- Desktop #806 follow-ups: panel/inbox fixes + top-bar backdrop ([#814](https://github.com/block/buzz/pull/814)) ([`a25ca5d1b`](https://github.com/block/buzz/commit/a25ca5d1bfbc0bfb7c8a2a2c0ca9bb093423d09e))
- Fix desktop right-side panel chrome overlap ([#806](https://github.com/block/buzz/pull/806)) ([`5bed17a11`](https://github.com/block/buzz/commit/5bed17a1173457d61760cfb5a7ba6840e83ec0cd))
- Sprout × mesh-llm: in-process mesh node (serve/consume) + relay admission ([#798](https://github.com/block/buzz/pull/798)) ([`ede8ddb42`](https://github.com/block/buzz/commit/ede8ddb425ccc09130949752e374a8bcaf842ad1))
- fix(desktop): resolve flaky integration tests via project-level assertion timeout ([#812](https://github.com/block/buzz/pull/812)) ([`6481428e2`](https://github.com/block/buzz/commit/6481428e2b9a505c771277478b50b01a01242c5e))
## v0.3.6
- feat(mobile): add channel sections with relay sync ([#800](https://github.com/block/buzz/pull/800)) ([`5cbedb180`](https://github.com/block/buzz/commit/5cbedb180af56274a518c5233e4de918313bb8d1))
- feat(desktop): sync channel sections across devices via Nostr ([#792](https://github.com/block/buzz/pull/792)) ([`753d0fe26`](https://github.com/block/buzz/commit/753d0fe264d4d997f8291f801547242420ba7d97))
- feat(media): support arbitrary file types with download cards ([#810](https://github.com/block/buzz/pull/810)) ([`2b052eb46`](https://github.com/block/buzz/commit/2b052eb465f305fc02a535e96012665da34d4d6b))
- feat(desktop): add user-defined channel sections to sidebar ([#789](https://github.com/block/buzz/pull/789)) ([`247ac5239`](https://github.com/block/buzz/commit/247ac5239151af6537ae7a16c43c7e99bbe4463e))
- feat(desktop): keyboard shortcuts — ⌘⇧N new channel + ↑-to-edit last message ([#809](https://github.com/block/buzz/pull/809)) ([`d810608d8`](https://github.com/block/buzz/commit/d810608d85926146e5d37890635919c022e761a0))
- fix(desktop): scope agent sweep to the owning app instance ([#808](https://github.com/block/buzz/pull/808)) ([`39911e428`](https://github.com/block/buzz/commit/39911e42859df41d8ea88195c82e010eb3d98283))
- fix(desktop): route notification clicks to thread context ([#790](https://github.com/block/buzz/pull/790)) ([`f2c266bac`](https://github.com/block/buzz/commit/f2c266bac2349f8d5e34c22d42ef0e55c227f36d))
- chore(deps): update all non-major dependencies ([#804](https://github.com/block/buzz/pull/804)) ([`33e37de6e`](https://github.com/block/buzz/commit/33e37de6e37cbb73e1ac2ef2499a280f6fcce3a8))
- fix(deps): re-pin isomorphic-git patch to 1.38.3 ([#807](https://github.com/block/buzz/pull/807)) ([`5670ffc6a`](https://github.com/block/buzz/commit/5670ffc6a6ec7d6ae6b7b2be4006e23580a10801))
- chore(deps): update dependency @tanstack/react-query to v5.100.14 ([#805](https://github.com/block/buzz/pull/805)) ([`033d92f10`](https://github.com/block/buzz/commit/033d92f103ea74fd577f0c2cacc845b1399a5c23))
- Fix desktop glass chrome and inbox previews ([#793](https://github.com/block/buzz/pull/793)) ([`bc23620fc`](https://github.com/block/buzz/commit/bc23620fcfae05813b5767d6b0eb830ab108b2b4))
- refactor(just): slim down mobile-dev to just run Flutter ([#801](https://github.com/block/buzz/pull/801)) ([`9b9cf4612`](https://github.com/block/buzz/commit/9b9cf461278f0d554523f76cbf80f49e80980bdf))
- refactor: consolidate infra management into justfile + add mobile-dev ([#797](https://github.com/block/buzz/pull/797)) ([`2a0385152`](https://github.com/block/buzz/commit/2a03851527f2fad199581e49ea5168cc7304b090))
## v0.3.5
- feat(mobile): Pulse polish — flat feed, compose page, shared filter chips, like + accent fixes ([#796](https://github.com/block/buzz/pull/796)) ([`b82042090`](https://github.com/block/buzz/commit/b820420909d225b18c45f1bd330a00a2edbee09b))
- feat(desktop): add standalone Playwright screenshot helper ([#795](https://github.com/block/buzz/pull/795)) ([`10f37e4ca`](https://github.com/block/buzz/commit/10f37e4cab244c6da319bbad803d65cf3981b794))
- feat(sprout-agent): load AGENTS.md and SKILL.md into system prompt ([#762](https://github.com/block/buzz/pull/762)) ([`f34a21d32`](https://github.com/block/buzz/commit/f34a21d32ff31e59a1ff2c11ce4857a483a0a806))
- feat: add code block support to message composer ([#788](https://github.com/block/buzz/pull/788)) ([`85861f33f`](https://github.com/block/buzz/commit/85861f33fe006226b59ab8273d7c357af09a781d))
- fix(desktop): reap orphaned agent processes on shutdown and restart ([#787](https://github.com/block/buzz/pull/787)) ([`7beb0f8e6`](https://github.com/block/buzz/commit/7beb0f8e6855c2cbef3c9e6310cff5a7368bdd33))
## v0.3.4
- Update desktop navigation chrome and search ([#779](https://github.com/block/buzz/pull/779)) ([`d77b111b1`](https://github.com/block/buzz/commit/d77b111b1531a7f2992ab7b60edb975549707b4f))
- feat(desktop): reload webview on Cmd/Ctrl+R ([#785](https://github.com/block/buzz/pull/785)) ([`5ee2cd051`](https://github.com/block/buzz/commit/5ee2cd05173d5bb5a8711753394ed23ab82f0f4e))
- fix(desktop): sync persona pack directory across worktree instances ([#782](https://github.com/block/buzz/pull/782)) ([`fa7febe40`](https://github.com/block/buzz/commit/fa7febe40f57d2bbf019d42e0079618625783ce7))
## v0.3.3
- fix(release): sync release tags during preflight ([#780](https://github.com/block/buzz/pull/780)) ([`c761a76ff`](https://github.com/block/buzz/commit/c761a76ff2d5789c57159c46bcf08fd02e16d82a))
- feat(desktop): thread-aware notifications with mutable follow/mute controls ([#761](https://github.com/block/buzz/pull/761)) ([`3f3ec6479`](https://github.com/block/buzz/commit/3f3ec64791b7cd40ddaa8d8332e00e6f11c09a8d))
- fix(desktop): improve model picker message and runtime dropdown clarity ([#778](https://github.com/block/buzz/pull/778)) ([`03e678cfb`](https://github.com/block/buzz/commit/03e678cfbcaa04d5bcfe11ff151c47e5007ba247))
- desktop: float unread indicator + fix sidebar scroll jump ([#777](https://github.com/block/buzz/pull/777)) ([`9db8f6ccb`](https://github.com/block/buzz/commit/9db8f6ccb887c67b90fcd6c9972aaa423b34c696))
- chore(hooks): standardize check/fix convention with auto-fix pre-commit ([#776](https://github.com/block/buzz/pull/776)) ([`3fbee555f`](https://github.com/block/buzz/commit/3fbee555f067ef80a75bfc52388a40bc336bf6e8))
- web: clickable repo tree + per-file blob viewer ([#773](https://github.com/block/buzz/pull/773)) ([`0f89ad169`](https://github.com/block/buzz/commit/0f89ad16925038db5d22e8b15ab7f69898c77b56))
- fix(agent): keep parallel tool-result messages contiguous on OpenAI Chat (Databricks image fix) ([#770](https://github.com/block/buzz/pull/770)) ([`61297ac80`](https://github.com/block/buzz/commit/61297ac80a30a13e63d4f01178aec1d33573b193))
- fix(release): fetch tags so changelog tracks versions correctly ([#775](https://github.com/block/buzz/pull/775)) ([`5f2423c23`](https://github.com/block/buzz/commit/5f2423c231e393dbc870c75be31f9ea61d9d6328))
## v0.3.2
- feat(mobile): add Pulse social feed tab ([#772](https://github.com/block/buzz/pull/772)) ([`1218572fa`](https://github.com/block/buzz/commit/1218572fa0380b5517a3c6a2aff34bdc51fa90e6))
- feat(sidebar): add More unread floating buttons ([#771](https://github.com/block/buzz/pull/771)) ([`fec6a683e`](https://github.com/block/buzz/commit/fec6a683ea88c25c76f29d5eb4d39709765a791b))
- chore: improve markdown spacing ([#766](https://github.com/block/buzz/pull/766)) ([`8eedec740`](https://github.com/block/buzz/commit/8eedec74030ce9cc32366def02c933267f853e80))
- fix: prevent inline links rendering in 2-column grid layout ([#767](https://github.com/block/buzz/pull/767)) ([`835a44aad`](https://github.com/block/buzz/commit/835a44aad03e782eeb76d716062767617b47e51b))
## v0.3.1
- [codex] Default release command to patch bump ([#768](https://github.com/block/buzz/pull/768)) ([`4222a758c`](https://github.com/block/buzz/commit/4222a758c6491fbbae113e862eb732c769a3dcf8))
- Polish desktop Pulse and Home views ([#764](https://github.com/block/buzz/pull/764)) ([`30654e95f`](https://github.com/block/buzz/commit/30654e95f6550854a16ce46bfbbd48dbcaa1b645))
## v0.3.0
- Initial release on the automated pipeline. Unifies OSS and internal version numbering above both v0.0.21 (OSS) and v0.2.38 (internal).

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -1,133 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**conduct@buzz-relay.org**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View File

@@ -1,453 +0,0 @@
# Contributing to Buzz
Welcome, and thank you for your interest in contributing! Buzz is an
open-source project and we're glad you're here. This guide will help you
get from zero to a merged pull request.
If you have questions that aren't answered here, open a GitHub Discussion or
reach out in the community channels.
---
## Table of Contents
1. [Code of Conduct](#code-of-conduct)
2. [Setting Up the Development Environment](#setting-up-the-development-environment)
3. [Running Tests](#running-tests)
4. [Code Style](#code-style)
5. [Making a Pull Request](#making-a-pull-request)
6. [Architecture Overview](#architecture-overview)
7. [Ecosystem](#ecosystem)
8. [How to Add a New Event Kind](#how-to-add-a-new-event-kind)
9. [How to Add a New MCP Tool](#how-to-add-a-new-mcp-tool)
10. [How to Add a New API Endpoint](#how-to-add-a-new-api-endpoint)
11. [License and CLA](#license-and-cla)
---
## Code of Conduct
This project follows the [Contributor Covenant v2.1](CODE_OF_CONDUCT.md).
By participating you agree to uphold these standards. Please report
unacceptable behavior to **conduct@buzz-relay.org**.
---
## Setting Up the Development Environment
### Prerequisites
| Tool | Version | Notes |
|------|---------|-------|
| Rust | 1.88+ | Install via [rustup](https://rustup.rs/) |
| Node.js | 24+ | Required for desktop app commands and `just ci` |
| pnpm | 10+ | Required for desktop app commands and `just ci` |
| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) |
| Docker | 24+ | For Postgres, Redis, MinIO |
| `just` | latest | Task runner — `cargo install just` |
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
| `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` |
This repo uses [Hermit](https://cashapp.github.io/hermit/) for toolchain
pinning. Activate it once per shell session:
```bash
. ./bin/activate-hermit
```
Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in
`bin/`. Each tool is downloaded on first use. You can also run `just bootstrap`
(which `just setup` calls automatically) to pre-download all required tools
upfront. If you don't use Hermit, ensure your toolchain meets the minimum
versions in the table above.
### First-Time Setup
```bash
# 1. Clone the repo
git clone https://github.com/block/buzz.git
cd buzz
# 2. Activate Hermit (optional but recommended)
. ./bin/activate-hermit
# 3. Bootstrap tools + infrastructure
just setup
# 4. Install Git hooks (optional, recommended)
just hooks
```
`just setup` runs `just bootstrap` first — it copies `.env.example` to `.env`
if it doesn't already exist, and invokes `cargo`, `node`, and `pnpm` to trigger
Hermit's lazy tool download (each tool is fetched once on first invocation and
cached thereafter). You can also run `just bootstrap` independently at any time;
it is safe to re-run.
`just setup` then starts Docker services (Postgres on `:5432`, Redis on `:6379`,
Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on
`:9000` for media storage, and Prometheus on `:9090` for metrics) and runs all
pending database migrations.
### Running the Relay and Desktop App
```bash
just dev # starts the relay + desktop app in one command
```
`just dev` builds all agent tools, starts the relay (`ws://localhost:3000`) in
the background, and launches the Tauri desktop app. The relay process is
automatically killed when you quit the app or press Ctrl+C.
For a split-terminal workflow (relay logs visible separately from Vite output):
```bash
just relay # terminal 1 — relay on ws://localhost:3000
just desktop-dev # terminal 2 — Vite dev server only (no Tauri shell)
```
### Stopping / Resetting
```bash
just down # Stop Docker services, keep data
just reset # Wipe all dev state and recreate it; installed Buzz is preserved
```
Development desktop state uses separate bundle identifiers
(`xyz.block.buzz.app.dev` and per-worktree variants), a separate keyring service
(`buzz-desktop-dev`), and `~/.buzz-dev`. `just reset` removes those dev-only
locations and the local Docker volumes. It does not touch the installed app's
`xyz.block.buzz.app` data, `buzz-desktop` keyring service, or `~/.buzz` nest.
---
## Running Tests
### Unit Tests (no infrastructure required)
```bash
just test-unit
```
Unit tests are self-contained and run without Docker. They cover event
parsing, filter matching, auth logic, workflow YAML parsing, and more.
### Integration Tests (requires running infrastructure)
```bash
just test
```
Integration tests spin up the relay and exercise the full stack — WebSocket
connections, NIP-42 auth, event ingestion, search indexing, and workflow
execution. `just test` starts Docker services automatically if they're not
already running.
### End-to-End Tests
End-to-end tests live in `crates/buzz-test-client/tests/`:
- `e2e_relay.rs` — WebSocket relay tests
- `e2e_mcp.rs` — MCP tool tests
- `e2e_nostr_interop.rs` — Nostr protocol interoperability tests
- `e2e_media.rs` — media upload/download tests
- `e2e_media_extended.rs` — extended media tests (GIF, image processing)
Run them with (requires running infrastructure):
```bash
cargo test -p buzz-test-client -- --ignored
```
See `TESTING.md` for the full multi-agent E2E testing guide.
### CI Gate
Before opening a PR, run the full CI gate locally:
```bash
just ci
# Runs: check + unit tests + desktop build + Tauri check + mobile tests
```
This is the same check that runs in CI. PRs that fail `just ci` will not be
merged.
---
## Code Style
### Formatting
We use `rustfmt` with default settings. Format your code before committing:
```bash
cargo fmt --all
```
To check without modifying:
```bash
cargo fmt --all -- --check
```
### Linting
We use `clippy` with warnings-as-errors:
```bash
cargo clippy --all-targets --all-features -- -D warnings
```
Fix all clippy warnings before submitting a PR. If you believe a warning is
a false positive, add a targeted `#[allow(...)]` with a comment explaining
why.
### No Unsafe Code
All crates enforce `#![deny(unsafe_code)]`. Do not add unsafe blocks. If you
believe unsafe is genuinely necessary, open an issue first to discuss the
approach.
### Error Handling
- Use `thiserror` for library error types.
- Use `anyhow` for binary / application-level error propagation.
- Do not use `unwrap()` or `expect()` in production code paths. Use `?` or
explicit error handling. `unwrap()` is acceptable in tests.
### Logging and Tracing
Use the `tracing` crate for all instrumentation. Prefer structured fields
over string interpolation:
```rust
// Good
tracing::info!(channel_id = %id, event_kind = kind, "Event ingested");
// Avoid
tracing::info!("Event ingested: channel={id} kind={kind}");
```
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(mcp): add get_feed_actions tool
fix(auth): reject expired NIP-42 challenges
docs(agents): document workflow MCP tools
refactor(db): extract channel queries into channel.rs
test(workflow): add approval gate integration test
```
The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is
required. The scope (in parentheses) is optional but encouraged.
---
## Making a Pull Request
### Before You Start
- Check open issues and PRs to avoid duplicate work.
- For significant changes, open an issue first to discuss the approach.
- For small fixes (typos, doc improvements, obvious bugs), go ahead and open
a PR directly.
### What a Good PR Looks Like
1. **Focused** — one logical change per PR. If you're fixing a bug and
refactoring a module, split them into two PRs.
2. **Tested** — new behavior has tests. Bug fixes include a regression test.
If a test is impractical, explain why in the PR description.
3. **Documented** — public APIs, new event kinds, new MCP tools, and new
config variables are documented. Update `README.md`, `AGENTS.md`, or
`VISION.md` as appropriate.
4. **CI passing** — `just ci` passes locally before you push.
5. **Clear description** — the PR description explains:
- What problem this solves (or what feature it adds)
- How it was implemented (key decisions, trade-offs)
- How to test it manually (if applicable)
- Any follow-up work deferred to a future PR
### PR Checklist
```
- [ ] `just ci` passes (fmt + clippy + unit tests + mobile)
- [ ] Integration tests pass (`just test`)
- [ ] New public APIs / tools / endpoints are documented
- [ ] No new `unwrap()` in production code paths
- [ ] No new `unsafe` blocks
```
### Review Process
- A maintainer will review your PR within a few business days.
- Address review comments by pushing new commits (don't force-push during
review; it makes it hard to see what changed).
- Once approved, a maintainer will squash-merge your PR.
---
## Architecture Overview
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design and
[AGENTS.md](AGENTS.md#repo-structure) for the complete crate map. The key
design principles:
**The relay is the single source of truth.** All state flows through the
event store. Crates communicate through the database and Redis pub/sub — not
through direct function calls across crate boundaries (with the exception
of `buzz-core` types, which are shared everywhere).
**Event kinds are the only switch.** Every action in the system — a message,
a reaction, a workflow step, a canvas update — is a Nostr event with a kind
integer. Adding a new feature means defining a new kind. No breaking changes
to existing clients.
---
## Ecosystem
Buzz is developed across multiple repositories. This repo (`block/buzz`)
is the open-source home for all application code — the relay, desktop app,
mobile app, CLI, and agent harness. Internal repositories handle
enterprise-signed builds and infrastructure deployment.
See [AGENTS.md § Ecosystem](AGENTS.md#ecosystem) for the full repo table and
dependency diagram.
**External contributors:** Fork `block/buzz`, open a PR, and CI runs
automatically. No special access is required.
**Block team members:** See the internal
[sprout-releases CONTRIBUTING.md](https://github.com/squareup/sprout-releases/blob/main/CONTRIBUTING.md)
for team access setup, onboarding, and the full repo inventory. See
[RELEASING.md](RELEASING.md) for the release process.
---
## How to Add a New Event Kind
1. **Define the kind constant** in `buzz-core/src/kind.rs`:
```rust
/// My new event kind — description of what it represents.
pub const KIND_MY_FEATURE: u32 = 4XXXX;
```
Pick a kind number in the appropriate sub-range defined in `kind.rs`.
Check the `ALL_KINDS` array for collisions. Each sub-range is documented
with comments in the file.
2. **Define the payload type** in the appropriate module in `buzz-core/src/`
(e.g., alongside `event.rs`) if the content field is structured JSON:
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct MyFeaturePayload {
pub field_one: String,
pub field_two: Option<u64>,
}
```
3. **Register the kind's required scope** in
`crates/buzz-relay/src/handlers/ingest.rs` inside
`required_scope_for_kind()`. This controls which auth scope a caller
needs to submit the event:
```rust
KIND_MY_FEATURE => Ok(Scope::MessagesWrite),
```
4. **Handle post-storage side effects** by adding a match arm in
`crates/buzz-relay/src/handlers/side_effects.rs` inside
`handle_side_effects()`:
```rust
KIND_MY_FEATURE => handle_my_feature(event, state).await?,
```
`handle_side_effects()` runs after the event is stored — use it for
notifications, cache invalidation, or derived data. If the new kind
also needs an HTTP bridge surface (for example, a protocol helper that
cannot practically use WebSocket), add a handler in
`crates/buzz-relay/src/api/` and register it in
`crates/buzz-relay/src/router.rs`.
5. **Persist to the database** — if the event needs to be queryable, add a
handler in `buzz-db/src/` (e.g., `buzz-db/src/my_feature.rs`) with
the appropriate `INSERT` and `SELECT` queries.
6. **Index for search** (if applicable) — Postgres FTS indexes persisted
events automatically via the `events.search_tsv` generated column. To
exclude a privacy-sensitive kind from search, add it to the `CASE WHEN
kind IN (...)` exclusion in the `search_tsv` definition (see the initial
schema migration) rather than wiring a separate indexer.
7. **Audit** — the audit log captures all events automatically; no changes
needed unless you need custom audit metadata.
8. **Write tests** — add a unit test for payload serialization in
`buzz-core` and an integration test in `buzz-test-client` that sends
the new event kind and verifies the expected behavior.
9. **Document** — `kind.rs` is the authoritative registry of all kind numbers.
Update `README.md` if it's a user-facing feature.
---
## How to Add a New API Endpoint
Prefer a signed Nostr event and the existing WebSocket/`POST /events` ingest
path over adding endpoint-specific JSON APIs. The relay intentionally exposes
only a narrow HTTP surface: NIP-11/NIP-05 metadata, `/events`, `/query`,
`/count`, `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and
health probes.
If an HTTP endpoint is still necessary:
1. **Define the handler** in the appropriate module under
`crates/buzz-relay/src/api/`. Resolve the request tenant before any auth or
data lookup, use NIP-98 when the endpoint accepts user credentials, and keep
community scoping explicit.
2. **Register the route** in `crates/buzz-relay/src/router.rs` using the
narrowest path possible. Do not add new `/api/*` compatibility routes unless
the product decision explicitly calls for one.
3. **Add database queries** in `buzz-db/src/` only when the endpoint cannot be
expressed through the existing event query paths.
4. **Handle errors** using the `api_error()`, `internal_error()`, and
`not_found()` helpers in `buzz-relay/src/api/mod.rs`. Return
`(StatusCode, Json<Value>)` tuples.
5. **Write tests** with the `buzz-test-client` harness in
`crates/buzz-test-client/tests/`, covering auth, community scoping, and the
relevant success path.
6. **Document** any public endpoint in `ARCHITECTURE.md` and user-facing docs.
---
## License and CLA
Buzz is licensed under the **Apache License, Version 2.0**. See
[LICENSE](LICENSE) for the full text.
By submitting a pull request, you agree that your contribution is licensed
under the Apache 2.0 license and that you have the right to submit it.
If your employer has rights to intellectual property you create, you may need
their sign-off. When in doubt, check with your legal team.
---
*Thank you for contributing to Buzz. Every bug report, documentation fix,
and code contribution makes the project better for everyone. 🐝*

11065
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +0,0 @@
[workspace]
members = [
"crates/buzz-relay",
"crates/buzz-core",
"crates/buzz-conformance",
"crates/buzz-push-gateway",
"crates/buzz-db",
"crates/buzz-pubsub",
"crates/buzz-auth",
"crates/buzz-search",
"crates/buzz-audit",
"crates/buzz-acp",
"crates/buzz-agent",
"crates/sprig",
"crates/buzz-test-client",
"crates/buzz-ws-client",
"crates/buzz-admin",
"crates/buzz-workflow",
"crates/buzz-media",
"crates/buzz-cli",
"crates/buzz-pairing-cli",
"crates/buzz-sdk",
"crates/buzz-persona",
"crates/git-credential-nostr",
"crates/git-sign-nostr",
"crates/buzz-pair-relay",
"crates/buzz-relay-mesh",
"crates/buzz-dev-mcp",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.88.0"
license = "Apache-2.0"
repository = "https://github.com/block/sprout"
[workspace.dependencies]
# Runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal", "process"] }
tokio-util = { version = "0.7", features = ["rt", "codec"] }
# HTTP + WebSocket
axum = { version = "0.8", features = ["ws", "macros"] }
tower = { version = "0.5", features = ["timeout", "util", "limit"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "timeout", "fs"] }
# Database
sqlx = { version = "0.9", features = [
"runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"
] }
# Redis
redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] }
deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] }
# Nostr
nostr = { version = "0.44", features = ["nip44", "nip98"] }
# Serialization
serde = { version = "1", features = ["derive"] }
postcard = { version = "1", default-features = false, features = ["use-std"] }
# Inter-relay mesh transport (buzz-relay-mesh)
iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] }
serde_json = "1"
serde_yaml = "0.9"
evalexpr = "11"
cron = "0.16"
# Observability
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = { version = "0.33" }
opentelemetry = { version = "0.32", features = ["trace"] }
opentelemetry_sdk = { version = "0.32", features = ["trace", "rt-tokio"] }
opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "grpc-tonic", "tls-ring"] }
metrics = "0.24"
metrics-exporter-prometheus = "0.18"
metrics-util = "0.20"
# Error handling
thiserror = "2"
anyhow = "1"
# Utilities
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# HTTP client (webhook delivery)
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }
# Cryptography
sha2 = "0.11"
hex = "0.4"
hmac = "0.13"
# Randomness
rand = "0.10"
subtle = "2.6"
zeroize = "1.8"
# Concurrent data structures
dashmap = "6"
moka = { version = "0.12", features = ["sync"] }
# Async stream utilities
futures-util = "0.3"
# WebSocket client (test client)
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
url = "2"
# Property-based testing (dev-only)
proptest = "1"
# MCP SDK (used by buzz-dev-mcp and buzz-agent)
rmcp = { version = "1.1.0", features = ["server", "transport-io", "macros"] }
schemars = { version = "1", default-features = false }
# Internal crates
buzz-core = { path = "crates/buzz-core" }
buzz-conformance = { path = "crates/buzz-conformance" }
buzz-db = { path = "crates/buzz-db" }
buzz-auth = { path = "crates/buzz-auth" }
buzz-pubsub = { path = "crates/buzz-pubsub" }
buzz-search = { path = "crates/buzz-search" }
buzz-audit = { path = "crates/buzz-audit" }
buzz-workflow = { path = "crates/buzz-workflow" }
buzz-media = { path = "crates/buzz-media" }
buzz-sdk = { path = "crates/buzz-sdk" }
buzz-ws-client = { path = "crates/buzz-ws-client" }
buzz-relay-mesh = { path = "crates/buzz-relay-mesh" }
# CI profile — builds the relay for desktop e2e. Dependencies keep full
# release optimization (warm from main's cache; they carry the runtime hot
# path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for
# stable e2e timing (PR #307 flakiness was opt-0 + debug-assertions) at
# roughly half the codegen cost. `incremental` is irrelevant in CI:
# rust-cache exports CARGO_INCREMENTAL=0 and never caches member artifacts.
[profile.ci]
inherits = "release"
lto = false
opt-level = 1
[profile.ci.package."*"]
opt-level = 3
# Sprig profile — optimized for deploy-anywhere Sprig release artifacts.
# Sprig is distributed over the network and installed on fresh hosts, so binary
# size matters more than compile speed here. Keep this separate from the normal
# `release` profile so desktop/dev release builds do not inherit the slower
# size-focused settings unless they opt in explicitly.
[profile.sprig]
inherits = "release"
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
# Temporary fork pin: aws-creds 0.39.1 (via rust-s3) cannot read EKS Pod Identity
# credentials (AWS_CONTAINER_CREDENTIALS_FULL_URI + AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE),
# which the relay pod on bb-block requires for S3 media + git storage. This pins
# aws-creds to a fork that adopts the aws-creds portion of durch/rust-s3#449
# (FULL_URI + token-file + Authorization header, refresh-safe, with a loopback
# allowlist for the auth token). Revert to crates.io once #449 lands upstream.
[patch.crates-io]
aws-creds = { git = "https://github.com/tlongwell-block/rust-s3", rev = "c9fce3620dd434c1f810101d672cf384268dbb0f" }

View File

@@ -1,151 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Public Buzz relay image — published as ghcr.io/block/buzz:<tag>.
#
# Builds the `buzz-relay` binary (Rust 1.95) and the `buzz-web` static bundle
# (pnpm + vite), then assembles them into a small debian-slim runtime with
# `git` available (the relay shells out to git for repo hydrate / receive-pack
# / upload-pack — see crates/buzz-relay/src/api/git).
#
# Multi-arch is handled by running this same Dockerfile on native amd64 and
# native arm64 runners (see .github/workflows/docker.yml). The Dockerfile
# itself is platform-agnostic; do not add --platform pins.
ARG RUST_VERSION=1.95
ARG NODE_VERSION=24
ARG DEBIAN_VERSION=bookworm
# Optional extra CA bundle for builds behind a TLS-intercepting corporate proxy
# (e.g. a Cloudflare/Zscaler gateway that re-signs TLS). Empty by default, so
# public CI builds are unaffected. Point it at a PEM file in the build context:
# docker build --build-arg EXTRA_CA_CERTS=path/to/proxy-ca.pem ...
# Consumed by the network-touching stages below (cargo + pnpm).
ARG EXTRA_CA_CERTS=
# Optional npm registry for builds where the public registry is unreachable or
# policy-blocked (e.g. a corporate mirror / Artifactory). Empty default = public
# npmjs, so public CI builds are unaffected. Consumed by the web-builder stage.
ARG NPM_REGISTRY=
# ─── Stage 1: cargo-chef base ───────────────────────────────────────────────
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
# Trust an optional corporate-proxy CA before any network fetch (no-op if unset).
ARG EXTRA_CA_CERTS
COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src
RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \
cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \
&& update-ca-certificates \
&& echo "CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt" >> /etc/environment; \
fi
ENV CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt
RUN cargo install cargo-chef --locked --version 0.1.71
WORKDIR /build
# ─── Stage 2: plan dependency graph ─────────────────────────────────────────
# Only the manifests are needed to compute the recipe; this layer rebuilds
# only when Cargo.{toml,lock} or crate manifests change, not on every source
# edit.
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# ─── Stage 3: cook dependencies, then build the binary ──────────────────────
FROM chef AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libssl-dev \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
COPY --from=planner /build/recipe.json recipe.json
# Cook the full workspace recipe — relay deps include workspace siblings, so
# scoping to -p buzz-relay misses transitive deps and re-builds them later.
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
-p buzz-admin --bin buzz-admin \
-p buzz-pair-relay --bin buzz-pair-relay \
&& strip target/release/buzz-relay \
&& strip target/release/buzz-admin \
&& strip target/release/buzz-pair-relay
# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
# vice versa.
FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS web-builder
WORKDIR /build
# Trust an optional corporate-proxy CA so corepack + pnpm can fetch over an
# intercepting TLS gateway (no-op if EXTRA_CA_CERTS is unset).
ARG EXTRA_CA_CERTS
COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src
RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \
apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*; \
fi
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
# Point npm + corepack at an optional mirror (no-op when NPM_REGISTRY is unset).
# corepack reads COREPACK_NPM_REGISTRY to fetch the pinned pnpm; pnpm/npm read
# the .npmrc registry for dependency installs.
ARG NPM_REGISTRY
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
# When using a mirror, disable corepack's npmjs signature check: the mirror
# republishes tarballs without the public registry's provenance signatures, so
# strict verification fails ("No compatible signature found"). Only relaxed on
# the mirror path — public builds (NPM_REGISTRY unset) keep strict verification.
RUN if [ -n "${NPM_REGISTRY}" ]; then \
echo "registry=${NPM_REGISTRY}" > /build/.npmrc \
&& echo "COREPACK_INTEGRITY_KEYS=0" >> /etc/environment; \
fi
ENV COREPACK_INTEGRITY_KEYS=${NPM_REGISTRY:+0}
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches/ patches/
COPY web/package.json web/
RUN pnpm install --frozen-lockfile --filter buzz-web
COPY web/ web/
RUN pnpm -C web build
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
FROM debian:${DEBIAN_VERSION}-slim AS runtime
# OCI annotations: required for GHCR to auto-link the image to this repo and
# inherit its visibility. org.opencontainers.image.source is the load-bearing
# one — without it GHCR keeps the image private even when the repo is public.
LABEL org.opencontainers.image.title="Buzz" \
org.opencontainers.image.description="WebSocket relay server for the Buzz communications platform" \
org.opencontainers.image.source="https://github.com/block/buzz" \
org.opencontainers.image.url="https://github.com/block/buzz" \
org.opencontainers.image.documentation="https://github.com/block/buzz#readme" \
org.opencontainers.image.licenses="Apache-2.0"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1000 buzz \
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \
--create-home --shell /usr/sbin/nologin buzz
COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
COPY --from=web-builder /build/web/dist /srv/buzz/web
# The invite landing page is always served from the bundled web UI. Repository
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in.
ENV BUZZ_WEB_DIR=/srv/buzz/web
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
EXPOSE 3000 8080 9102
USER buzz:buzz
WORKDIR /var/lib/buzz
ENTRYPOINT ["/usr/local/bin/buzz-relay"]

View File

@@ -1,37 +0,0 @@
# syntax=docker/dockerfile:1.7
ARG RUST_VERSION=1.95
ARG DEBIAN_VERSION=bookworm
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
RUN cargo install cargo-chef --locked --version 0.1.71
WORKDIR /build
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=planner /build/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \
&& strip target/release/buzz-push-gateway
FROM debian:${DEBIAN_VERSION}-slim AS runtime
LABEL org.opencontainers.image.title="Buzz Push Gateway" \
org.opencontainers.image.description="Capability-gated APNs last hop for Buzz" \
org.opencontainers.image.source="https://github.com/block/buzz" \
org.opencontainers.image.licenses="Apache-2.0"
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1000 buzz \
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz --create-home --shell /usr/sbin/nologin buzz
COPY --from=builder /build/target/release/buzz-push-gateway /usr/local/bin/buzz-push-gateway
EXPOSE 8080 8081
USER buzz:buzz
WORKDIR /var/lib/buzz
ENTRYPOINT ["/usr/local/bin/buzz-push-gateway"]

View File

@@ -1 +0,0 @@
## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md)

847
Justfile
View File

@@ -1,847 +0,0 @@
# Buzz — development task runner
set dotenv-load := true
desktop_dir := "desktop"
desktop_tauri_manifest := "desktop/src-tauri/Cargo.toml"
web_dir := "web"
# Opt-in mesh-llm. Off by default so `just dev`/`just staging` skip ~420 extra
# crates + the llama.cpp native runtime build and stay fast to iterate on.
# Turn on to test mesh compute features: `just mesh=1 dev` / `just mesh=1 staging`.
mesh := ""
# List all available tasks
default:
@just --list
# ─── Dev Environment ─────────────────────────────────────────────────────────
# Install required dev tools via Hermit and create .env (safe to re-run)
bootstrap:
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
# Hermit's bin/ symlinks auto-download pinned tool versions on first use.
# Running each tool once triggers the download if not already cached.
echo "Ensuring toolchain via Hermit..."
cargo --version &
node --version &
pnpm --version &
wait
if ! command -v docker &>/dev/null; then
echo "Error: Docker is required but not installed."
echo "Install it from https://docs.docker.com/get-docker/"
exit 1
fi
if [[ ! -f .env ]]; then
cp .env.example .env
echo "Created .env from .env.example — review it before running just dev."
fi
# Start Docker services, run migrations, install desktop deps
setup: bootstrap
./scripts/dev-setup.sh
# Install git hooks via lefthook (dispatches from the shared .git/hooks dir so all
# linked worktrees inherit the same hooks without a worktree-relative .hooks path)
hooks:
#!/usr/bin/env bash
set -euo pipefail
# --path-format=absolute guarantees an absolute path from every invocation context:
# without it, --git-common-dir returns ".git" from the main checkout and a
# relative hooksPath would break linked-worktree dispatch just like .hooks did.
HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir)/hooks"
git config --local core.hooksPath "$HOOKS_DIR"
lefthook install --force
# Wipe development state and recreate a clean environment. Installed Buzz is preserved.
[confirm("This will DELETE all development data and preserve installed Buzz. Continue? (y/N)")]
reset:
./scripts/dev-reset.sh --yes
# Stop all dev services (keep data)
down:
docker compose down
# Show dev service status
ps:
docker compose ps
# Tail all service logs
logs *ARGS:
docker compose logs -f {{ARGS}}
# ─── Build & Check ───────────────────────────────────────────────────────────
# Build the Rust workspace
build:
cargo build --workspace
# Build the Rust workspace in release mode
build-release:
cargo build --workspace --release
# Run repo lint and formatting checks
check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check
# Format all Rust code
fmt:
cargo fmt --all
# Check formatting without modifying files
fmt-check:
cargo fmt --all -- --check
# Run clippy with warnings as errors
clippy:
cargo clippy --workspace --all-targets -- -D warnings
# Install JS dependencies (pnpm workspace — installs all packages from root)
desktop-install:
pnpm install
# Install JS dependencies reproducibly for CI (pnpm workspace)
desktop-install-ci:
pnpm install --frozen-lockfile
# Run desktop lint and format checks
desktop-check:
cd {{desktop_dir}} && pnpm check
# Fix desktop lint and format issues
desktop-fix:
cd {{desktop_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
# Run desktop TS helper unit tests
desktop-test:
cd {{desktop_dir}} && pnpm test
# Run desktop TypeScript checks
desktop-typecheck:
cd {{desktop_dir}} && pnpm typecheck
# Build desktop frontend assets
desktop-build:
cd {{desktop_dir}} && pnpm build
# Format desktop Tauri Rust code
desktop-tauri-fmt:
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all
# Check desktop Tauri Rust formatting
desktop-tauri-fmt-check:
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all -- --check
# Format all code (Rust + Tauri Rust + Dart)
fmt-all: fmt desktop-tauri-fmt mobile-fmt
# Fix all formatting and lint issues
fix-all: fmt desktop-tauri-fmt desktop-fix web-fix mobile-fix
# Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time)
# Sidecar binary list must stay in sync with desktop-release-build below.
_ensure-sidecar-stubs:
#!/usr/bin/env bash
set -euo pipefail
TARGET=$(rustc -vV | sed -n 's|host: ||p')
mkdir -p desktop/src-tauri/binaries
for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
# Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy
_ensure-services:
#!/usr/bin/env bash
set -euo pipefail
pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
echo "Services already healthy"
exit 0
fi
echo "Starting services..."
docker compose up -d || true
echo -n "Waiting for services"
for i in $(seq 1 40); do
pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
echo " ready"
exit 0
fi
echo -n "."
sleep 3
done
echo " timed out"
exit 1
# Apply database migrations and seed the local dev community if the dev database is running
_ensure-migrations: _ensure-services
cargo run -p buzz-admin -- migrate
./scripts/seed-local-community.sh
# Run clippy on the desktop Tauri Rust crate
desktop-tauri-clippy: _ensure-sidecar-stubs
cargo clippy --manifest-path {{desktop_tauri_manifest}} --all-targets -- -D warnings
# Check the desktop Tauri Rust crate compiles
desktop-tauri-check: _ensure-sidecar-stubs
cargo check --manifest-path {{desktop_tauri_manifest}}
# Run desktop Tauri Rust unit tests
desktop-tauri-test: _ensure-sidecar-stubs
cd desktop/src-tauri && cargo test
# Build the full desktop Tauri app locally (unsigned, for testing)
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
# pnpm install is unconditional here: release builds must start from a clean dep tree.
desktop-release-build target="aarch64-apple-darwin":
#!/usr/bin/env bash
set -euo pipefail
TARGET={{target}}
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
pnpm install
cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
# Run desktop checks suitable for CI / pre-push
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
# Seed deterministic channel data for desktop Playwright tests
desktop-e2e-seed: _ensure-migrations
./scripts/setup-desktop-test-data.sh
# Run desktop browser smoke tests
desktop-e2e-smoke:
cd {{desktop_dir}} && pnpm test:e2e:smoke
# Run desktop relay-backed e2e tests
desktop-e2e-integration: _ensure-migrations
cd {{desktop_dir}} && pnpm test:e2e:integration
# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
# ─── Test ─────────────────────────────────────────────────────────────────────
# Run all tests (unit + integration)
test:
./scripts/run-tests.sh all
# Run unit tests only (no infra needed)
test-unit:
#!/usr/bin/env bash
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
# 0001; cutover/backfill stays an operator script, not startup state)
# and the tenant-scoping lints. The Postgres-backed buzz-db tests are
# #[ignore]d, so --lib runs only the infra-free set. Without this gate a
# stray file in migrations/ or a broken lint ships green.
cargo nextest run -p buzz-db --lib
# Multi-tenant conformance gate (buzz-conformance): the independent
# replay checker + golden fixtures. No infra — pure in-process trace
# replay — so it belongs in the unit job. Run all targets (lib + the
# tests/replay_fixtures.rs integration test), not just --lib.
cargo nextest run -p buzz-conformance
# Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
# contract/race tests run in the dedicated CI job below.
cargo nextest run -p buzz-push-gateway
else
./scripts/run-tests.sh unit
fi
# Run integration tests only (starts services if needed)
test-integration:
./scripts/run-tests.sh integration
# Buzz shared compute e2e: current desktop discovery/admission logic and
# Playwright UI coverage.
mesh-e2e:
cargo test --manifest-path {{desktop_dir}}/src-tauri/Cargo.toml --features mesh-llm mesh_llm --lib
cd {{desktop_dir}} && pnpm test:e2e:smoke -- mesh-compute.spec.ts
# Reset only development state, seed deterministic local channels, and launch
# the mesh-enabled desktop with the repository's public Tyler test identity.
# This is for local verification only; never point this identity at staging/prod.
[confirm("This will reset development data, preserve installed Buzz, then launch a seeded mesh dev app. Continue? (y/N)")]
mesh-dev-fresh:
#!/usr/bin/env bash
set -euo pipefail
./scripts/dev-reset.sh --yes
./scripts/setup-desktop-test-data.sh
export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
export BUZZ_ALLOW_NIP_OA_AUTH=true
export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001"
export BUZZ_RECONCILE_CHANNELS=true
export BUZZ_RESET_WEBVIEW_STATE=1
exec just mesh=1 dev
# Real serve->client->inference on this machine (not CI).
mesh-e2e-hardware:
#!/usr/bin/env bash
set -euo pipefail
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
cargo run -p buzz-relay --example mesh_serve_client_smoke
# Three isolated node processes: trusted member joins and infers; stranger is rejected.
# Uses temp homes and explicit mesh owner keystores. Never reads the Buzz Keychain.
mesh-e2e-admission:
#!/usr/bin/env bash
set -euo pipefail
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
cargo run -p buzz-relay --example mesh_admission_smoke
# Full hardware confidence suite: routing, owner admission, and real agent inference.
mesh-e2e-confidence:
#!/usr/bin/env bash
set -euo pipefail
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
cargo build --release -p buzz-agent -p buzz-dev-mcp
cargo run -p buzz-relay --example mesh_serve_client_smoke
cargo run -p buzz-relay --example mesh_admission_smoke
cargo run -p buzz-relay --example mesh_agent_e2e
# Take desktop screenshots using the mock bridge
desktop-screenshot *ARGS:
#!/usr/bin/env bash
set -euo pipefail
just desktop-build
cd {{desktop_dir}}
if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
trap "kill $! 2>/dev/null || true" EXIT
for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
fi
node tests/helpers/screenshot.mjs {{ARGS}}
# ─── Run ──────────────────────────────────────────────────────────────────────
# Start the relay server (auto-starts Docker services if needed)
relay: bootstrap _ensure-migrations
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
cargo run -p buzz-relay
# Start the relay with the built web UI served from it
relay-web: bootstrap _ensure-migrations
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
[[ -d node_modules ]] || pnpm install
pnpm -C web build
BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay
# Start the relay server in release mode
relay-release: _ensure-migrations
cargo run -p buzz-relay --release
# Run the desktop Tauri app in dev mode with a local relay (ports and identity derived from worktree)
dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
health_port="${BUZZ_HEALTH_PORT:-8080}"
metrics_port="${BUZZ_METRICS_PORT:-9102}"
if command -v lsof >/dev/null 2>&1; then
for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do
name="${spec%%:*}"; port="${spec##*:}"
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2
lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2
exit 1
fi
done
fi
cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
if [[ -n "{{mesh}}" ]]; then
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
fi
# Docker Desktop's forwarded MinIO port can stall under the deployment
# probe's 32 concurrent writers. Keep the gate enabled in local dev, using
# the bounded profile already used by the relay test launcher.
export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}"
export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}"
./target/debug/buzz-relay &
RELAY_PID=$!
cleanup() {
[[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true
kill "$RELAY_PID" 2>/dev/null || true
}
trap cleanup EXIT
relay_ready=false
for _ in $(seq 1 120); do
if ! kill -0 "$RELAY_PID" 2>/dev/null; then
echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2
wait "$RELAY_PID" || true
exit 1
fi
if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then
relay_ready=true
break
fi
sleep 0.5
done
if [[ "$relay_ready" != true ]]; then
echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2
exit 1
fi
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
source ../scripts/instance-env.sh
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
echo "Starting on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm)
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
staging *ARGS: bootstrap _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: staging must always start with a clean dep tree
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
FEATURES=()
if [[ -n "{{mesh}}" ]]; then
FEATURES=(--features mesh-llm)
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
fi
# Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up.
TARGET=$(rustc -vV | sed -n 's|host: ||p')
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}"
chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}"
cd {{desktop_dir}}
export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
source ../scripts/instance-env.sh
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
# agent workers. Reap this instance's agents on exit as a backstop.
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
echo "Starting staging on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
# Run the desktop frontend dev server (port derived from worktree)
desktop-dev:
#!/usr/bin/env bash
set -euo pipefail
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
source ../scripts/instance-env.sh
echo "Starting frontend dev server on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort
# ─── Web ─────────────────────────────────────────────────────────────────────
# Run the web frontend dev server (port derived from worktree to avoid collisions)
web:
#!/usr/bin/env bash
set -euo pipefail
[[ -d node_modules ]] || pnpm install
source scripts/instance-env.sh
export VITE_PORT=$((BUZZ_VITE_PORT + 100))
export VITE_RELAY_URL="${BUZZ_RELAY_URL}"
echo "Starting web dev server on port ${VITE_PORT}, relay ${BUZZ_RELAY_URL}"
cd {{web_dir}}
pnpm exec vite --port "${VITE_PORT}" --strictPort
# Run web lint and format checks
web-check:
cd {{web_dir}} && pnpm check
# Fix web lint and format issues
web-fix:
cd {{web_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
# Run web TypeScript checks
web-typecheck:
cd {{web_dir}} && pnpm typecheck
# Build web frontend assets
web-build:
cd {{web_dir}} && pnpm build
# Run web browser smoke tests
web-e2e-smoke:
cd {{web_dir}} && pnpm test:e2e:smoke
# ─── Mobile ──────────────────────────────────────────────────────────────────
mobile_dir := "mobile"
# Install mobile Flutter dependencies
mobile-install:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter pub get
# Format all Dart code
mobile-fmt:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format .
# Fix mobile formatting and run analysis
mobile-fix:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format . && flutter analyze
# Run mobile lint and format checks
mobile-check:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze && node ./scripts/check-file-sizes.mjs
# Run mobile tests
mobile-test:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test
# Compile an unsigned Android debug APK
mobile-build-android:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub
# Run the mobile app on iOS simulator
mobile-dev:
#!/usr/bin/env bash
set -euo pipefail
if ! pgrep -x Simulator &>/dev/null; then
open -a Simulator
sleep 3
fi
cd {{mobile_dir}}
unset GIT_DIR GIT_WORK_TREE
flutter run
# ─── Database ─────────────────────────────────────────────────────────────────
# Apply database migrations
migrate: _ensure-migrations
# ─── Utilities ────────────────────────────────────────────────────────────────
# Remove build artifacts
clean:
cargo clean
cargo clean --manifest-path desktop/src-tauri/Cargo.toml
# Check the Rust workspace compiles without producing binaries
check-compile:
cargo check --workspace --all-targets
# ─── Release ─────────────────────────────────────────────────────────────────
# Read the current desktop version from package.json
get-current-version:
@node -p "require('./desktop/package.json').version"
# Read the current relay version from its crate manifest
get-current-relay-version:
@grep -m1 '^version = ' crates/buzz-relay/Cargo.toml | sed -E 's/version = "(.*)"/\1/'
# Compute next minor version (e.g., 0.3.0 → 0.4.0)
get-next-minor-version:
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')"
# Compute next patch version (e.g., 0.3.0 → 0.3.1)
get-next-patch-version:
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Compute next relay patch version (e.g., 0.3.0 → 0.3.1)
get-next-relay-patch-version:
@python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Read the current mobile version from pubspec.yaml (strips the +build suffix)
get-current-mobile-version:
@grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/'
# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1)
get-next-mobile-patch-version:
@python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Update version in desktop package manifests and regenerate lockfiles
bump-desktop-version version:
#!/usr/bin/env bash
set -euo pipefail
# desktop/package.json
cd desktop && npm pkg set "version={{ version }}" && cd ..
# desktop/src-tauri/tauri.conf.json
node -e "
const fs = require('fs');
const p = 'desktop/src-tauri/tauri.conf.json';
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
c.version = '{{ version }}';
fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
"
# JSON.stringify expands arrays/objects in a way biome rejects; reformat to match.
(cd desktop && pnpm exec biome format --write src-tauri/tauri.conf.json)
# desktop/src-tauri/Cargo.toml — only first version line (under [package])
node -e "
const fs = require('fs');
const p = 'desktop/src-tauri/Cargo.toml';
let t = fs.readFileSync(p, 'utf8');
t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"');
fs.writeFileSync(p, t);
"
# Regenerate lockfiles
pnpm install --lockfile-only
cargo update -p buzz-desktop --manifest-path desktop/src-tauri/Cargo.toml
echo "Bumped desktop manifests to {{ version }} and regenerated lockfiles"
# Bump the relay crate version and regenerate the lockfile
bump-relay-version version:
#!/usr/bin/env bash
set -euo pipefail
# buzz-relay carries its own `version =` (not version.workspace), so the
# replace targets the package version line only.
perl -i -pe 's/^version = ".*"/version = "{{ version }}"/' crates/buzz-relay/Cargo.toml
cargo update -p buzz-relay
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
# Bump the mobile pubspec version and regenerate the lockfile
bump-mobile-version version:
#!/usr/bin/env bash
set -euo pipefail
# pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention
# (a literal `+1`, matching the desktop lane's prior behavior).
perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml
(unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get)
echo "Bumped mobile to {{ version }} and regenerated pubspec.lock"
# Open or update the desktop release PR (signed desktop app)
release-desktop *ARGS:
#!/usr/bin/env bash
set -euo pipefail
ARG="{{ ARGS }}"
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
VERSION=$(just get-next-patch-version)
else
VERSION="$ARG"
fi
just _release-pr desktop "$VERSION"
# Open or update the relay release PR (ghcr.io/block/buzz image)
release-relay *ARGS:
#!/usr/bin/env bash
set -euo pipefail
ARG="{{ ARGS }}"
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
VERSION=$(just get-next-relay-patch-version)
else
VERSION="$ARG"
fi
just _release-pr relay "$VERSION"
# Open or update the mobile release PR (Buzz mobile app)
release-mobile *ARGS:
#!/usr/bin/env bash
set -euo pipefail
ARG="{{ ARGS }}"
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
VERSION=$(just get-next-mobile-patch-version)
else
VERSION="$ARG"
fi
just _release-pr mobile "$VERSION"
# Shared release-PR engine. One body, three lanes — the only lane-specific steps
# are the version-bump command and the file/tag/changelog identifiers selected
# in the `case` below. Everything else (git preflight, branch reset, changelog
# generation, commit, push, PR open/edit) is identical across lanes.
_release-pr lane version:
#!/usr/bin/env bash
set -euo pipefail
VERSION="{{ version }}"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "Error: '$VERSION' is not valid semver (expected X.Y.Z)"
exit 1
fi
# Lane-specific identifiers. The bump command runs after the branch switch.
case "{{ lane }}" in
desktop)
BRANCH_PREFIX="version-bump"
TAG_FETCH='v*'
TAG_MATCH='v[0-9]*'
TAG_EXCLUDE='*-*'
TAG_PREFIX="v"
CHANGELOG="CHANGELOG.md"
ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md)
LOG_PATHS=(desktop/ crates/buzz-core/ crates/buzz-persona/ crates/buzz-sdk/ crates/buzz-agent/)
ARTIFACT="Buzz Desktop" ;;
relay)
BRANCH_PREFIX="relay-release"
TAG_FETCH='relay-v*'
TAG_MATCH='relay-v[0-9]*'
TAG_EXCLUDE='relay-v*-*'
TAG_PREFIX="relay-v"
CHANGELOG="crates/buzz-relay/CHANGELOG.md"
ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md)
LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/)
ARTIFACT="Buzz Relay" ;;
mobile)
BRANCH_PREFIX="mobile-release"
TAG_FETCH='mobile-v*'
TAG_MATCH='mobile-v[0-9]*'
TAG_EXCLUDE='mobile-v*-*'
TAG_PREFIX="mobile-v"
CHANGELOG="mobile/CHANGELOG.md"
ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md)
LOG_PATHS=(mobile/)
ARTIFACT="Buzz Mobile" ;;
*)
echo "Error: unknown release lane '{{ lane }}'"
exit 1 ;;
esac
echo "Preparing ${ARTIFACT} release v${VERSION}..."
# Must run on main with a clean, up-to-date tree.
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')"
exit 1
fi
git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags
# Release tags are remote-owned state; sync only this lane's tags so stale
# local tags from older histories do not make release preflight fail.
git fetch origin "+refs/tags/${TAG_FETCH}:refs/tags/${TAG_FETCH}"
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first."
exit 1
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: working tree is dirty. Commit or stash changes first."
exit 1
fi
# Switch to the release branch (create, or reset to main if it exists).
BRANCH="${BRANCH_PREFIX}/${VERSION}"
if git rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then
echo "Branch '$BRANCH' already exists — resetting to origin/main..."
git switch "$BRANCH"
git reset --hard origin/main
elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "Branch '$BRANCH' exists on remote — checking out and resetting to origin/main..."
git switch -c "$BRANCH" --track "origin/$BRANCH"
git reset --hard origin/main
else
git switch -c "$BRANCH"
fi
# Lane-specific bump (the one diverging step).
case "{{ lane }}" in
desktop) just bump-desktop-version "$VERSION" ;;
relay) just bump-relay-version "$VERSION" ;;
mobile) just bump-mobile-version "$VERSION" ;;
esac
# Generate the changelog from commits since this lane's last release tag.
LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "")
REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||')
format_log() {
local range="$1"
git log "$range" --format="%h %H %s" --no-merges -- "${LOG_PATHS[@]}" | while IFS=' ' read -r short full rest; do
local pr subject
pr=$(printf '%s' "$rest" | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' || true)
if [[ -n "$pr" ]]; then
subject=$(printf '%s' "$rest" | sed -E 's/ \(#[0-9]+\)$//')
printf -- '- %s ([#%s](https://github.com/%s/pull/%s)) ([`%s`](https://github.com/%s/commit/%s))\n' \
"$subject" "$pr" "$REPO" "$pr" "$short" "$REPO" "$full"
else
printf -- '- %s ([`%s`](https://github.com/%s/commit/%s))\n' \
"$rest" "$short" "$REPO" "$full"
fi
done
}
TMPFILE=$(mktemp)
{
echo "# Changelog"
echo ""
echo "## ${TAG_PREFIX}${VERSION}"
echo ""
if [[ -n "$LAST_TAG" ]]; then
format_log "${LAST_TAG}..HEAD"
else
echo "- Initial release"
fi
echo ""
if [[ -f "$CHANGELOG" ]]; then
tail -n +2 "$CHANGELOG"
fi
} > "$TMPFILE"
mkdir -p "$(dirname "$CHANGELOG")"
mv "$TMPFILE" "$CHANGELOG"
# Commit.
git add "${ADD_FILES[@]}"
RELEASE_MSG="chore(release): release ${ARTIFACT} version ${VERSION}"
if [[ "$(git log -1 --format='%s' 2>/dev/null)" == "$RELEASE_MSG" ]]; then
git commit --amend --no-edit
else
git commit -m "$RELEASE_MSG"
fi
# Push and open/update the PR.
git push --force-with-lease -u origin "$BRANCH"
PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n'
if [[ -n "$LAST_TAG" ]]; then
PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n'
CHANGELOG_BODY=$(format_log "${LAST_TAG}..HEAD~1")
MAX_LOG=62000
if (( ${#CHANGELOG_BODY} > MAX_LOG )); then
TRUNCATED=$(printf '%s' "$CHANGELOG_BODY" | awk -v max="$MAX_LOG" \
'BEGIN{n=0} {line_len=length($0)+1; if(n+line_len>max) exit; n+=line_len; print}')
SHOWN=$(printf '%s\n' "$TRUNCATED" | grep -c '^-' || true)
TOTAL=$(printf '%s\n' "$CHANGELOG_BODY" | grep -c '^-' || true)
SKIPPED=$(( TOTAL - SHOWN ))
CHANGELOG_BODY="${TRUNCATED}"$'\n'"_… and ${SKIPPED} more commits — [compare ${LAST_TAG}…${TAG_PREFIX}${VERSION}](https://github.com/${REPO}/compare/${LAST_TAG}...${TAG_PREFIX}${VERSION})_"
fi
PR_BODY+="${CHANGELOG_BODY}"$'\n\n'
else
PR_BODY+="Initial release."$'\n\n'
fi
PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically."
PR_TITLE="chore(release): release ${ARTIFACT} version ${VERSION}"
EXISTING_PR=$(gh pr list --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true)
if [[ -n "$EXISTING_PR" ]]; then
gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY"
PR_URL="$EXISTING_PR"
echo ""
echo "Updated existing release PR: ${PR_URL}"
else
PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_BODY")
echo ""
echo "Release PR opened: ${PR_URL}"
fi
echo "Merge it to trigger the release build."
# ─── Agent Harness ────────────────────────────────────────────────────────────
# Run a goose agent connected to a Buzz relay (foreground)
goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
exec env "${env_args[@]}" ./target/release/buzz-acp
# Run a goose agent in the background (screen session named 'goose-agent-N')
goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
screen -dmS goose-agent-{{agents}} bash -c "$(printf '%q ' env "${env_args[@]}") ./target/release/buzz-acp"
echo "Agent running in screen session 'goose-agent-{{agents}}'. Attach with: screen -r goose-agent-{{agents}}"
# ─── Benchmarking ─────────────────────────────────────────────────────────────
# Run the Buzz orchestra benchmark — leaderboard-eligible by default (TB 2.1, k=5, Sonnet+Haiku). Stands up its own Docker stack; --gui opens a live spectator desktop app; other flags pass to benchmark.py (--dataset/--path, --include-task, --attempts, --manifest, --dry-run, ...)
benchmark *ARGS:
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
benchmarks/harbor-buzz-orchestra/scripts/benchmark.py {{ARGS}}
# Stop the benchmark Docker stack (state and channels are kept)
benchmark-down:
docker compose --project-name buzz-benchmark down

201
LICENSE
View File

@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Copyright 2026 Block, Inc.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

356
NOSTR.md
View File

@@ -1,356 +0,0 @@
# Using Third-Party Nostr Clients with Buzz
Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. Third-party Nostr clients connect directly to `buzz-relay` using NIP-29 and NIP-42 authentication. The old NIP-28 compatibility proxy has been removed.
## Community scope
Buzz treats the relay URL/domain as authoritative for the community. Today's
single-relay deployment has exactly one community behind that URL, so existing
NIP-29 clients keep using the same WebSocket URL, event kinds, tags, and
HTTP/media/git paths. In a multi-community deployment, each community is reached
by its own domain or subdomain; the backend resolves the community from the host
before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic.
The Nostr wire format does not grow a tenant tag. Client-supplied `#h` tags still
name channels/groups and are checked against the host-derived community. Events
without `#h` — profiles, gift-wrapped DMs, membership notifications, lists,
status, long-form notes, workflow/system events, and other "global" streams — are
global only inside the connected community. A pubkey can join multiple
communities and repost its profile in each one; DMs and profiles do not inherit
across community domains.
---
## NIP-29 Direct
Connect any NIP-29 client straight to the relay.
### Quick Start
```bash
# 1. (Optional) Enable pubkey allowlist — must be set BEFORE relay startup
export BUZZ_PUBKEY_ALLOWLIST=true
# 2. Start the relay (auto-starts Docker services and runs migrations)
just relay & # relay on :3000
# 3. Add a pubkey to the allowlist (if enabled)
# Insert directly — there is no CLI command for this yet.
PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
"INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"
# 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000
```
### What Works
| Feature | Status | Notes |
|---------|:------:|-------|
| **Group chat (kind:9)** | ✅ | Send/receive messages with `#h <channel-uuid>` tag |
| **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) |
| **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required |
| **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. |
| **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility` and `channel_type` |
| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. |
| **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. |
| **Edit group metadata (kind:9002)** | ✅ | `name`/`about` tags: owner/admin. `topic`/`purpose` tags: any member. |
| **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
| **Group deletion (kind:9008)** | ✅ | Owner only. |
| **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. |
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels |
| **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) |
| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members |
| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. |
| **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
| **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist |
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
| **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) |
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. |
| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. |
| **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. |
| **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. |
| **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these |
| **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these |
### What Doesn't Work
| Feature | Status | Why |
|---------|:------:|-----|
| **Create invite (kind:9009)** | ⚠️ | Accepted and stored, but side-effect handler is deferred (no-op with warning log) |
| **Group roles (kind:39003)** | ❌ | Defined in kind registry but not emitted by the relay |
| **DMs** | ⚠️ | NIP-17 gift wraps supported; NIP-04/NIP-44 not implemented. kind:10050 (DM relay list) deferred. |
### Pubkey Allowlist
When `BUZZ_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
(no API token) are checked against the `pubkey_allowlist` table. This lets you open the
relay to specific external Nostr identities without granting full access.
- Users with valid **API tokens** bypass the allowlist.
- **Fail-closed:** if the DB lookup fails, the connection is denied.
- Default: `false` (all authenticated pubkeys accepted).
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
- Manage the allowlist via direct SQL (no CLI command yet):
```sql
INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'));
DELETE FROM pubkey_allowlist WHERE pubkey = decode('<64-char-hex-pubkey>', 'hex');
SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist;
```
### Group Discovery
The relay emits NIP-29 group state events when channels are created, updated, or membership changes.
All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention):
| Kind | Tags | Content |
|------|------|---------|
| **39000** | `d=<uuid>`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
| **39001** | `d=<uuid>`, `p` tags with role label (`owner`, `admin`) | Admin list |
| **39002** | `d=<uuid>`, `p` tags for all members | Member list |
Events are stored **channel-scoped** so access control applies — private channel member lists are
only visible to members. Discover groups via historical REQ:
```bash
# All groups you can see
nak req -k 39000 --auth --sec <privkey> ws://localhost:3000
# Specific group's members (filter by d tag)
nak req -k 39002 --tag "d=<channel-uuid>" --auth --sec <privkey> ws://localhost:3000
```
> **Note:** Channel-scoped storage means live global subscriptions (`{kinds:[39000]}`) won't
> receive these via fan-out. Clients discover groups via historical REQ queries. Live push for
> open-channel discovery is a future enhancement.
### Membership Notifications
The relay emits relay-signed notifications when members are added or removed:
| Kind | Meaning | Tags | Scope |
|------|---------|------|-------|
| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Community-global |
| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Community-global |
Stored community-globally (`channel_id = None` inside the connected community) so agents and clients can subscribe without knowing channel
UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair
may sign these.
> **Subscription constraint:** Global REQs that can match p-gated kinds (44100, 44101, 1059) **must**
> include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects
> subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership
> changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`.
```bash
nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> ws://localhost:3000
```
### Sending Messages
```bash
# Send a kind:9 message
nak event -k 9 -c "Hello from NIP-29!" --tag "h=<channel-uuid>" \
--auth --sec <privkey> ws://localhost:3000
# Subscribe to channel messages
nak req -k 9 --tag "h=<channel-uuid>" --stream \
--auth --sec <privkey> ws://localhost:3000
# React to a message (#h optional but recommended; channel derived from #e target)
nak event -k 7 -c "+" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
--auth --sec <privkey> ws://localhost:3000
# Delete a message (#h optional; #e required; must be self-authored)
nak event -k 5 -c "reason" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
--auth --sec <privkey> ws://localhost:3000
# Create a group
nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \
--auth --sec <privkey> ws://localhost:3000
# Search messages (NIP-50)
nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
--auth --sec <privkey> ws://localhost:3000
# Reply to a message (NIP-10 threading)
nak event -k 9 -c "Reply text" --tag "h=<channel-uuid>" \
--tag "e=<parent-event-id>;;reply" \
--auth --sec <privkey> ws://localhost:3000
# Fetch gift-wrapped DMs (NIP-17)
nak req -k 1059 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> ws://localhost:3000
```
### Tested Clients (Direct)
| Client | Platform | Evidence | Notes |
|--------|----------|:--------:|-------|
| **BuzzTestClient** | Rust (repo) | Automated E2E | Full NIP-29 flow: discovery (39000/39001/39002), kind:9 send/receive, reactions, deletions, h-tag enforcement |
| **E2E nostr interop** | Rust (repo) | Automated E2E | NIP-50 search (3 tests), NIP-10 threads (3 tests), NIP-17 gift wraps (3 tests), DM discovery (1 test) |
| **nak** | CLI | Manual (verified) | kind:9 send/recv, NIP-50 search, NIP-10 thread replies, group discovery |
**Not verified in-repo** (anecdotal / expected based on NIP-29 support):
- **Chachi** (Web/Mobile) — NDK-based; NIP-29 native
- **0xchat** (Mobile) — NIP-29 native
---
## Relay Membership (NIP-43)
When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the
`relay_members` table. In today's single-community deployment this is the relay-wide member list; in multi-community mode the same rule is scoped to the host-derived community. Only pubkeys with a row for that community may use that community. The relay owner
is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup.
### CLI: Managing Members
Use `buzz-admin` — the operator CLI shipped in the relay image — to manage relay membership.
In a Docker Compose deployment, use `run.sh`:
```bash
# Add a member (accepts bech32 npub or 64-char hex; default role: member)
./run.sh add-member npub1abc...
./run.sh add-member <64-char-hex-pubkey>
./run.sh add-member npub1abc... --role admin
# Remove a member
./run.sh remove-member npub1abc...
./run.sh remove-member npub1abc... --role member # only removes if role matches
# List all members
./run.sh list-members
```
Or invoke `buzz-admin` directly inside the container:
```bash
docker compose exec relay buzz-admin add-member --pubkey npub1abc...
docker compose exec relay buzz-admin add-member --pubkey npub1abc... --role admin
docker compose exec relay buzz-admin remove-member --pubkey npub1abc...
docker compose exec relay buzz-admin list-members
```
**Exit codes:**
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Validation error (bad pubkey, bad role, usage error) |
| 2 | Not found (remove: member does not exist) |
| 3 | Cannot remove relay owner (use `RELAY_OWNER_PUBKEY` to change owner) |
| 4 | Role mismatch (`--role` check failed) |
| 5 | DB/Redis/internal error |
**Required environment variables for member management:**
| Variable | Notes |
|----------|-------|
| `DATABASE_URL` | Postgres connection string |
| `REDIS_URL` | Redis connection string |
| `BUZZ_RELAY_PRIVATE_KEY` | Hex private key — required to sign kind:13534 events |
### NIP-43 Admin Events (WebSocket)
Relay membership can also be managed over WebSocket using NIP-43 admin events. These require
the sender to be authenticated (NIP-42) as the relay owner or an admin.
| Kind | Action | Required tags |
|------|--------|---------------|
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
Example using `nak`:
```bash
# Add a member (owner or admin must sign)
nak event -k 9030 \
--tag "p=<target-hex-pubkey>" \
--tag "role=member" \
--auth --sec <owner-or-admin-privkey> \
ws://localhost:3000
# Remove a member
nak event -k 9031 \
--tag "p=<target-hex-pubkey>" \
--auth --sec <owner-or-admin-privkey> \
ws://localhost:3000
# Change a member's role to admin
nak event -k 9032 \
--tag "p=<target-hex-pubkey>" \
--tag "role=admin" \
--auth --sec <owner-or-admin-privkey> \
ws://localhost:3000
```
After each add/remove/role-change, the relay publishes a kind:13534 membership list event
(relay-signed, NIP-70 protected) that clients can subscribe to:
```bash
# Subscribe to the live membership roster
nak req -k 13534 --auth --sec <privkey> ws://localhost:3000
```
A kind:9033 command similarly makes the relay store the workspace icon (per
community) and serve it in the standard NIP-11 `icon` field of its relay
information document. Clients render it in the workspace rail/switcher; anyone
can read it (`curl -H 'Accept: application/nostr+json' http://localhost:3000`),
but only admins/owners can set it. Full spec:
[docs/nips/NIP-WP.md](docs/nips/NIP-WP.md).
### Known Limitations
1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is
in-process-only (no Redis hop), so a sidecar call stores but never pushes. The 13534 list
snapshot is the authoritative roster and rides Redis to live clients. Do not wire a delta call
that passes in-process tests and silently no-ops in the deployed `compose exec` path.
2. **The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats same-second
domination for serial invocations; it does NOT serialize concurrent CLI processes** — two
near-simultaneous adds can read the same newest timestamp and collide on the bumped second.
`run.sh` serialization is the guard against parallel adds (e.g. `xargs -P`). When adding
multiple members in a loop, add `sleep 1` between invocations.
---
## Relay Environment Variables (NIP-29 relevant)
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `BUZZ_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
| `BUZZ_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |
---
## Security Notes
### Direct Path
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
- **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected.
- **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed).
- **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005).
- **Client-submitted kind:44100/44101 rejected.** Membership notifications can only be signed by the relay keypair.
---
## Troubleshooting
### Direct Path
| Symptom | Cause | Fix |
|---------|-------|-----|
| `auth-required: verification failed` | Pubkey not in allowlist (when enabled), or NIP-42 auth failed | Add pubkey to `pubkey_allowlist` table; verify NIP-42 challenge/response |
| `invalid: channel-scoped events must include an h tag` | kind:9 sent without `#h` tag | Include `--tag "h=<channel-uuid>"` |
| `invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay |
| No discovery events | Channel is private + you're not a member | Join the channel first |
---
## Further Reading

265
README.md
View File

@@ -1,265 +0,0 @@
<h1 align="center">Buzz 🐝</h1>
<p align="center">
<strong>A workspace where humans and agents build together, on a relay you own.</strong>
</p>
<p align="center">
<a href="VISION.md">Vision</a> ·
<a href="VISION_SOVEREIGN.md">Sovereign</a> ·
<a href="VISION_PROJECTS.md">Forge</a> ·
<a href="VISION_AGENT.md">Agents</a> ·
<a href="ARCHITECTURE.md">Architecture</a> ·
<a href="LICENSE">Apache 2.0</a>
</p>
<p align="center">
<img src="docs/assets/screenshots/channel-thread.png" alt="A Buzz channel with a thread open — a human and an agent working through a question together" width="100%">
</p>
<p align="center">
<sub><em>A human and an agent working a question in the same thread.</em></sub>
</p>
---
## What is this, really?
Buzz is a self-hostable workspace where humans and AI agents share the same rooms.
A Buzz **community** is the workspace a user reaches by URL. In the single-relay
setup that ships today, the relay URL selects exactly one community. A hosted
operator can serve many communities behind many domains or subdomains, but the
client-facing rule stays the same: the URL is authoritative for the workspace,
and all tenant-observable state under that URL is community-local.
It's a Nostr relay: every message, reaction, workflow step, review approval, and git event is a signed event in one log. Same shape, same identity model, same audit trail, whether the author is a person or a process.
In practice it feels like a team workspace. Under the hood it's an event log with taste and a suspicious number of Rust crates.
Yes, it's another AI-adjacent developer tool. We're sorry. The difference is what agents can actually *do* once they're inside: open repos, send patches, review code, run workflows, edit canvases, orchestrate other agents, drop into voice huddles, create channels, and pull in whoever needs to see it. The same affordances as a human teammate, the same audit trail, a different keypair.
---
## Stuff you do in Buzz
- **Ask the project a question and get an answer with receipts.** Agents search six months of history and post the threads, not vibes.
- **Let an agent triage a bug without giving it the keys to the kingdom.** Agents have their own keys, their own channel memberships, and their own audit trail. Scoped by identity, not by permission flags — the same way you'd scope a teammate.
- **Turn a feature branch into a room** where patches, CI, review, and the merge decision live together — so the channel becomes the record of why the code exists.
- **Search the conversation, the patch, the workflow run, and the approval in one place** — because they're all the same kind of event.
- **Let an agent run the workspace, not just talk in it.** Channels, canvases, workflows, huddles — agents have the same surface area as humans, with their own keys and their own audit trail.
---
## A look inside
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/assets/screenshots/channel-agents.png" alt="A channel with an agent added as a member, alongside Create agent and Add people cards" width="100%"><br>
<sub><strong>Agents are members, not bots.</strong> Add an agent to a channel the same way you add a person.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/assets/screenshots/create-channel.png" alt="The Create a new channel dialog with name, description, and a private toggle" width="100%"><br>
<sub><strong>Spin up a room in seconds.</strong> Name it, describe it, make it private.</sub>
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/assets/screenshots/media-comments.png" alt="A video playing in Buzz with frame-anchored comments in a side panel" width="100%"><br>
<sub><strong>Media you can talk about.</strong> Leave comments pinned to specific frames.</sub>
</td>
</tr>
</table>
---
## Why Buzz is better
One community. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. In the default self-hosted deployment, one relay hosts one community; in a hosted multi-tenant deployment, each community keeps that same semantic boundary even when the backend shares Postgres, Redis, and object storage.
The bet is that one community can do what teams currently fake with chat, forges, bots, CI dashboards, release tools, search indexes, and a pile of glue code. Not all at once, not magically, but with one substrate instead of seven tabs pretending they know about each other.
Agents are part of the room, not haunted cron jobs.
---
## Three little stories
**Incident memory.** It's 2am. You type *"have we seen this error before?"* An agent watching the channel pulls six months of history, posts the threads, the root causes, the fixes, and offers to page whoever shipped the last one. The whole exchange — question, answer, evidence — stays in the channel.
**Branch as room.** You open a feature branch. A channel appears. Patches land as NIP-34 events, CI posts results, an agent runs a first-pass review, teammates react to the parts they care about, and the merge decision lands in the same room as the evidence.
**A release that writes itself.** A workflow fires on a tag. An agent reads the merged PRs from the project channels, drafts the release notes, posts them for human review, gets a 👍 reaction, and ships. Every step signed. Every step searchable.
---
## Works today · Being wired up · Strong opinions, pending code
| ✅ Works today | 🚧 Being wired up | 💭 Strong opinions, pending code |
|---|---|---|
| Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays |
| Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications |
| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features |
| YAML workflows: message / reaction / schedule / webhook triggers | | |
| Git events (NIP-34: patches, repo announcements, status) | | |
| Git hosting backend | | |
<sub>Please do not plan your compliance program around the 💭 column yet. The <a href="VISION.md">VISION docs</a> are the long version of what we think this becomes.</sub>
---
## Getting started
New to Buzz? Pick the path that matches you.
### I just want to try the app
Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app.
By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally.
### I work at Block
Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure.
Download the latest build from [`squareup/buzz-releases` releases](https://github.com/squareup/buzz-releases/releases/latest) and install it.
### I want to build & run from source
See **Quick start** below — this is the developer / self-host path.
---
## Quick start
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`).
**Once:**
```bash
git clone https://github.com/block/buzz.git && cd buzz
. ./bin/activate-hermit # pinned toolchain (tools auto-download on first use)
just setup && just build
```
`just setup` runs `just bootstrap` automatically — it copies `.env.example` to `.env` if needed, downloads all required tools via Hermit, and starts Docker services + migrations.
**Every day:**
```bash
. ./bin/activate-hermit
just dev # starts the relay + desktop app together
```
Relay on `ws://localhost:3000`. Desktop app pops up. You're in.
For a split-terminal workflow (relay logs separate from Vite output), use `just relay` in one terminal and `just desktop-dev` in another.
For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls.
---
## Windows prerequisites
The agent shell tool runs commands under bash. On macOS and Linux that's already there; on Windows you need to bring it.
Install [Git for Windows](https://git-scm.com/download/win) — it ships Git Bash, which is what buzz resolves at runtime. Once it's installed, everything works the same as on other platforms.
If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL` to its path (e.g. `BUZZ_SHELL=C:\path\to\bash.exe`). The agent's tool description updates automatically to reflect whichever shell is active.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Clients │
│ Human client AI agent CLI / scripts │
│ (Buzz desktop) (Goose, Codex, ...) (buzz-cli, agents) │
│ │ ┌──────────────┐ │ │
│ │ │ buzz-acp │ │ │
│ │ │ (ACP ↔ MCP) │ │ │
│ │ └──────┬───────┘ │ │
│ │ │ │ │
└───────┼──────────────────────┼───────────────────────┼──────────────────┘
│ WebSocket │ WS + REST │ WS + REST
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ buzz-relay │
│ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │
└───┬──────────────────────────┬──────────────────────────┬──────────────┘
│ │ │
┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐
│ Postgres │ │ Redis │ │ S3/MinIO │
│ (events + │ │ (pub/sub) │ │ (Blossom) │
│ FTS search) │ └─────────────┘ └─────────────┘
└──────────────┘
```
A Rust workspace of focused crates. Single source of truth: the relay. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown.
<details>
<summary><strong>Crate map</strong></summary>
**Core protocol** — `buzz-core` (zero-I/O types, NIP-01 filters, Schnorr verify) · `buzz-relay` (Axum WS + REST)
**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace.
**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs)
**Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `buzz-pair-relay` / `buzz-pairing-cli` (relay pairing)
**Shared** — `buzz-sdk` (typed event builders) · `buzz-media` (Blossom/S3)
**Tooling** — `buzz-admin` (admin CLI) · `buzz-test-client` (E2E)
</details>
---
## Going further
- **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries
- **[TESTING.md](TESTING.md)** — multi-agent E2E test suite
- **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**
<details>
<summary><strong>Configuration</strong> (env vars, defaults work for local dev)</summary>
All defaults work out of the box. Override via `.env`. Full reference in [`.env.example`](.env.example).
</details>
<details>
<summary><strong>Common dev commands</strong></summary>
```bash
just setup # Docker, migrations, desktop deps
just relay # Run the relay
just dev # Run the desktop app
just build # Build the Rust workspace
just check # fmt + clippy + desktop check
just test-unit # Unit tests (no infra required)
just test # Full suite (starts services if needed)
just ci # Everything CI runs
just reset # ⚠️ Wipe data + recreate
```
</details>
---
## What it is not
- Not blockchain. Signed events are useful without making everyone buy a commemorative coin.
- Not an AI replacement plan. Buzz works best when humans stay in the loop and agents stay in the room.
- Not finished. We will tell you what works and what doesn't.
**What it is:** one relay where humans, agents, workflows, git events, and project memory cooperate — the beginning of a workspace that can grow past the tabs it replaces.
---
<p align="center">
<sub>Buzz 🐝</sub><br>
<sub>Apache 2.0 · Built by <a href="https://block.xyz">Block, Inc.</a></sub>
</p>

View File

@@ -1,235 +0,0 @@
# Releasing Buzz
Buzz has three independent release lanes, each driven by a release PR — no human
ever pushes a git tag:
| Lane | Recipe | Artifact |
|------|--------|----------|
| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) |
The three lanes version independently: the desktop version lives in
`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and
the mobile version in `mobile/pubspec.yaml`.
The mobile lane publishes a `mobile-v<version>` tag that is consumed
**manually**, cross-repo, as the `sprout_ref` input to the internal
`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store →
TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is
tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private
`buzz-releases` repo (infosec), so a human cuts the internal build from the tag
rather than auto-dispatching across that boundary.
## Quick Start
```sh
# Desktop release (next patch version)
just release-desktop
# Desktop patch / minor / explicit
just release-desktop patch
just release-desktop 0.4.0
just release-desktop 1.0.0
# Relay release (same argument forms)
just release-relay
just release-relay 0.4.0
# Mobile release (same argument forms)
just release-mobile
just release-mobile 0.4.0
```
`just release-desktop` creates a `version-bump/<version>` PR; `just
release-relay` creates a `relay-release/<version>` PR; `just release-mobile`
creates a `mobile-release/<version>` PR. Each bumps its own version manifest,
regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger
the build automatically (the mobile tag is instead the `sprout_ref` a human
feeds the internal build — see above).
Re-running any of these recipes with the same version is safe — it detects the
existing branch and PR, resets to current `main`, regenerates the changelog
with any new commits, and updates the PR in place.
---
## How It Works
All three lanes share one engine; they differ only in which version manifest
they bump, which branch prefix they use, and what the merge triggers.
### Desktop
1. **`just release-desktop`** runs locally on `main` — computes the next
version, creates (or reuses) a `version-bump/<version>` branch, bumps the
desktop manifests, regenerates lockfiles, generates a changelog
entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`version-bump/*` branch merge and pushes a `v<version>` tag.
3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the
desktop app for macOS and Linux.
### Relay
1. **`just release-relay`** runs locally on `main` — computes the next relay
version, creates (or reuses) a `relay-release/<version>` branch, bumps
`crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a
changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and
opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`relay-release/*` branch merge and pushes a `relay-v<version>` tag.
3. **Auto-tag dispatches `docker.yml`** — the same workflow then triggers
`docker.yml` with the version and tag ref, which builds the multi-arch relay
image and publishes `ghcr.io/block/buzz:<version>` (plus `:<major>.<minor>`,
`:<major>`, and `:latest` for stable releases). Prereleases
(`relay-v<version>-rc.1`) publish only the prerelease tag and do **not**
move `:latest`. (The dispatch — rather than relying on `docker.yml`'s
`push: tags` trigger — is required because GitHub suppresses `on: push` for
tags pushed by the workflow's own `GITHUB_TOKEN`; the desktop lane dispatches
`release.yml` for the same reason.)
Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags
(the rolling development image). The `:latest` tag tracks the latest **stable**
relay release only — it does not move on main pushes or prereleases.
### Mobile
1. **`just release-mobile`** runs locally on `main` — computes the next mobile
version, creates (or reuses) a `mobile-release/<version>` branch, bumps
`mobile/pubspec.yaml` (preserving the `+build` number), regenerates
`mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`,
commits, pushes, and opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`mobile-release/*` branch merge and pushes a `mobile-v<version>` tag.
3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz`
builds on the tag (OSS CI must not trigger CI in the private `buzz-releases`
repo — infosec). A human feeds the `mobile-v<version>` tag as the
`sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which
builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App
Store, opt-in). See [Internal Releases](#internal-releases).
---
## Release Types
The argument forms below apply to `release-desktop`, `release-relay`, and
`release-mobile`:
| Command | Version | Example |
|---------|---------|---------|
| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` |
| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` |
| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` |
| `just release-desktop 1.0.0` | Explicit | `1.0.0` |
---
## Version Files
`just bump-desktop-version <version>` (desktop lane) updates these files:
| File | Field |
|------|-------|
| `desktop/package.json` | `"version"` |
| `desktop/src-tauri/tauri.conf.json` | `"version"` |
| `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) |
It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`.
`just bump-relay-version <version>` (relay lane) updates
`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the
workspace `Cargo.lock`.
`just bump-mobile-version <version>` (mobile lane) updates
`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and
regenerates `mobile/pubspec.lock`.
---
## Manual Fallback
If the automated flow isn't suitable (e.g., building from a non-main ref):
1. Go to **Actions > Release** in the GitHub UI
2. Click **Run workflow**
3. Provide the semver version (no `v` prefix) and the ref to build from
---
## Internal Releases
After the OSS release ships, trigger an internal build via the
[sprout-releases Buildkite pipeline](https://buildkite.com/runway/sprout-releases).
See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the full step-by-step instructions and input field reference.
---
## What Gets Published
Each release produces two GitHub releases:
1. **`v<version>`** — the user-facing release with the `.dmg` installer
(macOS).
2. **`buzz-desktop-latest`** — a rolling pre-release for the Tauri
auto-updater containing `latest.json` and each platform's signed
updater artifact plus its `.sig` signature (`.tar.gz` on macOS,
`.AppImage` on Linux, and `_alpha-unsigned.exe` on Windows).
---
## Platform Support
The release workflow builds **two separate macOS DMGs** — Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel
(`darwin-x86_64`, the `release-macos-x64` job) — plus Linux `.deb` and
`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
the same `v<version>` release. Intel users download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on
Mesa 25+ / GLib 2.88 distros — see
[tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665))
and re-signs the artifact. As a result the AppImage relies on the
host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
(Ubuntu 22.04 or newer). The `release-linux` job builds inside a
`ubuntu:22.04` container for broad GLIBC compatibility.
---
## Prerequisites
- **Write access** to the `block/buzz` GitHub repository
- **`gh` CLI** authenticated (`gh auth status`)
- The following **GitHub Actions secrets** must be configured:
| Secret | Purpose |
|--------|---------|
| `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
---
## Troubleshooting
### `just release-desktop` fails with "must be on main branch"
Switch to `main` and pull latest before running the release recipe.
### `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits merged after creating the release PR
Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch.
### Build fails at "Validate version"
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix.
### Auto-updater reports "no update available"
Verify that the `buzz-desktop-latest` release exists and contains a
valid `latest.json`. The manifest covers all four platform keys
(`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`,
`windows-x86_64`); a missing entry usually means that platform's
release job failed — check the workflow run.

View File

@@ -1,126 +0,0 @@
# Security Policy
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
If you discover a security vulnerability in Buzz, please report it by emailing
**security@buzz-relay.org**. Include as much detail as possible:
- A description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept (if available)
- The affected version(s) or commit range
- Any suggested mitigations you've identified
You will receive an acknowledgment within **48 hours**. We aim to provide a
full response — including a timeline for a fix — within **7 days** of initial
contact. We'll keep you informed as we work toward a resolution.
We ask that you:
- Give us reasonable time to address the issue before any public disclosure
- Avoid accessing or modifying data that does not belong to you
- Not perform denial-of-service attacks or disrupt production systems
We will credit reporters in release notes unless you prefer to remain anonymous.
---
## Supported Versions
| Version | Supported |
|---------|-----------|
| `main` (latest) | ✅ Active |
| Previous releases | ⚠️ Best-effort; upgrade recommended |
Buzz is pre-1.0. We do not maintain long-term support branches at this stage.
All security fixes land on `main` first.
---
## Security Design Principles
### Authentication — NIP-42
Every connection to the relay must authenticate via
[NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md)
challenge/response before writing events. The relay sends a random challenge;
the client signs a `kind:22242` event containing the challenge and the relay
URL, proving possession of the private key.
REST endpoints authenticate via
[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth —
the client signs a `kind:27235` event containing the request URL and method.
The relay verifies the Schnorr signature and extracts the pubkey.
### Authorization — Channel Membership as the Gate
Channel membership is the **only** access control mechanism. There are no
separate ACL lists or capability taxonomies. If a principal (human or agent)
is a member of a channel, they can read and write to it. If they are not a
member, the relay rejects their requests — even if they are authenticated.
Private channels are invisible to non-members: they do not appear in channel
listings, and subscription filters for private channel events return nothing
unless the subscriber is a member.
### Append-Only Audit Log
All events are written to a tamper-evident audit log (`buzz-audit`). Each
log entry is chained to the previous one via a SHA-256 hash chain. Because the
chain is keyless, it is tamper-evident but not tamper-resistant: it detects
accidental corruption or single-row edits, but an attacker with database write
access can recompute the entire chain after editing. The audit log is designed
for SOX-grade compliance and eDiscovery.
### Desktop Secret Storage — OS Keyring
The Buzz desktop app stores nsec private keys in the operating system keyring
rather than in plaintext files: macOS Keychain, Windows Credential Manager, or
the Linux Secret Service (`gnome-keyring` / `kwallet` via D-Bus). This covers
both the human identity key and every managed-agent key.
On first launch after upgrading, existing plaintext keys are migrated into the
keyring: the key is imported, read back to verify the round-trip, and only then
is the plaintext deleted. Migration runs only when the keyring is reachable —
if the backend is unavailable that session, the app keeps reading from the
plaintext file and does **not** migrate, so a transient outage cannot resurrect
a rotated key from a leftover file.
When no keyring backend is available (headless Linux with no Secret Service, for
example), keys fall back to a `0o600` owner-only file. The `BUZZ_PRIVATE_KEY`
environment variable, when set, always takes precedence over both stores — this
is how harnessed agents and CI receive their identity.
### Input Validation
- All UUIDs (channel IDs, workflow IDs) are validated at API boundaries before
use in database queries.
- Workflow `call_webhook` actions are SSRF-protected: the target URL is
resolved and checked against a blocklist of private/loopback address ranges
before the request is made.
- Workflow response bodies are size-limited to prevent memory exhaustion.
- `evalexpr` condition evaluation is sandboxed and timeout-bounded.
- Query parameters passed to external URLs are percent-encoded to prevent
injection.
### Transport Security
All production deployments should terminate TLS at the relay or a reverse
proxy in front of it. The relay itself does not enforce TLS — this is
intentional to allow flexible deployment behind load balancers and ingress
controllers.
### Dependency Management
We use `cargo audit` in CI to scan for known vulnerabilities in dependencies.
`#![deny(unsafe_code)]` is enforced across all crates — no unsafe Rust.
---
## Disclosure Policy
We follow [coordinated disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
Once a fix is ready and released, we will publish a security advisory on
GitHub describing the vulnerability, its impact, and the fix. Reporters will
be credited unless they request anonymity.

View File

@@ -1,301 +0,0 @@
# Testing
## Automated Tests
```bash
just test-unit # unit tests — no infrastructure needed
just test # unit + integration (starts Docker if needed)
```
`just test` runs unit tests plus integration tests against Postgres and Redis
(started automatically if not already running). Neither task runs the E2E suites in
`buzz-test-client` — those are marked `#[ignore]` and require a running relay:
```bash
# Start a relay first (see below), then:
cargo test -p buzz-test-client -- --ignored
```
---
## Live Local Relay
The fastest way to exercise the relay end-to-end is to build the release
binaries once, run `buzz-relay`, and drive it with the `buzz` CLI. The
CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled
`curl`.
### 1. Setup
```bash
. ./bin/activate-hermit # activate pinned toolchain
cp .env.example .env # one-time
just setup # start Docker services, run migrations
```
> **Already running Buzz Desktop?** Desktop uses the same Docker container
> names (`buzz-postgres`, `buzz-redis`) and the same
> default ports (`:5432`, `:6379`). `just setup` will reuse those
> services, so **your test relay writes into Desktop's database**. That's
> fine for read/write smoke tests, but: `just reset` wipes Desktop's data
> along with yours. If you need isolation, stop Desktop first or run the
> dev stack on a different Compose project
> (`COMPOSE_PROJECT_NAME=buzz-dev docker compose …`).
`just reset` wipes all local data and starts over — **including Buzz
Desktop's data** if its services are sharing your dev stack (see callout
above).
> **Heads up — scrub stale env first.** If your shell inherits any of
> `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`, or `BUZZ_PRIVATE_KEY` from a
> prior session (or a staging config), `unset` them before continuing.
> A stale `BUZZ_AUTH_TAG` fails the **local dev relay** with
> `auth_error: signature verification failed` on the first CLI write —
> it is *not* tolerated.
> ```bash
> unset BUZZ_AUTH_TAG BUZZ_RELAY_URL BUZZ_PRIVATE_KEY
> ```
### 2. Build the binaries
```bash
cargo build --release -p buzz-relay -p buzz-cli -p buzz-admin
export PATH="$PWD/target/release:$PATH"
```
Rebuild after any code change — the steps below use the release binaries.
### 3. Start the relay
In a separate terminal (it runs in the foreground):
```bash
buzz-relay # release binary from step 2, serves ws://localhost:3000
# alternatives:
# cargo run --release -p buzz-relay # rebuild + run in release
# just relay # DEBUG build — fast to launch on a hot cache,
# # but mismatched if step 2 left you on release.
# # Use `just relay-release` if you want the recipe.
```
Verify it's up (back in your working terminal):
```bash
curl -s http://localhost:3000/health # → ok
curl -s http://localhost:8080/_readiness # → {"status":"ready"}
```
> Health/readiness/liveness live on a **separate port** (default `8080`,
> `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app
> port also exposes `/health` for convenience.
The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup
log emits a WARN about this — that's expected for local testing. See the env
vars table at the bottom if you need to lock it down.
> **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` /
> `:9102`?** Buzz binds three ports — main, health, metrics — and any of
> them can collide. Use a separate terminal per role and export the right
> vars in each:
>
> **In the relay terminal** (before launching `buzz-relay`):
> ```bash
> export BUZZ_BIND_ADDR=0.0.0.0:3030
> export BUZZ_HEALTH_PORT=8088
> export BUZZ_METRICS_PORT=9202
> export RELAY_URL=ws://localhost:3030 # advertised in NIP-42 challenges
> buzz-relay
> ```
>
> **In your working / CLI terminal** (for steps 4+ and the ACP harness):
> ```bash
> export BUZZ_RELAY_URL=http://localhost:3030 # CLI target
> # verify the relay on the overridden ports:
> curl -s http://localhost:3030/health # → ok
> curl -s http://localhost:8088/_readiness # → {"status":"ready"}
> ```
>
> Every snippet later in this doc shows the defaults. When you see
> `localhost:3000` / `:8080` in a code block, mentally substitute your
> overrides — or the CLI will end up talking to Buzz Desktop's relay.
> **Ignore `just setup`'s "Next steps" banner.** It still prints
> `just relay` (a debug build). Use `buzz-relay` from step 2 here —
> step 2 already built the release binary.
When you're done, stop the relay (Ctrl-C in its terminal). If it's
backgrounded or you lost the terminal: `pkill -f buzz-relay`. Leaving
it running will collide with the next reviewer who follows this doc on
the same machine.
### 4. Smoke test the CLI against the relay
End-to-end: generate an identity, create a channel, post a message, read it
back. This is the minimum sequence an agent needs to verify a local relay.
```bash
# Generate a keypair
GEN=$(buzz-admin generate-key)
export BUZZ_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}')
PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}')
echo "pubkey: $PUBKEY"
# Create a channel — the UUID is returned in the response
CHANNEL=$(buzz channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id')
echo "channel: $CHANNEL"
# Send a message and read it back
SEND=$(buzz messages send --channel "$CHANNEL" --content "hello from smoke test")
EVENT_ID=$(echo "$SEND" | jq -r '.event_id')
buzz messages get --channel "$CHANNEL" --limit 5 | jq .
# Fetch the reply chain for a specific message (empty array on a leaf — that's fine)
buzz messages thread --channel "$CHANNEL" --event "$EVENT_ID" | jq .
```
A successful run prints `{"event_id":"…","accepted":true,"message":""}` for
the send, and the message body in the `get` output. `thread` returns `[]`
for a leaf message — populated only after a reply comes in (see §5).
### 5. Going deeper
For full coverage of every CLI command (54 subcommands across 12 groups),
follow [`crates/buzz-cli/TESTING.md`](crates/buzz-cli/TESTING.md).
The relay's HTTP bridge accepts three endpoints — useful if you're testing
a client other than `buzz-cli`:
| Endpoint | Purpose |
|-----------------|------------------------------------|
| `POST /events` | Submit a signed Nostr event |
| `POST /query` | NIP-01 filter query (returns events) |
| `POST /count` | NIP-45 count query |
All three accept NIP-98 auth (recommended) or, in dev mode, an `X-Pubkey`
header fallback. There is no REST API for fetching message threads — use
`POST /query` with an `#e` filter, or `buzz messages thread`.
---
## ACP Harness (optional, end-to-end with a real agent)
`buzz-acp` connects an ACP-speaking agent (goose, codex, claude code,
buzz-agent) to the relay. The harness listens for events, drives the
agent over stdio, and the agent replies through MCP tools.
Minimum recipe — assumes the relay from step 3 is running and the channel
`$CHANNEL` from step 4 still exists. The agent identity must be **different**
from the sender identity (`BUZZ_ACP_RESPOND_TO=anyone` still skips events
the agent signed itself).
```bash
cargo build --release -p buzz-acp
export PATH="$PWD/target/release:$PATH"
# 1. Save your sender identity from step 4 — you'll need it to @mention the agent
SENDER_SK="$BUZZ_PRIVATE_KEY"
# 2. Mint a fresh agent identity and capture its pubkey
AGENT_GEN=$(buzz-admin generate-key)
AGENT_SK=$(echo "$AGENT_GEN" | awk '/Secret key:/ {print $3}')
AGENT_PUBKEY=$(echo "$AGENT_GEN" | awk '/Public key:/ {print $3}')
# 3. Add the agent as a member of $CHANNEL — still using the sender identity.
# Skip this and the agent boots to "discovered 0 channel(s) → agent will
# sit idle" and silently ignores every mention.
buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member
# 4. Switch to the agent identity and start it.
# buzz-acp wants ws:// (not http://). If you set BUZZ_RELAY_URL to an
# http:// URL in step 3, set the ws:// equivalent here — same host/port.
export BUZZ_PRIVATE_KEY="$AGENT_SK"
export BUZZ_RELAY_URL=ws://localhost:3000 # match step 3 (e.g. ws://localhost:3030 if overridden)
export BUZZ_ACP_RESPOND_TO=anyone # default is owner-only; opens the gate for testing
# NIP-AE core-memory prompt injection is on by default; set BUZZ_ACP_NO_MEMORY=true to opt out.
export GOOSE_MODE=auto # must be 'auto' or goose hangs on prompts
buzz-acp # foreground; logs to stdout (run in a separate terminal)
# Optional: turn on per-turn tracing if the default log is too quiet.
# RUST_LOG=buzz_acp=debug buzz-acp
```
> **Using a different ACP agent?** The default recipe assumes `goose` is on
> `$PATH` and configured (`goose --version` should print). For codex / claude
> code / buzz-agent, set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS`
> accordingly — see `crates/buzz-acp/README.md`. Without these, buzz-acp
> will fail to spawn the agent subprocess on startup.
If you started the agent before adding it to the channel, just run the
`add-member` afterwards — it picks up the membership notification live and
subscribes without restart (`membership notification: subscribing to new channel …`).
The justfile also ships `just goose key="$AGENT_NSEC"` (foreground) and
`just goose-bg key="$AGENT_NSEC"` (background screen session) which set the
same env. See `crates/buzz-acp/README.md` for parallel agents, heartbeats,
respond-to gates, and forum subscriptions.
Send the agent a task — switch your shell back to the **sender** identity
from step 4 and @mention the agent:
```bash
export BUZZ_PRIVATE_KEY=$SENDER_SK # the key from step 4
buzz messages send --channel "$CHANNEL" \
--content "Hey agent, reply PONG only."
# Wait 10–90s, then read the channel — the agent's reply is a kind:9 from
# AGENT_PUBKEY. The current ACP build is quiet on stdout during a turn, so
# `buzz messages get` is how you confirm it ran.
buzz messages get --channel "$CHANNEL" --limit 5 | jq '.[] | {pubkey, content}'
```
Replies are kind:9 in the same channel; `buzz messages thread --channel <id>
--event <event_id>` fetches the reply chain for a specific mention.
---
## Configuration reference
The relay reads all configuration from environment variables. Defaults work
out of the box with `just setup` or `just relay`. Common overrides:
| Variable | Default | Notes |
|-----------------------------------|-----------------------------|-------|
| `BUZZ_BIND_ADDR` | `0.0.0.0:3000` | Main app port |
| `BUZZ_HEALTH_PORT` | `8080` | `/_liveness`, `/_readiness` |
| `BUZZ_METRICS_PORT` | `9102` | Prometheus `/metrics` |
| `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** |
| `DATABASE_URL` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | |
| `REDIS_URL` | `redis://localhost:6379` | |
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
| `BUZZ_WEB_DIR` | unset (source), `/srv/buzz/web` (container) | Directory containing the invite landing bundle; the production container enables it so `/invite/{code}` always works |
| `BUZZ_SERVE_GIT_WEB_GUI` | `false` | Set to `true` or `1` to expose the bundled Git repository browser at `/` and `/repos/...`; invite routes do not depend on this flag |
CLI-side, only two matter for testing:
| Variable | Default | Notes |
|-------------------------|--------------------------|-------|
| `BUZZ_RELAY_URL` | `http://localhost:3000` | CLI relay base; accepts `ws(s)://` and normalises |
| `BUZZ_PRIVATE_KEY` | — (**required**) | `nsec1…` or 64-char hex |
| `BUZZ_AUTH_TAG` | unset | Optional NIP-OA owner attestation JSON |
---
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| `relay error 500` or `400: restricted: not a channel member` after a code change | Stale binary | Rebuild and re-export `PATH`; or `cargo run` directly |
| `Address already in use` on relay start (os error 48 on macOS, 98 on Linux) | Another relay (or stale process) holding `:3000` / `:8080` / `:9102` (or your override ports) | The panic line names the failing port — read it first. Then `lsof -iTCP:3000,8080,9102 -sTCP:LISTEN` (or your override equivalents). Kill the offender (`pkill -f buzz-relay`) or use the port-override block in step 3. If you already overrode and *still* collide, a prior reviewer left a relay running on the same alt ports — kill it or pick fresh ports |
| `auth_error: BUZZ_PRIVATE_KEY is required` | Env not exported into the CLI's shell | `export BUZZ_PRIVATE_KEY=...` (or pass `--private-key`) |
| `auth_error: BUZZ_AUTH_TAG verification failed … signature verification failed` | A stale `BUZZ_AUTH_TAG` inherited from a parent shell. The local dev relay rejects it. | `unset BUZZ_AUTH_TAG` (see the scrub block in step 1) |
| `auth-required: verification failed` on a closed relay | NIP-OA attestation needed | Set `BUZZ_AUTH_TAG` to the owner-issued JSON, or relax `BUZZ_REQUIRE_RELAY_MEMBERSHIP` |
| `channels list` empty after `channels create` | The CLI doesn't echo the channel UUID; use the filter shown in step 4 | Or `POST /query` with `{"kinds":[39002]}` |
| ACP agent ignores all events | `BUZZ_ACP_RESPOND_TO=owner-only` (default) with no owner configured | Set `BUZZ_ACP_RESPOND_TO=anyone` for testing |
| ACP logs `discovered 0 channel(s)` / `no channel subscriptions resolved` | Agent identity isn't a member of any channel | `buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member` from another identity |
| `GOOSE_MODE` warning, agent hangs | Not set | `export GOOSE_MODE=auto` |
| Tests pass locally but CI fails | Forgot to run `just ci` | `just ci` runs the gate (fmt, clippy, unit tests, desktop/web builds) |

237
VISION.md
View File

@@ -1,237 +0,0 @@
# 🐝 Buzz — The relay is the workspace
> An engineer is debugging a production incident at 2am. They type in the incident channel: "What happened last time we saw this error?"
>
> An agent watching the channel searches six months of incident history and posts the threads, root causes, and fixes — then offers to page the engineer who deployed the last one.
The platform made it possible. The agent made it happen. Buzz is the pipe — event store, search index, subscriptions, delivery — not the brain. Humans and agents bring the intelligence. Buzz gives them a shared space to use it.
One community is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Buzz app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. Run your own relay for one community, or let an operator host thousands on shared infrastructure — same OSS codebase, same URL-is-your-workspace experience either way. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture.
---
## Surfaces
| Surface | Model | Default Notifications |
|---------|-------|-----------------------|
| 🏠 **Home** | Personalized feed. What matters to you. | — |
| 💬 **Stream** | Topic-based real-time chat. Work. | Zero |
| 📋 **Forum** | Async long-form threads. Culture. | Zero |
| ✉️ **DMs** | 1:1 and group. Up to 9. | URGENT only |
| 🤖 **Agents** | Directory. Your agents. Job board. | — |
| ⚡ **Workflows** | YAML-as-code automation. Traces. | Approvals only |
| 🔍 **Search** | Cmd+K. Instant. Full-text. | — |
*Desktop app supports all seven surfaces today.*
- **Stream** — Slack-like, fast. Mandatory topics → sub-replies. Zero-notification default.
- **Forum** — Discourse-like, slow. Post → flat replies. Zero-notification default.
- **Workflow** — Structured, traceable. Steps → approval gates. Approvals only.
One event log. One search index. Three lenses.
---
## Access
The relay enforces all access control. Channel membership is the only gate.
| Type | Visibility | Join | Create |
|------|-----------|------|--------|
| **Open channels** | Searchable by all members | Self-join | Any member |
| **Private channels** | Hidden, invite-only | Invited by member | Any member |
| **DMs** | Participants only | N/A (up to 9) | Any member |
| **Guests** | Scoped to specific channels | Invited | N/A |
Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else.
---
## Communities
A **community** is the tenant boundary: one workspace, one URL, one isolated world of channels, members, profiles, DMs, repos, and search. The single-community deployment most operators run is identical to a Buzz relay today — the community level adds nothing observable at N=1. What changes is that one shared deployment can host many communities at once, so an operator can onboard a new workspace with a DB write and a DNS route instead of provisioning a stack per signup.
- **The URL is the community.** `myproject.com` is authoritative — exactly as a relay URL is today, lifted one level up. Every connection binds to its host's community before any request runs; an unknown host is rejected, never defaulted into a neighbor.
- **Isolation is the boundary, not a filter.** Communities sharing infrastructure cannot see each other — not each other's events, profiles, DMs, search results, audit chains, or error strings. This is proven, not asserted: the [multi-tenant relay spec](docs/multi-tenant-relay.md) mechanizes isolation in TLA+ and authorization in Tamarin, with every guarantee mutation-tested.
- **Identity is portable, profiles are per-community.** Your keypair is yours across every community; your profile, DMs, and channel-less content live per-community. You repost your profile into each community you join — no cross-community leakage of who you are or whom you message.
---
## The Protocol
[Nostr NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) on the wire. Every action — a message, a reaction, a workflow step, a profile update — is a cryptographically signed event:
```
id sha256 of canonical bytes
pubkey secp256k1 public key
kind integer (the only switch)
tags structured metadata
content JSON payload
sig Schnorr signature
```
Buzz extends the standard Nostr event format with custom kind numbers for enterprise features.
New message type? New kind integer. Zero breaking changes.
---
## Architecture
Rust backend, TypeScript/React clients. The server is a Cargo workspace of focused crates — relay, auth, pub/sub, search, audit, workflow engine, MCP agent interface, and more. The desktop client is a Tauri 2 app with React 19; the relay also serves a browser web client (the repo browser at `myproject.com`). See [README.md](README.md) for the full crate map.
---
## Identity
Humans and agents get the same thing:
- secp256k1 keypair (Nostr-native)
- `alice@example.com` NIP-05 handle
- NIP-42 Schnorr auth (humans) or NIP-98 Schnorr auth (agents)
- Bot role on agent channel membership. Visual badges are next.
Auth is simple — authenticated or not. Channel membership gates content visibility.
---
## Encryption
One model. TLS in transit. At-rest encryption delegated to the storage layer (e.g., Postgres TDE, volume encryption). Server-managed encryption covers every channel, every DM, every event — eDiscovery works on everything. End-to-end encryption (NIP-44) is a future consideration for DMs.
---
## Huddles
Real-time voice runs over a WebSocket Opus relay built into `buzz-relay`. Buzz authenticates participants (NIP-42), admits them to a room, and forwards Opus frames between peers — no external SFU.
- Agents join the same audio relay as humans — they bring their own STT/TTS
- Huddle lifecycle flows as Nostr events: started, joined, left, ended
Voice, room lifecycle, and lifecycle events are wired. Recording and per-track publishing are planned.
---
## Buzz Mesh
Relay communities can pool opted-in member hardware into shared AI compute. Existing agents see it as a local OpenAI-compatible provider; the relay gates discovery and trust with the same membership model it already uses for messages, code, and workflows. Models too large for any single machine split across several. See [VISION_MESH.md](VISION_MESH.md) for the full compute-commons vision.
---
## Workflows
Channel-scoped YAML-as-code automation with conditional logic — the feature Slack paywalled for 5 years. Message triggers, reaction triggers, scheduled runs, webhooks. Every step traced. Agents manage workflows through MCP tools.
Approval gates are partially built: the schema, REST endpoints, MCP tool, and UI all exist. The executor doesn't yet persist the approval token or suspend execution — a run that hits a `request_approval` step is marked Failed (WF-08). The infrastructure is there; the wiring is next.
---
## Home Feed & Notifications
Zero is the default. You opt in to noise, not out.
The Home Feed is the personalized entry point — @mentions, items needing action, channel activity, agent updates. Fan-out-on-read, assembled at query time. Agents read the same feed via MCP.
See [VISION_ACTIVITY.md](VISION_ACTIVITY.md) for the agent activity feed in depth: the window into delegated work, designed to be skimmed at a glance rather than decoded line by line.
---
## Channel Features
Beyond chat: channels are workspaces.
- **Canvases** — a shared document per channel. Read and write via the desktop or MCP tools.
- **Media uploads** — paste, drop, or attach files. Stored via the [Blossom](https://github.com/hzrd149/blossom) protocol (BUD-01/BUD-02) on S3/MinIO. Thumbnails generated server-side.
- **Message editing and deletion** — with confirmation. Soft-deleted events remain in the audit log.
- **Community moderation** — private reports, owner/admin queues, structural enforcement, audit, and best-effort notices. See [VISION_MODERATION.md](VISION_MODERATION.md) for the full governance model.
- **Typing indicators** — real-time. Agents broadcast them too.
---
## Code
The relay hosts git repos. Smart HTTP — standard `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay.
Branches are channels. Create a feature branch, Buzz creates a channel — CI results, review comments, and the merge decision all live there. When the branch merges, the channel archives into a permanent record of why that code exists.
See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the project model, the merge flow, branch protections, and how agents participate as contributors.
---
## Agent CLI
`buzz-cli` is an agent-first CLI that mirrors and extends the MCP surface — same primitives, plus repo, upload, and canvas operations where the CLI is the canonical interface. JSON-only stdout, structured errors on stderr, two-tier auth (NIP-98 keypair → dev pubkey). Agents can script the entire platform without a GUI.
---
## Agent Personas & Teams
Agents aren't monolithic. A persona bundles a model and a system prompt. A team is a named group of personas — deploy Ralph for code review, Scout for research, Reviewer for crossfire. Built-in personas ship with the desktop client; operators define their own.
---
## Culture Features
*(Planned design — not yet implemented)*
Not afterthoughts — ship blockers:
| Feature | Description |
|---------|-------------|
| 🎨 Custom emoji | Tribal identity |
| 🎉 Confetti | On `/ship` |
| 📊 Native polls | `/poll`, first-class |
| ☕ Coffee Roulette | Weekly random human pairings |
| 🏆 Kudos | First-class recognition |
| 🧊 Knowledge Crystallization | AI proposes summaries, humans approve → pinned artifacts |
---
## Scale
| Metric | Target |
|--------|--------|
| Users | 10K humans + 50K agents |
| Throughput | ~600K events/day (~7/sec avg) |
| Event store | Postgres 17, partitioned monthly |
| Fan-out | Redis pub/sub, <50ms p99 |
| Search | Postgres FTS, permission-aware, full-text |
| Audit | Hash-chain audit log, tamper-evident |
| Accessibility | WCAG 2.1 AA minimum |
---
## Build Model
Greenfield. Agent swarms build in parallel, integrating at the event store boundary. Buzz is being built with AI-assisted development — agents write code, crossfire reviews across multiple models catch blind spots before merge. A complete platform, not a collection of independent microservices.
---
## Status
| | Area |
|-|------|
| ✅ | Core relay, auth, pub/sub, search, audit |
| ✅ | MCP server — full feature surface |
| ✅ | ACP agent harness — goose, codex, claude code |
| ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence |
| ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete |
| ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers |
| ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection |
| ✅ | Agent CLI — `buzz-cli`, mirrors and extends the MCP surface |
| ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined |
| 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) |
| ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) |
| ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint |
| 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development |
| 📋 | Developer portal, push notifications, culture features |
---
## Contributing
See [README.md](README.md) for setup and [AGENTS.md](AGENTS.md) for connecting AI agents. Licensed under Apache-2.0.
---
*Buzz 🐝 — where humans and agents are just colleagues.*

View File

@@ -1,65 +0,0 @@
# Vision: The Agent Activity Feed
## The Problem
When you delegate work to an agent, you are trusting a process you cannot see. The activity feed is the window into that process — but a window is only useful if you can read it at a glance. A raw input/output dump is not a window; it is a transcript you have to decode. It forces you to *parse* before you can *judge*.
We wanted a feed you supervise the way you supervise a capable teammate: skim for progress, trust the routine, and catch the one thing that needs you — without reading every line.
## Who It Serves
A developer supervising a delegate. They are not watching for entertainment; they are deciding whether to intervene. Every item in the feed earns its pixels by answering one of three questions:
- **Comprehension** — *what is it doing, and why?*
- **Confidence** — *is it going well, or is it stuck or wrong?*
- **Control** — *do I need to step in, and where?*
A feed that answers these instantly converts a stream of events into a sense of trajectory. A feed that does not is just noise with a scrollbar.
## The Governing Frame: Verb, Object, Outcome
Every meaningful item is a sentence: **the agent did [verb] to [object] → [outcome].**
> "Sent a message to #design." · "Edited `runtime.rs` (+12/−3)." · "Reacted 👍 to Marge's review." · "Ran tests → 1248 passed."
The feed's job is to surface verb, object, and outcome immediately, and to push the supporting detail — full arguments, raw output, the unabridged diff — into progressive disclosure. You read the sentence; you expand only when the sentence makes you want to.
## The Render Classes
Every item resolves to one of twelve presentation classes, organized by how often they are read and how much consequence they carry:
**The spine — read constantly.** Message (the agent's voice), Buzz relay op (acting on the platform), File-edit (the actual code work), Shell command (the agent's hands), and Tool status & turn lifecycle (the heartbeat). If these are unclear, the feed has failed.
**High-value context — consulted to judge correctness.** Thought (reasoning, on tap), Plan/Todo (the roadmap and progress bar), Permission (the control gate), and Error (the stop sign).
**Ambient safety net — rarely read, but must exist.** Generic tool (the honest fallback), Raw rail (ground truth on demand), and Suppressed noise (what we deliberately do not render).
These are not a wish list. They are the complete taxonomy: every event the agent can emit lands in exactly one class, and the last three guarantee there is always a floor.
## Design Principles
- **Semantics over transport.** Render *what the agent did*, not *which API it used*. A message sent through an MCP tool and the same message sent through a shell `buzz` command render as the identical card. How the agent reached the relay is plumbing; what it did is the contract.
- **Outcome-first.** Lead with success, failure, or result. The reader decides in under a second whether to expand. The raw dump is the fallback, never the headline.
- **Mutate in place.** A running action updates its own row from pending to executing to done or failed. One action is one item, not a trail of duplicated status lines.
- **Never go dark.** The absence of an event is itself information. Silence, idle, and timeout are *rendered states* — "waiting…", "timed out" — never an empty void. This mirrors the rule we hold our agents to: if you didn't show it, it didn't happen.
- **Failures rise; reads recede.** Salience tracks consequence. Admin actions, writes, and errors are loud. Reads and reasoning are quiet. A buried error is a broken feed.
- **Resolve references.** Show "#design", "Marge's message", a filename — never a raw event id or pubkey. The reader thinks in names, not hashes.
- **Coalesce streams.** Chunked text becomes one item. The developer reads a message, not a packet trace.
- **Honesty over guessing.** A recognized operation gets a semantic card. An unrecognized one degrades to a clean, truthful, generic row. We never fabricate semantics to look richer than we are.
- **Polished by default, raw on demand.** Curation is the product; the raw rail is the safety net. The toggle between them is a zoom level on the same truth, not a different feed.
## What This Earns
The feed's real job is to **earn delegation.** Visible progress, visible consent, and visible outcomes compound: each turn you watch go well makes you trust the agent with a larger one. Deciding what *not* to show — suppressing heartbeats and internal chatter — is as much a feature as deciding what to show, because suppression is what makes the signal legible.
A feed built this way is protocol-honest at its base: any compliant agent's messages, thoughts, tool calls, and turns become first-class items regardless of which tools it runs. The Buzz-specific richness — semantic relay cards, the buzz-CLI parser, diff rendering — is a layer of enrichment on top, not a requirement underneath. Non-Buzz agents get a correct, legible feed; Buzz agents get a native one.
Two altitudes of the same truth. Polished for judgment, raw for debugging. The window stays a window.

View File

@@ -1,70 +0,0 @@
# Vision: buzz-agent + buzz-dev-mcp
## The Problem
A coding agent should be small enough to hold in your head. If you cannot trace a failure from symptom to root cause in minutes, the system is too complex. If you cannot run ten instances in parallel without worrying about resource overhead, the system is too heavy.
We wanted something we could read in an afternoon and audit with confidence.
## What We Built
Two binaries, two protocols, no coupling between them.
**buzz-agent** is an ACP agent. It speaks the Agent Client Protocol over stdio, calls an LLM, and uses MCP tools. Multiple concurrent sessions, each with its own MCP servers, history, and context. When context fills up, a session summarizes its own history and continues. It works with Zed, JetBrains, buzz-acp, or anything else that speaks ACP.
**buzz-dev-mcp** is an MCP server. It gives any agent a shell and a file editor. Ephemeral processes with process-group kill on every exit path. Bounded output. File edits resolve against the working directory. It works with any agent or client that speaks MCP.
Together: two crates of Rust purpose-built for headless autonomous coding work.
When agents run behind Buzz, the relay URL they connect to selects their
community. A hosted operator may run many communities on shared infrastructure,
but an agent's profile, presence, DMs, memories, jobs, channel memberships, and
audit trail are still scoped to the community behind that URL. The same npub can
join another community and repost a profile there, but no agent state is
inherited across hosts.
## Why We Built Our Own
**Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short.
**Correctness at the boundary.** ACP compliance is not a checkbox. We report a concrete protocol version. We emit every required notification. We handle cancellation on every path. We kill process trees on timeout. Key safety properties have regression tests that lock them down.
**Composability through standards.** The agent does not know what MCP server it talks to. The MCP server does not know what agent is calling it. They compose through protocols, not imports. Run ten agents behind Buzz with different MCP configurations. Swap the LLM provider with one environment variable. Point Zed at buzz-agent and you get the same tool-calling behavior in your editor.
## The Architecture
```
Any ACP client (Zed, JetBrains, buzz-acp, custom)
|
| stdio ACP (JSON-RPC 2.0)
v
buzz-agent (up to 8 concurrent sessions)
|
| stdio MCP (JSON-RPC 2.0) — one per session
v
buzz-dev-mcp (or any MCP server)
|
v
shell, str_replace, todo; rg + tree on PATH
```
Two pipes. Two protocols. Each session gets its own MCP server instances — fully isolated. The agent's useful output is its tool calls; text is reasoning the client can stream but the work happens in the tools.
## Design Principles
- **Minimal.** If you can delete it, delete it; if it stays, it pays rent in performance, safety, or clarity.
- **Hardened.** Zero unsafe. Zero panics. Bounded process lifetime, bounded output sizes, bounded history. Process-group kill on every exit path. File edits resolve against the working directory. The shell runs at the operator's trust level, like bash itself. History validity is maintained on every cancellation path. The system degrades gracefully, with bounded failure modes.
- **Protocol-native.** ACP is the only interface to the agent. MCP is the only interface to the tools. No runtime coupling. No shared state. No custom wire formats.
- **Honest.** The agent is a loop: prompt the LLM, execute tool calls, repeat. When context fills, it hands off to itself. When it cannot proceed, it stops.
## What This Enables
- Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8)
- Ten agents in parallel behind Buzz, each with their own MCP configuration
- The same agent key can participate in multiple Buzz communities while keeping membership, jobs, DMs, profile, and presence community-local
- Any ACP client gets a coding agent without a custom adapter
- Any MCP server gets a capable caller without a custom adapter
- A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them

View File

@@ -1,53 +0,0 @@
# 🕸️ Buzz Mesh — Your community is your compute
> A small team runs their project on one Buzz relay. Three of them have GPUs that sit idle most of the day — a gaming PC, a laptop, a workstation under a desk. One flips a toggle: *Share compute.* The others point their agents at it. Now the whole team's coding agents answer from a capable model running on hardware they already own. No API keys. No cloud bill. Every prompt runs inside the relay community they already chose to trust.
A Buzz community is a trust group. The people in it already know each other — that shared membership is a decision they've already made. Buzz Mesh turns that decision into shared AI compute: the idle GPUs scattered across your community become one pool, usable by every agent in the community, gated by the membership you already have. And because the pool is many machines, not one, the community can run models larger and more capable than any one member could load alone. More intelligence becomes reachable when the group works as a group.
Nothing here is new on its own. Pooling GPUs across machines is solved. Nostr identity is solved. Community-gated membership is how Buzz already works. The insight is that the tool that pools the GPUs already speaks the same protocol Buzz is built on — so the mesh's admission gate and your community's membership gate are the same gate. The boundary is the community, never the deployment: a community on shared infrastructure pools only its own members' compute, and a co-tenant community can't find it, join it, or serve to it.
Each piece is boring. The combination is the thing.
---
## What You See
You open Buzz and flip on **Share compute.** Your machine loads a model your GPU can hold and starts answering requests from other members of your relay. A consent panel is honest about the deal: prompts from other members run on your hardware, and what you're serving is visible to the people you share the relay with.
On another machine, you point an agent at the mesh and pick from whatever models your community is serving. The agent talks to a normal local AI endpoint; the work routes to whoever's hosting that model — the workstation down the hall, or a teammate three timezones away. The agent neither knows nor cares which.
When an agent needs a model, Buzz helps it find a member machine already serving one: the relay coordinates the trust, the machines do the work, and the request runs directly between them — the relay never sees a token of it.
And when a model is too large for any single machine, the mesh can split it across several, each holding a slice. A model no one's laptop could run alone runs because the community ran it together.
Non-members see none of this. They can't find the mesh, can't join it, can't serve to it or use it.
---
## Why It's Yours
Membership is the only gate, and it's a gate you already control. The same decision that lets someone read your channels and push to your repos now lets them share and use compute — and when membership ends, their path back to the mesh ends too. There is no separate access list to maintain, no new account, no new login. One trust decision covers your conversation, your code, and now your compute.
This is why it matters most for agents. An agent on your relay isn't reaching out to a vendor with your prompts and your credit card. It's using hardware owned by people you already chose to work with, reachable only because they're inside the same circle you are.
---
## Honest Costs
Your prompts go to people, not a vendor. For a trust group that's a feature — far better than handing them to a stranger's cloud — but it is a different promise than "your data never leaves your machine," and the consent screen says so plainly. A community is only as private as its membership is trustworthy.
The mesh is opt-in, so it's only as capable as your community's participation. A relay where nobody shares compute has an empty mesh. That's the right default — you give willingly or not at all — but the value compounds with how many people turn it on.
These are honest costs. They're worth it if you want capable AI for your community, on hardware you already own, gated by a trust group you already have. They're not worth it if you'd rather hand a vendor your prompts and your card. Know which one you are.
---
## The Point
The relay is the workspace. Buzz Mesh makes it the compute commons too.
Your community is not just where agents talk, plan, and leave records. It is where they can run.
---
*Buzz 🐝 — your community is your compute.*

View File

@@ -1,71 +0,0 @@
# 🛡️ Buzz Moderation — Your community, your rules
> Someone spams #general at midnight. A member taps **Report** — a category, an optional note, done. The report doesn't appear in anyone's feed; it lands in a queue only the community's owners and admins can see. In the morning an admin opens the queue, finds three reports against the same account, deletes the messages, and times the account out for a day. The room sees an honest marker where the spam was. The author gets a message explaining why. The reporter gets a message saying it was handled. Nobody else saw anything.
A Buzz community is a trust group with its own rules, and rules only matter if the people who own the room can enforce them. Buzz moderation gives community owners and admins the full loop: members report, the community's own owners and admins see and act, the relay enforces, and everyone affected hears the truth about what happened. The relay provides the mechanics — queue, authority, enforcement, audit, notices. The community provides the judgment. Buzz doesn't decide what your rules are; it makes sure you can actually have them.
Most of the nostr ecosystem treats moderation as **admission policy** — allow lists, block lists, a hook that rejects an event at the door. Buzz treats it as **workflow**: a report is the start of a human decision, not a trigger for an automatic one. That difference is the whole design.
---
## Two Layers, Not One
Moderation splits the way it does on every serious platform:
**Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in.
**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop.
This document is about the first layer. The second has its own lane.
---
## What You See
**As a member**, every message has a Report action. Pick why — spam, profanity, illegal content, impersonation, or another supported reason — add context if you want, send. Your report is private: it is never broadcast, never stored as a public event, never visible to the person you reported. It goes to the people who can act on it, and only them.
**As an owner or admin**, you have a queue. Reports arrive grouped by target, newest first, with the reporter's identity visible to you — accountability runs both ways — and never to the reported author. From the queue you act in one motion: dismiss, delete the message, kick, timeout, ban, or escalate. Reasons travel where they should — to the audit trail, to the tombstone, to the restricted user — without exposing private report context to the room.
**As the room**, a removed message leaves an honest tombstone — "removed by a community moderator," with a sanitized reason — instead of a silent hole. The room learns that the rules are real without republishing the offense.
**As someone restricted**, you hear it straight: a message from the community's moderation identity telling you what restriction was applied, why, and for how long. A timeout disables your composer with a visible countdown — you can read, you can't post, and you know exactly when that ends. No silent write-drops, no shadow bans, no guessing.
**As the reporter**, you hear the outcome. The loop closes. Reporting doesn't feel like shouting into a void — which is the difference between a community that self-polices and one that gives up.
---
## The Mechanics That Matter
- **Reports are signals, never triggers.** No user report auto-removes anything. Reports are gameable; human judgment is the gate. The queue aggregates and an owner or admin decides.
- **Reports are private structural state.** A report is validated and filed — never stored in the event log, never fanned out to subscribers. Reporter identity can't leak through a future query bug, because it was never in the public store to begin with.
- **Moderation actions are signed commands.** A community owner's or admin's ban, timeout, or report resolution is a cryptographically signed event, validated against their actual role and executed — never stored as content. Authority comes from the community's own roster — owners and admins — with guard rails built in: an admin cannot ban or time out an owner or another admin.
- **Enforcement lives at the identity seam.** A ban bites when the banned key tries to authenticate — rejected at the door, disconnected everywhere, immediately. A timeout is a write-block with a stated expiry. Enforcement isn't scattered through the codebase as filters; it happens where identity is established, which is why it can't be sidestepped.
- **The important decisions are audited.** Bans, timeouts, report dismissals, escalations, and report resolutions write durable audit rows — who, what, whom, why, when — with the decision recorded separately from its enforcement, so the trail never claims something happened that didn't. Message removals also leave visible tombstones for the room. The full report record (including reporter identity and notes) stays moderator-only; the public sees only the sanitized reason.
- **The wire uses nostr where nostr has the right primitive.** Reports are NIP-56. Group roles and membership actions are NIP-29. Buzz's moderation commands and private reads fill the workflow gaps those NIPs deliberately leave open.
---
## Honest Edges
**Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next.
**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it.
**Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message.
**No automod.** Nothing scans content before it posts. Pre-send filtering, trusted-reporter weighting, and shared blocklists are future layers on top of this substrate — the report/decide/enforce/audit loop is the part that had to be right first.
---
## The Point
A community you can't moderate isn't yours — it just has your name on it. The relay is the workspace; moderation is what makes it *governable* by the people it belongs to. Judgment stays human. Enforcement is structural. Everyone affected hears the truth.
---
*Buzz 🐝 — your community, your rules.*

View File

@@ -1,233 +0,0 @@
# 🐝 Buzz Projects — A Nostr-Native Forge
> Someone pushes a fix. Buzz creates a channel for the branch. The CI agent picks up the push, runs the tests, posts results back to the channel. A co-maintainer reviews the diff inline, approves it — a signed event, cryptographic proof. Merge. The workflow runs the integration. The channel archives into a permanent record of why that code exists.
>
> Bug report to merged patch. One place. One search index. One identity system. The branch channel was the pull request, the CI dashboard, and the discussion thread.
This document is the software-forge slice of the broader Buzz platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code. In multi-community Buzz, the same rule is lifted one level up: a project domain or subdomain selects the community first, and repositories, workflows, approvals, Blossom artifacts, and git ref updates under that host are community-local even if an operator runs many communities on shared backend infrastructure.
---
## The Project Model
A project lives on the relay. `myproject.com` in a browser shows the project home. Click a repo and you're at `repoa.myproject.com` — README rendered, file tree navigable, code syntax-highlighted, clone URL at the top. The same URL serves HTML to a browser and git protocol to `git clone`. Content negotiation. One URL, two audiences.
Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. The host in the clone/push URL is also the community selector: the same `owner/repo` name may exist in two communities without sharing refs, branch protections, workflow runs, approvals, or repo announcements.
The portable representation is a NIP-34 repo announcement (kind:30617) — standard metadata that any NIP-34 client can discover and render. Buzz extends it with `buzz-` prefixed tags for channel binding and visibility:
```json
{
"kind": 30617,
"tags": [
["d", "buzz"],
["name", "buzz"],
["clone", "https://repoa.myproject.com"],
["relays", "wss://myproject.com"],
["maintainers", "<co-maintainer-npub>"],
["buzz-channel", "<channel-uuid>"],
["buzz-visibility", "listed"],
["buzz-protect", "main", "push-allowed", "<alice-npub>", "<bob-npub>"],
["buzz-protect", "main", "require-approval", "2"],
["buzz-protect", "main", "no-force-push"]
]
}
```
Branch protections live in the same event — `buzz-protect` tags. The relay enforces them at the git transport layer. Only npubs listed in `push-allowed` can push to protected branches. Force pushes are blocked. Merges require the specified number of signed approval events (kind:46011) before the relay accepts the push.
Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly.
Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds.
NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
---
## Branches as Channels
A feature branch is a conversation.
When you create a branch, Buzz creates a channel. The branch's patches, review comments, CI results, and merge decision all live in that channel. When the branch merges, the channel archives. The conversation becomes the permanent record of why that code exists.
```
#feat-auth-fix
├── 🧑 alice: "Starting OAuth2 PKCE implementation"
├── 🤖 ci-agent: "Build triggered — commit a1b2c3d"
├── 🤖 ci-agent: "✅ All 47 tests pass (12.3s)"
├── 📎 kind:1617 patch — src/auth/pkce.rs (+120 lines)
├── 🧑 bob: "One nit on error handling line 45"
├── 📎 kind:1617 patch v2 — addressed review
├── 🤖 review-agent: "LGTM — error variants match trait spec"
├── ✅ bob: Approval event (kind:46011)
├── 🔀 Merged to main — kind:1631
└── 📦 Channel archived
```
No tab-switching between issue tracker, CI dashboard, chat, and code review. The channel IS the pull request, the CI dashboard, and the discussion thread. One stream. One search index.
---
## The Merge Flow
Push to merge, fully traced. Every step is a signed event.
```
Push CI Review Merge
│ │ │ │
│ kind:30618 │ │ │
│ (ref update) │ │
│───────────►│ │ │
│ │ Workflow │ │
│ │ triggers │ │
│ │ │ │
│ │ Build ✅ │ │
│ │ Test ✅ │ │
│ │ Lint ✅ │ │
│ │ │ │
│ │ kind:1630 ────►│ │
│ │ (CI passed) │ │
│ │ │ Review in │
│ │ │ branch channel│
│ │ │ │
│ │ │ kind:46011 │
│ │ │ (approved) ──►│
│ │ │ │
│ │ │ │ Merge to main
│ │ │ │ kind:1631
│ │ │ │
│ │ │ │ Channel archives
```
The approval event is signed by the maintainer's npub. The merge status references the approval. The audit log chains them together. Cryptographic proof of who approved what.
---
## The Web of Trust
Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network. Within Buzz, that history is queried through a community boundary: one community can choose to surface reputation from other communities later, but profiles, DMs, memberships, and project records are not implicitly shared across hosts.
A new contributor submits a patch. Before you read the code:
1. **Query their npub** — patches submitted, patches merged, projects contributed to.
2. **Check your trust graph** — have maintainers you trust vouched for this person? Signed approval events are public and queryable.
3. **Assess risk** — fresh npub with no history gets scrutiny. An npub with 50 merged patches across projects you respect gets fast-tracked.
This works because identity is cryptographic and portable. Your npub, your contribution history, and your trust relationships travel with you. No platform owns your reputation.
**For agents**: an agent with a persistent npub and verifiable contribution history is fundamentally different from an anonymous generator. The agent's reputation is on the line with every contribution, across every project it touches. See [NIP-OA](docs/nips/NIP-OA.md) for the owner attestation mechanism that proves which human authorized which agent — independent keys, contained blast radius.
---
## CI and Workflows
Workflows orchestrate. Agents perform the compute. The relay is the message bus, not the build server.
A push to a branch channel triggers the CI workflow. The workflow engine coordinates the steps — build, test, lint. Agents run the actual jobs on their own infrastructure: your server, a cloud function, a laptop. Results post back to the branch channel alongside the conversation.
Workflows live in the repo (`.buzz/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML. Workflow definitions, schedules, webhooks, runs, and approval tokens inherit the project/community selected by the host, so a webhook or cron trigger for one community cannot resolve a same-named workflow in another.
```yaml
name: CI
trigger:
on: diff_posted
steps:
- id: build
action: call_webhook
url: "https://ci.internal/build"
body: '{"commit": "{{trigger.commit}}"}'
- id: test
action: call_webhook
url: "https://ci.internal/test"
if: "steps.build.output.status == 'success'"
- id: gate
action: request_approval
message: "CI passed. Approve merge?"
if: "steps.test.output.status == 'success'"
```
Every step traced. Every trace a signed event. Change the project CI once and every branch gets it.
---
## Issues, Docs, Releases
### Issues → Forum + NIP-34
Bug reports are NIP-34 kind:1621 events, rendered through Buzz's forum surface. Threaded comments use NIP-22 kind:1111. Labels, assignees, milestones are nostr tags. Design discussions and RFCs use the forum's long-form async surface.
NIP-34 clients can discover and interact with issues. Buzz's forum gives them a home with threading, search, and agent triage.
### Docs → Canvases
Living documents, collaboratively editable by humans and agents via MCP tools. Not static HTML deployed to a CDN — documents that update when the code changes, because the doc writer agent watches ref updates and proposes edits.
### Releases → Agent + Workflow
An agent in `#releases` watches `main`. When a release is needed — triggered by a workflow or by a human posting "ship it" — it assembles the changelog from every merged patch since the last tag, posts a draft. The maintainer approves. The workflow builds artifacts, pushes to content-addressed storage (Blossom/S3), and publishes. Logged, signed, traceable.
---
## Agents as Contributors
Agents are project members with npubs, contribution histories, and reputations. The protocol treats them identically to humans. Visual badges distinguish them in the UI.
| | Human | Agent |
|---|---|---|
| Identity | secp256k1 keypair | secp256k1 keypair |
| Handle | `alice@buzz.dev` | `triage-bot@buzz.dev` |
| Events | Signed with npub | Signed with npub |
| History | On the relay | On the relay |
| Reputation | Earned by contributions | Earned by contributions |
| Role | Watches | Does |
|------|---------|------|
| **Triage** | Issues (kind:1621) | Labels, assigns, detects duplicates, pre-screens |
| **Review** | Patches (kind:1617) | First-pass code review, style checks, dependency audit |
| **Docs** | Ref updates (kind:30618) | Keeps docs in sync after merges |
| **Merge coordinator** | CI results | Runs the merge train, requests human sign-off |
| **Coding agent** | Jobs (kind:43001) | Implements tasks, submits patches for review |
---
## Nostr-Native
Standard kinds as substrate. Custom kinds only where genuinely novel.
| Layer | Standard NIP Kinds | Buzz Custom | Rationale |
|-------|-------------------|---------------|-----------|
| **Git state** | 30617, 30618, 1617, 1618, 1621, 1630-1633 (NIP-34) | — | Interop with ngit, gitworkshop.dev |
| **Comments** | 1111 (NIP-22) | — | Threaded replies everywhere |
| **Channels** | 9000-9022, 39000-39003 (NIP-29) | — | Project workspaces |
| **HTTP auth** | 27235 (NIP-98) | — | Git push authentication |
| **Agent identity** | 0 (NIP-01 profile) | — | Agents are npubs |
| **Artifacts** | 1063 (NIP-94) | — | Build outputs on Blossom/S3 |
| **Workflows** | — | 46001-46012 | No NIP equivalent |
| **Job dispatch** | — | 43001-43006 | Delegation trees |
| **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility |
| **Audit** | — | 48001 | Hash-chain tamper-evident log |
If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol.
---
## Status
| Capability | Status |
|---|---|
| Channels, forums, DMs, canvases | ✅ Ships today |
| Workflow engine (triggers, traces, conditional logic) | ✅ Ships today |
| MCP server + ACP agent harness | ✅ Ships today |
| Blossom media storage (SHA-256, S3) | ✅ Ships today |
| Approval gates | 🚧 Infrastructure exists; executor wiring in progress |
| Project binding (kind:30617 + `buzz-` tags) | 📋 Designed |
| Git hosting (smart HTTP + NIP-34) | ✅ Ships today |
| Merge coordinator | 📋 Designed |
| NIP-34 issues (kind:1621) | 📋 Designed |
| Web-of-trust reputation | 📋 Designed |
The collaboration platform is built, and git hosting ships today — `git clone`/`git push` over smart HTTP with NIP-34 manifests. The forge layer above it is the work ahead — the merge train, project binding, issues, and the reputation system, wired into the surfaces that already exist. See [VISION.md](VISION.md) for the platform and [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the sovereign relay story.
---
*Buzz 🐝 — the forge where identity is the foundation.*

View File

@@ -1,242 +0,0 @@
# Buzz — Your Project, Your Domain
`myproject.com` is your workspace. Not a GitHub org page that happens to have your
name on it. Not a Discord server that Discord could delete tomorrow. Your domain.
Your relay. One thing.
The relay is the workspace. Code lives there. Conversation lives there. Agents connect
to it. Automation flows through it. Artifacts publish from it. You type the URL, you
see the workspace. That's the whole idea.
Nothing here is novel on its own. Git hosting is solved. Chat is solved. Agents are
solved. Nostr identity is solved. The insight is putting them all behind one domain
with one identity system. Each piece is boring. The combination is the thing.
The examples here are about software because that's the sharpest case — code,
branches, CI, releases. But the model works for anything a group builds together.
Research, hardware, writing, governance. The relay doesn't care what's in the
channels or the repos. It stores signed events, routes messages, runs workflows,
and hosts files. What you build with that is up to you.
---
## What You See
You type `myproject.com`. You see a list of repos — like a GitHub org page, but it's
yours, running on your server, with your data. You click one. You're at
`repoa.myproject.com`. The README renders. The file tree is there. Click into `src/`
and the code is syntax-highlighted. The clone URL sits at the top:
```
git clone repoa.myproject.com
```
Next to it, a "Connect on Buzz" button.
No separate website. No static site generator, no deploy step, no "pages" concept.
The relay serves rendered HTML to browsers and responds to `git clone` at the same
URL — content negotiation, one domain, two audiences. The repo *is* the website.
A browser gets a rendered page. A git client gets a git server. Same URL.
You clone it. It works. No special tooling. No new protocol. Git is git. You push
the same way. Agents clone and push the same way. The repos are repos.
You hit "Connect on Buzz" and the app opens. You're in `#general`. The team is
talking. There's a forum with open issues and design discussions. There's a canvas
with the architecture doc — updated last week by the docs agent after a refactor
landed. Three feature branches are active, each one a channel. You're in.
Everything is here.
A new contributor finds the project the same way. They browse the code, read the
README, click into the forum and see the open issues. They read a design doc in the
canvas. They understand the project before they write a line of code. Then they hit
"Connect on Buzz" and they're in the community. No separate onboarding flow. No
"read our wiki at wiki.myproject.com and our forum at forum.myproject.com and our
chat at discord.gg/..." Everything is at `myproject.com`. That's where everything
is.
Not every project needs to run its own relay. Most people will just join one that
someone else runs — the way most people use GitHub instead of running Gitea. The
relay someone else runs is a **community**: one workspace at one URL, a tenant
boundary that may be its own dedicated deployment or one of thousands sharing
infrastructure. Either way it's the same OSS codebase, and the isolation between
communities is proven, not promised — a co-tenant cannot see your events,
profiles, DMs, or search. Your key stays yours across all of them; identity is
portable even when the hosting isn't. And you can use Buzz as a collaboration
layer on top of GitHub if that's what makes sense — work in Buzz channels, push
releases to your public repo. The sovereign setup is the full version. But the
tools work at every level of commitment.
---
## How Work Happens
A contributor files a bug. It lands in the forum. The triage agent — sitting in the
channel like any other member — labels it, finds a duplicate from six weeks ago,
links the relevant docs. This happens before a human reads it. The contributor gets
a response in seconds. The maintainer sees a pre-processed report, not a raw inbox.
They read the triage summary, confirm it's real, assign it. Thirty seconds of their
time instead of five minutes.
Someone picks up the bug. They create a branch. Buzz creates a channel:
`#feat-auth-fix`. They work there. They push commits. The CI agent picks up the
work — it's watching for pushes, it's just a member with compute — runs the tests,
posts results back to the channel. Green. The patch lands in the channel as a
reviewable diff. A co-maintainer reviews it inline, leaves a comment on line 45,
approves it. The approval is a signed event — cryptographic proof of who said yes
and when.
Merge. The workflow runs: sequential integration, tests after each merge, release
event published. The channel archives. The conversation is now the permanent record
of why that code exists. Not a chat thread that scrolled away. Not a PR that might
get deleted. The event log, on your relay, in your database. Someone reading that
code in two years can pull up the channel and see exactly what was discussed, what
was tried, what was rejected, and why the final approach won.
The whole flow — bug report to merged patch — happened in one place. No
tab-switching between issue tracker, CI dashboard, chat, and code review. One
stream. One URL. One search index. You search "auth refresh" and you get the bug
report, the channel discussion, the patches, the CI results, and the design doc
that motivated the whole thing — all in one query.
**The release flow** is where it clicks. An agent in `#releases` watches `main`.
When a release is needed — triggered by a workflow, or by a human posting "ship
it" — it assembles the changelog from every merged patch since the last tag,
formats it, posts a draft. The maintainer edits two lines, approves. The workflow
kicks off: builds artifacts, pushes to content-addressed storage, deploys wherever
the project deploys. The result posts back to `#releases`. Done. The whole thing
is logged, signed, and traceable.
The release agent isn't special infrastructure. It's an npub with compute that
happens to be fast. Same for the CI agent, the triage agent, the docs agent. They
sit in channels. They watch for things. They clone repos and push code. They get
pinged by workflows or by humans. A CI agent picks up work from a branch channel,
runs tests, posts results back. A docs agent watches for merges and proposes doc
updates. A release agent watches `main` and ships. They're not a separate system.
They're contributors.
Workflows are the connective tissue. They fire when messages match filters, when
conditions are met, when a timer ticks. They don't execute work — they coordinate
it. The relay is the message bus. Agents are the workers. They run wherever they
run: your server, a cloud function, a laptop. The workflow just knows who to ping
and when. You define a workflow once at the project level and every branch channel
inherits it automatically — no per-branch configuration, no copy-pasting YAML.
---
## The Social Layer
Not everything belongs in the repo. Not everything belongs in a channel. Some
things are announcements. Some things are essays. Buzz has surfaces for both,
and they live on the same relay as the code.
Short notes — project announcements, "we just shipped X," the kind of thing you'd
post on social media — are just notes. They live on the relay, they're public, they
propagate across the nostr network. Someone following your project sees them in
their feed. No separate social media account to maintain. No "follow us on Twitter"
link pointing somewhere else. Your relay is your social presence. When you ship a
release, you post a note. It goes out to everyone following the project's npub.
That's it.
Long-form posts are for the thinking that doesn't fit anywhere else. The design
decision that deserves more than a channel message. The research write-up explaining
why you chose a particular approach. The post-mortem on that outage. The RFC that
shaped the architecture. These aren't commit messages and they're not channel
threads — they're documents that deserve to exist on their own, searchable and
permanent, alongside the code that implements them. Years later, someone reading
the code can find the reasoning. It's on the same relay. Same search. Same
identity. The blog post and the code it describes are in the same place.
Channels are the working conversation — fast, ephemeral-feeling, but actually
permanent. The repo is the code and docs that belong with the code. Notes are the
public face. Long-form is the thinking. Each surface has its job. Nothing bleeds
into the wrong place.
The result is that your project has a complete public presence at one domain. The
code, the conversation, the announcements, the deep thinking — all there, all
searchable, all yours. A new contributor can browse the repo, read the design docs,
skim the recent announcements, and join the community without leaving `myproject.com`.
A researcher studying the project's evolution can trace decisions from the long-form
posts through the channel discussions to the merged patches. It's all connected
because it's all on the same relay.
---
## Identity
Your npub is your identity everywhere. One keypair. No accounts to create on each
new platform. No "sign in with GitHub" that means GitHub owns your identity. Your
keys, your identity.
Reputation follows you across projects. When you contribute to three projects and
get patches merged, that history is on the relay — signed, verifiable, queryable.
When you show up at a fourth project, your track record shows up with you. A
maintainer can see: merged patches, vouches from people they know, a history of
good work. That's not a number a platform assigned you. It's your actual history,
cryptographically attested. You can't fake it. You can't buy it. You earn it by
doing the work.
Web-of-trust emerges naturally. Who vouches for whom. Who merges whose patches.
Who has a track record. No special reputation system to design and build — it's
the natural consequence of cryptographic identity plus public contribution history.
A maintainer you trust vouches for a contributor you've never heard of. That vouch
is a signed event. You can see it. You can weight it. The trust graph grows
organically as people work together, and it's queryable across the whole network.
This matters especially for agents. An agent with a persistent keypair and a
verifiable contribution history is fundamentally different from an anonymous
generator with no history. The agent has skin in the game. Its reputation is on
the line with every contribution, across every project it touches. Bad work
degrades its standing everywhere, not just in one repo. That's the right incentive
structure, and it falls out of the identity model for free.
You don't have to build a separate detection system for low-quality contributions.
You look at the npub's history. A fresh keypair with no history and no vouches gets
scrutiny. An npub with 50 merged patches across projects you respect gets
fast-tracked. The signal is in the history. The web-of-trust does the filtering.
No platform owns your identity. No platform can suspend your account. Your keypair
is yours. If you move to a different relay, your identity comes with you. Your
contribution history is portable. The work you did is yours. If `myproject.com`
goes down, your npub still exists. Your history still exists. You stand up a new
relay, point your domain at it, and you're back. The project continues.
---
## What You Give Up
You run infrastructure. A server, a domain, a relay. That's not hard — a modest
VPS handles a small project comfortably — but it's not zero. Someone has to keep
it running. Someone has to handle backups. Someone has to deal with the 3 AM alert
when the disk fills up. Managed hosting can take that off your plate — your
project runs as a community on shared infrastructure, isolated from every other
tenant, same sovereignty, someone else handles the ops — but it's a cost either
way, in time or money. Worth knowing before you start.
Key management is harder than "sign in with Google." Losing your private key means
losing your identity. There's no "forgot password" flow, no support ticket to file,
no account recovery. Hardware keys help. Good practices help. But it's a real
tradeoff and you should go in knowing it. The same property that makes your identity
uncensorable makes it unrecoverable if you lose the key.
The ecosystem is young. The tooling is good and getting better, but it's not a
decade of polish. Some things will feel rough. Some integrations won't exist yet.
You're early, and early means occasionally hitting edges that haven't been smoothed.
Your contributors are early too. Most developers don't have a nostr keypair.
Onboarding friction is real — not insurmountable, but real. The "Connect on Buzz"
button is easy. Explaining what a keypair is takes a sentence. But it's a sentence
you'll have to write, and some contributors won't bother. You'll lose some people
at the door who would have clicked "sign in with GitHub" without thinking. That's
a real cost, especially early in a project when you're trying to grow a contributor
base.
These are honest costs. They're worth it if you care about owning your project —
your code, your community, your data, your identity. They're not worth it if you
just want the path of least resistance. Know which one you are.
---
## The Point
One domain. One identity. Everything in one place — and it's yours.

View File

@@ -1,6 +0,0 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.benchmark/
jobs/

View File

@@ -1,129 +0,0 @@
# Harbor Buzz Orchestra
A stock-Harbor custom agent that runs a manifest-defined team through the real
Buzz stack. Harbor sees one `BuzzOrchestraAgent`; behind that adapter, one
orchestrator and N workers coordinate over the production relay/Postgres.
Each agent runs *inside* the Harbor task container as the same
`buzz-acp` → `buzz-agent` → `buzz-dev-mcp` process tree the desktop app
launches: the production MCP toolset (shell, file tools, todo) with the
`buzz` CLI on the shell's PATH. No Harbor fork or patch is required.
## Define the team
The manifest is the benchmark condition. Each roster entry selects an agent
class's count, model endpoint, byte-pinned system prompt, generation settings,
and budget:
```yaml
condition: my-team
roster:
- id: orch
kind: orchestrator
role: lead
count: 1
endpoint: databricks/frontier
prompt: {path: personas/orchestrator.md, sha256: <sha256>}
generation: {max_output_tokens: 4096, context_window_tokens: 128000}
- id: worker
kind: worker
role: implementer
count: 4
endpoint: databricks/fast-worker
prompt: {path: personas/worker.md, sha256: <sha256>}
generation: {max_output_tokens: 4096, context_window_tokens: 128000}
```
`endpoint_config` maps those endpoint names to providers, URLs, and API-key
environment variables. The adapter contains no fixed roster or model.
## Run
With the production compose stack and model endpoints already running, execute
one task (`-p`), a directory of tasks, or replace `-p` with Harbor's dataset and
task selectors:
```bash
uv run --project benchmarks/harbor-buzz-orchestra/testbed harbor run --yes -p <TASK_OR_DIRECTORY> --agent harbor_buzz_orchestra:BuzzOrchestraAgent --agent-kwarg manifest=<CONDITION.yaml> --agent-kwarg provisioner_factory=harbor_buzz_testbed:provisioner_from_dict --agent-kwarg provisioner_config=<PROVISIONER.json> --agent-kwarg endpoint_config=<ENDPOINTS.json> --agent-kwarg artifact_root=benchmarks/harbor-buzz-orchestra --agent-kwarg buzz_acp_binary=<LINUX_BIN>/buzz-acp --agent-kwarg buzz_agent_binary=<LINUX_BIN>/buzz-agent --agent-kwarg buzz_dev_mcp_binary=<LINUX_BIN>/buzz-dev-mcp --agent-kwarg buzz_cli_binary=target/debug/buzz --agent-kwarg run_id="bench-$(date -u +%Y%m%dT%H%M%SZ)" --agent-timeout-multiplier 15 --n-concurrent 1
```
`buzz_acp_binary`/`buzz_agent_binary`/`buzz_dev_mcp_binary` must be **Linux**
builds matching the task image architecture — they are uploaded into each task
container (`just benchmark` cross-builds them automatically; musl-static, so
any Linux base image works). `buzz_cli_binary` is the **host** CLI the harness
uses to act as the trial user.
`--n-concurrent 1` is the safe laptop setting for a serialized local model; it
is not an orchestration requirement. Some TB graders install dependencies from
public package registries at verification time — run benchmarks off networks
that block those installs (e.g. corporate VPNs).
Each trial gets fresh keys and a private Buzz channel. The provisioner archives
rather than deletes that channel, leaving the relay/Postgres event timeline
and the per-agent acp/agent logs (downloaded into the trial's `buzz/`
artifacts) available for analysis.
## Leaderboard runs
`just benchmark` is the one-command path: it stands up a dedicated Docker
stack (`buzz-benchmark` compose project — relay :3600, Postgres :5633, secrets
generated once into the gitignored `.benchmark/`), applies the benchmark
schema, and defaults to leaderboard-eligible settings (Terminal-Bench 2.1,
5 attempts per problem, the Sonnet+Haiku team). All selectors pass through:
```bash
just benchmark # full TB 2.1, k=5
just benchmark --path <TASK_DIR> -k 1 # one local task, one attempt
just benchmark -i "cobol*" --attempts 3 # dataset subset
just benchmark --gui # watch the run live
```
One pinned user identity fronts the whole benchmark environment: it owns
every trial channel (named after the task) and posts every task prompt, and
trial channels are kept rather than archived. `--gui` adds that user to the
relay membership list and opens the Buzz desktop app logged in as them, so
channels fill the sidebar as the run progresses — watch, don't type; a human
message mid-trial would taint the run. `just benchmark-down` stops the stack.
Networking: the relay is host-header tenant-bound, so agents must dial its
canonical address (`ws://localhost:3600`) even from inside a task container.
`just benchmark` uploads a tiny std-only loopback forwarder
([`forwarder/relay_forwarder.rs`](forwarder/relay_forwarder.rs)) with the
agent stack; it listens on the container's loopback and bridges the byte
stream to the Docker host gateway (`host.docker.internal`, overridable via
`BUZZ_BENCHMARK_DOCKER_HOST`).
`scripts/run_leaderboard.py` is the layer underneath, for running against an
already-provisioned stack. It wraps the invocation above with only
leaderboard-legal settings — it does not accept or forward timeout or resource
overrides, so the job directory it produces passes Harbor's static validation
as-is. Give it a problem set, attempts per problem, and a team manifest:
```bash
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \
--dataset terminal-bench/terminal-bench-2-1 \
--attempts 5 \
--manifest benchmarks/harbor-buzz-orchestra/manifests/<TEAM>.yaml \
--endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/<ENDPOINTS>.json \
--provisioner-config <PROVISIONER.json>
```
`--path` replaces `--dataset` for local task directories; `--include-task` /
`--exclude-task` filter by glob; `--dry-run` prints the underlying `harbor run`
command. After the job finishes the script derives a `metadata.yaml` from the
manifest roster (validated schema; review the display names before submitting)
and prints the `harbor upload` / `harbor leaderboard submit` commands.
## Validate
```bash
cd benchmarks/harbor-buzz-orchestra
uv run --extra dev pytest -q
uv run --extra dev ruff check .
cd testbed
uv run --extra dev pytest -q
uv run --extra dev ruff check .
```
Live provisioner tests require the benchmark compose stack and opt-in
environment described in `testbed/tests/test_provisioner_live.py`.

View File

@@ -1,73 +0,0 @@
//! Loopback TCP forwarder for the benchmark task container.
//!
//! The benchmark relay is host-header tenant-bound: its community row is the
//! authority of its own `RELAY_URL` (e.g. `localhost:3600`), and a request
//! presenting any other `Host` fails closed. Agents inside a Harbor task
//! container can only reach the host-published relay via the Docker host
//! alias (`host.docker.internal`), which would present the wrong `Host`.
//!
//! So the container runtime uploads this forwarder next to the agent stack:
//! agents dial `ws://localhost:<port>` — presenting the exact `Host` the
//! community row expects — and the forwarder bridges the byte stream to the
//! host gateway. Transparent to everything above TCP (WebSocket, the buzz
//! CLI, git-over-HTTP). std-only; compiled with plain `rustc` against the
//! musl target, so it runs on any Linux task image.
//!
//! Usage: `relay-forwarder <listen-addr> <target-addr>`
use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::thread;
fn main() -> io::Result<()> {
let mut args = std::env::args().skip(1);
let (listen, target) = match (args.next(), args.next()) {
(Some(listen), Some(target)) => (listen, target),
_ => {
eprintln!("usage: relay-forwarder <listen-addr> <target-addr>");
std::process::exit(2);
}
};
let listener = TcpListener::bind(&listen)?;
// Readiness marker: the container runtime polls the log for this line
// before launching any agent.
println!("forwarding {listen} -> {target}");
for client in listener.incoming() {
let Ok(client) = client else { continue };
let target = target.clone();
thread::spawn(move || bridge(client, &target));
}
Ok(())
}
/// Connect upstream and pump bytes both ways until either side closes.
fn bridge(client: TcpStream, target: &str) {
let Ok(upstream) = TcpStream::connect(target) else {
let _ = client.shutdown(Shutdown::Both);
return;
};
let (Ok(client_read), Ok(upstream_read)) = (client.try_clone(), upstream.try_clone())
else {
return;
};
let downstream = thread::spawn(move || pipe(upstream_read, client));
pipe(client_read, upstream);
let _ = downstream.join();
}
/// Copy until EOF or error, then half-close the write side so protocols
/// layered on TCP (WebSocket close handshakes) terminate cleanly.
fn pipe(mut from: TcpStream, mut to: TcpStream) {
let mut buf = [0u8; 16 * 1024];
loop {
match from.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if to.write_all(&buf[..n]).is_err() {
break;
}
}
}
}
let _ = to.shutdown(Shutdown::Write);
}

View File

@@ -1,44 +0,0 @@
# M1 gate manifest: 1 orchestrator + 1 worker over one Buzz channel solving
# Harbor's examples/tasks/hello-world via vanilla `harbor run`.
#
# Endpoints are LOCAL PLACEHOLDERS for the M1 wiring proof; the pilot/2.1
# manifests will carry exact Databricks serving endpoint revisions and the
# frozen price table per the canonical plan.
schema_version: "1"
condition: M1-hello-world
roster:
- id: orch
kind: orchestrator
role: lead
count: 1
endpoint: local/placeholder-orchestrator
model_revision: m1-placeholder
prompt:
path: personas/orchestrator-m1.md
sha256: af64b515de914c99fbdfc5a50830909ac10ab3a4f37e8352a354019a6be74151
generation:
max_output_tokens: 4096
context_window_tokens: 128000
- id: worker
kind: worker
role: implementer
count: 1
endpoint: local/placeholder-worker
model_revision: m1-placeholder
prompt:
path: personas/worker-m1.md
sha256: f3fef3d2c42105c256ff019d71ef99506b31a23e8cb252cac727c8bc470eb304
generation:
max_output_tokens: 4096
context_window_tokens: 128000
prices:
local/placeholder-orchestrator:
input_per_million_usd: 0
cached_input_per_million_usd: 0
output_per_million_usd: 0
local/placeholder-worker:
input_per_million_usd: 0
cached_input_per_million_usd: 0
output_per_million_usd: 0
trial_budget:
timeout_seconds: 1800

View File

@@ -1,42 +0,0 @@
# Terminal-Bench trial: 1 Sonnet 4.6 orchestrator + 2 Haiku 4.5 workers over
# one Buzz channel. Endpoint names are exact Anthropic model IDs (the runtime
# passes the endpoint name to the provider as the model).
# Prices: Anthropic list $/Mtok as of 2026-07 (Sonnet 3/15, Haiku 1/5).
schema_version: "1"
condition: tb-cobol-sonnet46-2haiku45
roster:
- id: orch
kind: orchestrator
role: lead
count: 1
endpoint: claude-sonnet-4-6
model_revision: claude-sonnet-4-6
prompt:
path: personas/orchestrator-tb.md
sha256: 206829331cdd85fb277266901b9489e190ba23097600a8bb69142d044b9a42e6
generation:
max_output_tokens: 4096
context_window_tokens: 200000
- id: worker
kind: worker
role: implementer
count: 2
endpoint: claude-haiku-4-5
model_revision: claude-haiku-4-5
prompt:
path: personas/worker-tb.md
sha256: 664a994ae3ba93f02ebbb8f61f781f6a59ea3327b21e26e7ffc2d06827ed3e45
generation:
max_output_tokens: 4096
context_window_tokens: 200000
prices:
claude-sonnet-4-6:
input_per_million_usd: 3
cached_input_per_million_usd: 0.3
output_per_million_usd: 15
claude-haiku-4-5:
input_per_million_usd: 1
cached_input_per_million_usd: 0.1
output_per_million_usd: 5
trial_budget:
timeout_seconds: 900

View File

@@ -1,34 +0,0 @@
# Orchestrator — M1 hello-world
You are the orchestrator of a small team solving a terminal task. You do not
run commands yourself; workers do. You coordinate over a Buzz channel.
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
you. Nothing you write is visible to anyone unless you publish it: every
message — step assignments, verification requests, the final `DONE:`
— must be sent with
`buzz messages send --channel <channel-id> --content <text>`. Your team, your
channel id, and the user you report to are listed in the "Your team"
section below. Your turn is not complete until you have published your
message.
Rules:
1. Read the task instruction. Break it into the smallest concrete steps.
2. Assign each step to a worker with an @mention. One step per message.
State the exact goal and the success check, not just the command to run.
The task runs in the worker's terminal working directory. Unless the task
instruction itself names a path, refer to files by bare relative name
(`hello.txt`) — never invent an absolute path. More generally, relay the
task's requirements verbatim and do not add constraints the task does not
state (paths, encodings, byte-level rules such as forbidding a trailing
newline). Where the task is silent, let standard tool defaults apply.
3. Wait for the worker's report before assigning the next dependent step.
4. When a worker reports output, verify it against the task's success
criteria yourself before moving on.
5. When the task is complete, report back to the user: publish a final
message starting with `DONE:` that @mentions the user and summarizes
what was produced and how you verified it. The task is not finished
until this message is published — never conclude silently.
Keep messages short. Never fabricate command output. If a worker's report is
ambiguous, ask them to re-run with the exact verification command.

View File

@@ -1,43 +0,0 @@
# Orchestrator — Terminal-Bench team
You are the orchestrator of a small team solving a terminal task. You do not
run commands yourself; your workers do. You coordinate over a Buzz channel.
Your team, your channel id, and the user you report to are listed in the
"Your team" section below.
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
you. Nothing you write is visible to anyone unless you publish it: every
message — step assignments, verification requests, the final `DONE:`
report — must be sent with
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
not complete until you have published your message. Do not use the shell
for task work — that is your workers' job.
Tasks arrive as a channel message from the user @mentioning you. Address
each assignment to a specific worker by @mention, exactly one worker per
step. You may assign independent steps to different workers, but never give
two workers overlapping or conflicting work — they share one task
environment and one filesystem.
Rules:
1. Read the task instruction. Break it into the smallest concrete steps.
2. Assign each step to a worker with an @mention. One step per message.
State the exact goal and the success check, not just the command to run.
Relay the task's requirements verbatim — use the paths the task states,
and do not add constraints the task does not state (paths, encodings,
byte-level rules). Where the task is silent, let standard tool defaults
apply.
3. Wait for the worker's report before assigning the next dependent step.
4. When a worker reports output, verify it against the task's success
criteria before moving on: assign a verification step that runs the
task's own success check and shows real output. Assign each verification
step to a different worker than the one whose work is being verified —
independent verification, never self-review. Do not report completion on
a worker's claim alone.
5. When the task is complete and verified, report back to the user: publish
a final message starting with `DONE:` that @mentions the user and
summarizes what was produced and how it was verified. The task is not
finished until this message is published — never conclude silently.
Keep messages short. Never fabricate command output. If a worker's report is
ambiguous, ask them to re-run with the exact verification command.

View File

@@ -1,32 +0,0 @@
# Worker — M1 hello-world
You are a worker agent with terminal access, coordinating over a Buzz channel.
You work directly in the task environment: your `shell` tool runs
commands in it, and your file tools read and edit its files. The same
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
to the channel with
`buzz messages send --channel <channel-id> --content <text>`. Your team and your
channel id are listed in the "Your team" section below. Your turn is not
complete until you have published your report.
The orchestrator only wakes for messages that @mention it. Every report
you publish must start with an @mention of the agent that assigned you the
step (use their name exactly as it appears in their message). If the
assignment's event id is visible in your context, also pass
`--reply-to <event-id>` to thread the report. A report that mentions
nobody is invisible and the task will stall.
Rules:
1. Act only on steps assigned to you by the orchestrator's @mention.
2. Execute the requested step in the terminal. Prefer the smallest command
that achieves the stated goal. Create files in your terminal's current
working directory unless the task itself names a path. If an assignment
gives an absolute path your working directory does not contain, report
the mismatch instead of creating the directory — `mkdir -p` on an
invented path silently puts the file where the grader will never look.
3. Report back in one message: the command you ran, its exit code, and the
relevant output (trimmed, never invented).
4. If a command fails, report the failure verbatim and stop — do not
improvise a different approach without the orchestrator's direction.
5. Never claim success without showing the verifying output.

View File

@@ -1,32 +0,0 @@
# Worker — Terminal-Bench team
You are a worker agent with terminal access, coordinating over a Buzz
channel. Your team, your channel id, and your orchestrator are listed in
the "Your team" section below.
You work directly in the task environment: your `shell` tool runs
commands in it, and your file tools read and edit its files. The same
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
to the channel with
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
not complete until you have published your report.
The orchestrator only wakes for messages that @mention it. Every report
you publish must start with an @mention of the agent that assigned you the
step (use their name exactly as it appears in their message). If the
assignment's event id is visible in your context, also pass
`--reply-to <event-id>` to thread the report. A report that mentions
nobody is invisible and the task will stall.
Rules:
1. Act only on steps assigned to you by the orchestrator's @mention. If a
message assigns a step to a different worker, ignore it.
2. Execute the requested step in the terminal BEFORE writing any report.
Never describe output you have not yet produced. Prefer the smallest
command that achieves the stated goal. Use the paths the task or the
assignment states; do not invent paths.
3. Report back in one message: the command you ran, its exit code, and the
relevant output (trimmed, never invented).
4. If a command fails, report the failure verbatim and stop — do not
improvise a different approach without the orchestrator's direction.
5. Never claim success without showing the verifying output.

View File

@@ -1,29 +0,0 @@
[project]
name = "harbor-buzz-orchestra"
version = "0.1.0"
description = "Harbor custom agent for Buzz-coordinated multi-agent benchmarks"
requires-python = ">=3.12"
dependencies = [
"harbor>=0.16.1,<0.18",
"pydantic>=2.11",
"pyyaml>=6.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.optional-dependencies]
dev = [
"pytest>=8.4",
"pytest-asyncio>=1.2",
"ruff>=0.15",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py312"
line-length = 88

View File

@@ -1,550 +0,0 @@
#!/usr/bin/env python3
"""One-command benchmark: bring up the Buzz stack in Docker and run it.
``just benchmark`` wraps this script. Defaults are leaderboard-eligible out
of the box (Terminal-Bench 2.1, 5 attempts per problem, the Sonnet+Haiku
team); every ``run_leaderboard.py`` selector passes through unchanged. The
script owns everything around the run:
- A dedicated ``buzz-benchmark`` compose project reusing the production
bundle (``deploy/compose/compose.yml``) plus the benchmark port overlay,
on its own ports (relay :3600, Postgres :5633, metrics :9602) so it never
collides with a dev stack. Secrets and identities are generated once into
the gitignored ``.benchmark/`` state dir and reused across runs.
- One pinned *user* identity for the whole benchmark environment: it owns
every trial channel and posts every task, like one human running many
teams. Channels are kept (not archived) after each trial.
- ``--gui`` adds that user to the relay membership list and opens the Buzz
desktop app logged in as them, so a human can watch the teams work live.
Run inside the testbed environment (the just recipe does this):
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
benchmarks/harbor-buzz-orchestra/scripts/benchmark.py [--gui] [...]
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import secrets
import shutil
import subprocess
import sys
import time
from pathlib import Path
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
REPO_ROOT = PACKAGE_ROOT.parents[1]
STATE_DIR = PACKAGE_ROOT / ".benchmark"
COMPOSE_PROJECT = "buzz-benchmark"
COMPOSE_FILES = (
REPO_ROOT / "deploy" / "compose" / "compose.yml",
PACKAGE_ROOT / "testbed" / "compose.benchmark.yml",
)
RELAY_HTTP_PORT = 3600
PG_HOST_PORT = 5633
METRICS_HOST_PORT = 9602
GUI_BUNDLE_IDENTIFIER = "xyz.block.buzz.app.benchmark"
DEFAULT_DATASET = "terminal-bench/terminal-bench-2-1"
DEFAULT_ATTEMPTS = 5
DEFAULT_MANIFEST = PACKAGE_ROOT / "manifests" / "tb-cobol-sonnet-haiku.yaml"
DEFAULT_ENDPOINTS = PACKAGE_ROOT / "testbed" / "endpoints" / "anthropic-live.json"
SCHEMA_SQL = PACKAGE_ROOT / "testbed" / "sql" / "benchmark_schema.sql"
# Linux builds of the production agent stack, uploaded into each task
# container per trial. Built once in a rust:alpine container (musl → fully
# static, runs on any Linux task image of the same architecture) and cached.
AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp")
# Std-only loopback forwarder (not a workspace crate): agents dial the
# relay's canonical localhost address inside the task container and the
# forwarder bridges to the Docker host gateway. Compiled with plain rustc
# in the same cross-build step.
FORWARDER_SOURCE = PACKAGE_ROOT / "forwarder" / "relay_forwarder.rs"
FORWARDER_BINARY = "relay-forwarder"
LINUX_TARGET_DIR = STATE_DIR / "linux-target"
RUST_IMAGE = "rust:1.95-alpine"
_spec = importlib.util.spec_from_file_location(
"run_leaderboard", Path(__file__).resolve().parent / "run_leaderboard.py"
)
run_leaderboard = importlib.util.module_from_spec(_spec)
sys.modules.setdefault("run_leaderboard", run_leaderboard)
_spec.loader.exec_module(run_leaderboard)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__.splitlines()[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
)
problems = parser.add_mutually_exclusive_group()
problems.add_argument(
"--dataset", "-d", default=None,
help=f"Registry dataset (default: {DEFAULT_DATASET})",
)
problems.add_argument(
"--path", "-p", type=Path, help="Local task or dataset directory"
)
parser.add_argument(
"--include-task", "-i", action="append", default=[],
help="Task name to include (glob, repeatable)",
)
parser.add_argument(
"--exclude-task", "-x", action="append", default=[],
help="Task name to exclude (glob, repeatable)",
)
parser.add_argument(
"--attempts", "-k", type=int, default=DEFAULT_ATTEMPTS,
help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)",
)
parser.add_argument(
"--manifest", type=Path, default=DEFAULT_MANIFEST,
help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})",
)
parser.add_argument(
"--endpoint-config", type=Path, default=DEFAULT_ENDPOINTS,
help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})",
)
parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials")
parser.add_argument(
"--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root"
)
parser.add_argument("--job-name", default=None, help="Job name (default: lb-<condition>-<UTC>)")
parser.add_argument(
"--upload", action="store_true", help="Upload to Harbor Hub when the job finishes"
)
parser.add_argument(
"--gui", action="store_true",
help="Open the Buzz desktop app as the benchmark user to watch the run live",
)
parser.add_argument(
"--fresh", action="store_true",
help="Reset first: drop the stack's Docker volumes and the benchmark "
"GUI's app state (keys in state.json are kept)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Print the underlying harbor command and exit (no stack bring-up)",
)
return parser.parse_args(argv)
# -- state: secrets and identities, generated once --------------------------
def load_state() -> dict[str, str]:
"""Generate-or-load the benchmark environment's keys and secrets."""
from harbor_buzz_testbed.keys import generate_keypair, keypair_from_secret
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
state_path = STATE_DIR / "state.json"
if state_path.is_file():
state = json.loads(state_path.read_text())
else:
owner = generate_keypair()
user = generate_keypair()
state = {
"owner_secret_key": owner.secret_key,
"user_secret_key": user.secret_key,
"postgres_password": secrets.token_urlsafe(24),
"redis_password": secrets.token_urlsafe(24),
"typesense_api_key": secrets.token_hex(16),
"s3_access_key": secrets.token_hex(10),
"s3_secret_key": secrets.token_hex(20),
"git_hook_hmac_secret": secrets.token_hex(32),
"relay_private_key": generate_keypair().secret_key,
}
state_path.touch(mode=0o600)
state_path.write_text(json.dumps(state, indent=2))
state["owner_pubkey"] = keypair_from_secret(state["owner_secret_key"]).pubkey
state["user_pubkey"] = keypair_from_secret(state["user_secret_key"]).pubkey
return state
def print_user_identity(state: dict[str, str]) -> None:
"""Show the pinned benchmark user's key so a human can import it during
the desktop GUI's onboarding (this stack is local-only; the key guards
nothing beyond it)."""
from harbor_buzz_testbed.keys import encode_nsec
print(
f"benchmark user pubkey: {state['user_pubkey']}\n"
f"benchmark user nsec: {encode_nsec(state['user_secret_key'])} "
"(import this in the GUI onboarding to watch as the benchmark user)"
)
def write_env_file(state: dict[str, str]) -> Path:
"""Compose interpolation env — regenerated from state on every run."""
env_path = STATE_DIR / ".env"
lines = {
"BUZZ_IMAGE": os.environ.get("BUZZ_IMAGE", "ghcr.io/block/buzz:main"),
"BUZZ_DOMAIN": "localhost",
"RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}",
"BUZZ_MEDIA_BASE_URL": f"http://localhost:{RELAY_HTTP_PORT}/media",
"BUZZ_MEDIA_SERVER_DOMAIN": "localhost",
"BUZZ_CORS_ORIGINS": f"http://localhost:{RELAY_HTTP_PORT}",
"BUZZ_REQUIRE_AUTH_TOKEN": "true",
"BUZZ_REQUIRE_RELAY_MEMBERSHIP": "true",
"BUZZ_ALLOW_NIP_OA_AUTH": "true",
"BUZZ_AUTO_MIGRATE": "true",
"BUZZ_GIT_CONFORMANCE_PROBE": "true",
"RUST_LOG": "buzz_relay=info,buzz_db=info,buzz_auth=info",
"RELAY_OWNER_PUBKEY": state["owner_pubkey"],
"BUZZ_RELAY_PRIVATE_KEY": state["relay_private_key"],
"BUZZ_GIT_HOOK_HMAC_SECRET": state["git_hook_hmac_secret"],
"POSTGRES_DB": "buzz",
"POSTGRES_USER": "buzz",
"POSTGRES_PASSWORD": state["postgres_password"],
"REDIS_PASSWORD": state["redis_password"],
"TYPESENSE_API_KEY": state["typesense_api_key"],
"BUZZ_S3_ACCESS_KEY": state["s3_access_key"],
"BUZZ_S3_SECRET_KEY": state["s3_secret_key"],
"BUZZ_S3_BUCKET": "buzz-media",
"BUZZ_HTTP_PORT": str(RELAY_HTTP_PORT),
"BUZZ_PG_HOST_PORT": str(PG_HOST_PORT),
"BUZZ_METRICS_HOST_PORT": str(METRICS_HOST_PORT),
}
env_path.touch(mode=0o600)
env_path.write_text("".join(f"{k}={v}\n" for k, v in lines.items()))
return env_path
def postgres_dsn(state: dict[str, str]) -> str:
return (
f"postgresql://buzz:{state['postgres_password']}"
f"@127.0.0.1:{PG_HOST_PORT}/buzz"
)
def write_provisioner_config(
state: dict[str, str], endpoint_config: Path
) -> Path:
"""Resolve per-endpoint API keys from the environment and write the
provisioner config: pinned user, keep-channels teardown."""
endpoints = json.loads(endpoint_config.read_text())
llm_api_keys: dict[str, str] = {}
for name, entry in endpoints.items():
env_var = entry["api_key_env"]
key = os.environ.get(env_var)
if not key:
raise SystemExit(
f"endpoint {name!r} needs the {env_var} environment variable"
)
llm_api_keys[name] = key
config = {
"relay_http_url": f"http://localhost:{RELAY_HTTP_PORT}",
# The agents dial the relay's CANONICAL address — the relay is
# host-header tenant-bound, and its community row is the authority
# of RELAY_URL (localhost:3600). Inside the task container that
# loopback address is served by a tiny forwarder bridging to the
# Docker host gateway (see --relay-gateway below).
"relay_ws_url": f"ws://localhost:{RELAY_HTTP_PORT}",
"owner_secret_key": state["owner_secret_key"],
"postgres_dsn": postgres_dsn(state),
"llm_api_keys": llm_api_keys,
"user_secret_key": state["user_secret_key"],
"archive_on_teardown": False,
}
path = STATE_DIR / "provisioner.json"
path.touch(mode=0o600)
path.write_text(json.dumps(config, indent=2))
return path
# -- docker stack ------------------------------------------------------------
def compose_command(*args: str) -> list[str]:
command = [
"docker", "compose",
"--project-name", COMPOSE_PROJECT,
"--project-directory", str(STATE_DIR),
"--env-file", str(STATE_DIR / ".env"),
]
for file in COMPOSE_FILES:
command += ["-f", str(file)]
return command + list(args)
def stale_credential_volume(state: dict[str, str]) -> bool:
"""True when Postgres is up but rejects THIS clone's password — the
volume was initialized by another checkout's ``.benchmark/`` state
(compose project name is machine-global, state dir is per-clone)."""
import psycopg
try:
psycopg.connect(postgres_dsn(state), connect_timeout=5).close()
except psycopg.OperationalError as error:
return "password authentication failed" in str(error)
return False
def bring_up_stack(state: dict[str, str]) -> None:
"""Compose bring-up (idempotent), self-healing the one known-fatal
failure: a stale Postgres volume from a different clone. Nothing in
that volume is usable (we can't even authenticate to it), so drop the
volumes and retry once rather than aborting with instructions."""
try:
subprocess.run(compose_command("up", "-d", "--wait"), check=True)
except subprocess.CalledProcessError:
if not stale_credential_volume(state):
raise
print(
"benchmark Postgres volume was initialized by a different "
"checkout's .benchmark/ state — dropping the stale volumes and "
"retrying..."
)
subprocess.run(compose_command("down", "-v"), check=True)
subprocess.run(compose_command("up", "-d", "--wait"), check=True)
def reset_environment() -> None:
"""--fresh: drop the stack's Docker volumes and the benchmark GUI's
app state, together — GUI records (workspaces, read state) only stay
coherent as long as the database they reference exists. Keys in
``state.json`` are kept, so the same nsec works after the reset."""
subprocess.run(compose_command("down", "-v"), check=True)
if sys.platform == "darwin":
for domain in ("WebKit", "Caches", "Application Support"):
shutil.rmtree(
Path.home() / "Library" / domain / GUI_BUNDLE_IDENTIFIER,
ignore_errors=True,
)
def ensure_stack(state: dict[str, str]) -> None:
"""Bring the compose stack up (idempotent) and apply the benchmark schema."""
import psycopg
bring_up_stack(state)
deadline = time.monotonic() + 60
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
with psycopg.connect(postgres_dsn(state)) as conn:
conn.execute(SCHEMA_SQL.read_text())
conn.commit()
return
except psycopg.Error as error: # containers healthy but PG settling
last_error = error
time.sleep(2)
raise SystemExit(f"benchmark schema apply failed: {last_error}")
# -- buzz binaries -----------------------------------------------------------
def ensure_binaries() -> dict[str, Path]:
"""Find the host buzz CLI, building it once if missing."""
try:
return run_leaderboard.find_binaries(None)
except SystemExit:
print("host buzz CLI missing — building (cargo build, first run only)...")
cargo = REPO_ROOT / "bin" / "cargo"
subprocess.run(
[str(cargo), "build", "-p", "buzz-cli"],
cwd=REPO_ROOT,
check=True,
)
return run_leaderboard.find_binaries(None)
def linux_triple() -> str:
"""The musl triple matching the Docker engine that runs task containers."""
arch = subprocess.run(
["docker", "version", "--format", "{{.Server.Arch}}"],
capture_output=True, text=True, check=True,
).stdout.strip()
try:
return {
"arm64": "aarch64-unknown-linux-musl",
"amd64": "x86_64-unknown-linux-musl",
}[arch]
except KeyError:
raise SystemExit(f"unsupported Docker architecture: {arch!r}") from None
def ensure_agent_binaries() -> Path:
"""Cross-build the static Linux agent stack once, cached in .benchmark/.
The agents run *inside* each Harbor task container as the real
buzz-acp → buzz-agent → buzz-dev-mcp stack, so the binaries must be
Linux ELF for the task image architecture. musl-static means they run
on any Linux base image (glibc or not). The relay loopback forwarder
is compiled in the same step with plain rustc (std-only, no deps).
"""
triple = linux_triple()
bin_dir = LINUX_TARGET_DIR / triple / "release"
targets = AGENT_BINARIES + (FORWARDER_BINARY,)
if all((bin_dir / name).is_file() for name in targets):
return bin_dir
print(f"Linux agent binaries missing — cross-building for {triple} "
f"in {RUST_IMAGE} (first run only, ~2 min)...")
LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True)
(STATE_DIR / "cargo-registry").mkdir(exist_ok=True)
packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)]
forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT)
subprocess.run(
[
"docker", "run", "--rm",
"-v", f"{REPO_ROOT}:/src:ro",
"-v", f"{LINUX_TARGET_DIR}:/target",
"-v", f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry",
"-e", "CARGO_TARGET_DIR=/target",
"-w", "/src",
RUST_IMAGE,
"sh", "-c",
"apk add --no-cache musl-dev >/dev/null && "
f"cargo build --release --locked --target {triple} "
+ " ".join(packages)
+ f" && rustc --edition 2021 -O --target {triple}"
f" -o /target/{triple}/release/{FORWARDER_BINARY}"
f" /src/{forwarder_src}",
],
check=True,
)
missing = [n for n in targets if not (bin_dir / n).is_file()]
if missing:
raise SystemExit(f"cross-build produced no {', '.join(missing)} in {bin_dir}")
return bin_dir
# -- GUI ---------------------------------------------------------------------
def launch_gui(state: dict[str, str]) -> subprocess.Popen:
"""Open the Buzz desktop app logged in as the benchmark user.
The relay runs closed (membership required), so the user pubkey is first
added to the relay membership list via buzz-admin inside the container —
NIP-OA auth tags cover the agents, but the GUI authenticates as a plain
member, exactly like a human.
"""
subprocess.run(
compose_command(
"exec", "-T", "relay",
"buzz-admin", "add-member", "--pubkey", state["user_pubkey"],
),
check=True,
)
desktop_dir = REPO_ROOT / "desktop"
if not (desktop_dir / "node_modules").is_dir():
subprocess.run(["pnpm", "install"], cwd=desktop_dir, check=True)
# tauri dev needs sidecar files present; stub them and drop in the real
# CLI binary (mirrors the just staging recipe).
target = subprocess.run(
["rustc", "-vV"], capture_output=True, text=True, check=True
).stdout
triple = next(
line.split(": ", 1)[1] for line in target.splitlines() if line.startswith("host: ")
)
sidecar_dir = desktop_dir / "src-tauri" / "binaries"
sidecar_dir.mkdir(parents=True, exist_ok=True)
binaries = ensure_binaries()
for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp", "git-credential-nostr", "buzz"):
stub = sidecar_dir / f"{name}-{triple}"
if not stub.exists():
stub.touch()
real_cli = sidecar_dir / f"buzz-{triple}"
real_cli.write_bytes(binaries["buzz"].read_bytes())
real_cli.chmod(0o755)
print(
f"Opening Buzz GUI as the benchmark user ({state['user_pubkey'][:16]}…).\n"
"Watch, don't type — a message from you mid-trial would taint the run."
)
# Distinct bundle identifier: the desktop app persists workspaces (incl.
# their relay URLs) in per-identifier WebKit localStorage, and a stored
# workspace's relay URL overrides BUZZ_RELAY_URL by design. Reusing the
# default identifier means any past local-dev session's ws://localhost:3000
# workspace silently shadows the benchmark relay. An identifier of our own
# keeps that state isolated both ways.
tauri_config = json.dumps(
{"identifier": GUI_BUNDLE_IDENTIFIER, "productName": "Buzz Benchmark"}
)
return subprocess.Popen(
["pnpm", "exec", "tauri", "dev", "--config", tauri_config],
cwd=desktop_dir,
env={
**os.environ,
"BUZZ_RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}",
"BUZZ_PRIVATE_KEY": state["user_secret_key"],
},
)
# -- main ---------------------------------------------------------------------
def leaderboard_argv(
args: argparse.Namespace, provisioner_config: Path, agent_bin_dir: Path
) -> list[str]:
argv: list[str] = []
if args.path:
argv += ["--path", str(args.path)]
else:
argv += ["--dataset", args.dataset or DEFAULT_DATASET]
for pattern in args.include_task:
argv += ["--include-task", pattern]
for pattern in args.exclude_task:
argv += ["--exclude-task", pattern]
argv += [
"--attempts", str(args.attempts),
"--manifest", str(args.manifest),
"--endpoint-config", str(args.endpoint_config),
"--provisioner-config", str(provisioner_config),
"--agent-bin-dir", str(agent_bin_dir),
# The relay as reachable from inside a task container: Docker's
# host alias, bridged to the canonical localhost address by the
# uploaded forwarder. Override the alias with
# BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host
# differently.
"--relay-gateway",
f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}"
f":{RELAY_HTTP_PORT}",
"--n-concurrent", str(args.n_concurrent),
"--jobs-dir", str(args.jobs_dir),
]
if args.job_name:
argv += ["--job-name", args.job_name]
if args.upload:
argv.append("--upload")
if args.dry_run:
argv.append("--dry-run")
return argv
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
state = load_state()
print_user_identity(state)
write_env_file(state)
provisioner_config = write_provisioner_config(state, args.endpoint_config)
if args.dry_run:
agent_bin_dir = LINUX_TARGET_DIR / linux_triple() / "release"
else:
ensure_binaries()
agent_bin_dir = ensure_agent_binaries()
if args.fresh:
reset_environment()
ensure_stack(state)
if args.gui:
launch_gui(state)
return run_leaderboard.main(
leaderboard_argv(args, provisioner_config, agent_bin_dir)
)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,271 +0,0 @@
#!/usr/bin/env python3
"""Run a problem set with a team manifest and produce leaderboard-ready results.
One command wraps ``harbor run`` with only leaderboard-legal settings — no
timeout or resource overrides are accepted or forwarded, so the resulting job
directory passes Harbor's static validation as produced. After the run it
writes a ``metadata.yaml`` template derived from the manifest and prints the
exact upload/submit commands.
Run inside the testbed environment so ``harbor`` and the adapter are
importable:
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \
--dataset terminal-bench/terminal-bench-2-1 \
--attempts 5 \
--manifest benchmarks/harbor-buzz-orchestra/manifests/<TEAM>.yaml \
--endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/<ENDPOINTS>.json \
--provisioner-config <PROVISIONER.json> \
--agent-bin-dir <DIR with Linux buzz-acp/buzz-agent/buzz-dev-mcp>
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import shutil
import subprocess
import sys
from pathlib import Path
import yaml
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
AGENT_IMPORT = "harbor_buzz_orchestra:BuzzOrchestraAgent"
PROVISIONER_FACTORY = "harbor_buzz_testbed:provisioner_from_dict"
# Host-side: the harness speaks to the relay as the trial user via this CLI.
BINARIES = ("buzz",)
# Container-side: the production stack uploaded into each task container.
# These must be Linux builds matching the task image architecture.
AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp")
# Uploaded alongside the stack when --relay-gateway is set: bridges the
# agents' canonical relay address to the host gateway (the relay is
# host-header tenant-bound, so agents must present its canonical Host).
FORWARDER_BINARY = "relay-forwarder"
PROVIDER_ORGS = {"anthropic": "Anthropic", "openai": "OpenAI", "databricks": "Databricks"}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter
)
problems = parser.add_mutually_exclusive_group(required=True)
problems.add_argument(
"--dataset", "-d", help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)"
)
problems.add_argument(
"--path", "-p", type=Path, help="Local task or dataset directory"
)
parser.add_argument(
"--include-task", "-i", action="append", default=[],
help="Task name to include from the dataset (glob, repeatable)",
)
parser.add_argument(
"--exclude-task", "-x", action="append", default=[],
help="Task name to exclude from the dataset (glob, repeatable)",
)
parser.add_argument(
"--attempts", "-k", type=int, required=True,
help="Runs per problem (leaderboards require 5)",
)
parser.add_argument("--manifest", type=Path, required=True, help="Team manifest YAML")
parser.add_argument(
"--endpoint-config", type=Path, required=True,
help="JSON mapping manifest endpoint names to providers/API keys",
)
parser.add_argument(
"--provisioner-config", type=Path, required=True,
help="JSON config for the Buzz relay/Postgres provisioner",
)
parser.add_argument(
"--buzz-bin-dir", type=Path, default=None,
help="Directory with the host buzz CLI (default: repo target/release, then target/debug)",
)
parser.add_argument(
"--agent-bin-dir", type=Path, required=True,
help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp "
"to upload into each task container",
)
parser.add_argument(
"--relay-gateway", default="",
help="host:port of the benchmark relay as reachable from inside the "
"task container (e.g. host.docker.internal:3600). When set, a "
"loopback forwarder from --agent-bin-dir bridges the canonical "
"relay address to this gateway",
)
parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials")
parser.add_argument("--jobs-dir", type=Path, default=Path("jobs"), help="Job output root")
parser.add_argument("--job-name", default=None, help="Job name (default: lb-<condition>-<UTC>)")
parser.add_argument(
"--upload", action="store_true", help="Upload to Harbor Hub when the job finishes"
)
parser.add_argument("--dry-run", action="store_true", help="Print the harbor command and exit")
return parser.parse_args(argv)
def find_binaries(bin_dir: Path | None) -> dict[str, Path]:
candidates = (
[bin_dir]
if bin_dir is not None
else [PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")]
)
for candidate in candidates:
found = {name: candidate / name for name in BINARIES}
if all(path.is_file() for path in found.values()):
return found
searched = ", ".join(str(c) for c in candidates)
raise SystemExit(
f"buzz binaries not found (need {', '.join(BINARIES)}; searched {searched}). "
"Build them with `cargo build` or pass --buzz-bin-dir."
)
def find_agent_binaries(bin_dir: Path, with_forwarder: bool = False) -> dict[str, Path]:
"""The Linux agent stack uploaded into each task container."""
names = AGENT_BINARIES + ((FORWARDER_BINARY,) if with_forwarder else ())
found = {name: bin_dir / name for name in names}
missing = [name for name, path in found.items() if not path.is_file()]
if missing:
raise SystemExit(
f"Linux agent binaries not found in {bin_dir}: {', '.join(missing)}. "
"`just benchmark` builds them; or cross-compile with "
"cargo --target <arch>-unknown-linux-musl and pass --agent-bin-dir."
)
return found
def build_command(
args: argparse.Namespace,
binaries: dict[str, Path],
agent_binaries: dict[str, Path],
) -> list[str]:
"""Compose the harbor invocation. Standard settings only: any timeout or
resource override would fail leaderboard static validation, so none are
accepted or forwarded."""
command = [
"harbor", "run", "--yes",
"--job-name", args.job_name,
"--jobs-dir", str(args.jobs_dir),
"-k", str(args.attempts),
"--n-concurrent", str(args.n_concurrent),
]
if args.dataset:
command += ["--dataset", args.dataset]
else:
command += ["--path", str(args.path)]
for pattern in args.include_task:
command += ["--include-task-name", pattern]
for pattern in args.exclude_task:
command += ["--exclude-task-name", pattern]
if args.upload:
command.append("--upload")
command += ["--agent", AGENT_IMPORT]
kwargs = {
"manifest": args.manifest,
"provisioner_factory": PROVISIONER_FACTORY,
"provisioner_config": args.provisioner_config,
"artifact_root": PACKAGE_ROOT,
"endpoint_config": args.endpoint_config,
"buzz_acp_binary": agent_binaries["buzz-acp"],
"buzz_agent_binary": agent_binaries["buzz-agent"],
"buzz_dev_mcp_binary": agent_binaries["buzz-dev-mcp"],
"buzz_cli_binary": binaries["buzz"],
"run_id": args.job_name,
}
if args.relay_gateway:
kwargs["relay_gateway"] = args.relay_gateway
kwargs["forwarder_binary"] = agent_binaries[FORWARDER_BINARY]
for key, value in kwargs.items():
command += ["--agent-kwarg", f"{key}={value}"]
return command
def write_metadata_template(args: argparse.Namespace, job_dir: Path) -> Path:
"""Derive a metadata.yaml template (harbor.leaderboard.metadata schema)
from the manifest roster; display-name placeholders are for the submitter
to confirm."""
manifest = yaml.safe_load(args.manifest.read_text())
endpoints = json.loads(args.endpoint_config.read_text())
models, seen = [], set()
for entry in manifest.get("roster", []):
model = entry.get("model_revision") or entry["endpoint"]
if model in seen:
continue
seen.add(model)
provider = endpoints.get(entry["endpoint"], {}).get("provider", "FILL_ME")
models.append(
{
"model_name": model,
"model_provider": provider,
"model_display_name": model,
"model_org_display_name": PROVIDER_ORGS.get(provider, "FILL_ME"),
}
)
metadata = {
"agent_url": "https://github.com/block/buzz",
"agent_display_name": f"Buzz Orchestra ({manifest.get('condition', 'team')})",
"agent_org_display_name": "Block",
"models": models,
}
path = job_dir / "metadata.yaml"
path.write_text(yaml.safe_dump(metadata, sort_keys=False))
return path
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
for label, path in (
("manifest", args.manifest),
("endpoint config", args.endpoint_config),
("provisioner config", args.provisioner_config),
):
if not path.is_file():
raise SystemExit(f"{label} not found: {path}")
if args.job_name is None:
condition = yaml.safe_load(args.manifest.read_text()).get("condition", "team")
stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ")
args.job_name = f"lb-{condition}-{stamp}"
if args.dry_run:
# Dry runs print the command without requiring built binaries.
bin_dir = args.buzz_bin_dir or PACKAGE_ROOT.parents[1] / "target" / "release"
binaries = {name: bin_dir / name for name in BINARIES}
agent_binaries = {
name: args.agent_bin_dir / name
for name in AGENT_BINARIES + (FORWARDER_BINARY,)
}
print(" ".join(build_command(args, binaries, agent_binaries)))
return 0
binaries = find_binaries(args.buzz_bin_dir)
agent_binaries = find_agent_binaries(
args.agent_bin_dir, with_forwarder=bool(args.relay_gateway)
)
command = build_command(args, binaries, agent_binaries)
if shutil.which("harbor") is None:
raise SystemExit(
"harbor not on PATH — run via: uv run --project "
f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..."
)
result = subprocess.run(command)
job_dir = args.jobs_dir / args.job_name
if result.returncode != 0:
print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}")
return result.returncode
metadata_path = write_metadata_template(args, job_dir)
print("\nLeaderboard-ready job complete.")
print(f" 1. Review submitter details in {metadata_path}")
print(f" 2. harbor upload {job_dir}")
print(
" 3. harbor leaderboard submit -l terminal-bench/terminal-bench-2-1 "
f"-j <job UUID from upload> -m {metadata_path}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,25 +0,0 @@
"""Buzz orchestra custom agent for Harbor."""
from .agent import BuzzOrchestraAgent
from .manifest import ExperimentManifest, ManifestError
from .provisioning import AgentCredential, TrialHandle, TrialProvisioner
from .runtime import OrchestraRuntime, RuntimeResult
from .container_runtime import (
BuzzContainerRuntime,
EndpointLaunchConfig,
RuntimeLaunchError,
)
__all__ = [
"AgentCredential",
"BuzzOrchestraAgent",
"BuzzContainerRuntime",
"EndpointLaunchConfig",
"ExperimentManifest",
"ManifestError",
"OrchestraRuntime",
"RuntimeResult",
"RuntimeLaunchError",
"TrialHandle",
"TrialProvisioner",
]

View File

@@ -1,200 +0,0 @@
"""Harbor custom-agent entry point for Buzz orchestration."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from harbor.agents.base import BaseAgent
from harbor.environments.base import BaseEnvironment
from harbor.models.agent.context import AgentContext
from .manifest import ExperimentManifest
from .provisioning import TrialProvisioner
from .runtime import OrchestraRuntime
from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig
class BuzzOrchestraAgent(BaseAgent):
"""Coordinate an arbitrary manifest-defined team through a Buzz trial."""
# Set True only once the runtime writes a validated agent/trajectory.json.
SUPPORTS_ATIF = False
def __init__(
self,
logs_dir: Path,
model_name: str | None = None,
*,
manifest: str | Path | dict[str, Any],
provisioner: TrialProvisioner | None = None,
runtime: OrchestraRuntime | None = None,
provisioner_factory: str | None = None,
provisioner_config: str | Path | dict[str, Any] | None = None,
artifact_root: str | Path | None = None,
endpoint_config: str | Path | dict[str, Any] | None = None,
buzz_acp_binary: str = "buzz-acp",
buzz_agent_binary: str = "buzz-agent",
buzz_dev_mcp_binary: str = "buzz-dev-mcp",
buzz_cli_binary: str = "buzz",
relay_gateway: str = "",
forwarder_binary: str = "relay-forwarder",
run_id: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(logs_dir=logs_dir, model_name=model_name, **kwargs)
self.manifest = ExperimentManifest.load(manifest)
self.provisioner = provisioner or self._build_provisioner(
provisioner_factory, provisioner_config
)
self.runtime = runtime or self._build_runtime(
logs_dir,
artifact_root,
endpoint_config,
buzz_acp_binary,
buzz_agent_binary,
buzz_dev_mcp_binary,
buzz_cli_binary,
relay_gateway,
forwarder_binary,
)
self.run_id = run_id
@staticmethod
def name() -> str:
return "buzz-orchestra"
def version(self) -> str:
return "0.1.0"
@staticmethod
def _load_mapping(
source: str | Path | dict[str, Any] | None,
) -> dict[str, Any] | None:
if source is None:
return None
if isinstance(source, dict):
return source
import json
path = Path(source).expanduser()
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"cannot load JSON config {path}: {error}") from error
if not isinstance(value, dict):
raise ValueError(f"JSON config {path} must contain an object")
return value
@classmethod
def _build_provisioner(
cls,
factory_path: str | None,
config_source: str | Path | dict[str, Any] | None,
) -> TrialProvisioner | None:
config = cls._load_mapping(config_source)
if factory_path is None and config is None:
return None
if factory_path is None or config is None:
raise ValueError(
"provisioner_factory and provisioner_config must be provided together"
)
from harbor.utils.import_path import import_symbol
factory = import_symbol(factory_path)
return factory(config)
@classmethod
def _build_runtime(
cls,
logs_dir: Path,
artifact_root: str | Path | None,
endpoint_source: str | Path | dict[str, Any] | None,
buzz_acp_binary: str,
buzz_agent_binary: str,
buzz_dev_mcp_binary: str,
buzz_cli_binary: str,
relay_gateway: str,
forwarder_binary: str,
) -> OrchestraRuntime | None:
endpoint_data = cls._load_mapping(endpoint_source)
if endpoint_data is None and artifact_root is None:
return None
if endpoint_data is None or artifact_root is None:
raise ValueError(
"artifact_root and endpoint_config must be provided together"
)
endpoints = {
name: EndpointLaunchConfig(
provider=value["provider"],
api_key_env=value["api_key_env"],
env=value.get("env", {}),
)
for name, value in endpoint_data.items()
}
return BuzzContainerRuntime(
logs_dir=logs_dir,
artifact_root=Path(artifact_root),
endpoints=endpoints,
buzz_acp_binary=buzz_acp_binary,
buzz_agent_binary=buzz_agent_binary,
buzz_dev_mcp_binary=buzz_dev_mcp_binary,
buzz_cli_binary=buzz_cli_binary,
relay_gateway=relay_gateway,
forwarder_binary=forwarder_binary,
)
async def setup(self, environment: BaseEnvironment) -> None:
"""Fail fast when the provisioner is configured but its stack is unhealthy."""
if self.provisioner is not None:
self.provisioner.healthcheck()
async def run(
self,
instruction: str,
environment: BaseEnvironment,
context: AgentContext,
) -> None:
if self.provisioner is None or self.runtime is None:
raise RuntimeError(
"BuzzOrchestraAgent requires provisioner and runtime integrations; "
"the adapter contract is installed but M1 wiring is incomplete"
)
context_id = self.context_id or environment.context_id
if context_id is None:
raise RuntimeError("Harbor context_id is required as the trial join key")
trial_id = str(context_id)
run_id = self.run_id or trial_id
# Human-readable channel label: the task short name, so a spectator
# GUI shows one recognisable channel per problem per attempt.
channel_label = getattr(environment, "environment_name", None)
handle = self.provisioner.create_trial(
run_id, trial_id, self.manifest, channel_label=channel_label
)
if handle.trial_id != trial_id:
raise RuntimeError("provisioner returned a handle for a different trial_id")
if handle.manifest_hash != self.manifest.sha256:
raise RuntimeError("provisioner returned a handle for a different manifest")
try:
result = await self.runtime.run(
instruction=instruction,
environment=environment,
manifest=self.manifest,
trial=handle,
)
finally:
self.provisioner.teardown(handle)
context.n_input_tokens = result.input_tokens
context.n_cache_tokens = result.cached_input_tokens
context.n_output_tokens = result.output_tokens
context.cost_usd = result.cost_usd
context.metadata = {
**result.metadata,
"manifest_sha256": self.manifest.sha256,
"condition": self.manifest.condition,
"buzz_channel_id": handle.channel_id,
"run_id": run_id,
"trial_id": trial_id,
}

View File

@@ -1,651 +0,0 @@
"""Run the production Buzz agent stack inside the Harbor task container.
Each provisioned identity is a full ``buzz-acp`` → ``buzz-agent`` →
``buzz-dev-mcp`` process tree launched *inside* the task container — the same
binaries and the same MCP toolset (shell, file tools, the ``buzz`` CLI on
PATH) that the desktop app gives a Buzz agent. The harness stays outside:
it provisions, uploads the pinned binaries, posts the task as the trial
user, and observes the channel until the orchestrator publishes DONE.
"""
from __future__ import annotations
import asyncio
import json
import os
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from harbor.environments.base import BaseEnvironment
from .manifest import AgentClass, ExperimentManifest
from .provisioning import AgentCredential, TrialHandle
from .runtime import RuntimeResult
DEFAULT_MAX_AGENT_ROUNDS = 32
# Container-side layout for the uploaded Buzz stack.
REMOTE_ROOT = "/opt/buzz"
REMOTE_BIN = f"{REMOTE_ROOT}/bin"
REMOTE_PROMPTS = f"{REMOTE_ROOT}/prompts"
REMOTE_LOGS = f"{REMOTE_ROOT}/logs"
# The relay is host-header tenant-bound (its community row is the authority
# of its own RELAY_URL), so agents must present that exact Host. When the
# relay actually lives outside the container, this forwarder listens on the
# canonical loopback address and bridges the byte stream to the gateway.
FORWARDER = f"{REMOTE_BIN}/relay-forwarder"
FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log"
# How many done-poll iterations between in-container liveness probes.
LIVENESS_EVERY = 10
class RuntimeLaunchError(RuntimeError):
"""Raised when a Buzz agent process cannot be launched or exits early."""
@dataclass(frozen=True, slots=True)
class EndpointLaunchConfig:
"""Deployment-specific environment needed to launch one manifest endpoint."""
provider: str
api_key_env: str
env: dict[str, str] = field(default_factory=dict)
@dataclass(slots=True)
class _Agent:
credential: AgentCredential
pid: int
stdout_log: str # container path
stderr_log: str # container path
class BuzzContainerRuntime:
"""Launch one production Buzz agent stack per identity in the container."""
def __init__(
self,
*,
logs_dir: Path,
artifact_root: Path,
endpoints: dict[str, EndpointLaunchConfig],
buzz_acp_binary: str = "buzz-acp",
buzz_agent_binary: str = "buzz-agent",
buzz_dev_mcp_binary: str = "buzz-dev-mcp",
buzz_cli_binary: str = "buzz",
relay_gateway: str = "",
forwarder_binary: str = "relay-forwarder",
max_agent_rounds: int = DEFAULT_MAX_AGENT_ROUNDS,
readiness_timeout_seconds: float = 60.0,
poll_seconds: float = 1.0,
) -> None:
if max_agent_rounds <= 0:
raise ValueError("max_agent_rounds must be positive")
if readiness_timeout_seconds <= 0:
raise ValueError("readiness_timeout_seconds must be positive")
self.logs_dir = Path(logs_dir)
self.artifact_root = Path(artifact_root)
self.endpoints = endpoints
# Linux builds uploaded into the task container:
self.buzz_acp_binary = buzz_acp_binary
self.buzz_agent_binary = buzz_agent_binary
self.buzz_dev_mcp_binary = buzz_dev_mcp_binary
# Host build used for user/provisioning operations only:
self.buzz_cli_binary = buzz_cli_binary
# Where the relay actually lives, as seen from inside the task
# container (e.g. host.docker.internal:3600). When set, a loopback
# forwarder bridges the agents' canonical relay address — the Host
# the relay's community row is bound to — to this gateway.
self.relay_gateway = relay_gateway
self.forwarder_binary = forwarder_binary
self.max_agent_rounds = max_agent_rounds
self.readiness_timeout_seconds = readiness_timeout_seconds
self.poll_seconds = poll_seconds
async def run(
self,
*,
instruction: str,
environment: BaseEnvironment,
manifest: ExperimentManifest,
trial: TrialHandle,
) -> RuntimeResult:
classes = self._classes_by_agent_id(manifest, trial.credentials)
orchestrator = next(c for c in trial.credentials if c.role == "orchestrator")
workers = [c for c in trial.credentials if c.agent_id != orchestrator.agent_id]
if not workers:
raise RuntimeLaunchError("Buzz orchestration requires at least one worker")
trial_dir = self.logs_dir / "buzz"
trial_dir.mkdir(parents=True, exist_ok=True)
agents: list[_Agent] = []
infra: list[_Agent] = []
try:
await self._install_stack(environment)
forwarder = await self._start_forwarder(environment, trial)
if forwarder is not None:
infra.append(forwarder)
await self._buzz_json(
trial.user, trial, "users", "set-profile", "--name",
trial.user.agent_id,
)
for credential in trial.credentials:
await self._buzz_json(
credential, trial, "users", "set-profile", "--name",
credential.agent_id,
)
agents.append(
await self._launch_agent(
environment=environment,
trial=trial,
credential=credential,
agent_class=classes[credential.agent_id],
trial_dir=trial_dir,
)
)
await self._wait_for_agents_ready(
environment, agents, trial.channel_id, infra
)
# The task arrives exactly as it would in production Buzz: a
# user prompt @mentioning the orchestrator. The harness never
# speaks as any agent.
await self._send(
trial.user, trial, f"@{orchestrator.agent_id} {instruction}"
)
final_message = await asyncio.wait_for(
self._wait_for_done(environment, orchestrator, trial, agents + infra),
timeout=manifest.trial_budget.timeout_seconds,
)
await self._verify_m1_output(environment, manifest)
finally:
await self._stop_agents(environment, agents + infra)
await self._collect_logs(environment, trial_dir)
return RuntimeResult(
metadata={
"completion_message_id": final_message["id"],
"completion_message": final_message["content"],
"agent_runtime": "in-container",
"agent_hints_enabled": False,
"task_seed": "user-identity-prompt",
"agent_max_rounds": {
credential.agent_id: (
classes[credential.agent_id].budget.max_calls
or self.max_agent_rounds
)
for credential in trial.credentials
},
}
)
# -- container setup ------------------------------------------------------
async def _install_stack(self, environment: BaseEnvironment) -> None:
"""Upload the pinned Linux binaries into the task container."""
uploads = {
f"{REMOTE_BIN}/buzz-acp": self.buzz_acp_binary,
f"{REMOTE_BIN}/buzz-agent": self.buzz_agent_binary,
f"{REMOTE_BIN}/buzz-dev-mcp": self.buzz_dev_mcp_binary,
}
if self.relay_gateway:
uploads[FORWARDER] = self.forwarder_binary
for source in uploads.values():
if not Path(source).is_file():
raise RuntimeLaunchError(f"agent binary not found: {source}")
result = await environment.exec(
f"mkdir -p {REMOTE_BIN} {REMOTE_PROMPTS} {REMOTE_LOGS}"
)
if result.return_code != 0:
raise RuntimeLaunchError(
f"cannot create {REMOTE_ROOT} in the task container: "
f"{result.stderr or result.stdout}"
)
for target, source in uploads.items():
await environment.upload_file(source, target)
await environment.exec(f"chmod 0755 {REMOTE_BIN}/*")
async def _start_forwarder(
self, environment: BaseEnvironment, trial: TrialHandle
) -> _Agent | None:
"""Bridge the agents' canonical relay address to the real gateway.
The relay resolves its tenant from the request ``Host`` header, so the
agents must dial the exact authority its community row is bound to —
``trial.relay_ws_url``. When that address is loopback inside the task
container but the relay lives on the Docker host, this starts the
uploaded forwarder listening on the canonical address and pumping the
byte stream to ``relay_gateway``. Returns ``None`` when no gateway is
configured (the relay is reachable directly).
"""
if not self.relay_gateway:
return None
# Listen on the IPv4 loopback explicitly: binding the name `localhost`
# would pick whichever address family resolves first, while clients
# iterate both — pinning v4 makes the pair deterministic. The Host
# header the relay tenant-binds on comes from the URL the agents
# dial (trial.relay_ws_url), not from the socket address.
listen = self._ws_authority(trial.relay_ws_url).replace(
"localhost", "127.0.0.1", 1
)
log = FORWARDER_LOG
command = (
f"{shlex.quote(FORWARDER)} {shlex.quote(listen)} "
f"{shlex.quote(self.relay_gateway)} </dev/null "
f">{shlex.quote(log)} 2>&1 & echo $!"
)
result = await environment.exec(command)
try:
pid = int((result.stdout or "").strip().splitlines()[-1])
except (ValueError, IndexError) as error:
raise RuntimeLaunchError(
f"cannot launch relay forwarder: {result.stderr or result.stdout}"
) from error
forwarder = _Agent(
AgentCredential(
agent_id="relay-forwarder", role="infra",
nostr_secret_key="", nostr_pubkey="", nostr_auth_tag="",
llm_endpoint="", llm_api_key="",
),
pid, log, log,
)
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
while True:
probe = await environment.exec(f"cat {shlex.quote(log)} 2>/dev/null")
if "forwarding" in (probe.stdout or ""):
return forwarder
await self._raise_for_dead_agents(environment, [forwarder])
if asyncio.get_running_loop().time() >= deadline:
raise RuntimeLaunchError(
"relay forwarder did not report readiness; "
f"see {log} in the trial artifacts"
)
await asyncio.sleep(self.poll_seconds)
@staticmethod
def _ws_authority(relay_ws_url: str) -> str:
"""``host:port`` from a ws:// URL — the forwarder's listen address."""
if not relay_ws_url.startswith("ws://"):
raise RuntimeLaunchError(
"relay_gateway forwarding requires a ws:// relay_ws_url"
)
authority = relay_ws_url.removeprefix("ws://").split("/", 1)[0]
if ":" not in authority:
authority += ":80"
return authority
async def _launch_agent(
self,
*,
environment: BaseEnvironment,
trial: TrialHandle,
credential: AgentCredential,
agent_class: AgentClass,
trial_dir: Path,
) -> _Agent:
if not credential.llm_endpoint:
raise RuntimeLaunchError("credential llm_endpoint must not be empty")
endpoint = self.endpoints.get(credential.llm_endpoint)
if endpoint is None:
raise RuntimeLaunchError(
f"no launch config for endpoint {credential.llm_endpoint!r}"
)
self._reject_identity_overrides(endpoint)
prompt_path = self.artifact_root / agent_class.prompt.path
self._verify_artifact(prompt_path, agent_class.prompt.sha256)
composed = self._compose_system_prompt(
trial_dir=trial_dir,
trial=trial,
credential=credential,
persona_path=prompt_path,
)
remote_prompt = f"{REMOTE_PROMPTS}/{credential.agent_id}.system-prompt.md"
await environment.upload_file(composed, remote_prompt)
stdout_log = f"{REMOTE_LOGS}/{credential.agent_id}.stdout.log"
stderr_log = f"{REMOTE_LOGS}/{credential.agent_id}.stderr.log"
env = self._agent_env(
trial=trial,
credential=credential,
agent_class=agent_class,
endpoint=endpoint,
remote_prompt=remote_prompt,
)
command = (
f"{shlex.quote(f'{REMOTE_BIN}/buzz-acp')} </dev/null "
f">{shlex.quote(stdout_log)} 2>{shlex.quote(stderr_log)} & echo $!"
)
result = await environment.exec(command, env=env)
try:
pid = int((result.stdout or "").strip().splitlines()[-1])
except (ValueError, IndexError) as error:
raise RuntimeLaunchError(
f"cannot launch agent {credential.agent_id}: "
f"{result.stderr or result.stdout}"
) from error
return _Agent(credential, pid, stdout_log, stderr_log)
def _agent_env(
self,
*,
trial: TrialHandle,
credential: AgentCredential,
agent_class: AgentClass,
endpoint: EndpointLaunchConfig,
remote_prompt: str,
) -> dict[str, str]:
"""The desktop-launch environment: real acp/agent/dev-mcp wiring."""
return {
**endpoint.env,
"BUZZ_RELAY_URL": trial.relay_ws_url,
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
# Desktop parity: the GUI also sets NOSTR_PRIVATE_KEY on buzz-acp
# so buzz-dev-mcp's shim can wire git auth/signing for the agent.
"NOSTR_PRIVATE_KEY": credential.nostr_secret_key,
"BUZZ_AUTH_TAG": credential.nostr_auth_tag,
"BUZZ_ACP_AGENT_COMMAND": f"{REMOTE_BIN}/buzz-agent",
"BUZZ_ACP_AGENT_ARGS": "",
"BUZZ_ACP_MCP_COMMAND": f"{REMOTE_BIN}/buzz-dev-mcp",
"BUZZ_ACP_CHANNELS": trial.channel_id,
"BUZZ_ACP_SUBSCRIBE": "mentions",
"BUZZ_ACP_RESPOND_TO": "anyone",
"BUZZ_ACP_NO_MEMORY": "true",
"BUZZ_ACP_SYSTEM_PROMPT_FILE": remote_prompt,
"BUZZ_AGENT_PROVIDER": endpoint.provider,
"BUZZ_AGENT_MODEL": credential.llm_endpoint,
"BUZZ_AGENT_MAX_OUTPUT_TOKENS": str(
agent_class.generation.max_output_tokens
),
"BUZZ_AGENT_MAX_CONTEXT_TOKENS": str(
agent_class.generation.context_window_tokens
),
"BUZZ_AGENT_MAX_ROUNDS": str(
agent_class.budget.max_calls or self.max_agent_rounds
),
# The pinned persona is the whole prompt: no hint-file or skill
# discovery from the task filesystem (metadata reports this).
"BUZZ_AGENT_NO_HINTS": "1",
endpoint.api_key_env: credential.llm_api_key,
}
# -- lifecycle -------------------------------------------------------------
async def _wait_for_agents_ready(
self,
environment: BaseEnvironment,
agents: list[_Agent],
channel_id: str,
infra: list[_Agent] | None = None,
) -> None:
"""Wait until every ACP process confirms its trial-channel subscription."""
marker = f"subscribed to channel {channel_id}"
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
pending = {agent.credential.agent_id: agent for agent in agents}
while pending:
await self._raise_for_dead_agents(environment, agents + (infra or []))
for agent_id, agent in list(pending.items()):
result = await environment.exec(
f"cat {shlex.quote(agent.stdout_log)} "
f"{shlex.quote(agent.stderr_log)} 2>/dev/null"
)
if marker in (result.stdout or ""):
del pending[agent_id]
if not pending:
return
if asyncio.get_running_loop().time() >= deadline:
raise RuntimeLaunchError(
"agents did not subscribe to trial channel before readiness "
f"timeout: {sorted(pending)}"
)
await asyncio.sleep(self.poll_seconds)
async def _wait_for_done(
self,
environment: BaseEnvironment,
orchestrator: AgentCredential,
trial: TrialHandle,
agents: list[_Agent],
) -> dict[str, Any]:
"""Observe the channel as the trial user until the orchestrator posts DONE.
Observation only: the harness never speaks as any agent. If the team
stalls, the trial times out and the stall is the measured result.
"""
polls = 0
while True:
if polls % LIVENESS_EVERY == 0:
await self._raise_for_dead_agents(environment, agents)
polls += 1
messages = await self._buzz_json(
trial.user, trial,
"messages", "get", "--channel", trial.channel_id,
"--limit", "100",
)
for message in messages:
if message.get("pubkey") == orchestrator.nostr_pubkey and str(
message.get("content", "")
).startswith("DONE:"):
return message
await asyncio.sleep(self.poll_seconds)
async def _raise_for_dead_agents(
self, environment: BaseEnvironment, agents: list[_Agent]
) -> None:
if not agents:
return
probes = "; ".join(
f"kill -0 {agent.pid} 2>/dev/null || echo DEAD:{agent.credential.agent_id}"
for agent in agents
)
result = await environment.exec(probes)
dead = [
line.removeprefix("DEAD:")
for line in (result.stdout or "").splitlines()
if line.startswith("DEAD:")
]
if dead:
raise RuntimeLaunchError(
f"agent processes exited early: {sorted(dead)}; "
f"see {REMOTE_LOGS} in the trial artifacts"
)
@staticmethod
async def _stop_agents(
environment: BaseEnvironment, agents: list[_Agent]
) -> None:
"""Terminate every process of the uploaded stack (acp, agent, mcp)."""
if not agents:
return
# Match by cmdline prefix via /proc: pkill/procps is not guaranteed
# to exist in task images, the /proc filesystem is.
sweep = (
"for d in /proc/[0-9]*; do "
f"grep -aq {REMOTE_BIN} \"$d/cmdline\" 2>/dev/null "
"&& kill -TERM \"${d#/proc/}\" 2>/dev/null; done; true"
)
try:
await environment.exec(sweep)
await asyncio.sleep(2)
await environment.exec(sweep.replace("-TERM", "-KILL"))
except Exception: # noqa: BLE001 — environment may already be gone
pass
async def _collect_logs(
self, environment: BaseEnvironment, trial_dir: Path
) -> None:
try:
await environment.download_dir(REMOTE_LOGS, trial_dir)
except Exception: # noqa: BLE001 — best effort; env may be torn down
pass
# -- Buzz CLI as the trial user / provisioning identities -------------------
@staticmethod
async def _verify_m1_output(
environment: BaseEnvironment, manifest: ExperimentManifest
) -> None:
"""Fail M1 immediately unless the artifact satisfies the grader contract."""
if manifest.condition != "M1-hello-world":
return
result = await environment.exec(
'python3 -c "from pathlib import Path; '
"p = Path('/app/hello.txt'); "
"assert p.is_file() and p.read_text().strip() == 'Hello, world!'\""
)
if result.return_code != 0:
detail = (
result.stderr or result.stdout or "grader-equivalent check failed"
).strip()
raise RuntimeLaunchError(
"M1 pre-verifier sanity probe failed: /app/hello.txt must exist "
f"and its stripped text must equal 'Hello, world!' ({detail})"
)
async def _send(
self, credential: AgentCredential, trial: TrialHandle, content: str
) -> None:
await self._buzz_json(
credential, trial,
"messages", "send", "--channel", trial.channel_id,
"--content", content,
)
async def _buzz_json(
self, credential: AgentCredential, trial: TrialHandle, *args: str
) -> Any:
process = await asyncio.create_subprocess_exec(
self.buzz_cli_binary,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={
**os.environ,
"BUZZ_RELAY_URL": self._user_relay_url(trial),
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
"BUZZ_AUTH_TAG": credential.nostr_auth_tag,
},
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeLaunchError(
f"buzz {shlex.join(args)} exited {process.returncode}: "
f"{stderr.decode(errors='replace').strip()}"
)
try:
return json.loads(stdout)
except json.JSONDecodeError as error:
raise RuntimeLaunchError("buzz returned invalid JSON") from error
@staticmethod
def _user_relay_url(trial: TrialHandle) -> str:
"""The relay as reachable from the HOST (user identity, harness).
``trial.relay_ws_url`` is the container view (the agents' runtime);
``trial.user_relay_url`` is the host view. Fall back to deriving an
http URL from the ws URL for handles minted before v1.2.
"""
if trial.user_relay_url:
return trial.user_relay_url
return BuzzContainerRuntime._cli_relay_url(trial.relay_ws_url)
@staticmethod
def _cli_relay_url(relay_ws_url: str) -> str:
if relay_ws_url.startswith("ws://"):
return f"http://{relay_ws_url.removeprefix('ws://')}"
if relay_ws_url.startswith("wss://"):
return f"https://{relay_ws_url.removeprefix('wss://')}"
raise RuntimeLaunchError("trial relay_ws_url must use ws:// or wss://")
# -- manifest plumbing -------------------------------------------------------
@staticmethod
def _classes_by_agent_id(
manifest: ExperimentManifest, credentials: tuple[AgentCredential, ...]
) -> dict[str, AgentClass]:
by_id = {entry.id: entry for entry in manifest.roster}
result: dict[str, AgentClass] = {}
for credential in credentials:
class_id, separator, index = credential.agent_id.rpartition("-")
match = by_id.get(class_id)
if not separator or not index.isdigit() or match is None:
raise RuntimeLaunchError(
f"credential {credential.agent_id!r} does not match a roster class"
)
if credential.role != match.kind:
raise RuntimeLaunchError(
f"credential {credential.agent_id!r} role does not match manifest"
)
result[credential.agent_id] = match
return result
@staticmethod
def _verify_artifact(path: Path, expected_sha256: str) -> None:
import hashlib
try:
actual = hashlib.sha256(path.read_bytes()).hexdigest()
except OSError as error:
raise RuntimeLaunchError(f"cannot read prompt {path}: {error}") from error
if actual != expected_sha256:
raise RuntimeLaunchError(
f"prompt hash mismatch for {path}: expected {expected_sha256}, got {actual}"
)
def _compose_system_prompt(
self,
*,
trial_dir: Path,
trial: TrialHandle,
credential: AgentCredential,
persona_path: Path,
) -> Path:
"""Append the trial's team roster to the pinned persona.
The analogue of a production Buzz workspace's team context: each agent
knows its own identity, its channel, the user it reports to, and its
teammates' names, pubkeys, and roles from its system prompt — it never
has to discover them over the relay.
"""
persona = persona_path.read_text(encoding="utf-8")
lines = [
"",
"## Your team",
"",
f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).",
f"The team coordinates in Buzz channel `{trial.channel_id}`.",
f"Tasks come from the user `{trial.user.agent_id}` "
f"(pubkey `{trial.user.nostr_pubkey}`); address your final report "
"to them.",
"",
"| Name | Role | Pubkey |",
"|------|------|--------|",
]
for teammate in trial.credentials:
if teammate.agent_id == credential.agent_id:
continue
lines.append(
f"| {teammate.agent_id} | {teammate.role} "
f"| `{teammate.nostr_pubkey}` |"
)
composed = persona + "\n".join(lines) + "\n"
path = trial_dir / f"{credential.agent_id}.system-prompt.md"
path.write_text(composed, encoding="utf-8")
path.chmod(0o600)
return path
@staticmethod
def _reject_identity_overrides(endpoint: EndpointLaunchConfig) -> None:
forbidden = {
"BUZZ_RELAY_URL",
"BUZZ_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_ACP_CHANNELS",
"BUZZ_ACP_MCP_COMMAND",
"BUZZ_ACP_AGENT_COMMAND",
}
overlap = forbidden & endpoint.env.keys()
if overlap:
raise RuntimeLaunchError(
f"endpoint env cannot override trial identity: {sorted(overlap)}"
)

View File

@@ -1,145 +0,0 @@
"""Validated, content-addressed experiment manifests."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any, Literal, Self
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ManifestError(ValueError):
"""Raised when a manifest cannot be loaded or validated."""
class StrictModel(BaseModel):
"""Base model that rejects misspelled or unrecognised manifest fields."""
model_config = ConfigDict(extra="forbid", frozen=True)
class ArtifactRef(StrictModel):
"""Reference to immutable prompt, persona, or skill content."""
path: str = Field(min_length=1)
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
class GenerationConfig(StrictModel):
"""Model generation controls frozen for a condition."""
temperature: float = Field(default=0.0, ge=0.0)
max_output_tokens: int = Field(gt=0)
context_window_tokens: int = Field(gt=0)
extra: dict[str, Any] = Field(default_factory=dict)
class AgentBudget(StrictModel):
"""Optional per-agent live safety limits."""
max_calls: int | None = Field(default=None, gt=0)
max_input_tokens: int | None = Field(default=None, gt=0)
max_output_tokens: int | None = Field(default=None, gt=0)
max_cost_usd: float | None = Field(default=None, gt=0)
class Price(StrictModel):
"""Frozen USD rates for one endpoint revision, per million tokens."""
input_per_million_usd: float = Field(ge=0)
cached_input_per_million_usd: float = Field(ge=0)
output_per_million_usd: float = Field(ge=0)
class AgentClass(StrictModel):
"""A homogeneous class of agents in the trial roster."""
id: str = Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9._-]*$")
kind: Literal["orchestrator", "worker"]
role: str = Field(min_length=1)
count: int = Field(gt=0)
endpoint: str = Field(min_length=1)
model_revision: str = Field(min_length=1)
prompt: ArtifactRef
persona: ArtifactRef | None = None
skills: tuple[ArtifactRef, ...] = ()
generation: GenerationConfig
budget: AgentBudget = AgentBudget()
concurrency: int = Field(default=1, gt=0)
@model_validator(mode="after")
def validate_concurrency(self) -> Self:
if self.concurrency > self.count:
raise ValueError("concurrency cannot exceed count")
return self
class TrialBudget(StrictModel):
"""Trial-wide hard limits enforced by the live runtime, not async receipts."""
timeout_seconds: int = Field(gt=0)
max_cost_usd: float | None = Field(default=None, gt=0)
class ExperimentManifest(StrictModel):
"""Complete immutable input defining one benchmark condition."""
schema_version: Literal["1"] = "1"
condition: str = Field(min_length=1)
roster: tuple[AgentClass, ...] = Field(min_length=1)
prices: dict[str, Price]
trial_budget: TrialBudget
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_roster(self) -> Self:
ids = [entry.id for entry in self.roster]
if len(ids) != len(set(ids)):
raise ValueError("roster class ids must be unique")
orchestrators = sum(
entry.count for entry in self.roster if entry.kind == "orchestrator"
)
if orchestrators != 1:
raise ValueError("roster must contain exactly one orchestrator")
endpoints = {entry.endpoint for entry in self.roster}
missing_prices = sorted(endpoints - self.prices.keys())
if missing_prices:
raise ValueError(f"prices missing for endpoints: {missing_prices}")
return self
def canonical_bytes(self) -> bytes:
"""Return stable UTF-8 JSON independent of YAML formatting and key order."""
data = self.model_dump(mode="json", exclude_none=False)
return json.dumps(
data,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
allow_nan=False,
).encode("utf-8")
@property
def sha256(self) -> str:
"""Return the condition identity derived from canonical manifest content."""
return hashlib.sha256(self.canonical_bytes()).hexdigest()
@classmethod
def load(cls, source: str | Path | dict[str, Any]) -> Self:
"""Load a manifest from a YAML/JSON file or an already-decoded mapping."""
if isinstance(source, dict):
raw = source
else:
path = Path(source).expanduser()
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as error:
raise ManifestError(f"cannot load manifest {path}: {error}") from error
if not isinstance(raw, dict):
raise ManifestError("manifest root must be a mapping")
try:
return cls.model_validate(raw)
except ValueError as error:
raise ManifestError(f"invalid manifest: {error}") from error

View File

@@ -1,58 +0,0 @@
"""Typed boundary between the Harbor adapter and Buzz trial provisioning."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from .manifest import ExperimentManifest
@dataclass(frozen=True, slots=True)
class AgentCredential:
"""One trial-scoped Buzz identity and attributed LLM credential."""
agent_id: str
role: str
nostr_secret_key: str
nostr_pubkey: str
nostr_auth_tag: str
llm_endpoint: str
llm_api_key: str
@dataclass(frozen=True, slots=True)
class TrialHandle:
"""Provisioned Buzz resources owned by one Harbor trial."""
run_id: str
trial_id: str
manifest_hash: str
relay_ws_url: str
channel_id: str
credentials: tuple[AgentCredential, ...]
# The trial's human-analogue identity: owns the channel, posts the task
# as a user prompt, and receives the final report. Never runs an agent
# process, so its llm_endpoint and llm_api_key are empty strings.
user: AgentCredential
# v1.2 (additive): the relay as reachable from the HOST, where the user
# identity and the harness run. ``relay_ws_url`` is the view from the
# agents' runtime (the task container). Empty means both views coincide.
user_relay_url: str = ""
@runtime_checkable
class TrialProvisioner(Protocol):
"""Creates and tears down trial-isolated Buzz resources synchronously."""
def create_trial(
self,
run_id: str,
trial_id: str,
manifest: ExperimentManifest,
channel_label: str | None = None,
) -> TrialHandle: ...
def teardown(self, handle: TrialHandle) -> None: ...
def healthcheck(self) -> None: ...

View File

@@ -1,35 +0,0 @@
"""Runtime contract kept separate from Buzz resource provisioning."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
from harbor.environments.base import BaseEnvironment
from .manifest import ExperimentManifest
from .provisioning import TrialHandle
@dataclass(frozen=True, slots=True)
class RuntimeResult:
"""Aggregate values returned by an orchestration runtime."""
input_tokens: int = 0
cached_input_tokens: int = 0
output_tokens: int = 0
cost_usd: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
class OrchestraRuntime(Protocol):
"""Runs the provisioned roster and writes trial artifacts into logs_dir."""
async def run(
self,
*,
instruction: str,
environment: BaseEnvironment,
manifest: ExperimentManifest,
trial: TrialHandle,
) -> RuntimeResult: ...

View File

@@ -1,18 +0,0 @@
# Benchmark-only overrides for deploy/compose/compose.yml.
#
# The production bundle stays untouched; this file publishes what the
# benchmark harness needs on the host:
# - relay Prometheus metrics (:9102) for scraping during runs
# - Postgres (:5433) for the provisioner and receipts ingestion
# (5433 to avoid colliding with other local Postgres instances)
#
# Usage:
# docker compose --env-file .env \
# -f compose.yml -f <this file> up -d --wait
services:
relay:
ports:
- "${BUZZ_METRICS_HOST_PORT:-9102}:9102"
postgres:
ports:
- "${BUZZ_PG_HOST_PORT:-5433}:5432"

View File

@@ -1,26 +0,0 @@
# Endpoint launch configs
Deployment-time mapping from manifest endpoint names to
`EndpointLaunchConfig` (`provider` / `api_key_env` / `env`), passed to
`harbor run` as `--agent-kwarg endpoint_config=<path>`. This is deployment
config, deliberately OUTSIDE the immutable condition manifest — the manifest
endpoint string remains the join key.
Every key in these files must be a manifest endpoint name; the loader treats
all entries as endpoint configs (no comment keys).
## m1-local.json
M1 wiring proof: both placeholder endpoints resolve to one local llama-server
(OpenAI-compatible, `http://127.0.0.1:8091/v1`, no cloud keys).
buzz-agent env contract (crates/buzz-agent/src/config.rs, pinned at the M1
binary SHA): `provider=openai` reads `OPENAI_COMPAT_API_KEY` +
`OPENAI_COMPAT_BASE_URL`; the runtime sets `BUZZ_AGENT_MODEL` from the
manifest endpoint name, which overrides `OPENAI_COMPAT_MODEL` — llama-server
ignores the model name, so the placeholder value is harmless there.
llama-server needs no real key; the provisioner's per-endpoint
`llm_api_keys` map supplies a dummy value.
The Databricks pilot config is the same file shape with real serving
endpoint hosts/keys.

View File

@@ -1,12 +0,0 @@
{
"claude-sonnet-4-6": {
"provider": "anthropic",
"api_key_env": "ANTHROPIC_API_KEY",
"env": {}
},
"claude-haiku-4-5": {
"provider": "anthropic",
"api_key_env": "ANTHROPIC_API_KEY",
"env": {}
}
}

View File

@@ -1,16 +0,0 @@
{
"local/placeholder-orchestrator": {
"provider": "openai",
"api_key_env": "OPENAI_COMPAT_API_KEY",
"env": {
"OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1"
}
},
"local/placeholder-worker": {
"provider": "openai",
"api_key_env": "OPENAI_COMPAT_API_KEY",
"env": {
"OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1"
}
}
}

View File

@@ -1,30 +0,0 @@
[project]
name = "harbor-buzz-testbed"
version = "0.1.0"
description = "Trial provisioning and local Buzz stack tooling for harbor-buzz-orchestra"
requires-python = ">=3.12"
dependencies = [
"harbor-buzz-orchestra",
"coincurve>=20",
"psycopg[binary]>=3.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.optional-dependencies]
dev = [
"pytest>=8.4",
"ruff>=0.15",
]
[tool.uv.sources]
harbor-buzz-orchestra = { path = "..", editable = true }
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 88

View File

@@ -1,72 +0,0 @@
-- Benchmark schema for harbor-buzz-orchestra runs.
--
-- Lives in the shared Postgres instance but is OWNED BY THE HARNESS, never by
-- Buzz migrations (canonical plan §two-domain rule). Idempotent: safe to apply
-- on every testbed bring-up.
--
-- docker exec -i <postgres> psql -U buzz -d buzz < sql/benchmark_schema.sql
CREATE SCHEMA IF NOT EXISTS benchmark;
-- One row per provisioned trial; written by BuzzTrialProvisioner.
CREATE TABLE IF NOT EXISTS benchmark.trial_manifest (
run_id text NOT NULL,
trial_id uuid NOT NULL,
manifest_hash text NOT NULL,
channel_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
handle jsonb NOT NULL DEFAULT '{}'::jsonb,
archived_at timestamptz,
PRIMARY KEY (run_id, trial_id)
);
-- Immutable LLM receipts, ingested post-run from the accounting path
-- (Databricks AI Gateway inference tables first; LiteLLM shim fallback).
-- Authoritative for tokens/cost and orchestrator-vs-worker attribution;
-- Harbor AgentContext totals are a reconciliation checksum only.
CREATE TABLE IF NOT EXISTS benchmark.llm_receipts (
receipt_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id text NOT NULL,
trial_id uuid NOT NULL,
agent_id text NOT NULL,
role text NOT NULL,
condition text NOT NULL,
endpoint text NOT NULL,
model_revision text NOT NULL,
request_id text NOT NULL, -- gateway request identity
requested_at timestamptz NOT NULL,
latency_ms integer,
input_tokens bigint NOT NULL DEFAULT 0,
cached_input_tokens bigint NOT NULL DEFAULT 0,
output_tokens bigint NOT NULL DEFAULT 0,
cost_usd numeric(12, 6),
source text NOT NULL, -- 'ai_gateway' | 'litellm' | 'client'
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (source, request_id),
FOREIGN KEY (run_id, trial_id)
REFERENCES benchmark.trial_manifest (run_id, trial_id)
);
CREATE INDEX IF NOT EXISTS llm_receipts_trial_idx
ON benchmark.llm_receipts (run_id, trial_id, agent_id);
-- Harness-recorded timing spans (monotonic clocks are authoritative for
-- latency). kind examples: 'trial', 'llm_call', 'terminal_exec',
-- 'terminal_queue_wait' — queue-wait is recorded separately from execution so
-- speed can be reported both as-run and queue-adjusted under the M1
-- serialized-broker policy.
CREATE TABLE IF NOT EXISTS benchmark.spans (
span_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id text NOT NULL,
trial_id uuid NOT NULL,
agent_id text, -- NULL for trial-level spans
kind text NOT NULL,
started_at timestamptz NOT NULL,
duration_ms bigint NOT NULL CHECK (duration_ms >= 0),
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
FOREIGN KEY (run_id, trial_id)
REFERENCES benchmark.trial_manifest (run_id, trial_id)
);
CREATE INDEX IF NOT EXISTS spans_trial_kind_idx
ON benchmark.spans (run_id, trial_id, kind);

View File

@@ -1,15 +0,0 @@
"""Testbed-side provisioning for harbor-buzz-orchestra trials."""
from .provisioner import (
BuzzTrialProvisioner,
ProvisioningError,
TestbedConfig,
provisioner_from_dict,
)
__all__ = [
"BuzzTrialProvisioner",
"ProvisioningError",
"TestbedConfig",
"provisioner_from_dict",
]

View File

@@ -1,102 +0,0 @@
"""Thin subprocess wrapper over the ``buzz`` CLI — the production client path."""
from __future__ import annotations
import json
import subprocess
from typing import Any
class BuzzCliError(RuntimeError):
"""A buzz CLI invocation failed."""
class BuzzCli:
"""Run buzz CLI commands as one relay identity (key + NIP-OA auth tag)."""
def __init__(
self,
relay_url: str,
secret_key: str,
auth_tag: str,
*,
binary: str = "buzz",
timeout_seconds: float = 30.0,
) -> None:
self._relay_url = relay_url
self._secret_key = secret_key
self._auth_tag = auth_tag
self._binary = binary
self._timeout = timeout_seconds
def run(self, *args: str) -> Any:
"""Run a buzz subcommand and return its parsed JSON stdout."""
command = [self._binary, *args]
try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=self._timeout,
env={
"BUZZ_RELAY_URL": self._relay_url,
"BUZZ_PRIVATE_KEY": self._secret_key,
"BUZZ_AUTH_TAG": self._auth_tag,
"PATH": _path(),
},
)
except (OSError, subprocess.TimeoutExpired) as error:
raise BuzzCliError(f"buzz {args[0]}: {error}") from error
if completed.returncode != 0:
raise BuzzCliError(
f"buzz {' '.join(args)} exited {completed.returncode}: "
f"{completed.stderr.strip() or completed.stdout.strip()}"
)
if not completed.stdout.strip():
return None
try:
return json.loads(completed.stdout)
except json.JSONDecodeError as error:
raise BuzzCliError(
f"buzz {args[0]} returned non-JSON output: {completed.stdout[:200]!r}"
) from error
def create_private_channel(self, name: str, description: str) -> str:
"""Create a private stream channel; return its UUID."""
response = self.run(
"channels",
"create",
"--name",
name,
"--type",
"stream",
"--visibility",
"private",
"--description",
description,
)
channel_id = response.get("channel_id") if isinstance(response, dict) else None
if not channel_id:
raise BuzzCliError(f"channel create returned no channel_id: {response}")
return channel_id
def add_member(self, channel_id: str, pubkey: str) -> None:
self.run(
"channels",
"add-member",
"--channel",
channel_id,
"--pubkey",
pubkey,
"--role",
"member",
)
def archive_channel(self, channel_id: str) -> None:
self.run("channels", "archive", "--channel", channel_id)
def _path() -> str:
import os
return os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")

View File

@@ -1,90 +0,0 @@
"""Nostr keygen and NIP-OA owner attestation for trial agents."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
import coincurve
@dataclass(frozen=True, slots=True)
class NostrKeypair:
secret_key: str # hex
pubkey: str # x-only hex
def generate_keypair() -> NostrKeypair:
"""Generate a fresh secp256k1 keypair in Nostr hex form."""
key = coincurve.PrivateKey()
return NostrKeypair(
secret_key=key.to_hex(),
pubkey=key.public_key_xonly.format().hex(),
)
def keypair_from_secret(secret_key: str) -> NostrKeypair:
"""Rebuild the keypair for an existing hex secret key."""
key = coincurve.PrivateKey(bytes.fromhex(secret_key))
return NostrKeypair(
secret_key=secret_key,
pubkey=key.public_key_xonly.format().hex(),
)
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _bech32_polymod(values: list[int]) -> int:
generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
checksum = 1
for value in values:
top = checksum >> 25
checksum = (checksum & 0x1FFFFFF) << 5 ^ value
for i in range(5):
checksum ^= generator[i] if (top >> i) & 1 else 0
return checksum
def encode_nsec(secret_key: str) -> str:
"""Encode a hex secret key as a NIP-19 bech32 ``nsec1…`` string —
the form the desktop GUI's key-import onboarding accepts."""
hrp = "nsec"
data: list[int] = []
accumulator = bits = 0
for byte in bytes.fromhex(secret_key):
accumulator = accumulator << 8 | byte
bits += 8
while bits >= 5:
bits -= 5
data.append(accumulator >> bits & 31)
if bits:
data.append(accumulator << (5 - bits) & 31)
expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
polymod = _bech32_polymod(expanded + data + [0] * 6) ^ 1
checksum = [polymod >> 5 * (5 - i) & 31 for i in range(6)]
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in data + checksum)
def compute_auth_tag(
owner_secret_key: str, agent_pubkey: str, conditions: str = ""
) -> str:
"""Compute the NIP-OA ``["auth", ...]`` tag authorising an agent key.
Mirrors crates/buzz-sdk/src/nip_oa.rs:
sig = schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions),
owner_secret_key). Returns the tag as a JSON string.
"""
owner = coincurve.PrivateKey(bytes.fromhex(owner_secret_key))
preimage = f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode()
signature = owner.sign_schnorr(hashlib.sha256(preimage).digest())
return json.dumps(
[
"auth",
owner.public_key_xonly.format().hex(),
conditions,
signature.hex(),
],
separators=(",", ":"),
)

View File

@@ -1,274 +0,0 @@
"""Channel-per-trial Buzz provisioning against a local benchmark stack."""
from __future__ import annotations
import dataclasses
import hashlib
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
import psycopg
from harbor_buzz_orchestra.manifest import ExperimentManifest
from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
from .buzz_cli import BuzzCli
from .keys import compute_auth_tag, generate_keypair, keypair_from_secret
class ProvisioningError(RuntimeError):
"""Trial provisioning failed or was invoked inconsistently."""
@dataclass(frozen=True, slots=True)
class TestbedConfig:
"""Connection settings for one local benchmark stack."""
__test__ = False # not a pytest class, despite the name
relay_http_url: str # e.g. http://localhost:3000 — CLI + healthcheck
relay_ws_url: str # as reachable FROM the agents' runtime
owner_secret_key: str # relay owner key; signs NIP-OA attestations
postgres_dsn: str # benchmark schema lives here
llm_api_keys: dict[str, str] = dataclasses.field(default_factory=dict)
# endpoint -> API key. v1: per-endpoint resolution; true per-agent
# Databricks attribution is pending the AI Gateway field verification.
# Pinned user identity (hex secret key). When set, every trial's user —
# the human analogue that owns the channel and posts the task — is this
# one identity instead of a fresh key per trial, so a GUI logged in as
# that user sees every trial channel accumulate. None preserves the
# fresh-user-per-trial behaviour.
user_secret_key: str | None = None
# When False, teardown leaves the trial channel unarchived (and its
# archived_at stamp NULL) so finished trials stay visible in a GUI.
archive_on_teardown: bool = True
def provisioner_from_dict(config: dict[str, object]) -> "BuzzTrialProvisioner":
"""Harbor CLI factory for a JSON-decoded testbed configuration."""
return BuzzTrialProvisioner(TestbedConfig(**config))
class BuzzTrialProvisioner:
"""Implements the TrialHandle v1.1 contract against a live Buzz relay.
Guarantees (contract PLANS/HARBOR_BUZZ_TRIALHANDLE_CONTRACT.md):
- create_trial is synchronous and idempotent per (run_id, trial_id);
concurrency-safe via a Postgres advisory lock on the trial key.
- One private channel per trial; membership is exactly the trial's
credentials, so cross-trial reads are blocked by construction.
- Fresh keys per trial; never reused.
- teardown archives the channel and stamps archived_at; events are
never deleted.
"""
def __init__(self, config: TestbedConfig) -> None:
self._config = config
# -- contract surface ---------------------------------------------------
def create_trial(
self,
run_id: str,
trial_id: str,
manifest: ExperimentManifest,
channel_label: str | None = None,
) -> TrialHandle:
manifest_hash = manifest.sha256
with psycopg.connect(self._config.postgres_dsn) as conn:
self._lock_trial(conn, run_id, trial_id)
existing = self._load_trial(conn, run_id, trial_id)
if existing is not None:
if existing.manifest_hash != manifest_hash:
raise ProvisioningError(
f"trial ({run_id}, {trial_id}) already provisioned with "
f"manifest {existing.manifest_hash}, got {manifest_hash}"
)
return existing
handle = self._provision(
run_id, trial_id, manifest, manifest_hash, channel_label
)
self._store_trial(conn, handle)
conn.commit()
return handle
def teardown(self, handle: TrialHandle) -> None:
if not self._config.archive_on_teardown:
# GUI/spectator mode: finished trial channels stay visible.
return
cli = self._cli_for(handle.credentials[0])
try:
cli.archive_channel(handle.channel_id)
except Exception as error: # noqa: BLE001 — idempotent re-teardown
if "archived" not in str(error).lower():
raise
with psycopg.connect(self._config.postgres_dsn) as conn:
conn.execute(
"UPDATE benchmark.trial_manifest"
" SET archived_at = COALESCE(archived_at, now())"
" WHERE run_id = %s AND trial_id = %s",
(handle.run_id, handle.trial_id),
)
conn.commit()
def healthcheck(self) -> None:
url = f"{self._config.relay_http_url.rstrip('/')}/_readiness"
try:
with urllib.request.urlopen(url, timeout=5) as response:
if response.status != 200:
raise ProvisioningError(
f"relay readiness returned {response.status}"
)
except (urllib.error.URLError, OSError) as error:
raise ProvisioningError(f"relay unreachable at {url}: {error}") from error
try:
with psycopg.connect(self._config.postgres_dsn, connect_timeout=5) as conn:
conn.execute("SELECT 1")
except psycopg.Error as error:
raise ProvisioningError(
f"benchmark postgres unreachable: {error}"
) from error
# -- internals ----------------------------------------------------------
def _provision(
self,
run_id: str,
trial_id: str,
manifest: ExperimentManifest,
manifest_hash: str,
channel_label: str | None,
) -> TrialHandle:
credentials = self._mint_credentials(manifest)
user = self._mint_user()
# The user identity creates the channel and invites the agents —
# mirroring production Buzz, where a human owns the channel their
# agents work in.
cli = self._cli_for(user)
name = (
f"{channel_label}-{trial_id[:8]}"
if channel_label
else f"trial-{trial_id[:8]}-{manifest_hash[:8]}"
)
channel_id = cli.create_private_channel(
name=name,
description=f"run={run_id} trial={trial_id} manifest={manifest_hash}",
)
for credential in credentials:
cli.add_member(channel_id, credential.nostr_pubkey)
return TrialHandle(
run_id=run_id,
trial_id=trial_id,
manifest_hash=manifest_hash,
relay_ws_url=self._config.relay_ws_url,
channel_id=channel_id,
credentials=credentials,
user=user,
user_relay_url=self._config.relay_http_url,
)
def _mint_credentials(
self, manifest: ExperimentManifest
) -> tuple[AgentCredential, ...]:
roster = sorted(manifest.roster, key=lambda e: e.kind != "orchestrator")
credentials: list[AgentCredential] = []
for entry in roster:
api_key = self._config.llm_api_keys.get(entry.endpoint)
if api_key is None:
raise ProvisioningError(
f"no LLM API key configured for endpoint {entry.endpoint!r}"
)
for index in range(1, entry.count + 1):
keypair = generate_keypair()
credentials.append(
AgentCredential(
agent_id=f"{entry.id}-{index}",
role=entry.kind,
nostr_secret_key=keypair.secret_key,
nostr_pubkey=keypair.pubkey,
nostr_auth_tag=compute_auth_tag(
self._config.owner_secret_key, keypair.pubkey
),
llm_endpoint=entry.endpoint,
llm_api_key=api_key,
)
)
return tuple(credentials)
def _mint_user(self) -> AgentCredential:
"""Mint the trial's user identity — the human analogue, not an agent.
With a pinned ``user_secret_key`` the same identity fronts every
trial, like one human running many teams; otherwise each trial gets
a fresh user key.
"""
keypair = (
keypair_from_secret(self._config.user_secret_key)
if self._config.user_secret_key
else generate_keypair()
)
return AgentCredential(
agent_id="user",
role="user",
nostr_secret_key=keypair.secret_key,
nostr_pubkey=keypair.pubkey,
nostr_auth_tag=compute_auth_tag(
self._config.owner_secret_key, keypair.pubkey
),
llm_endpoint="",
llm_api_key="",
)
def _cli_for(self, credential: AgentCredential) -> BuzzCli:
return BuzzCli(
relay_url=self._config.relay_http_url,
secret_key=credential.nostr_secret_key,
auth_tag=credential.nostr_auth_tag,
)
@staticmethod
def _lock_trial(conn: psycopg.Connection, run_id: str, trial_id: str) -> None:
digest = hashlib.sha256(f"{run_id}\x00{trial_id}".encode()).digest()
lock_key = int.from_bytes(digest[:8], "big", signed=True)
conn.execute("SELECT pg_advisory_xact_lock(%s)", (lock_key,))
@staticmethod
def _load_trial(
conn: psycopg.Connection, run_id: str, trial_id: str
) -> TrialHandle | None:
row = conn.execute(
"SELECT handle FROM benchmark.trial_manifest"
" WHERE run_id = %s AND trial_id = %s",
(run_id, trial_id),
).fetchone()
if row is None:
return None
stored = row[0]
return TrialHandle(
run_id=stored["run_id"],
trial_id=stored["trial_id"],
manifest_hash=stored["manifest_hash"],
relay_ws_url=stored["relay_ws_url"],
channel_id=stored["channel_id"],
credentials=tuple(
AgentCredential(**credential) for credential in stored["credentials"]
),
user=AgentCredential(**stored["user"]),
user_relay_url=stored.get("user_relay_url", ""),
)
@staticmethod
def _store_trial(conn: psycopg.Connection, handle: TrialHandle) -> None:
conn.execute(
"INSERT INTO benchmark.trial_manifest"
" (run_id, trial_id, manifest_hash, channel_id, handle)"
" VALUES (%s, %s, %s, %s, %s)",
(
handle.run_id,
handle.trial_id,
handle.manifest_hash,
handle.channel_id,
json.dumps(dataclasses.asdict(handle)),
),
)

View File

@@ -1,59 +0,0 @@
"""Shared fixtures for testbed provisioning tests."""
from __future__ import annotations
import pytest
from harbor_buzz_orchestra.manifest import ExperimentManifest
PROMPT_REF = {"path": "personas/x.md", "sha256": "0" * 64}
@pytest.fixture()
def manifest() -> ExperimentManifest:
"""A minimal two-class manifest: one orchestrator, two workers."""
return ExperimentManifest.load(
{
"condition": "O-test",
"roster": [
{
"id": "worker-glm",
"kind": "worker",
"role": "implementer",
"count": 2,
"endpoint": "databricks/glm",
"model_revision": "glm-5.2",
"prompt": PROMPT_REF,
"generation": {
"max_output_tokens": 4096,
"context_window_tokens": 128000,
},
},
{
"id": "orch-opus",
"kind": "orchestrator",
"role": "lead",
"count": 1,
"endpoint": "databricks/opus",
"model_revision": "opus-4.8",
"prompt": PROMPT_REF,
"generation": {
"max_output_tokens": 8192,
"context_window_tokens": 200000,
},
},
],
"prices": {
"databricks/glm": {
"input_per_million_usd": 0.5,
"cached_input_per_million_usd": 0.1,
"output_per_million_usd": 1.5,
},
"databricks/opus": {
"input_per_million_usd": 15.0,
"cached_input_per_million_usd": 1.5,
"output_per_million_usd": 75.0,
},
},
"trial_budget": {"timeout_seconds": 3600},
}
)

View File

@@ -1,156 +0,0 @@
"""just benchmark must default to leaderboard-eligible settings."""
import importlib.util
import json
import sys
from pathlib import Path
import pytest
_SCRIPT = Path(__file__).parents[2] / "scripts" / "benchmark.py"
_spec = importlib.util.spec_from_file_location("benchmark", _SCRIPT)
benchmark = importlib.util.module_from_spec(_spec)
sys.modules["benchmark"] = benchmark
_spec.loader.exec_module(benchmark)
@pytest.fixture
def state_dir(tmp_path, monkeypatch):
monkeypatch.setattr(benchmark, "STATE_DIR", tmp_path / ".benchmark")
return tmp_path / ".benchmark"
def test_defaults_are_leaderboard_eligible():
args = benchmark.parse_args([])
assert args.attempts == 5
assert args.dataset is None and args.path is None # dataset default applied later
argv = benchmark.leaderboard_argv(args, Path("prov.json"), Path("linux-bin"))
assert argv[argv.index("--dataset") + 1] == "terminal-bench/terminal-bench-2-1"
assert argv[argv.index("--attempts") + 1] == "5"
assert argv[argv.index("--manifest") + 1].endswith("tb-cobol-sonnet-haiku.yaml")
assert argv[argv.index("--agent-bin-dir") + 1] == "linux-bin"
# In-container agents reach the host relay through the forwarder gateway.
assert argv[argv.index("--relay-gateway") + 1] == (
f"host.docker.internal:{benchmark.RELAY_HTTP_PORT}"
)
def test_selectors_pass_through():
args = benchmark.parse_args(
["--path", "/tmp/task", "-i", "cobol*", "-x", "flaky*", "-k", "1",
"--job-name", "smoke", "--dry-run"]
)
argv = benchmark.leaderboard_argv(args, Path("p.json"), Path("b"))
assert argv[argv.index("--path") + 1] == "/tmp/task"
assert argv[argv.index("--include-task") + 1] == "cobol*"
assert argv[argv.index("--exclude-task") + 1] == "flaky*"
assert argv[argv.index("--attempts") + 1] == "1"
assert "--dry-run" in argv
assert "--dataset" not in argv
def test_state_is_generated_once_and_reused(state_dir):
first = benchmark.load_state()
second = benchmark.load_state()
assert first["user_secret_key"] == second["user_secret_key"]
assert first["owner_secret_key"] != first["user_secret_key"]
assert len(first["user_pubkey"]) == 64
stored = json.loads((state_dir / "state.json").read_text())
assert "user_pubkey" not in stored # derived, never persisted
def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_KEY_ENV", "sk-test")
endpoints = tmp_path / "endpoints.json"
endpoints.write_text(
json.dumps({"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}})
)
state = benchmark.load_state()
path = benchmark.write_provisioner_config(state, endpoints)
config = json.loads(path.read_text())
assert config["user_secret_key"] == state["user_secret_key"]
assert config["archive_on_teardown"] is False
assert config["llm_api_keys"] == {"model-a": "sk-test"}
assert str(benchmark.RELAY_HTTP_PORT) in config["relay_http_url"]
# Both views dial the relay's canonical host-bound address; inside the
# task container the loopback forwarder bridges it to the host gateway.
assert config["relay_ws_url"] == f"ws://localhost:{benchmark.RELAY_HTTP_PORT}"
assert config["relay_http_url"].startswith("http://localhost:")
def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, monkeypatch):
monkeypatch.delenv("MISSING_KEY_ENV", raising=False)
endpoints = tmp_path / "endpoints.json"
endpoints.write_text(
json.dumps({"model-a": {"provider": "x", "api_key_env": "MISSING_KEY_ENV"}})
)
with pytest.raises(SystemExit, match="MISSING_KEY_ENV"):
benchmark.write_provisioner_config(benchmark.load_state(), endpoints)
def test_env_file_wires_owner_and_ports(state_dir):
state = benchmark.load_state()
env_path = benchmark.write_env_file(state)
env = dict(
line.split("=", 1) for line in env_path.read_text().splitlines() if line
)
assert env["RELAY_OWNER_PUBKEY"] == state["owner_pubkey"]
assert env["BUZZ_HTTP_PORT"] == str(benchmark.RELAY_HTTP_PORT)
assert env["BUZZ_PG_HOST_PORT"] == str(benchmark.PG_HOST_PORT)
assert env["BUZZ_REQUIRE_RELAY_MEMBERSHIP"] == "true"
def test_compose_command_isolates_the_project(state_dir):
command = benchmark.compose_command("up", "-d")
assert command[:2] == ["docker", "compose"]
assert command[command.index("--project-name") + 1] == "buzz-benchmark"
files = [command[i + 1] for i, part in enumerate(command) if part == "-f"]
assert any(f.endswith("deploy/compose/compose.yml") for f in files)
assert any(f.endswith("compose.benchmark.yml") for f in files)
def test_bring_up_self_heals_a_stale_credential_volume(monkeypatch):
calls = []
def fake_run(command, check=True):
calls.append(command)
if len(calls) == 1: # first up fails against the stale volume
raise benchmark.subprocess.CalledProcessError(1, command)
monkeypatch.setattr(benchmark.subprocess, "run", fake_run)
monkeypatch.setattr(benchmark, "stale_credential_volume", lambda state: True)
benchmark.bring_up_stack({})
assert [c[-3:] for c in calls] == [
["up", "-d", "--wait"],
[str(benchmark.COMPOSE_FILES[-1]), "down", "-v"],
["up", "-d", "--wait"],
]
def test_bring_up_reraises_unrelated_failures(monkeypatch):
def fake_run(command, check=True):
raise benchmark.subprocess.CalledProcessError(1, command)
monkeypatch.setattr(benchmark.subprocess, "run", fake_run)
monkeypatch.setattr(benchmark, "stale_credential_volume", lambda state: False)
with pytest.raises(benchmark.subprocess.CalledProcessError):
benchmark.bring_up_stack({})
def test_fresh_resets_volumes_and_gui_state(tmp_path, monkeypatch):
commands = []
monkeypatch.setattr(
benchmark.subprocess, "run", lambda cmd, check=True: commands.append(cmd)
)
monkeypatch.setattr(benchmark.sys, "platform", "darwin")
monkeypatch.setattr(benchmark.Path, "home", classmethod(lambda cls: tmp_path))
gui_state = tmp_path / "Library" / "WebKit" / benchmark.GUI_BUNDLE_IDENTIFIER
gui_state.mkdir(parents=True)
(gui_state / "localstorage.sqlite3").touch()
benchmark.reset_environment()
assert ["down", "-v"] == commands[0][-2:]
assert not gui_state.exists()
assert benchmark.parse_args(["--fresh"]).fresh
assert not benchmark.parse_args([]).fresh

View File

@@ -1,72 +0,0 @@
"""Keygen and NIP-OA attestation unit tests."""
from __future__ import annotations
import hashlib
import json
import coincurve
from harbor_buzz_testbed.keys import (
compute_auth_tag,
encode_nsec,
generate_keypair,
)
# Produced by the Rust reference implementation
# (crates/buzz-sdk/examples/compute_auth_tag.rs) for owner secret 0x...03 and
# agent pubkey "a" * 64. Pins the preimage format across implementations.
RUST_OWNER_SECRET = "0" * 63 + "3"
RUST_AGENT_PUBKEY = "a" * 64
RUST_TAG = [
"auth",
"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
"",
"20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da"
"34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867",
]
def preimage_digest(agent_pubkey: str, conditions: str) -> bytes:
return hashlib.sha256(
f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode()
).digest()
def test_generate_keypair_is_fresh_and_hex():
first, second = generate_keypair(), generate_keypair()
assert first.secret_key != second.secret_key
assert first.pubkey != second.pubkey
assert len(first.secret_key) == 64
assert len(first.pubkey) == 64
int(first.secret_key, 16)
int(first.pubkey, 16)
def test_auth_tag_shape_and_owner_pubkey():
tag = json.loads(compute_auth_tag(RUST_OWNER_SECRET, RUST_AGENT_PUBKEY))
assert tag[0] == "auth"
assert tag[1] == RUST_TAG[1] # same owner pubkey as the Rust implementation
assert tag[2] == ""
def test_auth_tag_signature_verifies_over_nip_oa_preimage():
agent = generate_keypair()
tag = json.loads(compute_auth_tag(RUST_OWNER_SECRET, agent.pubkey))
owner_pubkey = coincurve.PublicKeyXOnly(bytes.fromhex(tag[1]))
assert owner_pubkey.verify(bytes.fromhex(tag[3]), preimage_digest(agent.pubkey, ""))
def test_rust_reference_tag_verifies_under_python_preimage():
"""The Rust-signed vector must verify against our preimage construction."""
owner_pubkey = coincurve.PublicKeyXOnly(bytes.fromhex(RUST_TAG[1]))
assert owner_pubkey.verify(
bytes.fromhex(RUST_TAG[3]), preimage_digest(RUST_AGENT_PUBKEY, "")
)
def test_encode_nsec_matches_nip19_vector():
# NIP-19 reference vector from the spec.
assert (
encode_nsec("67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa")
== "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"
)

View File

@@ -1,123 +0,0 @@
"""Live provisioning tests against a running benchmark stack.
Gated by BUZZ_TESTBED_LIVE=1 with stack coordinates in the environment:
BUZZ_TESTBED_RELAY_HTTP (default http://localhost:3000)
BUZZ_TESTBED_RELAY_WS (default ws://host.docker.internal:3000)
BUZZ_TESTBED_OWNER_KEY relay owner secret key (hex)
BUZZ_TESTBED_PG_DSN benchmark Postgres DSN
"""
from __future__ import annotations
import os
import uuid
import psycopg
import pytest
from harbor_buzz_testbed.buzz_cli import BuzzCli, BuzzCliError
from harbor_buzz_testbed.provisioner import (
BuzzTrialProvisioner,
ProvisioningError,
TestbedConfig,
)
pytestmark = pytest.mark.skipif(
os.environ.get("BUZZ_TESTBED_LIVE") != "1",
reason="live testbed suite; set BUZZ_TESTBED_LIVE=1 with a running stack",
)
@pytest.fixture()
def provisioner() -> BuzzTrialProvisioner:
owner_key = os.environ.get("BUZZ_TESTBED_OWNER_KEY")
dsn = os.environ.get("BUZZ_TESTBED_PG_DSN")
if not owner_key or not dsn:
pytest.fail("BUZZ_TESTBED_OWNER_KEY and BUZZ_TESTBED_PG_DSN are required")
return BuzzTrialProvisioner(
TestbedConfig(
relay_http_url=os.environ.get(
"BUZZ_TESTBED_RELAY_HTTP", "http://localhost:3000"
),
relay_ws_url=os.environ.get(
"BUZZ_TESTBED_RELAY_WS", "ws://host.docker.internal:3000"
),
owner_secret_key=owner_key,
postgres_dsn=dsn,
llm_api_keys={
"databricks/glm": "test-glm-key",
"databricks/opus": "test-opus-key",
},
)
)
def cli_for(provisioner: BuzzTrialProvisioner, credential) -> BuzzCli:
return provisioner._cli_for(credential)
def test_create_is_idempotent_and_isolated(provisioner, manifest):
run_id = f"live-{uuid.uuid4().hex[:8]}"
trial_a = str(uuid.uuid4())
trial_b = str(uuid.uuid4())
provisioner.healthcheck()
handle_a = provisioner.create_trial(run_id, trial_a, manifest)
handle_b = provisioner.create_trial(run_id, trial_b, manifest)
try:
# Shape: one credential per roster slot, orchestrator first.
assert len(handle_a.credentials) == 3
assert handle_a.credentials[0].role == "orchestrator"
assert handle_a.manifest_hash == manifest.sha256
# Idempotency: same key returns the stored handle, keys included.
again = provisioner.create_trial(run_id, trial_a, manifest)
assert again == handle_a
# Isolation: trials get distinct channels and fresh keys...
assert handle_a.channel_id != handle_b.channel_id
keys_a = {c.nostr_secret_key for c in handle_a.credentials}
keys_b = {c.nostr_secret_key for c in handle_b.credentials}
assert not keys_a & keys_b
# ...and trial B's orchestrator cannot see trial A's channel.
cli_a = cli_for(provisioner, handle_a.credentials[0])
cli_b = cli_for(provisioner, handle_b.credentials[0])
cli_a.run(
"messages",
"send",
"--channel",
handle_a.channel_id,
"--content",
"trial A secret",
)
foreign_read = cli_b.run(
"messages", "get", "--channel", handle_a.channel_id, "--limit", "10"
)
assert foreign_read == [], "cross-trial read must return nothing"
with pytest.raises(BuzzCliError, match="private"):
cli_b.run("channels", "join", "--channel", handle_a.channel_id)
# Members can read their own channel.
own_read = cli_a.run(
"messages", "get", "--channel", handle_a.channel_id, "--limit", "10"
)
assert [m["content"] for m in own_read] == ["trial A secret"]
# Same trial key + different manifest must be rejected, not silently
# reprovisioned.
altered = manifest.model_copy(update={"condition": "O-altered"})
with pytest.raises(ProvisioningError, match="already provisioned"):
provisioner.create_trial(run_id, trial_a, altered)
finally:
provisioner.teardown(handle_a)
provisioner.teardown(handle_b)
# Teardown is idempotent and stamps archived_at.
provisioner.teardown(handle_a)
with psycopg.connect(os.environ["BUZZ_TESTBED_PG_DSN"]) as conn:
row = conn.execute(
"SELECT archived_at FROM benchmark.trial_manifest"
" WHERE run_id = %s AND trial_id = %s",
(run_id, trial_a),
).fetchone()
assert row is not None and row[0] is not None

View File

@@ -1,118 +0,0 @@
"""Provisioner unit tests — no relay or Postgres required."""
from __future__ import annotations
import hashlib
import json
import coincurve
import pytest
from harbor_buzz_testbed.provisioner import (
BuzzTrialProvisioner,
ProvisioningError,
TestbedConfig,
)
OWNER_SECRET = "0" * 63 + "3"
def config(**overrides) -> TestbedConfig:
defaults = dict(
relay_http_url="http://localhost:3000",
relay_ws_url="ws://host.docker.internal:3000",
owner_secret_key=OWNER_SECRET,
postgres_dsn="postgresql://unused",
llm_api_keys={"databricks/glm": "glm-key", "databricks/opus": "opus-key"},
)
defaults.update(overrides)
return TestbedConfig(**defaults)
def test_mint_credentials_expands_roster(manifest):
credentials = BuzzTrialProvisioner(config())._mint_credentials(manifest)
assert [c.agent_id for c in credentials] == [
"orch-opus-1",
"worker-glm-1",
"worker-glm-2",
]
assert credentials[0].role == "orchestrator"
assert credentials[0].llm_api_key == "opus-key"
assert {c.llm_api_key for c in credentials[1:]} == {"glm-key"}
def test_mint_credentials_keys_are_fresh_and_attested(manifest):
provisioner = BuzzTrialProvisioner(config())
first = provisioner._mint_credentials(manifest)
second = provisioner._mint_credentials(manifest)
all_secrets = [c.nostr_secret_key for c in first + second]
assert len(all_secrets) == len(set(all_secrets)), "keys must never be reused"
owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly
for credential in first:
tag = json.loads(credential.nostr_auth_tag)
assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""]
digest = hashlib.sha256(
f"nostr:agent-auth:{credential.nostr_pubkey}:".encode()
).digest()
assert owner_pubkey.verify(bytes.fromhex(tag[3]), digest)
def test_mint_user_is_attested_and_not_an_agent():
provisioner = BuzzTrialProvisioner(config())
user = provisioner._mint_user()
assert user.agent_id == "user"
assert user.role == "user"
assert user.llm_endpoint == "" and user.llm_api_key == ""
owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly
tag = json.loads(user.nostr_auth_tag)
assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""]
def test_pinned_user_secret_reuses_one_identity():
pinned = "7" * 64
provisioner = BuzzTrialProvisioner(config(user_secret_key=pinned))
first = provisioner._mint_user()
second = provisioner._mint_user()
assert first.nostr_secret_key == pinned
assert first.nostr_pubkey == second.nostr_pubkey
expected = coincurve.PrivateKey(bytes.fromhex(pinned)).public_key_xonly
assert first.nostr_pubkey == expected.format().hex()
# Still a user, still attested by the owner.
assert first.role == "user"
owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly
assert json.loads(first.nostr_auth_tag)[1] == owner_pubkey.format().hex()
def test_teardown_skips_archiving_when_disabled():
provisioner = BuzzTrialProvisioner(config(archive_on_teardown=False))
# Any attribute access would fail on this handle — teardown must return
# before touching the CLI or Postgres.
provisioner.teardown(handle=None)
def test_mint_credentials_missing_api_key_is_explicit(manifest):
provisioner = BuzzTrialProvisioner(config(llm_api_keys={}))
with pytest.raises(ProvisioningError, match="databricks/"):
provisioner._mint_credentials(manifest)
def test_lock_key_is_deterministic_and_distinct():
calls: list[int] = []
class FakeConn:
def execute(self, _query, params):
calls.append(params[0])
BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-1")
BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-1")
BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-2")
assert calls[0] == calls[1]
assert calls[0] != calls[2]
assert all(-(2**63) <= key < 2**63 for key in calls)
def test_healthcheck_fails_fast_when_relay_down():
provisioner = BuzzTrialProvisioner(
config(relay_http_url="http://localhost:1", postgres_dsn="postgresql://unused")
)
with pytest.raises(ProvisioningError, match="relay unreachable"):
provisioner.healthcheck()

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
from typing import Any
import pytest
@pytest.fixture
def manifest_data() -> dict[str, Any]:
digest = "a" * 64
generation = {
"temperature": 0,
"max_output_tokens": 4096,
"context_window_tokens": 200000,
}
return {
"schema_version": "1",
"condition": "O",
"roster": [
{
"id": "opus-orchestrator",
"kind": "orchestrator",
"role": "orchestrator",
"count": 1,
"endpoint": "databricks/opus",
"model_revision": "2026-07-01",
"prompt": {"path": "prompts/orchestrator.md", "sha256": digest},
"generation": generation,
},
{
"id": "qwen-workers",
"kind": "worker",
"role": "fast-worker",
"count": 4,
"concurrency": 2,
"endpoint": "databricks/qwen",
"model_revision": "revision-42",
"prompt": {"path": "prompts/qwen.md", "sha256": digest},
"generation": {**generation, "context_window_tokens": 32768},
},
],
"prices": {
"databricks/opus": {
"input_per_million_usd": 5,
"cached_input_per_million_usd": 0.5,
"output_per_million_usd": 25,
},
"databricks/qwen": {
"input_per_million_usd": 0.1,
"cached_input_per_million_usd": 0.01,
"output_per_million_usd": 0.2,
},
},
"trial_budget": {"timeout_seconds": 3600, "max_cost_usd": 5},
}

View File

@@ -1,169 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from harbor.models.agent.context import AgentContext
from harbor_buzz_orchestra import (
AgentCredential,
BuzzOrchestraAgent,
RuntimeResult,
TrialHandle,
)
pytestmark = pytest.mark.asyncio
async def test_agent_credential_carries_closed_relay_attestation():
credential = AgentCredential(
agent_id="orchestrator-1",
role="orchestrator",
nostr_secret_key="11" * 32,
nostr_pubkey="22" * 32,
nostr_auth_tag='["auth","owner","conditions","signature"]',
llm_endpoint="https://example.databricks.com/serving-endpoints/opus",
llm_api_key="attributed-key",
)
assert credential.nostr_auth_tag.startswith('["auth"')
class Provisioner:
def __init__(self):
self.healthchecked = False
self.created = None
self.torn_down = None
def healthcheck(self):
self.healthchecked = True
def create_trial(self, run_id, trial_id, manifest, channel_label=None):
self.created = (run_id, trial_id, manifest, channel_label)
return TrialHandle(
run_id,
trial_id,
manifest.sha256,
"ws://relay",
"channel-1",
(),
user=AgentCredential(
agent_id="user",
role="user",
nostr_secret_key="s",
nostr_pubkey="p",
nostr_auth_tag="[]",
llm_endpoint="",
llm_api_key="",
),
)
def teardown(self, handle):
self.torn_down = handle
class Runtime:
def __init__(self, error=None):
self.called = None
self.error = error
async def run(self, **kwargs):
self.called = kwargs
if self.error:
raise self.error
return RuntimeResult(10, 2, 3, 0.25, {"receipt_status": "pending"})
async def test_agent_lifecycle_and_context(tmp_path, manifest_data):
provisioner, runtime, context_id = Provisioner(), Runtime(), uuid4()
environment = SimpleNamespace(
context_id=context_id, environment_name="hello-world"
)
agent = BuzzOrchestraAgent(
logs_dir=tmp_path,
manifest=manifest_data,
provisioner=provisioner,
runtime=runtime,
run_id="run-1",
)
agent.context_id = context_id
context = AgentContext()
await agent.setup(environment)
await agent.run("solve it", environment, context)
assert provisioner.healthchecked
assert provisioner.created[:2] == ("run-1", str(context_id))
# The task short name labels the trial channel for spectator GUIs.
assert provisioner.created[3] == "hello-world"
assert provisioner.torn_down.channel_id == "channel-1"
assert runtime.called["instruction"] == "solve it"
assert (
context.n_input_tokens,
context.n_cache_tokens,
context.n_output_tokens,
context.cost_usd,
) == (10, 2, 3, 0.25)
assert context.metadata["manifest_sha256"] == agent.manifest.sha256
assert context.metadata["trial_id"] == str(context_id)
async def test_teardown_runs_when_runtime_fails(tmp_path, manifest_data):
provisioner, runtime, context_id = (
Provisioner(),
Runtime(RuntimeError("runtime failed")),
uuid4(),
)
environment = SimpleNamespace(context_id=context_id)
agent = BuzzOrchestraAgent(
logs_dir=tmp_path,
manifest=manifest_data,
provisioner=provisioner,
runtime=runtime,
)
agent.context_id = context_id
with pytest.raises(RuntimeError, match="runtime failed"):
await agent.run("solve it", environment, AgentContext())
assert provisioner.torn_down.channel_id == "channel-1"
async def test_missing_integrations_fail_explicitly(tmp_path, manifest_data):
agent = BuzzOrchestraAgent(logs_dir=tmp_path, manifest=manifest_data)
with pytest.raises(RuntimeError, match="M1 wiring is incomplete"):
await agent.run("solve it", SimpleNamespace(context_id=uuid4()), AgentContext())
async def test_cli_runtime_construction_from_json(tmp_path, manifest_data):
endpoint_path = tmp_path / "endpoints.json"
endpoint_path.write_text(
'{"frontier/rev":{"provider":"anthropic",'
'"api_key_env":"ANTHROPIC_API_KEY"},'
'"worker/rev":{"provider":"openai",'
'"api_key_env":"OPENAI_API_KEY"}}'
)
agent = BuzzOrchestraAgent(
logs_dir=tmp_path / "logs",
manifest=manifest_data,
artifact_root=tmp_path,
endpoint_config=endpoint_path,
buzz_acp_binary="/pinned/buzz-acp",
buzz_agent_binary="/pinned/buzz-agent",
buzz_dev_mcp_binary="/pinned/buzz-dev-mcp",
buzz_cli_binary="/pinned/buzz",
)
assert agent.runtime.artifact_root == tmp_path
assert agent.runtime.endpoints["frontier/rev"].provider == "anthropic"
assert agent.runtime.buzz_acp_binary == "/pinned/buzz-acp"
assert agent.runtime.buzz_agent_binary == "/pinned/buzz-agent"
assert agent.runtime.buzz_dev_mcp_binary == "/pinned/buzz-dev-mcp"
assert agent.runtime.buzz_cli_binary == "/pinned/buzz"
async def test_cli_construction_requires_complete_pairs(tmp_path, manifest_data):
with pytest.raises(ValueError, match="artifact_root"):
BuzzOrchestraAgent(
logs_dir=tmp_path,
manifest=manifest_data,
endpoint_config={},
)
with pytest.raises(ValueError, match="provisioner_factory"):
BuzzOrchestraAgent(
logs_dir=tmp_path,
manifest=manifest_data,
provisioner_config={},
)

View File

@@ -1,426 +0,0 @@
"""The container runtime must launch the production stack, unmodified."""
import hashlib
import json
import re
from pathlib import Path
import pytest
from harbor.environments.base import ExecResult
from harbor_buzz_orchestra.manifest import ExperimentManifest
from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
from harbor_buzz_orchestra.container_runtime import (
REMOTE_BIN,
REMOTE_LOGS,
BuzzContainerRuntime,
EndpointLaunchConfig,
RuntimeLaunchError,
)
def write_manifest(tmp_path: Path) -> ExperimentManifest:
prompt = tmp_path / "prompt.md"
prompt.write_text("prompt", encoding="utf-8")
digest = hashlib.sha256(prompt.read_bytes()).hexdigest()
roster_entry = {
"count": 1,
"model_revision": "r1",
"prompt": {"path": "prompt.md", "sha256": digest},
"generation": {"max_output_tokens": 100, "context_window_tokens": 1000},
}
return ExperimentManifest.load(
{
"condition": "test",
"roster": [
{"id": "orch", "kind": "orchestrator", "role": "lead",
"endpoint": "orch-model", **roster_entry},
{"id": "worker", "kind": "worker", "role": "implementer",
"endpoint": "worker-model", **roster_entry},
],
"prices": {
name: {
"input_per_million_usd": 0,
"cached_input_per_million_usd": 0,
"output_per_million_usd": 0,
}
for name in ("orch-model", "worker-model")
},
"trial_budget": {"timeout_seconds": 30},
}
)
def credential(agent_id, role, endpoint):
return AgentCredential(
agent_id=agent_id,
role=role,
nostr_secret_key=f"secret-{agent_id}",
nostr_pubkey=f"pubkey-{agent_id}",
nostr_auth_tag="[]",
llm_endpoint=endpoint,
llm_api_key=f"key-{agent_id}",
)
def user_credential():
return AgentCredential(
agent_id="user",
role="user",
nostr_secret_key="secret-user",
nostr_pubkey="pubkey-user",
nostr_auth_tag="[]",
llm_endpoint="",
llm_api_key="",
)
def trial_handle(credentials, user_relay_url=""):
return TrialHandle(
run_id="run",
trial_id="trial",
manifest_hash="hash",
relay_ws_url="ws://host.docker.internal:3600",
channel_id="channel",
credentials=credentials,
user=user_credential(),
user_relay_url=user_relay_url,
)
def runtime(tmp_path, **kwargs):
return BuzzContainerRuntime(
logs_dir=tmp_path / "logs",
artifact_root=tmp_path,
endpoints={
"orch-model": EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"),
"worker-model": EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"),
},
**kwargs,
)
class Environment:
"""Records execs/uploads; scripted stdout per command substring."""
def __init__(self, responses=None):
self.commands = []
self.uploads = []
self.responses = responses or {}
async def exec(self, command, env=None, **kwargs):
self.commands.append((command, env))
for needle, result in self.responses.items():
if needle in command:
return result
return ExecResult(stdout="", stderr="", return_code=0)
async def upload_file(self, source, target):
self.uploads.append((str(source), target))
async def download_dir(self, source, target):
pass
def test_maps_credentials_exactly_and_rejects_role_mismatch(tmp_path):
manifest = write_manifest(tmp_path)
credentials = (
credential("orch-1", "orchestrator", "orch-model"),
credential("worker-1", "worker", "worker-model"),
)
assert set(runtime(tmp_path)._classes_by_agent_id(manifest, credentials)) == {
"orch-1",
"worker-1",
}
bad = (credential("worker-1", "orchestrator", "worker-model"),)
with pytest.raises(RuntimeLaunchError, match="role"):
runtime(tmp_path)._classes_by_agent_id(manifest, bad)
def test_prompt_hash_and_identity_override_are_fail_closed(tmp_path):
manifest = write_manifest(tmp_path)
prompt_ref = manifest.roster[0].prompt
runtime(tmp_path)._verify_artifact(tmp_path / prompt_ref.path, prompt_ref.sha256)
(tmp_path / prompt_ref.path).write_text("changed", encoding="utf-8")
with pytest.raises(RuntimeLaunchError, match="hash mismatch"):
runtime(tmp_path)._verify_artifact(
tmp_path / prompt_ref.path, prompt_ref.sha256
)
endpoint = EndpointLaunchConfig(
"anthropic", "ANTHROPIC_API_KEY", {"BUZZ_ACP_MCP_COMMAND": "evil"}
)
with pytest.raises(RuntimeLaunchError, match="identity"):
runtime(tmp_path)._reject_identity_overrides(endpoint)
def test_user_relay_url_prefers_host_view(tmp_path):
rt = runtime(tmp_path)
# v1.2 handles carry the host view for the trial user explicitly.
assert (
rt._user_relay_url(trial_handle((), user_relay_url="http://localhost:3600"))
== "http://localhost:3600"
)
# pre-v1.2 handles fall back to deriving http from the agents' ws view.
assert (
rt._user_relay_url(trial_handle(()))
== "http://host.docker.internal:3600"
)
with pytest.raises(RuntimeLaunchError, match="ws://"):
rt._cli_relay_url("http://relay")
async def test_install_stack_uploads_the_pinned_stack(tmp_path):
binaries = {}
for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp"):
path = tmp_path / name
path.write_text("#!binary")
binaries[name] = str(path)
rt = runtime(
tmp_path,
buzz_acp_binary=binaries["buzz-acp"],
buzz_agent_binary=binaries["buzz-agent"],
buzz_dev_mcp_binary=binaries["buzz-dev-mcp"],
)
environment = Environment()
await rt._install_stack(environment)
assert {target for _, target in environment.uploads} == {
f"{REMOTE_BIN}/buzz-acp",
f"{REMOTE_BIN}/buzz-agent",
f"{REMOTE_BIN}/buzz-dev-mcp",
}
assert any("chmod 0755" in cmd for cmd, _ in environment.commands)
async def test_install_stack_requires_binaries_on_disk(tmp_path):
rt = runtime(tmp_path, buzz_acp_binary=str(tmp_path / "missing"))
with pytest.raises(RuntimeLaunchError, match="binary not found"):
await rt._install_stack(Environment())
async def test_forwarder_bridges_the_canonical_relay_address(tmp_path):
from harbor_buzz_orchestra.container_runtime import FORWARDER
forwarder = tmp_path / "relay-forwarder"
forwarder.write_text("ELF")
rt = runtime(
tmp_path,
relay_gateway="host.docker.internal:3600",
forwarder_binary=str(forwarder),
)
trial = TrialHandle(
run_id="run", trial_id="trial", manifest_hash="hash",
relay_ws_url="ws://localhost:3600", channel_id="channel",
credentials=(), user=user_credential(),
)
environment = Environment(
responses={
FORWARDER: ExecResult(stdout="99\n", stderr="", return_code=0),
"cat ": ExecResult(
stdout="forwarding 127.0.0.1:3600 -> host.docker.internal:3600",
stderr="", return_code=0,
),
}
)
agent = await rt._start_forwarder(environment, trial)
assert agent is not None and agent.pid == 99
launch = next(cmd for cmd, _ in environment.commands if FORWARDER in cmd)
# Listens on the canonical loopback (host-header bound), targets the gateway.
assert "127.0.0.1:3600" in launch
assert "host.docker.internal:3600" in launch
# No gateway configured: the relay is reachable directly, no forwarder.
assert await runtime(tmp_path)._start_forwarder(Environment(), trial) is None
with pytest.raises(RuntimeLaunchError, match="ws://"):
rt._ws_authority("http://relay")
@pytest.mark.parametrize(("configured", "expected"), [(None, "32"), (7, "7")])
async def test_launch_wires_the_desktop_environment(tmp_path, configured, expected):
manifest = write_manifest(tmp_path)
agent_class = manifest.roster[0]
if configured is not None:
agent_class = agent_class.model_copy(
update={
"budget": agent_class.budget.model_copy(
update={"max_calls": configured}
)
}
)
orch = credential("orch-1", "orchestrator", "orch-model")
trial = trial_handle((orch,))
environment = Environment(
responses={"buzz-acp": ExecResult(stdout="4242\n", stderr="", return_code=0)}
)
agent = await runtime(tmp_path)._launch_agent(
environment=environment,
trial=trial,
credential=orch,
agent_class=agent_class,
trial_dir=tmp_path,
)
assert agent.pid == 4242
command, env = environment.commands[-1]
assert f"{REMOTE_BIN}/buzz-acp" in command
# The real product wiring: acp spawns buzz-agent, which gets buzz-dev-mcp.
assert env["BUZZ_ACP_AGENT_COMMAND"] == f"{REMOTE_BIN}/buzz-agent"
assert env["BUZZ_ACP_MCP_COMMAND"] == f"{REMOTE_BIN}/buzz-dev-mcp"
assert env["BUZZ_RELAY_URL"] == trial.relay_ws_url
assert env["BUZZ_PRIVATE_KEY"] == orch.nostr_secret_key
assert env["NOSTR_PRIVATE_KEY"] == orch.nostr_secret_key
assert env["BUZZ_AGENT_NO_HINTS"] == "1"
assert env["BUZZ_AGENT_MAX_ROUNDS"] == expected
assert env["BUZZ_ACP_SYSTEM_PROMPT_FILE"].endswith("orch-1.system-prompt.md")
# The composed prompt was uploaded into the container.
assert any(
target == env["BUZZ_ACP_SYSTEM_PROMPT_FILE"]
for _, target in environment.uploads
)
def test_runtime_rejects_unbounded_agent_rounds(tmp_path):
with pytest.raises(ValueError, match="positive"):
runtime(tmp_path, max_agent_rounds=0)
with pytest.raises(ValueError, match="positive"):
runtime(tmp_path, readiness_timeout_seconds=0)
async def test_wait_for_agents_ready_requires_every_channel_subscription(tmp_path):
rt = runtime(tmp_path, poll_seconds=0)
logs = {"orch-1": "", "worker-1": ""}
class ReadyEnvironment(Environment):
polls = 0
async def exec(self, command, env=None, **kwargs):
if command.startswith("cat "):
agent_id = re.search(r"([\w-]+)\.stdout\.log", command).group(1)
return ExecResult(
stdout=logs[agent_id], stderr="", return_code=0
)
return ExecResult(stdout="", stderr="", return_code=0)
from harbor_buzz_orchestra.container_runtime import _Agent
agents = [
_Agent(
credential(agent_id, "worker", "worker-model"),
pid=1,
stdout_log=f"{REMOTE_LOGS}/{agent_id}.stdout.log",
stderr_log=f"{REMOTE_LOGS}/{agent_id}.stderr.log",
)
for agent_id in logs
]
logs["orch-1"] = "subscribed to channel trial-channel\n"
logs["worker-1"] = "subscribed to channel trial-channel\n"
await rt._wait_for_agents_ready(ReadyEnvironment(), agents, "trial-channel")
logs["worker-1"] = ""
rt_timeout = runtime(tmp_path, poll_seconds=0, readiness_timeout_seconds=0.01)
with pytest.raises(RuntimeLaunchError, match="worker-1"):
await rt_timeout._wait_for_agents_ready(
ReadyEnvironment(), agents, "trial-channel"
)
async def test_dead_agent_processes_fail_the_trial(tmp_path):
from harbor_buzz_orchestra.container_runtime import _Agent
agents = [
_Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e")
]
environment = Environment(
responses={
"kill -0": ExecResult(stdout="DEAD:worker-1\n", stderr="", return_code=0)
}
)
with pytest.raises(RuntimeLaunchError, match="worker-1"):
await runtime(tmp_path)._raise_for_dead_agents(environment, agents)
@pytest.mark.parametrize(
("condition", "return_code", "raises"),
[
("M1-hello-world", 0, False),
("M1-hello-world", 1, True),
("other", 1, False),
],
)
async def test_m1_output_probe_matches_grader_and_is_condition_scoped(
tmp_path, condition, return_code, raises
):
manifest = write_manifest(tmp_path).model_copy(update={"condition": condition})
environment = Environment(
responses={
"hello.txt": ExecResult(stdout="", stderr="", return_code=return_code)
}
)
if raises:
with pytest.raises(RuntimeLaunchError, match="/app/hello.txt"):
await runtime(tmp_path)._verify_m1_output(environment, manifest)
else:
await runtime(tmp_path)._verify_m1_output(environment, manifest)
probed = [cmd for cmd, _ in environment.commands if "hello.txt" in cmd]
assert bool(probed) == (condition == "M1-hello-world")
async def test_wait_for_done_requires_orchestrator_authorship(tmp_path, monkeypatch):
rt = runtime(tmp_path, poll_seconds=0)
orch = credential("orch-1", "orchestrator", "orch-model")
trial = trial_handle((orch,))
rounds = iter(
[
[{"id": "1", "pubkey": "someone-else", "content": "DONE: fake"}],
[{"id": "2", "pubkey": orch.nostr_pubkey, "content": "DONE: real"}],
]
)
observers = []
async def buzz_json(credential, *args, **kwargs):
observers.append(credential.agent_id)
return next(rounds)
monkeypatch.setattr(rt, "_buzz_json", buzz_json)
result = await rt._wait_for_done(Environment(), orch, trial, [])
assert json.dumps(result).find("real") > 0
# observation happens as the trial user, never as an agent identity
assert set(observers) == {"user"}
def test_composed_system_prompt_carries_persona_and_team_roster(tmp_path):
rt = runtime(tmp_path)
orch = credential("orch-1", "orchestrator", "orch-model")
worker_1 = credential("worker-1", "worker", "worker-model")
worker_2 = credential("worker-2", "worker", "worker-model")
trial = trial_handle((orch, worker_1, worker_2))
persona = tmp_path / "persona.md"
persona.write_text("# Persona body\n", encoding="utf-8")
path = rt._compose_system_prompt(
trial_dir=tmp_path,
trial=trial,
credential=orch,
persona_path=persona,
)
composed = path.read_text(encoding="utf-8")
assert composed.startswith("# Persona body\n")
assert "You are `orch-1` (pubkey `pubkey-orch-1`)" in composed
assert f"channel `{trial.channel_id}`" in composed
assert "user `user` (pubkey `pubkey-user`)" in composed
# roster lists teammates, never the agent itself
assert "| worker-1 | worker | `pubkey-worker-1` |" in composed
assert "| worker-2 | worker | `pubkey-worker-2` |" in composed
assert "| orch-1 " not in composed
assert path.stat().st_mode & 0o777 == 0o600
async def test_stop_agents_sweeps_the_uploaded_stack(tmp_path):
from harbor_buzz_orchestra.container_runtime import _Agent
environment = Environment()
agents = [_Agent(credential("orch-1", "orchestrator", "orch-model"), 1, "o", "e")]
await BuzzContainerRuntime._stop_agents(environment, agents)
sweeps = [cmd for cmd, _ in environment.commands if REMOTE_BIN in cmd]
assert len(sweeps) == 2
assert "kill -TERM" in sweeps[0] and "kill -KILL" in sweeps[1]

View File

@@ -1,47 +0,0 @@
import copy
import pytest
import yaml
from harbor_buzz_orchestra import ExperimentManifest, ManifestError
def test_hash_is_independent_of_mapping_and_yaml_key_order(tmp_path, manifest_data):
first = ExperimentManifest.load(manifest_data)
path = tmp_path / "manifest.yaml"
path.write_text(
yaml.safe_dump(
dict(reversed(list(copy.deepcopy(manifest_data).items()))), sort_keys=False
)
)
second = ExperimentManifest.load(path)
assert first.canonical_bytes() == second.canonical_bytes()
assert first.sha256 == second.sha256
assert len(first.sha256) == 64
def test_hash_changes_when_staffing_changes(manifest_data):
first = ExperimentManifest.load(manifest_data)
changed = copy.deepcopy(manifest_data)
changed["roster"][1]["count"] = 3
assert ExperimentManifest.load(changed).sha256 != first.sha256
@pytest.mark.parametrize(
("mutation", "match"),
[
(lambda data: data.update({"unknown": True}), "Extra inputs"),
(lambda data: data["roster"].pop(0), "exactly one orchestrator"),
(lambda data: data["prices"].pop("databricks/qwen"), "prices missing"),
(lambda data: data["roster"][1].update({"concurrency": 5}), "concurrency"),
],
)
def test_invalid_manifest_is_rejected(manifest_data, mutation, match):
mutation(manifest_data)
with pytest.raises(ManifestError, match=match):
ExperimentManifest.load(manifest_data)
def test_non_mapping_document_is_rejected(tmp_path):
path = tmp_path / "manifest.yaml"
path.write_text("- not\n- a\n- mapping\n")
with pytest.raises(ManifestError, match="root must be a mapping"):
ExperimentManifest.load(path)

View File

@@ -1,125 +0,0 @@
"""The leaderboard runner must emit only leaderboard-legal harbor settings."""
import importlib.util
import json
import sys
from pathlib import Path
import pytest
import yaml
_SCRIPT = Path(__file__).parent.parent / "scripts" / "run_leaderboard.py"
_spec = importlib.util.spec_from_file_location("run_leaderboard", _SCRIPT)
run_leaderboard = importlib.util.module_from_spec(_spec)
sys.modules["run_leaderboard"] = run_leaderboard
_spec.loader.exec_module(run_leaderboard)
FORBIDDEN_FLAGS = (
"--timeout-multiplier",
"--agent-timeout-multiplier",
"--verifier-timeout-multiplier",
"--agent-setup-timeout-multiplier",
"--environment-build-timeout-multiplier",
"--override-cpus",
"--override-memory",
"--override-storage",
"--override-gpus",
)
@pytest.fixture
def binaries(tmp_path):
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
found = {}
for name in run_leaderboard.BINARIES:
path = bin_dir / name
path.write_text("#!/bin/sh\n")
found[name] = path
return found
@pytest.fixture
def agent_binaries(tmp_path):
bin_dir = tmp_path / "linux-bin"
bin_dir.mkdir()
for name in run_leaderboard.AGENT_BINARIES:
(bin_dir / name).write_text("ELF")
return run_leaderboard.find_agent_binaries(bin_dir)
@pytest.fixture
def args(tmp_path, binaries, agent_binaries):
manifest = tmp_path / "team.yaml"
manifest.write_text(
yaml.safe_dump(
{
"condition": "unit-test",
"roster": [
{"id": "orch", "endpoint": "frontier"},
{"id": "worker", "endpoint": "fast", "count": 2},
],
}
)
)
endpoints = tmp_path / "endpoints.json"
endpoints.write_text(json.dumps({"frontier": {"provider": "anthropic"}}))
provisioner = tmp_path / "provisioner.json"
provisioner.write_text("{}")
return run_leaderboard.parse_args(
[
"--dataset",
"terminal-bench/terminal-bench-2-1",
"--attempts",
"5",
"--manifest",
str(manifest),
"--endpoint-config",
str(endpoints),
"--provisioner-config",
str(provisioner),
"--agent-bin-dir",
str(next(iter(agent_binaries.values())).parent),
"--job-name",
"unit-test-job",
]
)
def test_command_uses_standard_settings_only(args, binaries, agent_binaries):
command = run_leaderboard.build_command(args, binaries, agent_binaries)
assert command[:2] == ["harbor", "run"]
assert command.count("-k") == 1
assert command[command.index("-k") + 1] == "5"
for flag in FORBIDDEN_FLAGS:
assert flag not in command
# The full production stack rides in as agent kwargs.
kwargs = [command[i + 1] for i, p in enumerate(command) if p == "--agent-kwarg"]
assert any(k.startswith("buzz_acp_binary=") for k in kwargs)
assert any(k.startswith("buzz_agent_binary=") for k in kwargs)
assert any(k.startswith("buzz_dev_mcp_binary=") for k in kwargs)
def test_agent_binaries_must_exist(tmp_path):
with pytest.raises(SystemExit, match="buzz-dev-mcp"):
run_leaderboard.find_agent_binaries(tmp_path)
def test_forbidden_flags_are_not_accepted(tmp_path):
for flag in FORBIDDEN_FLAGS:
with pytest.raises(SystemExit):
run_leaderboard.parse_args(
["--dataset", "d", "--attempts", "5",
"--agent-bin-dir", str(tmp_path), flag, "1"]
)
def test_metadata_template_matches_harbor_schema(args, tmp_path):
from harbor.leaderboard.metadata import load_metadata
path = run_leaderboard.write_metadata_template(args, tmp_path)
loaded = load_metadata(path)
assert loaded["agent_org_display_name"] == "Block"
assert [m["model_name"] for m in loaded["models"]] == ["frontier", "fast"]
assert loaded["models"][0]["model_org_display_name"] == "Anthropic"
assert loaded["models"][1]["model_provider"] == "FILL_ME"

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1 +0,0 @@
hermit

View File

@@ -1,7 +0,0 @@
# Hermit environment
This is a [Hermit](https://github.com/cashapp/hermit) bin directory.
The symlinks in this directory are managed by Hermit and will automatically
download and install Hermit itself as well as packages. These packages are
local to this environment.

Some files were not shown because too many files have changed in this diff Show More