feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
[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"
+54
View File
@@ -0,0 +1,54 @@
# 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
+252
View File
@@ -0,0 +1,252 @@
# =============================================================================
# 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
# Optional read-replica URL; unset/blank keeps all reads on the writer.
# READ_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5433/buzz
PGHOST=localhost
PGPORT=5432
PGUSER=buzz
PGPASSWORD=buzz_dev
PGDATABASE=buzz
# -----------------------------------------------------------------------------
# Redis 7
# -----------------------------------------------------------------------------
REDIS_URL=redis://localhost:6379
# Max connections in the relay's shared Redis pool (default 16).
# BUZZ_REDIS_POOL_SIZE=16
# Max connections in each of the relay's Postgres pools — writer and, when
# READ_DATABASE_URL is set, reader (default 50).
# BUZZ_DB_POOL_SIZE=50
# -----------------------------------------------------------------------------
# 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
# Shared Redis-backed admission limits. Defaults shown below; each value must
# be a positive integer.
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
# BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300
# BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10
# BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120
# BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN=600
# BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300
# BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600
# -----------------------------------------------------------------------------
# Git (NIP-34 bare repositories)
# -----------------------------------------------------------------------------
# Root directory for ephemeral Git workspaces and the disposable pack cache.
# Default: ./repos (relative to CWD).
# BUZZ_GIT_REPO_PATH=./repos
# BUZZ_GIT_MAX_PACK_BYTES=524288000
# BUZZ_GIT_MAX_REPO_BYTES=1048576000
# Process-local immutable pack/index cache. Zero disables retention.
# BUZZ_GIT_PACK_CACHE_PATH=./repos/.pack-cache
# BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120
# BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2
# -----------------------------------------------------------------------------
# S3-Compatible Object Storage (media + Git/CAS)
# -----------------------------------------------------------------------------
# The local MinIO container is reachable from host processes at localhost:9000.
# Path style keeps the bucket in the URL path and is required by this local DNS
# setup. Use `virtual` only when the provider requires bucket-as-subdomain URLs.
BUZZ_S3_ENDPOINT=http://localhost:9000
BUZZ_S3_ACCESS_KEY=buzz_dev
BUZZ_S3_SECRET_KEY=buzz_dev_secret
BUZZ_S3_BUCKET=buzz-media
BUZZ_S3_REGION=us-east-1
BUZZ_S3_ADDRESSING_STYLE=path
# -----------------------------------------------------------------------------
# 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
# GET/HEAD /media/* always require Blossom t=get auth and relay membership.
# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer
# read; setting either (including to false) changes nothing and the relay warns
# about it at startup.
# -----------------------------------------------------------------------------
# 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_datastore=info,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug
# Optional OpenTelemetry-only target filter. This is deliberately independent
# from RUST_LOG so log verbosity changes cannot break trace parentage.
# BUZZ_OTEL_FILTER=buzz_relay=info,buzz_datastore=info
# 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 (132).
# 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=
# ── Desktop development ──────────────────────────────────────────────────────
# DEV-only: replay first-run onboarding and the Welcome Team kickoff on each
# app launch while keeping the current identity and relay data.
# VITE_BUZZ_FORCE_FRESH_ONBOARDING=true
# ── 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 (0100). 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
# Optional relay join policy. Markdown is served by the relay so every join
# surface can present the same documents. Each document and the independent age
# attestation are optional; configuring any one enables policy acceptance.
# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
# BUZZ_AGE_ATTESTATION_REQUIRED=true
+8
View File
@@ -0,0 +1,8 @@
# Git for Windows defaults to core.autocrlf=true, so without this every text
# file lands in the working copy with CRLF. Biome formats with LF
# (biome.json sets no lineEnding override), which fails `biome check` on
# effectively every file, and
# desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs asserts on
# patches/*.patch with `\n`-joined patterns. Normalize to LF in the working
# copy on every platform; the stored blobs are already LF.
* text=auto eol=lf
+1
View File
@@ -0,0 +1 @@
* @block/buzz-oss-team
+24
View File
@@ -0,0 +1,24 @@
---
name: Bug report
about: Report a reproducible bug in Buzz
labels: bug
---
**Describe the bug**
A clear and concise description of what the bug is.
**Steps to reproduce**
1. Go to '...'
2. Click on '...'
3. See error
**Expected behavior**
What did you expect to happen?
**Version and platform**
Find your version at the bottom of the Settings sidebar. Write "unknown" if you can't determine it.
- Buzz version:
- OS:
**Logs / additional context**
Paste any relevant logs, error messages, or screenshots here.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+21
View File
@@ -0,0 +1,21 @@
---
name: Feature request
about: Propose a new feature or improvement
labels: enhancement
---
**Motivation**
What problem does this solve? Who runs into it and when?
**Proposed solution**
Describe what you'd like to see.
**Alternatives considered**
What other approaches did you consider?
**Additional context**
Anything else that helps — links, screenshots, prior art.
---
Before opening: please [search open issues and PRs](https://github.com/block/buzz/issues?q=is%3Aopen) for duplicates — link the closest one, or say "none found".
+8
View File
@@ -0,0 +1,8 @@
## Summary
<!-- What does this change and why? -->
### Related issue
<!-- Fixes #1234, or N/A. Before opening: search existing issues/PRs for duplicates — link the closest one, or say "none found". -->
### Testing
<!-- How was this verified? UI change? Include before/after screenshots (or a short recording). -->
@@ -0,0 +1,163 @@
name: Auto-tag on Release PR Merge
# Four PR-driven release lanes share this workflow. Each uses 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 desktop-v<v> → release.yml (desktop app)
# relay-release/<v> → tag relay-v<v> → docker.yml (relay image)
# chart-release/<v> → tag chart-v<v> → helm-chart.yml (main helm chart)
# push-chart-release/<v> → tag push-chart-v<v> → push-gateway-helm-chart.yml
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
# → tag chart-v<v> → helm-chart.yml (helm chart)
#
# Mobile candidate tags do not come from merged PRs. Operators create immutable
# mobile-v<v>-rc.N tags directly from remote main with scripts/mobile-release.sh,
# then hand the exact tag to buzz-releases.
#
# Release tags are created with a short-lived token from the dedicated
# buzz-release-bot GitHub App. GitHub attributes the ref creation to that
# App, so the consumer's `on.push.tags` trigger runs normally. The workflow's
# default GITHUB_TOKEN remains read-only and is never used to create a tag.
#
# Mobile is manual-only by infosec necessity: OSS `block/buzz` CI must
# not trigger CI in the private `buzz-releases` repo. A human feeds the exact
# mobile candidate tag to the private Buildkite pipeline, which builds and
# ships mobile.
on:
pull_request:
types: [closed]
branches: [main]
permissions:
contents: read
jobs:
auto-tag:
permissions:
contents: read
pull-requests: read
checks: read
statuses: read
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.0.3
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
persist-credentials: false
- 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="desktop-v" ;;
relay-release/*)
VERSION="${BRANCH#relay-release/}"
TAG_PREFIX="relay-v" ;;
chart-release/*)
VERSION="${BRANCH#chart-release/}"
TAG_PREFIX="chart-v" ;;
push-chart-release/*)
VERSION="${BRANCH#push-chart-release/}"
TAG_PREFIX="push-chart-v" ;;
*)
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" ;;
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 "tag=${TAG_PREFIX}${VERSION}"
if [[ "$TAG_PREFIX" == desktop-v ]]; then
echo "target_sha=${{ github.event.pull_request.head.sha }}"
echo "desktop=true"
else
echo "target_sha=$GITHUB_SHA"
echo "desktop=false"
fi
} >> "$GITHUB_OUTPUT"
echo "Tagging ${TAG_PREFIX}${VERSION}"
- name: Verify immutable reviewed desktop candidate
if: steps.release.outputs.desktop == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.release.outputs.tag }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGED_AT: ${{ github.event.pull_request.merged_at }}
run: |
VERSION="${VERSION#desktop-v}"
export VERSION
scripts/verify-desktop-release-merge.sh
- name: Create release tagger token
if: steps.release.outputs.enabled == 'true'
id: release-tagger
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
permission-contents: write
- name: Create and push tag
if: steps.release.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
TARGET_SHA: ${{ steps.release.outputs.target_sha }}
run: |
set -euo pipefail
# Check gh's exit status, not its output. A missing ref returns a 404
# JSON body on stdout, which must not be mistaken for an existing tag.
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation"
exit 0
else
echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)"
exit 1
fi
fi
if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$TAG" \
-f sha="$TARGET_SHA" \
--silent; then
# Ref creation is atomic. A concurrent retry may have won the race;
# accept that only when it created the exact immutable ref.
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
echo "Tag $TAG was concurrently created at $TARGET_SHA"
exit 0
fi
echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)"
exit 1
fi
+39
View File
@@ -0,0 +1,39 @@
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.0.3
- 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 .
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
name: Desktop release cache tag-scope proof
# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all
# four canaries. Every job restores only and requires an exact cache hit.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
macos:
name: Prove macOS ${{ matrix.target }} cache visibility
if: github.repository == 'block/buzz'
runs-on: macos-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
features: mesh-llm
- target: x86_64-apple-darwin
features: default
steps:
- name: Require cache proof tag
run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }'
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Patch proof dependency graph
run: |
cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
- name: Compute exact release cache key
id: rust_cache_key
env:
CACHE_TARGET: ${{ matrix.target }}
CACHE_FEATURES: ${{ matrix.features }}
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
- name: Restore exact default-branch cache from tag
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Require exact cache hit
env:
CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
linux:
name: Prove Linux cache visibility
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
timeout-minutes: 15
defaults:
run:
shell: bash
steps:
- name: Require cache proof tag and install release native tools
run: |
[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }
apt-get update
apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Patch proof dependency graph
run: |
cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT"
- name: Compute exact release cache key
id: rust_cache_key
env:
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
- name: Restore exact default-branch cache from tag
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Require exact cache hit
env:
CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
windows:
name: Prove Windows cache visibility
if: github.repository == 'block/buzz'
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Require cache proof tag
shell: bash
run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }'
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Patch proof dependency graph
shell: bash
run: |
cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
shell: bash
run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT"
- name: Compute exact release cache key
id: rust_cache_key
shell: bash
env:
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
- name: Restore exact default-branch cache from tag
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Require exact cache hit
shell: bash
env:
CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
@@ -0,0 +1,28 @@
name: Desktop Release Candidate
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: read
jobs:
validate:
name: Desktop Release Candidate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- name: Validate immutable desktop candidate
if: startsWith(github.event.pull_request.head.ref, 'version-bump/')
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.event.pull_request.head.ref }}
run: |
VERSION="${VERSION#version-bump/}"
scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY"
+495
View File
@@ -0,0 +1,495 @@
name: Docker image
# Builds and publishes the public Buzz relay images as ghcr.io/block/buzz.
# Normal tags contain stripped binaries; matching debug-* tags contain the same
# optimized binaries with line-table debug information for native profilers.
#
# 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>
# + :debug-main + :debug-sha-<7>
# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major}
# + matching :debug-* tags
# (+ :latest/:debug-latest for stable releases)
# - pull_request → build only (no push), cache stays warm
# - workflow_dispatch → manual relay-tag rescue at the tag itself
#
# Why workflow_dispatch carries a version input:
# Normal releases arrive through the push:tags trigger above. The input is
# retained only for an operator to rerun publication manually at an immutable
# relay tag. The workflow rejects a dispatch whose github.ref, checked-out
# HEAD, and relay-v tag do not resolve to one commit.
# 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.
#
# 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: true
# 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
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#relay-v}}"
scripts/verify-release-ref.sh relay-v "$VERSION"
- 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 a manual 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 release image by digest
id: build-release
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
target: runtime
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: Build and push debug image by digest
id: build-debug
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
target: runtime-debug
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
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 }}
- name: Export release and debug digests
if: github.event_name != 'pull_request'
env:
RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }}
DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }}
run: |
mkdir -p /tmp/digests-release /tmp/digests-debug
touch "/tmp/digests-release/${RELEASE_DIGEST#sha256:}"
touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}"
- name: Upload release digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-release-${{ matrix.arch }}
path: /tmp/digests-release/*
if-no-files-found: error
retention-days: 1
- name: Upload debug digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-debug-${{ matrix.arch }}
path: /tmp/digests-debug/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge ${{ matrix.variant }} 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
strategy:
fail-fast: false
matrix:
include:
- variant: release
tag_prefix: ""
- variant: debug
tag_prefix: debug-
steps:
- name: Download all per-arch digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-${{ matrix.variant }}-*
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.
flavor: |
latest=auto
prefix=${{ matrix.tag_prefix }},onlatest=true
tags: |
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
type=sha,prefix=${{ matrix.tag_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 }}
VARIANT: ${{ matrix.variant }}
MERGED_DIGEST: ${{ steps.manifest.outputs.digest }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
{
echo "### Published \`${IMAGE_NAME}\` (${VARIANT})"
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.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#relay-v}}"
scripts/verify-release-ref.sh relay-v "$VERSION"
- 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"
+256
View File
@@ -0,0 +1,256 @@
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:
# Normal releases arrive through the `push.tags` trigger below. The inputs are
# retained only for an operator to rerun publication manually at an immutable
# chart tag; the publish job checks out inputs.ref and packages at
# inputs.version.
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.3.1
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.3.1
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 or a manual 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.3.1
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"
+265
View File
@@ -0,0 +1,265 @@
name: Linux Canary
# Produces unsigned Linux .deb and .AppImage packages from main without
# creating a tag, GitHub Release, or auto-updater artifact. Artifacts are
# available as a short-lived GitHub Actions artifact for explicit testing.
#
# Design notes vs. signed-macos-canary.yml:
# - fix-appimage.sh is run without signing env vars; the script detects
# their absence and skips re-signing, repacking only (documented inline).
# - Build tools match release.yml; cache keys derive the concrete linker and
# native library identity rather than assuming the moving runner image.
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build Linux canary
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
timeout-minutes: 60
permissions:
contents: read
env:
# AppImage tools are themselves AppImages; containers lack FUSE so we
# must use the extract-and-run fallback.
APPIMAGE_EXTRACT_AND_RUN: "1"
defaults:
run:
shell: bash
steps:
- name: Require main
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
echo "::error::Canary builds must run from main; got $SOURCE_REF"
exit 1
fi
- name: Install system dependencies
env:
DEBIAN_FRONTEND: noninteractive
run: |
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
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
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
- name: Install appimagetool
run: |
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"
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
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: 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: Derive canary version
id: version
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Desktop version '$BASE_VERSION' is not semver"
exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building canary version $VERSION from $GITHUB_SHA"
- name: Patch canary version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT"
# Compute this after cargo update so the key describes the graph that is
# actually compiled. The helper normalizes only Buzz Desktop's release
# version, allowing a canary to warm an otherwise identical tag build.
- name: Compute exact release cache key
id: rust_cache_key
env:
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py \
--platform "$RUNNER_OS" \
--target x86_64-unknown-linux-gnu \
--features mesh-llm \
--native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
echo "Release cache key: $KEY"
- name: Restore exact release Cargo cache
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"createUpdaterArtifacts": false
}
}
JSON
- name: Build sidecars
run: |
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Build Linux Tauri app
run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.canary.conf.json
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Fix AppImage (remove infra libs, symlink system GStreamer)
# fix-appimage.sh checks for TAURI_SIGNING_PRIVATE_KEY and skips
# re-signing when absent, so no signing env vars are needed here.
# Repacking still runs, removing the Mesa/GLib/GStreamer conflict libs.
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]}"
- name: Measure release Cargo cache inputs
if: always()
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
# Only this trusted, main-bound canary writes the cache. Excluding bundle
# output prevents installers from entering it.
- name: Save exact release Cargo cache
if: steps.rust_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Locate Linux build artifacts
id: artifacts
run: |
set -euo pipefail
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"
- name: Upload Linux canary packages
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-linux-canary-${{ github.sha }}
path: |
${{ steps.artifacts.outputs.deb }}
${{ steps.artifacts.outputs.appimage }}
if-no-files-found: error
retention-days: 7
+126
View File
@@ -0,0 +1,126 @@
name: macOS Intel Canary
# Produces an unsigned Intel DMG from trusted main. Its release-equivalent
# Cargo state warms the distinct x86_64 release target without signing or
# publishing anything.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build macOS Intel canary
if: github.repository == 'block/buzz'
runs-on: macos-latest
timeout-minutes: 60
env:
TARGET: x86_64-apple-darwin
steps:
- name: Require main
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
echo "::error::Canary builds must run from main; got $SOURCE_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Add Rust target
run: rustup target add "$TARGET"
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Derive and patch canary version
run: |
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}"
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
- name: Compute exact release cache key
id: rust_cache_key
env:
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py \
--platform "$RUNNER_OS" \
--target "$TARGET" \
--features default \
--native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
echo "Release cache key: $KEY"
- name: Restore exact release Cargo cache
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}}
JSON
- name: Build Intel sidecars
run: |
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build unsigned Intel DMG
run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json
env:
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 fresh Intel DMG
id: artifact
run: |
DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1)
[[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; }
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
- name: Upload Intel canary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-macos-intel-canary-${{ github.sha }}
path: ${{ steps.artifact.outputs.dmg }}
if-no-files-found: error
retention-days: 7
- name: Measure release Cargo cache inputs
if: always()
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
- name: Save exact release Cargo cache
if: steps.rust_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
+111
View File
@@ -0,0 +1,111 @@
name: Mesh Lifecycle
# Relay-driven mesh lifecycle smoke: membership → signed discovery notes →
# relay-derived allowlist → join → CPU inference over QUIC → stranger denied
# (relay membership rejection + no routed inference, with a differential
# trusted-inference health proof so a dead serve node can't fake a denial).
# Runs the full Buzz "shared compute" join story with three real mesh-llm
# node processes on one runner, using the Buzz relay as the control plane
# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses
# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh).
on:
push:
branches: [main]
paths:
- 'crates/buzz-relay/examples/mesh_*.rs'
- 'crates/buzz-relay/Cargo.toml'
- 'crates/buzz-admin/**'
- 'crates/buzz-test-client/**'
- 'crates/buzz-ws-client/**'
- 'Cargo.lock'
- 'desktop/src-tauri/src/mesh_llm/**'
- 'scripts/ci-mesh-lifecycle-smoke.sh'
- 'scripts/start-relay-for-tests.sh'
- '.github/workflows/mesh-lifecycle.yml'
pull_request:
paths:
- 'crates/buzz-relay/examples/mesh_*.rs'
- 'crates/buzz-relay/Cargo.toml'
- 'crates/buzz-admin/**'
- 'crates/buzz-test-client/**'
- 'crates/buzz-ws-client/**'
- 'Cargo.lock'
- 'desktop/src-tauri/src/mesh_llm/**'
- 'scripts/ci-mesh-lifecycle-smoke.sh'
- 'scripts/start-relay-for-tests.sh'
- '.github/workflows/mesh-lifecycle.yml'
workflow_dispatch:
concurrency:
group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
jobs:
lifecycle-smoke:
name: Relay-Driven Mesh Lifecycle Smoke
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
save-if: ${{ github.event_name != 'pull_request' }}
# The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU
# build) on first init, and the serve node downloads the smoke model
# from HuggingFace on first run. Key on the lockfile so a mesh pin bump
# rolls the runtime cache; the model ref is stable.
- name: Restore mesh runtime + model caches
id: mesh-caches
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cache/mesh-llm/native-runtimes
~/.cache/huggingface/hub
key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }}
restore-keys: |
mesh-lifecycle-${{ runner.os }}-smollm2-135m-
- 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: Run relay-driven mesh lifecycle smoke
run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log
- name: Save mesh runtime + model caches
if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cache/mesh-llm/native-runtimes
~/.cache/huggingface/hub
key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }}
- name: Upload relay + harness logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mesh-lifecycle-logs
path: |
/tmp/buzz-relay.log
/tmp/mesh-lifecycle-harness.log
if-no-files-found: ignore
@@ -0,0 +1,69 @@
name: Publish Mobile Release Candidate
run-name: Publish mobile-v${{ inputs.version }}-rc.${{ inputs.candidate_number }}
on:
workflow_dispatch:
inputs:
version:
description: Mobile marketing version (X.Y.Z)
required: true
type: string
candidate_number:
description: Expected next release-candidate number
required: true
type: string
target_sha:
description: Exact current block/buzz main commit
required: true
type: string
concurrency:
group: mobile-release-candidate-${{ inputs.version }}
cancel-in-progress: false
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require the reviewed workflow from main
env:
DISPATCH_REF: ${{ github.ref }}
run: |
if [ "$DISPATCH_REF" != "refs/heads/main" ]; then
echo "::error::Mobile candidates must be dispatched from main, not $DISPATCH_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Require canonical repository
env:
REPOSITORY: ${{ github.repository }}
run: |
if [ "$REPOSITORY" != "block/buzz" ]; then
echo "::error::Mobile candidate publication is restricted to block/buzz"
exit 1
fi
- name: Create release tagger token
id: release-tagger
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
permission-contents: write
- name: Publish annotated candidate tag
env:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
MOBILE_VERSION: ${{ inputs.version }}
CANDIDATE_NUMBER: ${{ inputs.candidate_number }}
TARGET_SHA: ${{ inputs.target_sha }}
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA"
@@ -0,0 +1,68 @@
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"
+952
View File
@@ -0,0 +1,952 @@
name: Release
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
on:
push:
tags:
- 'desktop-v[0-9]*'
jobs:
# Shared setup: verify the immutable release tag, determine the version, and
# create the release objects all four platform jobs upload into.
setup:
name: Setup
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
source_sha: ${{ steps.source.outputs.source_sha }}
steps:
- name: Determine version
id: version
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$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
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
id: source
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
scripts/verify-release-ref.sh desktop-v "$VERSION"
echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT"
release:
name: Release
if: github.repository == 'block/buzz'
runs-on: macos-latest
needs: setup
timeout-minutes: 60
permissions:
contents: read
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.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- 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-backend-kubernetes -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
# mdx-ios-codesign-helper discovers this file by its exact lowercase basename.
- name: Stage signing entitlements
run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist"
- 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: ${{ runner.temp }}/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
desktop/scripts/verify-macos-entitlements.sh \
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. Give each architecture a unique
# release basename before artifacts are merged by the final writer.
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
RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz"
mv "$ARCHIVE" "$RENAMED"
mv "$SIG" "${RENAMED}.sig"
ARCHIVE="$RENAMED"
SIG="${RENAMED}.sig"
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: Stage Apple Silicon release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-release-macos-arm64
if-no-files-found: error
path: |
${{ steps.artifacts.outputs.dmg }}
${{ steps.artifacts.outputs.archive }}
${{ 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: read
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.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- 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-backend-kubernetes -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
# mdx-ios-codesign-helper discovers this file by its exact lowercase basename.
- name: Stage signing entitlements
run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist"
- 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: ${{ runner.temp }}/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"
desktop/scripts/verify-macos-entitlements.sh "$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
RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz"
mv "$ARCHIVE" "$RENAMED"
mv "$SIG" "${RENAMED}.sig"
ARCHIVE="$RENAMED"
SIG="${RENAMED}.sig"
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: Stage Intel macOS release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-release-macos-x64
if-no-files-found: error
path: |
${{ steps.unsigned.outputs.dmg }}
${{ steps.artifacts.outputs.archive }}
${{ 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:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
needs: setup
timeout-minutes: 60
permissions:
contents: read
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:24.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.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Mark workspace safe for git (containerized job)
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Verify tag-bound release source
env:
VERSION: ${{ needs.setup.outputs.version }}
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- 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-backend-kubernetes -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 --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
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, shim host 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: Stage Linux release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-release-linux-x64
if-no-files-found: error
path: |
${{ steps.linux-artifacts.outputs.deb }}
${{ steps.linux-artifacts.outputs.appimage }}
${{ steps.linux-artifacts.outputs.archive }}
${{ steps.linux-artifacts.outputs.sig }}
release-windows:
name: Release Windows
runs-on: windows-latest
needs: setup
timeout-minutes: 60
permissions:
contents: read
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.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
shell: bash
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- 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: read, 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: Stage Windows release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-release-windows-x64
if-no-files-found: error
path: |
${{ steps.artifacts.outputs.exe }}
${{ steps.artifacts.outputs.sig }}
assemble-manifest:
name: Assemble multi-platform latest.json
# Only the tag-bound setup path can reach this job.
if: |
always() &&
needs.setup.result == 'success' &&
needs.release.result == 'success' &&
needs.release-macos-x64.result == 'success' &&
needs.release-linux.result == 'success' &&
needs.release-windows.result == 'success' &&
github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.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.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Verify tag-bound release source
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- name: Download staged release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: desktop-release-*
path: staged-by-platform
- name: Flatten staged artifacts without basename collisions
run: |
set -euo pipefail
mkdir staged
while IFS= read -r -d '' file; do
name="$(basename "$file")"
[[ ! -e "staged/$name" ]] || {
echo "::error::release artifact basename collision: $name"
exit 1
}
cp "$file" "staged/$name"
done < <(find staged-by-platform -type f -print0)
- 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 draft release has every updater archive
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
assets=$(find staged -type f -exec basename {} \;)
for spec in \
"$RESULT_ARM64:$ARCHIVE_ARM64" \
"$RESULT_X64:$ARCHIVE_X64" \
"$RESULT_LINUX:$ARCHIVE_LINUX" \
"$RESULT_WIN:$ARCHIVE_WIN"; do
result="${spec%%:*}"
archive="${spec#*:}"
if [[ "$result" == success ]]; then
[[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; }
grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; }
fi
done
- 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/desktop-v${VERSION}"
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: Create or verify versioned draft
run: |
set -euo pipefail
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE"
[[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; }
PRERELEASE_FLAGS=()
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAGS=(--prerelease --latest=false)
fi
if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then
EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish)
IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft)
[[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || {
echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1;
}
if [[ "$IS_DRAFT" != true ]]; then
echo "already_published=true" >> "$GITHUB_ENV"
fi
else
gh release create "desktop-v${VERSION}" \
--draft \
--target "${{ needs.setup.outputs.source_sha }}" \
--title "Buzz Desktop v${VERSION}" \
--notes-file "$NOTES_FILE" \
"${PRERELEASE_FLAGS[@]}"
fi
- name: Upload complete artifact set to versioned draft
if: env.already_published != 'true'
run: |
mapfile -t files < <(find staged -type f -print)
[[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; }
gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber
- name: Publish complete versioned release
if: env.already_published != 'true'
run: gh release edit "desktop-v${VERSION}" --draft=false
- name: Upload latest.json to rolling release last
if: ${{ !contains(needs.setup.outputs.version, '-') }}
run: gh release upload buzz-desktop-latest latest.json --clobber
+257
View File
@@ -0,0 +1,257 @@
name: Signed macOS Canary
# Produces a signed and notarized Apple Silicon DMG from main without creating
# a tag, GitHub Release, or auto-updater artifact. The DMG is available only as
# a short-lived GitHub Actions artifact for explicit testing.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build signed macOS canary
if: github.repository == 'block/buzz'
runs-on: macos-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write # required by block/apple-codesign-action for OIDC
steps:
- name: Require main
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
echo "::error::Signed canary builds must run from main; got $SOURCE_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- 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: Derive canary version
id: version
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Desktop version '$BASE_VERSION' is not semver"
exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building canary version $VERSION from $GITHUB_SHA"
- name: Patch canary version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
# Compute this after cargo update so the key describes the graph that is
# actually compiled. The helper normalizes only Buzz Desktop's release
# version, allowing a canary to warm an otherwise identical tag build.
- name: Compute exact release cache key
id: rust_cache_key
env:
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py \
--platform "$RUNNER_OS" \
--target aarch64-apple-darwin \
--features mesh-llm \
--native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
echo "Release cache key: $KEY"
- name: Restore exact release Cargo cache
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"macOS": {
"minimumSystemVersion": "10.15"
},
"createUpdaterArtifacts": false
}
}
JSON
- name: Build sidecars
run: |
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -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
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$MESH_REV_SHORT" -type d -name "$MESH_REV_SHORT" | head -1)
if [[ -z "$MESH_ROOT" ]]; then
echo "::error::mesh-llm checkout for $MESH_REV_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.canary.conf.json
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"
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
- name: Locate unsigned DMG
id: unsigned
run: |
DMG=$(find desktop/src-tauri/target/release/bundle/dmg -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found"
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
# mdx-ios-codesign-helper discovers this file by its exact lowercase basename.
- name: Stage signing entitlements
run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist"
- 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: ${{ runner.temp }}/entitlements.plist
artifact-name: buzz-canary-${{ github.sha }}-${{ github.run_id }}-arm64
- name: Verify signed app
env:
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
run: |
set -euo pipefail
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract"
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
codesign --verify --deep --strict --verbose=2 "$EXTRACT_DIR/Buzz.app"
spctl --assess --type execute --verbose=4 "$EXTRACT_DIR/Buzz.app"
desktop/scripts/verify-macos-entitlements.sh "$EXTRACT_DIR/Buzz.app"
- name: Stage signed DMG
id: artifact
env:
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
NAME="Buzz_${VERSION}_aarch64-signed.dmg"
cp "$SIGNED_DMG" "$RUNNER_TEMP/$NAME"
echo "path=$RUNNER_TEMP/$NAME" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
- name: Upload signed canary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-macos-canary-${{ github.sha }}
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
retention-days: 7
- name: Measure release Cargo cache inputs
if: always()
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
# Only this trusted, main-bound canary writes the cache. Excluding bundle
# output prevents installers or signed artifacts from entering it.
- name: Save exact release Cargo cache
if: steps.rust_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
+233
View File
@@ -0,0 +1,233 @@
name: Sprig image
# Builds and publishes the public agent container image as
# ghcr.io/block/buzz-sprig — the digest-pinned box the Kubernetes backend
# deploys agents into (see Dockerfile.sprig and docs/remote-agents.md).
#
# Strategy mirrors docker.yml (the relay image): each architecture builds on
# its native runner, pushes to GHCR by digest, then a merge job stitches the
# per-arch digests into one multi-arch manifest and attests provenance.
# No QEMU emulation.
#
# Triggers:
# - push to main (paths-filtered) → :main + :sha-<7>
# - tag sprig-v* → semver family (shared with sprig.yml's
# binary release — one tag versions both)
# - pull_request (paths-filtered) → build only, no push
# - workflow_dispatch → manual publish at the current ref
#
# NOTE: the first push creates the GHCR package PRIVATE by default. An org
# admin must flip ghcr.io/block/buzz-sprig to public once (Package settings →
# Change visibility). Subsequent pushes keep the visibility.
on:
push:
branches: [main]
tags: ["sprig-v[0-9]*"]
paths:
- "Dockerfile.sprig"
- "scripts/sprig-entrypoint.sh"
- ".github/workflows/sprig-image.yml"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "crates/**"
pull_request:
paths:
- "Dockerfile.sprig"
- "scripts/sprig-entrypoint.sh"
- ".github/workflows/sprig-image.yml"
workflow_dispatch: {}
concurrency:
group: sprig-image-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
permissions: {}
env:
# Single source of truth for the image name; override with the
# GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml).
IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || 'ghcr.io/block/buzz-sprig' }}
jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
packages: write
id-token: write
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
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
with:
# Same OOM cap as docker.yml — Rust compiles blow the 7GB runner
# at buildkit's default parallelism of 4.
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: ${{ env.IMAGE_NAME }}
# match=^sprig-v(.*)$ strips the tag prefix for the semver parser,
# exactly as docker.yml does for relay-v. :latest comes from
# flavor.latest=auto — stable semver only, never main pushes.
tags: |
type=ref,event=branch
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}},match=^sprig-v(.*)$
type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$
labels: |
org.opencontainers.image.title=Buzz Sprig
org.opencontainers.image.description=Agent runtime image for Buzz remote agents (buzz-acp multicall + git + curl)
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.sprig
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
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: sprig-digest-${{ 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
id-token: write
attestations: write
steps:
- name: Download per-arch digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: sprig-digest-*
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 (see docker.yml).
flavor: |
latest=auto
tags: |
type=ref,event=branch
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}},match=^sprig-v(.*)$
type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$
- 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
tags=()
while IFS= read -r tag; do
[ -n "$tag" ] && tags+=("-t" "$tag")
done <<< "$META_TAGS"
digests=()
for digest in *; do
digests+=("${IMAGE_NAME}@sha256:${digest}")
done
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
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
# Verify with: gh attestation verify oci://ghcr.io/block/buzz-sprig:<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 }}
DIGEST: ${{ steps.manifest.outputs.digest }}
run: |
{
echo "### Sprig image published"
echo '```'
echo "${IMAGE_NAME}@${DIGEST}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+184
View File
@@ -0,0 +1,184 @@
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.0.3
- 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.0.3
- 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}\`)."
gh release edit "$TAG" \
--prerelease \
--title "$TITLE" \
--notes "$NOTES"
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.0.3
- 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/*
+197
View File
@@ -0,0 +1,197 @@
name: Windows Canary
# Produces an unsigned Windows NSIS installer from main without creating
# a tag, GitHub Release, or auto-updater artifact. The installer is available
# only as a short-lived GitHub Actions artifact for explicit testing.
#
# Design notes vs. signed-macos-canary.yml:
# - No mesh-llm: release-windows doesn't build it.
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build Windows canary
if: github.repository == 'block/buzz'
runs-on: windows-latest
timeout-minutes: 60
permissions:
contents: read
env:
TARGET: x86_64-pc-windows-msvc
steps:
- name: Require main
shell: bash
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
echo "::error::Canary builds must run from main; got $SOURCE_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# The Windows runner ships with rustup preinstalled; rust-toolchain.toml
# in the repo root pins the channel (1.95.0) automatically. We only need
# to ensure the cross-compile target is registered; on windows-latest the
# host IS x86_64-pc-windows-msvc so this is typically a no-op.
- name: Add Rust target
shell: bash
run: rustup target add "$TARGET"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
package-manager-cache: false
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11.4.0
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
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
shell: bash
run: pnpm install --frozen-lockfile
- name: Derive canary version
id: version
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Desktop version '$BASE_VERSION' is not semver"
exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building canary version $VERSION from $GITHUB_SHA"
- name: Patch canary version
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Resolve native toolchain identity
id: native_toolchain
shell: bash
run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT"
# Compute this after cargo update so the key describes the graph that is
# actually compiled. The helper normalizes only Buzz Desktop's release
# version, allowing a canary to warm an otherwise identical tag build.
- name: Compute exact release cache key
id: rust_cache_key
shell: bash
env:
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
run: |
KEY=$(scripts/desktop-release-cache-key.py \
--platform "$RUNNER_OS" \
--target x86_64-pc-windows-msvc \
--features default \
--native-inputs "$NATIVE_TOOLCHAIN_ID")
echo "key=$KEY" >> "$GITHUB_OUTPUT"
echo "Release cache key: $KEY"
- name: Restore exact release Cargo cache
id: rust_cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Generate non-updating bundle config
shell: bash
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"createUpdaterArtifacts": false
}
}
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 --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Locate NSIS installer
id: artifact
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
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
echo "exe=$EXE" >> "$GITHUB_OUTPUT"
- name: Upload Windows canary installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-windows-canary-${{ github.sha }}
path: ${{ steps.artifact.outputs.exe }}
if-no-files-found: error
retention-days: 7
- name: Measure release Cargo cache inputs
if: always()
shell: bash
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
# Only this trusted, main-bound canary writes the cache. Excluding bundle
# output prevents installers from entering it.
- name: Save exact release Cargo cache
if: steps.rust_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
desktop/src-tauri/target
!desktop/src-tauri/target/**/release/bundle
key: ${{ steps.rust_cache_key.outputs.key }}
- name: Save pnpm store cache
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
+74
View File
@@ -0,0 +1,74 @@
# Build artifacts
/target/
/dist/
/admin-web/dist/
# Python cache
__pycache__/
*.pyc
# 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
+4
View File
@@ -0,0 +1,4 @@
# Cargo registry checksums are integrity hashes generated by Cargo, not payment data.
exclude_rules_for_files:
sq.pii.cc.visa:
- Cargo.lock
+10
View File
@@ -0,0 +1,10 @@
{
"schema": 2,
"version": "0.5.8",
"base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f",
"previous_tag": "desktop-v0.5.7",
"previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954",
"previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3",
"tag": "desktop-v0.5.8",
"commit_count": 4
}
+3
View File
@@ -0,0 +1,3 @@
{
"rust-analyzer.cargo.targetDir": true
}
+600
View File
@@ -0,0 +1,600 @@
# 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/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop 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)
├─► buzz-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), desktop TypeScript
typechecking (`tsc --noEmit`), 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. Before agents run Git or hooks, activate the
repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook
commands to compensate for an unconfigured shell `PATH`.
**Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push.
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.
This applies to events *inside* a channel. Addressable events that describe a
channel carry its id in their `d` tag instead: kind:39000 (metadata),
kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels
from the `d` tag of their kind:39002 events, not from `h`.
**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`.
**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri
bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured`
in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so
`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails
with `Cannot read properties of undefined (reading 'invoke')` — the app renders
"Community connection failed" instead of the UI under test. That looks exactly
like a product bug rather than a build mistake, so it burns real time.
`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for
you; prefer them over a manual build plus `playwright test`.
**Stale server:** `reuseExistingServer: true` means a previous build's server
serves old code. Kill port 4173 and re-run `pnpm build:e2e` 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.
### Community Switching
The desktop app supports multiple communities (each backed by a different relay).
Switching communities does **not** reload the page — it uses React key-based
remounting. `<AppReady key={communityKey} />` in `App.tsx` forces the entire
community-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 community-scoped
singleton needs a reset function wired into `resetCommunityState()` in
`desktop/src/features/communities/useCommunityInit.ts`.
Current singletons that are reset on relay boundary changes (same-relay
reconnects preserve pending avatar verification work):
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
- `resetRateLimitGate()` — clears any active rate-limit window from the old relay
- `clearAllDrafts()` — message draft cache
- `resetAgentObserverStore()` — agent observer relay store
- `resetActiveAgentTurnsStore()` — active agent turn timers
- `resetAgentWorkingSignal()` — agent working indicator signal
- `resetAvatarProfileSync()` — pending verified-avatar profile writes
- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts
- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
- `resetMediaCaches()` — proxy port and relay origin caches
- `resetVideoPlayerState()` — video player singleton
- `resetRenderScopedReactionHydration()` — reaction hydration cache
- `clearSearchHitEventCache()` — search result event cache
- `clearMarkdownNodeCache()` — markdown parse-node cache
- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events)
**If you add a new module-level cache, Map, or class instance that holds
community-scoped data, you must add its reset to `resetCommunityState()`.**
Failure to do so causes data from the old community to leak into the new one.
Key files:
- `desktop/src/app/App.tsx` — community key, init gate, remount boundary
- `desktop/src/features/communities/useCommunityInit.ts``resetCommunityState()`, applies config to Tauri backend
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `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
```
When run from a git worktree, `just mobile-dev` (and `just
mobile-build-android`) give the debug build a per-worktree app identifier
(keyed to the worktree directory name) and a branch-labelled app name via
`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees
install side by side. Release builds are unaffected. `just mobile-clean`
removes stale worktree-suffixed installs from simulators/emulators. See
[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio
usage.
### 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`, `scripts/mobile-release.sh`, candidate tags, internal builds
- [README.md](README.md) — project overview and quick start
+827
View File
@@ -0,0 +1,827 @@
# 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 |
|-------|---------|
| 09999 | Standard Nostr kinds (NIP-01 through NIP-XX) |
| 1000019999 | Replaceable events (NIP-16) |
| 2000029999 | Ephemeral events — not stored, not audited |
| 3000039999 | Parameterized replaceable events |
| 4000049999 | 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 |
| 4600146012 | KIND_WORKFLOW_* | Workflow execution events |
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |
`buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. 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 2000029999 → 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 4600146012)
```
Steps 1012 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 (4600146012), 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 2000029999)
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 (2000029999) 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 180` — 180-second TTL (3× the 60-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 (132, 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 132 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 | 180s | 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). |
+1615
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# 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
+529
View File
@@ -0,0 +1,529 @@
# 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 an issue](https://github.com/block/buzz/issues/new).
---
## Table of Contents
1. [Code of Conduct](#code-of-conduct)
2. [Before You Open a PR](#before-you-open-a-pr)
3. [Setting Up the Development Environment](#setting-up-the-development-environment)
4. [Running Tests](#running-tests)
5. [Code Style](#code-style)
6. [Making a Pull Request](#making-a-pull-request)
7. [Architecture Overview](#architecture-overview)
8. [Ecosystem](#ecosystem)
9. [How to Add a New Event Kind](#how-to-add-a-new-event-kind)
10. [How to Add a New MCP Tool](#how-to-add-a-new-mcp-tool)
11. [How to Add a New API Endpoint](#how-to-add-a-new-api-endpoint)
12. [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**.
---
## Before You Open a PR
Before starting, search [open PRs](https://github.com/block/buzz/pulls) and [open issues](https://github.com/block/buzz/issues) for duplicates — someone may already be working on the same thing. When you open your PR, link the closest existing one in the description (or say "none found").
For anything beyond a small fix, opening an issue first is strongly recommended. Describe the problem and proposed solution so a maintainer can acknowledge the approach before you build — it avoids two people building the same thing in parallel.
Buzz is an agent platform, so AI-assisted PRs are welcome. No need to disclose the tools you used, but you own and must have reviewed the final code. Submissions that are clearly unreviewed may be closed with a pointer here.
We squash-merge, so your PR title becomes the commit subject in `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) format: `feat(mcp): add get_feed_actions tool`. The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is required. See the [Commit Messages](#commit-messages) section for the full reference.
### Sign Your Commits
```bash
git commit -s
```
Every commit needs a Developer Certificate of Origin (DCO) sign-off. The `-s` flag appends a `Signed-off-by` trailer that certifies you wrote the change and can contribute it under the project license. The **DCO Check** will block your PR without it.
#### Fix unsigned commits already pushed
```bash
git rebase --signoff main
git push --force-with-lease
```
#### Auto-setup for future commits
```bash
just hooks
```
This installs a `commit-msg` hook that adds the sign-off trailer automatically for `git commit` and `git merge`. Other flows (`git rebase`, `git cherry-pick`) still need their own flag — `--signoff` and `-s` respectively.
We review as capacity allows — focused PRs that follow this guide move fastest.
---
## 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` | 2.1.3 (Hermit-pinned) | Auto-installed by `just hooks` — no manual install needed |
| `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.
#### Linux: Tauri system libraries
Hermit pins language toolchains, not system libraries. On Linux, the desktop
app's Rust crates link against GTK and WebKitGTK, so `just ci` (and any
`just desktop-tauri-*` recipe) needs these installed system-wide first. On
Debian/Ubuntu:
```bash
sudo apt-get install -y --no-install-recommends \
build-essential curl file libasound2-dev libayatana-appindicator3-dev \
libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \
patchelf wget
```
This is the same list CI installs (see `.github/workflows/ci.yml`), so matching
it locally keeps your results comparable to CI. Other distributions ship these
under different package names — see the
[Tauri prerequisites](https://tauri.app/start/prerequisites/) for the
equivalents.
Without them, `just ci` fails partway through `just check` with a pkg-config
error such as:
```
The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found.
```
If you're only touching the relay, CLI, or other server-side crates, you can
skip this and run the narrower recipes instead — `just fmt-check`, `just
clippy`, `just test-unit`, and `just test` need no GTK.
### 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. If `just ci` fails on formatting, `just fix-all` fixes it in one shot (`rustfmt` + Tauri fmt + desktop, web, and mobile formatters).
---
## 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
### 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
6. **Shows the UI** — any PR that changes the desktop or mobile UI includes
before/after screenshots (or a short recording for interactions) in the
description. We can't run every branch locally — screenshots let us review
UI changes same-day instead of waiting for someone to build your branch.
### PRs We're Unlikely to Merge
Some kinds of PRs usually get closed — not because they're bad ideas, but
because we can't safely review them without prior discussion:
- **Large refactors or dependency swaps** without a prior issue agreeing on
the direction
- **Cosmetic renames or style-only churn** that doesn't fix a bug or improve
clarity
- **Entirely new features** with no prior discussion
- **Drive-by changes bundled into an unrelated fix** — split them out
If you're considering any of these, open an issue first and we'll tell you
quickly whether it's a direction we'd merge. That saves your time as much as
ours.
### What to Expect After You Open a PR
- Maintainers triage new PRs on a best-effort cadence. Focused PRs that
follow this guide move fastest.
- Duplicates and PRs that skip this guide may be closed with a pointer here
rather than a full review. A close isn't a rejection of you or the idea —
address the gaps and reopen (or open a fresh PR) anytime.
- 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. 🐝*
Generated
+11786
View File
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
[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",
"crates/buzz-voice",
"crates/buzz-backend-kubernetes",
"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"] }
# Kubernetes (buzz-backend-kubernetes provider). No `ring` feature here: the
# process-level CryptoProvider is installed explicitly at startup, matching
# buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the
# crate's own rustls dependency.
kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] }
k8s-openapi = { version = "0.26", features = ["v1_31"] }
# 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"
base64 = "0.22"
# 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" }
+178
View File
@@ -0,0 +1,178 @@
# 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/*
# Keep enough DWARF for native profilers to resolve optimized code to source
# locations. The normal runtime strips it below; runtime-debug retains it.
ENV CARGO_PROFILE_RELEASE_DEBUG=line-tables-only
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
# Derive the normal release binaries from the same optimized ELF files as the
# debug image so the two variants cannot drift at code-generation time.
FROM builder AS stripped-binaries
RUN 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/
COPY admin-web/package.json admin-web/
RUN pnpm install --frozen-lockfile --filter buzz-web --filter buzz-admin-web
COPY web/ web/
COPY admin-web/ admin-web/
RUN pnpm -C web build && pnpm -C admin-web build
# ─── Stage 5: shared runtime ────────────────────────────────────────────────
FROM debian:${DEBIAN_VERSION}-slim AS runtime-base
# 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=web-builder /build/web/dist /srv/buzz/web
COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-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. The
# admin bundle is inert until BUZZ_ADMIN_HOST is configured.
ENV BUZZ_WEB_DIR=/srv/buzz/web \
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
EXPOSE 3000 8080 9102
# deploy/compose mounts a volume here; pre-created so it inherits buzz:buzz.
RUN mkdir -p /data/git && chown buzz:buzz /data/git
USER buzz:buzz
WORKDIR /var/lib/buzz
ENTRYPOINT ["/usr/local/bin/buzz-relay"]
# Optimized binaries with line-table debug information for native profiling.
# Published under debug-* tags; runtime behavior otherwise matches the normal
# image exactly.
FROM runtime-base AS runtime-debug
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
# Keep the stripped runtime as the final/default Dockerfile target so existing
# `docker build .` callers and release tags retain their current behavior.
FROM runtime-base AS runtime
COPY --from=stripped-binaries /build/target/release/buzz-relay /usr/local/bin/buzz-relay
COPY --from=stripped-binaries /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=stripped-binaries /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
+37
View File
@@ -0,0 +1,37 @@
# 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"]
+44
View File
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.7
# Multi-arch is produced by building this file on native amd64 and arm64 runners.
# Keep both bases pinned to manifest-list digests so either architecture resolves
# to immutable source bytes.
FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder
RUN apk add --no-cache \
build-base \
cmake \
git \
musl-dev \
openssl-dev \
openssl-libs-static \
perl \
pkgconf \
protoc
WORKDIR /build
COPY . .
RUN cargo build --locked --profile sprig -p sprig \
&& strip target/sprig/sprig
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
RUN apk add --no-cache bash ca-certificates curl git \
&& adduser -D -h /home/agent agent \
&& install -d -o agent -g agent /workspace /home/agent \
&& git config --system gpg.format x509 \
&& git config --system gpg.x509.program /usr/local/bin/git-sign-nostr \
&& git config --system commit.gpgSign true \
&& git config --system tag.gpgSign true
COPY --from=builder --chmod=0755 /build/target/sprig/sprig /usr/local/bin/sprig
COPY --chmod=0755 scripts/sprig-entrypoint.sh /usr/local/bin/sprig-entrypoint
RUN for name in \
buzz-acp buzz-agent buzz-dev-mcp rg tree buzz \
git-credential-nostr git-sign-nostr; do \
ln -s sprig "/usr/local/bin/$name"; \
done
ENV HOME=/home/agent \
PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin
WORKDIR /home/agent
USER agent
ENTRYPOINT ["/usr/local/bin/sprig-entrypoint"]
+1
View File
@@ -0,0 +1 @@
## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md)
+979
View File
@@ -0,0 +1,979 @@
# 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`/`just production`
# 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` / `just mesh=1 production`.
mesh := ""
# Reset only the current standalone desktop instance before launch.
# Usage: `just fresh=1 desktop-standalone`.
fresh := ""
# 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
# Use the Hermit-pinned lefthook (bin/lefthook self-downloads on first use):
# works with no pre-installed lefthook and guarantees the pinned version
# rather than whatever happens to be on PATH.
export PATH="{{justfile_directory()}}/bin:$PATH"
# --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
SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz)
if [[ "$TARGET" != *windows* ]]; then
SIDECARS+=(buzz-backend-kubernetes)
fi
for bin in "${SIDECARS[@]}"; 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}} --workspace --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 --workspace
# Run the native terminal latency gate explicitly on a known-idle host.
# This is intentionally excluded from shared CI: scheduler contention makes a
# wall-clock assertion flaky, and the release profile is the shipped shape.
desktop-terminal-performance-test:
cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
# Verify compiled-flag behavior under both compile states (clean + capability set).
# Runs the auto-connect and owner-only access focused tests twice with
# independently supplied expected values; build.rs rerun-if-env-changed
# triggers recompilation.
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
cd desktop/src-tauri
echo "=== Clean build (no flag) → expect false ==="
env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \
cargo test compiled_flag_matches_expected -- --ignored --nocapture
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
cargo test --lib
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
cargo test compiled_policy_matches_expected -- --ignored --nocapture
echo "=== Internal build (flags set) → expect true ==="
BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \
cargo test compiled_flag_matches_expected -- --ignored --nocapture
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
cargo test --lib
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
cargo test compiled_policy_matches_expected -- --ignored --nocapture
echo "Both compiled states verified."
# 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"
if [[ "$TARGET" != *windows* ]]; then
touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET"
fi
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 only the e2e specs changed vs origin/main (both projects) before pushing
desktop-e2e-pre-push: _ensure-migrations
git fetch origin main
cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main
# 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
set -euo pipefail
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
cargo nextest run -p buzz-voice --lib
cargo nextest run -p buzz-cli
# 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
# Kubernetes backend provider: the decision layers (state machine, GC
# planner, env precedence, naming, wire) are pure functions with a fake
# substrate, so they belong in the unit job. Enumerated explicitly
# because nothing in CI runs `cargo test --workspace` — workspace
# membership alone buys clippy/check, not a single executed test.
cargo nextest run -p buzz-backend-kubernetes
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
pnpm -C {{desktop_dir}} build:e2e
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
# Build and run the private read-only admin dashboard
admin: bootstrap _ensure-migrations
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
[[ -d node_modules ]] || pnpm install
pnpm -C admin-web build
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
cargo run -p buzz-relay
# Seed deterministic reports and product feedback for local admin dashboard review
admin-seed: _ensure-migrations
./scripts/seed-admin-dashboard.sh
# Run focused relay and browser checks for the read-only admin dashboard
admin-check: fmt-check
cargo check -p buzz-relay --all-targets
cargo test -p buzz-relay api::admin
cargo test -p buzz-relay router::tests
pnpm -C admin-web check
pnpm -C admin-web exec playwright test
# 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-backend-kubernetes -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 only the desktop app. No relay, database, Docker, migrations, or .env are needed.
# The app opens normally and asks for a community before making a relay connection.
desktop-standalone *ARGS: _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
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")
for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do
cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
unset BUZZ_PRIVATE_KEY BUZZ_SHARE_IDENTITY
if [[ -n "{{fresh}}" ]]; then
export BUZZ_RESET_WEBVIEW_STATE=1
fi
source ../scripts/instance-env.sh
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
export BUZZ_DEV_KEYRING_SERVICE="buzz-desktop-dev.${BUZZ_INSTANCE_SLUG:-main}"
if [[ -n "{{fresh}}" ]]; then
../scripts/reset-desktop-standalone-state.sh "$INSTANCE_ID" "$BUZZ_DEV_KEYRING_SERVICE"
fi
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started"
pnpm exec tauri dev --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-backend-kubernetes -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 0-byte sidecar stubs with real binaries so tauri dev picks them up.
# buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
# exe dir for executable buzz-backend-* files, so the non-executable stub that
# tauri dev copies next to the exe would hide the provider from "Run on".
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")
STAGING_SIDECARS=(buzz)
if [[ "$TARGET" != *windows* ]]; then
STAGING_SIDECARS+=(buzz-backend-kubernetes)
fi
for bin in "${STAGING_SIDECARS[@]}"; do
cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
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 app against the production relay (installs deps + builds agent tools automatically)
production *ARGS: bootstrap _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: production must always start with a clean dep tree
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -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 0-byte sidecar stubs with real binaries so tauri dev picks them up.
# buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
# exe dir for executable buzz-backend-* files, so the non-executable stub that
# tauri dev copies next to the exe would hide the provider from "Run on".
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")
PRODUCTION_SIDECARS=(buzz)
if [[ "$TARGET" != *windows* ]]; then
PRODUCTION_SIDECARS+=(buzz-backend-kubernetes)
fi
for bin in "${PRODUCTION_SIDECARS[@]}"; do
cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
cd {{desktop_dir}}
export BUZZ_RELAY_URL="wss://buzz.block.builderlab.xyz"
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 production 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}} && node ./scripts/check-l10n.mjs && 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
# Regenerate the emoji dataset asset from desktop's emoji-mart install.
# Output is committed — rerun after bumping @emoji-mart/data.
mobile-emoji-data:
node {{mobile_dir}}/scripts/generate-emoji-data.mjs
# Compile an unsigned Android debug APK (worktree-aware debug identity)
mobile-build-android:
./scripts/mobile-worktree-overrides.sh
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub
# Run the mobile app on iOS simulator (worktree-aware debug identity)
mobile-dev:
#!/usr/bin/env bash
set -euo pipefail
if ! pgrep -x Simulator &>/dev/null; then
open -a Simulator
sleep 3
fi
./scripts/mobile-worktree-overrides.sh
cd {{mobile_dir}}
unset GIT_DIR GIT_WORK_TREE
flutter run
# Uninstall stale worktree-suffixed Buzz debug installs (production apps kept)
mobile-clean:
./scripts/mobile-worktree-clean.sh
# ─── 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}')"
# 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"
# Open or update the desktop release PR from an immutable origin/main snapshot
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
scripts/prepare-desktop-release.sh "$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"
# Shared release-PR engine for desktop and relay. Mobile publishes immutable
# candidate tags directly from remote main instead of using metadata-only PRs.
_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" ;;
*)
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" ;;
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
View File
@@ -0,0 +1,201 @@
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.
+372
View File
@@ -0,0 +1,372 @@
# 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'))"
# 4. 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
# Subscribe to reactions to channel messages — include #h for live delivery (see note below)
nak req -k 7 --tag "h=<channel-uuid>" --stream \
--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
```
> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is
> ignored for channel determination). Reactions to channel-scoped events are therefore
> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly
> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of
> those reactions — subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` instead.
> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit
> `h` tags are matched directly, and tagless reactions match via their stored channel.
### 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
- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide).
- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents.
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals.
+287 -2
View File
@@ -1,3 +1,288 @@
# buzz-web-demo
<h1 align="center">Buzz 🐝</h1>
用于验证 Buzz Web 仓库入口和浏览功能
<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="RELEASING.md">Releasing</a> ·
<a href="LICENSE">Apache 2.0</a>
</p>
<p align="center">
<img src="docs/assets/screenshots/channel-thread.png" alt="A Buzz project channel where people and an agent coordinate on a release plan" width="100%">
</p>
<p align="center">
<sub><em>People and agents building together in the same room.</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="People and agents collaborating in a Buzz engineering channel and reacting with emoji" 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 Add a channel dialog with search, filters, and channels to join or create" 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):
| Platform | File |
|---|---|
| macOS (Apple Silicon) | `Buzz_<version>_aarch64.dmg` |
| macOS (Intel) | `Buzz_<version>_x64.dmg` |
| Linux (x86_64) | `Buzz_<version>_amd64.AppImage` or `Buzz_<version>_amd64.deb` |
| Windows (x64) | `Buzz_<version>_x64-setup_alpha-unsigned.exe` |
On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel.
The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**.
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 want my own hosted relay
To run a relay for your team without managing servers, you can deploy one to Railway in a click:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/buzz-relay-block)
See [here](https://engineering.block.xyz/blog/run-your-own-buzz-relay) for details.
### 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.
Want a single-node / VPS relay instead of the local-dev stack? Use the production Compose bundle in [`deploy/compose/`](deploy/compose/README.md) (`docker compose` + Postgres, Redis, MinIO, optional Caddy/TLS). The root [`docker-compose.yml`](docker-compose.yml) is for day-to-day development only.
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>
+321
View File
@@ -0,0 +1,321 @@
# Releasing Buzz
Buzz has three independent release lanes. Desktop and relay use release PRs.
Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Entry point | Artifact |
|------|-------------|----------|
| Desktop | `just release-desktop <version>` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
The lanes version independently. Desktop reads its manifests, relay reads its
crate manifest, and mobile derives both source and marketing version from the
exact candidate tag. The mobile handoff to the private `buzz-releases` pipeline
remains manual because OSS CI cannot trigger private CI.
## Quick Start
Prepare desktop releases locally from an up-to-date, clean `main` checkout:
```sh
just release-desktop 0.5.3
```
The recipe generates the immutable candidate and opens or updates its pull
request. Candidate branch creation uses the operator's GitHub permissions; the
release App is intentionally limited to creating protected release tags.
```sh
# Relay release
just release-relay
just release-relay 0.4.0
# Publish the next mobile candidate from the exact current remote main commit
scripts/mobile-release.sh candidate 0.5.0
```
Desktop uses an immutable generated candidate PR; relay continues using its
metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable
candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release.
---
## How It Works
### Desktop
1. Run `just release-desktop <version>` from a clean, up-to-date `main` checkout.
The script creates one deterministic candidate commit and records both its
frozen base and the verified prior release ledger in candidate metadata.
2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or
pushing the branch creates a new candidate and requires checks to run again.
3. **Squash merge** the PR after all protected-branch checks pass. The merge is
the human authorization event; an authorized owner/admin bypass is treated
the same way. Unrelated changes reaching `main` do not invalidate the
reviewed candidate.
4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR
identity, validates candidate content, and proves every required check came
from its trusted producer and was successful when the PR merged. It creates
`desktop-v<version>` at the exact reviewed PR head—not the squash commit.
Retries accept that tag only at the same SHA and never move it. GitHub does
not expose when an individual check rerun was created, so an ordinary rerun
after merge deliberately makes tag verification fail closed; inspect that
run and create a new candidate version rather than retrying the blocked tag.
5. The tag triggers `release.yml`. It builds and stages all platform artifacts,
publishes the versioned release only after the complete set succeeds, then
updates the rolling updater manifest last for stable versions.
Because squash merging leaves immutable candidate tags on side history, the next
release uses validated prior candidate metadata as its ledger boundary. It
includes unrelated commits after the prior frozen base and excludes exactly the
prior release's recorded squash commit; tag ancestry is deliberately irrelevant.
### Relay
1. **`just release-relay`** runs locally on `main`, creates or updates a
`relay-release/<version>` PR, bumps `crates/buzz-relay/Cargo.toml`,
regenerates `Cargo.lock`, and updates the relay changelog.
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes
`relay-v<version>`.
3. **The tag triggers `docker.yml`.** Stable releases update the version
aliases and `latest`; prereleases do not. Each release also publishes an
optimized, symbol-bearing image under matching `debug-` tags (for example,
`debug-0.3.0` and `debug-latest`) for native profiling. The ordinary tags
remain stripped and are the default for deployments that do not need it.
Every push to `main` continues to publish the rolling relay `:main` and
`:sha-<7>` tags, plus matching `:debug-main` and `:debug-sha-<7>` variants.
### Mobile
1. **Publish a candidate.** From a clean checkout whose `origin` is the
canonical `block/buzz` repository, run
`scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches
the exact current `origin/main` commit, derives the next number from exact
remote tags for that marketing version, and publishes an annotated
`mobile-vX.Y.Z-rc.N` tag there through the dedicated `buzz-release-bot`
GitHub App. It never uses the operator's checked-out commit and never moves
an existing candidate.
2. **Build the exact tag.** Enter the candidate tag as `mobile_ref` in the
private Buzz mobile Buildkite pipeline. OSS CI deliberately cannot trigger
that private pipeline. The tag supplies both source commit and release
version. Flutter receives clean marketing version `X.Y.Z`; Buildkite's
monotonically increasing build number supplies the platform build number.
3. **Promote tested artifacts.** Promote the already-built signed artifact for
each platform through its store workflow. Record the exact tag with the
build or rollout record. No source ref is changed and no final build is cut.
The iOS and Android artifacts for one marketing version may come from different
RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships
`mobile-v0.5.0-rc.3`. Each platform's exact candidate tag is its source record.
There is intentionally no single selected or final candidate for the marketing
version.
The simplification trades away a separate stabilization line. Unrelated commits
that reach `main` become part of every later candidate, and there is no retained
hotfix branch or branch-ancestry history. Add a dedicated hotfix flow later if a
release actually needs isolation from `main`.
`mobile/pubspec.yaml` keeps `0.0.0+1` only as a valid, visibly non-release
fallback for local development and validation builds. Release jobs always
inject both version fields. `mobile/CHANGELOG.md` is retained as historical
release data. It is not a release ledger for this flow.
---
## Version Sources
| Lane | Release version authority |
|------|---------------------------|
| Desktop | `desktop/package.json` and synchronized desktop manifests |
| Relay | `crates/buzz-relay/Cargo.toml` |
| Mobile | Exact `mobile-vX.Y.Z-rc.N` remote tag |
`just bump-desktop-version <version>` updates the desktop manifests and
regenerates their lockfiles. `just bump-relay-version <version>` updates the
relay crate and regenerates `Cargo.lock`. Mobile has no bump recipe or
release-metadata PR.
---
## Signed macOS Canary
Use the manual **Signed macOS Canary** workflow when you need an Apple Silicon
build of current `main` for explicit testing without publishing a release:
```sh
gh workflow run signed-macos-canary.yml --repo block/buzz --ref main
```
The workflow derives a `-test.<run-number>` version, signs and notarizes the
DMG, verifies it with Gatekeeper, and uploads it as a short-lived Actions
artifact with seven-day retention. Because this is a public repository, any
signed-in GitHub user can download that artifact while it exists; it is
unpublished, not private. The workflow has no release permissions, does not
create or move tags, and cannot update `buzz-desktop-latest` or `latest.json`.
Download the artifact from the completed run:
```sh
gh run download <run-id> --repo block/buzz --name <artifact-name>
```
The workflow intentionally accepts only `main`. Use the normal release process
for distributable builds or builds from an immutable release tag.
---
## Release Retry
`release.yml` has no manual dispatch and cannot build from `main` or another
caller-selected ref. If a run for an existing immutable
`desktop-v<version>` tag fails, rerun that failed workflow from GitHub Actions
(or use `gh run rerun <run-id> --failed --repo block/buzz`). A stable rerun also
repairs `buzz-desktop-latest/latest.json` if the original run published the
versioned release but failed during that final rolling-manifest upload. Do not
recreate, move, or push the immutable tag again.
Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
---
## Internal Releases
For mobile, trigger the private
[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with
an exact RC tag for the platform build being cut. For desktop, start
[Release Desktop](https://buildkite.com/runway/sprout-releases) and enter the
exact public source tag as `desktop_ref=desktop-v<version>`; a generic
`v<version>` tag is intentionally rejected. See the
[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the rest of the private pipeline contract.
---
## What Gets Published
Desktop publishes two GitHub releases:
1. **`desktop-v<version>`**: the user-facing release with installers.
2. **`buzz-desktop-latest`**: the rolling auto-updater release.
Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
and rollout records retain the exact tag they used. Mobile does not publish a
GitHub Release or a stable `mobile-vX.Y.Z` alias.
---
## 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), an unsigned Windows x64
NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and
`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached
to the same `desktop-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
- An `origin` remote whose configured URL is the canonical `block/buzz`
repository
- `gh` CLI authenticated with permission to push the candidate branch and open
its pull request
- The Default `main` ruleset configured for squash-only merging, strict required
checks, stale-review dismissal, and the **Desktop Release Candidate** check
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
non-fast-forward protections and `buzz-release-bot` as its sole always-bypass
actor
- The `buzz-release-bot` App credentials configured for GitHub Actions
- The following **GitHub Actions variables and secrets** configured for the
desktop release lane:
| Name | Kind | Purpose |
|------|------|---------|
| `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags |
| `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key |
| `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` |
| `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket |
| `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key |
| `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key |
Mobile candidate publication requires workflow-dispatch access and the existing
release App because strict tag protection denies direct human creation. The App
must be installed on `block/buzz`, have Contents write and Metadata read, and
retain an `always` bypass on the immutable `mobile-v*` tag rules. It does not
require GitHub Releases permissions, repository Administration permission, or a
mobile release-branch ruleset. The publisher validates the App token's effective
`current_user_can_bypass` value rather than reading the ruleset's hidden bypass
actor list.
---
## Troubleshooting
### The desktop candidate is stale or cannot be squash merged
Do not update the branch manually and do not weaken the ruleset. Run
`just release-desktop <version>` again from current `main`; this regenerates the
candidate, reruns CI, and requires a fresh trusted approval on the new exact
head. The post-merge verifier refuses to tag a squash whose parent differs from
the recorded candidate base or whose tree differs from the validated PR head.
### Local `just release-desktop` fails with "must be on main branch"
Switch to `main` and pull latest before running the release recipe.
### Local `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits land after publishing a mobile candidate
Run `scripts/mobile-release.sh candidate <version>` again after the intended
fix reaches remote `main`. It publishes a new immutable RC tag at the new exact
remote commit. Continue referring to each tested or shipped platform artifact by
its own exact tag.
### `scripts/mobile-release.sh candidate` fails because `main` moved during publication
The App-backed workflow may already have published the requested immutable RC
at the prior `main` tip before the operator command detects the race. Do not
move or delete that tag, and do not treat it as the candidate for current
`main`. Inspect the run URL from the command output, then rerun
`scripts/mobile-release.sh candidate <version>` to publish the next RC from the
new current `main` tip.
### A mobile candidate command selects the wrong RC number
Do not retry by moving or deleting a tag. Inspect the exact remote `mobile-v*`
tags and resolve the unexpected state. Candidate numbers are monotonically
increasing remote identities.
### A mobile candidate publication is rejected by repository rules
Confirm `buzz-release-bot` remains the sole always-bypass actor for the active
`mobile-v*` ruleset and that its Actions credentials are available. Do not grant
direct human creation or weaken update or deletion protection. Existing
candidate tags must remain immutable.
### 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.
+126
View File
@@ -0,0 +1,126 @@
# 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
**buzz@block.xyz**. 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.
+311
View File
@@ -0,0 +1,311 @@
# 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.
To exercise deferred ACP startup, add `BUZZ_ACP_LAZY_POOL=true` before launching
`buzz-acp`. The harness should connect, authenticate, subscribe, and publish
online presence without starting the configured ACP child. The first accepted,
flushable mention should start exactly one child and then dispatch the queued
message. Automated coverage in `pool_lifecycle_state` pins single-wake,
retry/backoff, and stale-result behavior; it does not replace this real
relay/process smoke test.
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 1090s, 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_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. |
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
| `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) |
+244
View File
@@ -0,0 +1,244 @@
# 🐝 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 an owner/admin | 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.
---
## Remote Agents
An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture.
---
## 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 |
| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review |
| 📋 | 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.*
+65
View File
@@ -0,0 +1,65 @@
# 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.
+70
View File
@@ -0,0 +1,70 @@
# 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
+53
View File
@@ -0,0 +1,53 @@
# 🕸️ 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.*
+71
View File
@@ -0,0 +1,71 @@
# 🛡️ 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.*
+265
View File
@@ -0,0 +1,265 @@
# 🐝 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, no custom kind for the repo itself.
NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
---
## One Project, Many Repos
Real work spans repositories. The platform is a relay, a desktop app, and a mobile app — three repos, one project. Render one card per repo and they look like three unrelated things.
Grouping is the one forge semantic that per-repo tags cannot express, and it's worth being precise about why, because everything else here deliberately avoids a custom kind.
Put membership in each `kind:30617` and a project spanning Alice's and Bob's repos needs *both* of them to publish a tag naming the group. Alice can't enroll Bob's repo — she can't sign for his key. Cross-owner grouping becomes impossible, and the project's own name, description, and channel end up scattered across events with no single writer and no deletion story: dropping a repo from the group would mean editing an event you don't control.
So there is exactly one custom kind — [NIP-MP](docs/nips/NIP-MP.md), `kind:30621`. One signer, one replaceable event, all group state in one place:
```json
{
"kind": 30621,
"tags": [
["d", "platform"],
["name", "Platform"],
["a", "30617:<alice-npub-hex>:buzz"],
["a", "30617:<bob-npub-hex>:buzz-infra"],
["buzz-channel", "<channel-uuid>"],
["buzz-visibility", "listed"]
]
}
```
A project points at repos. That's all it does. The signer gets no authority over any member — no edit, no delete, no push, no admin. Adding Bob's repo to your project is your signed assertion that the two belong together, and it changes nothing about Bob's repo or who can push to it. Push policy reads the repo's own event, never the project's.
The cost is stated plainly: a third-party NIP-34 client sees the member repos individually and ignores the grouping. Nothing degrades — the repos are still standard, portable `kind:30617` events. And a repo in no project still renders on its own, exactly as before.
---
## 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 |
| **Multi-repo projects** | — | 30621 ([NIP-MP](docs/nips/NIP-MP.md)) | Cross-owner grouping is unexpressible in per-repo tags |
| **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 |
| Multi-repo projects (kind:30621, [NIP-MP](docs/nips/NIP-MP.md)) | 📋 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.*
+73
View File
@@ -0,0 +1,73 @@
# 🛰️ Buzz Remote Agents — Same agent, new body
> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation.
An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working.
Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing.
---
## Same Agent, New Body
What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine.
So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)).
---
## The Only Tether
Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate.
Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance.
This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote.
---
## Bodies Are Replaceable
Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately.
Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)).
The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle.
---
## Agents That Know When to Leave
The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing.
Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact.
---
## Honest Costs
**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work.
**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide.
**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did.
**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought.
**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there.
**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about three minutes if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. A bounded wrong dot, never an indefinite one.
**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu.
These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are.
---
## The Point
The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether.
---
*Buzz 🐝 — your agent, everywhere.*
+242
View File
@@ -0,0 +1,242 @@
# 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.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Buzz 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "buzz-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"lint": "biome lint .",
"check": "biome check . && pnpm typecheck && pnpm test",
"format": "biome format --write .",
"test": "vitest run src --passWithNoTests",
"test:e2e": "pnpm build && playwright test"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"jsdom": "^27.3.0",
"typescript": "~6.0.0",
"vitest": "^4.1.1"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
webServer: {
command: "pnpm exec vite preview --host 127.0.0.1 --port 4174",
url: "http://127.0.0.1:4174",
reuseExistingServer: true,
},
use: { baseURL: "http://127.0.0.1:4174" },
});
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 466 309">
<style>
.mark { fill: #231e1e; }
@media (prefers-color-scheme: dark) {
.mark { fill: #d7d72e; }
}
</style>
<defs>
<mask id="bee-mask">
<circle cx="91.7" cy="154.5" r="91.7" fill="white"/>
<circle cx="374.3" cy="154.5" r="91.7" fill="white"/>
<rect x="128" y="0" width="210" height="309" rx="34" fill="white"/>
<circle cx="193.3" cy="84.4" r="27" fill="black"/>
<circle cx="276" cy="84.4" r="27" fill="black"/>
<rect x="166.3" y="157.2" width="136.9" height="38.3" rx="5" fill="black"/>
<rect x="166.9" y="235.1" width="136.2" height="37.6" rx="5" fill="black"/>
</mask>
</defs>
<rect class="mark" width="466" height="309" mask="url(#bee-mask)"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

+917
View File
@@ -0,0 +1,917 @@
import {
type ChangeEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ApiFailure, request } from "./api";
import { useI18n, type MessageKey } from "./i18n";
import type {
FeedbackDetail,
FeedbackSummary,
Report,
ReportDetail as ReportDetailData,
} from "./types";
import { useResource } from "./useResource";
function usePath() {
const [path, setPath] = useState(location.pathname);
useEffect(() => {
const update = () => setPath(location.pathname);
addEventListener("popstate", update);
return () => removeEventListener("popstate", update);
}, []);
const navigate = useCallback((url: string) => {
history.pushState(null, "", url);
dispatchEvent(new PopStateEvent("popstate"));
}, []);
return { path, navigate };
}
function Link({
href,
className,
activeWhenNested = false,
children,
}: {
href: string;
className?: string;
activeWhenNested?: boolean;
children: ReactNode;
}) {
const { path, navigate } = usePath();
const active =
path === href || (activeWhenNested && path.startsWith(`${href}/`));
return (
<a
href={href}
className={className}
aria-current={active ? "page" : undefined}
onClick={(event) => {
if (!event.metaKey && !event.ctrlKey) {
event.preventDefault();
navigate(href);
}
}}
>
{children}
</a>
);
}
function StateView<T>({
resource,
children,
}: {
resource: ReturnType<typeof useResource<T>>;
children: (data: T) => ReactNode;
}) {
const { t } = useI18n();
if (resource.loading && !resource.data)
return <div className="state">{t("loading")}</div>;
if (resource.error && !resource.data) {
const forbidden =
resource.error instanceof ApiFailure && resource.error.status === 403;
return (
<div className="state error" role="alert">
<h2>{forbidden ? t("accessDenied") : t("couldNotLoadData")}</h2>
<p>{t("requestFailed")}</p>
<button type="button" onClick={resource.refetch}>
{t("retry")}
</button>
</div>
);
}
return resource.data ? children(resource.data) : null;
}
function Reports() {
const { t, locale } = useI18n();
const resource = useResource(
() => request<Report[]>("/reports?status=open&limit=100"),
"reports",
);
return (
<Page
eyebrow={t("moderation")}
title={t("openReports")}
description={t("reportsDescription")}
>
<StateView resource={resource}>
{(reports) =>
reports.length ? (
<div className="cards">
{reports.map((report) => (
<Link
key={report.id}
href={`/reports/${report.id}`}
className="card-link"
>
<article className="record-card">
<span className="record-icon report-icon">
<ReportIcon />
</span>
<div className="record-primary">
<span className="tag">
{dataLabel(t, report.reportType, reportTypeLabels)}
</span>
<strong>{report.communityHost}</strong>
<code>
{dataLabel(t, report.targetKind, targetKindLabels)}:{" "}
{short(report.target)}
</code>
</div>
<div className="record-date">
<span>{t("submitted")}</span>
<time>
{date(report.createdAt, locale, t("unknownDate"))}
</time>
</div>
<ArrowIcon />
</article>
</Link>
))}
</div>
) : (
<Empty />
)
}
</StateView>
</Page>
);
}
function ReportDetail({ id }: { id: string }) {
const { t, locale } = useI18n();
const resource = useResource(
() => request<ReportDetailData>(`/reports/${id}`),
id,
);
return (
<Page
eyebrow={t("moderation")}
title={t("reportDetail")}
description={t("reportDetailDescription")}
back="/reports"
>
<StateView resource={resource}>
{(report) => (
<article className="detail">
<div className="detail-heading">
<span className="record-icon report-icon">
<ReportIcon />
</span>
<div>
<span className="tag">
{dataLabel(t, report.reportType, reportTypeLabels)}
</span>
<h2>{report.communityHost}</h2>
</div>
</div>
<dl>
<dt>{t("status")}</dt>
<dd>
<span className="status">
{dataLabel(t, report.status, reportStatusLabels)}
</span>
</dd>
<dt>{t("reporter")}</dt>
<dd>
<code>{report.reporterPubkey}</code>
</dd>
<dt>{t("target")}</dt>
<dd>
<code>{report.target}</code>
</dd>
{report.targetKind === "event" ? (
<>
<dt>{t("message")}</dt>
<dd>
{report.message ? (
<div className="reported-message">
{report.message.deletedAt ? (
<span className="status">{t("deleted")}</span>
) : null}
<p>{report.message.content}</p>
<div className="reported-message-meta">
<span>{t("author")}</span>
<code>{report.message.authorPubkey}</code>
<span>{t("created")}</span>
<time>
{date(
report.message.createdAt,
locale,
t("unknownDate"),
)}
</time>
</div>
</div>
) : (
<p className="message-unavailable">
{t("messageUnavailable")}
</p>
)}
</dd>
</>
) : null}
<dt>{t("note")}</dt>
<dd className="sensitive">{report.note ?? t("noNote")}</dd>
</dl>
</article>
)}
</StateView>
</Page>
);
}
function FeedbackList() {
const { t, locale } = useI18n();
const resource = useResource(
() => request<FeedbackSummary[]>("/feedback"),
"feedback",
);
const [query, setQuery] = useState("");
const [community, setCommunity] = useState("all");
const [timeRange, setTimeRange] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [statuses, setStatuses] = useState(loadFeedbackStatuses);
const updateStatus = (id: string, event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
setStatuses((current) => {
const next = {
...current,
[id]: checked,
};
saveFeedbackStatuses(next);
return next;
});
};
return (
<Page
eyebrow={t("product")}
title={t("feedback")}
description={t("feedbackDescription")}
>
<StateView resource={resource}>
{(items) => {
if (!items.length) return <Empty />;
return (
<FeedbackResults
items={items}
query={query}
community={community}
timeRange={timeRange}
statusFilter={statusFilter}
statuses={statuses}
>
{({ communities, filtered }) => (
<>
<div className="feedback-filters">
<label className="search-field">
<span>{t("searchFeedback")}</span>
<div>
<SearchIcon />
<input
type="search"
placeholder={t("searchFeedback")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</label>
<label>
<span>{t("community")}</span>
<select
value={community}
onChange={(event) => setCommunity(event.target.value)}
>
<option value="all">{t("allCommunities")}</option>
{communities.map((host) => (
<option key={host} value={host}>
{host}
</option>
))}
</select>
</label>
<label>
<span>{t("received")}</span>
<select
value={timeRange}
onChange={(event) => setTimeRange(event.target.value)}
>
<option value="all">{t("anyTime")}</option>
<option value="day">{t("last24Hours")}</option>
<option value="week">{t("last7Days")}</option>
<option value="month">{t("last30Days")}</option>
</select>
</label>
<label>
<span>{t("status")}</span>
<select
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value)
}
>
<option value="all">{t("anyStatus")}</option>
<option value="pending">{t("needsAction")}</option>
<option value="acted-on">{t("actedOn")}</option>
</select>
</label>
</div>
<p className="result-count" aria-live="polite">
{t("submissionsCount", {
filtered: filtered.length,
total: items.length,
})}
</p>
{filtered.length ? (
<div className="cards">
{filtered.map((item) => (
<article
key={item.id}
className="record-card feedback-card feedback-record"
>
<Link
href={`/feedback/${item.id}`}
className="feedback-main-link"
>
<span className="record-icon feedback-icon">
<CategoryIcon category={item.category} />
</span>
<div className="record-primary">
<CategoryTag category={item.category} />
<strong>{item.bodySummary}</strong>
<span className="record-provenance">
{item.communityHost}
<code>{short(item.submitterPubkey)}</code>
</span>
</div>
</Link>
<label className="feedback-status">
<input
type="checkbox"
checked={statuses[item.id] ?? false}
onChange={(event) => updateStatus(item.id, event)}
/>
{t("actedOn")}
<span className="visually-hidden">
{t("feedback")} · {item.communityHost}
</span>
</label>
<div className="record-date">
<span>{t("received")}</span>
<time>
{date(item.receivedAt, locale, t("unknownDate"))}
</time>
</div>
<Link
href={`/feedback/${item.id}`}
className="record-open-link"
>
<span className="visually-hidden">
{t("feedback")} · {item.communityHost}
</span>
<ArrowIcon />
</Link>
</article>
))}
</div>
) : (
<div className="state">{t("noMatchingFeedback")}</div>
)}
</>
)}
</FeedbackResults>
);
}}
</StateView>
</Page>
);
}
function FeedbackResults({
items,
query,
community,
timeRange,
statusFilter,
statuses,
children,
}: {
items: FeedbackSummary[];
query: string;
community: string;
timeRange: string;
statusFilter: string;
statuses: FeedbackStatuses;
children: (results: {
communities: string[];
filtered: FeedbackSummary[];
}) => ReactNode;
}) {
const results = useMemo(() => {
const communities = [...new Set(items.map((item) => item.communityHost))]
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
const normalizedQuery = query.trim().toLocaleLowerCase();
const after = timeRangeStart(timeRange);
const filtered = items.filter((item) => {
if (community !== "all" && item.communityHost !== community) return false;
if (statusFilter === "pending" && statuses[item.id]) return false;
if (statusFilter === "acted-on" && !statuses[item.id]) return false;
if (after !== undefined) {
const receivedAt = new Date(item.receivedAt).valueOf();
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
}
if (!normalizedQuery) return true;
return [
item.bodySummary,
item.communityHost,
item.category ?? "uncategorized",
item.submitterPubkey,
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
});
return { communities, filtered };
}, [items, query, community, timeRange, statusFilter, statuses]);
return children(results);
}
function FeedbackDetailView({ id }: { id: string }) {
const { t, locale } = useI18n();
const resource = useResource(
() => request<FeedbackDetail>(`/feedback/${id}`),
id,
);
return (
<Page
eyebrow={t("product")}
title={t("feedbackDetail")}
description={t("feedbackDetailDescription")}
back="/feedback"
backLabel={t("backToFeedback")}
>
<StateView resource={resource}>
{(feedback) => {
const attachments = feedbackAttachments(
feedback.id,
feedback.tags,
feedback.communityHost,
);
const body = stripAttachmentMarkdown(feedback.body, attachments);
return (
<article className="detail">
<div className="detail-heading">
<span className="record-icon feedback-icon">
<CategoryIcon category={feedback.category} />
</span>
<div>
<CategoryTag category={feedback.category} />
<h2>{feedback.communityHost}</h2>
</div>
</div>
<dl>
<dt>{t("feedback")}</dt>
<dd className="sensitive feedback-body">{body}</dd>
{attachments.length ? (
<>
<dt>{t("attachments")}</dt>
<dd className="attachments">
{attachments.map((attachment) => (
<Attachment
key={`${attachment.hash}-${attachment.url}`}
attachment={attachment}
/>
))}
</dd>
</>
) : null}
<dt>{t("submittedBy")}</dt>
<dd>
<code>{feedback.submitterPubkey}</code>
</dd>
<dt>{t("event")}</dt>
<dd>
<code>{feedback.eventId}</code>
</dd>
<dt>{t("created")}</dt>
<dd>
{date(feedback.eventCreatedAt, locale, t("unknownDate"))}
</dd>
<dt>{t("received")}</dt>
<dd>{date(feedback.receivedAt, locale, t("unknownDate"))}</dd>
</dl>
</article>
);
}}
</StateView>
</Page>
);
}
type FeedbackStatuses = Record<string, boolean>;
interface FeedbackAttachment {
url: string;
sourceUrl: string;
mimeType: string;
hash: string;
size?: number;
dimensions?: string;
filename?: string;
}
const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status";
function loadFeedbackStatuses(): FeedbackStatuses {
try {
const stored = localStorage.getItem(FEEDBACK_STATUS_KEY);
return stored ? (JSON.parse(stored) as FeedbackStatuses) : {};
} catch {
return {};
}
}
function saveFeedbackStatuses(statuses: FeedbackStatuses) {
try {
localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses));
} catch {
// The controls remain useful for the current session if storage is blocked.
}
}
function timeRangeStart(range: string) {
const durations: Record<string, number> = {
day: 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000,
month: 30 * 24 * 60 * 60 * 1000,
};
const duration = durations[range];
return duration ? Date.now() - duration : undefined;
}
function feedbackAttachments(
feedbackId: string,
tags: string[][],
communityHost: string,
): FeedbackAttachment[] {
return tags.flatMap((tag) => {
if (tag[0] !== "imeta") return [];
const values = new Map<string, string>();
for (const entry of tag.slice(1)) {
const separator = entry.indexOf(" ");
if (separator > 0) {
values.set(entry.slice(0, separator), entry.slice(separator + 1));
}
}
const url = values.get("url");
const mimeType = values.get("m");
const hash = values.get("x");
const safeUrl = url && safeAttachmentUrl(url, communityHost);
if (!safeUrl || !mimeType || !hash) return [];
const parsedSize = Number(values.get("size"));
return [
{
url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
sourceUrl: safeUrl,
mimeType,
hash,
size:
Number.isFinite(parsedSize) && parsedSize > 0
? parsedSize
: undefined,
dimensions: values.get("dim"),
filename: values.get("filename"),
},
];
});
}
function safeAttachmentUrl(value: string, communityHost: string) {
try {
const url = new URL(value, `${location.protocol}//${communityHost}`);
return ["http:", "https:"].includes(url.protocol) &&
url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
url.pathname.startsWith("/media/")
? url.href
: undefined;
} catch {
return undefined;
}
}
function stripAttachmentMarkdown(
body: string,
attachments?: FeedbackAttachment[],
) {
const knownUrls = attachments
? new Set(attachments.map((attachment) => attachment.sourceUrl))
: undefined;
return body
.replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
return isMedia ? "" : match;
})
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
const url = attachment.url;
const name =
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
const metadata = [
attachment.mimeType,
attachment.dimensions,
attachment.size ? formatBytes(attachment.size) : undefined,
]
.filter(Boolean)
.join(" · ");
if (attachment.mimeType.startsWith("image/")) {
return (
<figure className="image-attachment">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={name} loading="lazy" />
</a>
<figcaption>
<span>{name}</span>
<small>{metadata}</small>
</figcaption>
</figure>
);
}
return (
<a
className="file-attachment"
href={url}
target="_blank"
rel="noreferrer"
download={name}
>
<FileIcon />
<span>
<strong>{name}</strong>
<small>{metadata}</small>
</span>
<ArrowIcon />
</a>
);
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function Page({
eyebrow,
title,
description,
back,
backLabel,
children,
}: {
eyebrow: string;
title: string;
description: string;
back?: string;
backLabel?: string;
children: ReactNode;
}) {
const { t } = useI18n();
return (
<section>
<header className="page-title">
{back ? (
<Link href={back} className="back-link">
<ArrowIcon /> {backLabel ?? t("backToReports")}
</Link>
) : null}
<p>{eyebrow}</p>
<h1>{title}</h1>
<span>{description}</span>
</header>
{children}
</section>
);
}
function Empty() {
const { t } = useI18n();
return <div className="state">{t("noRecords")}</div>;
}
function short(value: string) {
return value.length > 20 ? `${value.slice(0, 10)}${value.slice(-8)}` : value;
}
function date(value: string, locale: string, unknownDate: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? unknownDate
: new Intl.DateTimeFormat(locale === "zh-Hans" ? "zh-CN" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
}
function BuzzMark() {
return (
<svg viewBox="0 0 466 309" aria-hidden="true">
<path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
<path
fillRule="evenodd"
d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
clipRule="evenodd"
/>
</svg>
);
}
function ReportIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
<path d="M12 7.5v5M12 16.5h.01" />
</svg>
);
}
function FeedbackIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5.5h14v10H9l-4 3v-13Z" />
<path d="M8.5 9h7M8.5 12h4.5" />
</svg>
);
}
function CategoryTag({ category }: { category?: string }) {
const { t } = useI18n();
const labels: Record<string, string> = {
bug: t("categoryBug"),
praise: t("categoryPraise"),
"needs-work": t("categoryNeedsWork"),
};
return (
<span className="tag">
<CategoryIcon category={category} />
{category ? (labels[category] ?? category) : t("uncategorized")}
</span>
);
}
const reportTypeLabels: Record<string, MessageKey> = {
spam: "reportSpam",
illegal: "reportIllegal",
nudity: "reportNudity",
malware: "reportMalware",
impersonation: "reportImpersonation",
profanity: "reportProfanity",
other: "reportOther",
};
const reportStatusLabels: Record<string, MessageKey> = {
open: "statusOpen",
resolved: "statusResolved",
dismissed: "statusDismissed",
escalated: "statusEscalated",
};
const targetKindLabels: Record<string, MessageKey> = {
event: "targetEvent",
pubkey: "targetPubkey",
blob: "targetBlob",
};
function dataLabel(
t: ReturnType<typeof useI18n>["t"],
value: string,
labels: Record<string, MessageKey>,
) {
const key = labels[value];
return key ? t(key) : value;
}
function CategoryIcon({ category }: { category?: string }) {
if (category === "bug") return <BugIcon />;
if (category === "praise") return <ThumbsUpIcon />;
if (category === "needs-work") return <WrenchIcon />;
return <FeedbackIcon />;
}
function BugIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>
);
}
function ThumbsUpIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
<path d="M7 10v12" />
</svg>
);
}
function WrenchIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4 4" />
</svg>
);
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 3h8l4 4v14H6V3Z" />
<path d="M14 3v5h4M9 13h6M9 17h4" />
</svg>
);
}
function ArrowIcon() {
return (
<svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
);
}
export function App() {
const { path } = usePath();
const { locale, setLocale, t } = useI18n();
const report = path.match(/^\/reports\/([^/]+)$/);
const feedback = path.match(/^\/feedback\/([^/]+)$/);
const content = report ? (
<ReportDetail id={report[1]} />
) : feedback ? (
<FeedbackDetailView id={feedback[1]} />
) : path === "/feedback" ? (
<FeedbackList />
) : (
<Reports />
);
return (
<div className="app">
<header className="app-header">
<Link href="/reports" className="brand">
<span className="brand-mark">
<BuzzMark />
</span>
<span>{t("buzzAdmin")}</span>
</Link>
<nav>
<Link href="/reports" className="nav-link" activeWhenNested>
<ReportIcon /> {t("reports")}
</Link>
<Link href="/feedback" className="nav-link" activeWhenNested>
<FeedbackIcon /> {t("feedback")}
</Link>
</nav>
<fieldset className="locale-switcher">
<legend className="visually-hidden">{t("localeSwitch")}</legend>
<button
type="button"
className={locale === "zh-Hans" ? "active" : undefined}
aria-pressed={locale === "zh-Hans"}
onClick={() => setLocale("zh-Hans")}
>
{t("localeChinese")}
</button>
<button
type="button"
className={locale === "en" ? "active" : undefined}
aria-pressed={locale === "en"}
onClick={() => setLocale("en")}
>
{t("localeEnglish")}
</button>
</fieldset>
</header>
<main>{content}</main>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
const PREFIX = "/api/admin/v1";
export class ApiFailure extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export async function request<T>(path: string): Promise<T> {
const response = await fetch(`${PREFIX}${path}`, {
credentials: "same-origin",
headers: { accept: "application/json" },
});
if (!response.ok) {
const envelope = await response.json().catch(() => null);
throw new ApiFailure(
response.status,
envelope?.error?.message ?? `Request failed (${response.status})`,
);
}
return response.json() as Promise<T>;
}
+76
View File
@@ -0,0 +1,76 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { messages, type Locale, type MessageKey } from "./messages";
const STORAGE_KEY = "buzz-admin-locale";
const DEFAULT_LOCALE: Locale = "zh-Hans";
interface I18nValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: MessageKey, values?: Record<string, string | number>) => string;
}
const I18nContext = createContext<I18nValue | null>(null);
function readLocale(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh-Hans") return stored;
} catch {
// Storage may be disabled; the default remains usable.
}
return DEFAULT_LOCALE;
}
function interpolate(
template: string,
values?: Record<string, string | number>,
): string {
if (!values) return template;
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) =>
String(values[key] ?? `{{${key}}}`),
);
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(readLocale);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Keep the current session usable when storage is blocked.
}
}, []);
const t = useCallback(
(key: MessageKey, values?: Record<string, string | number>) =>
interpolate(messages[locale][key] ?? messages.en[key], values),
[locale],
);
useEffect(() => {
document.documentElement.lang = locale;
document.title = t("appTitle");
}, [locale, t]);
const value = useMemo(
() => ({ locale, setLocale, t }),
[locale, setLocale, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const value = useContext(I18nContext);
if (!value) throw new Error("useI18n must be used inside I18nProvider");
return value;
}
export { DEFAULT_LOCALE, STORAGE_KEY };
export type { Locale, MessageKey } from "./messages";
+158
View File
@@ -0,0 +1,158 @@
export const en = {
appTitle: "Buzz admin",
localeName: "English",
localeSwitch: "Language",
localeEnglish: "English",
localeChinese: "简体中文",
loading: "Loading…",
accessDenied: "Access denied",
couldNotLoadData: "Could not load data",
retry: "Retry",
requestFailed: "The request failed. Please try again.",
moderation: "Moderation",
openReports: "Open reports",
reportsDescription: "Review reports across every Buzz community.",
submitted: "Submitted",
reportDetail: "Report detail",
reportDetailDescription: "The full report as submitted to the relay.",
status: "Status",
reporter: "Reporter",
target: "Target",
message: "Message",
deleted: "Deleted",
author: "Author",
created: "Created",
messageUnavailable:
"Message content is unavailable. It may have expired or been removed from event storage.",
note: "Note",
noNote: "No note provided.",
product: "Product",
feedback: "Feedback",
feedbackDescription: "Recent product feedback from across Buzz.",
searchFeedback: "Search feedback",
community: "Community",
allCommunities: "All communities",
received: "Received",
anyTime: "Any time",
last24Hours: "Last 24 hours",
last7Days: "Last 7 days",
last30Days: "Last 30 days",
anyStatus: "Any status",
needsAction: "Needs action",
actedOn: "Acted on",
submissionsCount: "{{filtered}} of {{total}} submissions",
noMatchingFeedback: "No matching feedback.",
feedbackDetail: "Feedback detail",
feedbackDetailDescription: "The complete feedback submission and its source.",
backToFeedback: "Back to feedback",
backToReports: "Back to reports",
attachments: "Attachments",
submittedBy: "Submitted by",
event: "Event",
noRecords: "No records.",
unknownDate: "Unknown date",
uncategorized: "Uncategorized",
categoryBug: "Bug",
categoryPraise: "Praise",
categoryNeedsWork: "Needs work",
buzzAdmin: "Buzz Admin",
reports: "Reports",
reportSpam: "Spam",
reportIllegal: "Illegal content",
reportNudity: "Nudity",
reportMalware: "Malware",
reportImpersonation: "Impersonation",
reportProfanity: "Profanity",
reportOther: "Other",
statusOpen: "Open",
statusResolved: "Resolved",
statusDismissed: "Dismissed",
statusEscalated: "Escalated",
targetEvent: "Event",
targetPubkey: "Public key",
targetBlob: "File",
languageUpdated: "Language updated",
} as const;
export const zhHans: Record<keyof typeof en, string> = {
appTitle: "Buzz 管理后台",
localeName: "简体中文",
localeSwitch: "语言",
localeEnglish: "English",
localeChinese: "简体中文",
loading: "加载中…",
accessDenied: "无权访问",
couldNotLoadData: "无法加载数据",
retry: "重试",
requestFailed: "请求失败,请稍后重试。",
moderation: "内容审核",
openReports: "待处理举报",
reportsDescription: "查看所有 Buzz 社区的举报。",
submitted: "提交时间",
reportDetail: "举报详情",
reportDetailDescription: "查看提交到 relay 的完整举报内容。",
status: "状态",
reporter: "举报人",
target: "目标",
message: "消息",
deleted: "已删除",
author: "作者",
created: "创建时间",
messageUnavailable: "消息内容不可用,可能已过期或已从事件存储中移除。",
note: "备注",
noNote: "未提供备注。",
product: "产品",
feedback: "反馈",
feedbackDescription: "查看来自 Buzz 各社区的近期产品反馈。",
searchFeedback: "搜索反馈",
community: "社区",
allCommunities: "所有社区",
received: "收到时间",
anyTime: "不限时间",
last24Hours: "过去 24 小时",
last7Days: "过去 7 天",
last30Days: "过去 30 天",
anyStatus: "不限状态",
needsAction: "待处理",
actedOn: "已处理",
submissionsCount: "共 {{total}} 条,当前显示 {{filtered}} 条",
noMatchingFeedback: "没有匹配的反馈。",
feedbackDetail: "反馈详情",
feedbackDetailDescription: "查看完整反馈内容及其来源。",
backToFeedback: "返回反馈列表",
backToReports: "返回举报列表",
attachments: "附件",
submittedBy: "提交人",
event: "事件",
noRecords: "暂无记录。",
unknownDate: "未知日期",
uncategorized: "未分类",
categoryBug: "问题",
categoryPraise: "表扬",
categoryNeedsWork: "需要改进",
buzzAdmin: "Buzz 管理后台",
reports: "举报",
reportSpam: "垃圾信息",
reportIllegal: "违规内容",
reportNudity: "裸露内容",
reportMalware: "恶意软件",
reportImpersonation: "冒充他人",
reportProfanity: "粗俗内容",
reportOther: "其他",
statusOpen: "待处理",
statusResolved: "已解决",
statusDismissed: "已驳回",
statusEscalated: "已升级",
targetEvent: "事件",
targetPubkey: "公钥",
targetBlob: "文件",
languageUpdated: "语言已更新",
};
export type MessageKey = keyof typeof en;
export type Locale = "en" | "zh-Hans";
export const messages: Record<Locale, Record<MessageKey, string>> = {
en,
"zh-Hans": zhHans,
};
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { I18nProvider } from "./i18n";
import "./styles.css";
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
createRoot(root).render(
<StrictMode>
<I18nProvider>
<App />
</I18nProvider>
</StrictMode>,
);
+759
View File
@@ -0,0 +1,759 @@
:root {
font-family:
"Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
color: #231e1e;
background: #d7e7f6;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
input,
select {
font: inherit;
}
button {
color: white;
background: #231e1e;
border: 0;
border-radius: 999px;
padding: 0.75rem 1.25rem;
cursor: pointer;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
}
.app-header {
width: min(1120px, calc(100% - 3rem));
margin: 0 auto;
padding: 1.5rem 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.04em;
}
.brand b {
font-weight: 400;
color: rgb(35 30 30 / 60%);
}
.brand-mark {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: 0.7rem;
color: #d7d72e;
background: #231e1e;
}
.brand-mark svg {
width: 1.7rem;
fill: currentColor;
}
nav {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 999px;
background: rgb(255 255 255 / 42%);
backdrop-filter: blur(12px);
}
.nav-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1rem;
border-radius: 999px;
color: rgb(35 30 30 / 60%);
font-size: 0.9rem;
letter-spacing: -0.035em;
transition:
color 160ms ease,
background 160ms ease;
}
.nav-link svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-link:hover {
color: #231e1e;
}
.nav-link[aria-current="page"] {
color: #231e1e;
background: white;
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
}
.locale-switcher {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.25rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 999px;
background: rgb(255 255 255 / 42%);
backdrop-filter: blur(12px);
}
.locale-switcher button {
padding: 0.45rem 0.7rem;
color: rgb(35 30 30 / 60%);
background: transparent;
font-size: 0.78rem;
}
.locale-switcher button.active {
color: #231e1e;
background: white;
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
}
main {
width: min(1120px, calc(100% - 3rem));
margin: 0 auto;
padding: 4.5rem 0 7rem;
}
.page-title {
max-width: 52rem;
margin-bottom: 3rem;
}
.page-title > p {
margin: 0 0 1rem;
color: rgb(35 30 30 / 55%);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(3rem, 7vw, 5.5rem);
font-weight: 400;
line-height: 0.92;
letter-spacing: -0.065em;
}
.page-title > span {
display: block;
margin-top: 1.5rem;
color: rgb(35 30 30 / 62%);
font-size: 1.05rem;
line-height: 1.5;
letter-spacing: -0.035em;
}
.back-link {
width: fit-content;
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 2.25rem;
color: rgb(35 30 30 / 62%);
font-size: 0.9rem;
letter-spacing: -0.035em;
}
.cards {
display: grid;
gap: 0.8rem;
}
.feedback-filters {
display: grid;
grid-template-columns: minmax(14rem, 1fr) repeat(3, auto);
gap: 0.75rem;
align-items: end;
margin-bottom: 0.75rem;
padding: 1rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1.25rem;
background: rgb(255 255 255 / 45%);
backdrop-filter: blur(12px);
}
.feedback-filters label {
display: grid;
gap: 0.45rem;
}
.feedback-filters label > span {
color: rgb(35 30 30 / 48%);
font-size: 0.7rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.feedback-filters input,
.feedback-filters select {
min-height: 2.75rem;
border: 1px solid rgb(35 30 30 / 12%);
border-radius: 999px;
outline: none;
color: #231e1e;
background: white;
}
.feedback-filters input:focus,
.feedback-filters select:focus {
border-color: #231e1e;
box-shadow: 0 0 0 3px rgb(35 30 30 / 10%);
}
.feedback-filters select {
padding: 0 2.25rem 0 1rem;
}
.search-field > div {
position: relative;
}
.search-field input {
width: 100%;
padding: 0 1rem 0 2.6rem;
}
.search-field svg {
position: absolute;
z-index: 1;
top: 50%;
left: 1rem;
width: 1rem;
height: 1rem;
transform: translateY(-50%);
fill: none;
stroke: rgb(35 30 30 / 45%);
stroke-width: 1.7;
stroke-linecap: round;
}
.result-count {
margin: 0 0 0.75rem;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
}
.card-link {
display: block;
border-radius: 1.6rem;
}
.record-card,
.detail,
.state {
background: white;
border-radius: 1.6rem;
box-shadow: 0 1rem 4rem rgb(35 30 30 / 6%);
}
.record-card {
min-height: 7.2rem;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 1.4rem;
padding: 1.2rem 1.5rem;
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.feedback-record {
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.feedback-main-link {
min-width: 0;
display: flex;
align-items: center;
gap: 1.4rem;
}
.record-open-link:hover {
text-decoration: underline;
text-underline-offset: 0.2rem;
}
.feedback-status {
min-width: 5rem;
display: flex;
align-items: center;
gap: 0.4rem;
color: rgb(35 30 30 / 62%);
font-size: 0.78rem;
cursor: pointer;
}
.feedback-status input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #231e1e;
}
.record-open-link {
display: grid;
place-items: center;
padding: 0.5rem;
border-radius: 999px;
color: rgb(35 30 30 / 30%);
}
.card-link:hover .record-card {
transform: translateY(-2px);
box-shadow: 0 1.4rem 4.5rem rgb(35 30 30 / 12%);
}
.record-icon {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 999px;
color: #231e1e;
}
.report-icon {
background: #d7d72e;
}
.feedback-icon {
background: #d7e7f6;
}
.record-icon svg {
width: 1.35rem;
height: 1.35rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.record-primary {
min-width: 0;
display: grid;
justify-items: start;
gap: 0.35rem;
}
.tag,
.status {
display: inline-flex;
align-items: center;
border-radius: 999px;
background: #f2f2ed;
padding: 0.25rem 0.55rem;
color: rgb(35 30 30 / 65%);
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tag {
gap: 0.3rem;
}
.tag svg {
width: 0.78rem;
height: 0.78rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.record-primary strong {
max-width: 42rem;
overflow: hidden;
color: #231e1e;
font-size: 1rem;
font-weight: 400;
line-height: 1.35;
letter-spacing: -0.04em;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-card .record-primary strong {
display: -webkit-box;
overflow: hidden;
line-height: 1.35;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.record-provenance {
display: flex;
align-items: center;
gap: 0.65rem;
color: rgb(35 30 30 / 55%);
font-size: 0.78rem;
}
code {
color: rgb(35 30 30 / 50%);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
word-break: break-all;
}
.record-provenance code {
padding-left: 0.65rem;
border-left: 1px solid rgb(35 30 30 / 14%);
}
.record-date {
display: grid;
justify-items: end;
gap: 0.3rem;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
letter-spacing: -0.025em;
}
.record-date span {
color: rgb(35 30 30 / 32%);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.arrow-icon {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.back-link .arrow-icon {
transform: rotate(180deg);
}
.record-card > .arrow-icon {
color: rgb(35 30 30 / 30%);
}
.detail {
padding: 2rem;
}
.detail-heading {
display: flex;
align-items: center;
gap: 1rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgb(35 30 30 / 9%);
}
.detail-heading h2 {
margin: 0.45rem 0 0;
font-size: 1.5rem;
font-weight: 400;
letter-spacing: -0.05em;
}
dl {
display: grid;
grid-template-columns: 9rem minmax(0, 1fr);
gap: 1.4rem;
margin: 2rem 0 0;
}
dt {
color: rgb(35 30 30 / 48%);
font-size: 0.82rem;
}
dd {
min-width: 0;
margin: 0;
}
.reported-message {
display: grid;
justify-items: start;
gap: 1rem;
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
background: #f6f6f1;
padding: 1rem;
}
.reported-message p,
.message-unavailable {
margin: 0;
overflow-wrap: anywhere;
line-height: 1.5;
white-space: pre-wrap;
}
.reported-message-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.4rem 0.75rem;
width: 100%;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
}
.message-unavailable {
color: rgb(35 30 30 / 60%);
font-style: italic;
}
.sensitive {
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
background: #f6f6f1;
padding: 1rem;
line-height: 1.5;
}
.feedback-body {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.attachments {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.75rem;
align-items: start;
}
.image-attachment {
overflow: hidden;
margin: 0;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1rem;
background: #f6f6f1;
}
.image-attachment a {
display: block;
}
.image-attachment img {
width: 100%;
max-height: 32rem;
display: block;
object-fit: contain;
background: rgb(35 30 30 / 4%);
}
.image-attachment figcaption,
.file-attachment {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.image-attachment figcaption {
justify-content: space-between;
}
.image-attachment figcaption span,
.file-attachment strong {
min-width: 0;
overflow: hidden;
font-size: 0.82rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-main-link:hover strong {
text-decoration: underline;
text-underline-offset: 0.2rem;
}
.image-attachment small,
.file-attachment small {
color: rgb(35 30 30 / 48%);
font-size: 0.7rem;
}
.file-attachment {
align-self: start;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1rem;
background: #f6f6f1;
}
.file-attachment:hover strong {
text-decoration: underline;
text-underline-offset: 0.15rem;
}
.file-attachment > svg:first-child {
width: 1.5rem;
height: 1.5rem;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-attachment > span {
min-width: 0;
display: grid;
flex: 1;
gap: 0.2rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.state {
min-height: 14rem;
display: grid;
place-content: center;
justify-items: center;
padding: 2rem;
color: rgb(35 30 30 / 56%);
text-align: center;
letter-spacing: -0.035em;
}
.state h2 {
margin: 0;
color: #231e1e;
font-size: 1.4rem;
font-weight: 400;
}
.state p {
margin: 0.75rem 0 1.25rem;
}
.state.error {
color: #9f2424;
}
@media (max-width: 720px) {
.app-header {
width: min(100% - 2rem, 1120px);
align-items: flex-start;
flex-direction: column;
gap: 1rem;
}
main {
width: min(100% - 2rem, 1120px);
padding-top: 3rem;
}
h1 {
font-size: clamp(3rem, 16vw, 4.5rem);
}
.record-card {
grid-template-columns: auto minmax(0, 1fr);
}
.feedback-filters {
grid-template-columns: 1fr;
}
.feedback-record {
grid-template-columns: minmax(0, 1fr) auto;
}
.feedback-main-link {
grid-column: 1 / -1;
}
.feedback-status {
grid-column: 1;
flex-wrap: wrap;
}
.feedback-record .record-date {
grid-column: 1;
}
.record-open-link {
grid-column: 2;
grid-row: 2 / span 2;
}
.record-date {
grid-column: 2;
justify-items: start;
}
.record-card > .arrow-icon {
display: none;
}
dl {
grid-template-columns: 1fr;
gap: 0.6rem;
}
dd + dt {
margin-top: 0.9rem;
}
}
+47
View File
@@ -0,0 +1,47 @@
export interface Report {
id: string;
communityId: string;
communityHost: string;
reporterPubkey: string;
targetKind: "event" | "pubkey" | "blob";
target: string;
channelId?: string;
reportType: string;
note?: string;
status: string;
createdAt: string;
}
export interface ReportedMessage {
authorPubkey: string;
content: string;
createdAt: string;
deletedAt: string | null;
}
export interface ReportDetail extends Report {
message: ReportedMessage | null;
}
export interface FeedbackSummary {
id: string;
communityId: string;
communityHost: string;
submitterPubkey: string;
category?: string;
bodySummary: string;
receivedAt: string;
}
export interface FeedbackDetail {
id: string;
communityId: string;
communityHost: string;
eventId: string;
submitterPubkey: string;
category?: string;
body: string;
tags: string[][];
eventCreatedAt: string;
receivedAt: string;
}
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface Resource<T> {
data?: T;
error?: Error;
loading: boolean;
stale: boolean;
refetch: () => void;
}
export function useResource<T>(
load: () => Promise<T>,
key: string,
): Resource<T> {
const [data, setData] = useState<T>();
const [error, setError] = useState<Error>();
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const loadRef = useRef(load);
const activeRequest = useRef("");
loadRef.current = load;
const refetch = useCallback(() => setRevision((value) => value + 1), []);
useEffect(() => {
const requestId = `${key}\0${revision}`;
activeRequest.current = requestId;
const isCurrent = () => activeRequest.current === requestId;
const loadCurrent = async () => {
setLoading(true);
setError(undefined);
try {
const value = await loadRef.current();
if (isCurrent()) setData(value);
} catch (reason) {
if (isCurrent()) {
setError(
reason instanceof Error ? reason : new Error("Request failed"),
);
}
} finally {
if (isCurrent()) setLoading(false);
}
};
void loadCurrent();
return () => {
activeRequest.current = "";
};
}, [key, revision]);
return {
data,
error,
loading,
stale: loading && data !== undefined,
refetch,
};
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+334
View File
@@ -0,0 +1,334 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("buzz-admin-locale", "en");
});
await page.route("**/api/admin/v1/**", async (route) => {
await route.fulfill({ contentType: "application/json", body: "[]" });
});
});
for (const [path, heading] of [
["/reports", "Open reports"],
["/feedback", "Feedback"],
]) {
test(`${path} supports a deep link and empty state`, async ({ page }) => {
await page.goto(path);
await expect(page.getByRole("heading", { name: heading })).toBeVisible();
await expect(page.getByText("No records.")).toBeVisible();
});
}
test("forbidden reads have an explicit state", async ({ page }) => {
await page.route("**/api/admin/v1/reports?**", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
error: { code: "forbidden", message: "request is not authorized" },
}),
}),
);
await page.goto("/reports");
await expect(
page.getByRole("heading", { name: "Access denied" }),
).toBeVisible();
});
test("report rows render the relay response contract", async ({ page }) => {
await page.route("**/api/admin/v1/reports?**", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id: "0e6caad8-1e18-4cd7-84fa-7264103f0a08",
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
reporterPubkey: "21".repeat(32),
targetKind: "event",
target: "12".repeat(32),
reportType: "spam",
status: "open",
createdAt: "2026-07-17T17:30:00Z",
},
]),
}),
);
await page.goto("/reports");
await expect(page.getByText("design.buzz.xyz")).toBeVisible();
await expect(page.getByText("Spam")).toBeVisible();
await expect(page.getByText("Unknown date")).toHaveCount(0);
});
test("defaults to Simplified Chinese and can switch to English", async ({
page,
}) => {
await page.addInitScript(() => localStorage.clear());
await page.goto("/reports");
await expect(page.getByRole("heading", { name: "待处理举报" })).toBeVisible();
await page.getByRole("button", { name: "English" }).click();
await expect(
page.getByRole("heading", { name: "Open reports" }),
).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "en");
});
test("event report detail renders the reported message content", async ({
page,
}) => {
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08";
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
reporterPubkey: "21".repeat(32),
targetKind: "event",
target: "12".repeat(32),
reportType: "spam",
status: "open",
createdAt: "2026-07-17T17:30:00Z",
message: {
authorPubkey: "31".repeat(32),
content:
"This is the complete reported message.\nIt preserves lines.",
createdAt: "2026-07-17T17:25:00Z",
deletedAt: null,
},
}),
}),
);
await page.goto(`/reports/${id}`);
await expect(
page.getByText("This is the complete reported message.", { exact: false }),
).toBeVisible();
await expect(page.getByText("31".repeat(32))).toBeVisible();
await expect(
page.getByText("Message content is unavailable", { exact: false }),
).toHaveCount(0);
});
test("event report detail explains when message content is unavailable", async ({
page,
}) => {
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09";
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
reporterPubkey: "21".repeat(32),
targetKind: "event",
target: "12".repeat(32),
reportType: "spam",
status: "open",
createdAt: "2026-07-17T17:30:00Z",
message: null,
}),
}),
);
await page.goto(`/reports/${id}`);
await expect(
page.getByText("Message content is unavailable", { exact: false }),
).toBeVisible();
});
test("feedback cards open the complete submission", async ({ page }) => {
const id = "feed0000-0000-4000-8000-000000000001";
const fullBody = `${"Long feedback ".repeat(30)}end of feedback`;
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
eventId: "31".repeat(32),
submitterPubkey: "21".repeat(32),
category: "needs-work",
body: fullBody,
tags: [],
eventCreatedAt: "2026-07-17T17:25:00Z",
receivedAt: "2026-07-17T17:30:00Z",
}),
}),
);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "needs-work",
bodySummary: `${fullBody.slice(0, 240)}`,
receivedAt: "2026-07-17T17:30:00Z",
},
]),
}),
);
await page.goto("/feedback");
const card = page.locator(".feedback-record");
await expect(card.locator(".record-provenance")).toContainText(
"design.buzz.xyz",
);
await card.locator(".feedback-main-link").click();
await expect(page).toHaveURL(`/feedback/${id}`);
await expect(
page.getByRole("heading", { name: "Feedback detail" }),
).toBeVisible();
await expect(
page.getByText("end of feedback", { exact: false }),
).toBeVisible();
});
test("feedback can be searched and filtered by community and time", async ({
page,
}) => {
const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id: "recent",
communityId: "one",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "bug",
bodySummary: "Composer freezes after sleep",
receivedAt: recent,
},
{
id: "old",
communityId: "two",
communityHost: "engineering.buzz.xyz",
submitterPubkey: "22".repeat(32),
category: "praise",
bodySummary: "Calls are much more reliable",
receivedAt: old,
},
]),
}),
);
await page.goto("/feedback");
await expect(page.getByText("2 of 2 submissions")).toBeVisible();
await page.getByRole("searchbox", { name: "Search feedback" }).fill("calls");
await expect(page.getByText("Calls are much more reliable")).toBeVisible();
await expect(page.getByText("Composer freezes after sleep")).toHaveCount(0);
await page.getByRole("searchbox", { name: "Search feedback" }).fill("");
await page.getByLabel("Community").selectOption("design.buzz.xyz");
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
await page.getByLabel("Community").selectOption("all");
await page.getByLabel("Received").selectOption("day");
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
});
test("feedback status is stored locally by feedback id", async ({ page }) => {
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id: "feedback-one",
communityId: "one",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "bug",
bodySummary: "Composer freezes after sleep",
receivedAt: new Date().toISOString(),
},
]),
}),
);
await page.goto("/feedback");
await page.getByRole("checkbox", { name: "Acted on" }).check();
await page.reload();
await expect(page.getByRole("checkbox", { name: "Acted on" })).toBeChecked();
await page.getByLabel("Status").selectOption("acted-on");
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
await page.getByLabel("Status").selectOption("pending");
await expect(page.getByText("No matching feedback.")).toBeVisible();
});
test("feedback attachments render from imeta without raw markdown", async ({
page,
}) => {
const id = "feedback-with-attachments";
const imageUrl = `https://design.buzz.xyz/media/${"a".repeat(64)}.png`;
const fileUrl = `https://design.buzz.xyz/media/${"b".repeat(64)}.txt`;
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "one",
communityHost: "design.buzz.xyz",
eventId: "31".repeat(32),
submitterPubkey: "21".repeat(32),
category: "bug",
body: `Composer froze.\n![image](${imageUrl})\n[diagnostics.txt](${fileUrl})`,
tags: [
[
"imeta",
`url ${imageUrl}`,
"m image/png",
`x ${"a".repeat(64)}`,
"size 48213",
"dim 1280x720",
"filename screenshot.png",
],
[
"imeta",
`url ${fileUrl}`,
"m text/plain",
`x ${"b".repeat(64)}`,
"size 391",
"filename diagnostics.txt",
],
],
eventCreatedAt: "2026-07-17T17:25:00Z",
receivedAt: "2026-07-17T17:30:00Z",
}),
}),
);
await page.goto(`/feedback/${id}`);
await expect(
page.getByText("Composer froze.", { exact: true }),
).toBeVisible();
await expect(page.getByText("![image]", { exact: false })).toHaveCount(0);
await expect(
page.getByRole("img", { name: "screenshot.png" }),
).toHaveAttribute(
"src",
`/api/admin/v1/feedback/${id}/attachments/${"a".repeat(64)}`,
);
await expect(
page.getByRole("link", { name: /diagnostics.txt/ }),
).toHaveAttribute(
"href",
`/api/admin/v1/feedback/${id}/attachments/${"b".repeat(64)}`,
);
const fileHeight = await page
.locator(".file-attachment")
.evaluate((element) => element.getBoundingClientRect().height);
expect(fileHeight).toBeLessThan(100);
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts", "playwright.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: { port: 4174 },
build: { sourcemap: false },
});
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.benchmark/
jobs/
+129
View File
@@ -0,0 +1,129 @@
# 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`.
@@ -0,0 +1,73 @@
//! 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);
}
@@ -0,0 +1,44 @@
# 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
@@ -0,0 +1,42 @@
# 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
@@ -0,0 +1,34 @@
# 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.
@@ -0,0 +1,43 @@
# 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.
@@ -0,0 +1,32 @@
# 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.
@@ -0,0 +1,32 @@
# 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.
@@ -0,0 +1,29 @@
[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
@@ -0,0 +1,607 @@
#!/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),
"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"],
"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']}@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())
@@ -0,0 +1,316 @@
#!/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, check=False)
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())
@@ -0,0 +1,25 @@
"""Buzz orchestra custom agent for Harbor."""
from .agent import BuzzOrchestraAgent
from .container_runtime import (
BuzzContainerRuntime,
EndpointLaunchConfig,
RuntimeLaunchError,
)
from .manifest import ExperimentManifest, ManifestError
from .provisioning import AgentCredential, TrialHandle, TrialProvisioner
from .runtime import OrchestraRuntime, RuntimeResult
__all__ = [
"AgentCredential",
"BuzzContainerRuntime",
"BuzzOrchestraAgent",
"EndpointLaunchConfig",
"ExperimentManifest",
"ManifestError",
"OrchestraRuntime",
"RuntimeLaunchError",
"RuntimeResult",
"TrialHandle",
"TrialProvisioner",
]
@@ -0,0 +1,200 @@
"""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 .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig
from .manifest import ExperimentManifest
from .provisioning import TrialProvisioner
from .runtime import OrchestraRuntime
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 TypeError(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,
}
@@ -0,0 +1,687 @@
"""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 = 0 # 0 = unbounded (BUZZ_AGENT_MAX_ROUNDS=0); the trial budget is the clock
# 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 >= 0 (0 = unbounded)")
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. The orchestrator is mentioned by pubkey,
# not by name resolution: task text is untrusted payload, and any
# @-token inside it (e.g. Vim's `:%normal! @a`) would otherwise
# fail member resolution and kill the trial before the agent
# ever saw the task. An explicit --mention demotes unresolved
# @-tokens in the text to presentation-only.
await self._send(
trial.user,
trial,
f"@{orchestrator.agent_id} {instruction}",
mention=orchestrator.nostr_pubkey,
)
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: S110, 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: S110, 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,
*,
mention: str | None = None,
) -> None:
args = [
"messages",
"send",
"--channel",
trial.channel_id,
"--content",
content,
]
if mention is not None:
args += ["--mention", mention]
await self._buzz_json(credential, trial, *args)
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} | `{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)}"
)
@@ -0,0 +1,145 @@
"""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
@@ -0,0 +1,58 @@
"""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: ...
@@ -0,0 +1,35 @@
"""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: ...
@@ -0,0 +1,18 @@
# 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"
@@ -0,0 +1,26 @@
# 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.
@@ -0,0 +1,12 @@
{
"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": {}
}
}
@@ -0,0 +1,16 @@
{
"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"
}
}
}
@@ -0,0 +1,30 @@
[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
@@ -0,0 +1,72 @@
-- 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);
@@ -0,0 +1,15 @@
"""Testbed-side provisioning for harbor-buzz-orchestra trials."""
from .provisioner import (
BuzzTrialProvisioner,
ProvisioningError,
TestbedConfig,
provisioner_from_dict,
)
__all__ = [
"BuzzTrialProvisioner",
"ProvisioningError",
"TestbedConfig",
"provisioner_from_dict",
]
@@ -0,0 +1,103 @@
"""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,
check=False,
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")
@@ -0,0 +1,90 @@
"""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=(",", ":"),
)
@@ -0,0 +1,274 @@
"""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:
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)),
),
)

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